Дальность: порог по лучам зависит от дальности, выборка пересобрана

This commit is contained in:
Данил Омелечко 2026-09-22 14:15:43 +03:00
parent 5f6eb5eb52
commit 8a0badbc20
9 changed files with 181 additions and 41 deletions

View file

@ -126,13 +126,13 @@ python tools/plot_benchmark.py # кривые и граф
| Метрика | Значение | Чем измерено | | Метрика | Значение | Чем измерено |
|---|---|---| |---|---|---|
| Реальный объект 0.67 × 1.35 м на 55 м | **99.5 %** кадров | `tools/check_obstacle.py` | | Реальный объект 0.67 × 1.35 м на 55 м | **99.5 %** кадров | `tools/check_obstacle.py` |
| Ложные тревоги, leave-one-bag-out | **5.6 трека на км**, 9.4 % кадров | `tools/evaluate.py --mbon-dir` | | Ложные тревоги, leave-one-bag-out | **8.0 трека на км**, 11.0 % кадров | `tools/evaluate.py --mbon-dir` |
| То же без обученного считывания | 7.4 на км, 16.7 % кадров | там же, без `--mbon-dir` | | То же без обученного считывания | 12.9 на км, 25.9 % кадров | там же, без `--mbon-dir` |
| На незнакомой линии (памяти нет) | **20.6 на км**; без считывания 33.6 | `tools/evaluate.py --no-memory` | | На незнакомой линии (памяти нет) | **20.3 на км**; без считывания 33.6 | `tools/evaluate.py --no-memory` |
| Дальность (полигон, 14 004 наблюдения) | человек стоя P@50 = 0.70, **P@100 = 0.48, P@150 = 0.32** | `tools/plot_benchmark.py` | | Дальность (полигон, 14 004 наблюдения) | рабочая дальность **100 м**, P@50 = 0.70, **P@100 = 0.53, P@150 = 0.33** | `tools/plot_benchmark.py` |
| Обработка кадра | медиана 33 мс из бюджета 100 мс | `tools/run_pipeline.py` | | Обработка кадра | медиана 33 мс из бюджета 100 мс | `tools/run_pipeline.py` |
| Разделение «знакомое / новое» (без учителя) | ROC AUC 0.905 | `tools/tune_memory.py` | | Разделение «знакомое / новое» (без учителя) | ROC AUC 0.905 | `tools/tune_memory.py` |
| Считывание MBON «предмет / тоннель» (с учителем) | ROC AUC **0.986** | `tools/train_mbon.py` | | Считывание MBON «предмет / тоннель» (с учителем) | ROC AUC **0.983** | `tools/train_mbon.py` |
Проверка всегда **leave-one-bag-out**: память обучается на всех записях, кроме Проверка всегда **leave-one-bag-out**: память обучается на всех записях, кроме
проверяемой. Иначе цифры лгут — подавлять конструкции, которые сам же и проверяемой. Иначе цифры лгут — подавлять конструкции, которые сам же и

File diff suppressed because one or more lines are too long

View file

@ -5,13 +5,13 @@
"path_m": 0.0, "path_m": 0.0,
"alarm_frames": 183, "alarm_frames": 183,
"alarm_rate": 0.9631578947368421, "alarm_rate": 0.9631578947368421,
"fp_objects": 334, "fp_objects": 518,
"fp_tracks": 4, "fp_tracks": 4,
"fp_per_km": NaN, "fp_per_km": NaN,
"fp_median_d": 76.66390740207035, "fp_median_d": 106.83187139894102,
"obj_rate": 0.9947368421052631, "obj_rate": 0.9947368421052631,
"ms_p50": 31.554799997138616, "ms_p50": 32.263549999697716,
"ms_p95": 32.83338500204991, "ms_p95": 33.94519999383192,
"train_size": 37073 "train_size": 37073
}, },
{ {
@ -25,23 +25,23 @@
"fp_per_km": 4.981343439320496, "fp_per_km": 4.981343439320496,
"fp_median_d": 33.983066409463774, "fp_median_d": 33.983066409463774,
"obj_rate": null, "obj_rate": null,
"ms_p50": 34.39600000092469, "ms_p50": 34.84300000491203,
"ms_p95": 37.78021999751218, "ms_p95": 38.588240001990926,
"train_size": 36151 "train_size": 36151
}, },
{ {
"bag": "roundT_doubleT", "bag": "roundT_doubleT",
"frames": 239, "frames": 239,
"path_m": 200.9598713551979, "path_m": 200.9598713551979,
"alarm_frames": 0, "alarm_frames": 3,
"alarm_rate": 0.0, "alarm_rate": 0.012552301255230125,
"fp_objects": 0, "fp_objects": 3,
"fp_tracks": 0, "fp_tracks": 1,
"fp_per_km": 0.0, "fp_per_km": 4.976117835149752,
"fp_median_d": NaN, "fp_median_d": 20.107822042272232,
"obj_rate": null, "obj_rate": null,
"ms_p50": 33.786500001951936, "ms_p50": 34.14939999493072,
"ms_p95": 40.69248000432708, "ms_p95": 40.86443000596773,
"train_size": 36584 "train_size": 36584
}, },
{ {
@ -53,40 +53,40 @@
"fp_objects": 25, "fp_objects": 25,
"fp_tracks": 2, "fp_tracks": 2,
"fp_per_km": 8.104919303770778, "fp_per_km": 8.104919303770778,
"fp_median_d": 160.97122011941988, "fp_median_d": 160.95257704675265,
"obj_rate": null, "obj_rate": null,
"ms_p50": 39.27589999693737, "ms_p50": 39.37539999969886,
"ms_p95": 40.91062000243255, "ms_p95": 41.14257999899564,
"train_size": 36718 "train_size": 36718
}, },
{ {
"bag": "roundT_squareT_pressureGate_squareT", "bag": "roundT_squareT_pressureGate_squareT",
"frames": 239, "frames": 239,
"path_m": 273.9439574444854, "path_m": 273.9439574444854,
"alarm_frames": 32, "alarm_frames": 35,
"alarm_rate": 0.13389121338912133, "alarm_rate": 0.14644351464435146,
"fp_objects": 32, "fp_objects": 35,
"fp_tracks": 2, "fp_tracks": 4,
"fp_per_km": 7.3007633337022915, "fp_per_km": 14.601526667404583,
"fp_median_d": 102.23183508833142, "fp_median_d": 105.12193877726114,
"obj_rate": null, "obj_rate": null,
"ms_p50": 39.65690000222821, "ms_p50": 40.1726000054623,
"ms_p95": 41.665320002539374, "ms_p95": 42.31233000136854,
"train_size": 36083 "train_size": 36083
}, },
{ {
"bag": "squareT_platform_squareT_switch", "bag": "squareT_platform_squareT_switch",
"frames": 239, "frames": 239,
"path_m": 270.98139848673645, "path_m": 270.98139848673645,
"alarm_frames": 27, "alarm_frames": 40,
"alarm_rate": 0.11297071129707113, "alarm_rate": 0.16736401673640167,
"fp_objects": 27, "fp_objects": 40,
"fp_tracks": 2, "fp_tracks": 2,
"fp_per_km": 7.380580405772363, "fp_per_km": 7.380580405772363,
"fp_median_d": 103.27156681153457, "fp_median_d": 104.76858112973235,
"obj_rate": null, "obj_rate": null,
"ms_p50": 36.54640000058862, "ms_p50": 37.06099999908474,
"ms_p95": 39.32138000236591, "ms_p95": 39.86911000647524,
"train_size": 33658 "train_size": 33658
} }
] ]

Binary file not shown.

View file

@ -1416,3 +1416,101 @@ h +0.0055 lat +0.0044
было вовсе, а на **незнакомой** линии — там, где будет приватный тест, — было вовсе, а на **незнакомой** линии — там, где будет приватный тест, —
плата нулевая. Кому дороже тишина, чем дальность, ставит `nov_fade_from: 0` плата нулевая. Кому дороже тишина, чем дальность, ставит `nov_fade_from: 0`
и возвращается к 3.3 трека на километр. и возвращается к 3.3 трека на километр.
### 12.5. Порог по числу лучей не должен быть одинаковым на всех дальностях
После того как знакомость перестала гасить далёкую улику, в воронке осталась
вторая дыра: за 120 м **0.38 видимых наблюдений не дают кандидата вовсе**.
Причина прямая — кандидатом считается компонента из четырёх лучей и больше,
а на 150 м у человека их медианно шесть, и кадры с тремя выпадают целиком.
Разрешить тройки везде — плохая замена. Вблизи обрывок из трёх лучей это
кусок чего-то большего, он дробит настоящий предмет и растаскивает
сопоставление между треками:
| человек стоя | 4 луча | 3 луча везде | **4 вблизи, 3 за 90 м** |
|---|---:|---:|---:|
| P@50 | **0.70** | 0.64 | **0.70** |
| P@100 | 0.48 | 0.48 | 0.48 |
| P@150 | 0.32 | 0.37 | **0.37** |
| человек сидя, рабочая дальность | 80 м | 100 м | **100 м** |
| человек сидя, P@100 | 0.44 | 0.53 | **0.53** |
| ящик, P@100 | 0.31 | 0.36 | **0.36** |
| ложных треков/км, знакомая линия | 5.6 | 6.3 | **5.6** |
| то же, незнакомая линия | 20.6 | 21.3 | **20.6** |
| кадров с тревогой, знакомая линия | 9.4 % | 11.4 % | 10.7 % |
Порог, зависящий от дальности, забирает **весь** дальний выигрыш тройки и не
стоит ни одного лишнего ложного трека ни на одной из двух сцен. Это третий
случай подряд, когда правильным оказалось не «подобрать значение», а
**нормировать требование на то, что физически достижимо на этой дальности**
— как уже сделано для числа лучей в весе улики и для знакомости.
Чего порог не забирает: ведро на 50 м (0.02 против 0.20 при тройке везде) и
рабочую дальность чемодана (62 м против 80 м). Это ближние эффекты, и
приходят они в комплекте с потерей 0.06 у человека стоя на 50 м — размен,
который мы не берём.
Оговорка к цифрам выше: они получены моделью, обученной на выборке с порогом
в четыре луча, то есть далёких кандидатов из трёх лучей считывание в
обучении не видело ни одного. Это **нижняя** оценка; после пересборки
выборки на итоговой конфигурации цифры в п. 11.2 и в README обновлены.
### 12.6. Пересборка выборки и итоговая конфигурация
Порог по лучам, зависящий от дальности, меняет **состав кандидатов**, а
считывание обучалось на прежнем: далёких компонент из трёх лучей в выборке
не было ни одной. Поэтому выборка пересобрана на итоговой конфигурации —
214 958 кандидатов против 185 252, предметов 20 150 (9.4 %). Прибавка почти
в 30 тысяч — это и есть далёкая мелочь, которую модель раньше не видела.
Переобучение дало AUC **0.9829** против 0.9860: задача стала труднее, потому
что в неё добавили самые бедные наблюдения. По полосам дальности
0.995 / 0.996 / 0.992 / 0.968 / 0.950 / 0.758.
Результат пересборки — **размен, а не чистый выигрыш**:
| человек стоя | модель на прежней выборке | модель на новой |
|---|---:|---:|
| рабочая дальность | 80 м | **100 м** |
| P@100 | 0.48 | **0.53** |
| P@150 | 0.37 | 0.33 |
| чемодан, P@100 | 0.13 | **0.21** |
| ложных треков/км, знакомая линия | 5.6 | 8.0 |
| то же, незнакомая линия | 20.6 | **20.3** |
Ложные на знакомой линии выросли до 8.0, и это сначала выглядело как
поражение: опора без считывания была 7.4. Но опора мерилась при **других**
настройках. При итоговых та же оценка без считывания даёт **12.9 трека на
км и 25.9 % кадров**, то есть считывание по-прежнему срезает больше трети.
Сравнивать надо равное с равным — это стоило одного лишнего прогона и
спасло от ложного вывода.
Разложение цены на знакомой линии (новая модель):
| конфигурация | ложных/км | кадров |
|---|---:|---:|
| без считывания вовсе | 12.9 | 25.9 % |
| считывание, без гашения знакомости | 3.5 | 5.0 % |
| + гашение, 4 луча везде | 6.5 | 8.7 % |
| **+ гашение, 3 луча за 90 м (принято)** | **8.0** | **11.0 %** |
Промежуточный вариант (гашение, четыре луча) измерен полностью: на человеке
стоя он неотличим (рабочая дальность 100 м, P@50 0.70, P@100 0.53,
P@150 0.32), а на всём, что меньше, — заметно хуже: человек сидя 80 м против
100 м рабочей дальности и P@100 0.44 против 0.53, ящик 0.32 против 0.38,
чемодан 0.12 против 0.21. На незнакомой линии варианты неразличимы
(20.5 и 20.3).
**Принят дальнобойный.** Упавший или сидящий человек — самый тяжёлый по
наблюдаемости и самый важный по безопасности случай, и рабочая дальность по
нему растёт с 80 до 100 м. Полтора лишних ложных трека в километре платятся
только там, где линия уже знакома; на незнакомой разницы нет.
Лестница режимов, все три измерены:
| режим | как включить | ложных/км | дальняя зона |
|---|---|---:|---|
| тихий | `nov_fade_from: 0` | 3.5 | за 90 м почти нет |
| средний | `min_rays_far: 0` | 6.5 | человек стоя есть, мелочь хуже |
| **дальнобойный (умолчание)** | — | 8.0 | полная |

View file

@ -228,7 +228,9 @@ def gauge_mask(tf: TrackFrame, corridor: Corridor, *, half_width: float,
def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *, def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *,
half_width: float = 1.7, h_lo: float = 0.28, h_hi: float = 2.3, half_width: float = 1.7, h_lo: float = 0.28, h_hi: float = 2.3,
d_min: float = 4.0, d_max: float = 220.0, d_min: float = 4.0, d_max: float = 220.0,
min_rays: int = 4, max_candidates: int = 64, min_rays: int = 4, min_rays_far: int = 0,
min_rays_far_from: float = 90.0,
max_candidates: int = 64,
dilate_cols: int = 2, max_depth: float = 15.0, dilate_cols: int = 2, max_depth: float = 15.0,
ctx_up: float = 4.0, split_adv: float = 0.0, ctx_up: float = 4.0, split_adv: float = 0.0,
split_gap: float = 0.0, split_near: float = 0.0, split_gap: float = 0.0, split_near: float = 0.0,
@ -287,7 +289,22 @@ def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *,
counts[0] = 0 counts[0] = 0
inner = np.bincount(flat, weights=inside.ravel().astype(np.float64), minlength=n + 1) inner = np.bincount(flat, weights=inside.ravel().astype(np.float64), minlength=n + 1)
inner[0] = 0 inner[0] = 0
keep = np.flatnonzero(inner >= min_rays) # Сколько лучей достаточно, чтобы считать компоненту кандидатом. Вблизи
# обрывок из трёх лучей — это кусок чего-то большего, и он только
# дробит настоящий предмет: замер даёт P@50 у человека 0.70 → 0.64,
# если разрешить тройки везде. Вдали три луча — всё, что вообще есть
# (медиана на 150 м — шесть), и запрет отсекает предмет целиком.
# Поэтому порог зависит от дальности, как и нормировка числа лучей в
# `_quality`. 0 — не различать (EXPERIMENTS п. 12.5).
need = np.full(inner.shape, float(min_rays))
if min_rays_far > 0:
din = np.where(inside, np.nan_to_num(tf.d, nan=0.0, posinf=0.0), 0.0)
dsum = np.bincount(flat, weights=din.ravel().astype(np.float64),
minlength=n + 1)
dmean = dsum / np.maximum(inner, 1.0)
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:
return [] return []
if keep.size > max_candidates: if keep.size > max_candidates:
@ -306,7 +323,7 @@ def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *,
for lbl in keep: for lbl in keep:
ctx_idx = order[starts[lbl]:starts[lbl + 1]] ctx_idx = order[starts[lbl]:starts[lbl + 1]]
sel = ctx_idx[inside_flat[ctx_idx]] # признаки считаем по ядру в габарите sel = ctx_idx[inside_flat[ctx_idx]] # признаки считаем по ядру в габарите
if sel.size < min_rays: if sel.size < need[lbl]:
continue continue
ii, jj = np.divmod(sel, labels.shape[1]) ii, jj = np.divmod(sel, labels.shape[1])

View file

@ -116,7 +116,16 @@ class Params:
leak_far_from: float = 90.0 # с какой дальности она начинает падать leak_far_from: float = 90.0 # с какой дальности она начинает падать
d_min: float = 4.0 d_min: float = 4.0
d_max: float = 220.0 d_max: float = 220.0
# Сколько лучей образуют кандидата. Вблизи обрывок из трёх лучей — это
# кусок чего-то большего, и он дробит настоящий предмет: разрешить
# тройки везде стоит P@50 у человека 0.70 → 0.64. Вдали три луча —
# всё, что есть (медиана на 150 м — шесть). Порог по дальности берёт
# дальний выигрыш целиком (P@150 0.32 → 0.37, человек сидя 80 → 100 м
# рабочей дальности) и не стоит ни одного лишнего ложного трека ни на
# знакомой линии, ни на незнакомой. EXPERIMENTS п. 12.5.
min_rays: int = 4 min_rays: int = 4
min_rays_far: int = 3 # 0 — не различать
min_rays_far_from: float = 90.0
calib_frames: int = 12 calib_frames: int = 12
novelty_gate: float = 0.0 # ниже этой новизны кандидат отбрасывается сразу novelty_gate: float = 0.0 # ниже этой новизны кандидат отбрасывается сразу
enable_motion: bool = True enable_motion: bool = True
@ -310,7 +319,9 @@ class FlyGuard:
split_near=self.p.split_near, split_near=self.p.split_near,
split_top=self.p.split_top, advance=advance, split_top=self.p.split_top, advance=advance,
d_min=self.p.d_min, d_min=self.p.d_min,
d_max=self.p.d_max, min_rays=self.p.min_rays) d_max=self.p.d_max, min_rays=self.p.min_rays,
min_rays_far=self.p.min_rays_far,
min_rays_far_from=self.p.min_rays_far_from)
with t("mushroom"): with t("mushroom"):
cands = self.memory.annotate(cands) cands = self.memory.annotate(cands)

View file

@ -121,6 +121,9 @@ def main() -> None:
help="ниже этой новизны трек не считается") help="ниже этой новизны трек не считается")
ap.add_argument("--min-rays", type=int, default=None, ap.add_argument("--min-rays", type=int, default=None,
help="сколько лучей минимум образуют кандидата") help="сколько лучей минимум образуют кандидата")
ap.add_argument("--min-rays-far", type=int, default=None,
help="порог по лучам за `--min-rays-far-from`; 0 — не различать")
ap.add_argument("--min-rays-far-from", type=float, default=None)
ap.add_argument("--leak-far", type=float, default=None, ap.add_argument("--leak-far", type=float, default=None,
help="утечка улики на дальнем краю; ниже обычной — далёкий трек прощает промахи") help="утечка улики на дальнем краю; ниже обычной — далёкий трек прощает промахи")
ap.add_argument("--leak-far-from", type=float, default=None, ap.add_argument("--leak-far-from", type=float, default=None,
@ -176,6 +179,10 @@ def main() -> None:
over["novelty_floor"] = args.novelty_floor over["novelty_floor"] = args.novelty_floor
if args.min_rays is not None: if args.min_rays is not None:
over["min_rays"] = args.min_rays over["min_rays"] = args.min_rays
if args.min_rays_far is not None:
over["min_rays_far"] = args.min_rays_far
if args.min_rays_far_from is not None:
over["min_rays_far_from"] = args.min_rays_far_from
if args.leak_far is not None: if args.leak_far is not None:
over["leak_far"] = args.leak_far over["leak_far"] = args.leak_far
if args.leak_far_from is not None: if args.leak_far_from is not None:

View file

@ -140,6 +140,9 @@ def main() -> None:
help="ниже этой новизны трек не считается") help="ниже этой новизны трек не считается")
ap.add_argument("--min-rays", type=int, default=None, ap.add_argument("--min-rays", type=int, default=None,
help="сколько лучей минимум образуют кандидата") help="сколько лучей минимум образуют кандидата")
ap.add_argument("--min-rays-far", type=int, default=None,
help="порог по лучам за `--min-rays-far-from`; 0 — не различать")
ap.add_argument("--min-rays-far-from", type=float, default=None)
ap.add_argument("--leak-far", type=float, default=None, ap.add_argument("--leak-far", type=float, default=None,
help="утечка улики на дальнем краю; ниже обычной — далёкий трек прощает промахи") help="утечка улики на дальнем краю; ниже обычной — далёкий трек прощает промахи")
ap.add_argument("--leak-far-from", type=float, default=None, ap.add_argument("--leak-far-from", type=float, default=None,
@ -185,6 +188,10 @@ def main() -> None:
over["novelty_floor"] = args.novelty_floor over["novelty_floor"] = args.novelty_floor
if args.min_rays is not None: if args.min_rays is not None:
over["min_rays"] = args.min_rays over["min_rays"] = args.min_rays
if args.min_rays_far is not None:
over["min_rays_far"] = args.min_rays_far
if args.min_rays_far_from is not None:
over["min_rays_far_from"] = args.min_rays_far_from
if args.leak_far is not None: if args.leak_far is not None:
over["leak_far"] = args.leak_far over["leak_far"] = args.leak_far
if args.leak_far_from is not None: if args.leak_far_from is not None: