From af317803abf80623995cbb953cc802c6122f81be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=B0=D0=BD=D0=B8=D0=BB=20=D0=9E=D0=BC=D0=B5=D0=BB?= =?UTF-8?q?=D0=B5=D1=87=D0=BA=D0=BE?= Date: Tue, 22 Sep 2026 14:52:44 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B0=D0=B7=D1=80=D0=B5=D0=B7=20=D0=B2?= =?UTF-8?q?=D0=B1=D0=BB=D0=B8=D0=B7=D0=B8:=20=D0=BD=D0=B0=D1=88=D1=91?= =?UTF-8?q?=D0=BB=20=D0=BF=D1=80=D0=B8=D1=87=D0=B8=D0=BD=D1=83=20=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D0=B6=D0=BD=D0=B5=D0=B9=20=D0=BF=D0=BE=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D0=B8,=20=D0=BB=D0=B5=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=BE=D0=BA=D1=83=D0=BF=D0=B8=D0=BB=D0=BE=D1=81?= =?UTF-8?q?=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/EXPERIMENTS.md | 56 +++++++++++++++++++++++++++++++++++++++++ flyguard/lobula.py | 32 +++++++++++++++++++---- flyguard/pipeline.py | 8 +++++- tools/evaluate.py | 4 +++ tools/make_benchmark.py | 4 +++ 5 files changed, 98 insertions(+), 6 deletions(-) diff --git a/docs/EXPERIMENTS.md b/docs/EXPERIMENTS.md index 9c10f70..1ada1b7 100644 --- a/docs/EXPERIMENTS.md +++ b/docs/EXPERIMENTS.md @@ -1514,3 +1514,59 @@ P@150 0.32), а на всём, что меньше, — заметно хуже: | тихий | `nov_fade_from: 0` | 3.5 | за 90 м почти нет | | средний | `min_rays_far: 0` | 6.5 | человек стоя есть, мелочь хуже | | **дальнобойный (умолчание)** | — | 8.0 | полная | + +### 12.7. Ближняя потеря: причина найдена, лечение отвергнуто + +В воронке оставалась последняя необъяснённая дыра: на 6–30 м, где предмет +даёт сотни лучей, кандидат не появлялся в 18 % видимых наблюдений. Прямая +трассировка вставки на 6.5, 8, 10, 12, 20 и 28 м ничего не воспроизвела — +кандидат находился в **100 %** случаев (616 из 616). Ответ нашёлся в самих +данных полигона: + +| смещение предмета от оси | наблюдений | без кандидата | +|---|---:|---:| +| 0.0 м | 198 | 0.01 | +| +0.9 м | 198 | **0.34** | + +Хуже всего там, где есть платформа: `doubleT_platform` и +`squareT_platform_squareT_switch` по 0.27 против 0.11–0.14 у остальных. +Предмет у края габарита слипается с кромкой платформы в одну связную +компоненту и отбрасывается вместе с ней — **то же самое, что и в полосе +30–50 м**. Причина общая: разрез по контрасту запрещён ближе 55 м. + +Комментарий в коде объяснял этот запрет тем, что вблизи предмет со стеной +не слипается. Замер показал, что это неверно, и объяснение исправлено. + +Лечение пробовалось не «в лоб» (резать везде — уже отвергнуто в п. 12.1), а +избирательно: резать вблизи только компоненты, **слишком растянутые по +глубине**, чтобы быть предметом. Мерой служит разброс дальности внутри +компоненты: человек занимает десятки сантиметров, кромка платформы — +десятки метров. Две свёртки по меткам, доли миллисекунды. + +| порог растянутости | ложных/км, знакомая | незнакомая | +|---|---:|---:| +| выключено | **8.0** | **20.3** | +| > 8 м | 10.0 | 25.2 | +| > 4 м | 12.0 | — | +| > 2 м | 12.0 | — | + +Воронка при самом строгом пороге (решений от видимых наблюдений): + +| полоса | без разреза | с разрезом | +|---|---:|---:| +| 6–30 м | 0.83 | **0.90** | +| 30–50 м | 0.59 | **0.68** | +| 50–70 м | **0.81** | 0.71 | +| 70–90 м | **0.62** | 0.54 | + +**Отвергнуто.** Ближние полосы вылечились, но сломались средние: человек +стоя P@50 0.70 → 0.67, рабочая дальность «как есть» 80 → 62 м, посторонних +тревог на кадр 0.071 → 0.102. Механизм тот же, что и у тройки лучей вблизи: +разрез на 50–55 м дробит предмет на конкурирующие обрывки, и настоящий трек +проигрывает им в торможении по соседству. + +Итог размена: мы вылечили бы полосу, которая при служебном торможении с +60 км/ч и так за тормозным путём, сломав ту, что внутри него, и заплатили бы +четвертью ложных треков на обеих сценах. Механизм оставлен в коде выключенным +(`near_long: 0`): на линии без платформ у самого габарита он может оказаться +дешевле — но проверять это надо замером на той линии. diff --git a/flyguard/lobula.py b/flyguard/lobula.py index 78494b6..4f6d202 100644 --- a/flyguard/lobula.py +++ b/flyguard/lobula.py @@ -234,6 +234,7 @@ def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *, dilate_cols: int = 2, max_depth: float = 15.0, ctx_up: float = 4.0, split_adv: float = 0.0, split_gap: float = 0.0, split_near: float = 0.0, + near_long: float = 0.0, split_top: int = 0, advance: np.ndarray | None = None, masks: tuple | None = None) -> list[Candidate]: @@ -272,11 +273,32 @@ def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *, # и торчать из своего окружения. fig = gap_img if (split_adv <= 0 or advance is None) else np.where(advance > split_adv, gap_img, 0.0) if split_near > 0: - # Ближе этой дальности покадровый тракт видит предмет и без разреза: - # компонента со стеной там не слипается, лучей много, форма читается. - # Резать там незачем, а обстановки, дающей контраст, в ближнем поле - # на порядок больше — оттуда и берутся лишние кандидаты. - fig = np.where(tf.d > split_near, fig, 0.0) + # Ближе этой дальности разрез был выключен целиком, и причина + # стояла в комментарии неверная: считалось, что вблизи предмет со + # стеной не слипается. Замер показал обратное — на 6…30 м предмет, + # смещённый от оси на 0.9 м, теряется в 34 % наблюдений против 1 % + # на самой оси, и хуже всего там, где есть платформа. Он уходит + # вместе с её кромкой. + # + # Резать вблизи всё подряд дорого: обстановки, дающей контраст, в + # ближнем поле на порядок больше. Поэтому режем только компоненты, + # СЛИШКОМ РАСТЯНУТЫЕ по глубине, чтобы быть предметом: человек + # занимает по дальности десятки сантиметров, кромка платформы и + # стена — десятки метров. Мерой служит разброс дальности внутри + # компоненты (две свёртки по меткам, доли миллисекунды). + near = tf.d <= split_near + if near_long > 0.0 and n > 0: + lab = labels.ravel() + rr = np.where(np.isfinite(tf.r), tf.r, 0.0).ravel() + cnt = np.bincount(lab, minlength=n + 1).astype(np.float64) + s1 = np.bincount(lab, weights=rr, minlength=n + 1) + s2 = np.bincount(lab, weights=rr * rr, minlength=n + 1) + c = np.maximum(cnt, 1.0) + var = np.maximum(s2 / c - (s1 / c) ** 2, 0.0) + stretched = np.sqrt(var) > near_long + stretched[0] = False + near = near & ~stretched[labels] + fig = np.where(near, 0.0, fig) labels, n = split_by_figure(labels, n, tf.r, fig, max_depth=max_depth, thr=split_gap, col_reach=dilate_cols + 1, top=split_top) diff --git a/flyguard/pipeline.py b/flyguard/pipeline.py index 56e09ed..34ebebb 100644 --- a/flyguard/pipeline.py +++ b/flyguard/pipeline.py @@ -50,7 +50,12 @@ class Params: # вытаскивает предмет из компоненты, растёкшейся вдоль стены круглого # тоннеля, где покадровый тракт слеп целиком. 0 — выключено. split_gap: float = 6.0 - split_near: float = 55.0 # ближе этого не резать: там и так видно + split_near: float = 55.0 # ближе этого резать только растянутое + # Разброс дальности внутри компоненты, выше которого её режут и в + # ближней зоне: предмет по дальности занимает десятки сантиметров, + # кромка платформы и стена — десятки метров. 0 — не резать вблизи + # вовсе. Замер — EXPERIMENTS п. 12.7. + near_long: float = 0.0 split_top: int = 1 # сколько фигур выносить из одной компоненты # Накопление слабых улик в координатах пути (см. fan_body.py). Заменяет # недоступный на большой дальности контраст к фону: на 140…185 м `gap` @@ -317,6 +322,7 @@ class FlyGuard: split_adv=self.p.split_adv, split_gap=self.p.split_gap, split_near=self.p.split_near, + near_long=self.p.near_long, split_top=self.p.split_top, advance=advance, d_min=self.p.d_min, d_max=self.p.d_max, min_rays=self.p.min_rays, diff --git a/tools/evaluate.py b/tools/evaluate.py index 5b2117b..38b54bc 100644 --- a/tools/evaluate.py +++ b/tools/evaluate.py @@ -119,6 +119,8 @@ def main() -> None: help="с какой дальности порог начинает падать, м") ap.add_argument("--novelty-floor", type=float, default=None, help="ниже этой новизны трек не считается") + ap.add_argument("--near-long", type=float, default=None, + help="разброс дальности, выше которого компоненту режут и вблизи; 0 — не резать вблизи") ap.add_argument("--min-rays", type=int, default=None, help="сколько лучей минимум образуют кандидата") ap.add_argument("--min-rays-far", type=int, default=None, @@ -177,6 +179,8 @@ def main() -> None: over["warn_far_from"] = args.warn_far_from if args.novelty_floor is not None: over["novelty_floor"] = args.novelty_floor + if args.near_long is not None: + over["near_long"] = args.near_long if args.min_rays is not None: over["min_rays"] = args.min_rays if args.min_rays_far is not None: diff --git a/tools/make_benchmark.py b/tools/make_benchmark.py index 72b83c2..dac01ea 100644 --- a/tools/make_benchmark.py +++ b/tools/make_benchmark.py @@ -138,6 +138,8 @@ def main() -> None: help="с какой дальности порог начинает падать, м") ap.add_argument("--novelty-floor", type=float, default=None, help="ниже этой новизны трек не считается") + ap.add_argument("--near-long", type=float, default=None, + help="разброс дальности, выше которого компоненту режут и вблизи; 0 — не резать вблизи") ap.add_argument("--min-rays", type=int, default=None, help="сколько лучей минимум образуют кандидата") ap.add_argument("--min-rays-far", type=int, default=None, @@ -186,6 +188,8 @@ def main() -> None: over["warn_far_from"] = args.warn_far_from if args.novelty_floor is not None: over["novelty_floor"] = args.novelty_floor + if args.near_long is not None: + over["near_long"] = args.near_long if args.min_rays is not None: over["min_rays"] = args.min_rays if args.min_rays_far is not None: