фантомы: штраф за плоское у пола, отдельный проход по верхней секции для висящего, верх секции 3.3 м

This commit is contained in:
Данил Омелечко 2026-09-25 10:48:11 +03:00
parent d1da4ec117
commit 5aa3dead0e
8 changed files with 307 additions and 42 deletions

View file

@ -196,14 +196,14 @@ k_sigma=0.75` у `make_benchmark.py`, `evaluate.py`, `make_training_set.py` и
| Метрика | Значение | Чем измерено |
|---|---|---|
| Реальный объект 0.67 × 1.35 м на 55 м | **99.5 %** кадров | `tools/check_obstacle.py` |
| Синтетика организаторов, 10 предметов | **8 из 10** верно | `tools/eval_org_synth.py` |
| Синтетика организаторов, 10 предметов | **9 из 10** верно | `tools/eval_org_synth.py` |
| Ложные тревоги, leave-one-bag-out | **5.7 трека на км**, 8.4 % кадров | `tools/evaluate.py --mbon-dir` |
| То же без обученного считывания | 11.9 на км, 20.3 % кадров | там же, без `--mbon-dir` |
| На незнакомой линии (памяти нет) | **13.8 на км**; без считывания 36.1 | `tools/evaluate.py --no-memory` |
| Вторая половина `new_data`: другой день, 3.41 км, не видена при обучении | **14.4 на км**, 20.3 % кадров | `tools/eval_new_data.py` |
| На незнакомой линии (памяти нет) | **12.8 на км**; без считывания 36.1 | `tools/evaluate.py --no-memory` |
| Вторая половина `new_data`: другой день, 3.41 км, не видена при обучении | **11.7 на км**, 17.9 % кадров | `tools/eval_new_data.py` |
| Дальность (полигон, 15 560 наблюдений) | человек стоя: рабочая дальность **100 м**, P@50 = 0.72, P@100 = 0.52, P@150 = 0.46 | `tools/plot_benchmark.py` |
| Человек, упавший на пути | P@50 = **0.50** (было 0.27), рабочая дальность 20 м | там же |
| Обработка кадра | медиана 31 мс из бюджета 100 мс; на ядре уровня стенда жюри 50 / 60 мс (медиана / p95) | контейнер, EXPERIMENTS п. 18.5 |
| Обработка кадра | медиана 33 мс из бюджета 100 мс; на ядре уровня стенда жюри 55 / 65 мс (медиана / p95) | контейнер, EXPERIMENTS п. 18.5, 19.5 |
| Разделение «знакомое / новое» (без учителя) | ROC AUC 0.905 | `tools/tune_memory.py` |
| Считывание MBON «предмет / тоннель» (с учителем) | ROC AUC **0.986** | `tools/train_mbon.py` |

File diff suppressed because one or more lines are too long

View file

@ -3,12 +3,12 @@
"bag": "doubleT_obstacle",
"frames": 190,
"path_m": 0.0,
"alarm_frames": 130,
"alarm_rate": 0.6842105263157895,
"fp_objects": 232,
"fp_tracks": 5,
"alarm_frames": 167,
"alarm_rate": 0.8789473684210526,
"fp_objects": 369,
"fp_tracks": 4,
"fp_per_km": NaN,
"fp_median_d": 106.83262984484747,
"fp_median_d": 106.82908159892504,
"obj_rate": 0.9947368421052631,
"ms_p50": NaN,
"ms_p95": NaN,
@ -23,7 +23,7 @@
"fp_objects": 42,
"fp_tracks": 2,
"fp_per_km": 9.962686878640993,
"fp_median_d": 34.009163839731684,
"fp_median_d": 34.00916383973144,
"obj_rate": null,
"ms_p50": NaN,
"ms_p95": NaN,

View file

@ -3008,3 +3008,134 @@ rosbag2 — пауза, ±3 с, кадр вперёд, скорость ×0.1…
в 0.19 м над рельсом (вблизи — 1–6 см высотой, память её знает) и
дальнее пятно на 147 м, которого при подъезде нет. Настоящий объект в
`for_hackathon` один — в `doubleT_obstacle`.
### 18.11. «Человек» слева в RViz — тень детали у лидара
На `doubleT_obstacle` в RViz слева от габарита виден тёмный силуэт, похожий на
человека. Это не предмет: тёмное в облаке обзора — место без точек, а человек
выглядел бы пятном точек, как объект на 55 м. Между краем габарита и левой
стеной от 3 до 9 м выше 1.1 м нет ни одной точки, в 6 м вокруг лидара — только
стена, низкий короб у пути (0.3–0.5 м) и части самого поезда.
Дыру даёт деталь в 0.48–0.62 м от лидара — спереди-слева и ниже него (азимут
14–34° влево, угол места 12–25° вниз), во всех 200 кадрах на месте, по форме —
перевёрнутое «Г». Лучи, которые она перекрывает, иначе легли бы на пол в
3.3–5.5 м впереди от края габарита до стены и на низ стены. Саму деталь облако
обзора не показывает (оно начинается с 0.5 м), поэтому видна одна тень
(`docs/figures/doubleT_mount_shadow.png`). Это и слепое пятно прибора: полоса
пола у левого края габарита в 3–5 м впереди на этой записи не наблюдается.
## 19. Фантомы и висящее: плоское у пола, отдельный проход по верхней секции (25.09)
Задача от пользователя: «чтобы всё проходило на `cloud_with_fake_obj`» и убрать
фантомы — «предмет появляется на 1–2 секунды, а при приближении исчезает».
### 19.1. Откуда фантомы
Подробный журнал конвейера (у каждого кандидата вес улики, отсчёт считывания
MBON, новизна; у каждого трека ход улики) на синтетике и шести записях.
Настоящие предметы копят улику до 1.0 и живут десятки кадров; фантомы живут
9–21 кадр. Все восемь (пять на синтетике, три на записях с поезда) — трёх
видов:
| вид | где | признак |
|---|---|---|
| плоское у пола | синтетика 40→25 и 32→22 м; порог у гермозатвора 65→31 м (28 кадров) | высота 0.01–0.05 м, верх 0.18–0.23 м над рельсом |
| дальнее | синтетика 178 и 120 м; `squareT_…` 103→85 и 146→136 м | 3–8 лучей, вблизи на этом месте ничего нет |
| у края габарита | синтетика 16→14 м, u −1.01…−1.17 | конструкция, выходящая из габарита по мере подъезда |
Важная деталь: при `mbon_blend = 1` вес наблюдения целиком решает обученное
считывание, и плоским фантомам оно ставит 0.78–0.99 — в его обучении был
кабель на путях. Значит, правило должно стоять поверх модели.
### 19.2. Плоское у пола
Вблизи (до 70 м) кольца лидара идут достаточно часто, чтобы судить о высоте:
настоящий низкий предмет (0.3 м на рельсе, 2×0.2 м поперёк путей) даёт
0.1–0.4 м по высоте и верх выше 0.4 м. Кандидат с высотой меньше 0.06 м и
верхом ниже 0.26 м над рельсом теперь получает вес ×0.15
(`flat_h`/`flat_top`/`flat_d`/`flat_w`). Множитель, а не запрет: предмет,
хоть раз показавший высоту, своё доберёт. Организаторы подтвердили, что лежащее
в жёлобе препятствием не считается.
Итог: порог гермозатвора и оба плоских фантома синтетики ушли; на пустых
записях 3 → 2 ложных трека, 73 → 45 кадров с тревогой.
### 19.3. Висящее со свода
Стержень 5 см (№ 10) не находился вовсе. Основной проход тянет контекст
кластеризации до свода (`ctx_up`) — иначе срез колонны по верху габарита сам
выглядит предметом. Но поэтому всё свисающее склеивается со сводом, доля лучей
в габарите у стержня — ноль, вес ~0.005.
Первая попытка — не пускать свод над верхней секцией в контекст
(`top_ceiling_cut`). Стержень нашёлся с 13 м, но на `roundT_doubleT` вышла новая
тревога: на 33 м по оси — стойка от пола до 2.75 м с коробкой на 2.0–2.6 м,
похоже на светофор у стрелки, к которому поезд не едет (двухпутный тоннель, наша
ось смотрит прямо). Раньше стойку держал в компоненте свод, над которым она
крепится. Срез освободил и её. Отвергнуто.
Принято: отдельный проход по верхней секции (`lobula._hanging`, `top_detect`).
Его контекст — только сама секция с запасом 0.3 м по сторонам и 0.4 м вниз, без
свода. Компонента, у которой есть лучи ниже `h_hi`, уходит к полу — это стоящий
предмет, его ведёт основной проход (светофор так и остаётся без тревоги).
Кандидаты основного прохода, целиком лежащие в верхней секции, отбрасываются,
чтобы не двоить трек. Ещё три вещи понадобились по ходу:
* **новизна и MBON в верхней секции выключены.** Обе обученные части собирались
до того, как у габарита появилась верхняя секция, и таких форм не видели.
Опора по числу лучей для висящего не ниже 0.5 — нормировка рассчитана на
предмет с человека, а стержень тонок по природе и упирался в нижний край 0.25;
* **плоское у верхней границы — свод.** На 83 м ошибка наклона плоскости пути
опустила свод до 3.66–3.69 м, в секцию; такой кандидат отбрасывается;
* **верх секции 3.3 м, а не 3.7.** После выключения новизны в тревоги пошли
кабели и кронштейны свода на 3.2–3.6 м (`doubleT_platform` 61 м,
`roundT_doubleT` 22 м). Новизна их не отделяет (медиана 0.24–0.41 у них,
0.29–0.57 у стержня), высота низа — отделяет: у деталей свода он в основном
выше 3.2 м, у предметов организаторов — 2.8 и 3.0 м.
Итог: стержень найден, но поздно — с 13.4 м: в поясе до 3.3 м на нём вдвое
меньше лучей, чем до 3.7 (при 3.7 он находился с 50 м, но ценой двух ложных
треков на записях). «2×2 сверху» держится 48 кадров вместо 24, с 87 м. Попутно
«0.3 на рельсе» находится с 76 м вместо 36. Механизм не разбирался; вероятно,
раньше рядом жили мусорные треки из верхней секции, склеенные со сводом, и
торможение соседних треков (`_inhibit`) гасило его улику.
### 19.4. Дальние фантомы: возраст трека — отвергнуто
Дальний фантом по одному кадру неотличим от настоящего предмета: те же 3–4
луча, отсчёт MBON 0.2–0.8, новизна на дальности выключена (п. 12.3). Отличает
его только время: он пропадает при подъезде. Проверено правило «дальше 100 м
тревога только за треком, прожившим 15 кадров» (`far_confirm`): на синтетике
фантомов 3 → 2 и кадров тревоги 24 → 9, но полигон теряет 200 наблюдений из
15 560, P@150 у человека стоя 0.46 → 0.37, сидя 0.39 → 0.21, у чемодана и
ящика вдвое. Посторонних тревог на полигоне меньше на 14 %, но такой ценой
дальности не берём. Параметр оставлен выключенным.
### 19.5. Итог
Принято: `flat_h = 0.06`, `top_detect = true`, `h_top = 3.3`. Все прогоны парные:
| проверка | было | стало |
|---|---|---|
| синтетика организаторов | 8 из 10, фантомов 5 (43 кадра) | **9 из 10**, фантомов **3 (24 кадра)** |
| пустые записи с поезда | 3 трека, 73 кадра | **2 трека, 45 кадров** |
| знакомая линия (LOO) | 5.7 на км, 8.4 % кадров | 5.7 на км, 8.4 % |
| незнакомая (без памяти) | 13.8 на км, 24.3 % | **12.8 на км, 20.7 %** |
| вторая половина `new_data` | 49 треков, 14.4 на км, 20.3 % | **40 треков, 11.7 на км, 17.9 %** |
| полигон, наблюдения | — | 0→1: 0, 1→0: 3 (каска) |
| полигон, посторонние | 742 | 758 |
| `doubleT_obstacle` | 99.5 % | 99.5 % |
| кадр в контейнере, `doubleT_obstacle` / синтетика | 30.7 / 41.8 мс | 33.4 / 43.5 мс |
| на ядре уровня жюри (квота 0.61), p95 | 60.2 / 71.2 мс | 64–66 / 79.7 мс |
Что не взято и почему:
* **№ 6, 2×2 у края.** В габарит он заходит на 0.06–0.28 м (граница оценки оси
плавает от кадра к кадру), остальные 2 м — снаружи. Для конвейера это то же,
что шкаф у стены: MBON даёт 0.00–0.02, доля лучей в габарите — ноль. Правило
«компактное, стоит на полу, уходит за габарит» усилило бы и фантом у края на
16 м, который как раз такой.
* **Дальние фантомы** — п. 19.4.
* **Фантом у края на 16 м**: настоящая конструкция, заходящая в габарит на
0.03–0.19 м; с габаритом 1.2 м он граничный.

View file

@ -148,10 +148,12 @@ class CentralComplex:
max_tracks: int = 48, use_shape: bool = True,
mbon_power: float = 1.0, mbon_blend: float = 1.0,
nov_fade_from: float = 0.0, nov_fade_to: float = 160.0,
lying_exempt: bool = False, top_from: float = float("inf")):
lying_exempt: bool = False, top_from: float = float("inf"),
flat: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0)):
self.use_shape = use_shape
self.lying_exempt = lying_exempt
self.top_from = top_from # низ выше этого — предмет в верхней секции габарита
self.flat = flat # (высота, верх, дальность, множитель), см. _quality
self.mbon_power = mbon_power
self.mbon_blend = mbon_blend
self.nov_fade_from = nov_fade_from
@ -228,7 +230,7 @@ class CentralComplex:
t.misses = 0
w = _quality(c, self.use_shape, self.mbon_power, self.mbon_blend,
self.nov_fade_from, self.nov_fade_to,
self.lying_exempt, self.top_from)
self.lying_exempt, self.top_from, self.flat)
t.observe(c, self.s_world + c.d, w)
t.evidence = min(1.0, t.evidence + self.gain * w)
if dt > 1e-3:
@ -256,7 +258,7 @@ class CentralComplex:
last_n_rays=c.n_rays, novelty=c.novelty)
w = _quality(c, self.use_shape, self.mbon_power, self.mbon_blend,
self.nov_fade_from, self.nov_fade_to,
self.lying_exempt, self.top_from)
self.lying_exempt, self.top_from, self.flat)
t.observe(c, self.s_world + c.d, w)
t.evidence = self.gain * w
t.hits = 1
@ -301,7 +303,8 @@ def expected_rays(d: float) -> float:
def _quality(c: Candidate, use_shape: bool = True, mbon_power: float = 1.0,
mbon_blend: float = 1.0, nov_fade_from: float = 0.0,
nov_fade_to: float = 160.0, lying_exempt: bool = False,
top_from: float = float("inf")) -> float:
top_from: float = float("inf"),
flat: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0)) -> float:
"""Вес одного наблюдения: сколько улики оно добавляет.
Дальний объект даёт мало лучей не потому, что он сомнительный, а потому что
@ -311,7 +314,16 @@ def _quality(c: Candidate, use_shape: bool = True, mbon_power: float = 1.0,
стена тянутся дальше и в стороны. Новизна из грибовидного тела входит сюда
же множителем.
"""
support = float(np.clip(c.n_rays / expected_rays(c.d), 0.25, 1.0))
# Висящее в верхней секции (`top_from`) — отдельная статья. Обе обученные
# части, считывание MBON и память тоннеля, собирались до того, как у
# габарита появилась верхняя секция, и таких форм не видели: их ответ там
# не значит ничего, и оба канала здесь выключены. Нормировка числа лучей
# рассчитана на предмет с человека, а свисающий кабель или стержень тонок
# по природе: у стержня 5 см опора упиралась в нижний край 0.25 на любой
# дальности. Пояс над вагоном в исправном тоннеле пуст, поэтому нижний
# край для висящего поднят до 0.5. EXPERIMENTS п. 19.
top = c.h_min >= top_from
support = float(np.clip(c.n_rays / expected_rays(c.d), 0.5 if top else 0.25, 1.0))
# Контраст к фону — главная улика вблизи и недоступная вдали. На 170 м
# кольцо окружения ламины упирается в стену тоннеля, стоящую на той же
@ -341,8 +353,7 @@ def _quality(c: Candidate, use_shape: bool = True, mbon_power: float = 1.0,
# лоток или кронштейн висят на стене, и под ними пусто. Кроме верхней
# секции габарита (`top_from`): у оси под сводом стен нет, и висящее
# там — это то, что свисает в путь вагона.
grounded = (1.0 if c.h_min >= top_from
else float(np.clip(1.25 - c.h_min / 1.2, 0.15, 1.0)))
grounded = 1.0 if top else float(np.clip(1.25 - c.h_min / 1.2, 0.15, 1.0))
else:
whole = compact = grounded = 1.0
# Знакомость на большой дальности не просто бесполезна, а вредна. Замер:
@ -352,8 +363,8 @@ def _quality(c: Candidate, use_shape: bool = True, mbon_power: float = 1.0,
# вырождается, и память узнаёт в предмете любую далёкую конструкцию.
# Поэтому вклад новизны гасится с дальностью, а вблизи, где она и
# работает, остаётся как был. 0 — не гасить (EXPERIMENTS п. 12.3).
novel = novelty_gain(c.novelty)
if nov_fade_from > 0.0 and c.d > nov_fade_from:
novel = 1.0 if top else novelty_gain(c.novelty)
if not top and nov_fade_from > 0.0 and c.d > nov_fade_from:
span = max(nov_fade_to - nov_fade_from, 1e-3)
w = 1.0 - min((c.d - nov_fade_from) / span, 1.0)
novel = novel ** w
@ -370,10 +381,23 @@ def _quality(c: Candidate, use_shape: bool = True, mbon_power: float = 1.0,
p = c.extra.get("mbon") if c.extra else None
# висящее в верхней секции модель не видела ни разу (обучена на стоящих
# предметах) и гасит его — здесь решает ручная формула
if p is not None and mbon_blend > 0.0 and c.h_min < top_from:
if p is not None and mbon_blend > 0.0 and not top:
pm = float(np.clip(p, 1e-4, 1.0)) ** max(mbon_power, 1e-3)
b = float(np.clip(mbon_blend, 0.0, 1.0))
hand = pm ** b * max(hand, 1e-4) ** (1.0 - b)
# Плоское у самого пола: высотой в сантиметры, целиком ниже `flat[1]` над
# рельсом. Это пластины и кромки на полотне, края жёлоба, порог
# гермозатвора — по ответу организаторов то, что в жёлобе, препятствием не
# считается. Модель таким ставит 0.8–0.99: в её обучении был кабель на
# путях. А ложных тревог это давало больше всего: порог гермозатвора на
# 65→31 м и два фантома на синтетике. Настоящий низкий предмет (0.3 м на
# рельсе, 2×0.2 м поперёк путей) вблизи даёт 0.1–0.4 м по высоте и верх
# выше 0.4 м. Дальше `flat[2]` решётка слишком редкая, чтобы судить о
# высоте, и там правило молчит. Множитель, а не запрет: если предмет хоть в
# одном кадре покажет высоту, он своё доберёт. EXPERIMENTS п. 19.
fh, ftop, fd, fw = flat
if fh > 0.0 and c.d <= fd and c.height < fh and c.h_min + c.height < ftop:
hand *= fw
return float(np.clip(hand * novel, 0.0, 1.0))

View file

@ -65,7 +65,8 @@ class DescendingNeurons:
warn_far: float | None = None, warn_far_from: float = 90.0,
track_blend: float = 0.0, track_score: str = "w_mean",
track_gate: float = 0.0, track_readout=None,
far_channel: bool = False):
far_channel: bool = False, far_confirm: int = 0,
far_confirm_from: float = 100.0):
self.warn_evidence = warn_evidence
self.clear_evidence = clear_evidence
self.emergency_evidence = emergency_evidence
@ -101,6 +102,13 @@ class DescendingNeurons:
# способом; включать вместе с ним — ослабить дальний край дважды.
# Замер — EXPERIMENTS п. 16.
self.far_channel = far_channel
# Дальний трек поднимает тревогу, только прожив `far_confirm` кадров.
# Вдали предмет — три-четыре луча, и по одному кадру он неотличим от
# вспышки на кромке или стыке; фантомы на синтетике и в записях жили
# 0.5–1 с и пропадали при подъезде. Торможению дальний трек не нужен
# срочно: до него сотня метров. 0 — выключено. EXPERIMENTS п. 19.
self.far_confirm = far_confirm
self.far_confirm_from = far_confirm_from
self.track_score = track_score
self.track_readout = track_readout
self._latched: set[int] = set()
@ -134,6 +142,9 @@ class DescendingNeurons:
or not nov_ok):
self._latched.discard(t.id)
continue
if (self.far_confirm > 0 and d > self.far_confirm_from
and t.age < self.far_confirm and t.id not in self._latched):
continue
ready.append((t, d))
use_track = ((self.track_blend > 0.0 or self.track_gate > 0.0)

View file

@ -289,7 +289,10 @@ def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *,
core_width: float = 0.85, core_from: float = 0.0,
platform_filter: bool = False,
platform_u: float = 1.30, platform_h_lo: float = 1.05,
platform_h_hi: float = 1.25) -> list[Candidate]:
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) -> list[Candidate]:
"""Выделить кандидатов: связные пятна лучей, попавших в габарит.
Кластеризация ведётся по **расширенной** области, а членство в габарите
@ -410,33 +413,27 @@ def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *,
sorted_lbl = labels.ravel()[order]
starts = np.searchsorted(sorted_lbl, np.arange(n + 2))
inside_flat = inside.ravel()
out: list[Candidate] = []
for lbl in keep:
ctx_idx = order[starts[lbl]:starts[lbl + 1]]
sel = ctx_idx[inside_flat[ctx_idx]] # признаки считаем по ядру в габарите
if sel.size < need[lbl]:
continue
ii, jj = np.divmod(sel, labels.shape[1])
width_img = labels.shape[1]
def build(sel: np.ndarray, n_ctx: int) -> Candidate | None:
"""Кандидат по лучам ядра `sel` (плоские индексы) из компоненты в `n_ctx` лучей."""
ii, jj = np.divmod(sel, width_img)
d = tf.d[ii, jj]
u = lat[ii, jj]
h = tf.h[ii, jj]
nn = d.size
depth = float(d.max() - d.min()) if nn > 1 else 0.0
if depth > max_depth:
# ни один посторонний предмет не тянется на десятки метров вдоль пути:
# это полотно, стена или кабельный лоток, взятые под скользящим углом
continue
return None
cand = Candidate(
d=float(np.median(d)), u=float(np.median(u)), h=float(np.median(h)),
d_min=float(d.min()), h_min=float(h.min()),
width=float(u.max() - u.min()) if nn > 1 else 0.0,
height=float(h.max() - h.min()) if nn > 1 else 0.0,
depth=depth,
containment=float(sel.size / max(ctx_idx.size, 1)),
containment=float(sel.size / max(n_ctx, 1)),
n_rays=int(nn), n_rings=int(np.unique(ii).size), n_cols=int(np.unique(jj).size),
gap=float(np.median(gap_img[ii, jj])),
on=float(np.median(lam.on[ii, jj])),
@ -449,7 +446,82 @@ def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *,
)
# индексы лучей нужны только для отладочной визуализации; они небольшие
cand.extra["rays"] = (ii, jj)
return cand
use_top = top_detect and h_top > h_hi and half_width_top > 0.0
h_flat = tf.h.ravel()
inside_flat = inside.ravel()
out: list[Candidate] = []
for lbl in keep:
ctx_idx = order[starts[lbl]:starts[lbl + 1]]
sel = ctx_idx[inside_flat[ctx_idx]] # признаки считаем по ядру в габарите
if sel.size < need[lbl]:
continue
if use_top and np.all(h_flat[sel] >= h_hi):
# целиком в верхней секции: это работа отдельного прохода ниже —
# здесь такой кандидат склеен со сводом и только двоил бы трек
continue
cand = build(sel, ctx_idx.size)
if cand is not None:
out.append(cand)
if use_top:
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)
out.sort(key=lambda c: c.d)
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]:
"""Свисающее со свода в путь вагона: отдельный проход по верхней секции.
Основной проход тянет контекст до свода, чтобы колонна не выглядела
предметом, — и поэтому всё, что свисает с потолка, склеивается со сводом:
доля лучей в габарите у стержня из синтетики организаторов была ноль.
Просто не пускать свод в контекст нельзя: тогда освобождаются и стоящие
конструкции, прицепленные к своду, — на `roundT_doubleT` так вышла тревога
на светофоре у стрелки. Поэтому здесь свой проход: контекст — только сама
верхняя секция с запасом `margin_u` по сторонам и `below` вниз. Компонента,
у которой есть лучи ниже `h_hi`, продолжается к полу: это стоящий предмет
(столб, светофор, человек высокого роста), и его ведёт основной проход.
Висящему не нужна опора снизу, и обученное считывание его не судит
(см. central_complex._quality, `top_from`). EXPERIMENTS п. 19.
"""
band = (in_range & (tf.d < top_d_max) & (abs_lat < half_width_top)
& (tf.h >= h_hi) & (tf.h < h_top))
if not band.any():
return []
ext = (in_range & (tf.d < top_d_max) & (abs_lat < half_width_top + margin_u)
& (tf.h > h_hi - below) & (tf.h < h_top))
labels, n = cluster_by_depth(ext, tf.r, col_reach=col_reach)
if n == 0:
return []
flat_lbl = labels.ravel()
order = np.argsort(flat_lbl, kind="stable")
starts = np.searchsorted(flat_lbl[order], np.arange(n + 2))
band_flat = band.ravel()
low_flat = (tf.h < h_hi).ravel()
out = []
for lbl in range(1, n + 1):
idx = order[starts[lbl]:starts[lbl + 1]]
if idx.size < min_rays or low_flat[idx].any():
continue
sel = idx[band_flat[idx]]
if sel.size < min_rays:
continue
cand = build(sel, idx.size)
if cand is None:
continue
if cand.height < 0.1 and cand.h_min > h_top - 0.2:
# Плоское у самой верхней границы — это свод: на 80–90 м ошибка
# наклона плоскости пути опускает его в секцию (на `squareT_…`
# так вышла тревога на 83 м, высота 3.66–3.69 м при границе 3.7).
# Свисающее начинается ниже и имеет высоту.
continue
cand.extra["top"] = True
out.append(cand)
return out

View file

@ -62,11 +62,30 @@ class Params:
# Верхняя секция габарита: от h_hi до h_top при |u| < half_width_top —
# то, что свисает со свода в путь вагона (см. lobula.gauge_mask). Вагон
# выше 3.5 м, а предмет «2×2 сверху» у организаторов висит низом на 3.0 м
# над рельсом — при верхе 2.3 м он был не виден вовсе. h_top <= h_hi —
# секции нет. EXPERIMENTS п. 18.4.
h_top: float = 3.7
# над рельсом — при верхе 2.3 м он был не виден вовсе. Верх 3.3, а не 3.7:
# у свода в настоящих тоннелях свои кабели и кронштейны, и ошибка наклона
# плоскости пути на 60–90 м опускает их на 0.1–0.3 м — при 3.7 они шли в
# тревоги (низ у них в основном выше 3.2 м, у предметов организаторов —
# 2.8 и 3.0). h_top <= h_hi — секции нет. EXPERIMENTS п. 18.4, 19.
h_top: float = 3.3
half_width_top: float = 1.0
top_d_max: float = 90.0 # дальше верхней секции нет, м
# Плоское у пола (см. central_complex._quality): вертикальный размер меньше
# flat_h, верх ниже flat_top над рельсом, не дальше flat_d — вес наблюдения
# умножается на flat_w. flat_h: 0 — выключено. EXPERIMENTS п. 19.
flat_h: float = 0.06
# Отдельный проход по верхней секции для свисающего со свода (см.
# lobula._hanging): в основном проходе оно склеено со сводом.
top_detect: bool = True
# Дальний трек (дальше far_confirm_from) поднимает тревогу, только прожив
# far_confirm кадров (см. descending.py). 0 — выключено. Замерено и
# отвергнуто: 15 кадров убирают короткие дальние фантомы, но полигон теряет
# 200 наблюдений, P@150 у человека стоя 0.46 → 0.37. EXPERIMENTS п. 19.
far_confirm: int = 0
far_confirm_from: float = 100.0
flat_top: float = 0.26
flat_d: float = 70.0
flat_w: float = 0.15
ctx_up: float = 4.0 # насколько контекст лезет выше габарита, м
# Разделение фигуры и фона по движению (см. lobula.split_by_figure):
# порог скорости сближения вдоль луча, в долях пройденного пути.
@ -292,7 +311,9 @@ class FlyGuard:
nov_fade_to=self.p.nov_fade_to,
lying_exempt=self.p.lying_exempt,
top_from=(self.p.h_hi if self.p.h_top > self.p.h_hi
else float("inf")))
else float("inf")),
flat=(self.p.flat_h, self.p.flat_top, self.p.flat_d,
self.p.flat_w))
# без накопления улик трек подтверждается первым же наблюдением
self.dn = (DescendingNeurons(
warn_evidence=self.p.warn_evidence,
@ -306,7 +327,9 @@ class FlyGuard:
track_gate=self.p.track_gate,
track_score=self.p.track_score,
track_readout=track_readout,
far_channel=self.p.far_channel) if self.p.use_tracking
far_channel=self.p.far_channel,
far_confirm=self.p.far_confirm,
far_confirm_from=self.p.far_confirm_from) if self.p.use_tracking
else DescendingNeurons(warn_evidence=0.0, clear_evidence=0.0,
emergency_evidence=0.0, min_hits=1))
@ -448,7 +471,11 @@ class FlyGuard:
h_lo_core=self.p.h_lo_core,
core_width=self.p.core_width,
core_from=self.p.core_from,
platform_filter=self.p.platform_filter)
platform_filter=self.p.platform_filter,
h_top=self.p.h_top,
half_width_top=self.p.half_width_top,
top_d_max=self.p.top_d_max,
top_detect=self.p.top_detect)
with t("mushroom"):
cands = self.memory.annotate(cands)