Ретина, ламина, медулла, лобула, грибовидное тело, веерное тело, центральный комплекс, нисходящие нейроны. Обучение памяти тоннеля и считывания MBON, оценка leave-one-bag-out, полигон дальности, 24 теста. Реальный объект на 55 м — 98.9 % кадров, ложных 7.5 трека на км, кадр обрабатывается за 33 мс на CPU. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
315 lines
16 KiB
Python
315 lines
16 KiB
Python
"""Синтетические препятствия: трассировка лучей в реальные кадры.
|
||
|
||
Разметки в датасете нет, а организаторы прямо предупредили, что приватный тест
|
||
собран добавлением синтезированных препятствий в новые проезды. Поэтому свой
|
||
полигон строится тем же способом: берётся настоящий кадр пустого тоннеля,
|
||
в него трассировкой лучей вставляется предмет заданного размера на заданной
|
||
дистанции, и получается **размеченный** пример с точно известным ответом.
|
||
|
||
Вставка идёт в исходное облако точек, а не в готовый дальностный образ, поэтому
|
||
через конвейер проходит ровно тот же путь, что и настоящие данные, начиная
|
||
с ретины.
|
||
|
||
Модель сенсора намеренно пессимистична: добавляется шум дальности, вероятность
|
||
несостоявшегося эха растёт с расстоянием и с углом падения, а интенсивность
|
||
считается по ламбертовой модели. Лучше недооценить свой детектор, чем на
|
||
защите обнаружить, что полигон был слишком добрым.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass, field
|
||
|
||
import numpy as np
|
||
|
||
from .cdr import PointCloud2
|
||
from .geometry import RailPlane
|
||
from .retina import ScanLayout
|
||
|
||
INF = np.float32(np.inf)
|
||
|
||
|
||
# --------------------------------------------------------------------------- тела
|
||
|
||
@dataclass
|
||
class Box:
|
||
"""Параллелепипед в координатах пути, стоящий на плоскости рельсов."""
|
||
|
||
length: float # вдоль пути, м
|
||
width: float # поперёк, м
|
||
height: float # вверх от рельса, м
|
||
h_base: float = 0.0
|
||
|
||
def intersect(self, od, ou, oh, dd, du, dh) -> np.ndarray:
|
||
lo = np.array([-self.length / 2, -self.width / 2, self.h_base], np.float32)
|
||
hi = np.array([self.length / 2, self.width / 2, self.h_base + self.height], np.float32)
|
||
t0 = np.full(od.shape, -INF, np.float32)
|
||
t1 = np.full(od.shape, INF, np.float32)
|
||
for o, d, a, b in ((od, dd, lo[0], hi[0]), (ou, du, lo[1], hi[1]),
|
||
(oh, dh, lo[2], hi[2])):
|
||
with np.errstate(divide="ignore", invalid="ignore"):
|
||
ta = (a - o) / d
|
||
tb = (b - o) / d
|
||
lo_t = np.where(d != 0, np.minimum(ta, tb), np.where((o >= a) & (o <= b), -INF, INF))
|
||
hi_t = np.where(d != 0, np.maximum(ta, tb), np.where((o >= a) & (o <= b), INF, -INF))
|
||
t0 = np.maximum(t0, lo_t)
|
||
t1 = np.minimum(t1, hi_t)
|
||
hit = (t1 >= np.maximum(t0, 0.0)) & np.isfinite(t0)
|
||
return np.where(hit, np.maximum(t0, 0.0), INF)
|
||
|
||
|
||
@dataclass
|
||
class Cylinder:
|
||
"""Вертикальный цилиндр — человек, столб, ведро."""
|
||
|
||
radius: float
|
||
height: float
|
||
h_base: float = 0.0
|
||
|
||
def intersect(self, od, ou, oh, dd, du, dh) -> np.ndarray:
|
||
a = dd * dd + du * du
|
||
b = 2.0 * (od * dd + ou * du)
|
||
c = od * od + ou * ou - self.radius ** 2
|
||
disc = b * b - 4 * a * c
|
||
ok = (disc > 0) & (a > 1e-9)
|
||
sq = np.sqrt(np.where(ok, disc, 0.0))
|
||
with np.errstate(divide="ignore", invalid="ignore"):
|
||
t = (-b - sq) / (2 * a)
|
||
t2 = (-b + sq) / (2 * a)
|
||
t = np.where(t > 0, t, t2)
|
||
h = oh + t * dh
|
||
ok &= (t > 0) & (h >= self.h_base) & (h <= self.h_base + self.height)
|
||
return np.where(ok, t, INF)
|
||
|
||
|
||
@dataclass
|
||
class Sphere:
|
||
radius: float
|
||
h_centre: float
|
||
|
||
def intersect(self, od, ou, oh, dd, du, dh) -> np.ndarray:
|
||
oz = oh - self.h_centre
|
||
a = dd * dd + du * du + dh * dh
|
||
b = 2.0 * (od * dd + ou * du + oz * dh)
|
||
c = od * od + ou * ou + oz * oz - self.radius ** 2
|
||
disc = b * b - 4 * a * c
|
||
ok = disc > 0
|
||
sq = np.sqrt(np.where(ok, disc, 0.0))
|
||
with np.errstate(divide="ignore", invalid="ignore"):
|
||
t = (-b - sq) / (2 * a)
|
||
return np.where(ok & (t > 0), t, INF)
|
||
|
||
|
||
@dataclass
|
||
class ObjectModel:
|
||
"""Предмет: набор тел плюс отражательные свойства."""
|
||
|
||
name: str
|
||
parts: list = field(default_factory=list)
|
||
reflectivity: float = 0.3 # 0…1, доля отражённого света
|
||
|
||
def intersect(self, od, ou, oh, dd, du, dh) -> np.ndarray:
|
||
t = np.full(od.shape, INF, np.float32)
|
||
for p in self.parts:
|
||
t = np.minimum(t, p.intersect(od, ou, oh, dd, du, dh))
|
||
return t
|
||
|
||
@property
|
||
def size(self) -> tuple[float, float]:
|
||
"""Грубые габариты (ширина, высота) для отчётов."""
|
||
w = h = 0.0
|
||
for p in self.parts:
|
||
if isinstance(p, Box):
|
||
w = max(w, p.width); h = max(h, p.h_base + p.height)
|
||
elif isinstance(p, Cylinder):
|
||
w = max(w, 2 * p.radius); h = max(h, p.h_base + p.height)
|
||
elif isinstance(p, Sphere):
|
||
w = max(w, 2 * p.radius); h = max(h, p.h_centre + p.radius)
|
||
return w, h
|
||
|
||
|
||
def catalogue() -> dict[str, ObjectModel]:
|
||
"""Набор предметов, встречающихся в тоннеле, от крупных к мелким."""
|
||
return {
|
||
"человек_стоя": ObjectModel("человек_стоя", [
|
||
Cylinder(radius=0.22, height=1.45),
|
||
Sphere(radius=0.11, h_centre=1.60)], reflectivity=0.35),
|
||
"человек_сидя": ObjectModel("человек_сидя", [
|
||
Box(0.45, 0.50, 0.85)], reflectivity=0.35),
|
||
"ящик": ObjectModel("ящик", [Box(0.60, 0.60, 0.60)], reflectivity=0.40),
|
||
"чемодан": ObjectModel("чемодан", [Box(0.25, 0.45, 0.55)], reflectivity=0.30),
|
||
"ведро": ObjectModel("ведро", [Cylinder(radius=0.15, height=0.35)], reflectivity=0.45),
|
||
"камень": ObjectModel("камень", [Sphere(radius=0.11, h_centre=0.11)], reflectivity=0.20),
|
||
"бутылка": ObjectModel("бутылка", [Cylinder(radius=0.045, height=0.30)],
|
||
reflectivity=0.25),
|
||
"кабель": ObjectModel("кабель", [Box(2.20, 0.06, 0.06)], reflectivity=0.15),
|
||
"каска": ObjectModel("каска", [Sphere(radius=0.14, h_centre=0.10)], reflectivity=0.55),
|
||
}
|
||
|
||
|
||
# --------------------------------------------------------------------------- вставка
|
||
|
||
@dataclass
|
||
class Placement:
|
||
"""Где стоит предмет."""
|
||
|
||
d: float # вперёд от сенсора, м
|
||
u: float = 0.0 # поперёк от оси пути, м
|
||
yaw_deg: float = 0.0 # поворот вокруг вертикали (для вытянутых тел)
|
||
|
||
|
||
def ray_dirs_track(layout: ScanLayout, plane: RailPlane):
|
||
"""Направления лучей в координатах пути: (dd, du, dh) и начало (0, 0, H)."""
|
||
dirs = layout.dirs
|
||
dd = -dirs[..., 1].astype(np.float32)
|
||
du = dirs[..., 0].astype(np.float32)
|
||
dh = (dirs[..., 2] - plane.a * dd - plane.b * du).astype(np.float32)
|
||
return dd, du, dh, np.float32(plane.height)
|
||
|
||
|
||
# Паспортные данные Pandar128E3X (руководство v4p5, п. 1.4 и Приложение A):
|
||
# дальность 0.3…200 м при отражательной способности 10 %, вероятность
|
||
# обнаружения на паспортной дальности PoD = 70 %, точность ±2 см на 1…200 м.
|
||
SPEC_REFLECTIVITY = 0.10
|
||
SPEC_POD = 0.70
|
||
HARD_MAX_RANGE = 230.0 # дальше в датасете возвратов не встречается
|
||
|
||
# Расходимость луча в руководстве не указана; принята равной угловому шагу
|
||
# решётки (0.1° по азимуту, 0.125° по элевации в полосе высокого разрешения) —
|
||
# это верхняя оценка, дающая консервативный результат для мелких предметов.
|
||
BEAM_DIV_H = 1.75e-3 # рад
|
||
BEAM_DIV_V = 2.18e-3 # рад
|
||
|
||
_CHANNEL_RANGE: np.ndarray | None = None
|
||
|
||
|
||
def channel_max_range() -> np.ndarray:
|
||
"""Паспортная дальность каждого канала при 10 % отражения, (128,).
|
||
|
||
Каналы сильно неравноправны: 34–65 «дальнобойные» и берут 200 м, а 98–128
|
||
смотрят в землю и рассчитаны только на ближнее и среднее поле. Без учёта
|
||
этого синтетический полигон завышал бы дальность обнаружения для предметов,
|
||
попадающих в нижние каналы.
|
||
"""
|
||
global _CHANNEL_RANGE
|
||
if _CHANNEL_RANGE is None:
|
||
import csv
|
||
from pathlib import Path
|
||
path = Path(__file__).with_name("data") / "pandar128_channels.csv"
|
||
try:
|
||
rows = sorted(csv.DictReader(open(path, encoding="utf-8")),
|
||
key=lambda r: int(r["channel"]))
|
||
_CHANNEL_RANGE = np.array([float(r["max_range_10pct_m"]) for r in rows],
|
||
np.float32)
|
||
except (OSError, KeyError, ValueError):
|
||
_CHANNEL_RANGE = np.full(128, 200.0, np.float32)
|
||
return _CHANNEL_RANGE
|
||
|
||
|
||
def dropout_probability(r: np.ndarray, reflectivity: float,
|
||
ang_w: np.ndarray | float = 1.0,
|
||
ang_h: np.ndarray | float = 1.0,
|
||
max_range: np.ndarray | float = 200.0) -> np.ndarray:
|
||
"""Вероятность, что эхо не вернётся.
|
||
|
||
Принятая мощность падает как ρ·A/r², где A — доля пятна луча, закрытая
|
||
предметом. Отсюда «эффективная дальность» r·√(ρ_паспорт/(ρ·A)), которую
|
||
остаётся сравнить с паспортной дальностью **этого канала**. Переход сделан
|
||
логистическим и смещён так, чтобы на паспортной дальности вероятность
|
||
обнаружения равнялась заявленным 70 %.
|
||
|
||
Заполнение пятна важно именно для мелких предметов: на 200 м луч шириной
|
||
1.75 мрад покрывает 35 см, и бутылка диаметром 9 см отражает лишь четверть
|
||
его энергии — поэтому она пропадает намного раньше человека, хотя по
|
||
геометрии в неё ещё попадают лучи.
|
||
"""
|
||
fill = np.clip(ang_w / BEAM_DIV_H, 0.05, 1.0) * np.clip(ang_h / BEAM_DIV_V, 0.05, 1.0)
|
||
rho = max(reflectivity, 0.02) * fill
|
||
eff = r * np.sqrt(SPEC_REFLECTIVITY / rho)
|
||
width = 0.12 * np.asarray(max_range, np.float32)
|
||
r50 = np.asarray(max_range, np.float32) + width * np.log(SPEC_POD / (1 - SPEC_POD))
|
||
p_detect = 1.0 / (1.0 + np.exp((eff - r50) / np.maximum(width, 1e-3)))
|
||
p_detect = np.where(r > HARD_MAX_RANGE, 0.0, p_detect)
|
||
return np.clip(1.0 - p_detect, 0.0, 1.0).astype(np.float32)
|
||
|
||
|
||
def inject(pc: PointCloud2, layout: ScanLayout, plane: RailPlane,
|
||
obj: ObjectModel, place: Placement, *,
|
||
rng: np.random.Generator | None = None,
|
||
range_noise: float = 0.02, cols: slice | None = None) -> tuple[PointCloud2, dict]:
|
||
"""Вставить предмет в облако точек. Возвращает (новое облако, разметка)."""
|
||
rng = rng or np.random.default_rng()
|
||
n_rings, n_az, n_echo = layout.n_rings, layout.n_az, layout.n_echo
|
||
|
||
dd, du, dh, H = ray_dirs_track(layout, plane)
|
||
# начало луча в системе предмета: сенсор в (0,0,H), предмет в (d, u, 0)
|
||
od = np.full(dd.shape, -np.float32(place.d), np.float32)
|
||
ou = np.full(dd.shape, -np.float32(place.u), np.float32)
|
||
oh = np.full(dd.shape, H, np.float32)
|
||
|
||
if place.yaw_deg:
|
||
c, s = np.cos(np.radians(place.yaw_deg)), np.sin(np.radians(place.yaw_deg))
|
||
od, ou = c * od + s * ou, -s * od + c * ou
|
||
dd, du = c * dd + s * du, -s * dd + c * du
|
||
|
||
t = obj.intersect(od, ou, oh, dd, du, dh)
|
||
hit = np.isfinite(t) & (t > 1.0)
|
||
if not hit.any():
|
||
return pc, dict(hit_rays=0, d=place.d, u=place.u, name=obj.name)
|
||
|
||
# шум дальности и пропуски эха
|
||
t = t + rng.normal(0.0, range_noise, t.shape).astype(np.float32)
|
||
w_obj, h_obj = obj.size
|
||
ang_w = w_obj / np.maximum(t, 1.0)
|
||
ang_h = h_obj / np.maximum(t, 1.0)
|
||
ch_range = channel_max_range()
|
||
per_ray_range = (ch_range[:n_rings, None] if ch_range.size >= n_rings
|
||
else np.float32(200.0))
|
||
p_drop = dropout_probability(t, obj.reflectivity, ang_w, ang_h, per_ray_range)
|
||
hit &= rng.random(t.shape) > p_drop
|
||
|
||
pts = pc.points.copy()
|
||
ring_i, col_i = np.nonzero(hit)
|
||
raw_col = layout.gather[ring_i, col_i] # выпрямленный столбец → сырой
|
||
new_r = t[ring_i, col_i]
|
||
|
||
# плоский индекс точки: (сырой столбец · число эх + эхо) · число колец + кольцо
|
||
base = (raw_col * n_echo) * n_rings + ring_i
|
||
fx, fy, fz = pts["x"], pts["y"], pts["z"] # виды на поля, запись идёт в pts
|
||
|
||
# предмет виден, только если он ближе уже зарегистрированного эха
|
||
r_exist = np.full(base.shape, INF, np.float32)
|
||
for e in range(n_echo):
|
||
idx = base + e * n_rings
|
||
ex, ey, ez = fx[idx], fy[idx], fz[idx]
|
||
have = (ex != 0) | (ey != 0) | (ez != 0)
|
||
r_e = np.where(have, np.sqrt(ex * ex + ey * ey + ez * ez), INF)
|
||
r_exist = np.minimum(r_exist, r_e)
|
||
|
||
closer = new_r < r_exist
|
||
ring_i, col_i, base, new_r = ring_i[closer], col_i[closer], base[closer], new_r[closer]
|
||
n_written = int(base.size)
|
||
if n_written:
|
||
dir_sel = layout.dirs[ring_i, col_i]
|
||
nx = (dir_sel[:, 0] * new_r).astype(np.float32)
|
||
ny = (dir_sel[:, 1] * new_r).astype(np.float32)
|
||
nz = (dir_sel[:, 2] * new_r).astype(np.float32)
|
||
# ламбертова интенсивность: ρ·cosθ/r², приведена к шкале прибора 0…255
|
||
cos_inc = np.clip(np.abs(dir_sel[:, 1]), 0.05, 1.0)
|
||
inten = np.clip(2.2e4 * obj.reflectivity * cos_inc / (new_r ** 2), 1, 255)
|
||
for e in range(n_echo):
|
||
idx = base + e * n_rings
|
||
fx[idx] = nx
|
||
fy[idx] = ny
|
||
fz[idx] = nz
|
||
pts["intensity"][idx] = inten.astype(np.float32)
|
||
|
||
out = PointCloud2(stamp=pc.stamp, frame_id=pc.frame_id, height=pc.height,
|
||
width=pc.width, point_step=pc.point_step,
|
||
is_dense=pc.is_dense, points=pts)
|
||
w, hgt = obj.size
|
||
return out, dict(hit_rays=int(n_written), d=float(place.d), u=float(place.u),
|
||
name=obj.name, width=w, height=hgt,
|
||
reflectivity=obj.reflectivity,
|
||
# индексы лучей, в которые предмет реально записан: по ним
|
||
# разметка кандидата точная, а не «по дальности примерно»
|
||
rays=(ring_i, col_i))
|