forked from Dan4ick/Lidar_Muxa
281 lines
16 KiB
Python
281 lines
16 KiB
Python
"""Оценка обобщаемости: leave-one-bag-out.
|
||
|
||
Память тоннеля обучается на всех данных, **кроме** проверяемого бэга, и только
|
||
после этого конвейер прогоняется по нему. Иначе цифры лгут: подавлять
|
||
конструкции, которые сам же и запомнил, умеет кто угодно, а на приватном тесте
|
||
будет новый участок тоннеля.
|
||
|
||
Отчёт: ложные тревоги на километр пути и доля кадров с тревогой для пустых
|
||
бэгов; для `doubleT_obstacle` — ещё и доля кадров, в которых найден настоящий
|
||
объект на ~55 м.
|
||
|
||
python tools/evaluate.py --device cuda --out artifacts/generalisation.json
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import tempfile
|
||
|
||
import numpy as np
|
||
|
||
import _bootstrap as B # noqa: F401
|
||
import _parallel as P
|
||
from flyguard.bag import Bag, find_bags
|
||
from flyguard.mushroom_body import MushroomBody, MushroomBodyConfig
|
||
from flyguard.pipeline import FlyGuard, Params
|
||
|
||
OBSTACLE_BAG = "doubleT_obstacle"
|
||
TRUE_D = (50.0, 62.0)
|
||
|
||
|
||
def _work(task):
|
||
"""Одна задача — один бэг. Память и считывание грузятся по пути уже здесь."""
|
||
path, params, limit, mem_path, rd_path = task
|
||
memory = MushroomBody.load(mem_path) if mem_path else None
|
||
readout = None
|
||
if rd_path:
|
||
from flyguard.mbon_readout import MbonReadout
|
||
readout = MbonReadout.load(rd_path)
|
||
return run_bag(path, memory, limit, params, readout=readout)
|
||
|
||
|
||
def train_excluding(per_bag: dict[str, np.ndarray], extra: np.ndarray | None,
|
||
exclude: str, target: float, device: str) -> MushroomBody:
|
||
parts = [v for k, v in per_bag.items() if k not in (exclude, OBSTACLE_BAG)]
|
||
if extra is not None:
|
||
parts.append(extra)
|
||
X = np.concatenate(parts).astype(np.float32)
|
||
mb = MushroomBody(MushroomBodyConfig())
|
||
mb.fit_normalizer(X)
|
||
mb.learn(X, rate=mb.auto_rate(X.shape[0], target), device=device)
|
||
return mb
|
||
|
||
|
||
def run_bag(bag_path, memory, limit: int, params: Params | None = None,
|
||
readout=None) -> dict:
|
||
fg = FlyGuard(params or Params(), memory=memory, readout=readout)
|
||
bag = Bag(bag_path)
|
||
n = alarms = obj_hits = fp_objects = 0
|
||
path_m = 0.0
|
||
fp_dists, times = [], []
|
||
# один и тот же лоток, попавший в треки, виден сотню кадров подряд; для
|
||
# эксплуатации важно не это, а сколько РАЗНЫХ ложных объектов возникло —
|
||
# именно столько раз поезд затормозил бы напрасно
|
||
fp_tracks: set[int] = set()
|
||
for _, pc in bag.frames(stop=limit):
|
||
res = fg.process(pc)
|
||
if res is None:
|
||
continue
|
||
n += 1
|
||
path_m += res.ego.ds if res.ego else 0.0
|
||
times.append(res.total_ms)
|
||
d = res.decision
|
||
is_obstacle_bag = bag.path.name == OBSTACLE_BAG
|
||
mine = [o for o in d.objects if TRUE_D[0] < o.distance < TRUE_D[1]] \
|
||
if is_obstacle_bag else []
|
||
others = [o for o in d.objects if o not in mine]
|
||
if mine:
|
||
obj_hits += 1
|
||
if others:
|
||
alarms += 1
|
||
fp_objects += len(others)
|
||
fp_tracks.update(o.track_id for o in others)
|
||
fp_dists.extend(o.distance for o in others)
|
||
km = max(path_m / 1000.0, 1e-6)
|
||
return dict(bag=bag.path.name, frames=n, path_m=path_m,
|
||
alarm_frames=alarms, alarm_rate=alarms / max(n, 1),
|
||
fp_objects=fp_objects, fp_tracks=len(fp_tracks),
|
||
fp_per_km=(len(fp_tracks) / km) if path_m > 5 else float("nan"),
|
||
fp_median_d=float(np.median(fp_dists)) if fp_dists else float("nan"),
|
||
obj_rate=obj_hits / max(n, 1) if bag.path.name == OBSTACLE_BAG else None,
|
||
ms_p50=float(np.median(times)) if times else 0.0,
|
||
ms_p95=float(np.percentile(times, 95)) if times else 0.0)
|
||
|
||
|
||
def main() -> None:
|
||
ap = argparse.ArgumentParser(description=__doc__)
|
||
ap.add_argument("--root", default=str(B.DATA / "for_hackathon"))
|
||
ap.add_argument("--cache", default=str(B.CACHE / "tune_candidates.npz"))
|
||
ap.add_argument("--extra-cache", default=str(B.CACHE / "new_data_candidates.npz"))
|
||
ap.add_argument("--limit", type=int, default=250)
|
||
ap.add_argument("--target", type=float, default=0.4)
|
||
ap.add_argument("--device", default="cpu")
|
||
ap.add_argument("--split-adv", type=float, default=None,
|
||
help="порог разделения фигуры и фона; 0 — выключить")
|
||
ap.add_argument("--split-gap", type=float, default=None,
|
||
help="порог разреза по контрасту ламины, м; 0 — выключить")
|
||
ap.add_argument("--split-near", type=float, default=None,
|
||
help="ближе этой дальности не резать, м")
|
||
ap.add_argument("--split-top", type=int, default=None,
|
||
help="сколько фигур выносить из одной компоненты; 0 — все")
|
||
ap.add_argument("--no-acc", action="store_true", help="выключить накопитель")
|
||
ap.add_argument("--no-hab", action="store_true", help="выключить привыкание")
|
||
ap.add_argument("--mbon", default="", help="путь к обученному считыванию MBON")
|
||
ap.add_argument("--mbon-dir", default="",
|
||
help="каталог с моделями по складкам (mbon_<бэг>.npz): "
|
||
"для каждого бэга берётся модель, его не видевшая")
|
||
ap.add_argument("--mbon-blend", type=float, default=None,
|
||
help="1 — только модель, 0 — только ручная формула")
|
||
ap.add_argument("--mbon-power", type=float, default=None,
|
||
help="резкость вероятности модели")
|
||
ap.add_argument("--warn", type=float, default=None,
|
||
help="порог улики для тревоги (по умолчанию 0.5)")
|
||
ap.add_argument("--clear", type=float, default=None,
|
||
help="нижний порог гистерезиса (по умолчанию 0.3)")
|
||
ap.add_argument("--min-hits", type=int, default=None,
|
||
help="наблюдений, без которых трек не считается")
|
||
ap.add_argument("--warn-far", type=float, default=None,
|
||
help="порог тревоги на дальнем краю; ниже обычного — послабление далёким трекам")
|
||
ap.add_argument("--warn-far-from", type=float, default=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,
|
||
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,
|
||
help="утечка улики на дальнем краю; ниже обычной — далёкий трек прощает промахи")
|
||
ap.add_argument("--leak-far-from", type=float, default=None,
|
||
help="с какой дальности утечка начинает падать, м")
|
||
ap.add_argument("--nov-fade-from", type=float, default=None,
|
||
help="с какой дальности гасить вклад знакомости; 0 — не гасить")
|
||
ap.add_argument("--nov-fade-to", type=float, default=None,
|
||
help="к какой дальности вклад знакомости обнуляется")
|
||
ap.add_argument("--mbon-prior-from", type=float, default=None,
|
||
help="с какой дальности поправлять оценку модели на распространённость предметов; 0 — не поправлять")
|
||
ap.add_argument("--no-memory", action="store_true",
|
||
help="совсем без памяти тоннеля — так выглядит первый проезд по новой линии")
|
||
ap.add_argument("--out", default=str(B.ARTIFACTS / "generalisation.json"))
|
||
P.add_argument(ap)
|
||
args = ap.parse_args()
|
||
|
||
d = np.load(args.cache, allow_pickle=True)
|
||
per_bag = {str(k): d[f"X_{k}"].astype(np.float32) for k in d["names"]}
|
||
extra = None
|
||
from pathlib import Path
|
||
if Path(args.extra_cache).exists():
|
||
extra = np.load(args.extra_cache)["X"].astype(np.float32)
|
||
print(f"дополнительно в обучение: {extra.shape[0]} кандидатов из new_data")
|
||
|
||
over = {}
|
||
if args.split_adv is not None:
|
||
over["split_adv"] = args.split_adv
|
||
if args.split_gap is not None:
|
||
over["split_gap"] = args.split_gap
|
||
if args.split_near is not None:
|
||
over["split_near"] = args.split_near
|
||
if args.split_top is not None:
|
||
over["split_top"] = args.split_top
|
||
if args.no_acc:
|
||
over["enable_accumulator"] = False
|
||
if args.no_hab:
|
||
over["enable_habituation"] = False
|
||
if args.mbon_blend is not None:
|
||
over["mbon_blend"] = args.mbon_blend
|
||
if args.mbon_power is not None:
|
||
over["mbon_power"] = args.mbon_power
|
||
if args.warn is not None:
|
||
over["warn_evidence"] = args.warn
|
||
over["clear_evidence"] = args.warn * 0.6 if args.clear is None else args.clear
|
||
elif args.clear is not None:
|
||
over["clear_evidence"] = args.clear
|
||
if args.min_hits is not None:
|
||
over["min_hits"] = args.min_hits
|
||
if args.warn_far is not None:
|
||
over["warn_far"] = args.warn_far
|
||
if args.warn_far_from is not 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:
|
||
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:
|
||
over["leak_far"] = args.leak_far
|
||
if args.leak_far_from is not None:
|
||
over["leak_far_from"] = args.leak_far_from
|
||
if args.nov_fade_from is not None:
|
||
over["nov_fade_from"] = args.nov_fade_from
|
||
if args.nov_fade_to is not None:
|
||
over["nov_fade_to"] = args.nov_fade_to
|
||
if args.mbon_prior_from is not None:
|
||
over["mbon_prior_from"] = args.mbon_prior_from
|
||
params = Params(**over)
|
||
fold_paths: dict[str, str] = {}
|
||
if args.mbon_dir:
|
||
from pathlib import Path as _P
|
||
for f in _P(args.mbon_dir).glob("mbon_*.npz"):
|
||
fold_paths[f.stem[len("mbon_"):]] = str(f)
|
||
print(f"считывание MBON по складкам: {args.mbon_dir} "
|
||
f"({len(fold_paths)} моделей)")
|
||
elif args.mbon:
|
||
print(f"считывание MBON: {args.mbon}")
|
||
|
||
# Память обучается ЗДЕСЬ, а не в воркере: обучение идёт на видеокарте, и
|
||
# делить одну карту на пять процессов незачем. Воркеру достаётся готовый
|
||
# файл — долгая часть это проход по записи, а не обучение.
|
||
tmp = None if args.no_memory else tempfile.TemporaryDirectory(prefix="fg_mem_")
|
||
tasks, sizes = [], []
|
||
for p in find_bags(args.root):
|
||
mem_path, n_seen = "", 0
|
||
if tmp is not None:
|
||
mem = train_excluding(per_bag, extra, p.name, args.target, args.device)
|
||
mem_path = str(Path(tmp.name) / f"mem_{p.name}.npz")
|
||
mem.save(mem_path)
|
||
n_seen = int(mem.n_seen)
|
||
del mem
|
||
rd = fold_paths.get(p.name, args.mbon) if fold_paths else args.mbon
|
||
if fold_paths and p.name not in fold_paths and p.name != OBSTACLE_BAG:
|
||
print(f" внимание: для {p.name} нет своей складки")
|
||
tasks.append((p, params, args.limit, mem_path, rd))
|
||
sizes.append(n_seen)
|
||
|
||
# Задержка кадра под пятью процессами вырастает с 32 до 56 мс — это
|
||
# свойство замера, а не конвейера, и такое число нельзя показывать как
|
||
# запас по бюджету. Поэтому в параллельном режиме оно не печатается
|
||
# вовсе: перепутать nan с честным замером невозможно, а предупреждение
|
||
# в шапке пролистывается.
|
||
par = P.resolve(args.jobs, len(tasks)) > 1
|
||
if par:
|
||
print("параллельно: задержка кадра не измеряется, для неё нужен --jobs 1")
|
||
slots: list = [None] * len(tasks)
|
||
for i, task, r, secs in P.run(_work, tasks, args.jobs):
|
||
r["train_size"] = sizes[i]
|
||
if par:
|
||
r["ms_p50"] = r["ms_p95"] = float("nan")
|
||
slots[i] = r
|
||
obj = f"объект {r['obj_rate']:6.1%} | " if r["obj_rate"] is not None else ""
|
||
print(f"{r['bag']:40s} кадров {r['frames']:4d} путь {r['path_m']:6.0f} м | "
|
||
f"{obj}тревог {r['alarm_rate']:6.1%} | ложных треков {r['fp_tracks']:3d} "
|
||
f"({r['fp_per_km']:6.1f} на км) | {r['ms_p50']:5.1f}/{r['ms_p95']:5.1f} мс | "
|
||
f"{secs:5.0f} с", flush=True)
|
||
rows = [r for r in slots if r is not None]
|
||
if tmp is not None:
|
||
tmp.cleanup()
|
||
|
||
B.ARTIFACTS.mkdir(parents=True, exist_ok=True)
|
||
with open(args.out, "w", encoding="utf-8") as f:
|
||
json.dump(rows, f, ensure_ascii=False, indent=1)
|
||
empty = [r for r in rows if r["bag"] != OBSTACLE_BAG]
|
||
print("\nсводка по пустым бэгам:")
|
||
print(f" доля кадров с ложной тревогой: {np.mean([r['alarm_rate'] for r in empty]):.2%}")
|
||
fp_km = [r["fp_per_km"] for r in empty if np.isfinite(r["fp_per_km"])]
|
||
if fp_km:
|
||
print(f" разных ложных треков на километр: {np.mean(fp_km):.1f} "
|
||
f"(медиана {np.median(fp_km):.1f})")
|
||
print(f" суммарный путь: {sum(r['path_m'] for r in empty):.0f} м")
|
||
print("сохранено:", args.out)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|