Lidar_Muxa/tools/make_benchmark.py

441 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Размеченный полигон: сценарии сближения с синтетическим препятствием.
Для каждого бэга и каждого типа предмета строится сценарий: предмет ставится
в фиксированную точку тоннеля далеко впереди, поезд к нему подъезжает, и на
каждом кадре известна истинная дистанция. Отсюда получаются именно те цифры,
которые просит ТЗ: с какой дальности предмет уверенно виден, сколько ложных
тревог и как это зависит от размера.
Первый проход считает собственное движение по чистым данным (это и есть
разметка по дистанции), второй — гоняет конвейер по кадрам со вставленным
предметом. Все сценарии одного бэга обрабатываются в одном проходе по файлу:
чтение данных дороже самой обработки.
python tools/make_benchmark.py --out artifacts/benchmark.npz --memory artifacts/mushroom_body.npz
"""
from __future__ import annotations
import argparse
import json
import zlib
from dataclasses import dataclass
import numpy as np
import _bootstrap as B # noqa: F401
import _parallel as P
import _params as PS
from flyguard.bag import Bag, find_bags
from flyguard.mushroom_body import MushroomBody
from flyguard.pipeline import FlyGuard, Params
from flyguard.synth import IntensityEnv, Placement, catalogue, inject
from flyguard.track_readout import describe_track
HOLDOUT = "doubleT_obstacle" # там уже есть настоящий объект
def scenario_rng(seed: int, name: str, lateral: float) -> np.random.Generator:
"""Свой генератор на каждый сценарий.
Раньше генератор был один на запись, и сценарии тянули из него по очереди,
кадр за кадром. Тогда любой новый предмет в каталоге сдвигал поток для ВСЕХ
остальных: цифры по ящику менялись оттого, что где-то добавили человека
лёжа. Теперь поток сценария зависит только от него самого.
Зерно берётся через crc32, а не через `hash()`: встроенный хэш строк
солится заново в каждом процессе, и воспроизводимость пропала бы.
"""
return np.random.default_rng(
[seed, zlib.crc32(name.encode("utf-8")), int(round(lateral * 1000)) & 0xFFFF])
@dataclass
class _ScenarioState:
name: str
lat_base: float
pipe: FlyGuard
d_init: float
v_lat: float
is_static: bool
s_start: float = 0.0
t0: float | None = None
rng: np.random.Generator | None = None
def _work(task):
"""Одна задача — один бэг.
Обученное грузится путями и уже внутри процесса: передавать модели через
межпроцессную границу незачем, а свою складку каждый воркер берёт сам.
"""
if len(task) >= 11:
(path, params, limit, d_start, laterals, seed, mem_path, rd_path,
want_tracks, trk_path, aug_params) = task[:11]
else:
(path, params, limit, d_start, laterals, seed, mem_path, rd_path,
want_tracks, trk_path) = task
aug_params = {}
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)
track_rd = None
if trk_path:
from flyguard.track_readout import TrackReadout
track_rd = TrackReadout.load(trk_path)
return run_bag(path, params, memory, limit, d_start, laterals, seed,
readout=readout, want_tracks=want_tracks,
track_readout=track_rd, aug_params=aug_params)
def ego_track(bag: Bag, params: Params, limit: int | None):
"""Первый проход: пройденный путь на каждом кадре (разметка по дистанции)."""
fg = FlyGuard(params, memory=None)
s, stamps = [], []
total = 0.0
for _, pc in bag.frames(stop=limit):
res = fg.process(pc)
if res is None:
s.append(None); stamps.append(pc.stamp); continue
total += res.ego.ds if res.ego else 0.0
s.append(total); stamps.append(pc.stamp)
return s, stamps
def run_bag(bag_path, params: Params, memory, limit: int, d_start: float,
laterals: tuple[float, ...], seed: int, readout=None,
want_tracks: bool = False, track_readout=None,
aug_params: dict | None = None):
aug = aug_params or {}
use_augment = bool(aug.get("augment", False))
d_start_min = float(aug.get("d_start_min", 40.0))
d_start_max = float(aug.get("d_start_max", 200.0))
v_lat_max = float(aug.get("v_lat_max", 0.35))
s_noise_std = float(aug.get("s_noise_std", 0.12))
respawn = bool(aug.get("respawn", False))
static_prob = float(aug.get("static_prob", 0.15))
bag = Bag(bag_path)
s_track, _ = ego_track(bag, params, limit)
have = [x for x in s_track if x is not None]
if len(have) < 20:
return []
travel = have[-1] - have[0]
cat = catalogue()
scen = [(name, lat) for name in cat for lat in laterals]
records = [[] for _ in scen]
# Выборка для считывания по трекам: описание каждого живого трека на
# каждом кадре и метка «это вставленный предмет». Собирается здесь, а не
# отдельным инструментом, ровно затем, чтобы распределение совпадало с
# рабочим: та же память тоннеля, то же покадровое считывание, те же
# пороги. Обученное на другой обстановке считывание нечего и мерить.
trk_rows: list = []
def _create_sc(name: str, lat: float, rng=None) -> _ScenarioState:
# при повторном появлении поток продолжается, а не начинается заново:
# иначе каждое возрождение повторяло бы одну и ту же дальность и дрейф
rng = rng if rng is not None else scenario_rng(seed, name, lat)
p = FlyGuard(params, memory=memory, readout=readout, track_readout=track_readout)
if use_augment:
d_init = float(rng.uniform(d_start_min, d_start_max))
is_static = bool(rng.uniform(0.0, 1.0) < static_prob)
is_human = "человек" in name
v_lat = float(rng.uniform(-v_lat_max, v_lat_max)) if (is_human or rng.uniform(0.0, 1.0) < 0.6) else 0.0
else:
d_init = d_start
is_static = False
v_lat = 0.0
return _ScenarioState(name=name, lat_base=lat, pipe=p, rng=rng, d_init=d_init,
v_lat=v_lat, is_static=is_static)
scenarios = [_create_sc(name, lat) for name, lat in scen]
# решётка и поза нужны для вставки — берутся из отдельного «чистого» конвейера
guide = FlyGuard(params, memory=None)
for k, (_, pc) in enumerate(bag.frames(stop=limit)):
gres = guide.process(pc)
if gres is None or s_track[k] is None:
continue
s_now = s_track[k] - have[0]
env = IntensityEnv(pc) # один раз на кадр, общий для сценариев
for i, sc in enumerate(scenarios):
if sc.t0 is None:
sc.t0 = pc.stamp
sc.s_start = s_now
dt = max(pc.stamp - sc.t0, 0.0)
ds = 0.0 if sc.is_static else (s_now - sc.s_start)
noise_s = float(np.clip(sc.rng.normal(0.0, s_noise_std), -0.35, 0.35)) if (use_augment and s_noise_std > 0) else 0.0
d_true = sc.d_init - ds + noise_s
if d_true < 6.0:
if respawn and use_augment:
scenarios[i] = _create_sc(sc.name, sc.lat_base, sc.rng)
sc = scenarios[i]
sc.t0 = pc.stamp
sc.s_start = s_now
noise_s = float(np.clip(sc.rng.normal(0.0, s_noise_std), -0.35, 0.35)) if s_noise_std > 0 else 0.0
d_true = sc.d_init + noise_s
if d_true < 6.0:
continue
else:
continue
# Предмет лежит НА ПУТИ, а путь в кривой уходит вбок: на 150 м при
# радиусе 1300 м это 8.6 м. Если ставить его в поперечных координатах
# сенсора, он окажется в стене, а не в габарите.
lat_t = float(np.clip(sc.lat_base + sc.v_lat * dt, -1.25, 1.25))
u_obj = float(gres.corridor.centre(np.array([d_true], np.float32))[0]) + lat_t
pc2, lab = inject(pc, guide.layout_full, gres.plane, cat[sc.name],
Placement(d=d_true, u=u_obj), rng=sc.rng, env=env)
res = sc.pipe.process(pc2)
if res is None:
continue
tol = max(3.0, 0.12 * d_true)
hit = any(abs(o.distance - d_true) < tol for o in res.decision.objects)
fp = sum(1 for o in res.decision.objects if abs(o.distance - d_true) >= tol)
# Воронка потерь. Лучи в предмет попали — а дальше он может
# пропасть на любой из трёх ступеней, и лечатся они по-разному:
# нет кандидата — вопрос к кластеризации и разделению фигуры и
# фона, нет трека — к сопоставлению по кадрам, нет решения —
# к порогу. Без этого разбиения улучшать нечего, кроме удачи.
cand = any(abs(c.d - d_true) < tol for c in res.candidates)
cx = sc.pipe.cx
trk = any(abs(tr.distance(cx.s_world) - d_true) < tol
for tr in cx.tracks)
records[i].append((d_true, int(hit), fp, lab["hit_rays"],
int(cand), int(trk)))
if want_tracks:
for tr in cx.tracks:
dd = tr.distance(cx.s_world)
if not (0.0 < dd <= params.d_max):
continue
trk_rows.append((describe_track(tr, cx.s_world),
int(abs(dd - d_true) < tol), dd))
out = []
for (name, lat), rec in zip(scen, records):
if not rec:
continue
a = np.array(rec, np.float32)
out.append(dict(bag=bag.path.name, obj=name, lateral=lat,
d=a[:, 0].tolist(), hit=a[:, 1].tolist(),
fp=a[:, 2].tolist(), rays=a[:, 3].tolist(),
cand=a[:, 4].tolist(), trk=a[:, 5].tolist(),
travel=float(travel)))
tracks = None
if want_tracks and trk_rows:
tracks = (np.stack([r[0] for r in trk_rows]).astype(np.float32),
np.array([r[1] for r in trk_rows], np.int8),
np.array([r[2] for r in trk_rows], np.float32))
return out, tracks
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--root", default=str(B.DATA / "for_hackathon"))
ap.add_argument("--memory")
ap.add_argument("--mbon", default="",
help="обученное считывание MBON одной моделью; она видела "
"эти бэги — годится только для отладки")
ap.add_argument("--mbon-dir", default="",
help="каталог с моделями по складкам (mbon_<бэг>.npz): для "
"каждого бэга берётся модель, его НЕ видевшая. Иначе "
"дальность завышена: считывание обучалось ровно на "
"таких же вставках в этот же тоннель")
ap.add_argument("--out", default=str(B.ARTIFACTS / "benchmark.json"))
ap.add_argument("--tracks-out", default="",
help="куда сложить выборку по трекам (npz); пусто — не собирать")
ap.add_argument("--track", default="",
help="считывание по трекам одной моделью; она видела эти "
"записи — годится только для отладки")
ap.add_argument("--track-dir", default="",
help="каталог с моделями по складкам (track_<бэг>.npz)")
ap.add_argument("--track-blend", type=float, default=None,
help="доля считывания по треку в решении; 0 — только улика")
ap.add_argument("--track-gate", type=float, default=None,
help="жёсткий порог по отсчёту трека; 0 — не проверять")
ap.add_argument("--track-score", default=None,
choices=("w_mean", "w_ema", "p_mean", "model"),
help="чем мерить трек: средний вес наблюдения, средний "
"отсчёт считывания или обученная модель")
ap.add_argument("--limit", type=int, default=250)
ap.add_argument("--d-start", type=float, default=200.0)
ap.add_argument("--laterals", default="0.0,0.9")
ap.add_argument("--seed", type=int, default=12345)
ap.add_argument("--mbon-blend", type=float, default=None,
help="1 — только модель, 0 — только ручная формула")
ap.add_argument("--warn", type=float, default=None,
help="порог улики для тревоги (по умолчанию 0.5)")
ap.add_argument("--clear", type=float, default=None)
ap.add_argument("--min-hits", type=int, default=None)
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("--augment", action="store_true",
help="включить случайные аугментации: d_start ∈ [d_min, d_max], "
"боковой дрейф v_lat, шум по дальности δs и стоячие поезда")
ap.add_argument("--d-start-min", type=float, default=40.0,
help="минимальная дальность спавна при аугментации, м")
ap.add_argument("--d-start-max", type=float, default=200.0,
help="максимальная дальность спавна при аугментации, м")
ap.add_argument("--v-lat-max", type=float, default=0.35,
help="максимальная боковая скорость препятствия (дрейф), м/с")
ap.add_argument("--s-noise-std", type=float, default=0.12,
help="СКО шума по продольной координате/дальности (джиттер лучей), м")
ap.add_argument("--respawn", action="store_true",
help="пересоздавать препятствие впереди при приближении ближе 6 м")
ap.add_argument("--static-prob", type=float, default=0.15,
help="вероятность сценария со стоячим поездом (ds = 0)")
ap.add_argument("--device", default="auto", choices=["auto", "cuda", "cpu"],
help="устройство вычислений ('auto', 'cuda', 'cpu')")
P.add_argument(ap)
PS.add_argument(ap)
args = ap.parse_args()
B.ARTIFACTS.mkdir(parents=True, exist_ok=True)
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}")
track_paths: dict[str, str] = {}
if args.track_dir:
from pathlib import Path as _P2
for f in _P2(args.track_dir).glob("track_*.npz"):
track_paths[f.stem[len("track_"):]] = str(f)
print(f"считывание по трекам по складкам: {args.track_dir} "
f"({len(track_paths)} моделей)")
elif args.track:
print(f"считывание по трекам: {args.track}")
over = {}
if args.mbon_blend is not None:
over["mbon_blend"] = args.mbon_blend
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
if args.track_blend is not None:
over["track_blend"] = args.track_blend
if args.track_score is not None:
over["track_score"] = args.track_score
if args.track_gate is not None:
over["track_gate"] = args.track_gate
over["device"] = args.device
params = Params(**PS.apply(over, args.set, Params))
laterals = tuple(float(x) for x in args.laterals.split(","))
tasks = []
for p in find_bags(args.root):
if p.name == HOLDOUT:
continue
rd = args.mbon
if fold_paths:
rd = fold_paths.get(p.name, "")
if not rd:
print(f" внимание: для {p.name} нет своей складки — пропуск")
continue
trk = track_paths.get(p.name, args.track) if track_paths else args.track
aug_params = {
"augment": args.augment,
"d_start_min": args.d_start_min,
"d_start_max": args.d_start_max,
"v_lat_max": args.v_lat_max,
"s_noise_std": args.s_noise_std,
"respawn": args.respawn,
"static_prob": args.static_prob,
}
tasks.append((p, params, args.limit, args.d_start, laterals, args.seed,
args.memory or "", rd, bool(args.tracks_out), trk, aug_params))
# Печатается по готовности, собирается по номеру задачи: порядок сценариев
# в файле не должен зависеть от того, какой бэг досчитался первым.
slots: list = [None] * len(tasks)
trk_slots: list = [None] * len(tasks)
for i, task, got, secs in P.run(_work, tasks, args.jobs):
rec, tracks = got
slots[i] = rec
trk_slots[i] = (task[0].name, tracks)
n = sum(len(r["d"]) for r in rec)
extra = f", треков {tracks[0].shape[0]:7d}" if tracks is not None else ""
print(f" {task[0].name:42s} сценариев {len(rec):3d}, наблюдений {n:6d}"
f"{extra}, {secs:6.1f} с", flush=True)
all_rec = [r for rec in slots if rec for r in rec]
if args.tracks_out:
from flyguard.track_readout import TRACK_FEATURES
out_arr, names = {}, []
for got in trk_slots:
if got is None or got[1] is None:
continue
name, (Xb, yb, db) = got
names.append(name)
out_arr[f"X_{name}"] = Xb
out_arr[f"y_{name}"] = yb
out_arr[f"d_{name}"] = db
np.savez_compressed(args.tracks_out, names=np.array(names),
features=np.array(TRACK_FEATURES), **out_arr)
tot = sum(v.shape[0] for k, v in out_arr.items() if k.startswith("X_"))
pos = sum(int(v.sum()) for k, v in out_arr.items() if k.startswith("y_"))
print(f"выборка по трекам: {tot} наблюдений, предметных {pos} "
f"({pos / max(tot, 1):.1%}) -> {args.tracks_out}")
with open(args.out, "w", encoding="utf-8") as f:
json.dump(all_rec, f, ensure_ascii=False)
print(f"сохранено: {args.out} ({len(all_rec)} сценариев)")
if __name__ == "__main__":
main()