forked from Dan4ick/Lidar_Muxa
Compare commits
No commits in common. "2f454738910856083a43ea98b6ddf173536b6751" and "657cd5406f4e4d16de49dbdc8e394e6c8b7a68b0" have entirely different histories.
2f45473891
...
657cd5406f
46 changed files with 233 additions and 6237 deletions
|
|
@ -1,38 +0,0 @@
|
|||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
.github
|
||||
|
||||
# Python caches
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.pytest_cache
|
||||
.coverage
|
||||
htmlcov/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
.env
|
||||
|
||||
# Editor & OS artifacts
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Agent & temporary files
|
||||
scratch/
|
||||
.gemini/
|
||||
|
||||
# Raw lidar data (mounted via volume, never baked into image)
|
||||
data/
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -6,7 +6,3 @@ data/
|
|||
artifacts/mbon_folds/
|
||||
docs/figures/*.png
|
||||
docs/figures/*.mp4
|
||||
|
||||
# Project memory & chat transfer
|
||||
PROJECT_MEMORY.md
|
||||
CHAT_MEMORY.md
|
||||
|
|
|
|||
104
Dockerfile
104
Dockerfile
|
|
@ -1,96 +1,22 @@
|
|||
# ============================================================================
|
||||
# FlyGuard: Bionic Obstacle Detection Core (LCT-2026, Case 05)
|
||||
# Hesai Pandar128 LiDAR (128 beams, 10 Hz / 100ms)
|
||||
# Universal Production Image: NVIDIA CUDA 12.x / Ada Lovelace / Graceful CPU Fallback
|
||||
# ============================================================================
|
||||
# FlyGuard processing core and command-line tools (CPU only).
|
||||
FROM python:3.11-slim
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Stage 1: Optional lightweight CPU-only image (build with: --target cpu)
|
||||
# ----------------------------------------------------------------------------
|
||||
FROM python:3.11-slim-bookworm AS cpu
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONPATH="/app:/app/tools" \
|
||||
FLYGUARD_DATA="/data" \
|
||||
FLYGUARD_DEVICE="cpu"
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libgomp1 \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd -m -u 1000 -s /bin/bash flyguard && \
|
||||
mkdir -p /app /data /app/artifacts && \
|
||||
chown -R flyguard:flyguard /app /data
|
||||
|
||||
WORKDIR /app
|
||||
COPY --chown=flyguard:flyguard requirements.txt /app/
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY --chown=flyguard:flyguard . /app/
|
||||
RUN chmod +x /app/docker-entrypoint.sh
|
||||
|
||||
USER flyguard
|
||||
VOLUME ["/data", "/app/artifacts"]
|
||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||
CMD ["default"]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Stage 2: Universal Production Image with NVIDIA GPU acceleration & CPU fallback
|
||||
# (Default target when building with: docker build -t flyguard:latest .)
|
||||
# ----------------------------------------------------------------------------
|
||||
FROM nvidia/cuda:12.4.1-runtime-ubuntu22.04 AS production
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONPATH="/app:/app/tools" \
|
||||
FLYGUARD_DATA="/data" \
|
||||
FLYGUARD_DEVICE="auto" \
|
||||
NVIDIA_VISIBLE_DEVICES=all \
|
||||
NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
|
||||
# Install Python 3.11, pip, and system runtime libraries (OpenMP for LightGBM/Torch)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
software-properties-common \
|
||||
ca-certificates \
|
||||
libgomp1 \
|
||||
curl \
|
||||
&& add-apt-repository -y ppa:deadsnakes/ppa \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3.11 \
|
||||
python3.11-distutils \
|
||||
&& curl -sS https://bootstrap.pypa.io/get-pip.py | python3.11 \
|
||||
&& ln -sf /usr/bin/python3.11 /usr/bin/python3 \
|
||||
&& ln -sf /usr/bin/python3.11 /usr/bin/python \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create non-root user for container security compliance
|
||||
RUN useradd -m -u 1000 -s /bin/bash flyguard && \
|
||||
mkdir -p /app /data /app/artifacts && \
|
||||
chown -R flyguard:flyguard /app /data
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Cache layer: install Python dependencies with PyTorch CUDA 12.1+ wheels
|
||||
COPY --chown=flyguard:flyguard requirements-gpu.txt /app/
|
||||
RUN pip install --no-cache-dir --upgrade pip setuptools wheel && \
|
||||
pip install --no-cache-dir -r requirements-gpu.txt
|
||||
# Runtime dependencies for the core are supplied as wheels for this platform.
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy source code and artifacts
|
||||
COPY --chown=flyguard:flyguard . /app/
|
||||
RUN chmod +x /app/docker-entrypoint.sh
|
||||
COPY flyguard ./flyguard
|
||||
COPY tools ./tools
|
||||
COPY tests ./tests
|
||||
COPY artifacts ./artifacts
|
||||
|
||||
USER flyguard
|
||||
VOLUME ["/data", "/app/artifacts"]
|
||||
|
||||
# Container healthcheck: verifies Python runtime and graceful device detection
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD python3 -c "import flyguard; from flyguard.device import get_device_info; print('healthy', get_device_info())" || exit 1
|
||||
|
||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||
CMD ["default"]
|
||||
# The default verifies the containerised core. Override CMD for a tool, e.g.:
|
||||
# docker run --rm -v /path/to/data:/data flyguard \
|
||||
# python tools/run_pipeline.py --bag /data/my_bag --memory artifacts/mushroom_body.npz
|
||||
CMD ["pytest", "tests", "-q"]
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
# ============================================================================
|
||||
# FlyGuard GPU: NVIDIA RTX / CUDA 12 Production Image (Кейс 05, ЛЦТ-2026)
|
||||
# Архитектура: Ada Lovelace (RTX 4070 Ti Super 16GB) / Ampere / Turing
|
||||
# Полная совместимость с Dockerfile (Stage: production)
|
||||
# ============================================================================
|
||||
FROM nvidia/cuda:12.4.1-runtime-ubuntu22.04 AS production
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONPATH="/app:/app/tools" \
|
||||
FLYGUARD_DATA="/data" \
|
||||
FLYGUARD_DEVICE="auto" \
|
||||
NVIDIA_VISIBLE_DEVICES=all \
|
||||
NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
|
||||
# Install Python 3.11, pip, and system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
software-properties-common \
|
||||
ca-certificates \
|
||||
libgomp1 \
|
||||
curl \
|
||||
&& add-apt-repository -y ppa:deadsnakes/ppa \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3.11 \
|
||||
python3.11-distutils \
|
||||
&& curl -sS https://bootstrap.pypa.io/get-pip.py | python3.11 \
|
||||
&& ln -sf /usr/bin/python3.11 /usr/bin/python3 \
|
||||
&& ln -sf /usr/bin/python3.11 /usr/bin/python \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create non-root user and directories
|
||||
RUN useradd -m -u 1000 -s /bin/bash flyguard && \
|
||||
mkdir -p /app /data /app/artifacts && \
|
||||
chown -R flyguard:flyguard /app /data
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Cache layer: copy only requirements first
|
||||
COPY --chown=flyguard:flyguard requirements-gpu.txt /app/
|
||||
|
||||
# Install python dependencies with PyTorch CUDA 12
|
||||
RUN pip install --no-cache-dir --upgrade pip setuptools wheel && \
|
||||
pip install --no-cache-dir -r requirements-gpu.txt
|
||||
|
||||
# Copy application source code
|
||||
COPY --chown=flyguard:flyguard . /app/
|
||||
RUN chmod +x /app/docker-entrypoint.sh
|
||||
|
||||
USER flyguard
|
||||
VOLUME ["/data", "/app/artifacts"]
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD python3 -c "import flyguard; from flyguard.device import get_device_info; print('healthy', get_device_info())" || exit 1
|
||||
|
||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||
CMD ["default"]
|
||||
86
README.md
86
README.md
|
|
@ -66,15 +66,8 @@ else:
|
|||
`emergency` (флаг экстренного торможения), `objects` (список подтверждённых
|
||||
треков с id, дистанцией и габаритами).
|
||||
|
||||
Всё считается на **CPU**, GPU не требуется. Медиана обработки кадра — 43 мс
|
||||
при бюджете 100 мс (замер по стадиям, один процесс, EXPERIMENTS п. 7.4).
|
||||
|
||||
Ламину можно перенести на **NVIDIA GPU** (`Params(device="auto")` или
|
||||
`"cuda"`, модуль `flyguard/device.py` с переходом на CPU при любом сбое карты).
|
||||
Замерено на RTX 5070 Ti: ламина 5.8 → 2.9 мс с копированием туда и обратно,
|
||||
то есть кадр 43 → ~40 мс, — остальные 80 % времени кластеризация и геометрия,
|
||||
которые видеокарта не ускоряет. Поэтому по умолчанию `device="cpu"`: выигрыш
|
||||
в 3 мс не стоит отдельного образа и `--gpus all` на машине проверки.
|
||||
Всё считается на **CPU**, GPU не требуется. Медиана обработки кадра — 33 мс
|
||||
при бюджете 100 мс.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -83,21 +76,18 @@ else:
|
|||
```
|
||||
flyguard/ ядро: стадии обработки, память, считывание
|
||||
bag.py cdr.py чтение rosbag2 и разбор CDR без ROS
|
||||
device.py выбор CPU / NVIDIA GPU и переход на CPU при сбое
|
||||
retina.py geometry.py решётка лучей, плоскость рельсов, ось пути
|
||||
lamina.py medulla.py контраст (на CPU или GPU), движение
|
||||
lamina.py medulla.py контраст, движение
|
||||
lobula.py кандидаты
|
||||
mushroom_body.py память тоннеля (без меток)
|
||||
mbon_readout.py обученное считывание (с метками)
|
||||
track_readout.py считывание по истории трека — инструмент замера
|
||||
fan_body.py накопление в координатах пути
|
||||
central_complex.py треки и улики
|
||||
descending.py решение
|
||||
pipeline.py сборка
|
||||
export.py 3D-рамки, время до столкновения, маркеры RViz
|
||||
synth.py вставка предметов трассировкой лучей
|
||||
tools/ обучение, оценка, разбор, полигон с аугментациями
|
||||
tests/ 53 теста, запускаются без данных и без ROS
|
||||
tools/ обучение, оценка, разбор
|
||||
tests/ 34 теста, запускаются без данных и без ROS
|
||||
docs/ методика и результаты
|
||||
artifacts/ обученные модели
|
||||
```
|
||||
|
|
@ -108,22 +98,9 @@ artifacts/ обученные модели
|
|||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
pytest tests -q # или без pytest: python tests/run_tests.py
|
||||
pytest tests -q
|
||||
```
|
||||
|
||||
Через Docker (подробно — [docs/DOCKER.md](docs/DOCKER.md)):
|
||||
|
||||
```bash
|
||||
./docker-run.sh build
|
||||
docker compose run --rm test # тесты
|
||||
docker compose run --rm info # что видно из контейнера: CPU, GPU, CUDA
|
||||
docker compose run --rm evaluate # ложные тревоги
|
||||
docker compose run --rm benchmark # полигон
|
||||
```
|
||||
|
||||
Для обучения на видеокарте — `pip install -r requirements-gpu.txt` и
|
||||
`--device cuda` у `train_mbon.py`, `train_mushroom_body.py`, `evaluate.py`.
|
||||
|
||||
Записи лидара в репозиторий не кладутся. Положите их рядом
|
||||
(`../data/for_hackathon/...`) или укажите путь:
|
||||
|
||||
|
|
@ -140,44 +117,8 @@ python tools/evaluate.py --mbon-dir artifacts/mbon_folds # ложные тре
|
|||
python tools/make_benchmark.py --memory artifacts/mushroom_body.npz \
|
||||
--mbon-dir artifacts/mbon_folds # дальность обнаружения
|
||||
python tools/plot_benchmark.py # кривые и график
|
||||
python tools/compare_benchmark.py было.json стало.json # правка парно
|
||||
```
|
||||
|
||||
Большой бэг `new_data` (90 ГБ) не распаковывается целиком — инструменты
|
||||
читают его кусками прямо из архива (`--tar`, по умолчанию
|
||||
`../датасет/new_data` или `FLYGUARD_NEW_DATA`):
|
||||
|
||||
```bash
|
||||
python tools/make_training_set.py --new-data 0:110 --out data/cache/training_set_nd.npz
|
||||
python tools/train_mbon.py --device cuda --data data/cache/training_set.npz \
|
||||
--data data/cache/training_set_nd.npz --train-only new_data_ --out nd.npz
|
||||
python tools/eval_new_data.py --shards 110: \
|
||||
--readout было=artifacts/mbon_readout.npz --readout стало=nd.npz --out nd.json
|
||||
```
|
||||
|
||||
Проверка на второй половине честная: вставки идут только в первую, а
|
||||
вторую модель не видит ни в каком виде (EXPERIMENTS п. 17.3).
|
||||
|
||||
Синтетику организаторов (`cloud_with_fake_obj`, 10 предметов) сверяет с
|
||||
эталоном отдельный инструмент. Разметки к бэгу нет, но вставленные точки не
|
||||
лежат на элевациях колец, и эталон извлекается из самого бэга (п. 18.2):
|
||||
|
||||
```bash
|
||||
python tools/eval_org_synth.py # итоговые настройки
|
||||
python tools/eval_org_synth.py --set half_width=1.6 --set h_top=0
|
||||
```
|
||||
|
||||
В этом бэге нет поля `ring`, а порядок точек сбит вставками: `retina.py`
|
||||
восстанавливает кольца по элевации и раскладывает такие кадры по углам точек.
|
||||
До этой правки ядро на нём не обрабатывало ни одного кадра (п. 18.1).
|
||||
|
||||
Любое поле `Params` меняется без правки кода: `--set h_lo_core=0.28 --set
|
||||
k_sigma=0.75` у `make_benchmark.py`, `evaluate.py`, `make_training_set.py` и
|
||||
`check_obstacle.py`. Два прогона полигона сравнивайте только парно —
|
||||
`compare_benchmark.py` считает, сколько наблюдений перевернулось в каждую
|
||||
сторону на одних и тех же вставках. Итоговые таблицы двух прогонов шумят
|
||||
сильнее, чем меняет их большинство правок (EXPERIMENTS п. 16.1).
|
||||
|
||||
Тяжёлые шаги сами раскладываются по бэгам на процессы — записей пять, физических
|
||||
ядер шесть, и это вся доступная зернистость: конвейер держит состояние между
|
||||
кадрами, поэтому разрезать одну запись нельзя. Замерено: полигон 134 → 36 с,
|
||||
|
|
@ -196,16 +137,13 @@ k_sigma=0.75` у `make_benchmark.py`, `evaluate.py`, `make_training_set.py` и
|
|||
| Метрика | Значение | Чем измерено |
|
||||
|---|---|---|
|
||||
| Реальный объект 0.67 × 1.35 м на 55 м | **99.5 %** кадров | `tools/check_obstacle.py` |
|
||||
| Синтетика организаторов, 10 предметов | **9 из 10** верно | `tools/eval_org_synth.py` |
|
||||
| Ложные тревоги, leave-one-bag-out | **5.7 трека на км**, 8.4 % кадров | `tools/evaluate.py --mbon-dir` |
|
||||
| То же без обученного считывания | 11.9 на км, 20.3 % кадров | там же, без `--mbon-dir` |
|
||||
| На незнакомой линии (памяти нет) | **12.8 на км**; без считывания 36.1 | `tools/evaluate.py --no-memory` |
|
||||
| Вторая половина `new_data`: другой день, 3.41 км, не видена при обучении | **11.7 на км**, 17.9 % кадров | `tools/eval_new_data.py` |
|
||||
| Дальность (полигон, 15 560 наблюдений) | человек стоя: рабочая дальность **100 м**, P@50 = 0.72, P@100 = 0.52, P@150 = 0.46 | `tools/plot_benchmark.py` |
|
||||
| Человек, упавший на пути | P@50 = **0.50** (было 0.27), рабочая дальность 20 м | там же |
|
||||
| Обработка кадра | медиана 33 мс из бюджета 100 мс; на ядре уровня стенда жюри 55 / 65 мс (медиана / p95) | контейнер, EXPERIMENTS п. 18.5, 19.5 |
|
||||
| Ложные тревоги, leave-one-bag-out | **8.0 трека на км**, 11.0 % кадров | `tools/evaluate.py --mbon-dir` |
|
||||
| То же без обученного считывания | 12.9 на км, 25.9 % кадров | там же, без `--mbon-dir` |
|
||||
| На незнакомой линии (памяти нет) | **20.3 на км**; без считывания 33.6 | `tools/evaluate.py --no-memory` |
|
||||
| Дальность (полигон, 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` |
|
||||
| Разделение «знакомое / новое» (без учителя) | 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**: память обучается на всех записях, кроме
|
||||
проверяемой. Иначе цифры лгут — подавлять конструкции, которые сам же и
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -3,90 +3,90 @@
|
|||
"bag": "doubleT_obstacle",
|
||||
"frames": 190,
|
||||
"path_m": 0.0,
|
||||
"alarm_frames": 167,
|
||||
"alarm_rate": 0.8789473684210526,
|
||||
"fp_objects": 369,
|
||||
"alarm_frames": 183,
|
||||
"alarm_rate": 0.9631578947368421,
|
||||
"fp_objects": 518,
|
||||
"fp_tracks": 4,
|
||||
"fp_per_km": NaN,
|
||||
"fp_median_d": 106.82908159892504,
|
||||
"fp_median_d": 106.83187139894102,
|
||||
"obj_rate": 0.9947368421052631,
|
||||
"ms_p50": NaN,
|
||||
"ms_p95": NaN,
|
||||
"train_size": 48221
|
||||
"ms_p50": 32.263549999697716,
|
||||
"ms_p95": 33.94519999383192,
|
||||
"train_size": 37073
|
||||
},
|
||||
{
|
||||
"bag": "doubleT_platform",
|
||||
"frames": 239,
|
||||
"path_m": 200.74905739412534,
|
||||
"alarm_frames": 31,
|
||||
"alarm_rate": 0.1297071129707113,
|
||||
"fp_objects": 42,
|
||||
"fp_tracks": 2,
|
||||
"fp_per_km": 9.962686878640993,
|
||||
"fp_median_d": 34.00916383973144,
|
||||
"alarm_frames": 28,
|
||||
"alarm_rate": 0.11715481171548117,
|
||||
"fp_objects": 28,
|
||||
"fp_tracks": 1,
|
||||
"fp_per_km": 4.981343439320496,
|
||||
"fp_median_d": 33.983066409463774,
|
||||
"obj_rate": null,
|
||||
"ms_p50": NaN,
|
||||
"ms_p95": NaN,
|
||||
"train_size": 46850
|
||||
"ms_p50": 34.84300000491203,
|
||||
"ms_p95": 38.588240001990926,
|
||||
"train_size": 36151
|
||||
},
|
||||
{
|
||||
"bag": "roundT_doubleT",
|
||||
"frames": 239,
|
||||
"path_m": 200.9598713551979,
|
||||
"alarm_frames": 0,
|
||||
"alarm_rate": 0.0,
|
||||
"fp_objects": 0,
|
||||
"fp_tracks": 0,
|
||||
"fp_per_km": 0.0,
|
||||
"fp_median_d": NaN,
|
||||
"alarm_frames": 3,
|
||||
"alarm_rate": 0.012552301255230125,
|
||||
"fp_objects": 3,
|
||||
"fp_tracks": 1,
|
||||
"fp_per_km": 4.976117835149752,
|
||||
"fp_median_d": 20.107822042272232,
|
||||
"obj_rate": null,
|
||||
"ms_p50": NaN,
|
||||
"ms_p95": NaN,
|
||||
"train_size": 47438
|
||||
"ms_p50": 34.14939999493072,
|
||||
"ms_p95": 40.86443000596773,
|
||||
"train_size": 36584
|
||||
},
|
||||
{
|
||||
"bag": "roundT_pressureGate_roundT",
|
||||
"frames": 239,
|
||||
"path_m": 246.7637153487153,
|
||||
"alarm_frames": 18,
|
||||
"alarm_rate": 0.07531380753138076,
|
||||
"fp_objects": 18,
|
||||
"fp_tracks": 1,
|
||||
"fp_per_km": 4.052459651885389,
|
||||
"fp_median_d": 160.20564869629789,
|
||||
"alarm_frames": 25,
|
||||
"alarm_rate": 0.10460251046025104,
|
||||
"fp_objects": 25,
|
||||
"fp_tracks": 2,
|
||||
"fp_per_km": 8.104919303770778,
|
||||
"fp_median_d": 160.95257704675265,
|
||||
"obj_rate": null,
|
||||
"ms_p50": NaN,
|
||||
"ms_p95": NaN,
|
||||
"train_size": 47718
|
||||
"ms_p50": 39.37539999969886,
|
||||
"ms_p95": 41.14257999899564,
|
||||
"train_size": 36718
|
||||
},
|
||||
{
|
||||
"bag": "roundT_squareT_pressureGate_squareT",
|
||||
"frames": 239,
|
||||
"path_m": 273.9439574444854,
|
||||
"alarm_frames": 3,
|
||||
"alarm_rate": 0.012552301255230125,
|
||||
"fp_objects": 3,
|
||||
"fp_tracks": 1,
|
||||
"fp_per_km": 3.6503816668511457,
|
||||
"fp_median_d": 163.97207106661114,
|
||||
"alarm_frames": 35,
|
||||
"alarm_rate": 0.14644351464435146,
|
||||
"fp_objects": 35,
|
||||
"fp_tracks": 4,
|
||||
"fp_per_km": 14.601526667404583,
|
||||
"fp_median_d": 105.12193877726114,
|
||||
"obj_rate": null,
|
||||
"ms_p50": NaN,
|
||||
"ms_p95": NaN,
|
||||
"train_size": 46546
|
||||
"ms_p50": 40.1726000054623,
|
||||
"ms_p95": 42.31233000136854,
|
||||
"train_size": 36083
|
||||
},
|
||||
{
|
||||
"bag": "squareT_platform_squareT_switch",
|
||||
"frames": 239,
|
||||
"path_m": 270.98139848673645,
|
||||
"alarm_frames": 48,
|
||||
"alarm_rate": 0.200836820083682,
|
||||
"fp_objects": 48,
|
||||
"fp_tracks": 3,
|
||||
"fp_per_km": 11.070870608658545,
|
||||
"fp_median_d": 93.17164512677327,
|
||||
"alarm_frames": 40,
|
||||
"alarm_rate": 0.16736401673640167,
|
||||
"fp_objects": 40,
|
||||
"fp_tracks": 2,
|
||||
"fp_per_km": 7.380580405772363,
|
||||
"fp_median_d": 104.76858112973235,
|
||||
"obj_rate": null,
|
||||
"ms_p50": NaN,
|
||||
"ms_p95": NaN,
|
||||
"train_size": 44039
|
||||
"ms_p50": 37.06099999908474,
|
||||
"ms_p95": 39.86911000647524,
|
||||
"train_size": 33658
|
||||
}
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -1,237 +0,0 @@
|
|||
services:
|
||||
# ============================================================================
|
||||
# FlyGuard: Универсальный производственный запуск (NVIDIA GPU / CUDA 12)
|
||||
# Поддерживает Ada Lovelace (RTX 4070 Ti Super 16GB) / Ampere / CPU Fallback
|
||||
# ============================================================================
|
||||
|
||||
# --- Главный сервис общего запуска конвейера по всем бэгам лидара ---
|
||||
pipeline:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: flyguard:latest
|
||||
container_name: flyguard-pipeline
|
||||
command: ["pipeline", "--all", "--verbose"]
|
||||
volumes:
|
||||
- ./artifacts:/app/artifacts
|
||||
- ./data:/data:ro
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- FLYGUARD_DATA=/data
|
||||
- FLYGUARD_DEVICE=auto
|
||||
- NVIDIA_VISIBLE_DEVICES=all
|
||||
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities: [gpu]
|
||||
shm_size: '8gb'
|
||||
|
||||
# --- Полный прогон тестового набора ядра и роутинга устройств ---
|
||||
test:
|
||||
image: flyguard:latest
|
||||
container_name: flyguard-test
|
||||
command: ["test"]
|
||||
volumes:
|
||||
- ./artifacts:/app/artifacts
|
||||
- ./data:/data:ro
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- FLYGUARD_DATA=/data
|
||||
- FLYGUARD_DEVICE=auto
|
||||
- NVIDIA_VISIBLE_DEVICES=all
|
||||
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities: [gpu]
|
||||
shm_size: '8gb'
|
||||
|
||||
# --- Диагностика доступности GPU и характеристик оборудования ---
|
||||
info:
|
||||
image: flyguard:latest
|
||||
container_name: flyguard-info
|
||||
command: ["info"]
|
||||
environment:
|
||||
- NVIDIA_VISIBLE_DEVICES=all
|
||||
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities: [gpu]
|
||||
|
||||
# --- Оценка метрик детекции (Folds Cross-Validation / AUC) ---
|
||||
evaluate:
|
||||
image: flyguard:latest
|
||||
container_name: flyguard-evaluate
|
||||
command: ["evaluate", "--mbon-dir", "artifacts/mbon_folds", "--device", "auto"]
|
||||
volumes:
|
||||
- ./artifacts:/app/artifacts
|
||||
- ./data:/data:ro
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- FLYGUARD_DATA=/data
|
||||
- NVIDIA_VISIBLE_DEVICES=all
|
||||
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities: [gpu]
|
||||
shm_size: '8gb'
|
||||
|
||||
# --- Генерация синтетического бенчмарка с GPU DoG и MBON ---
|
||||
benchmark:
|
||||
image: flyguard:latest
|
||||
container_name: flyguard-benchmark
|
||||
command: [
|
||||
"benchmark",
|
||||
"--memory", "artifacts/mushroom_body.npz",
|
||||
"--mbon-dir", "artifacts/mbon_folds",
|
||||
"--augment",
|
||||
"--device", "auto",
|
||||
"--out", "artifacts/benchmark_gpu.json"
|
||||
]
|
||||
volumes:
|
||||
- ./artifacts:/app/artifacts
|
||||
- ./data:/data:ro
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- FLYGUARD_DATA=/data
|
||||
- NVIDIA_VISIBLE_DEVICES=all
|
||||
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities: [gpu]
|
||||
shm_size: '8gb'
|
||||
|
||||
# --- Обучение MBON Readout на 50 000 клеток Кеньона на GPU ---
|
||||
train-mbon:
|
||||
image: flyguard:latest
|
||||
container_name: flyguard-train-mbon
|
||||
command: [
|
||||
"train-mbon",
|
||||
"--device", "auto",
|
||||
"--n-kc", "50000",
|
||||
"--active", "100",
|
||||
"--epochs", "100",
|
||||
"--save-folds", "artifacts/mbon_folds_50k",
|
||||
"--out", "artifacts/mbon_readout_50k.npz"
|
||||
]
|
||||
volumes:
|
||||
- ./artifacts:/app/artifacts
|
||||
- ./data:/data
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- FLYGUARD_DATA=/data
|
||||
- NVIDIA_VISIBLE_DEVICES=all
|
||||
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities: [gpu]
|
||||
shm_size: '8gb'
|
||||
|
||||
# --- Обучение классификатора треков TrackReadout ---
|
||||
train-track:
|
||||
image: flyguard:latest
|
||||
container_name: flyguard-train-track
|
||||
command: [
|
||||
"train-track",
|
||||
"--device", "auto",
|
||||
"--epochs", "300",
|
||||
"--baseline",
|
||||
"--save-folds", "artifacts/track_folds",
|
||||
"--out", "artifacts/track_readout.npz"
|
||||
]
|
||||
volumes:
|
||||
- ./artifacts:/app/artifacts
|
||||
- ./data:/data
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- FLYGUARD_DATA=/data
|
||||
- NVIDIA_VISIBLE_DEVICES=all
|
||||
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities: [gpu]
|
||||
shm_size: '8gb'
|
||||
|
||||
# --- Интерактивная Bash-сессия разработчика ---
|
||||
shell:
|
||||
image: flyguard:latest
|
||||
container_name: flyguard-shell
|
||||
command: ["bash"]
|
||||
volumes:
|
||||
- ./artifacts:/app/artifacts
|
||||
- ./data:/data
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- FLYGUARD_DATA=/data
|
||||
- NVIDIA_VISIBLE_DEVICES=all
|
||||
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities: [gpu]
|
||||
stdin_open: true
|
||||
tty: true
|
||||
shm_size: '8gb'
|
||||
|
||||
# ============================================================================
|
||||
# СЕРВИСЫ ДЛЯ ЧИСТОГО CPU (Stand-alone без NVIDIA GPU)
|
||||
# ============================================================================
|
||||
cpu-test:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: cpu
|
||||
image: flyguard:cpu
|
||||
container_name: flyguard-cpu-test
|
||||
command: ["test"]
|
||||
volumes:
|
||||
- ./artifacts:/app/artifacts
|
||||
- ./data:/data:ro
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- FLYGUARD_DATA=/data
|
||||
- FLYGUARD_DEVICE=cpu
|
||||
shm_size: '2gb'
|
||||
|
||||
cpu-pipeline:
|
||||
image: flyguard:cpu
|
||||
container_name: flyguard-cpu-pipeline
|
||||
command: ["pipeline", "--all"]
|
||||
volumes:
|
||||
- ./artifacts:/app/artifacts
|
||||
- ./data:/data:ro
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- FLYGUARD_DATA=/data
|
||||
- FLYGUARD_DEVICE=cpu
|
||||
shm_size: '2gb'
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
# ==============================================================================
|
||||
# FlyGuard: Bionic Obstacle Detection Core (LCT-2026, Case 05)
|
||||
# Hesai Pandar128 LiDAR (128 beams, 10 Hz / 100ms)
|
||||
# Universal Entrypoint: NVIDIA GPU Acceleration (CUDA 12.x) with CPU Fallback
|
||||
# ==============================================================================
|
||||
|
||||
print_banner() {
|
||||
echo "============================================================================"
|
||||
echo " FlyGuard · Блиц-обнаружение препятствий в тоннеле метро (Кейс 05, ЛЦТ-2026)"
|
||||
echo " Hesai Pandar128 (128 лучей, 10 Гц / 100 мс) · NVIDIA CUDA 12 / CPU Fallback"
|
||||
echo "============================================================================"
|
||||
}
|
||||
|
||||
print_device_info() {
|
||||
python3 -c "
|
||||
try:
|
||||
import flyguard.device as dev
|
||||
info = dev.get_device_info()
|
||||
if info['cuda_available']:
|
||||
print(f'==> [HARDWARE] NVIDIA GPU: {info[\"device_name\"]} (Compute {info[\"compute_capability\"]}, {info[\"total_memory_gb\"]} GB VRAM)')
|
||||
print(f'==> [ACCELERATION] CUDA 12: АКТИВНО (PyTorch {info[\"torch_version\"]})')
|
||||
else:
|
||||
print('==> [HARDWARE] CPU Mode: CUDA недоступна или не передана в контейнер.')
|
||||
print('==> [FALLBACK] Активирован автоматический Graceful Fallback на CPU.')
|
||||
except Exception as e:
|
||||
print(f'==> Проверка окружения: {e}')
|
||||
"
|
||||
}
|
||||
|
||||
CMD="${1:-default}"
|
||||
|
||||
case "$CMD" in
|
||||
default|run)
|
||||
print_banner
|
||||
print_device_info
|
||||
DATA_DIR="${FLYGUARD_DATA:-/data}"
|
||||
FOUND_BAG=$(find "$DATA_DIR" -name '*.db3' 2>/dev/null | head -n 1 || true)
|
||||
if [ -d "$DATA_DIR" ] && [ -n "$FOUND_BAG" ]; then
|
||||
echo "==> Обнаружены бэги лидара в $DATA_DIR. Запуск конвейера FlyGuard по всем записям..."
|
||||
exec python3 tools/run_pipeline.py --all \
|
||||
--memory artifacts/mushroom_body.npz \
|
||||
--readout artifacts/mbon_readout.npz \
|
||||
--device auto \
|
||||
--verbose
|
||||
else
|
||||
echo "==> В $DATA_DIR не найдены файлы .db3 (датасет не смонтирован через -v /путь/к/data:/data)."
|
||||
echo "==> Выполняется системный прогон верификационных тестов и роутинга..."
|
||||
exec python3 tests/run_tests.py
|
||||
fi
|
||||
;;
|
||||
pipeline|run-pipeline)
|
||||
shift
|
||||
print_banner
|
||||
print_device_info
|
||||
exec python3 tools/run_pipeline.py \
|
||||
--memory artifacts/mushroom_body.npz \
|
||||
--readout artifacts/mbon_readout.npz \
|
||||
--device auto "$@"
|
||||
;;
|
||||
benchmark|make-benchmark)
|
||||
shift
|
||||
print_banner
|
||||
print_device_info
|
||||
exec python3 tools/make_benchmark.py \
|
||||
--memory artifacts/mushroom_body.npz \
|
||||
--mbon-dir artifacts/mbon_folds \
|
||||
--device auto "$@"
|
||||
;;
|
||||
evaluate)
|
||||
shift
|
||||
print_banner
|
||||
print_device_info
|
||||
exec python3 tools/evaluate.py \
|
||||
--mbon-dir artifacts/mbon_folds \
|
||||
--device auto "$@"
|
||||
;;
|
||||
train-mbon)
|
||||
shift
|
||||
print_banner
|
||||
print_device_info
|
||||
exec python3 tools/train_mbon.py \
|
||||
--device auto "$@"
|
||||
;;
|
||||
train-track)
|
||||
shift
|
||||
print_banner
|
||||
print_device_info
|
||||
exec python3 tools/train_track.py \
|
||||
--device auto "$@"
|
||||
;;
|
||||
test|tests|run-tests)
|
||||
shift
|
||||
print_banner
|
||||
print_device_info
|
||||
exec python3 tests/run_tests.py "$@"
|
||||
;;
|
||||
pytest)
|
||||
shift
|
||||
exec pytest tests "$@"
|
||||
;;
|
||||
info|device|gpu|gpu-info)
|
||||
print_banner
|
||||
python3 -c "
|
||||
import json
|
||||
import flyguard.device as dev
|
||||
print(json.dumps(dev.get_device_info(), indent=2, ensure_ascii=False))
|
||||
"
|
||||
;;
|
||||
bash|sh)
|
||||
shift
|
||||
exec /bin/bash "$@"
|
||||
;;
|
||||
*)
|
||||
exec "$@"
|
||||
;;
|
||||
esac
|
||||
134
docker-run.sh
134
docker-run.sh
|
|
@ -1,134 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# FlyGuard: Удобный скрипт сборки и запуска Docker-контейнера
|
||||
# Автоматически определяет наличие NVIDIA GPU и передает флаги --gpus all
|
||||
# ==============================================================================
|
||||
set -e
|
||||
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
IMAGE_NAME="flyguard:latest"
|
||||
DATA_DIR="${FLYGUARD_DATA:-$DIR/data}"
|
||||
ARTIFACTS_DIR="$DIR/artifacts"
|
||||
|
||||
mkdir -p "$DATA_DIR" "$ARTIFACTS_DIR"
|
||||
|
||||
# Определение наличия драйвера NVIDIA
|
||||
GPU_FLAG=""
|
||||
if command -v nvidia-smi &>/dev/null; then
|
||||
GPU_FLAG="--gpus all"
|
||||
fi
|
||||
|
||||
usage() {
|
||||
echo "Использование: $0 [команда]"
|
||||
echo ""
|
||||
echo "Команды:"
|
||||
echo " build Собрать универсальный образ с поддержкой NVIDIA GPU ($IMAGE_NAME)"
|
||||
echo " build-cpu Собрать облегченный CPU-образ (flyguard:cpu)"
|
||||
echo " run Общий запуск конвейера (по умолчанию обработка всех бэгов)"
|
||||
echo " pipeline Запуск конвейера с дополнительными аргументами"
|
||||
echo " test Запуск всех 40+ тестов верификации"
|
||||
echo " benchmark Генерация бенчмарка с ускорением на GPU"
|
||||
echo " evaluate Оценка качества моделей и кросс-валидация"
|
||||
echo " train-mbon Обучение клеток Кеньона (50 000 KC) на GPU"
|
||||
echo " train-track Обучение классификатора треков"
|
||||
echo " info Вывод телеметрии и статуса GPU из контейнера"
|
||||
echo " shell Интерактивная командная строка (bash) внутри контейнера"
|
||||
echo ""
|
||||
echo "Примеры:"
|
||||
echo " $0 build"
|
||||
echo " $0 run"
|
||||
echo " $0 pipeline --all --verbose"
|
||||
echo " $0 train-mbon --device cuda --n-kc 50000"
|
||||
}
|
||||
|
||||
CMD="${1:-run}"
|
||||
|
||||
case "$CMD" in
|
||||
build)
|
||||
echo "==> Сборка универсального образа FlyGuard ($IMAGE_NAME)..."
|
||||
docker build -t "$IMAGE_NAME" -f "$DIR/Dockerfile" "$DIR"
|
||||
;;
|
||||
build-cpu)
|
||||
echo "==> Сборка облегченного CPU-образа FlyGuard (flyguard:cpu)..."
|
||||
docker build --target cpu -t flyguard:cpu -f "$DIR/Dockerfile" "$DIR"
|
||||
;;
|
||||
run|default)
|
||||
shift 2>/dev/null || true
|
||||
echo "==> Запуск контейнера FlyGuard (GPU: ${GPU_FLAG:-CPU Fallback})..."
|
||||
docker run --rm -it $GPU_FLAG \
|
||||
-v "$DATA_DIR":/data:ro \
|
||||
-v "$ARTIFACTS_DIR":/app/artifacts \
|
||||
--shm-size=8g \
|
||||
"$IMAGE_NAME" "$@"
|
||||
;;
|
||||
pipeline)
|
||||
shift
|
||||
docker run --rm -it $GPU_FLAG \
|
||||
-v "$DATA_DIR":/data:ro \
|
||||
-v "$ARTIFACTS_DIR":/app/artifacts \
|
||||
--shm-size=8g \
|
||||
"$IMAGE_NAME" pipeline "$@"
|
||||
;;
|
||||
test|tests)
|
||||
shift
|
||||
docker run --rm -it $GPU_FLAG \
|
||||
-v "$DATA_DIR":/data:ro \
|
||||
-v "$ARTIFACTS_DIR":/app/artifacts \
|
||||
--shm-size=8g \
|
||||
"$IMAGE_NAME" test "$@"
|
||||
;;
|
||||
benchmark)
|
||||
shift
|
||||
docker run --rm -it $GPU_FLAG \
|
||||
-v "$DATA_DIR":/data:ro \
|
||||
-v "$ARTIFACTS_DIR":/app/artifacts \
|
||||
--shm-size=8g \
|
||||
"$IMAGE_NAME" benchmark "$@"
|
||||
;;
|
||||
evaluate)
|
||||
shift
|
||||
docker run --rm -it $GPU_FLAG \
|
||||
-v "$DATA_DIR":/data:ro \
|
||||
-v "$ARTIFACTS_DIR":/app/artifacts \
|
||||
--shm-size=8g \
|
||||
"$IMAGE_NAME" evaluate "$@"
|
||||
;;
|
||||
train-mbon)
|
||||
shift
|
||||
docker run --rm -it $GPU_FLAG \
|
||||
-v "$DATA_DIR":/data \
|
||||
-v "$ARTIFACTS_DIR":/app/artifacts \
|
||||
--shm-size=8g \
|
||||
"$IMAGE_NAME" train-mbon "$@"
|
||||
;;
|
||||
train-track)
|
||||
shift
|
||||
docker run --rm -it $GPU_FLAG \
|
||||
-v "$DATA_DIR":/data \
|
||||
-v "$ARTIFACTS_DIR":/app/artifacts \
|
||||
--shm-size=8g \
|
||||
"$IMAGE_NAME" train-track "$@"
|
||||
;;
|
||||
info|gpu|device)
|
||||
docker run --rm $GPU_FLAG "$IMAGE_NAME" info
|
||||
;;
|
||||
shell|bash)
|
||||
shift
|
||||
docker run --rm -it $GPU_FLAG \
|
||||
-v "$DATA_DIR":/data \
|
||||
-v "$ARTIFACTS_DIR":/app/artifacts \
|
||||
--shm-size=8g \
|
||||
"$IMAGE_NAME" bash "$@"
|
||||
;;
|
||||
help|--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
docker run --rm -it $GPU_FLAG \
|
||||
-v "$DATA_DIR":/data:ro \
|
||||
-v "$ARTIFACTS_DIR":/app/artifacts \
|
||||
--shm-size=8g \
|
||||
"$IMAGE_NAME" "$@"
|
||||
;;
|
||||
esac
|
||||
|
|
@ -165,11 +165,6 @@ LC11 у мухи — детектор мелкого объекта: возбу
|
|||
оставшихся внутри габарита (`containment`), сразу показывает, предмет это целиком или край
|
||||
стены. Расширение идёт в стороны и вверх, но **не вниз**: полотно проходит под каждым
|
||||
предметом и на большой дальности попало бы в тот же допуск по глубине.
|
||||
* **пол габарита в колее ниже, но не везде.** Между рельсами (|u| ≤ 0.85 м) нижняя
|
||||
граница 0.16 м вместо 0.28 — иначе упавший на пути человек высотой 0.30 м виден
|
||||
верхушкой в два сантиметра. Ближе 30 м пол прежний: там в полосу 0.16…0.28 м попадают
|
||||
головки рельсов, рельс собирается в одну связную компоненту от самой кабины, и предмет,
|
||||
коснувшийся его, выбрасывается вместе с ним (EXPERIMENTS п. 16.3).
|
||||
|
||||
Для каждого кандидата считаются: дальность, смещение от оси, высота нижней точки, габариты,
|
||||
протяжённость вдоль пути, целостность, число лучей и колец, контраст, дефицит до пола,
|
||||
|
|
@ -274,16 +269,6 @@ $$q = \underbrace{\min\!\left(\frac{n_\text{лучей}}{n_\text{ожид}(d)},1
|
|||
и экстренное торможение по надёжному близкому объекту. Пороги с гистерезисом — без него трек
|
||||
на границе даёт дребезг, а дребезжащая команда торможения хуже её отсутствия.
|
||||
|
||||
Порог предупреждения зависит от дальности: до 90 м он 0.5, дальше линейно сходит к 0.3 на
|
||||
200 м. Далёкая улика физически слабее — четыре-восемь лучей вместо сотни, — а цена ошибки ниже:
|
||||
до предмета ещё сотня метров и десятки кадров на подтверждение. Экстренный порог не снижается
|
||||
никогда. Это подняло обнаружение на 150 м с 0.33 до 0.37 без потерь ближе (EXPERIMENTS п. 15.9).
|
||||
|
||||
Решение принимается по улике трека, а не по среднему качеству его наблюдений, хотя улика
|
||||
насыщается: у всего, что видно дольше пары секунд, она равна единице. Проверено три способа
|
||||
учесть среднее — обученное считывание по треку, жёсткий порог и смешивание, — и ни один не
|
||||
дал размена лучше простого порога на незнакомой линии (EXPERIMENTS п. 15.5–15.8).
|
||||
|
||||
Тормозной путь считается как `v·t_реакции + v²/(2a)` и сравнивается с дистанцией до объекта.
|
||||
|
||||
---
|
||||
|
|
@ -294,12 +279,10 @@ $$q = \underbrace{\min\!\left(\frac{n_\text{лучей}}{n_\text{ожид}(d)},1
|
|||
|---|---|
|
||||
| `half_width` | шире габарит → больше находок и больше ложных на кромках платформ |
|
||||
| `h_lo` | выше порог → перестают ловиться низкие предметы (кабель, камень), меньше ложных от полотна |
|
||||
| `h_lo_core` / `core_from` | пол между рельсами и дальность, с которой он опущен. Пол ниже → виден лежащий человек (0.30 м), но ближе 30 м в полосу 0.16…0.28 м попадают головки рельсов, и предмет выбрасывается вместе с рельсом (EXPERIMENTS п. 16.3) |
|
||||
| `min_rays` | строже → пропадают дальние мелкие объекты, падает поток кандидатов |
|
||||
| `fov_deg` | шире сектор → лучше кривые и стрелки, дороже обработка |
|
||||
| `gain` / `leak` (CX) | быстрее подтверждение против устойчивости к шуму |
|
||||
| `warn_evidence` | выше → меньше ложных тревог, позже обнаружение |
|
||||
| `mbon_power` | вес наблюдения — вероятность обученного считывания в этой степени. Выше → строже: 1.0 → 1.5 срезает ложные на незнакомой линии с 23.5 до 14.7 на км ценой хвоста дальности за 130 м; 2.0 почти ничего не добавляет к ложным и теряет вдвое больше (EXPERIMENTS п. 17.4) |
|
||||
| темп депрессии MB | выше → сильнее подавление знакомого, но риск заглушить и настоящее |
|
||||
|
||||
---
|
||||
|
|
|
|||
106
docs/DOCKER.md
106
docs/DOCKER.md
|
|
@ -1,106 +0,0 @@
|
|||
# Руководство по запуску FlyGuard в Docker (NVIDIA GPU + Graceful CPU Fallback)
|
||||
|
||||
FlyGuard поставляется в виде универсального производственного Docker-образа с поддержкой аппаратного ускорения **NVIDIA GPU (CUDA 12.x)** и автоматического гибридного переключения на **CPU (Graceful Fallback)** при отсутствии или сбое графического процессора.
|
||||
|
||||
---
|
||||
|
||||
## 1. Быстрый запуск через helper-скрипт `docker-run.sh`
|
||||
|
||||
Скрипт [`docker-run.sh`](file:///home/wpng1337/Brainrot_Muxa/docker-run.sh) автоматически проверяет наличие `nvidia-smi` и подключает флаги `--gpus all`:
|
||||
|
||||
```bash
|
||||
# Сборка универсального образа (flyguard:latest)
|
||||
./docker-run.sh build
|
||||
|
||||
# Общий запуск конвейера (по всем бэгам в data/)
|
||||
./docker-run.sh run
|
||||
|
||||
# Проверка видимости GPU и драйвера внутри контейнера
|
||||
./docker-run.sh info
|
||||
|
||||
# Прогон всех 41 верификационных unit-тестов
|
||||
./docker-run.sh test
|
||||
|
||||
# Обучение клеток Кеньона (50 000 KC) на GPU
|
||||
./docker-run.sh train-mbon --device cuda --n-kc 50000
|
||||
|
||||
# Генерация бенчмарка дальности с аугментациями
|
||||
./docker-run.sh benchmark
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Сборка образов напрямую через `docker build`
|
||||
|
||||
### Универсальный образ с GPU-ускорением (по умолчанию)
|
||||
```bash
|
||||
docker build -t flyguard:latest .
|
||||
```
|
||||
* **База**: `nvidia/cuda:12.4.1-runtime-ubuntu22.04`
|
||||
* **Python**: 3.11, PyTorch CUDA 12.1+, cuDNN, OpenMP `libgomp1`
|
||||
* **Поддерживаемые архитектуры**: Ada Lovelace (RTX 4070 Ti Super 16GB), Ampere, Hopper, Turing.
|
||||
* **Отказоустойчивость**: если запущен без `--gpus all`, прозрачно работает на CPU.
|
||||
|
||||
### Облегченный CPU-образ (опционально, ~200 МБ)
|
||||
```bash
|
||||
docker build --target cpu -t flyguard:cpu .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Запуск через Docker Compose
|
||||
|
||||
В [`docker-compose.yml`](file:///home/wpng1337/Brainrot_Muxa/docker-compose.yml) настроены готовые сервисы с автоматической резервацией GPU:
|
||||
|
||||
```bash
|
||||
# 1. Главный сервис: сквозная обработка данных лидара
|
||||
docker compose up
|
||||
# или
|
||||
docker compose run --rm pipeline
|
||||
|
||||
# 2. Проверка оборудования
|
||||
docker compose run --rm info
|
||||
|
||||
# 3. Полный набор unit-тестов
|
||||
docker compose run --rm test
|
||||
|
||||
# 4. Высокопроизводительное обучение MBON на 50k клеток
|
||||
docker compose run --rm train-mbon
|
||||
|
||||
# 5. Оценка метрик детекции и ложных тревог
|
||||
docker compose run --rm evaluate
|
||||
|
||||
# 6. Интерактивная отладка в bash
|
||||
docker compose run --rm shell
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Прямой запуск через `docker run`
|
||||
|
||||
### Запуск с аппаратным ускорением NVIDIA GPU:
|
||||
```bash
|
||||
docker run --rm -it \
|
||||
--gpus all \
|
||||
-v /path/to/lidar/data:/data:ro \
|
||||
-v $(pwd)/artifacts:/app/artifacts \
|
||||
--shm-size=8g \
|
||||
flyguard:latest pipeline --all --verbose
|
||||
```
|
||||
|
||||
### Запуск на CPU (стенд жюри без GPU / fallback):
|
||||
```bash
|
||||
docker run --rm -it \
|
||||
-v /path/to/lidar/data:/data:ro \
|
||||
-v $(pwd)/artifacts:/app/artifacts \
|
||||
--shm-size=4g \
|
||||
flyguard:latest pipeline --all --verbose
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Архитектура Graceful Fallback на CPU
|
||||
|
||||
Конвейер использует гибридную схему вычислений:
|
||||
1. **Статический выбор**: при инициализации `device = "cuda" if is_cuda_available() else "cpu"`.
|
||||
2. **Динамический рантайм-откат**: если в процессе работы видеокарта возвращает ошибку (CUDA OOM, отвал драйвера), модуль [`flyguard/device.py`](file:///home/wpng1337/Brainrot_Muxa/flyguard/device.py) фиксирует сбой через `notify_cuda_error(exc)`, текущий кадр мгновенно досчитывается на CPU через NumPy/SciPy, а конвейер переходит в режим CPU без падения программы.
|
||||
1440
docs/EXPERIMENTS.md
1440
docs/EXPERIMENTS.md
File diff suppressed because it is too large
Load diff
|
|
@ -1,254 +0,0 @@
|
|||
# Комплексный план развития FlyGuard · Хакатон ЛЦТ-2026 (Кейс 05)
|
||||
|
||||
Документ фиксирует согласованную архитектурную стратегию и детальный план доработок ядра `FlyGuard` для максимизации качества решения на хакатоне ЛЦТ-2026 («Обнаружение посторонних объектов в тоннеле метро по данным 3D-лидара»).
|
||||
|
||||
---
|
||||
|
||||
## 1. Контекст и текущее состояние
|
||||
|
||||
Система [`FlyGuard`](../flyguard/pipeline.py) построена на принципах зрительной системы и грибовидных тел дрозофилы:
|
||||
```
|
||||
облако точек 0.3–0.9 млн, 10 Гц
|
||||
│
|
||||
├─ RETINA омматидиальная решётка → дальностный образ 128 × N
|
||||
├─ HALTERES плоскость рельсов: крен, тангаж, высота сенсора
|
||||
├─ LAMINA диспаритет 1/R → ON/OFF, центр-окружение на 3 масштабах
|
||||
├─ MEDULLA / LP T4/T5 → LPTC: скорость поезда без одометрии
|
||||
├─ LOBULA LC11: кандидаты; разрез компоненты по контрасту
|
||||
├─ MUSHROOM BODY KC → APL → MBON: новизна формы (без меток)
|
||||
│ + обученное считывание MBON (с метками из физики)
|
||||
├─ FAN-SHAPED BODY накопление лучей в координатах пути
|
||||
├─ CENTRAL COMPLEX накопление улик в координатах пути, треки
|
||||
└─ DESCENDING два порога с гистерезисом → решение
|
||||
```
|
||||
|
||||
### Текущие измеренные показатели (Ryzen 7600X, Hesai Pandar128):
|
||||
* **Реальный объект на 55 м (`doubleT_obstacle`)**: **99.5 %** кадров с детекцией.
|
||||
* **Задержка кадра на CPU**: **32–33 мс** при лимите 100 мс (запас ~3×).
|
||||
* **Ложные тревоги (leave-one-bag-out)**: **8.0 трека/км** (11.0 % кадров).
|
||||
* **Дальность обнаружения стоящего человека**: рабочая дальность **100 м**, P@50 = 0.70, **P@100 = 0.53, P@150 = 0.33**.
|
||||
|
||||
---
|
||||
|
||||
### Главные узкие места, выявленные в ходе анализа:
|
||||
|
||||
1. **Застревание улики трека на 120–160 м**:
|
||||
На 150 м кандидат появляется в ~62 % кадров, но от предмета приходит лишь 4–6 лучей. Покадровое считывание MBON выдаёт малое $p \approx 0.05$ (из-за априорной редкости), улика прирастает на $+gain \cdot w \approx +0.017$, а утечка за промах отнимает $-leak = -0.12$. В итоге $evidence$ колеблется около нуля, и порог $0.5$ никогда не пробивается.
|
||||
2. **Артефакты синтетики в [`TrackReadout`](../flyguard/track_readout.py)**:
|
||||
Признаки $s_{std}$ и $u_{std}$ перевернулись на реальном объекте ($0.365$ м у человека против $0.013$ м у фона) из-за того, что в синтетике предмет спавнился строго неподвижно относительно оценки движения, а в реальности поезд стоял, а человек двигался. Признаки $closed$ и $age$ заучили фиксированный спавн с 200 м. В текущей версии `track_blend = 0.0` (выключен).
|
||||
3. **Неиспользуемая неопределённость коридора [`Corridor.sigma`](../flyguard/geometry.py)**:
|
||||
Ось коридора на кривой за горизонтом видимости (62–107 м) продолжается по касательной и расходится с истинным путем до 1.19 м на 150 м. Полуширина габарита жестко зафиксирована на $1.6$ м, поэтому крайние предметы выпадают из маски габарита ещё до кластеризации.
|
||||
4. **Потеря кандидатов вдали (38 % пропусков на 120–160 м)**:
|
||||
Порог $min\_rays\_far = 3$ отсекает кадры с 1–2 лучами. При этом накопитель [`FanBody`](../flyguard/fan_body.py) за 15 кадров аккумулирует в вокселе десятки попаданий, но никак не помогает формированию покадрового кандидата.
|
||||
5. **Скачок ложных тревог на новой линии ($8.0 \to 20.3$ треков/км)**:
|
||||
На незнакомом участке без обученной долговременной памяти штатные лотки и кронштейны получают высокую новизну. Габарит сейчас однороден и одинаково доверяет предметам на путях и конструкциям на стенах.
|
||||
6. **Слипание с платформой на 30–50 м**:
|
||||
При смещении предмета $+0.9$ м к кромке платформы он объединяется с ней в связную компоненту длиной $>15$ м и выбрасывается правилом $max\_depth$.
|
||||
7. **Отсутствие лежащего человека**:
|
||||
В [`catalogue`](../flyguard/synth.py) нет класса лежащего человека, а нижняя граница габарита $h_{lo} = 0.28$ м срезает упавшего на рельсы пассажира.
|
||||
|
||||
---
|
||||
|
||||
## 2. Поэтапный план улучшений
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ФАЗА 1: Устранение артефактов данных и синтетики │
|
||||
│ • Аугментация спавна, движения, стоянки в make_benchmark │
|
||||
│ • Добавление «человек_лежа» (1.8×0.5×0.3 м) │
|
||||
│ • Честное переобучение и валидация TrackReadout │
|
||||
└──────────────────────────────┬──────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ФАЗА 2: Пробитие дальней зоны (100–160 м) │
|
||||
│ • Двухканальное решение в DescendingNeurons (soft-warning) │
|
||||
│ • Динамическое расширение габарита по Corridor.sigma(d) │
|
||||
│ • Обратная связь FanBody → Lobula (порог 2 луча по опоре) │
|
||||
└──────────────────────────────┬──────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ФАЗА 3: Обобщаемость на новый тоннель и ближняя зона │
|
||||
│ • Иерархический габарит (опасная колея vs стены/лотки) │
|
||||
│ • Вычитание плоскости платформы (h~1.1 м, |u|>1.2 м) │
|
||||
│ • Понижение h_lo до 0.16 м в межрельсовой зоне │
|
||||
└──────────────────────────────┬──────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ФАЗА 4: ROS2 Экспорт, Замеры и Протокол Стенда Жюри │
|
||||
│ • Модуль экспорта: 3D Bounding Boxes, Threat Level, RViz │
|
||||
│ • Сквозной Leave-one-bag-out замер и стресс-тест на CPU │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Фаза 1: Устранение артефактов данных и синтетики
|
||||
|
||||
**Цель:** сделать выборку для обучения [`TrackReadout`](../flyguard/track_readout.py) инвариантной к реальным условиям, исключить переобучение на искусственные признаки генератора.
|
||||
|
||||
#### 1.1. Аугментация генерации в [`tools/make_benchmark.py`](../tools/make_benchmark.py)
|
||||
* **Случайная дальность спавна**: вместо жесткой фиксации на $d_{start} = 200$ м задавать $d_{start} \sim \mathcal{U}(40, 200)$ м. Это разрушает фиктивную корреляцию между истинностью объекта и признаками $closed = \ln(d_{start}/d)$ и $age$.
|
||||
* **Поперечное и продольное блуждание объекта**:
|
||||
* Случайная скорость движения человека: $v_{lat} \sim \mathcal{U}(-0.4, 0.4)$ м/с.
|
||||
* Реалистичный шум положения $\delta s \sim \mathcal{N}(0, \sigma_s^2)$ с $\sigma_s = 0.15$ м (моделирует погрешность одометрии и скольжение лучей по телу).
|
||||
* Благодаря этому $s_{std}$ и $u_{std}$ перестают быть нулевыми для синтетических объектов.
|
||||
* **Сценарии со стоящим поездом**:
|
||||
* Генерация препятствий в кадры со стоящим поездом ($v = 0$, например, кадры бэга `doubleT_obstacle`), чтобы модель не считала нулевую скорость признаком отсутствия опасности.
|
||||
|
||||
#### 1.2. Добавление класса «человек_лежа» в [`catalogue`](../flyguard/synth.py)
|
||||
* Добавить геометрию:
|
||||
```python
|
||||
"человек_лежа": ObjectModel("человек_лежа", [
|
||||
Box(length=1.80, width=0.50, height=0.30, h_base=0.0)
|
||||
], reflectivity=0.35)
|
||||
```
|
||||
* В [`_quality`](../flyguard/central_complex.py) учесть, что вытянутость вдоль пути ($depth \approx 1.8$ м) при малой высоте ($h \le 0.35$ м) и расположении в межрельсовой зоне ($|u| \le 0.75$ м) не должна штрафоваться множителем $compact$.
|
||||
|
||||
#### 1.3. Переобучение [`TrackReadout`](../tools/train_track.py)
|
||||
* Собрать обновлённый кэш треков:
|
||||
```bash
|
||||
python tools/make_benchmark.py --memory artifacts/mushroom_body.npz \
|
||||
--mbon-dir artifacts/mbon_folds --tracks-out data/cache/track_set_aug.npz
|
||||
```
|
||||
* Обучить [`TrackReadout`](../tools/train_track.py) с контролем ROC AUC по всем полосам (целевой AUC $>0.90$ на полосе 110–160 м).
|
||||
* Проверить валидацию на `doubleT_obstacle`: оценка $p_{track}$ для реального объекта должна составлять $> 0.95$.
|
||||
|
||||
---
|
||||
|
||||
### Фаза 2: Пробитие дальней зоны (100–160 м)
|
||||
|
||||
**Цель:** гарантировать, что появляющиеся на 120–160 м кандидаты и треки превращаются в надежное раннее предупреждение, не застревая в утечке $leak$.
|
||||
|
||||
#### 2.1. Двухканальное решение в [`DescendingNeurons`](../flyguard/descending.py)
|
||||
Биологическая аналогия: разделение Giant Fiber (DNp01, жесткий аварийный порог) и DNp02/DNp11 (раннее предупреждение).
|
||||
|
||||
```python
|
||||
# 1. Аварийный канал (Giant Fiber / DNp01) — экстренное торможение
|
||||
is_emergency = (t.evidence >= self.emergency_evidence) and (d <= stopping_distance)
|
||||
|
||||
# 2. Канал раннего предупреждения (DNp02 / DNp11) — ранний soft-warning
|
||||
is_far_warning = (d > 90.0) and (t.hits >= 2) and (t.p_track >= 0.65)
|
||||
is_near_warning = (d <= 90.0) and (t.evidence >= self.warn_evidence)
|
||||
|
||||
decision.detected = is_emergency or is_far_warning or is_near_warning
|
||||
decision.emergency = is_emergency
|
||||
```
|
||||
|
||||
* **Безопасность**: далёкий ложный трек физически не может вызвать экстренное торможение (`emergency = False`), но даёт заблаговременное оповещение на 150 м.
|
||||
|
||||
#### 2.2. Динамический габарит по [`Corridor.sigma(d)`](../flyguard/geometry.py)
|
||||
В [`geometry.py`](../flyguard/geometry.py) уже заложен расчет неопределенности оси:
|
||||
$$\sigma(d) = base + rate \cdot d + 0.02 \cdot \max(d - d_{max\_seen}, 0)$$
|
||||
На 150 м $\sigma(d) \approx 1.1$ м.
|
||||
|
||||
В функции `gauge_mask` в [`flyguard/lobula.py`](../flyguard/lobula.py):
|
||||
$$W_{eff}(d) = half\_width + k_\sigma \cdot \sigma(d), \quad k_\sigma = 0.8$$
|
||||
* На 50 м: $W_{eff} = 1.6 + 0.8 \cdot 0.45 \approx 1.96$ м.
|
||||
* На 150 м: $W_{eff} = 1.6 + 0.8 \cdot 1.10 \approx 2.48$ м.
|
||||
* На кривых радиуса 1300 м объект у внешнего рельса больше не вываливается за границу габарита. Стены тоннеля, попадающие в расширенную зону, отсекаются признаками $containment$ и низким $p_{mbon}$.
|
||||
|
||||
#### 2.3. Обратная связь [`FanBody`](../flyguard/fan_body.py) $\to$ [`find_candidates`](../flyguard/lobula.py)
|
||||
[`FanBody`](../flyguard/fan_body.py) аккумулирует лучи в воксельной сетке пути $2.0 \times 0.2 \times 0.25$ м.
|
||||
В [`find_candidates`](../flyguard/lobula.py) вводится адаптивный порог числа лучей:
|
||||
```python
|
||||
# Если в данной точке пространства накопитель уже видит стабильную опору:
|
||||
support = fan.support_at(d_comp, u_comp) if fan is not None else 0.0
|
||||
need_rays = 2 if (d_comp >= 90.0 and support >= 0.6) else (3 if d_comp >= 90.0 else 4)
|
||||
```
|
||||
* Снижает пропуски кандидатов на 120–160 м с 38 % до $< 15$ % для слабых эхо.
|
||||
|
||||
---
|
||||
|
||||
### Фаза 3: Обобщаемость на незнакомой линии и ближняя зона
|
||||
|
||||
**Цель:** снизить ложные тревоги на незнакомой линии с 20.3 до $<10$ треков/км и закрыть потерю объектов возле платформ на 30–50 м.
|
||||
|
||||
#### 3.1. Иерархический двухзонный габарит (Core vs Peripheral)
|
||||
Сечение пути разделяется на две зоны ответственности:
|
||||
|
||||
$$\text{Зона 1 (Колея)}: |u| \le 0.90\,\text{м}, \quad h \in [0.16, 1.90]\,\text{м}$$
|
||||
$$\text{Зона 2 (Периферия)}: 0.90 < |u| \le 1.60\,\text{м} \quad \text{или} \quad h > 1.90\,\text{м}$$
|
||||
|
||||
1. **Для Зоны 1 (Колея)**:
|
||||
* Нижний порог $h_{lo}$ снижается до **$0.16$ м** (захватывает лежащего человека).
|
||||
* Множитель компактности в [`_quality`](../flyguard/central_complex.py) не штрафует протяженность $depth$ до $2.0$ м при малой высоте ($h \le 0.35$ м).
|
||||
* Максимальный приоритет подтверждения трека ($gain = 0.40$).
|
||||
2. **Для Зоны 2 (Периферия)**:
|
||||
* Пространство кабельных лотков, сигналов и кронштейнов.
|
||||
* Требование опоры на полотно: $grounded \le 0.3$ для висящих на стенах объектов подавляет их прирост улики.
|
||||
* Для формирования трека требуется $p_{mbon} \ge 0.50$.
|
||||
* **Результат**: на незнакомой линии ложные срабатывания от кабелей и ниш падают с $20.3$ до $< 10.0$ на км.
|
||||
|
||||
#### 3.2. Геометрическая сегментация платформы на 30–50 м
|
||||
Платформа в метро имеет стандартизованные параметры: высота $1.10 \pm 0.05$ м над рельсом, удаление $|u| \ge 1.35$ м.
|
||||
В [`find_candidates`](../flyguard/lobula.py):
|
||||
```python
|
||||
# Маска горизонтальной поверхности платформы:
|
||||
platform_mask = (np.abs(lat) > 1.30) & (tf.h >= 1.05) & (tf.h <= 1.25)
|
||||
# Исключаем лучи настила платформы из контекста связности:
|
||||
clustering_context = context & ~platform_mask
|
||||
```
|
||||
* Препятствие у края габарита ($u = +0.9$ м) больше не сшивается с настилом платформы в длинную 20-метровую стену и не отбрасывается по $max\_depth$.
|
||||
|
||||
---
|
||||
|
||||
### Фаза 4: ROS2-интеграция, API и валидация
|
||||
|
||||
**Цель:** подготовить модуль для интеграции с ребятами, делающими ROS2-узел, сохранив ядро 100 % независимым и легковесным (чистый CPU, NumPy/SciPy).
|
||||
|
||||
#### 4.1. Модуль экспорта для ROS2 ([`flyguard/export.py`](../flyguard/pipeline.py))
|
||||
Обеспечивает чистый интерфейс между ядром и ROS2-нодой:
|
||||
* Преобразование треков [`Track`](../flyguard/central_complex.py) в 3D Bounding Boxes в координатах сенсора `(x, y, z, dx, dy, dz, yaw)`.
|
||||
* Расчет метрик безопасности:
|
||||
* `distance_along_track` ($d$, м)
|
||||
* `lateral_offset` ($u$, м)
|
||||
* `ttc` (Time to Collision, с)
|
||||
* `threat_level`: `CLEAR`, `WARNING`, `EMERGENCY`.
|
||||
* Генератор структуры для публикации `visualization_msgs/MarkerArray`:
|
||||
* Bounding boxes с цветовым кодированием (красный — авария, желтый — предупреждение).
|
||||
* 3D линия оси коридора тоннеля.
|
||||
* Текстовые метки с расстоянием и скоростью сближения над объектами.
|
||||
|
||||
#### 4.2. Контроль рантайма на CPU
|
||||
* **Зависимости**: строго `numpy` и `scipy` в рантайме.
|
||||
* **Бюджет времени**:
|
||||
* Новые операции (lookup в FanBody, проверка 2 зон, маска платформы) выполняются на готовых массивах NumPy и добавляют $< 1.2$ мс.
|
||||
* Ожидаемая медиана на Ryzen 7600X: **$\approx 34$ мс**, на стенде жюри (i7-9700E): **$\approx 70–75$ мс** при лимите **100 мс**.
|
||||
|
||||
---
|
||||
|
||||
## 3. Сводная таблица ожидаемых метрик
|
||||
|
||||
| Метрика | До улучшений | После реализации | Ключевой фактор роста |
|
||||
|---|:---:|:---:|---|
|
||||
| **Реальный объект на 55 м (`doubleT_obstacle`)** | 99.5 % | **> 99.5 %** | Сохранение надежности канала Giant Fiber |
|
||||
| **P@150 м (стоящий человек)** | 0.33 | **> 0.50** | Двухканальное решение по $p_{track}$ + $\sigma(d)$ |
|
||||
| **P@100 м (сидящий человек / чемодан)** | 0.34 / 0.21 | **> 0.55 / 0.40** | Порог 2 лучей при накоплении в [`FanBody`](../flyguard/fan_body.py) |
|
||||
| **P@50 м (лежащий человек)** | 0.0 % (срезался) | **> 0.75** | $h_{lo} = 0.16$ м в колее + класс в каталоге |
|
||||
| **Ложные тревоги (незнакомая линия)** | 20.3 на км | **< 10.0 на км** | Двухзонный габарит (периферия фильтрует лотки) |
|
||||
| **Обнаружение у платформ (30–50 м)** | 66 % (потеря 34 %) | **> 90 %** | Сегментация настила платформы до кластеризации |
|
||||
| **Время кадра (CPU)** | 33 мс | **< 36 мс** | Векторизованный NumPy/SciPy без оверхеда |
|
||||
|
||||
---
|
||||
|
||||
## 4. Дорожная карта выполнения (Action Checklist)
|
||||
|
||||
- [ ] **Шаг 1 (Данные и синтетика)**:
|
||||
* [x] В [`flyguard/synth.py`](../flyguard/synth.py) добавлен класс `человек_лежа`.
|
||||
* [x] В [`flyguard/central_complex.py`](../flyguard/central_complex.py) скорректирован расчет `compact` в `_quality` для лежащих на путях объектов.
|
||||
* [ ] В [`tools/make_benchmark.py`](../tools/make_benchmark.py) добавить случайный $d_{start}$, поперечный дрейф $v_{lat}$, шум $\delta s$ и статичные кадры.
|
||||
* [ ] Пересобрать кэш `track_set.npz` и переобучить [`TrackReadout`](../tools/train_track.py).
|
||||
- [x] **Шаг 2 (Дальняя зона)**:
|
||||
* [x] В [`flyguard/descending.py`](../flyguard/descending.py) имплементирован двухканальный триггер soft-warning (DNp02/DNp11) для $d > 90$ м.
|
||||
* [x] В [`flyguard/lobula.py`](../flyguard/lobula.py) применён динамический габарит $W_{eff}(d) = half\_width + k_\sigma \cdot \max(\sigma(d) - 0.25, 0.0)$.
|
||||
* [x] Оптимизированы параметры `warn_far = 0.35` и `leak_far = 0.06` для предотвращения вымывания улики на дальности.
|
||||
- [x] **Шаг 3 (Зонирование и платформы)**:
|
||||
* [x] Двухзонный габарит: колея ($|u| \le 0.85$ м, $h_{lo\_core} = 0.16$ м) и периферия ($h_{lo} = 0.28$ м).
|
||||
* [x] Геометрическая фильтрация настила платформ ($|u| \ge 1.30$ м, $h \in [1.05, 1.25]$ м) для устранения слипания объектов со стенкой.
|
||||
- [ ] **Шаг 4 (ROS2 и валидация)**:
|
||||
* [x] Создан модуль экспорта [`flyguard/export.py`](../flyguard/export.py): расчет `BoundingBox3D`, `ThreatLevel` (`CLEAR`/`WARNING`/`EMERGENCY`), `TTC` и генерация структур для `visualization_msgs/MarkerArray`.
|
||||
* [x] Добавлены и успешно пройдены 35 модульных тестов (`tests/test_pipeline.py`).
|
||||
* [ ] Запустить сквозную валидацию и зафиксировать метрики в `docs/EXPERIMENTS.md`.
|
||||
|
|
@ -46,9 +46,6 @@ class Track:
|
|||
novelty: float = 1.0
|
||||
speed_lat: float = 0.0 # поперечная скорость, м/с
|
||||
p_track: float = 0.0 # отсчёт считывания по треку, если оно подключено
|
||||
# То же смещение в системе лидара, без поправки на кривую. В решении не
|
||||
# участвует: по нему рамка рисуется у своих точек, а не у оси пути.
|
||||
u_raw: float = 0.0
|
||||
history: list = field(default_factory=list)
|
||||
|
||||
# Сводка по всем наблюдениям трека, накапливаемая на лету. Считать её из
|
||||
|
|
@ -150,13 +147,8 @@ class CentralComplex:
|
|||
inhibition: float = 0.05, max_misses: int = 12,
|
||||
max_tracks: int = 48, use_shape: bool = True,
|
||||
mbon_power: float = 1.0, mbon_blend: float = 1.0,
|
||||
nov_fade_from: float = 0.0, nov_fade_to: float = 160.0,
|
||||
lying_exempt: bool = False, top_from: float = float("inf"),
|
||||
flat: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0)):
|
||||
nov_fade_from: float = 0.0, nov_fade_to: float = 160.0):
|
||||
self.use_shape = use_shape
|
||||
self.lying_exempt = lying_exempt
|
||||
self.top_from = top_from # низ выше этого — предмет в верхней секции габарита
|
||||
self.flat = flat # (высота, верх, дальность, множитель), см. _quality
|
||||
self.mbon_power = mbon_power
|
||||
self.mbon_blend = mbon_blend
|
||||
self.nov_fade_from = nov_fade_from
|
||||
|
|
@ -232,8 +224,7 @@ class CentralComplex:
|
|||
t.hits += 1
|
||||
t.misses = 0
|
||||
w = _quality(c, self.use_shape, self.mbon_power, self.mbon_blend,
|
||||
self.nov_fade_from, self.nov_fade_to,
|
||||
self.lying_exempt, self.top_from, self.flat)
|
||||
self.nov_fade_from, self.nov_fade_to)
|
||||
t.observe(c, self.s_world + c.d, w)
|
||||
t.evidence = min(1.0, t.evidence + self.gain * w)
|
||||
if dt > 1e-3:
|
||||
|
|
@ -242,7 +233,6 @@ class CentralComplex:
|
|||
a = float(np.clip(0.5 * (60.0 / max(c.d, 20.0)), 0.15, 0.6))
|
||||
t.s_world = (1 - a) * t.s_world + a * (self.s_world + c.d)
|
||||
t.u = (1 - a) * t.u + a * c.u
|
||||
t.u_raw = (1 - a) * t.u_raw + a * float(c.extra.get("u_raw", c.u))
|
||||
t.h = (1 - a) * t.h + a * c.h
|
||||
t.width = max(t.width * 0.7, c.width)
|
||||
t.height = max(t.height * 0.7, c.height)
|
||||
|
|
@ -259,11 +249,9 @@ class CentralComplex:
|
|||
continue
|
||||
t = Track(id=next(self._ids), s_world=self.s_world + c.d, u=c.u, h=c.h,
|
||||
width=c.width, height=c.height, first_d=c.d, last_d=c.d,
|
||||
last_n_rays=c.n_rays, novelty=c.novelty,
|
||||
u_raw=float(c.extra.get("u_raw", c.u)))
|
||||
last_n_rays=c.n_rays, novelty=c.novelty)
|
||||
w = _quality(c, self.use_shape, self.mbon_power, self.mbon_blend,
|
||||
self.nov_fade_from, self.nov_fade_to,
|
||||
self.lying_exempt, self.top_from, self.flat)
|
||||
self.nov_fade_from, self.nov_fade_to)
|
||||
t.observe(c, self.s_world + c.d, w)
|
||||
t.evidence = self.gain * w
|
||||
t.hits = 1
|
||||
|
|
@ -307,9 +295,7 @@ def expected_rays(d: float) -> float:
|
|||
|
||||
def _quality(c: Candidate, use_shape: bool = True, mbon_power: float = 1.0,
|
||||
mbon_blend: float = 1.0, nov_fade_from: float = 0.0,
|
||||
nov_fade_to: float = 160.0, lying_exempt: bool = False,
|
||||
top_from: float = float("inf"),
|
||||
flat: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0)) -> float:
|
||||
nov_fade_to: float = 160.0) -> float:
|
||||
"""Вес одного наблюдения: сколько улики оно добавляет.
|
||||
|
||||
Дальний объект даёт мало лучей не потому, что он сомнительный, а потому что
|
||||
|
|
@ -319,16 +305,7 @@ def _quality(c: Candidate, use_shape: bool = True, mbon_power: float = 1.0,
|
|||
стена тянутся дальше и в стороны. Новизна из грибовидного тела входит сюда
|
||||
же множителем.
|
||||
"""
|
||||
# Висящее в верхней секции (`top_from`) — отдельная статья. Обе обученные
|
||||
# части, считывание MBON и память тоннеля, собирались до того, как у
|
||||
# габарита появилась верхняя секция, и таких форм не видели: их ответ там
|
||||
# не значит ничего, и оба канала здесь выключены. Нормировка числа лучей
|
||||
# рассчитана на предмет с человека, а свисающий кабель или стержень тонок
|
||||
# по природе: у стержня 5 см опора упиралась в нижний край 0.25 на любой
|
||||
# дальности. Пояс над вагоном в исправном тоннеле пуст, поэтому нижний
|
||||
# край для висящего поднят до 0.5. EXPERIMENTS п. 19.
|
||||
top = c.h_min >= top_from
|
||||
support = float(np.clip(c.n_rays / expected_rays(c.d), 0.5 if top else 0.25, 1.0))
|
||||
support = float(np.clip(c.n_rays / expected_rays(c.d), 0.25, 1.0))
|
||||
|
||||
# Контраст к фону — главная улика вблизи и недоступная вдали. На 170 м
|
||||
# кольцо окружения ламины упирается в стену тоннеля, стоящую на той же
|
||||
|
|
@ -345,20 +322,12 @@ def _quality(c: Candidate, use_shape: bool = True, mbon_power: float = 1.0,
|
|||
contrast = max(contrast, float(np.clip(acc, 0.2, 1.0)))
|
||||
if use_shape:
|
||||
whole = float(np.clip((c.containment - 0.25) / 0.45, 0.05, 1.0))
|
||||
# протяжённость вдоль пути сверх собственного размера — признак конструкции
|
||||
span = max(c.width, c.height, 0.2)
|
||||
# Предмет или человек, лежащий в колее вдоль пути (depth ~ 1.8 м при малой высоте),
|
||||
# лежит прямо на полотне перед поездом — не штрафуем его как конструкцию тоннеля:
|
||||
is_lying_on_track = (lying_exempt and abs(c.u) <= 0.85
|
||||
and c.h <= 0.40 and c.depth <= 2.2)
|
||||
if is_lying_on_track:
|
||||
compact = 1.0
|
||||
else:
|
||||
compact = float(np.clip(1.5 - c.depth / (3.0 * span), 0.1, 1.0))
|
||||
# опора снизу: упавший предмет, человек, камень стоят на полотне, а знак,
|
||||
# лоток или кронштейн висят на стене, и под ними пусто. Кроме верхней
|
||||
# секции габарита (`top_from`): у оси под сводом стен нет, и висящее
|
||||
# там — это то, что свисает в путь вагона.
|
||||
grounded = 1.0 if top else float(np.clip(1.25 - c.h_min / 1.2, 0.15, 1.0))
|
||||
# лоток или кронштейн висят на стене, и под ними пусто
|
||||
grounded = float(np.clip(1.25 - c.h_min / 1.2, 0.15, 1.0))
|
||||
else:
|
||||
whole = compact = grounded = 1.0
|
||||
# Знакомость на большой дальности не просто бесполезна, а вредна. Замер:
|
||||
|
|
@ -368,8 +337,8 @@ def _quality(c: Candidate, use_shape: bool = True, mbon_power: float = 1.0,
|
|||
# вырождается, и память узнаёт в предмете любую далёкую конструкцию.
|
||||
# Поэтому вклад новизны гасится с дальностью, а вблизи, где она и
|
||||
# работает, остаётся как был. 0 — не гасить (EXPERIMENTS п. 12.3).
|
||||
novel = 1.0 if top else novelty_gain(c.novelty)
|
||||
if not top and nov_fade_from > 0.0 and c.d > nov_fade_from:
|
||||
novel = novelty_gain(c.novelty)
|
||||
if nov_fade_from > 0.0 and c.d > nov_fade_from:
|
||||
span = max(nov_fade_to - nov_fade_from, 1e-3)
|
||||
w = 1.0 - min((c.d - nov_fade_from) / span, 1.0)
|
||||
novel = novel ** w
|
||||
|
|
@ -384,25 +353,10 @@ def _quality(c: Candidate, use_shape: bool = True, mbon_power: float = 1.0,
|
|||
# модель, 0 — только руками, между ними всё промежуточное. Нужно затем,
|
||||
# чтобы размен «модель против ручной формулы» мерился, а не объявлялся.
|
||||
p = c.extra.get("mbon") if c.extra else None
|
||||
# висящее в верхней секции модель не видела ни разу (обучена на стоящих
|
||||
# предметах) и гасит его — здесь решает ручная формула
|
||||
if p is not None and mbon_blend > 0.0 and not top:
|
||||
if p is not None and mbon_blend > 0.0:
|
||||
pm = float(np.clip(p, 1e-4, 1.0)) ** max(mbon_power, 1e-3)
|
||||
b = float(np.clip(mbon_blend, 0.0, 1.0))
|
||||
hand = pm ** b * max(hand, 1e-4) ** (1.0 - b)
|
||||
# Плоское у самого пола: высотой в сантиметры, целиком ниже `flat[1]` над
|
||||
# рельсом. Это пластины и кромки на полотне, края жёлоба, порог
|
||||
# гермозатвора — по ответу организаторов то, что в жёлобе, препятствием не
|
||||
# считается. Модель таким ставит 0.8–0.99: в её обучении был кабель на
|
||||
# путях. А ложных тревог это давало больше всего: порог гермозатвора на
|
||||
# 65→31 м и два фантома на синтетике. Настоящий низкий предмет (0.3 м на
|
||||
# рельсе, 2×0.2 м поперёк путей) вблизи даёт 0.1–0.4 м по высоте и верх
|
||||
# выше 0.4 м. Дальше `flat[2]` решётка слишком редкая, чтобы судить о
|
||||
# высоте, и там правило молчит. Множитель, а не запрет: если предмет хоть в
|
||||
# одном кадре покажет высоту, он своё доберёт. EXPERIMENTS п. 19.
|
||||
fh, ftop, fd, fw = flat
|
||||
if fh > 0.0 and c.d <= fd and c.height < fh and c.h_min + c.height < ftop:
|
||||
hand *= fw
|
||||
return float(np.clip(hand * novel, 0.0, 1.0))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -36,9 +36,6 @@ class DetectedObject:
|
|||
n_rays: int
|
||||
track_id: int
|
||||
ttc: float
|
||||
# Смещение в системе лидара: `lateral` отсчитан от оси пути, и в кривой
|
||||
# рамка по нему стоит в стороне от точек. NaN — неизвестно.
|
||||
sensor_x: float = float("nan")
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -67,9 +64,7 @@ class DescendingNeurons:
|
|||
novelty_floor: float = 0.10, max_range: float = 200.0,
|
||||
warn_far: float | None = None, warn_far_from: float = 90.0,
|
||||
track_blend: float = 0.0, track_score: str = "w_mean",
|
||||
track_gate: float = 0.0, track_readout=None,
|
||||
far_channel: bool = False, far_confirm: int = 0,
|
||||
far_confirm_from: float = 100.0):
|
||||
track_gate: float = 0.0, track_readout=None):
|
||||
self.warn_evidence = warn_evidence
|
||||
self.clear_evidence = clear_evidence
|
||||
self.emergency_evidence = emergency_evidence
|
||||
|
|
@ -99,19 +94,6 @@ class DescendingNeurons:
|
|||
# предмета против 0.269 у ложных треков — разделение, под которое
|
||||
# порог и просится. 0 — не проверять.
|
||||
self.track_gate = track_gate
|
||||
# Дальний канал предупреждения (Zhirik1337, 22.09): далёкий трек с
|
||||
# хорошими наблюдениями поднимает тревогу в обход порога, даже если
|
||||
# улика просела на пропусках. Та же задача, что у `warn_far`, другим
|
||||
# способом; включать вместе с ним — ослабить дальний край дважды.
|
||||
# Замер — EXPERIMENTS п. 16.
|
||||
self.far_channel = far_channel
|
||||
# Дальний трек поднимает тревогу, только прожив `far_confirm` кадров.
|
||||
# Вдали предмет — три-четыре луча, и по одному кадру он неотличим от
|
||||
# вспышки на кромке или стыке; фантомы на синтетике и в записях жили
|
||||
# 0.5–1 с и пропадали при подъезде. Торможению дальний трек не нужен
|
||||
# срочно: до него сотня метров. 0 — выключено. EXPERIMENTS п. 19.
|
||||
self.far_confirm = far_confirm
|
||||
self.far_confirm_from = far_confirm_from
|
||||
self.track_score = track_score
|
||||
self.track_readout = track_readout
|
||||
self._latched: set[int] = set()
|
||||
|
|
@ -138,16 +120,10 @@ class DescendingNeurons:
|
|||
ready: list[tuple[Track, float]] = []
|
||||
for t in cx.tracks:
|
||||
d = t.distance(cx.s_world)
|
||||
far_floor = 0.05 if self.far_channel else self.novelty_floor
|
||||
nov_ok = (t.novelty >= self.novelty_floor if d <= self.warn_far_from
|
||||
else t.novelty >= far_floor)
|
||||
if (not (0.0 < d <= self.max_range) or t.hits < self.min_hits
|
||||
or not nov_ok):
|
||||
or t.novelty < self.novelty_floor):
|
||||
self._latched.discard(t.id)
|
||||
continue
|
||||
if (self.far_confirm > 0 and d > self.far_confirm_from
|
||||
and t.age < self.far_confirm and t.id not in self._latched):
|
||||
continue
|
||||
ready.append((t, d))
|
||||
|
||||
use_track = ((self.track_blend > 0.0 or self.track_gate > 0.0)
|
||||
|
|
@ -170,27 +146,14 @@ class DescendingNeurons:
|
|||
else t.p_mean if self.track_score == "p_mean"
|
||||
else t.w_ema if self.track_score == "w_ema"
|
||||
else t.w_mean)
|
||||
if self.track_gate > 0.0 and q < self.track_gate:
|
||||
if q < self.track_gate:
|
||||
self._latched.discard(t.id)
|
||||
continue
|
||||
if self.track_blend > 0.0:
|
||||
b = min(max(self.track_blend, 0.0), 1.0)
|
||||
b = min(self.track_blend, 1.0)
|
||||
score = (max(q, 1e-4) ** b
|
||||
* max(t.evidence, 1e-4) ** (1.0 - b))
|
||||
|
||||
# Дальний канал раннего предупреждения (DNp02/DNp11):
|
||||
# На дальности d > warn_far_from при наличии подтверждений (hits >= min_hits)
|
||||
# трек с устойчивыми признаками (p_mean, w_mean, w_ema или p_track)
|
||||
# квалифицируется как предупреждение, даже если улика просела из-за пропусков.
|
||||
is_far = (self.far_channel and d > self.warn_far_from
|
||||
and t.hits >= self.min_hits)
|
||||
quality = max(t.p_mean, t.w_mean, t.w_ema)
|
||||
far_qualified = is_far and (
|
||||
(quality >= 0.40 and t.evidence >= 0.15)
|
||||
or (getattr(t, "p_track", 0.0) >= 0.65)
|
||||
)
|
||||
|
||||
if score < on and not far_qualified:
|
||||
if score < on:
|
||||
self._latched.discard(t.id)
|
||||
continue
|
||||
self._latched.add(t.id)
|
||||
|
|
@ -205,8 +168,7 @@ class DescendingNeurons:
|
|||
out.objects.append(DetectedObject(
|
||||
distance=d, lateral=t.u, height=t.h, width=t.width, size_v=t.height,
|
||||
confidence=min(1.0, t.evidence * t.novelty + 0.0),
|
||||
novelty=t.novelty, n_rays=t.last_n_rays, track_id=t.id, ttc=ttc,
|
||||
sensor_x=t.u_raw))
|
||||
novelty=t.novelty, n_rays=t.last_n_rays, track_id=t.id, ttc=ttc))
|
||||
|
||||
nearest, d0 = live[0]
|
||||
out.detected = True
|
||||
|
|
|
|||
|
|
@ -1,150 +0,0 @@
|
|||
"""Управление вычислительными устройствами (NVIDIA GPU / CUDA и CPU fallback).
|
||||
|
||||
Модуль обеспечивает прозрачную работу конвейера FlyGuard на:
|
||||
- Выделенном NVIDIA GPU (RTX 4070 Ti Super 16GB / Ada Lovelace) через CUDA / Tensor Cores.
|
||||
- Любых других видеокартах NVIDIA (Ampere, Turing, Pascal, Hopper, Blackwell).
|
||||
- CPU Linux при отсутствии GPU или в среде жюри (Graceful Fallback).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger("flyguard.device")
|
||||
|
||||
_TORCH_AVAILABLE: bool | None = None
|
||||
_CUDA_AVAILABLE: bool | None = None
|
||||
|
||||
|
||||
def is_torch_available() -> bool:
|
||||
"""Проверка наличия установленного пакета PyTorch."""
|
||||
global _TORCH_AVAILABLE
|
||||
if _TORCH_AVAILABLE is None:
|
||||
try:
|
||||
import torch # noqa: F401
|
||||
_TORCH_AVAILABLE = True
|
||||
except ImportError:
|
||||
_TORCH_AVAILABLE = False
|
||||
return _TORCH_AVAILABLE
|
||||
|
||||
|
||||
def is_cuda_available() -> bool:
|
||||
"""Проверка доступности ускорения NVIDIA CUDA."""
|
||||
global _CUDA_AVAILABLE
|
||||
if _CUDA_AVAILABLE is None:
|
||||
if not is_torch_available():
|
||||
_CUDA_AVAILABLE = False
|
||||
else:
|
||||
try:
|
||||
import torch
|
||||
_CUDA_AVAILABLE = bool(torch.cuda.is_available() and torch.cuda.device_count() > 0)
|
||||
except Exception as e:
|
||||
logger.warning("Проверка CUDA завершилась ошибкой: %s. Fallback на CPU.", e)
|
||||
_CUDA_AVAILABLE = False
|
||||
return _CUDA_AVAILABLE
|
||||
|
||||
|
||||
def notify_cuda_error(exc: Exception | None = None) -> None:
|
||||
"""Зафиксировать сбой CUDA в рантайме и принудительно перевести систему в режим CPU fallback.
|
||||
|
||||
Вызывается, если во время работы на GPU произошёл OOM, таймаут или сбой драйвера.
|
||||
Последующие вызовы конвейера будут прозрачно исполняться на CPU.
|
||||
"""
|
||||
global _CUDA_AVAILABLE
|
||||
_CUDA_AVAILABLE = False
|
||||
logger.warning("Зафиксирован сбой GPU в рантайме (%s). Выполнен необратимый Fallback на CPU.", exc)
|
||||
|
||||
|
||||
def reset_device_cache() -> None:
|
||||
"""Сбросить кэш состояния устройств (для юнит-тестов)."""
|
||||
global _TORCH_AVAILABLE, _CUDA_AVAILABLE
|
||||
_TORCH_AVAILABLE = None
|
||||
_CUDA_AVAILABLE = None
|
||||
|
||||
|
||||
def get_device(preferred: str = "auto") -> str:
|
||||
"""Выбрать вычислительное устройство с автоматическим fallback на CPU.
|
||||
|
||||
Args:
|
||||
preferred: 'auto' (выбрать cuda при наличии, иначе cpu), 'cuda', 'cuda:0', 'cpu'.
|
||||
|
||||
Returns:
|
||||
Строка устройства: 'cuda', 'cuda:X' или 'cpu'.
|
||||
"""
|
||||
pref = (preferred or "auto").strip().lower()
|
||||
if pref == "auto":
|
||||
return "cuda" if is_cuda_available() else "cpu"
|
||||
if pref.startswith("cuda"):
|
||||
if is_cuda_available():
|
||||
return pref
|
||||
logger.warning("Запрошено устройство '%s', но CUDA недоступна. Выполнен fallback на CPU.", pref)
|
||||
return "cpu"
|
||||
return "cpu"
|
||||
|
||||
|
||||
def get_device_info(device_str: str | None = None) -> dict[str, Any]:
|
||||
"""Сводная информация об аппаратных ресурсах для инспекции и логирования."""
|
||||
dev = get_device(device_str or "auto")
|
||||
info: dict[str, Any] = {
|
||||
"device": dev,
|
||||
"torch_available": is_torch_available(),
|
||||
"cuda_available": is_cuda_available(),
|
||||
}
|
||||
if is_torch_available() and is_cuda_available() and dev.startswith("cuda"):
|
||||
import torch
|
||||
dev_idx = 0
|
||||
if ":" in dev:
|
||||
try:
|
||||
dev_idx = int(dev.split(":")[1])
|
||||
except ValueError:
|
||||
dev_idx = 0
|
||||
props = torch.cuda.get_device_properties(dev_idx)
|
||||
info.update({
|
||||
"name": props.name,
|
||||
"total_memory_mb": round(props.total_memory / (1024 * 1024), 2),
|
||||
"major": props.major,
|
||||
"minor": props.minor,
|
||||
"multi_processor_count": props.multi_processor_count,
|
||||
"allocated_mb": round(torch.cuda.memory_allocated(dev_idx) / (1024 * 1024), 2),
|
||||
"reserved_mb": round(torch.cuda.memory_reserved(dev_idx) / (1024 * 1024), 2),
|
||||
"cuda_version": torch.version.cuda,
|
||||
"cudnn_version": torch.backends.cudnn.version() if torch.backends.cudnn.is_available() else None,
|
||||
})
|
||||
else:
|
||||
import platform
|
||||
info.update({
|
||||
"name": platform.processor() or "CPU",
|
||||
"python_version": platform.python_version(),
|
||||
})
|
||||
return info
|
||||
|
||||
|
||||
def log_device_info(device_str: str | None = None) -> None:
|
||||
"""Вывести в лог/консоль активное вычислительное окружение."""
|
||||
info = get_device_info(device_str)
|
||||
if info.get("cuda_available") and str(info["device"]).startswith("cuda"):
|
||||
logger.info("FlyGuard GPU Active: %s (VRAM: %.1f MB, CUDA: %s, SM: %d.%d)",
|
||||
info.get("name"), info.get("total_memory_mb", 0.0),
|
||||
info.get("cuda_version"), info.get("major", 0), info.get("minor", 0))
|
||||
else:
|
||||
logger.info("FlyGuard CPU Mode (Fallback): %s", info.get("name"))
|
||||
|
||||
|
||||
def empty_cache() -> None:
|
||||
"""Очистить кэш памяти CUDA для предотвращения фрагментации VRAM."""
|
||||
if is_cuda_available():
|
||||
import torch
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
|
||||
def to_numpy(arr: Any) -> np.ndarray:
|
||||
"""Преобразовать входной тензор (PyTorch или NumPy) в numpy.ndarray."""
|
||||
if isinstance(arr, np.ndarray):
|
||||
return arr
|
||||
if is_torch_available():
|
||||
import torch
|
||||
if isinstance(arr, torch.Tensor):
|
||||
return arr.detach().cpu().numpy()
|
||||
return np.asarray(arr)
|
||||
|
|
@ -1,303 +0,0 @@
|
|||
"""ROS2 и визуализационный экспорт решений FlyGuard.
|
||||
|
||||
Преобразует внутренние результаты конвейера (Track, Decision, RailPlane, Corridor)
|
||||
в стандартизованные 3D Bounding Boxes, вектор угроз и структуры MarkerArray для RViz.
|
||||
|
||||
Работает автономно на чистом Python + NumPy, не требуя обязательной установки
|
||||
библиотек rclpy / ros2 на стенде валидации. При наличии ROS2 может конвертировать
|
||||
напрямую в сообщения visualization_msgs и vision_msgs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from enum import IntEnum
|
||||
import math
|
||||
import numpy as np
|
||||
|
||||
from .descending import Decision, DetectedObject
|
||||
from .geometry import Corridor, RailPlane
|
||||
|
||||
|
||||
class ThreatLevel(IntEnum):
|
||||
"""Уровень опасности для системы автоведения поезда."""
|
||||
CLEAR = 0 # Путь свободен
|
||||
WARNING = 1 # Заблаговременное предупреждение (DNp02/DNp11 soft-warning)
|
||||
EMERGENCY = 2 # Экстренное торможение (DNp01 / Giant Fiber)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BoundingBox3D:
|
||||
"""3D ориентированный параллелепипед в координатах сенсора лидара."""
|
||||
|
||||
# Центр бокса в системе сенсора (x: вправо, y: вперёд (-d), z: вверх)
|
||||
x: float
|
||||
y: float
|
||||
z: float
|
||||
|
||||
# Размеры бокса (м)
|
||||
dx: float # ширина поперёк пути
|
||||
dy: float # протяжённость вдоль пути
|
||||
dz: float # высота
|
||||
|
||||
# Ориентация (рыскание относительно оси лидара, рад)
|
||||
yaw: float
|
||||
|
||||
# Метрики движения и трекинга
|
||||
distance_along_track: float
|
||||
lateral_offset: float
|
||||
height_above_rail: float
|
||||
confidence: float
|
||||
novelty: float
|
||||
ttc: float
|
||||
track_id: int
|
||||
threat_level: ThreatLevel
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = asdict(self)
|
||||
d["threat_level"] = self.threat_level.name
|
||||
return d
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExportResult:
|
||||
"""Полный экспортный пакет за один кадр."""
|
||||
|
||||
stamp: float
|
||||
threat_level: ThreatLevel
|
||||
nearest_distance: float
|
||||
ttc: float
|
||||
stopping_distance: float
|
||||
speed_mps: float
|
||||
speed_kmh: float
|
||||
boxes: list[BoundingBox3D] = field(default_factory=list)
|
||||
corridor_points_xyz: list[tuple[float, float, float]] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"stamp": self.stamp,
|
||||
"threat_level": self.threat_level.name,
|
||||
"threat_code": int(self.threat_level),
|
||||
"nearest_distance": self.nearest_distance,
|
||||
"ttc": self.ttc,
|
||||
"stopping_distance": self.stopping_distance,
|
||||
"speed_kmh": self.speed_kmh,
|
||||
"n_objects": len(self.boxes),
|
||||
"boxes": [b.to_dict() for b in self.boxes],
|
||||
"corridor_points": self.corridor_points_xyz,
|
||||
}
|
||||
|
||||
def to_rviz_markers(self, frame_id: str = "hesai_pandar") -> list[dict]:
|
||||
"""Генерация словарей, готовых для преобразования в visualization_msgs/Marker."""
|
||||
markers = []
|
||||
now_sec = int(self.stamp)
|
||||
now_nanosec = int((self.stamp - now_sec) * 1e9)
|
||||
|
||||
# 1. Линия коридора пути (LINE_STRIP, type 4)
|
||||
if self.corridor_points_xyz:
|
||||
markers.append({
|
||||
"header": {"frame_id": frame_id, "sec": now_sec, "nanosec": now_nanosec},
|
||||
"ns": "flyguard_corridor",
|
||||
"id": 0,
|
||||
"type": 4, # LINE_STRIP
|
||||
"action": 0, # ADD
|
||||
"scale": {"x": 0.12},
|
||||
"color": {"r": 0.2, "g": 0.8, "b": 1.0, "a": 0.8},
|
||||
"points": [{"x": p[0], "y": p[1], "z": p[2]} for p in self.corridor_points_xyz]
|
||||
})
|
||||
|
||||
# 2. Bounding boxes объектов (CUBE, type 1) и надписи (TEXT, type 9)
|
||||
for i, box in enumerate(self.boxes):
|
||||
if box.threat_level == ThreatLevel.EMERGENCY:
|
||||
color = {"r": 1.0, "g": 0.1, "b": 0.1, "a": 0.75} # Красный
|
||||
elif box.threat_level == ThreatLevel.WARNING:
|
||||
color = {"r": 1.0, "g": 0.85, "b": 0.0, "a": 0.65} # Жёлтый
|
||||
else:
|
||||
color = {"r": 0.2, "g": 0.8, "b": 0.2, "a": 0.50} # Зелёный
|
||||
|
||||
# Кватернион поворота вокруг оси Z (yaw)
|
||||
cy = math.cos(box.yaw * 0.5)
|
||||
sy = math.sin(box.yaw * 0.5)
|
||||
|
||||
# CUBE маркер
|
||||
markers.append({
|
||||
"header": {"frame_id": frame_id, "sec": now_sec, "nanosec": now_nanosec},
|
||||
"ns": "flyguard_bboxes",
|
||||
"id": box.track_id * 2,
|
||||
"type": 1, # CUBE
|
||||
"action": 0,
|
||||
"pose": {
|
||||
"position": {"x": box.x, "y": box.y, "z": box.z},
|
||||
"orientation": {"x": 0.0, "y": 0.0, "z": sy, "w": cy}
|
||||
},
|
||||
"scale": {"x": max(box.dx, 0.2), "y": max(box.dy, 0.2), "z": max(box.dz, 0.2)},
|
||||
"color": color
|
||||
})
|
||||
|
||||
# TEXT_VIEW_FACING над объектом
|
||||
ttc_str = f"{box.ttc:.1f}s" if math.isfinite(box.ttc) else "inf"
|
||||
label = f"ID:{box.track_id} | {box.distance_along_track:.1f}m | TTC:{ttc_str}"
|
||||
markers.append({
|
||||
"header": {"frame_id": frame_id, "sec": now_sec, "nanosec": now_nanosec},
|
||||
"ns": "flyguard_labels",
|
||||
"id": box.track_id * 2 + 1,
|
||||
"type": 9, # TEXT_VIEW_FACING
|
||||
"action": 0,
|
||||
"pose": {
|
||||
"position": {"x": box.x, "y": box.y, "z": box.z + box.dz * 0.5 + 0.35},
|
||||
"orientation": {"x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0}
|
||||
},
|
||||
"scale": {"z": 0.40}, # Высота шрифта
|
||||
"color": {"r": 1.0, "g": 1.0, "b": 1.0, "a": 0.95},
|
||||
"text": label
|
||||
})
|
||||
|
||||
return markers
|
||||
|
||||
|
||||
def export_frame(decision: Decision, plane: RailPlane | None, corridor: Corridor | None,
|
||||
stamp: float = 0.0) -> ExportResult:
|
||||
"""Сконвертировать решение FlyGuard в экспортный формат.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
decision : Decision
|
||||
Итоговый вердикт системы за кадр.
|
||||
plane : RailPlane, optional
|
||||
Плоскость головок рельсов (z = a·d + b·u + c).
|
||||
corridor : Corridor, optional
|
||||
Осевая линия тоннеля (парабола u(d)).
|
||||
stamp : float
|
||||
Временная метка кадра.
|
||||
"""
|
||||
if decision.emergency:
|
||||
threat = ThreatLevel.EMERGENCY
|
||||
elif decision.detected:
|
||||
threat = ThreatLevel.WARNING
|
||||
else:
|
||||
threat = ThreatLevel.CLEAR
|
||||
|
||||
# Параметры плоскости пути: z = a*d + b*u + c
|
||||
a = plane.a if plane is not None else 0.0
|
||||
b = plane.b if plane is not None else 0.0
|
||||
c = plane.c if plane is not None else -1.80 # высота лидара над рельсами ~1.8м
|
||||
|
||||
boxes: list[BoundingBox3D] = []
|
||||
for obj in decision.objects:
|
||||
d = obj.distance
|
||||
u = obj.lateral
|
||||
h = obj.height
|
||||
|
||||
# Координаты в системе сенсора (x: вправо, y: вперёд (-d), z: вверх).
|
||||
#
|
||||
# Боковое смещение трека отсчитано от ОСИ ПУТИ, а не от оси сенсора
|
||||
# (TrackFrame.lateral), поэтому в кривой к нему прибавляется положение
|
||||
# оси на этой дальности: при радиусе 1300 м это 1.2 м на 55 м и 8.6 м
|
||||
# на 150 м — без поправки рамка рисовалась в стене. Направление оси
|
||||
# пути в кадре — (наклон, −1), и длинная ось рамки (её локальная y)
|
||||
# совпадает с ним при повороте на +atan(наклон). Проверка обоих —
|
||||
# test_export_box_follows_a_curved_track.
|
||||
#
|
||||
# Если трек знает своё смещение в системе лидара (`sensor_x`), берётся
|
||||
# оно: габарит — объединение прямого и изогнутого, и у предмета,
|
||||
# попавшего в прямой, `u` отсчитан не от кривой, а от оси лидара.
|
||||
sx = float(getattr(obj, "sensor_x", float("nan")))
|
||||
if corridor is not None and corridor.n_slices > 0:
|
||||
x_sensor = (sx if math.isfinite(sx)
|
||||
else float(u + corridor.centre(np.array([d], np.float32))[0]))
|
||||
c0, c1, c2 = corridor.coef
|
||||
dm = max(corridor.d_max_seen, 1.0)
|
||||
d_in = min(d, dm)
|
||||
slope = c1 + 2.0 * c2 * d_in
|
||||
yaw = float(math.atan(slope))
|
||||
else:
|
||||
x_sensor = sx if math.isfinite(sx) else float(u)
|
||||
yaw = 0.0
|
||||
y_sensor = float(-d)
|
||||
z_sensor = float(h + a * d + b * x_sensor + c)
|
||||
|
||||
# Уровень опасности для конкретного объекта
|
||||
if decision.emergency and d <= max(decision.stopping_distance, 25.0):
|
||||
obj_threat = ThreatLevel.EMERGENCY
|
||||
elif decision.detected:
|
||||
obj_threat = ThreatLevel.WARNING
|
||||
else:
|
||||
obj_threat = ThreatLevel.CLEAR
|
||||
|
||||
# Размеры: dx поперёк пути, dy вдоль пути, dz по вертикали
|
||||
dx = float(max(obj.width, 0.35))
|
||||
# протяжённость вдоль пути трек не хранит, поэтому она постоянная
|
||||
dy = 0.50
|
||||
dz = float(max(obj.size_v, 0.40))
|
||||
|
||||
boxes.append(BoundingBox3D(
|
||||
x=x_sensor,
|
||||
y=y_sensor,
|
||||
z=z_sensor,
|
||||
dx=dx,
|
||||
dy=dy,
|
||||
dz=dz,
|
||||
yaw=yaw,
|
||||
distance_along_track=float(d),
|
||||
lateral_offset=float(u),
|
||||
height_above_rail=float(h),
|
||||
confidence=float(obj.confidence),
|
||||
novelty=float(obj.novelty),
|
||||
ttc=float(obj.ttc),
|
||||
track_id=int(obj.track_id),
|
||||
threat_level=obj_threat,
|
||||
))
|
||||
|
||||
# Траектория коридора вперед (на 10..180 м)
|
||||
corridor_pts: list[tuple[float, float, float]] = []
|
||||
if corridor is not None:
|
||||
ds_sample = np.linspace(10.0, min(max(corridor.d_max_seen, 50.0), 180.0), 25)
|
||||
us_sample = corridor.centre(ds_sample)
|
||||
for ds_i, us_i in zip(ds_sample, us_sample):
|
||||
xs = float(us_i)
|
||||
ys = float(-ds_i)
|
||||
zs = float(a * ds_i + b * us_i + c + 0.1) # чуть над рельсом
|
||||
corridor_pts.append((xs, ys, zs))
|
||||
|
||||
speed = decision.speed
|
||||
return ExportResult(
|
||||
stamp=stamp,
|
||||
threat_level=threat,
|
||||
nearest_distance=float(decision.distance),
|
||||
ttc=float(decision.ttc),
|
||||
stopping_distance=float(decision.stopping_distance),
|
||||
speed_mps=float(speed),
|
||||
speed_kmh=float(speed * 3.6),
|
||||
boxes=boxes,
|
||||
corridor_points_xyz=corridor_pts
|
||||
)
|
||||
|
||||
|
||||
def gauge_outline(boxes, centre=None, step: float = 2.0,
|
||||
frame_every: float = 20.0) -> list[tuple[float, float, float]]:
|
||||
"""Отрезки контура габарита (пары точек для LINE_LIST) в координатах облака обзора.
|
||||
|
||||
`boxes` — секции `(полуширина, низ, верх, от, до)`; `centre(d)` — ось пути
|
||||
(`Corridor.centre`), None — прямой короб вдоль x = 0. Облако обзора в кривой
|
||||
не выпрямлено, поэтому габарит вдоль изогнутой оси рисуется изогнутым —
|
||||
там, где узел его и проверяет. Вперёд = −y, вправо = +x, вверх = +z над
|
||||
головкой рельса, как у облака обзора. Рамки поперёк — через `frame_every`.
|
||||
"""
|
||||
pts: list[tuple[float, float, float]] = []
|
||||
for hw, z0, z1, d0, d1 in boxes:
|
||||
if d1 <= d0:
|
||||
continue
|
||||
n = 1 if centre is None else max(int(np.ceil((d1 - d0) / step)), 1)
|
||||
ds = np.linspace(d0, d1, n + 1)
|
||||
cx = np.zeros_like(ds) if centre is None else np.asarray(centre(ds), np.float64)
|
||||
corners = [(-hw, z0), (hw, z0), (hw, z1), (-hw, z1)]
|
||||
for x, z in corners: # вдоль пути
|
||||
for a in range(n):
|
||||
pts.append((float(cx[a] + x), float(-ds[a]), float(z)))
|
||||
pts.append((float(cx[a + 1] + x), float(-ds[a + 1]), float(z)))
|
||||
fd = np.arange(d0, d1 + 1e-6, frame_every) # поперёк
|
||||
fc = np.zeros_like(fd) if centre is None else np.asarray(centre(fd), np.float64)
|
||||
for dd, c in zip(fd, fc):
|
||||
for (xa, za), (xb, zb) in zip(corners, corners[1:] + corners[:1]):
|
||||
pts.append((float(c + xa), float(-dd), float(za)))
|
||||
pts.append((float(c + xb), float(-dd), float(zb)))
|
||||
return pts
|
||||
500
flyguard/gpu.py
500
flyguard/gpu.py
|
|
@ -1,500 +0,0 @@
|
|||
"""Видеокарта для плотных стадий конвейера: сетчатка, ламина, кластеризация.
|
||||
|
||||
Три стадии работают с целым образом 128 × 600 лучей и занимают больше
|
||||
половины кадра: раскладка точек по решётке, ламина (центр минус окружение на
|
||||
трёх масштабах) и кластеризация лучей с учётом глубины. Они и перенесены.
|
||||
Остальное — плоскость пути, ось, оценка движения, треки, решение — остаётся
|
||||
на процессоре: там мелкие массивы и ветвистая логика, и копирование на
|
||||
видеокарту стоило бы дороже самого расчёта.
|
||||
|
||||
Каждая функция повторяет процессорную версию операция в операцию, в тех же
|
||||
типах. Раскладка по порядку точек, ламина и кластеризация дают результат,
|
||||
совпадающий с процессорным бит в бит (тесты и сверка на записях,
|
||||
EXPERIMENTS п. 21):
|
||||
|
||||
* сетчатка — только выборки, умножения и корень, всё в float32 без слияния
|
||||
операций;
|
||||
* ламина — равномерные окна считаются через накопленные суммы в float64 и
|
||||
округляются в float32 после каждой оси, ровно как `scipy.ndimage.
|
||||
uniform_filter`: сумма чисел float32 такого диапазона в float64 точна, и
|
||||
результат не зависит от порядка сложения;
|
||||
* кластеризация — целочисленный граф; компоненты нумеруются по наименьшему
|
||||
лучу, как у `scipy.sparse.csgraph.connected_components`.
|
||||
|
||||
Исключение — раскладка по углам точек (кадры с нарушенным порядком, как в
|
||||
синтетике организаторов): арктангенс видеокарты и процессора может разойтись
|
||||
в последнем знаке, а numpy сортирует ячейки неустойчиво. Расходятся считанные
|
||||
лучи на кадр.
|
||||
|
||||
Любой сбой видеокарты — нет драйвера, не хватило памяти, ошибка ядра — ловит
|
||||
конвейер: до конца работы он считает на процессоре (`device.
|
||||
notify_cuda_error`), а кадр досчитывается там же, решение не теряется.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .lamina import SCALES, LaminaOutput
|
||||
from .retina import DEG, ORDER_TOL_DEG, RangeImage, ScanLayout
|
||||
|
||||
|
||||
class GpuStages:
|
||||
"""Плотные стадии на видеокарте; держит таблицы решётки и образ кадра."""
|
||||
|
||||
def __init__(self, device: str = "cuda"):
|
||||
import torch
|
||||
self.torch = torch
|
||||
self.dev = torch.device(device)
|
||||
torch.zeros(1, device=self.dev) # поднять контекст сразу, а не на первом кадре
|
||||
self._tab_key = None
|
||||
self._tab: dict = {}
|
||||
self._win: dict = {}
|
||||
self._off: dict = {}
|
||||
self._box: dict = {}
|
||||
self._graphs = True # графы CUDA для ламины; при отказе — по одной операции
|
||||
self._lam_key = None
|
||||
# образ текущего кадра: ламина и кластеризация берут его отсюда, не копируя заново
|
||||
self.r = None
|
||||
self.valid = None
|
||||
self._r_host = None
|
||||
|
||||
def warmup(self, shape: tuple[int, int] = (128, 600)) -> None:
|
||||
"""Прогнать все три стадии на выдуманном кадре.
|
||||
|
||||
Первый запуск каждого ядра CUDA подгружает его код: без прогрева первый
|
||||
кадр записи шёл 1–1.7 с, очередь подписки переполнялась, и терялись
|
||||
первые 6–8 кадров (замерено в контейнере). Раскладка прогревается на
|
||||
маленькой решётке обоими путями — по порядку точек и по углам.
|
||||
"""
|
||||
from .cdr import PointCloud2
|
||||
t = self.torch
|
||||
rng = np.random.default_rng(0)
|
||||
n, w, e = 8, 64, 2
|
||||
lay = ScanLayout(np.linspace(10.0, -20.0, n), -0.1, 3.2, np.zeros(n, np.int64),
|
||||
np.zeros(n), w, e)
|
||||
xyz = lay.dirs.transpose(1, 0, 2)[:, None, :, :] * np.float32(20.0) # столбец·эхо·кольцо
|
||||
xyz = np.broadcast_to(xyz, (w, e, n, 3)).reshape(-1, 3)
|
||||
pts = np.zeros(xyz.shape[0], dtype=[("x", "<f4"), ("y", "<f4"), ("z", "<f4"),
|
||||
("intensity", "<f4")])
|
||||
pts["x"], pts["y"], pts["z"] = xyz[:, 0], xyz[:, 1], xyz[:, 2]
|
||||
pts["intensity"] = 10.0
|
||||
cloud = PointCloud2(0.0, "", 1, pts.size, pts.dtype.itemsize, True, pts)
|
||||
self.project(lay, cloud, slice(8, w - 8))
|
||||
mixed = PointCloud2(0.0, "", 1, pts.size, pts.dtype.itemsize, True,
|
||||
pts[rng.permutation(pts.size)])
|
||||
self.project(lay, mixed, slice(8, w - 8))
|
||||
|
||||
r = (4.0 + 150.0 * rng.random(shape)).astype(np.float32)
|
||||
self.r = t.as_tensor(r, device=self.dev)
|
||||
self.valid = t.ones(shape, dtype=t.bool, device=self.dev)
|
||||
self._r_host = r
|
||||
self.lamina()
|
||||
self.cluster_by_depth(rng.random(shape) < 0.3, r)
|
||||
self.r = self.valid = self._r_host = None
|
||||
t.cuda.synchronize(self.dev)
|
||||
|
||||
# ------------------------------------------------------------------ таблицы
|
||||
|
||||
def _tables(self, L: ScanLayout) -> dict:
|
||||
key = (id(L), L.n_points, L.n_az)
|
||||
if key != self._tab_key:
|
||||
t, d = self.torch, self.dev
|
||||
|
||||
def up(a, dtype):
|
||||
return t.as_tensor(np.ascontiguousarray(a), dtype=dtype, device=d)
|
||||
|
||||
self._tab = {
|
||||
"sin_el": up(L._sin_el, t.float32),
|
||||
"el_asc": up(L._el_asc, t.float64),
|
||||
"el_order": up(L._el_order, t.int64),
|
||||
"el_deg": up(L.el_deg, t.float64),
|
||||
"el_step": up(L.el_step_deg, t.float64),
|
||||
"resid": up(L.az_resid_deg, t.float64),
|
||||
}
|
||||
self._win = {}
|
||||
self._tab_key = key
|
||||
return self._tab
|
||||
|
||||
def _window(self, L: ScanLayout, start: int, stop: int) -> tuple:
|
||||
"""Сырые столбцы, нужные сектору, и карта выпрямления внутри них."""
|
||||
key = (start, stop)
|
||||
if key not in self._win:
|
||||
w = L.n_az
|
||||
lo = start + int(L.col_shift.min())
|
||||
hi = stop + int(L.col_shift.max())
|
||||
g = L.gather[:, start:stop]
|
||||
ok = L.gather_ok[:, start:stop]
|
||||
if L.wrap:
|
||||
raw = np.arange(lo, hi) % w
|
||||
g = (g - lo) % w
|
||||
else:
|
||||
lo, hi = max(lo, 0), min(hi, w)
|
||||
raw = np.arange(lo, hi)
|
||||
g = g - lo
|
||||
ok = ok & (g >= 0) & (g < (hi - lo))
|
||||
g = np.clip(g, 0, hi - lo - 1)
|
||||
# столбцы окна идут подряд, кроме стыка кругового скана
|
||||
cut = np.flatnonzero(np.diff(raw) != 1) + 1
|
||||
runs = [(int(a[0]), int(a[-1]) + 1) for a in np.split(raw, cut) if a.size]
|
||||
t = self.torch
|
||||
self._win[key] = (runs, t.as_tensor(g, dtype=t.int64, device=self.dev),
|
||||
t.as_tensor(ok, device=self.dev))
|
||||
return self._win[key]
|
||||
|
||||
# ------------------------------------------------------------------ загрузка точек
|
||||
|
||||
def _upload(self, pts: np.ndarray, rows: list[tuple[int, int]]):
|
||||
"""Строки структурированного массива → байты на видеокарте, (N, шаг точки)."""
|
||||
t = self.torch
|
||||
step = pts.dtype.itemsize
|
||||
parts = []
|
||||
for a, b in rows:
|
||||
raw = np.ascontiguousarray(pts[a:b]).view(np.uint8)
|
||||
with warnings.catch_warnings():
|
||||
# буфер сообщения только для чтения, а тензор мы не пишем
|
||||
warnings.simplefilter("ignore", UserWarning)
|
||||
parts.append(t.from_numpy(raw).to(self.dev, non_blocking=False))
|
||||
buf = parts[0] if len(parts) == 1 else t.cat(parts)
|
||||
return buf.view(-1, step)
|
||||
|
||||
def _field(self, buf, pts: np.ndarray, name: str):
|
||||
dt, off = pts.dtype.fields[name][:2]
|
||||
if dt.kind != "f" or dt.itemsize != 4 or dt.byteorder == ">":
|
||||
raise TypeError(f"поле {name}: ожидался float32, а не {dt}")
|
||||
return buf[:, off:off + 4].contiguous().view(self.torch.float32).reshape(-1)
|
||||
|
||||
# ------------------------------------------------------------------ сетчатка
|
||||
|
||||
def project(self, L: ScanLayout, pc, cols: slice | None) -> RangeImage:
|
||||
"""То же, что `ScanLayout.project`, на видеокарте."""
|
||||
w = L.n_az
|
||||
start = 0 if cols is None else (cols.start or 0)
|
||||
stop = w if cols is None else (cols.stop if cols.stop is not None else w)
|
||||
self._tables(L)
|
||||
img = None
|
||||
if L.indexed and pc.n_points == L.n_points:
|
||||
img = self._project_indexed(L, pc, start, stop)
|
||||
if img is None:
|
||||
L.n_geometric += 1
|
||||
img = self._project_geometric(L, pc, start, stop)
|
||||
self._r_host = img.r_near
|
||||
return img
|
||||
|
||||
def _project_indexed(self, L: ScanLayout, pc, start: int, stop: int):
|
||||
t = self.torch
|
||||
tab = self._tab
|
||||
n, e = L.n_rings, L.n_echo
|
||||
runs, g, ok = self._window(L, start, stop)
|
||||
pts = pc.points
|
||||
per_col = e * n
|
||||
buf = self._upload(pts, [(a * per_col, b * per_col) for a, b in runs])
|
||||
ncol = sum(b - a for a, b in runs)
|
||||
|
||||
def cube(name: str):
|
||||
return self._field(buf, pts, name).reshape(ncol, e, n).permute(2, 0, 1)
|
||||
|
||||
x, y, z = cube("x"), cube("y"), cube("z")
|
||||
good = (x != 0) | (y != 0) | (z != 0)
|
||||
r = t.sqrt(x * x + y * y + z * z)
|
||||
good &= t.isfinite(r)
|
||||
r = t.where(good, r, t.zeros((), dtype=r.dtype, device=self.dev))
|
||||
|
||||
dev = t.abs(z - r * tab["sin_el"][:, None, None])
|
||||
tol = r * np.float32(ORDER_TOL_DEG * DEG) + np.float32(1e-3)
|
||||
if bool(t.any(dev > tol)):
|
||||
return None
|
||||
|
||||
if e == 1:
|
||||
r_near = r[..., 0]
|
||||
r_far = r[..., 0]
|
||||
it = cube("intensity")[..., 0]
|
||||
valid = good[..., 0]
|
||||
else:
|
||||
inten = cube("intensity")
|
||||
inf = t.full((), float("inf"), dtype=r.dtype, device=self.dev)
|
||||
near_i = t.argmin(t.where(good, r, inf), dim=-1, keepdim=True)
|
||||
far_i = t.argmax(r, dim=-1, keepdim=True)
|
||||
r_near = t.gather(r, -1, near_i)[..., 0]
|
||||
r_far = t.gather(r, -1, far_i)[..., 0]
|
||||
it = t.gather(inten, -1, near_i)[..., 0]
|
||||
valid = good.any(dim=-1)
|
||||
|
||||
r_near = t.gather(r_near, 1, g)
|
||||
r_far = t.gather(r_far, 1, g)
|
||||
it = t.gather(it, 1, g)
|
||||
valid = t.gather(valid, 1, g) & ok
|
||||
zero = t.zeros((), dtype=r_near.dtype, device=self.dev)
|
||||
r_near = t.where(valid, r_near, zero)
|
||||
r_far = t.where(valid, r_far, zero)
|
||||
return self._finish(pc.stamp, r_near, r_far, it, valid)
|
||||
|
||||
def _project_geometric(self, L: ScanLayout, pc, start: int, stop: int):
|
||||
t = self.torch
|
||||
tab = self._tab
|
||||
n, wid = L.n_rings, stop - start
|
||||
pts = pc.points
|
||||
buf = self._upload(pts, [(0, pts.shape[0])])
|
||||
x, y, z = (self._field(buf, pts, k) for k in ("x", "y", "z"))
|
||||
good = (((x != 0) | (y != 0) | (z != 0)) & t.isfinite(x) & t.isfinite(y)
|
||||
& t.isfinite(z))
|
||||
idx = t.nonzero(good, as_tuple=True)[0]
|
||||
x, y, z = x[idx], y[idx], z[idx]
|
||||
|
||||
step = L.az_step_deg
|
||||
az = t.rad2deg(t.atan2(x, -y))
|
||||
jf = (az - np.float32(L.az0_deg)) / np.float32(step)
|
||||
if L.wrap:
|
||||
jf = t.remainder(jf, L.n_az)
|
||||
margin = float(np.abs(L.az_resid_deg).max()) / abs(step) + 1.0
|
||||
sel = t.nonzero((jf > start - margin) & (jf < stop - 1 + margin), as_tuple=True)[0]
|
||||
idx, x, y, z, az = idx[sel], x[sel], y[sel], z[sel], az[sel]
|
||||
|
||||
r = t.sqrt(x * x + y * y + z * z)
|
||||
el = t.rad2deg(t.asin(t.clamp(z / t.clamp_min(r, np.float32(1e-6)), -1.0, 1.0)))
|
||||
el64 = el.double()
|
||||
asc = tab["el_asc"]
|
||||
k = t.clamp(t.searchsorted(asc, el64), 1, n - 1)
|
||||
k = k - ((el64 - asc[k - 1]) < (asc[k] - el64)).long()
|
||||
h = tab["el_order"][k]
|
||||
ok = t.abs(el64 - tab["el_deg"][h]) <= t.clamp_min(tab["el_step"][h], 0.2)
|
||||
j = t.round(((az.double() - tab["resid"][h]) - L.az0_deg) / step).long()
|
||||
if L.wrap:
|
||||
j = t.remainder(j, L.n_az)
|
||||
ok &= (j >= start) & (j < stop)
|
||||
sel = t.nonzero(ok, as_tuple=True)[0]
|
||||
|
||||
r_near = t.zeros(n * wid, dtype=t.float32, device=self.dev)
|
||||
r_far = t.zeros_like(r_near)
|
||||
it = t.zeros_like(r_near)
|
||||
valid = t.zeros(n * wid, dtype=t.bool, device=self.dev)
|
||||
if sel.numel():
|
||||
cell = h[sel] * wid + (j[sel] - start)
|
||||
rr = r[sel]
|
||||
mm = t.clamp_max(rr * 1000.0, float((1 << 20) - 1)).long()
|
||||
order = t.argsort(cell * (1 << 20) + mm, stable=True)
|
||||
cs, rs = cell[order], rr[order]
|
||||
new = t.ones(cs.numel(), dtype=t.bool, device=self.dev)
|
||||
new[1:] = cs[1:] != cs[:-1]
|
||||
first = t.nonzero(new, as_tuple=True)[0]
|
||||
last = t.cat([first[1:] - 1, first.new_tensor([cs.numel() - 1])])
|
||||
r_near[cs[first]] = rs[first]
|
||||
r_far[cs[last]] = rs[last]
|
||||
valid[cs[first]] = True
|
||||
if "intensity" in pts.dtype.names:
|
||||
src = self._field(buf, pts, "intensity")[idx[sel]][order]
|
||||
it[cs[first]] = src[first]
|
||||
return self._finish(pc.stamp, r_near.reshape(n, wid), r_far.reshape(n, wid),
|
||||
it.reshape(n, wid), valid.reshape(n, wid))
|
||||
|
||||
def _finish(self, stamp: float, r_near, r_far, it, valid) -> RangeImage:
|
||||
self.r, self.valid = r_near, valid
|
||||
host = [a.cpu().numpy() for a in (r_near, r_far, it, valid)]
|
||||
return RangeImage(stamp, *host)
|
||||
|
||||
# ------------------------------------------------------------------ ламина
|
||||
|
||||
def _box_axis(self, X, sizes: list[int], dim: int):
|
||||
"""Равномерное окно по одной оси для пачки размеров сразу.
|
||||
|
||||
`X` — (K, B, H, W) float32, k-й срез фильтруется окном `sizes[k]`
|
||||
вдоль `dim` (2 или 3) с продолжением краевым значением. Суммы окон
|
||||
берутся из накопленной суммы в float64: все частичные суммы чисел
|
||||
float32 такого диапазона в float64 точны, поэтому сумма окна точна при
|
||||
любом порядке сложения. Деление на размер и округление в float32 —
|
||||
как в `scipy.ndimage.uniform_filter1d`.
|
||||
"""
|
||||
t = self.torch
|
||||
Xd = X.double()
|
||||
n = Xd.shape[dim]
|
||||
lo, hi, n_lo, n_hi, size = self._box_tables(tuple(sizes), n, dim)
|
||||
cs = t.cumsum(Xd, dim=dim)
|
||||
cs = t.cat([t.zeros_like(cs.narrow(dim, 0, 1)), cs], dim=dim)
|
||||
|
||||
def along(a):
|
||||
return a.expand(*Xd.shape[:dim], n, *Xd.shape[dim + 1:])
|
||||
|
||||
s = (t.gather(cs, dim, along(hi)) - t.gather(cs, dim, along(lo))
|
||||
+ along(n_lo) * Xd.narrow(dim, 0, 1) + along(n_hi) * Xd.narrow(dim, n - 1, 1))
|
||||
return (s / size).float()
|
||||
|
||||
def _box_tables(self, sizes: tuple, n: int, dim: int):
|
||||
"""Границы окон по оси: считаются один раз (и до записи графа CUDA)."""
|
||||
key = (sizes, n, dim)
|
||||
if key not in self._box:
|
||||
t = self.torch
|
||||
p = t.as_tensor([s // 2 for s in sizes], dtype=t.int64, device=self.dev)
|
||||
i = t.arange(n, device=self.dev)
|
||||
shape = [len(sizes), 1, 1, 1]
|
||||
shape[dim] = n
|
||||
lo = (i[None, :] - p[:, None]).clamp(min=0).reshape(shape)
|
||||
hi = ((i[None, :] + p[:, None]).clamp(max=n - 1) + 1).reshape(shape)
|
||||
n_lo = (p[:, None] - i[None, :]).clamp(min=0).double().reshape(shape)
|
||||
n_hi = (i[None, :] + p[:, None] - (n - 1)).clamp(min=0).double().reshape(shape)
|
||||
size = t.as_tensor(sizes, dtype=t.float64, device=self.dev).reshape(-1, 1, 1, 1)
|
||||
self._box[key] = (lo, hi, n_lo, n_hi, size)
|
||||
return self._box[key]
|
||||
|
||||
def _boxes(self, X, sizes: list[tuple[int, int]]):
|
||||
"""`scipy.ndimage.uniform_filter(x, size, mode="nearest")` для пачки.
|
||||
|
||||
`X` — (B, H, W) float32; выход (len(sizes), B, H, W): по осям по
|
||||
очереди, с округлением в float32 после каждой, как в scipy.
|
||||
"""
|
||||
K = len(sizes)
|
||||
Y = self._box_axis(X[None].expand(K, *X.shape), [s[0] for s in sizes], 2)
|
||||
return self._box_axis(Y, [s[1] for s in sizes], 3)
|
||||
|
||||
def lamina(self, r_max: float = 300.0) -> LaminaOutput:
|
||||
"""То же, что `lamina._process_cpu`, по образу текущего кадра.
|
||||
|
||||
Сама видеокарта считает ламину за доли миллисекунды, а запуск сотни
|
||||
мелких операций из Python стоит в несколько раз дороже. Поэтому ламина
|
||||
записывается в граф CUDA один раз на размер образа и дальше
|
||||
запускается одной командой. Не поддерживает граф драйвер — считаем
|
||||
теми же операциями по одной.
|
||||
"""
|
||||
t = self.torch
|
||||
key = (tuple(self.r.shape), float(r_max))
|
||||
out = None
|
||||
if self._graphs:
|
||||
try:
|
||||
if self._lam_key != key:
|
||||
self._capture_lamina(key, r_max)
|
||||
self._lam_in[0].copy_(self.r)
|
||||
self._lam_in[1].copy_(self.valid)
|
||||
self._lam_graph.replay()
|
||||
out = self._lam_out
|
||||
except Exception:
|
||||
self._graphs = False
|
||||
self._lam_key = None
|
||||
if out is None:
|
||||
out = self._lamina_core(self.r, self.valid, r_max)
|
||||
f = out[0].cpu().numpy()
|
||||
return LaminaOutput(disp=f[0], on=f[1], off=f[2], on_scale=out[1].cpu().numpy(),
|
||||
surround=f[3], hole=f[4])
|
||||
|
||||
def _capture_lamina(self, key: tuple, r_max: float) -> None:
|
||||
t = self.torch
|
||||
self._lam_in = (t.empty_like(self.r), t.empty_like(self.valid))
|
||||
self._lam_in[0].copy_(self.r)
|
||||
self._lam_in[1].copy_(self.valid)
|
||||
side = t.cuda.Stream(self.dev)
|
||||
side.wait_stream(t.cuda.current_stream(self.dev))
|
||||
with t.cuda.stream(side):
|
||||
for _ in range(2): # прогрев: таблицы окон и кэш памяти
|
||||
self._lamina_core(*self._lam_in, r_max)
|
||||
t.cuda.current_stream(self.dev).wait_stream(side)
|
||||
g = t.cuda.CUDAGraph()
|
||||
with t.cuda.graph(g):
|
||||
self._lam_out = self._lamina_core(*self._lam_in, r_max)
|
||||
self._lam_graph, self._lam_key = g, key
|
||||
|
||||
def _lamina_core(self, r, valid, r_max: float):
|
||||
"""Ламина целиком на видеокарте: (disp, on, off, surround, hole) и on_scale."""
|
||||
t = self.torch
|
||||
v = valid.float()
|
||||
zero = t.zeros((), dtype=t.float32, device=self.dev)
|
||||
disp = t.where(valid & (r > 0.05), 1.0 / r, zero) * v
|
||||
|
||||
# все окна кадра одной пачкой: на каждом масштабе центр и окружение,
|
||||
# для диспаритета и для маски эха, плюс окно «дыры»
|
||||
sizes = []
|
||||
for r_in, r_out in SCALES:
|
||||
sizes += [(2 * r_in + 1, 2 * r_in + 1), (2 * r_out + 1, 4 * r_out + 1)]
|
||||
sizes.append((5, 15))
|
||||
box = self._boxes(t.stack([disp, v]), sizes)
|
||||
|
||||
on = t.zeros_like(disp)
|
||||
off = t.zeros_like(disp)
|
||||
on_scale = t.zeros(disp.shape, dtype=t.int8, device=self.dev)
|
||||
surround_mid = None
|
||||
for k, (r_in, r_out) in enumerate(SCALES):
|
||||
s_in, s_out = sizes[2 * k], sizes[2 * k + 1]
|
||||
n_in = s_in[0] * s_in[1]
|
||||
n_out = s_out[0] * s_out[1]
|
||||
num = box[2 * k + 1, 0] * n_out - box[2 * k, 0] * n_in
|
||||
den = box[2 * k + 1, 1] * n_out - box[2 * k, 1] * n_in
|
||||
sur = t.where(den > 0.5, num / den, zero)
|
||||
enough = den > 8.0
|
||||
c = t.where(enough, disp - sur, zero)
|
||||
pos = t.clamp_min(c, 0.0) * v
|
||||
neg = t.clamp_min(-(disp - sur), 0.0) * enough
|
||||
better = pos > on
|
||||
on = t.where(better, pos, on)
|
||||
on_scale = t.where(better, t.full((), k, dtype=t.int8, device=self.dev), on_scale)
|
||||
off = t.maximum(off, neg)
|
||||
if k == 1:
|
||||
surround_mid = sur
|
||||
hole = 1.0 - box[-1, 1]
|
||||
on = t.clamp(on, 0.0, 1.0 / max(r_max, 1.0) * 1e4)
|
||||
return t.stack([disp, on, off, surround_mid, hole]), on_scale
|
||||
|
||||
# ------------------------------------------------------------------ кластеризация
|
||||
|
||||
def _offsets(self, col_reach: int, row_reach: int):
|
||||
key = (col_reach, row_reach)
|
||||
if key not in self._off:
|
||||
pairs = [(dr, dc) for dr in range(0, row_reach + 1)
|
||||
for dc in range(-col_reach, col_reach + 1) if not (dr == 0 and dc <= 0)]
|
||||
p = self.torch.tensor(pairs, dtype=self.torch.int64, device=self.dev)
|
||||
self._off[key] = (p[:, 0:1], p[:, 1:2])
|
||||
return self._off[key]
|
||||
|
||||
def cluster_by_depth(self, mask: np.ndarray, r: np.ndarray, *,
|
||||
rel_tol: float = 0.06, abs_tol: float = 0.35,
|
||||
col_reach: int = 3, row_reach: int = 2):
|
||||
"""То же, что `lobula.cluster_by_depth`, с теми же номерами компонент.
|
||||
|
||||
Рёбра строятся сразу для всех соседей, компоненты — подвешиванием к
|
||||
меньшему номеру со сжатием путей: у каждой компоненты корнем остаётся
|
||||
её наименьший луч, и нумерация по корням совпадает со scipy.
|
||||
"""
|
||||
t = self.torch
|
||||
h, w = mask.shape
|
||||
if r is self._r_host and self.r is not None:
|
||||
rt = self.r.reshape(-1)
|
||||
else:
|
||||
rt = t.as_tensor(np.ascontiguousarray(r), dtype=t.float32,
|
||||
device=self.dev).reshape(-1)
|
||||
m = t.as_tensor(np.ascontiguousarray(mask), device=self.dev).reshape(-1)
|
||||
idx = t.nonzero(m, as_tuple=True)[0]
|
||||
n = idx.numel()
|
||||
if n == 0:
|
||||
return np.zeros(mask.shape, np.int32), 0
|
||||
lut = t.full((h * w,), -1, dtype=t.int64, device=self.dev)
|
||||
lut[idx] = t.arange(n, device=self.dev)
|
||||
rows = t.div(idx, w, rounding_mode="floor")
|
||||
cols = idx - rows * w
|
||||
ri = rt[idx]
|
||||
dr, dc = self._offsets(col_reach, row_reach)
|
||||
nr = rows[None, :] + dr
|
||||
nc = cols[None, :] + dc
|
||||
ok = (nr < h) & (nc >= 0) & (nc < w)
|
||||
nb = lut[t.where(ok, nr * w + nc, t.zeros_like(nr))]
|
||||
ok &= nb >= 0
|
||||
rb = ri[nb.clamp(min=0)]
|
||||
ra = ri[None, :].expand_as(rb)
|
||||
ok &= t.abs(ra - rb) <= (abs_tol + rel_tol * t.minimum(ra, rb))
|
||||
s = t.arange(n, device=self.dev)[None, :].expand_as(nb)[ok]
|
||||
d = nb[ok]
|
||||
|
||||
parent = t.arange(n, device=self.dev)
|
||||
while True:
|
||||
for _ in range(4):
|
||||
ps, pd = parent[s], parent[d]
|
||||
parent.scatter_reduce_(0, t.maximum(ps, pd), t.minimum(ps, pd), reduce="amin")
|
||||
parent = parent[parent]
|
||||
parent = parent[parent]
|
||||
if not bool((parent[s] != parent[d]).any()):
|
||||
break
|
||||
while True:
|
||||
nxt = parent[parent]
|
||||
if bool(t.equal(nxt, parent)):
|
||||
break
|
||||
parent = nxt
|
||||
roots, comp = t.unique(parent, return_inverse=True)
|
||||
labels = t.zeros(h * w, dtype=t.int32, device=self.dev)
|
||||
labels[idx] = (comp + 1).to(t.int32)
|
||||
return labels.reshape(h, w).cpu().numpy(), int(roots.numel())
|
||||
|
|
@ -70,8 +70,8 @@ def _annulus_mean(v: np.ndarray, m: np.ndarray, r_in: int, r_out: int):
|
|||
return out, den
|
||||
|
||||
|
||||
def _process_cpu(r: np.ndarray, valid: np.ndarray, *, r_max: float = 300.0) -> LaminaOutput:
|
||||
"""CPU-реализация через SciPy uniform_filter."""
|
||||
def process(r: np.ndarray, valid: np.ndarray, *, r_max: float = 300.0) -> LaminaOutput:
|
||||
"""Посчитать ON/OFF-каналы ламины по дальностному образу."""
|
||||
v = valid.astype(np.float32)
|
||||
disp = np.zeros_like(r, dtype=np.float32)
|
||||
np.divide(1.0, r, out=disp, where=valid & (r > 0.05))
|
||||
|
|
@ -106,104 +106,6 @@ def _process_cpu(r: np.ndarray, valid: np.ndarray, *, r_max: float = 300.0) -> L
|
|||
hole=hole.astype(np.float32))
|
||||
|
||||
|
||||
def _process_gpu(r: np.ndarray, valid: np.ndarray, *, r_max: float = 300.0, device: str = "cuda") -> LaminaOutput:
|
||||
"""Ускоренная GPU-реализация 2D-фильтрации DoG через PyTorch CUDA тензоры.
|
||||
|
||||
На NVIDIA RTX 4070 Ti Super сокращает время расчета кадра с 8 мс до 0.25 мс.
|
||||
"""
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
with torch.no_grad():
|
||||
dev = torch.device(device)
|
||||
r_t = torch.as_tensor(r, dtype=torch.float32, device=dev)
|
||||
v_t = torch.as_tensor(valid, dtype=torch.float32, device=dev)
|
||||
|
||||
mask_valid = (v_t > 0.5) & (r_t > 0.05)
|
||||
disp_t = torch.where(mask_valid, 1.0 / r_t, torch.zeros_like(r_t)) * v_t
|
||||
|
||||
disp_4d = disp_t.unsqueeze(0).unsqueeze(0) # (1, 1, H, W)
|
||||
v_4d = v_t.unsqueeze(0).unsqueeze(0)
|
||||
|
||||
on_t = torch.zeros_like(disp_t)
|
||||
off_t = torch.zeros_like(disp_t)
|
||||
on_scale_t = torch.zeros_like(disp_t, dtype=torch.int8)
|
||||
surround_mid_t = None
|
||||
|
||||
for k, (r_in, r_out) in enumerate(SCALES):
|
||||
pad_i = (r_in, r_in, r_in, r_in)
|
||||
pad_o = (2 * r_out, 2 * r_out, r_out, r_out)
|
||||
|
||||
k_in = (2 * r_in + 1, 2 * r_in + 1)
|
||||
k_out = (2 * r_out + 1, 4 * r_out + 1)
|
||||
|
||||
disp_pad_i = F.pad(disp_4d, pad_i, mode='replicate')
|
||||
disp_pad_o = F.pad(disp_4d, pad_o, mode='replicate')
|
||||
v_pad_i = F.pad(v_4d, pad_i, mode='replicate')
|
||||
v_pad_o = F.pad(v_4d, pad_o, mode='replicate')
|
||||
|
||||
n_in = float(k_in[0] * k_in[1])
|
||||
n_out = float(k_out[0] * k_out[1])
|
||||
|
||||
sum_disp_i = F.avg_pool2d(disp_pad_i, k_in, stride=1) * n_in
|
||||
sum_disp_o = F.avg_pool2d(disp_pad_o, k_out, stride=1) * n_out
|
||||
sum_v_i = F.avg_pool2d(v_pad_i, k_in, stride=1) * n_in
|
||||
sum_v_o = F.avg_pool2d(v_pad_o, k_out, stride=1) * n_out
|
||||
|
||||
num_t = (sum_disp_o - sum_disp_i).squeeze(0).squeeze(0)
|
||||
den_t = (sum_v_o - sum_v_i).squeeze(0).squeeze(0)
|
||||
|
||||
sur_t = torch.where(den_t > 0.5, num_t / den_t, torch.zeros_like(num_t))
|
||||
enough_t = den_t > 8.0
|
||||
|
||||
c_t = torch.where(enough_t, disp_t - sur_t, torch.zeros_like(disp_t))
|
||||
pos_t = torch.clamp_min(c_t, 0.0) * v_t
|
||||
neg_t = torch.clamp_min(-(disp_t - sur_t), 0.0) * enough_t.float()
|
||||
|
||||
better_t = pos_t > on_t
|
||||
on_t = torch.where(better_t, pos_t, on_t)
|
||||
on_scale_t = torch.where(better_t, torch.tensor(k, dtype=torch.int8, device=dev), on_scale_t)
|
||||
off_t = torch.maximum(off_t, neg_t)
|
||||
|
||||
if k == 1:
|
||||
surround_mid_t = sur_t
|
||||
|
||||
pad_hole = (7, 7, 2, 2)
|
||||
v_pad_h = F.pad(v_4d, pad_hole, mode='replicate')
|
||||
hole_mean = F.avg_pool2d(v_pad_h, (5, 15), stride=1).squeeze(0).squeeze(0)
|
||||
hole_t = 1.0 - hole_mean
|
||||
|
||||
on_t = torch.clamp(on_t, 0.0, 1.0 / max(r_max, 1.0) * 1e4)
|
||||
|
||||
return LaminaOutput(
|
||||
disp=disp_t.cpu().numpy(),
|
||||
on=on_t.cpu().numpy(),
|
||||
off=off_t.cpu().numpy(),
|
||||
on_scale=on_scale_t.cpu().numpy(),
|
||||
surround=surround_mid_t.cpu().numpy() if surround_mid_t is not None else np.zeros_like(r, dtype=np.float32),
|
||||
hole=hole_t.cpu().numpy().astype(np.float32)
|
||||
)
|
||||
|
||||
|
||||
def process(r: np.ndarray, valid: np.ndarray, *, r_max: float = 300.0,
|
||||
device: str | None = None) -> LaminaOutput:
|
||||
"""Посчитать ON/OFF-каналы ламины по дальностному образу (автовыбор GPU / CPU)."""
|
||||
target_dev = device or "cpu" # без явной просьбы — процессор, как раньше
|
||||
if target_dev == "auto":
|
||||
from .device import get_device
|
||||
target_dev = get_device("auto")
|
||||
|
||||
if target_dev.startswith("cuda"):
|
||||
try:
|
||||
return _process_gpu(r, valid, r_max=r_max, device=target_dev)
|
||||
except Exception as e:
|
||||
from .device import notify_cuda_error
|
||||
notify_cuda_error(e)
|
||||
return _process_cpu(r, valid, r_max=r_max)
|
||||
|
||||
return _process_cpu(r, valid, r_max=r_max)
|
||||
|
||||
|
||||
def contrast_to_depth_gap(on: np.ndarray, r: np.ndarray) -> np.ndarray:
|
||||
"""Перевести ON-контраст диспаритета в «насколько ближе окружения», м.
|
||||
|
||||
|
|
|
|||
|
|
@ -212,63 +212,16 @@ def split_by_figure(labels: np.ndarray, n: int, r: np.ndarray,
|
|||
return out, next_id
|
||||
|
||||
|
||||
def _core_zone(abs_lat: np.ndarray, d: np.ndarray, core_width: float,
|
||||
core_from: float) -> np.ndarray:
|
||||
"""Где действует пониженный пол: в колее и не ближе core_from."""
|
||||
zone = abs_lat <= core_width
|
||||
if core_from > 0.0:
|
||||
with np.errstate(invalid="ignore"):
|
||||
zone = zone & (d >= core_from)
|
||||
return zone
|
||||
|
||||
|
||||
def gauge_mask(tf: TrackFrame, corridor: Corridor, *, half_width: float,
|
||||
h_lo: float, h_hi: float, d_min: float, d_max: float,
|
||||
k_sigma: float = 0.0, h_lo_core: float = 1e9,
|
||||
core_width: float = 0.85, core_from: float = 0.0,
|
||||
platform_filter: bool = False,
|
||||
platform_u: float = 1.30, platform_h_lo: float = 1.05,
|
||||
platform_h_hi: float = 1.25,
|
||||
h_top: float = 0.0, half_width_top: float = 0.0,
|
||||
top_d_max: float = 90.0):
|
||||
h_lo: float, h_hi: float, d_min: float, d_max: float):
|
||||
"""Лучи, попавшие в габарит приближения. Считается один раз на кадр.
|
||||
|
||||
Нужны и для выделения кандидатов, и для накопителя веерного тела, поэтому
|
||||
вынесены отдельно: пересчитывать один и тот же набор масок дважды незачем.
|
||||
|
||||
Верхняя секция (`h_top` > `h_hi`) — от `h_hi` до `h_top`, уже основной:
|
||||
|u| < `half_width_top`. Она ловит то, что свисает со свода в путь вагона,
|
||||
и не цепляет кабели и светильники на стенах, которые у круглого свода на
|
||||
этой высоте подходят к оси ближе полуширины габарита. Дальше `top_d_max`
|
||||
её нет: там ошибка наклона плоскости и оси пути поднимает и сдвигает к
|
||||
оси конструкции свода, и на пустых записях они шли в кандидаты сотнями.
|
||||
"""
|
||||
lat = tf.lateral(corridor)
|
||||
abs_lat = np.abs(lat)
|
||||
in_range = tf.valid & (tf.d > d_min) & (tf.d < d_max)
|
||||
|
||||
if k_sigma > 0.0:
|
||||
sig = corridor.sigma(tf.d)
|
||||
w_eff = half_width + k_sigma * np.maximum(sig - 0.25, 0.0)
|
||||
else:
|
||||
w_eff = half_width
|
||||
|
||||
# Двухзонный пол: в межрельсовой колее (|u| <= core_width) порог h_lo_core (0.16 м),
|
||||
# чтобы не терять лежащего человека или низкие предметы. Опускается он
|
||||
# только дальше core_from: вблизи в полосу 0.16…0.28 м попадают головки
|
||||
# рельсов, и предмет теряется вместе с ними (EXPERIMENTS п. 16).
|
||||
floor_h = np.where(_core_zone(abs_lat, tf.d, core_width, core_from),
|
||||
min(h_lo_core, h_lo), h_lo)
|
||||
|
||||
inside = in_range & (abs_lat < w_eff) & (tf.h > floor_h) & (tf.h < h_hi)
|
||||
if h_top > h_hi and half_width_top > 0.0:
|
||||
inside |= (in_range & (tf.d < top_d_max) & (abs_lat < half_width_top)
|
||||
& (tf.h >= h_hi) & (tf.h < h_top))
|
||||
|
||||
if platform_filter:
|
||||
platform = (abs_lat >= platform_u) & (tf.h >= platform_h_lo) & (tf.h <= platform_h_hi)
|
||||
inside = inside & ~platform
|
||||
|
||||
inside = in_range & (np.abs(lat) < half_width) & (tf.h > h_lo) & (tf.h < h_hi)
|
||||
return inside, lat, in_range
|
||||
|
||||
|
||||
|
|
@ -284,15 +237,7 @@ def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *,
|
|||
near_long: float = 0.0,
|
||||
split_top: int = 0,
|
||||
advance: np.ndarray | None = None,
|
||||
masks: tuple | None = None,
|
||||
k_sigma: float = 0.0, h_lo_core: float = 1e9,
|
||||
core_width: float = 0.85, core_from: float = 0.0,
|
||||
platform_filter: bool = False,
|
||||
platform_u: float = 1.30, platform_h_lo: float = 1.05,
|
||||
platform_h_hi: float = 1.25,
|
||||
h_top: float = 0.0, half_width_top: float = 0.0,
|
||||
top_d_max: float = 90.0, top_detect: bool = False,
|
||||
top_min_rays: int = 3, cluster=None) -> list[Candidate]:
|
||||
masks: tuple | None = None) -> list[Candidate]:
|
||||
"""Выделить кандидатов: связные пятна лучей, попавших в габарит.
|
||||
|
||||
Кластеризация ведётся по **расширенной** области, а членство в габарите
|
||||
|
|
@ -303,12 +248,7 @@ def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *,
|
|||
"""
|
||||
if masks is None:
|
||||
masks = gauge_mask(tf, corridor, half_width=half_width, h_lo=h_lo,
|
||||
h_hi=h_hi, d_min=d_min, d_max=d_max,
|
||||
k_sigma=k_sigma, h_lo_core=h_lo_core,
|
||||
core_width=core_width, core_from=core_from,
|
||||
platform_filter=platform_filter,
|
||||
platform_u=platform_u, platform_h_lo=platform_h_lo,
|
||||
platform_h_hi=platform_h_hi)
|
||||
h_hi=h_hi, d_min=d_min, d_max=d_max)
|
||||
inside, lat, in_range = masks
|
||||
if not inside.any():
|
||||
return []
|
||||
|
|
@ -319,27 +259,12 @@ def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *,
|
|||
# Вверх, наоборот, нужно доставать до свода: колонна и рама крепи идут от
|
||||
# полотна до потолка, и если обрезать контекст на полпути, срез сам выглядит
|
||||
# компактным предметом ростом с габарит.
|
||||
abs_lat = np.abs(lat)
|
||||
if k_sigma > 0.0:
|
||||
sig = corridor.sigma(tf.d)
|
||||
w_ctx = half_width + 1.6 + k_sigma * np.maximum(sig - 0.25, 0.0)
|
||||
else:
|
||||
w_ctx = half_width + 1.6
|
||||
|
||||
floor_h = np.where(_core_zone(abs_lat, tf.d, core_width, core_from),
|
||||
min(h_lo_core, h_lo), h_lo)
|
||||
context = (in_range & (abs_lat < w_ctx)
|
||||
& (tf.h > floor_h) & (tf.h < h_hi + ctx_up))
|
||||
|
||||
if platform_filter:
|
||||
platform = (abs_lat >= platform_u) & (tf.h >= platform_h_lo) & (tf.h <= platform_h_hi)
|
||||
context = context & ~platform
|
||||
context = (in_range & (np.abs(lat) < half_width + 1.6)
|
||||
& (tf.h > h_lo) & (tf.h < h_hi + ctx_up))
|
||||
|
||||
gap_img = contrast_to_depth_gap(lam.on, tf.r)
|
||||
|
||||
# `cluster` — та же кластеризация на видеокарте (FlyGuard._cluster), с тем
|
||||
# же результатом; здесь самая большая маска кадра, и выигрыш в ней
|
||||
labels, n = (cluster or cluster_by_depth)(context, tf.r, col_reach=dilate_cols + 1)
|
||||
labels, n = cluster_by_depth(context, tf.r, col_reach=dilate_cols + 1)
|
||||
if n == 0:
|
||||
return []
|
||||
if split_gap > 0:
|
||||
|
|
@ -415,27 +340,33 @@ def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *,
|
|||
sorted_lbl = labels.ravel()[order]
|
||||
starts = np.searchsorted(sorted_lbl, np.arange(n + 2))
|
||||
|
||||
width_img = labels.shape[1]
|
||||
inside_flat = inside.ravel()
|
||||
out: list[Candidate] = []
|
||||
for lbl in keep:
|
||||
ctx_idx = order[starts[lbl]:starts[lbl + 1]]
|
||||
sel = ctx_idx[inside_flat[ctx_idx]] # признаки считаем по ядру в габарите
|
||||
if sel.size < need[lbl]:
|
||||
continue
|
||||
ii, jj = np.divmod(sel, labels.shape[1])
|
||||
|
||||
def build(sel: np.ndarray, n_ctx: int) -> Candidate | None:
|
||||
"""Кандидат по лучам ядра `sel` (плоские индексы) из компоненты в `n_ctx` лучей."""
|
||||
ii, jj = np.divmod(sel, width_img)
|
||||
d = tf.d[ii, jj]
|
||||
u = lat[ii, jj]
|
||||
h = tf.h[ii, jj]
|
||||
nn = d.size
|
||||
|
||||
depth = float(d.max() - d.min()) if nn > 1 else 0.0
|
||||
if depth > max_depth:
|
||||
# ни один посторонний предмет не тянется на десятки метров вдоль пути:
|
||||
# это полотно, стена или кабельный лоток, взятые под скользящим углом
|
||||
return None
|
||||
continue
|
||||
|
||||
cand = Candidate(
|
||||
d=float(np.median(d)), u=float(np.median(u)), h=float(np.median(h)),
|
||||
d_min=float(d.min()), h_min=float(h.min()),
|
||||
width=float(u.max() - u.min()) if nn > 1 else 0.0,
|
||||
height=float(h.max() - h.min()) if nn > 1 else 0.0,
|
||||
depth=depth,
|
||||
containment=float(sel.size / max(n_ctx, 1)),
|
||||
containment=float(sel.size / max(ctx_idx.size, 1)),
|
||||
n_rays=int(nn), n_rings=int(np.unique(ii).size), n_cols=int(np.unique(jj).size),
|
||||
gap=float(np.median(gap_img[ii, jj])),
|
||||
on=float(np.median(lam.on[ii, jj])),
|
||||
|
|
@ -448,86 +379,7 @@ def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *,
|
|||
)
|
||||
# индексы лучей нужны только для отладочной визуализации; они небольшие
|
||||
cand.extra["rays"] = (ii, jj)
|
||||
# Смещение в системе лидара, без поправки на кривую: `u` отсчитан от
|
||||
# оси пути, и в кривой рамка по нему встала бы в стороне от своих
|
||||
# точек (на синтетике — в 1.2 м на 39 м). Нужно только для рисования.
|
||||
cand.extra["u_raw"] = float(np.median(tf.u[ii, jj]))
|
||||
return cand
|
||||
|
||||
use_top = top_detect and h_top > h_hi and half_width_top > 0.0
|
||||
h_flat = tf.h.ravel()
|
||||
inside_flat = inside.ravel()
|
||||
out: list[Candidate] = []
|
||||
for lbl in keep:
|
||||
ctx_idx = order[starts[lbl]:starts[lbl + 1]]
|
||||
sel = ctx_idx[inside_flat[ctx_idx]] # признаки считаем по ядру в габарите
|
||||
if sel.size < need[lbl]:
|
||||
continue
|
||||
if use_top and np.all(h_flat[sel] >= h_hi):
|
||||
# целиком в верхней секции: это работа отдельного прохода ниже —
|
||||
# здесь такой кандидат склеен со сводом и только двоил бы трек
|
||||
continue
|
||||
cand = build(sel, ctx_idx.size)
|
||||
if cand is not None:
|
||||
out.append(cand)
|
||||
|
||||
if use_top:
|
||||
out += _hanging(tf, abs_lat, in_range, build, h_hi=h_hi, h_top=h_top,
|
||||
half_width_top=half_width_top, top_d_max=top_d_max,
|
||||
min_rays=top_min_rays, col_reach=dilate_cols + 1)
|
||||
|
||||
out.sort(key=lambda c: c.d)
|
||||
return out
|
||||
|
||||
|
||||
def _hanging(tf: TrackFrame, abs_lat, in_range, build, *, h_hi: float, h_top: float,
|
||||
half_width_top: float, top_d_max: float, min_rays: int,
|
||||
col_reach: int, margin_u: float = 0.3, below: float = 0.4) -> list[Candidate]:
|
||||
"""Свисающее со свода в путь вагона: отдельный проход по верхней секции.
|
||||
|
||||
Основной проход тянет контекст до свода, чтобы колонна не выглядела
|
||||
предметом, — и поэтому всё, что свисает с потолка, склеивается со сводом:
|
||||
доля лучей в габарите у стержня из синтетики организаторов была ноль.
|
||||
Просто не пускать свод в контекст нельзя: тогда освобождаются и стоящие
|
||||
конструкции, прицепленные к своду, — на `roundT_doubleT` так вышла тревога
|
||||
на светофоре у стрелки. Поэтому здесь свой проход: контекст — только сама
|
||||
верхняя секция с запасом `margin_u` по сторонам и `below` вниз. Компонента,
|
||||
у которой есть лучи ниже `h_hi`, продолжается к полу: это стоящий предмет
|
||||
(столб, светофор, человек высокого роста), и его ведёт основной проход.
|
||||
Висящему не нужна опора снизу, и обученное считывание его не судит
|
||||
(см. central_complex._quality, `top_from`). EXPERIMENTS п. 19.
|
||||
"""
|
||||
band = (in_range & (tf.d < top_d_max) & (abs_lat < half_width_top)
|
||||
& (tf.h >= h_hi) & (tf.h < h_top))
|
||||
if not band.any():
|
||||
return []
|
||||
ext = (in_range & (tf.d < top_d_max) & (abs_lat < half_width_top + margin_u)
|
||||
& (tf.h > h_hi - below) & (tf.h < h_top))
|
||||
labels, n = cluster_by_depth(ext, tf.r, col_reach=col_reach)
|
||||
if n == 0:
|
||||
return []
|
||||
flat_lbl = labels.ravel()
|
||||
order = np.argsort(flat_lbl, kind="stable")
|
||||
starts = np.searchsorted(flat_lbl[order], np.arange(n + 2))
|
||||
band_flat = band.ravel()
|
||||
low_flat = (tf.h < h_hi).ravel()
|
||||
out = []
|
||||
for lbl in range(1, n + 1):
|
||||
idx = order[starts[lbl]:starts[lbl + 1]]
|
||||
if idx.size < min_rays or low_flat[idx].any():
|
||||
continue
|
||||
sel = idx[band_flat[idx]]
|
||||
if sel.size < min_rays:
|
||||
continue
|
||||
cand = build(sel, idx.size)
|
||||
if cand is None:
|
||||
continue
|
||||
if cand.height < 0.1 and cand.h_min > h_top - 0.2:
|
||||
# Плоское у самой верхней границы — это свод: на 80–90 м ошибка
|
||||
# наклона плоскости пути опускает его в секцию (на `squareT_…`
|
||||
# так вышла тревога на 83 м, высота 3.66–3.69 м при границе 3.7).
|
||||
# Свисающее начинается ниже и имеет высоту.
|
||||
continue
|
||||
cand.extra["top"] = True
|
||||
out.append(cand)
|
||||
return out
|
||||
|
|
|
|||
|
|
@ -125,11 +125,7 @@ class MbonReadout:
|
|||
k = self.n_active
|
||||
chunk = max(1, int(2 ** 26 // max(self.cfg.n_kc, 1)))
|
||||
|
||||
if device == "auto":
|
||||
from .device import get_device
|
||||
device = get_device("auto")
|
||||
if device and device != "cpu":
|
||||
try:
|
||||
import torch
|
||||
out_i = np.empty((X.shape[0], k), np.int64)
|
||||
out_v = np.empty((X.shape[0], k), np.float32)
|
||||
|
|
@ -145,10 +141,6 @@ class MbonReadout:
|
|||
out_i[i:i + chunk] = a.cpu().numpy()
|
||||
out_v[i:i + chunk] = v.cpu().numpy()
|
||||
return out_i, out_v
|
||||
except Exception as e:
|
||||
from .device import notify_cuda_error
|
||||
notify_cuda_error(e)
|
||||
# Переход к расчету на CPU
|
||||
|
||||
out_i = np.empty((X.shape[0], k), np.int64)
|
||||
out_v = np.empty((X.shape[0], k), np.float32)
|
||||
|
|
@ -217,11 +209,18 @@ class MbonReadout:
|
|||
|
||||
# ------------------------------------------------------------------ обучение
|
||||
|
||||
def _learn_cpu(self, X: np.ndarray, y: np.ndarray, *, epochs: int = 60,
|
||||
lr: float = 4.0, l2: float = 1e-5, verbose: bool = False) -> None:
|
||||
"""Обучение на CPU через NumPy."""
|
||||
def learn(self, X: np.ndarray, y: np.ndarray, *, epochs: int = 60,
|
||||
lr: float = 4.0, l2: float = 1e-5, device: str | None = None,
|
||||
verbose: bool = False) -> None:
|
||||
"""Логистическая регрессия по разрежённому коду — депрессия с учителем.
|
||||
|
||||
Градиент по весу клетки Кеньона — это сумма ошибок по тем примерам, где
|
||||
она была активна, взвешенная её же откликом. То есть буквально: синапс
|
||||
ослабляется на примерах, где MBON сработал зря, и усиливается там, где
|
||||
не сработал зря. У мухи это делает дофамин.
|
||||
"""
|
||||
y = np.asarray(y, np.float32)
|
||||
a, v = self.encode(X, device="cpu")
|
||||
a, v = self.encode(X, device=device)
|
||||
n, k = a.shape
|
||||
flat = a.ravel()
|
||||
for ep in range(epochs):
|
||||
|
|
@ -238,6 +237,8 @@ class MbonReadout:
|
|||
print(f" эпоха {ep + 1:4d}: логистическая потеря {loss:.4f}")
|
||||
zr = self.bias + (self.w_mbon[a] * v).sum(axis=1)
|
||||
self._calibrate(zr, y)
|
||||
# опора по полосам: медиана калиброванного логита среди всех
|
||||
# кандидатов полосы. Первый признак дескриптора — log(d).
|
||||
zc = self.gain * zr + self.shift
|
||||
dd = np.exp(np.asarray(X[:, 0], np.float64))
|
||||
ref = []
|
||||
|
|
@ -247,107 +248,6 @@ class MbonReadout:
|
|||
else (ref[-1] if ref else 0.0))
|
||||
self.ref_z = np.array(ref, np.float32)
|
||||
|
||||
def _learn_gpu(self, X: np.ndarray, y: np.ndarray, *, epochs: int = 60,
|
||||
lr: float = 4.0, l2: float = 1e-5, device: str = "cuda",
|
||||
verbose: bool = False) -> None:
|
||||
"""GPU-ускоренная оптимизация MBON с учителем через тензоры PyTorch на CUDA.
|
||||
|
||||
Устраняет синхронизацию с CPU внутри цикла по эпохам, позволяя масштабировать
|
||||
ёмкость до 50 000 – 100 000 клеток Кеньона без падения скорости.
|
||||
"""
|
||||
import torch
|
||||
dev = torch.device(device)
|
||||
y_t = torch.as_tensor(y, dtype=torch.float32, device=dev)
|
||||
n = y_t.shape[0]
|
||||
k = self.n_active
|
||||
|
||||
m = torch.as_tensor(self.mean, dtype=torch.float32, device=dev)
|
||||
s = torch.as_tensor(self.scale, dtype=torch.float32, device=dev)
|
||||
w_proj = torch.as_tensor(self.W, dtype=torch.float32, device=dev).T.contiguous()
|
||||
|
||||
chunk = max(1, int(2 ** 26 // max(self.cfg.n_kc, 1)))
|
||||
a_list, v_list = [], []
|
||||
with torch.no_grad():
|
||||
for i in range(0, n, chunk):
|
||||
t = torch.as_tensor(X[i:i + chunk], dtype=torch.float32, device=dev)
|
||||
y_proj = torch.relu(((t - m) / s) @ w_proj)
|
||||
v_chunk, a_chunk = torch.topk(y_proj, k, dim=1)
|
||||
v_chunk = v_chunk * (k / v_chunk.sum(1, keepdim=True).clamp_min(1e-6))
|
||||
a_list.append(a_chunk)
|
||||
v_list.append(v_chunk)
|
||||
|
||||
a_t = torch.cat(a_list, dim=0) if len(a_list) > 1 else a_list[0]
|
||||
v_t = torch.cat(v_list, dim=0) if len(v_list) > 1 else v_list[0]
|
||||
|
||||
w_mbon_t = torch.as_tensor(self.w_mbon, dtype=torch.float32, device=dev)
|
||||
bias_t = torch.tensor(self.bias, dtype=torch.float32, device=dev)
|
||||
flat_a = a_t.reshape(-1)
|
||||
|
||||
for ep in range(epochs):
|
||||
z = bias_t + torch.sum(w_mbon_t[a_t] * v_t, dim=1)
|
||||
p = torch.sigmoid(z)
|
||||
g = (p - y_t) / n
|
||||
weights = (g.unsqueeze(1) * v_t).reshape(-1)
|
||||
grad = torch.zeros(self.cfg.n_kc, dtype=torch.float32, device=dev)
|
||||
grad.scatter_add_(0, flat_a, weights)
|
||||
w_mbon_t -= lr * (grad + l2 * w_mbon_t)
|
||||
bias_t -= lr * g.sum()
|
||||
|
||||
if verbose and (ep + 1) % 50 == 0:
|
||||
loss = -(y_t * torch.log(p.clamp_min(1e-7)) + (1.0 - y_t) * torch.log((1.0 - p).clamp_min(1e-7))).mean()
|
||||
print(f" [GPU] эпоха {ep + 1:4d}: логистическая потеря {loss.item():.4f}")
|
||||
|
||||
zr_t = bias_t + torch.sum(w_mbon_t[a_t] * v_t, dim=1)
|
||||
|
||||
# Калибровка Платта полностью на GPU
|
||||
g_t = torch.tensor(1.0, dtype=torch.float32, device=dev)
|
||||
sh_t = torch.tensor(0.0, dtype=torch.float32, device=dev)
|
||||
for _ in range(400):
|
||||
p_cal = torch.sigmoid(g_t * zr_t + sh_t)
|
||||
e_cal = p_cal - y_t
|
||||
g_t -= 2.0 * (e_cal * zr_t).mean() / torch.clamp_min((zr_t * zr_t).mean(), 1e-6)
|
||||
sh_t -= 2.0 * e_cal.mean()
|
||||
|
||||
self.w_mbon = w_mbon_t.cpu().numpy()
|
||||
self.bias = np.float32(bias_t.item())
|
||||
self.gain = np.float32(g_t.item())
|
||||
self.shift = np.float32(sh_t.item())
|
||||
|
||||
zr = zr_t.cpu().numpy()
|
||||
zc = self.gain * zr + self.shift
|
||||
dd = np.exp(np.asarray(X[:, 0], np.float64))
|
||||
ref = []
|
||||
for lo, hi in zip(self.ref_edges[:-1], self.ref_edges[1:]):
|
||||
m_mask = (dd >= lo) & (dd < hi)
|
||||
ref.append(float(np.median(zc[m_mask])) if m_mask.sum() >= 50
|
||||
else (ref[-1] if ref else 0.0))
|
||||
self.ref_z = np.array(ref, np.float32)
|
||||
|
||||
def learn(self, X: np.ndarray, y: np.ndarray, *, epochs: int = 60,
|
||||
lr: float = 4.0, l2: float = 1e-5, device: str | None = None,
|
||||
verbose: bool = False) -> None:
|
||||
"""Логистическая регрессия по разрежённому коду — депрессия с учителем.
|
||||
|
||||
Поддерживает прозрачное переключение между NVIDIA GPU (CUDA) и CPU.
|
||||
"""
|
||||
target_dev = device or "cpu" # без явной просьбы — процессор, как раньше
|
||||
if target_dev == "auto":
|
||||
from .device import get_device
|
||||
target_dev = get_device("auto")
|
||||
|
||||
if target_dev.startswith("cuda"):
|
||||
try:
|
||||
self._learn_gpu(X, y, epochs=epochs, lr=lr, l2=l2,
|
||||
device=target_dev, verbose=verbose)
|
||||
return
|
||||
except Exception as e:
|
||||
from .device import notify_cuda_error
|
||||
notify_cuda_error(e)
|
||||
import logging
|
||||
logging.getLogger("flyguard.mbon").warning("GPU learning failed (%s), fallback to CPU", e)
|
||||
|
||||
self._learn_cpu(X, y, epochs=epochs, lr=lr, l2=l2, verbose=verbose)
|
||||
|
||||
def _calibrate(self, z: np.ndarray, y: np.ndarray, iters: int = 400) -> None:
|
||||
"""Шкалирование Платта: подобрать наклон и сдвиг по обучающей выборке."""
|
||||
g, sh = 1.0, 0.0
|
||||
|
|
|
|||
|
|
@ -152,28 +152,18 @@ class MushroomBody:
|
|||
# целиком, поэтому кодирование идёт порциями фиксированного объёма
|
||||
chunk = max(1, int(2 ** 26 // max(self.cfg.n_kc, 1)))
|
||||
|
||||
target_dev = device
|
||||
if target_dev == "auto":
|
||||
from .device import get_device
|
||||
target_dev = get_device("auto")
|
||||
|
||||
if target_dev and target_dev != "cpu":
|
||||
try:
|
||||
if device and device != "cpu":
|
||||
import torch
|
||||
out = np.empty((X.shape[0], k), np.int64)
|
||||
with torch.no_grad():
|
||||
m = torch.as_tensor(self.mean, device=target_dev)
|
||||
s = torch.as_tensor(self.scale, device=target_dev)
|
||||
w = torch.as_tensor(self.W, device=target_dev).T.contiguous()
|
||||
m = torch.as_tensor(self.mean, device=device)
|
||||
s = torch.as_tensor(self.scale, device=device)
|
||||
w = torch.as_tensor(self.W, device=device).T.contiguous()
|
||||
for i in range(0, X.shape[0], chunk):
|
||||
t = torch.as_tensor(X[i:i + chunk], device=target_dev)
|
||||
t = torch.as_tensor(X[i:i + chunk], device=device)
|
||||
y = ((t - m) / s) @ w
|
||||
out[i:i + chunk] = torch.topk(y, k, dim=1).indices.cpu().numpy()
|
||||
return out
|
||||
except Exception as e:
|
||||
from .device import notify_cuda_error
|
||||
notify_cuda_error(e)
|
||||
# Fallback на CPU ниже
|
||||
|
||||
if X.shape[0] <= chunk:
|
||||
z = (X - self.mean) / self.scale
|
||||
|
|
@ -213,39 +203,7 @@ class MushroomBody:
|
|||
для набора сразу эквивалентно возведению в степень по числу попаданий, —
|
||||
поэтому цикл по примерам не нужен.
|
||||
"""
|
||||
target_dev = device or "cpu" # без явной просьбы — процессор, как раньше
|
||||
if target_dev == "auto":
|
||||
from .device import get_device
|
||||
target_dev = get_device("auto")
|
||||
|
||||
if target_dev.startswith("cuda"):
|
||||
try:
|
||||
import torch
|
||||
dev = torch.device(target_dev)
|
||||
k = self.n_active
|
||||
chunk = max(1, int(2 ** 26 // max(self.cfg.n_kc, 1)))
|
||||
m = torch.as_tensor(self.mean, device=dev)
|
||||
s = torch.as_tensor(self.scale, device=dev)
|
||||
w = torch.as_tensor(self.W, device=dev).T.contiguous()
|
||||
act_list = []
|
||||
with torch.no_grad():
|
||||
for i in range(0, X.shape[0], chunk):
|
||||
t = torch.as_tensor(X[i:i + chunk], device=dev)
|
||||
y = ((t - m) / s) @ w
|
||||
top_idx = torch.topk(y, k, dim=1).indices
|
||||
act_list.append(top_idx)
|
||||
act_t = torch.cat(act_list, dim=0) if len(act_list) > 1 else act_list[0]
|
||||
cnt_t = torch.bincount(act_t.reshape(-1), minlength=self.cfg.n_kc)
|
||||
w_mbon_t = torch.as_tensor(self.w_mbon, device=dev)
|
||||
w_mbon_t *= torch.pow(torch.tensor(1.0 - rate, device=dev), cnt_t.float())
|
||||
self.w_mbon = w_mbon_t.cpu().numpy()
|
||||
self.n_seen += X.shape[0]
|
||||
return
|
||||
except Exception as e:
|
||||
from .device import notify_cuda_error
|
||||
notify_cuda_error(e)
|
||||
|
||||
act = self.encode(X, device="cpu")
|
||||
act = self.encode(X, device=device)
|
||||
cnt = np.bincount(act.ravel(), minlength=self.cfg.n_kc)
|
||||
self.w_mbon *= np.power(1.0 - rate, cnt).astype(np.float32)
|
||||
self.n_seen += len(act)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@
|
|||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
|
@ -23,7 +22,7 @@ from .descending import Decision, DescendingNeurons
|
|||
from .geometry import (STRAIGHT, Corridor, RailPlane, TrackFrame, fit_corridor,
|
||||
fit_rail_plane)
|
||||
from .fan_body import FanBody
|
||||
from .lobula import Candidate, cluster_by_depth, find_candidates, gauge_mask
|
||||
from .lobula import Candidate, find_candidates, gauge_mask
|
||||
from .medulla import EgoMotion, EgoMotionEstimator, EmdBank, looming
|
||||
from .mbon_readout import MbonReadout
|
||||
from .mushroom_body import Habituation, HabituationConfig, MushroomBody, NoMemory
|
||||
|
|
@ -35,58 +34,9 @@ class Params:
|
|||
"""Все настройки конвейера в одном месте."""
|
||||
|
||||
fov_deg: float = 30.0 # полусектор обработки по азимуту
|
||||
# Полуширина габарита. 1.2, а не 1.6: так габарит задают организаторы в
|
||||
# синтетике 24.09 — «у края» кончается на 1.13 м от оси, «вне габарита,
|
||||
# но близко» начинается с 1.14. При 1.6 второй давал ложную тревогу, а
|
||||
# в `doubleT_obstacle` в полосу заходили колонны между путями. Полигон
|
||||
# парно +43 / −33 наблюдения, посторонних 793 → 742 (EXPERIMENTS п. 18.4).
|
||||
half_width: float = 1.2
|
||||
half_width: float = 1.6 # полуширина габарита, м
|
||||
h_lo: float = 0.28 # нижняя граница габарита над рельсом, м
|
||||
# Три правки габарита (Zhirik1337, 22.09), каждая мерилась отдельно на
|
||||
# двух сценах и полигоне (EXPERIMENTS п. 16):
|
||||
# h_lo_core — пол в колее: между рельсами (|u| ≤ core_width) нижняя
|
||||
# граница опускается, чтобы не терять лежащего человека.
|
||||
# ПРИНЯТО, но только дальше core_from: ближе в полосу
|
||||
# 0.16…0.28 м попадают головки рельсов, рельс собирается
|
||||
# в одну компоненту от кабины, и предмет выбрасывается
|
||||
# вместе с ней. Лежачий P@50 0.27 → 0.50;
|
||||
# k_sigma — габарит расширяется там, где ось пути известна хуже
|
||||
# (в кривых вдали); 0 — не расширять. Отвергнуто;
|
||||
# platform_filter — вырезать из габарита настил платформы. Отвергнуто:
|
||||
# в тоннеле без платформы режет стену на обрывки.
|
||||
h_lo_core: float = 0.16
|
||||
core_width: float = 0.85 # полуширина колеи, м
|
||||
core_from: float = 30.0 # ближе этой дальности пол в колее не опускается, м
|
||||
k_sigma: float = 0.0
|
||||
platform_filter: bool = False
|
||||
h_hi: float = 2.3 # верхняя граница, м
|
||||
# Верхняя секция габарита: от h_hi до h_top при |u| < half_width_top —
|
||||
# то, что свисает со свода в путь вагона (см. lobula.gauge_mask). Вагон
|
||||
# выше 3.5 м, а предмет «2×2 сверху» у организаторов висит низом на 3.0 м
|
||||
# над рельсом — при верхе 2.3 м он был не виден вовсе. Верх 3.3, а не 3.7:
|
||||
# у свода в настоящих тоннелях свои кабели и кронштейны, и ошибка наклона
|
||||
# плоскости пути на 60–90 м опускает их на 0.1–0.3 м — при 3.7 они шли в
|
||||
# тревоги (низ у них в основном выше 3.2 м, у предметов организаторов —
|
||||
# 2.8 и 3.0). h_top <= h_hi — секции нет. EXPERIMENTS п. 18.4, 19.
|
||||
h_top: float = 3.3
|
||||
half_width_top: float = 1.0
|
||||
top_d_max: float = 90.0 # дальше верхней секции нет, м
|
||||
# Плоское у пола (см. central_complex._quality): вертикальный размер меньше
|
||||
# flat_h, верх ниже flat_top над рельсом, не дальше flat_d — вес наблюдения
|
||||
# умножается на flat_w. flat_h: 0 — выключено. EXPERIMENTS п. 19.
|
||||
flat_h: float = 0.06
|
||||
# Отдельный проход по верхней секции для свисающего со свода (см.
|
||||
# lobula._hanging): в основном проходе оно склеено со сводом.
|
||||
top_detect: bool = True
|
||||
# Дальний трек (дальше far_confirm_from) поднимает тревогу, только прожив
|
||||
# far_confirm кадров (см. descending.py). 0 — выключено. Замерено и
|
||||
# отвергнуто: 15 кадров убирают короткие дальние фантомы, но полигон теряет
|
||||
# 200 наблюдений, P@150 у человека стоя 0.46 → 0.37. EXPERIMENTS п. 19.
|
||||
far_confirm: int = 0
|
||||
far_confirm_from: float = 100.0
|
||||
flat_top: float = 0.26
|
||||
flat_d: float = 70.0
|
||||
flat_w: float = 0.15
|
||||
ctx_up: float = 4.0 # насколько контекст лезет выше габарита, м
|
||||
# Разделение фигуры и фона по движению (см. lobula.split_by_figure):
|
||||
# порог скорости сближения вдоль луча, в долях пройденного пути.
|
||||
|
|
@ -136,13 +86,7 @@ class Params:
|
|||
# веса улики в центральном комплексе там, где модель передана конвейеру;
|
||||
# без модели всё работает по-прежнему.
|
||||
enable_mbon: bool = True
|
||||
# Резкость: вес наблюдения p**power. Рабочая точка 1.5 выбрана сравнением
|
||||
# при равной строгости (EXPERIMENTS п. 17.4): ложных треков на незнакомой
|
||||
# линии 23.5 → 14.7 на км, на знакомой 6.5 → 5.7, на второй половине
|
||||
# new_data 23.9 → 17.4, а полигон теряет 142 наблюдения из 7 600: 39
|
||||
# вблизи у края габарита (пограничный случай п. 17.1–17.2) и 82 за 90 м.
|
||||
# 2.0 срезает ещё 127 наблюдений ради 0.4 трека на км.
|
||||
mbon_power: float = 1.5
|
||||
mbon_power: float = 1.0 # резкость: p**power перед смешиванием
|
||||
mbon_blend: float = 1.0 # 1 — только модель, 0 — только ручная формула
|
||||
# Гашение знакомости с дальностью: за `nov_fade_from` её вклад падает
|
||||
# до нуля к `nov_fade_to`. За 90 м новизна измеримо ВРЕДНА — у
|
||||
|
|
@ -163,16 +107,6 @@ class Params:
|
|||
# Считывание по треку (см. track_readout.py): геометрическое смешивание
|
||||
# обученного отсчёта с уликой перед сравнением с порогом. 0 — решает
|
||||
# только улика, как раньше. Замер — EXPERIMENTS п. 15.
|
||||
# Правки решения (Zhirik1337, 22.09), обе выключены по замеру (п. 16):
|
||||
# lying_exempt — не штрафовать за вытянутость низкий предмет в колее
|
||||
# (лежащий человек вытянут вдоль пути, как конструкция).
|
||||
# Правит ручную формулу, а при mbon_blend = 1 вес целиком
|
||||
# от модели — не действует;
|
||||
# far_channel — дальний канал предупреждения в обход порога. Выигрыш
|
||||
# настоящий, но та же задача, что warn_far, втрое дороже:
|
||||
# +3.0 и +7.1 ложных трека на км на двух сценах.
|
||||
lying_exempt: bool = False
|
||||
far_channel: bool = False
|
||||
track_blend: float = 0.0
|
||||
track_gate: float = 0.0 # жёсткий порог по тому же отсчёту
|
||||
# Чем считать «насколько это предмет» по треку: `w_mean` — средний вес
|
||||
|
|
@ -191,18 +125,9 @@ class Params:
|
|||
# (3.3 на км в обоих случаях). Замер — EXPERIMENTS п. 11.3.
|
||||
min_hits: int = 2
|
||||
novelty_floor: float = 0.10
|
||||
# Порог тревоги, линейно опускаемый с 0.5 до `warn_far` на отрезке от
|
||||
# `warn_far_from` до 200 м. В п. 12.2 ровно эта правка была отвергнута:
|
||||
# тогда улика далёкого трека колебалась около нуля, и опускать порог под
|
||||
# нулевым сигналом было бессмысленно. После гашения знакомости и порога
|
||||
# по лучам улика перестала быть нулём, и та же правка дала P@150
|
||||
# 0.33 → 0.37 без потерь на 50 и 100 м ценой нуля ложных треков на
|
||||
# знакомой линии и одного на незнакомой. Экстренного торможения она не
|
||||
# касается: у него свой порог и условие d ≤ max(тормозной путь, 25 м).
|
||||
# EXPERIMENTS п. 15.9.
|
||||
warn_far: float = 0.30
|
||||
warn_far_from: float = 90.0
|
||||
leak_far: float = 0.12 # утечка улики на дальнем краю; равна leak — выключено
|
||||
warn_far: float = 0.5 # порог тревоги на дальнем краю
|
||||
warn_far_from: float = 90.0 # с какой дальности он начинает падать
|
||||
leak_far: float = 0.12 # утечка улики на дальнем краю
|
||||
leak_far_from: float = 90.0 # с какой дальности она начинает падать
|
||||
d_min: float = 4.0
|
||||
d_max: float = 220.0
|
||||
|
|
@ -217,18 +142,7 @@ class Params:
|
|||
min_rays_far: int = 3 # 0 — не различать
|
||||
min_rays_far_from: float = 90.0
|
||||
calib_frames: int = 12
|
||||
# Скачок времени записи больше этого (или назад) — перемотка, повтор или
|
||||
# выпадение данных: треки, одометрия и ось пути начинаются заново. 0 — не
|
||||
# сбрасывать никогда.
|
||||
reset_gap_s: float = 2.0
|
||||
novelty_gate: float = 0.0 # ниже этой новизны кандидат отбрасывается сразу
|
||||
# Где считать плотные стадии — сетчатку, ламину, кластеризацию (gpu.py):
|
||||
# 'auto' — видеокарта, если PyTorch её видит, иначе процессор; 'cuda';
|
||||
# 'cpu'. Результат одинаков: на видеокарте те же операции в тех же типах.
|
||||
# Любой сбой видеокарты переводит конвейер на процессор до конца работы,
|
||||
# кадр досчитывается там же. Узел поднимает видеокарту в фоне
|
||||
# (`gpu_background`), считая первые кадры на процессоре. EXPERIMENTS п. 21.
|
||||
device: str = "auto"
|
||||
enable_motion: bool = True
|
||||
enable_memory: bool = True
|
||||
enable_looming: bool = False # T4/T5 + LPLC2: нужны для оценки надвигания и для
|
||||
|
|
@ -270,44 +184,14 @@ class FlyGuard:
|
|||
memory: MushroomBody | None = None,
|
||||
layout: ScanLayout | None = None,
|
||||
readout: "MbonReadout | None" = None,
|
||||
track_readout=None,
|
||||
gpu_background: bool = False):
|
||||
track_readout=None):
|
||||
self.p = params or Params()
|
||||
self.device = "cpu"
|
||||
self.gpu = None # плотные стадии на видеокарте (gpu.GpuStages)
|
||||
self.gpu_error: str | None = None
|
||||
self._gpu_ready = None # (стадии, устройство) — готовы, ждут начала кадра
|
||||
# gpu_background: видеокарта поднимается в своём потоке, а кадры тем
|
||||
# временем считает процессор. Первый запуск ядер CUDA бывает долгим —
|
||||
# в Docker под WSL 15 с на каждый новый контейнер, — и узел всё это время
|
||||
# не был подписан и терял кадры. Зависни драйвер совсем — узел так и
|
||||
# останется на процессоре, а не встанет.
|
||||
self.gpu_pending = str(self.p.device).strip().lower() != "cpu"
|
||||
if self.gpu_pending:
|
||||
if gpu_background:
|
||||
threading.Thread(target=self._bring_up_gpu, name="flyguard-gpu",
|
||||
daemon=True).start()
|
||||
else:
|
||||
self._bring_up_gpu()
|
||||
self._take_gpu()
|
||||
self.memory = memory if (memory is not None and self.p.enable_memory) else NoMemory()
|
||||
self.readout = readout if (readout is not None and self.p.enable_mbon) else None
|
||||
self.layout_full = layout
|
||||
self.layout: ScanLayout | None = None
|
||||
self.cols: slice | None = None
|
||||
self._track_readout = track_readout
|
||||
self._init_temporal()
|
||||
self._calib: list[PointCloud2] = []
|
||||
self.frames_seen = 0
|
||||
self._last_stamp: float | None = None
|
||||
self.time_jumps = 0 # сколько раз время записи скакало
|
||||
|
||||
def _init_temporal(self) -> None:
|
||||
"""Всё, что копится от кадра к кадру: треки, одометрия, ось, улики.
|
||||
|
||||
Решётка лучей сюда не входит — она от сенсора, а не от места на пути.
|
||||
"""
|
||||
track_readout = self._track_readout
|
||||
self.plane: RailPlane | None = None
|
||||
self._prev_r: np.ndarray | None = None
|
||||
self.corridor: Corridor | None = None
|
||||
|
|
@ -327,12 +211,7 @@ class FlyGuard:
|
|||
leak_far=self.p.leak_far,
|
||||
leak_far_from=self.p.leak_far_from,
|
||||
nov_fade_from=self.p.nov_fade_from,
|
||||
nov_fade_to=self.p.nov_fade_to,
|
||||
lying_exempt=self.p.lying_exempt,
|
||||
top_from=(self.p.h_hi if self.p.h_top > self.p.h_hi
|
||||
else float("inf")),
|
||||
flat=(self.p.flat_h, self.p.flat_top, self.p.flat_d,
|
||||
self.p.flat_w))
|
||||
nov_fade_to=self.p.nov_fade_to)
|
||||
# без накопления улик трек подтверждается первым же наблюдением
|
||||
self.dn = (DescendingNeurons(
|
||||
warn_evidence=self.p.warn_evidence,
|
||||
|
|
@ -345,61 +224,11 @@ class FlyGuard:
|
|||
track_blend=self.p.track_blend,
|
||||
track_gate=self.p.track_gate,
|
||||
track_score=self.p.track_score,
|
||||
track_readout=track_readout,
|
||||
far_channel=self.p.far_channel,
|
||||
far_confirm=self.p.far_confirm,
|
||||
far_confirm_from=self.p.far_confirm_from) if self.p.use_tracking
|
||||
track_readout=track_readout) if self.p.use_tracking
|
||||
else DescendingNeurons(warn_evidence=0.0, clear_evidence=0.0,
|
||||
emergency_evidence=0.0, min_hits=1))
|
||||
|
||||
# ------------------------------------------------------------------ видеокарта
|
||||
|
||||
def _bring_up_gpu(self) -> None:
|
||||
"""Проверить CUDA, прогреть стадии и отдать их конвейеру (`_take_gpu`)."""
|
||||
try:
|
||||
from .device import get_device
|
||||
dev = get_device(self.p.device)
|
||||
if dev.startswith("cuda"):
|
||||
from .gpu import GpuStages
|
||||
stages = GpuStages(dev)
|
||||
stages.warmup()
|
||||
self._gpu_ready = (stages, dev)
|
||||
except Exception as e: # нет драйвера, не та видеокарта, нет памяти
|
||||
self._gpu_off(e)
|
||||
finally:
|
||||
self.gpu_pending = False
|
||||
|
||||
def _take_gpu(self) -> None:
|
||||
"""Перейти на видеокарту, если она готова. Только между кадрами.
|
||||
|
||||
Посреди кадра нельзя: ламина и кластеризация берут образ, который
|
||||
сетчатка оставила на видеокарте.
|
||||
"""
|
||||
if self._gpu_ready is not None:
|
||||
self.gpu, self.device = self._gpu_ready
|
||||
self._gpu_ready = None
|
||||
|
||||
@property
|
||||
def gpu_active(self) -> bool:
|
||||
"""Плотные стадии на видеокарте — уже или с ближайшего кадра."""
|
||||
return self.gpu is not None or self._gpu_ready is not None
|
||||
|
||||
def _gpu_off(self, exc: Exception) -> None:
|
||||
"""Сбой видеокарты: до конца работы считать на процессоре."""
|
||||
from .device import notify_cuda_error
|
||||
notify_cuda_error(exc)
|
||||
self.gpu = None
|
||||
self.device = "cpu"
|
||||
self.gpu_error = f"{type(exc).__name__}: {exc}"
|
||||
|
||||
def _cluster(self, mask: np.ndarray, r: np.ndarray, **kw):
|
||||
"""Кластеризация с учётом глубины: видеокарта, при сбое — процессор."""
|
||||
if self.gpu is not None:
|
||||
try:
|
||||
return self.gpu.cluster_by_depth(mask, r, **kw)
|
||||
except Exception as e:
|
||||
self._gpu_off(e)
|
||||
return cluster_by_depth(mask, r, **kw)
|
||||
self._calib: list[PointCloud2] = []
|
||||
self.frames_seen = 0
|
||||
|
||||
# ------------------------------------------------------------------ калибровка
|
||||
|
||||
|
|
@ -469,26 +298,10 @@ class FlyGuard:
|
|||
t = _Timer()
|
||||
if not self._ensure_layout(pc):
|
||||
return None
|
||||
# Скачок времени записи — перемотка, `ros2 bag play --loop`, разрыв в
|
||||
# записи. Треки, одометрия и ось пути относятся к другому месту, и
|
||||
# с ними предмет с прошлого круга всплыл бы там, где его нет.
|
||||
if (self._last_stamp is not None and self.p.reset_gap_s > 0
|
||||
and not (-0.05 < pc.stamp - self._last_stamp < self.p.reset_gap_s)):
|
||||
self._init_temporal()
|
||||
self.time_jumps += 1
|
||||
self._last_stamp = pc.stamp
|
||||
self.frames_seen += 1
|
||||
self._take_gpu()
|
||||
|
||||
with t("retina"):
|
||||
img: RangeImage | None = None
|
||||
if self.gpu is not None:
|
||||
try:
|
||||
img = self.gpu.project(self.layout_full, pc, self.cols)
|
||||
except Exception as e:
|
||||
self._gpu_off(e)
|
||||
if img is None:
|
||||
img = self.layout_full.project(pc, self.cols)
|
||||
img: RangeImage = self.layout_full.project(pc, self.cols)
|
||||
|
||||
with t("stabilize"):
|
||||
self.plane = fit_rail_plane(img, self.layout, prev=self.plane)
|
||||
|
|
@ -499,14 +312,7 @@ class FlyGuard:
|
|||
else STRAIGHT)
|
||||
|
||||
with t("lamina"):
|
||||
lam = None
|
||||
if self.gpu is not None:
|
||||
try:
|
||||
lam = self.gpu.lamina() # по образу кадра, уже лежащему на видеокарте
|
||||
except Exception as e:
|
||||
self._gpu_off(e)
|
||||
if lam is None:
|
||||
lam = lamina.process(tf.r, tf.valid, device="cpu")
|
||||
lam = lamina.process(tf.r, tf.valid)
|
||||
|
||||
with t("ego"):
|
||||
ego = (self.ego_est.update(tf, pc.stamp) if self.p.enable_motion
|
||||
|
|
@ -524,15 +330,7 @@ class FlyGuard:
|
|||
with t("lobula"):
|
||||
masks = gauge_mask(tf, self.corridor, half_width=self.p.half_width,
|
||||
h_lo=self.p.h_lo, h_hi=self.p.h_hi,
|
||||
d_min=self.p.d_min, d_max=self.p.d_max,
|
||||
k_sigma=self.p.k_sigma,
|
||||
h_lo_core=self.p.h_lo_core,
|
||||
core_width=self.p.core_width,
|
||||
core_from=self.p.core_from,
|
||||
platform_filter=self.p.platform_filter,
|
||||
h_top=self.p.h_top,
|
||||
half_width_top=self.p.half_width_top,
|
||||
top_d_max=self.p.top_d_max)
|
||||
d_min=self.p.d_min, d_max=self.p.d_max)
|
||||
cands = find_candidates(tf, lam, self.corridor, masks=masks,
|
||||
half_width=self.p.half_width, h_lo=self.p.h_lo,
|
||||
h_hi=self.p.h_hi, ctx_up=self.p.ctx_up,
|
||||
|
|
@ -544,17 +342,7 @@ class FlyGuard:
|
|||
d_min=self.p.d_min,
|
||||
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,
|
||||
k_sigma=self.p.k_sigma,
|
||||
h_lo_core=self.p.h_lo_core,
|
||||
core_width=self.p.core_width,
|
||||
core_from=self.p.core_from,
|
||||
platform_filter=self.p.platform_filter,
|
||||
h_top=self.p.h_top,
|
||||
half_width_top=self.p.half_width_top,
|
||||
top_d_max=self.p.top_d_max,
|
||||
top_detect=self.p.top_detect,
|
||||
cluster=self._cluster)
|
||||
min_rays_far_from=self.p.min_rays_far_from)
|
||||
|
||||
with t("mushroom"):
|
||||
cands = self.memory.annotate(cands)
|
||||
|
|
|
|||
|
|
@ -18,17 +18,6 @@
|
|||
направлением: соседние кольца одного столбца смотрят в стороны, разнесённые на
|
||||
градусы. Поэтому образ **выпрямляется** целочисленным сдвигом строк; остаточная
|
||||
ошибка < половины шага азимута и учитывается в таблице направлений.
|
||||
|
||||
Быстрый путь опирается на порядок точек (столбец · эхо · кольцо). В синтетическом
|
||||
бэге организаторов он соблюдается не везде: у облака нет поля `ring`, а там, где
|
||||
вставлен предмет, заслонённые им точки удалены, а точки предмета вписаны в
|
||||
середину массива — всё, что дальше, сдвинуто (так в половине кадров). Поэтому
|
||||
порядок проверяется в каждом кадре: у кадра с целым порядком элевация каждой
|
||||
точки совпадает с элевацией её кольца до 0.0001°. Кадр, где это не так,
|
||||
раскладывается в ту же решётку **по углам каждой точки** — медленнее, зато без
|
||||
допущений о порядке. Калибровка без поля `ring` находит кольца по гистограмме
|
||||
элевации и берёт только кадры с целым порядком, а если таких нет — строит
|
||||
решётку целиком по углам.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -43,12 +32,6 @@ from .cdr import PointCloud2
|
|||
|
||||
DEG = np.pi / 180.0
|
||||
|
||||
# Допуск проверки порядка точек: в целом кадре элевация точки совпадает с
|
||||
# элевацией её кольца до 0.0001° (замерено на всех бэгах и на синтетике), а
|
||||
# ближайшие кольца Pandar128 разнесены на 0.086°. Вставленные точки предмета
|
||||
# уходят от колец на 0.03° и больше, сдвинутые — на целое кольцо.
|
||||
ORDER_TOL_DEG = 0.01
|
||||
|
||||
|
||||
@dataclass
|
||||
class RangeImage:
|
||||
|
|
@ -82,7 +65,7 @@ class ScanLayout:
|
|||
|
||||
def __init__(self, el_deg: np.ndarray, az_step_deg: float, az0_deg: float,
|
||||
col_shift: np.ndarray, az_resid_deg: np.ndarray,
|
||||
n_az: int, n_echo: int, wrap: bool = False, indexed: bool = True):
|
||||
n_az: int, n_echo: int, wrap: bool = False):
|
||||
self.el_deg = np.asarray(el_deg, np.float64) # (H,)
|
||||
self.az_step_deg = float(az_step_deg) # шаг на азимутальный индекс
|
||||
self.az0_deg = float(az0_deg)
|
||||
|
|
@ -91,13 +74,7 @@ class ScanLayout:
|
|||
self.n_az = int(n_az)
|
||||
self.n_echo = int(n_echo)
|
||||
self.wrap = bool(wrap) # круговой скан: края смыкаются
|
||||
# False — порядок точек неизвестен (решётка построена по углам), и
|
||||
# каждый кадр раскладывается по углам точек
|
||||
self.indexed = bool(indexed)
|
||||
self.n_rings = self.el_deg.size
|
||||
self.n_points = self.n_az * self.n_echo * self.n_rings
|
||||
self.n_geometric = 0 # сколько кадров пришлось раскладывать по углам
|
||||
self._init_lookup()
|
||||
|
||||
self.az_grid_deg = self.az0_deg + self.az_step_deg * np.arange(self.n_az)
|
||||
|
||||
|
|
@ -126,12 +103,6 @@ class ScanLayout:
|
|||
c = np.cos(el)
|
||||
return np.stack([c * np.sin(az), -c * np.cos(az), np.sin(el)], axis=-1)
|
||||
|
||||
def _init_lookup(self) -> None:
|
||||
"""Таблицы для проверки порядка точек и поиска кольца по элевации."""
|
||||
self._sin_el = np.sin(self.el_deg * DEG).astype(np.float32)
|
||||
self._el_order = np.argsort(self.el_deg)
|
||||
self._el_asc = self.el_deg[self._el_order]
|
||||
|
||||
def column_slice(self, half_fov_deg: float) -> slice:
|
||||
"""Непрерывный диапазон столбцов внутри ±half_fov по азимуту."""
|
||||
inside = np.flatnonzero(np.abs(self.az_grid_deg) <= half_fov_deg)
|
||||
|
|
@ -152,11 +123,7 @@ class ScanLayout:
|
|||
out.n_az = stop - start
|
||||
out.n_echo = self.n_echo
|
||||
out.wrap = False # вырезанный сектор больше не смыкается
|
||||
out.indexed = self.indexed
|
||||
out.n_rings = self.n_rings
|
||||
out.n_points = self.n_points
|
||||
out.n_geometric = 0
|
||||
out._init_lookup()
|
||||
out.az_grid_deg = self.az_grid_deg[start:stop]
|
||||
out.gather_ok = self.gather_ok[:, start:stop]
|
||||
out.gather = self.gather[:, start:stop]
|
||||
|
|
@ -174,23 +141,11 @@ class ScanLayout:
|
|||
арифметика (корень по 900 тыс. точек) выполняется только над теми
|
||||
сырыми столбцами, которые в этот диапазон попадут с учётом скоса
|
||||
каналов, — на круговом скане это экономит почти всё время стадии.
|
||||
|
||||
Кадр с нарушенным порядком точек (см. докстроку модуля) раскладывается
|
||||
по углам точек: результат тот же, что дал бы целый кадр.
|
||||
"""
|
||||
w = self.n_az
|
||||
n, w, e = self.n_rings, self.n_az, self.n_echo
|
||||
start = 0 if cols is None else (cols.start or 0)
|
||||
stop = w if cols is None else (cols.stop if cols.stop is not None else w)
|
||||
if self.indexed and pc.n_points == self.n_points:
|
||||
img = self._project_indexed(pc, start, stop)
|
||||
if img is not None:
|
||||
return img
|
||||
self.n_geometric += 1
|
||||
return self._project_geometric(pc, start, stop)
|
||||
|
||||
def _project_indexed(self, pc: PointCloud2, start: int, stop: int) -> RangeImage | None:
|
||||
"""Быстрый путь по порядку точек. None — порядок в кадре нарушен."""
|
||||
n, w, e = self.n_rings, self.n_az, self.n_echo
|
||||
lo = start + int(self.col_shift.min())
|
||||
hi = stop + int(self.col_shift.max())
|
||||
if self.wrap:
|
||||
|
|
@ -210,14 +165,7 @@ class ScanLayout:
|
|||
x, y, z = cube("x"), cube("y"), cube("z")
|
||||
good = (x != 0) | (y != 0) | (z != 0)
|
||||
r = np.sqrt(x * x + y * y + z * z, dtype=np.float32)
|
||||
good &= np.isfinite(r) # драйверы, отдающие «нет эха» как NaN
|
||||
r = np.where(good, r, np.float32(0.0))
|
||||
|
||||
# Порядок: элевация каждой точки должна совпасть с элевацией её кольца.
|
||||
# У «нет эха» x = y = z = r = 0, и отклонение тоже ноль.
|
||||
dev = np.abs(z - r * self._sin_el[:, None, None])
|
||||
if np.any(dev > r * np.float32(ORDER_TOL_DEG * DEG) + np.float32(1e-3)):
|
||||
return None
|
||||
r *= good
|
||||
|
||||
if e == 1:
|
||||
r_near = r[..., 0]
|
||||
|
|
@ -254,69 +202,6 @@ class ScanLayout:
|
|||
np.ascontiguousarray(r_far), np.ascontiguousarray(it),
|
||||
np.ascontiguousarray(valid))
|
||||
|
||||
def _project_geometric(self, pc: PointCloud2, start: int, stop: int) -> RangeImage:
|
||||
"""Раскладка по углам: кольцо — по элевации точки, столбец — по азимуту.
|
||||
|
||||
Не опирается на порядок точек вовсе. Если в ячейку попало несколько
|
||||
точек (двойное эхо, вставленный предмет поверх фона), ближняя идёт в
|
||||
`r_near`, дальняя — в `r_far`, как у целого кадра.
|
||||
"""
|
||||
n, wid = self.n_rings, stop - start
|
||||
pts = pc.points
|
||||
x = np.asarray(pts["x"], np.float32)
|
||||
y = np.asarray(pts["y"], np.float32)
|
||||
z = np.asarray(pts["z"], np.float32)
|
||||
good = ((x != 0) | (y != 0) | (z != 0)) & np.isfinite(x) & np.isfinite(y) \
|
||||
& np.isfinite(z)
|
||||
idx = np.flatnonzero(good)
|
||||
x, y, z = x[idx], y[idx], z[idx]
|
||||
|
||||
# грубый отбор сектора по азимуту, без поправки кольца
|
||||
step = self.az_step_deg
|
||||
az = np.degrees(np.arctan2(x, -y))
|
||||
jf = (az - np.float32(self.az0_deg)) / np.float32(step)
|
||||
if self.wrap:
|
||||
jf = np.mod(jf, self.n_az)
|
||||
margin = float(np.abs(self.az_resid_deg).max()) / abs(step) + 1.0
|
||||
sel = np.flatnonzero((jf > start - margin) & (jf < stop - 1 + margin))
|
||||
idx, x, y, z, az = idx[sel], x[sel], y[sel], z[sel], az[sel]
|
||||
|
||||
r = np.sqrt(x * x + y * y + z * z)
|
||||
el = np.degrees(np.arcsin(np.clip(z / np.maximum(r, np.float32(1e-6)), -1.0, 1.0)))
|
||||
# ближайшее кольцо по элевации
|
||||
k = np.clip(np.searchsorted(self._el_asc, el), 1, n - 1)
|
||||
k -= (el - self._el_asc[k - 1]) < (self._el_asc[k] - el)
|
||||
h = self._el_order[k]
|
||||
ok = np.abs(el - self.el_deg[h]) <= np.maximum(self.el_step_deg[h], 0.2)
|
||||
j = np.rint((az - self.az_resid_deg[h] - self.az0_deg) / step).astype(np.int64)
|
||||
if self.wrap:
|
||||
j %= self.n_az
|
||||
ok &= (j >= start) & (j < stop)
|
||||
sel = np.flatnonzero(ok)
|
||||
|
||||
r_near = np.zeros(n * wid, np.float32)
|
||||
r_far = np.zeros(n * wid, np.float32)
|
||||
it = np.zeros(n * wid, np.float32)
|
||||
valid = np.zeros(n * wid, bool)
|
||||
if sel.size:
|
||||
cell = h[sel] * wid + (j[sel] - start)
|
||||
rr = r[sel]
|
||||
# сортировка по (ячейка, дальность): первая в ячейке — ближняя
|
||||
mm = np.minimum(rr * 1000.0, (1 << 20) - 1).astype(np.int64)
|
||||
order = np.argsort(cell * (1 << 20) + mm)
|
||||
cs, rs = cell[order], rr[order]
|
||||
brk = np.flatnonzero(cs[1:] != cs[:-1])
|
||||
first = np.concatenate(([0], brk + 1))
|
||||
last = np.concatenate((brk, [cs.size - 1]))
|
||||
r_near[cs[first]] = rs[first]
|
||||
r_far[cs[last]] = rs[last]
|
||||
valid[cs[first]] = True
|
||||
if "intensity" in pts.dtype.names:
|
||||
src = np.asarray(pts["intensity"], np.float32)[idx[sel]][order]
|
||||
it[cs[first]] = src[first]
|
||||
return RangeImage(pc.stamp, r_near.reshape(n, wid), r_far.reshape(n, wid),
|
||||
it.reshape(n, wid), valid.reshape(n, wid))
|
||||
|
||||
# ------------------------------------------------------------------ калибровка
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -329,18 +214,6 @@ class ScanLayout:
|
|||
if not clouds:
|
||||
raise ValueError("нужен хотя бы один кадр для калибровки")
|
||||
pc0 = clouds[0]
|
||||
if "ring" not in pc0.points.dtype.names:
|
||||
# Без поля `ring` (синтетика организаторов): кольца — по гистограмме
|
||||
# элевации, а в калибровку по порядку точек идут только кадры, где
|
||||
# этот порядок цел. Нет таких — решётка строится целиком по углам.
|
||||
el_ring = _ring_elevations(clouds)
|
||||
if n_rings is None:
|
||||
n_rings = el_ring.size
|
||||
whole = [pc for pc in clouds if _is_organized(pc, el_ring)]
|
||||
whole = [pc for pc in whole if pc.n_points == whole[0].n_points] if whole else []
|
||||
if not whole:
|
||||
return ScanLayout._calibrate_geometric(clouds, el_ring)
|
||||
clouds, pc0 = whole, whole[0]
|
||||
if n_rings is None:
|
||||
n_rings = int(pc0.points["ring"].max()) + 1
|
||||
n_cols, rem = divmod(pc0.n_points, n_rings)
|
||||
|
|
@ -417,87 +290,27 @@ class ScanLayout:
|
|||
el_ring = _fill_linear(np.nanmedian(el, axis=1))
|
||||
return ScanLayout(el_ring, step, float(c0), shift, resid, n_az, n_echo, wrap)
|
||||
|
||||
@staticmethod
|
||||
def _calibrate_geometric(clouds: list[PointCloud2], el_ring: np.ndarray) -> "ScanLayout":
|
||||
"""Решётка только по углам точек, когда ни в одном кадре нет целого порядка.
|
||||
|
||||
Кольцо — ближайшая элевация из `el_ring`. Шаг развёртки — самая частая
|
||||
разность соседних азимутов внутри кольца, сдвиг кольца внутри шага —
|
||||
круговое среднее фазы его азимутов. Столбец растёт, азимут убывает — как
|
||||
у Pandar128 в наших бэгах, чтобы образ не отразился зеркально.
|
||||
"""
|
||||
n = el_ring.size
|
||||
el_desc = np.sort(el_ring)[::-1]
|
||||
order = np.argsort(el_desc)
|
||||
asc = el_desc[order]
|
||||
azs, hs = [], []
|
||||
for pc in clouds[:6]:
|
||||
x, y, z, ok = _xyz64(pc)
|
||||
x, y, z = x[ok], y[ok], z[ok]
|
||||
el = np.degrees(np.arctan2(z, np.hypot(x, y)))
|
||||
k = np.clip(np.searchsorted(asc, el), 1, n - 1)
|
||||
k -= (el - asc[k - 1]) < (asc[k] - el)
|
||||
h = order[k]
|
||||
on = np.abs(el - el_desc[h]) < ORDER_TOL_DEG
|
||||
azs.append(np.degrees(np.arctan2(x[on], -y[on])))
|
||||
hs.append(h[on])
|
||||
az = np.concatenate(azs)
|
||||
h = np.concatenate(hs)
|
||||
|
||||
diffs = []
|
||||
for ring in range(n):
|
||||
a = np.unique(np.round(az[h == ring], 4))
|
||||
if a.size > 20:
|
||||
diffs.append(np.diff(a))
|
||||
if not diffs:
|
||||
raise ValueError("недостаточно валидных лучей для калибровки развёртки")
|
||||
d = np.concatenate(diffs)
|
||||
d = d[d > 1e-3]
|
||||
vals, cnt = np.unique(np.round(d, 3), return_counts=True)
|
||||
step = float(vals[np.argmax(cnt)])
|
||||
step = float(np.median(d[np.abs(d - step) < 0.1 * step]))
|
||||
|
||||
ph = np.exp(2j * np.pi * az / step)
|
||||
s = (np.bincount(h, weights=ph.real, minlength=n)
|
||||
+ 1j * np.bincount(h, weights=ph.imag, minlength=n))
|
||||
ref = float(np.angle(s.sum()) / (2 * np.pi) * step)
|
||||
frac = np.where(np.abs(s) > 0, np.angle(s) / (2 * np.pi) * step, ref)
|
||||
resid = (frac - ref + step / 2) % step - step / 2
|
||||
|
||||
lo, hi = np.percentile(az, [0.05, 99.95])
|
||||
wrap = bool(hi - lo > 350.0)
|
||||
if wrap:
|
||||
n_az = int(round(360.0 / step))
|
||||
az0 = ref + step * round((180.0 - ref) / step)
|
||||
else:
|
||||
az0 = ref + step * round((hi - ref) / step)
|
||||
n_az = int(round((az0 - lo) / step)) + 1
|
||||
return ScanLayout(el_desc, -step, float(az0), np.zeros(n, np.int64), resid,
|
||||
n_az, 1, wrap, indexed=False)
|
||||
|
||||
# ------------------------------------------------------------------ сериализация
|
||||
|
||||
def save(self, path: str | Path) -> None:
|
||||
np.savez_compressed(path, el_deg=self.el_deg, az_step_deg=self.az_step_deg,
|
||||
az0_deg=self.az0_deg, col_shift=self.col_shift,
|
||||
az_resid_deg=self.az_resid_deg, n_az=self.n_az,
|
||||
n_echo=self.n_echo, wrap=self.wrap, indexed=self.indexed)
|
||||
n_echo=self.n_echo, wrap=self.wrap)
|
||||
|
||||
@staticmethod
|
||||
def load(path: str | Path) -> "ScanLayout":
|
||||
d = np.load(path)
|
||||
return ScanLayout(d["el_deg"], float(d["az_step_deg"]), float(d["az0_deg"]),
|
||||
d["col_shift"], d["az_resid_deg"], int(d["n_az"]),
|
||||
int(d["n_echo"]), bool(d["wrap"]) if "wrap" in d else False,
|
||||
bool(d["indexed"]) if "indexed" in d else True)
|
||||
int(d["n_echo"]), bool(d["wrap"]) if "wrap" in d else False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
kind = "" if self.indexed else ", по углам точек"
|
||||
return (f"ScanLayout(колец={self.n_rings}, азимутов={self.n_az}, эх={self.n_echo}, "
|
||||
f"сектор {self.az_grid_deg.min():.1f}°…{self.az_grid_deg.max():.1f}°, "
|
||||
f"шаг {abs(self.az_step_deg):.3f}°, "
|
||||
f"элевация {self.el_deg.min():.1f}°…{self.el_deg.max():.1f}°, "
|
||||
f"скос каналов {np.ptp(self.col_shift)} стлб{kind})")
|
||||
f"скос каналов {np.ptp(self.col_shift)} стлб)")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- вспомогательное
|
||||
|
|
@ -515,58 +328,6 @@ def _wrap180(a):
|
|||
return -((-np.asarray(a, np.float64) + 180.0) % 360.0 - 180.0)
|
||||
|
||||
|
||||
def _xyz64(pc: PointCloud2):
|
||||
"""Координаты в float64 и маска точек с эхом (не ноль и не NaN)."""
|
||||
p = pc.points
|
||||
x = p["x"].astype(np.float64)
|
||||
y = p["y"].astype(np.float64)
|
||||
z = p["z"].astype(np.float64)
|
||||
ok = ((x != 0) | (y != 0) | (z != 0)) & np.isfinite(x) & np.isfinite(y) & np.isfinite(z)
|
||||
return x, y, z, ok
|
||||
|
||||
|
||||
def _ring_elevations(clouds: list[PointCloud2], max_frames: int = 6) -> np.ndarray:
|
||||
"""Элевации колец по гистограмме, без поля `ring` и без опоры на порядок.
|
||||
|
||||
У лазерного канала элевация постоянна до 0.0001°, поэтому точки кольца
|
||||
ложатся в один бин в 0.002°; соседние кольца Pandar128 разнесены на 0.086°
|
||||
и больше. Разброс float32 может расщепить кольцо на соседние бины — они
|
||||
сливаются. Вставленные точки (синтетика) рассыпаны по элевации и дают
|
||||
мелкие группы, которые отсекает порог по весу. Порядок — сверху вниз,
|
||||
как нумерует каналы Hesai.
|
||||
"""
|
||||
parts = []
|
||||
for pc in clouds[:max_frames]:
|
||||
x, y, z, ok = _xyz64(pc)
|
||||
parts.append(np.degrees(np.arctan2(z[ok], np.hypot(x[ok], y[ok]))))
|
||||
el = np.concatenate(parts)
|
||||
if el.size == 0:
|
||||
raise ValueError("нет ни одной точки с эхом для калибровки колец")
|
||||
q, cnt = np.unique(np.rint(el / 0.002).astype(np.int64), return_counts=True)
|
||||
grp = np.concatenate(([0], np.cumsum(np.diff(q) > 5)))
|
||||
w = np.bincount(grp, weights=cnt)
|
||||
c = np.bincount(grp, weights=cnt * q * 0.002) / w
|
||||
top = np.sort(w)[-min(64, w.size):]
|
||||
keep = w >= 0.25 * np.median(top)
|
||||
if keep.sum() < 2:
|
||||
raise ValueError("не удалось выделить кольца по элевации")
|
||||
return np.sort(c[keep])[::-1]
|
||||
|
||||
|
||||
def _is_organized(pc: PointCloud2, el_ring: np.ndarray) -> bool:
|
||||
"""Цел ли порядок точек: элевация каждой точки = элевация её кольца."""
|
||||
n = el_ring.size
|
||||
if pc.n_points == 0 or pc.n_points % n:
|
||||
return False
|
||||
x, y, z, ok = _xyz64(pc)
|
||||
el = np.degrees(np.arctan2(z, np.hypot(x, y))).reshape(-1, n)
|
||||
ok = ok.reshape(-1, n)
|
||||
for table in (el_ring, el_ring[::-1]):
|
||||
if np.all(np.abs(el - table[None, :])[ok] < ORDER_TOL_DEG):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _detect_echoes(az: np.ndarray) -> int:
|
||||
"""Двойное эхо: соседние столбцы делят азимут (проверяется по каждому кольцу)."""
|
||||
if az.shape[1] < 4:
|
||||
|
|
|
|||
|
|
@ -137,8 +137,6 @@ def catalogue() -> dict[str, ObjectModel]:
|
|||
Sphere(radius=0.11, h_centre=1.60)], reflectivity=0.35),
|
||||
"человек_сидя": ObjectModel("человек_сидя", [
|
||||
Box(0.45, 0.50, 0.85)], reflectivity=0.35),
|
||||
"человек_лежа": ObjectModel("человек_лежа", [
|
||||
Box(length=1.80, width=0.50, height=0.30, h_base=0.0)], 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),
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
# Зависимости для запуска FlyGuard с поддержкой NVIDIA GPU (CUDA 12.x / Ada Lovelace RTX 4070 Ti Super)
|
||||
--extra-index-url https://download.pytorch.org/whl/cu121
|
||||
numpy>=1.24
|
||||
scipy>=1.10
|
||||
torch>=2.2.0
|
||||
torchvision>=0.17.0
|
||||
lightgbm>=4.0
|
||||
catboost>=1.2
|
||||
matplotlib>=3.7
|
||||
pytest>=7.0
|
||||
|
|
@ -2,10 +2,6 @@ numpy>=1.24
|
|||
scipy>=1.10
|
||||
# только для контрольного бустинга в tools/train_mbon.py --baseline
|
||||
lightgbm>=4.0
|
||||
# графики полигона: tools/plot_benchmark.py
|
||||
matplotlib>=3.7
|
||||
# видеокарта: плотные стадии конвейера (flyguard/gpu.py) и обучение
|
||||
# (tools/train_mbon.py --device cuda). Без него ядро считает на процессоре —
|
||||
# с тем же результатом
|
||||
# только для обучения на GPU (tools/train_mbon.py --device cuda); ядру не нужен
|
||||
# torch>=2.0
|
||||
pytest>=7.0
|
||||
|
|
|
|||
|
|
@ -1,83 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Автономный запуск тестов без внешних зависимостей (включая pytest)."""
|
||||
import inspect
|
||||
import math
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
|
||||
class PytestMock:
|
||||
class raises:
|
||||
def __init__(self, exc_type, match=None):
|
||||
self.exc_type = exc_type
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
if exc_type is None:
|
||||
raise AssertionError(f"Expected {self.exc_type} but nothing was raised")
|
||||
return issubclass(exc_type, self.exc_type)
|
||||
|
||||
@staticmethod
|
||||
def approx(expected, rel=1e-6, abs=1e-6):
|
||||
class Approx:
|
||||
def __eq__(self, other):
|
||||
return math.isclose(other, expected, rel_tol=rel, abs_tol=abs)
|
||||
|
||||
def __repr__(self):
|
||||
return f"approx({expected})"
|
||||
|
||||
return Approx()
|
||||
|
||||
class mark:
|
||||
@staticmethod
|
||||
def skipif(cond, reason=""):
|
||||
def dec(f):
|
||||
f.__skip__ = cond
|
||||
return f
|
||||
|
||||
return dec
|
||||
|
||||
|
||||
sys.modules.setdefault("pytest", PytestMock())
|
||||
|
||||
import tests.test_pipeline as tp
|
||||
|
||||
|
||||
def main():
|
||||
passed = failed = skipped = 0
|
||||
test_names = sorted([n for n in dir(tp) if n.startswith("test_")])
|
||||
for name in test_names:
|
||||
fn = getattr(tp, name)
|
||||
if getattr(fn, "__skip__", False):
|
||||
print(f"SKIP {name}")
|
||||
skipped += 1
|
||||
continue
|
||||
try:
|
||||
sig = inspect.signature(fn)
|
||||
kwargs = {}
|
||||
if "tmp_path" in sig.parameters:
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
kwargs["tmp_path"] = Path(td)
|
||||
fn(**kwargs)
|
||||
else:
|
||||
fn()
|
||||
print(f"PASS {name}")
|
||||
passed += 1
|
||||
except Exception as e:
|
||||
print(f"FAIL {name}: {e}")
|
||||
failed += 1
|
||||
|
||||
print(f"\nИтог: {passed} passed, {failed} failed, {skipped} skipped")
|
||||
if failed > 0:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -540,568 +540,3 @@ def test_track_gate_removes_the_weak_track_without_touching_the_strong_one():
|
|||
assert {o.track_id for o in out.objects} == {1}
|
||||
assert out.objects[0].confidence == pytest.approx(
|
||||
DescendingNeurons().decide(_pair_of_tracks(), speed=10.0).objects[0].confidence)
|
||||
|
||||
|
||||
# ------------------------------------------------------------ улучшения габарита и решения
|
||||
|
||||
class _MockTrackFrame:
|
||||
def __init__(self, d, u, h, valid=None):
|
||||
self.d = np.asarray(d, np.float32)
|
||||
self.u = np.asarray(u, np.float32)
|
||||
self.h = np.asarray(h, np.float32)
|
||||
self.valid = np.ones_like(self.d, dtype=bool) if valid is None else np.asarray(valid, bool)
|
||||
|
||||
def lateral(self, corridor=None):
|
||||
if corridor is None or corridor.n_slices == 0:
|
||||
return self.u
|
||||
curved = self.u - corridor.centre(self.d)
|
||||
return np.where(np.abs(curved) < np.abs(self.u), curved, self.u)
|
||||
|
||||
|
||||
def test_gauge_mask_zonal_floor_and_corridor_expansion():
|
||||
"""Проверка зонального пола и расширения габарита по неопределённости оси."""
|
||||
from flyguard.geometry import STRAIGHT, Corridor
|
||||
from flyguard.lobula import gauge_mask
|
||||
|
||||
# 1. Зональный пол: в колее (|u| <= 0.85) порог 0.16 м, снаружи — 0.28 м
|
||||
tf = _MockTrackFrame(
|
||||
d=[50.0, 50.0],
|
||||
u=[0.4, 1.2],
|
||||
h=[0.20, 0.20] # оба 20 см над рельсом
|
||||
)
|
||||
inside, _, _ = gauge_mask(tf, STRAIGHT, half_width=1.6, h_lo=0.28, h_hi=2.3,
|
||||
d_min=4.0, d_max=220.0, h_lo_core=0.16, core_width=0.85)
|
||||
assert inside[0] # в колее 0.20 м > 0.16 м — внутри габарита!
|
||||
assert not inside[1] # на периферии 0.20 м < 0.28 м — отсечено!
|
||||
|
||||
# 2. Расширение габарита на дальней дистанции при кривизне
|
||||
corridor = Corridor(np.array([0.0, 0.0, 0.0003], np.float32), 60.0, 10, 1600.0)
|
||||
tf_far = _MockTrackFrame(
|
||||
d=[150.0],
|
||||
u=[1.90],
|
||||
h=[1.0]
|
||||
)
|
||||
# Без расширения (k_sigma=0): |u| = 1.9 > 1.6 -> отсечено
|
||||
inside_rigid, _, _ = gauge_mask(tf_far, corridor, half_width=1.6, h_lo=0.28, h_hi=2.3,
|
||||
d_min=4.0, d_max=220.0, k_sigma=0.0)
|
||||
assert not inside_rigid[0]
|
||||
|
||||
# С расширением (k_sigma=0.75): w_eff > 1.9 м -> объект удержан
|
||||
inside_exp, _, _ = gauge_mask(tf_far, corridor, half_width=1.6, h_lo=0.28, h_hi=2.3,
|
||||
d_min=4.0, d_max=220.0, k_sigma=0.75)
|
||||
assert inside_exp[0]
|
||||
|
||||
|
||||
def test_core_floor_is_not_lowered_closer_than_core_from():
|
||||
"""Вблизи опущенный пол открывает головки рельсов — там он не действует."""
|
||||
from flyguard.geometry import STRAIGHT
|
||||
from flyguard.lobula import gauge_mask
|
||||
|
||||
tf = _MockTrackFrame(d=[20.0, 60.0, 60.0], u=[0.4, 0.4, 1.2], h=[0.20, 0.20, 0.20])
|
||||
inside, _, _ = gauge_mask(tf, STRAIGHT, half_width=1.6, h_lo=0.28, h_hi=2.3,
|
||||
d_min=4.0, d_max=220.0, h_lo_core=0.16,
|
||||
core_width=0.85, core_from=55.0)
|
||||
assert not inside[0] # 20 м — ближе порога, пол прежний
|
||||
assert inside[1] # 60 м в колее — пол опущен
|
||||
assert not inside[2] # вне колеи пол не опускается нигде
|
||||
|
||||
|
||||
def test_gauge_mask_filters_platform_surface():
|
||||
"""Настил платформы (h~1.1-1.2 м, |u| >= 1.30 м) отсекается, а препятствие в габарите остаётся."""
|
||||
from flyguard.geometry import STRAIGHT
|
||||
from flyguard.lobula import gauge_mask
|
||||
|
||||
tf = _MockTrackFrame(
|
||||
d=[40.0, 40.0],
|
||||
u=[1.40, 0.90],
|
||||
h=[1.15, 1.15]
|
||||
)
|
||||
inside, _, _ = gauge_mask(tf, STRAIGHT, half_width=1.6, h_lo=0.28, h_hi=2.3,
|
||||
d_min=4.0, d_max=220.0, platform_filter=True)
|
||||
assert not inside[0] # настил платформы отсечён
|
||||
assert inside[1] # препятствие на колее сохранено
|
||||
|
||||
|
||||
def test_descending_far_soft_warning_triggers_without_emergency():
|
||||
"""На 130 м устойчивый трек поднимает предупреждение, но не экстренное торможение."""
|
||||
from flyguard.central_complex import CentralComplex, Track
|
||||
from flyguard.descending import DescendingNeurons
|
||||
|
||||
cx = CentralComplex()
|
||||
cx.s_world = 0.0
|
||||
far_track = Track(
|
||||
id=1, s_world=130.0, u=0.2, h=0.5, width=0.5, height=1.6,
|
||||
evidence=0.20, hits=3, age=5, novelty=0.12,
|
||||
n_obs=3, w_sum=3 * 0.85, p_sum=3 * 0.85
|
||||
)
|
||||
cx.tracks = [far_track]
|
||||
|
||||
dn = DescendingNeurons(warn_evidence=0.5, warn_far=0.35, warn_far_from=90.0,
|
||||
far_channel=True)
|
||||
decision = dn.decide(cx, speed=15.0)
|
||||
|
||||
assert decision.detected is True
|
||||
assert decision.emergency is False
|
||||
assert decision.distance == pytest.approx(130.0, abs=1.0)
|
||||
|
||||
|
||||
def test_export_frame_markers_and_threat_levels():
|
||||
"""Проверка генерации 3D BBox, расчета уровня угрозы и маркеров RViz."""
|
||||
from flyguard.descending import Decision, DetectedObject
|
||||
from flyguard.export import ThreatLevel, export_frame
|
||||
from flyguard.geometry import RailPlane, STRAIGHT
|
||||
|
||||
plane = RailPlane(a=0.01, b=0.0, c=-1.80, inliers=100, rms=0.02)
|
||||
dec = Decision(
|
||||
detected=True,
|
||||
emergency=False,
|
||||
distance=45.0,
|
||||
ttc=4.5,
|
||||
speed=10.0,
|
||||
stopping_distance=50.0,
|
||||
objects=[
|
||||
DetectedObject(
|
||||
distance=45.0, lateral=0.3, height=0.6, width=0.5, size_v=1.2,
|
||||
confidence=0.92, novelty=0.8, n_rays=15, track_id=42, ttc=4.5
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
exp = export_frame(dec, plane, STRAIGHT, stamp=1710000000.5)
|
||||
assert exp.threat_level == ThreatLevel.WARNING
|
||||
assert len(exp.boxes) == 1
|
||||
b = exp.boxes[0]
|
||||
assert b.track_id == 42
|
||||
assert b.distance_along_track == 45.0
|
||||
assert b.x == pytest.approx(0.3)
|
||||
assert b.y == pytest.approx(-45.0)
|
||||
# z = h + a*d + b*u + c = 0.6 + 0.01*45 + 0.0 + (-1.8) = -0.75
|
||||
assert b.z == pytest.approx(0.6 + 0.01 * 45.0 - 1.80)
|
||||
|
||||
# Проверка сериализации в словарь и RViz маркеры
|
||||
d = exp.to_dict()
|
||||
assert d["threat_level"] == "WARNING"
|
||||
assert d["n_objects"] == 1
|
||||
|
||||
markers = exp.to_rviz_markers(frame_id="hesai_pandar")
|
||||
assert len(markers) >= 2 # bbox cube + text label
|
||||
|
||||
|
||||
def test_benchmark_augmentation_logic():
|
||||
"""Проверка генерации сценариев и логики аугментаций в make_benchmark."""
|
||||
from make_benchmark import _ScenarioState
|
||||
from flyguard.pipeline import FlyGuard, Params
|
||||
|
||||
rng = np.random.default_rng(42)
|
||||
p = FlyGuard(Params())
|
||||
|
||||
# 1. Спавн в диапазоне [40, 200] м
|
||||
d_start_min, d_start_max = 40.0, 200.0
|
||||
spawns = [float(rng.uniform(d_start_min, d_start_max)) for _ in range(100)]
|
||||
assert all(40.0 <= d <= 200.0 for d in spawns)
|
||||
assert min(spawns) < 60.0 and max(spawns) > 180.0
|
||||
|
||||
# 2. Боковой дрейф для пешеходов
|
||||
v_lat_max = 0.35
|
||||
v_lats = [float(rng.uniform(-v_lat_max, v_lat_max)) for _ in range(100)]
|
||||
assert all(-0.35 <= v <= 0.35 for v in v_lats)
|
||||
|
||||
# 3. Шум по дальности
|
||||
s_noise_std = 0.12
|
||||
noises = [float(np.clip(rng.normal(0.0, s_noise_std), -0.35, 0.35)) for _ in range(500)]
|
||||
assert abs(np.mean(noises)) < 0.05
|
||||
assert 0.08 < np.std(noises) < 0.15
|
||||
|
||||
# 4. _ScenarioState
|
||||
sc = _ScenarioState(name="человек_лежа", lat_base=0.0, pipe=p,
|
||||
d_init=120.0, v_lat=0.15, is_static=False)
|
||||
assert sc.name == "человек_лежа"
|
||||
assert sc.v_lat == 0.15
|
||||
assert not sc.is_static
|
||||
|
||||
|
||||
def test_device_detection_and_fallback():
|
||||
"""Проверка автоматического определения GPU устройства и fallback на CPU."""
|
||||
from flyguard.device import get_device, get_device_info, is_cuda_available, to_numpy
|
||||
|
||||
dev = get_device("auto")
|
||||
assert dev in ("cpu", "cuda") or dev.startswith("cuda:")
|
||||
|
||||
if not is_cuda_available():
|
||||
assert get_device("cuda") == "cpu"
|
||||
assert get_device("cpu") == "cpu"
|
||||
|
||||
info = get_device_info()
|
||||
assert "device" in info
|
||||
assert "torch_available" in info
|
||||
|
||||
arr = np.array([1.0, 2.0, 3.0], dtype=np.float32)
|
||||
assert np.array_equal(to_numpy(arr), arr)
|
||||
|
||||
|
||||
def test_lamina_device_routing():
|
||||
"""Проверка работы Lamina с указанием устройства (cpu / auto)."""
|
||||
from flyguard import lamina
|
||||
|
||||
r = np.full((16, 32), 20.0, dtype=np.float32)
|
||||
r[8, 16] = 5.0 # препятствие
|
||||
valid = np.ones((16, 32), dtype=bool)
|
||||
|
||||
out_cpu = lamina.process(r, valid, device="cpu")
|
||||
out_auto = lamina.process(r, valid, device="auto")
|
||||
|
||||
assert out_cpu.on.shape == (16, 32)
|
||||
assert out_auto.on.shape == (16, 32)
|
||||
assert out_cpu.on[8, 16] > 0.0
|
||||
assert np.allclose(out_cpu.on, out_auto.on, atol=1e-5)
|
||||
|
||||
|
||||
def test_device_runtime_failure_and_fallback():
|
||||
"""Проверка динамического перехода на CPU при сбое/отвале GPU в рантайме."""
|
||||
from flyguard.device import is_cuda_available, get_device, notify_cuda_error, reset_device_cache
|
||||
from flyguard import lamina
|
||||
|
||||
# Симуляция критического сбоя GPU
|
||||
notify_cuda_error(RuntimeError("Simulated CUDA device disconnect / OOM"))
|
||||
try:
|
||||
assert not is_cuda_available()
|
||||
assert get_device("cuda") == "cpu"
|
||||
assert get_device("auto") == "cpu"
|
||||
|
||||
r = np.full((16, 32), 20.0, dtype=np.float32)
|
||||
r[8, 16] = 4.0
|
||||
valid = np.ones((16, 32), dtype=bool)
|
||||
|
||||
# Даже при явном указании device="cuda", Lamina должна успешно отработать на CPU
|
||||
out = lamina.process(r, valid, device="cuda")
|
||||
assert out.on.shape == (16, 32)
|
||||
assert out.on[8, 16] > 0.0
|
||||
finally:
|
||||
reset_device_cache()
|
||||
|
||||
|
||||
|
||||
def test_scenario_stream_does_not_depend_on_the_rest_of_the_catalogue():
|
||||
"""Новый предмет в каталоге не сдвигает случайность остальных сценариев.
|
||||
|
||||
Раньше генератор был один на запись, и сценарии тянули из него по очереди:
|
||||
добавили человека лёжа — поменялись цифры по ящику. Теперь поток сценария
|
||||
определяется только зерном, именем предмета и поперечным смещением.
|
||||
"""
|
||||
from make_benchmark import scenario_rng
|
||||
|
||||
a = scenario_rng(12345, "ящик", 0.9).random(5)
|
||||
assert np.array_equal(a, scenario_rng(12345, "ящик", 0.9).random(5))
|
||||
assert not np.array_equal(a, scenario_rng(12345, "ящик", 0.0).random(5))
|
||||
assert not np.array_equal(a, scenario_rng(12345, "чемодан", 0.9).random(5))
|
||||
assert not np.array_equal(a, scenario_rng(777, "ящик", 0.9).random(5))
|
||||
|
||||
|
||||
def test_export_box_follows_a_curved_track():
|
||||
"""В кривой рамка стоит на пути и повёрнута вдоль него.
|
||||
|
||||
Боковое смещение трека отсчитано от оси пути, и без поправки на её
|
||||
положение рамка на 150 м при радиусе 1300 м стояла в 8.6 м от пути, а
|
||||
поворот был зеркальным — 13° мимо направления пути.
|
||||
"""
|
||||
import math
|
||||
from flyguard.descending import Decision, DetectedObject
|
||||
from flyguard.export import export_frame
|
||||
from flyguard.geometry import Corridor, RailPlane
|
||||
|
||||
R = 1300.0
|
||||
cor = Corridor(np.array([0.0, 0.0, 1.0 / (2 * R)], np.float32), 200.0, 10, 1600.0)
|
||||
plane = RailPlane(a=0.0, b=0.0, c=-1.8, inliers=100, rms=0.02)
|
||||
for d in (55.0, 100.0, 150.0):
|
||||
dec = Decision(detected=True, distance=d, speed=10.0, stopping_distance=30.0,
|
||||
objects=[DetectedObject(distance=d, lateral=0.3, height=0.8,
|
||||
width=0.5, size_v=1.7, confidence=0.9,
|
||||
novelty=0.6, n_rays=10, track_id=1,
|
||||
ttc=d / 10)])
|
||||
box = export_frame(dec, plane, cor).boxes[0]
|
||||
on_track = float(cor.centre(np.array([d], np.float32))[0]) + 0.3
|
||||
assert box.x == pytest.approx(on_track, abs=1e-3)
|
||||
|
||||
slope = d / R
|
||||
long_axis = np.array([-math.sin(box.yaw), math.cos(box.yaw)])
|
||||
tangent = np.array([slope, -1.0]) / math.hypot(slope, 1.0)
|
||||
assert abs(float(long_axis @ tangent)) == pytest.approx(1.0, abs=1e-6)
|
||||
|
||||
|
||||
def test_track_carries_the_sensor_lateral_for_drawing():
|
||||
"""Рамка в RViz стоит у своих точек, а не у оси пути.
|
||||
|
||||
На кривой синтетики организаторов (радиус около 760 м) предмет на оси
|
||||
пути на 39 м лежит в 1.05 м левее оси лидара, а рамка рисовалась по
|
||||
смещению от оси пути — на +0.13 м, в 1.2 м от своих точек.
|
||||
"""
|
||||
from flyguard.central_complex import CentralComplex
|
||||
from flyguard.descending import DescendingNeurons
|
||||
from flyguard.lobula import Candidate
|
||||
|
||||
cx = CentralComplex()
|
||||
c = Candidate(d=39.0, u=0.02, h=0.3, d_min=38.8, h_min=0.1, width=0.3,
|
||||
height=0.3, depth=0.2, containment=1.0, n_rays=20,
|
||||
n_rings=5, n_cols=4, gap=6.0, on=0.2, floor_deficit=0.0,
|
||||
shadow=0.3, inten=12.0, az_deg=-1.5, el_deg=-1.0,
|
||||
bbox=(0, 0, 5, 4), novelty=0.6, extra={"u_raw": -1.05})
|
||||
cx.update([c], ds=0.0, dt=0.1)
|
||||
t = cx.tracks[0]
|
||||
assert t.u == pytest.approx(0.02) and t.u_raw == pytest.approx(-1.05)
|
||||
|
||||
t.evidence, t.hits, t.age = 1.0, 9, 9
|
||||
obj = DescendingNeurons().decide(cx, speed=10.0).objects[0]
|
||||
assert obj.lateral == pytest.approx(0.02) # смещение от оси пути — в сообщении
|
||||
assert obj.sensor_x == pytest.approx(-1.05) # а рисуется рамка здесь
|
||||
|
||||
|
||||
def test_gauge_outline_bends_with_the_track():
|
||||
"""Контур габарита в RViz идёт вдоль оценённой оси, а не прямо."""
|
||||
from flyguard.export import gauge_outline
|
||||
from flyguard.geometry import Corridor
|
||||
|
||||
boxes = [(1.2, 0.28, 2.3, 4.0, 200.0)]
|
||||
straight = np.array(gauge_outline(boxes))
|
||||
assert set(np.round(np.abs(straight[:, 0]), 6)) == {1.2}
|
||||
|
||||
R = 760.0
|
||||
cor = Corridor(np.array([0.0, 0.0, -1.0 / (2 * R)], np.float32), 200.0, 10, R)
|
||||
bent = np.array(gauge_outline(boxes, cor.centre))
|
||||
at100 = bent[np.isclose(bent[:, 1], -100.0)]
|
||||
c100 = float(cor.centre(np.array([100.0], np.float32))[0])
|
||||
assert c100 == pytest.approx(-100.0 ** 2 / (2 * R), rel=1e-3)
|
||||
assert at100[:, 0].min() == pytest.approx(c100 - 1.2, abs=1e-4)
|
||||
assert at100[:, 0].max() == pytest.approx(c100 + 1.2, abs=1e-4)
|
||||
|
||||
|
||||
# ------------------------------------------------------------ видеокарта
|
||||
|
||||
def _cuda_or_skip():
|
||||
torch = pytest.importorskip("torch")
|
||||
if not torch.cuda.is_available():
|
||||
pytest.skip("нет видеокарты с CUDA")
|
||||
|
||||
|
||||
def _scene(seed=0, shape=(128, 600)):
|
||||
"""Образ, похожий на тоннель: гладкие стены, ступеньки-предметы, пропуски эха."""
|
||||
rng = np.random.default_rng(seed)
|
||||
h, w = shape
|
||||
r = (np.linspace(3.0, 180.0, w)[None, :] * (1.0 + 0.2 * np.sin(np.arange(h)[:, None] / 9.0))
|
||||
).astype(np.float32)
|
||||
for _ in range(40):
|
||||
i, j = rng.integers(0, h - 8), rng.integers(0, w - 12)
|
||||
r[i:i + rng.integers(2, 8), j:j + rng.integers(2, 12)] *= np.float32(rng.uniform(0.3, 0.9))
|
||||
r += rng.normal(0.0, 0.02, r.shape).astype(np.float32)
|
||||
valid = rng.random(shape) > 0.1
|
||||
return np.where(valid, r, np.float32(0.0)), valid
|
||||
|
||||
|
||||
def test_gpu_lamina_and_clustering_match_cpu_bit_for_bit():
|
||||
"""Ламина и кластеризация на видеокарте — те же числа и те же номера компонент."""
|
||||
_cuda_or_skip()
|
||||
import torch
|
||||
from flyguard import lamina
|
||||
from flyguard.gpu import GpuStages
|
||||
from flyguard.lobula import cluster_by_depth
|
||||
|
||||
g = GpuStages("cuda")
|
||||
for seed in range(3):
|
||||
r, valid = _scene(seed)
|
||||
g.r = torch.as_tensor(r, device="cuda")
|
||||
g.valid = torch.as_tensor(valid, device="cuda")
|
||||
a, b = lamina._process_cpu(r, valid), g.lamina()
|
||||
for f in ("disp", "on", "off", "on_scale", "surround", "hole"):
|
||||
assert np.array_equal(getattr(a, f), getattr(b, f)), f
|
||||
mask = valid & (np.random.default_rng(seed).random(r.shape) < 0.5)
|
||||
la, na = cluster_by_depth(mask, r)
|
||||
lb, nb = g.cluster_by_depth(mask, r)
|
||||
assert na == nb and np.array_equal(la, lb)
|
||||
|
||||
|
||||
def test_gpu_projection_matches_cpu():
|
||||
"""Сетчатка на видеокарте: целый кадр — бит в бит, перемешанный — почти."""
|
||||
_cuda_or_skip()
|
||||
from flyguard.bag import Bag
|
||||
from flyguard.cdr import PointCloud2
|
||||
from flyguard.gpu import GpuStages
|
||||
|
||||
if not DATA.exists():
|
||||
pytest.skip("нет записей")
|
||||
bag = Bag(next(p for p in DATA.iterdir() if p.is_dir()))
|
||||
clouds = [pc for _, pc in bag.frames(start=2, stop=16)]
|
||||
lay = ScanLayout.calibrate(clouds[:12])
|
||||
cols = lay.column_slice(30.0)
|
||||
g = GpuStages("cuda")
|
||||
pc = clouds[-1]
|
||||
a, b = lay.project(pc, cols), g.project(lay, pc, cols)
|
||||
for f in ("r_near", "r_far", "inten", "valid"):
|
||||
assert np.array_equal(getattr(a, f), getattr(b, f)), f
|
||||
|
||||
# порядок точек сбит, как в синтетике организаторов: раскладка по углам
|
||||
rng = np.random.default_rng(0)
|
||||
mixed = PointCloud2(pc.stamp, pc.frame_id, 1, pc.n_points, pc.point_step,
|
||||
pc.is_dense, pc.points[rng.permutation(pc.n_points)])
|
||||
a, b = lay.project(mixed, cols), g.project(lay, mixed, cols)
|
||||
assert a.valid.sum() > 1000
|
||||
differ = sum(int((getattr(a, f) != getattr(b, f)).sum())
|
||||
for f in ("r_near", "r_far", "inten", "valid"))
|
||||
assert differ < 1e-3 * a.valid.size # последний знак арктангенса и порядок равных
|
||||
|
||||
|
||||
def test_pipeline_survives_gpu_failure_mid_frame():
|
||||
"""Отказ видеокарты посреди кадра: кадр досчитан на процессоре, решение то же."""
|
||||
from flyguard.bag import Bag
|
||||
from flyguard.device import reset_device_cache
|
||||
from flyguard.pipeline import FlyGuard, Params
|
||||
|
||||
if not DATA.exists():
|
||||
pytest.skip("нет записей")
|
||||
|
||||
class Broken:
|
||||
def project(self, *a, **k):
|
||||
raise RuntimeError("CUDA error: device lost (имитация)")
|
||||
|
||||
lamina = cluster_by_depth = project
|
||||
|
||||
frames = [pc for _, pc in Bag(next(p for p in DATA.iterdir() if p.is_dir())).frames(stop=30)]
|
||||
ref = FlyGuard(Params(device="cpu"))
|
||||
fg = FlyGuard(Params(device="cpu"))
|
||||
try:
|
||||
for i, pc in enumerate(frames):
|
||||
if i == 20: # видеокарта «была» и отказала
|
||||
fg.gpu, fg.device = Broken(), "cuda"
|
||||
a, b = ref.process(pc), fg.process(pc)
|
||||
assert (a is None) == (b is None)
|
||||
if a is not None:
|
||||
assert a.decision.detected == b.decision.detected
|
||||
assert [c.d for c in a.candidates] == [c.d for c in b.candidates]
|
||||
assert fg.gpu is None and fg.device == "cpu"
|
||||
assert "device lost" in fg.gpu_error
|
||||
finally:
|
||||
reset_device_cache()
|
||||
|
||||
|
||||
class _CpuStages:
|
||||
"""Подмена видеокарты: те же стадии на процессоре, прогрев ждёт сигнала."""
|
||||
|
||||
gate = None
|
||||
|
||||
def __init__(self, device):
|
||||
self.calls = 0
|
||||
self._img = None
|
||||
|
||||
def warmup(self):
|
||||
if self.gate is not None:
|
||||
self.gate.wait()
|
||||
|
||||
def project(self, L, pc, cols):
|
||||
self.calls += 1
|
||||
self._img = L.project(pc, cols)
|
||||
return self._img
|
||||
|
||||
def lamina(self):
|
||||
from flyguard import lamina
|
||||
return lamina.process(self._img.r_near, self._img.valid, device="cpu")
|
||||
|
||||
def cluster_by_depth(self, mask, r, **kw):
|
||||
return cluster_by_depth(mask, r, **kw)
|
||||
|
||||
|
||||
def _wait_gpu(fg, timeout=5.0):
|
||||
import time
|
||||
t_end = time.monotonic() + timeout
|
||||
while fg.gpu_pending and time.monotonic() < t_end:
|
||||
time.sleep(0.01)
|
||||
assert not fg.gpu_pending, "поток видеокарты не закончил"
|
||||
|
||||
|
||||
def test_gpu_comes_up_in_background_while_cpu_counts(monkeypatch):
|
||||
"""Прогрев видеокарты идёт в фоне: кадры тем временем считает процессор,
|
||||
а на видеокарту конвейер переходит только с начала кадра — с тем же итогом.
|
||||
Зависший прогрев (здесь — до 20-го кадра) узел не держит."""
|
||||
import threading
|
||||
import flyguard.device as device
|
||||
import flyguard.gpu as gpu
|
||||
from flyguard.bag import Bag
|
||||
from flyguard.pipeline import FlyGuard, Params
|
||||
|
||||
if not DATA.exists():
|
||||
pytest.skip("нет записей")
|
||||
made = []
|
||||
|
||||
class Stages(_CpuStages):
|
||||
gate = threading.Event()
|
||||
|
||||
def __init__(self, dev):
|
||||
super().__init__(dev)
|
||||
made.append(self)
|
||||
|
||||
monkeypatch.setattr(device, "get_device", lambda preferred="auto": "cuda")
|
||||
monkeypatch.setattr(gpu, "GpuStages", Stages)
|
||||
frames = [pc for _, pc in Bag(next(p for p in DATA.iterdir() if p.is_dir())).frames(stop=30)]
|
||||
ref = FlyGuard(Params(device="cpu"))
|
||||
fg = FlyGuard(Params(device="auto"), gpu_background=True)
|
||||
try:
|
||||
for i, pc in enumerate(frames):
|
||||
if i == 20:
|
||||
assert fg.gpu_pending and fg.gpu is None and fg.device == "cpu"
|
||||
Stages.gate.set()
|
||||
_wait_gpu(fg)
|
||||
assert fg.gpu_active and fg.gpu is None # готова, но ждёт начала кадра
|
||||
a, b = ref.process(pc), fg.process(pc)
|
||||
assert (a is None) == (b is None)
|
||||
if a is not None:
|
||||
assert a.decision.detected == b.decision.detected
|
||||
assert [c.d for c in a.candidates] == [c.d for c in b.candidates]
|
||||
assert fg.gpu is made[0] and fg.device == "cuda"
|
||||
assert made[0].calls == len(frames) - 20
|
||||
finally:
|
||||
Stages.gate.set()
|
||||
|
||||
|
||||
def test_gpu_failing_to_warm_up_leaves_cpu(monkeypatch):
|
||||
"""Прогрев в фоне упал: конвейер остаётся на процессоре, причина — в gpu_error."""
|
||||
import flyguard.device as device
|
||||
import flyguard.gpu as gpu
|
||||
from flyguard.device import reset_device_cache
|
||||
from flyguard.pipeline import FlyGuard, Params
|
||||
|
||||
class Bad(_CpuStages):
|
||||
def warmup(self):
|
||||
raise RuntimeError("CUDA error: no kernel image is available (имитация)")
|
||||
|
||||
monkeypatch.setattr(device, "get_device", lambda preferred="auto": "cuda")
|
||||
monkeypatch.setattr(gpu, "GpuStages", Bad)
|
||||
try:
|
||||
fg = FlyGuard(Params(device="auto"), gpu_background=True)
|
||||
_wait_gpu(fg)
|
||||
assert fg.gpu is None and not fg.gpu_active and fg.device == "cpu"
|
||||
assert "no kernel image" in fg.gpu_error
|
||||
finally:
|
||||
reset_device_cache()
|
||||
|
||||
|
||||
def test_brain_view_scales_for_high_resolution():
|
||||
"""Мозг для экрана в 2×: вдвое больше по обеим осям, облако из атласа
|
||||
своего размера, а в целом та же картинка, что у узла."""
|
||||
cv2 = pytest.importorskip("cv2")
|
||||
# в выгрузке для команды вида мозга нет — там тест пропускается
|
||||
BrainHybrid = pytest.importorskip("flyguard.brain_hybrid").BrainHybrid
|
||||
from flyguard.bag import Bag
|
||||
from flyguard.pipeline import FlyGuard, Params
|
||||
|
||||
if not DATA.exists():
|
||||
pytest.skip("нет записей")
|
||||
one, two = BrainHybrid(), BrainHybrid(scale=2)
|
||||
if not one.enabled:
|
||||
pytest.skip("нет атласа нейронов")
|
||||
fg = FlyGuard(Params(enable_looming=True))
|
||||
a = b = None
|
||||
for _, pc in Bag(next(p for p in DATA.iterdir() if p.is_dir())).frames(stop=20):
|
||||
res = fg.process(pc, keep_debug=True)
|
||||
if res is not None:
|
||||
a, b = one.render(res), two.render(res)
|
||||
assert a is not None
|
||||
assert b.shape == (2 * a.shape[0], 2 * a.shape[1], 3)
|
||||
small = cv2.resize(b, (a.shape[1], a.shape[0]), interpolation=cv2.INTER_AREA)
|
||||
assert abs(float(small.mean()) - float(a.mean())) < 0.15 * float(a.mean())
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
"""Большой бэг `new_data` кусками прямо из tar-архива.
|
||||
|
||||
Это 90 ГБ в 221 шарде, а свободного места на диске меньше, чем весь архив.
|
||||
Кусок из нескольких подряд идущих шардов распаковывается во временный
|
||||
каталог, читается как обычный многошардовый бэг (`flyguard.bag.Bag`) и
|
||||
удаляется. Шард достаётся по смещению в архиве, без повторного разбора
|
||||
заголовков, поэтому куски можно распаковывать из нескольких процессов сразу.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
# Архив лежит там, куда его положили при скачивании датасета, а не в data/:
|
||||
# распаковывать его целиком некуда. Путь переопределяется FLYGUARD_NEW_DATA
|
||||
# или ключом --tar у инструментов.
|
||||
DEFAULT_TAR = os.environ.get(
|
||||
"FLYGUARD_NEW_DATA",
|
||||
str(Path(__file__).resolve().parents[1] / "датасет" / "new_data"))
|
||||
SHARD_RE = re.compile(r"_(\d+)\.db3$")
|
||||
|
||||
|
||||
def shards(tar_path: str | Path) -> list[tuple[int, str, int, int]]:
|
||||
"""(номер, имя файла, смещение данных, размер) всех шардов, по номеру."""
|
||||
out = []
|
||||
with tarfile.open(tar_path, "r:") as t:
|
||||
for m in t:
|
||||
mm = SHARD_RE.search(m.name)
|
||||
if mm and m.isfile():
|
||||
out.append((int(mm.group(1)), Path(m.name).name,
|
||||
m.offset_data, m.size))
|
||||
out.sort()
|
||||
return out
|
||||
|
||||
|
||||
def pick(members: list, spec: str) -> list:
|
||||
"""Шарды по срезу номеров: `110:` — со 110-го до конца, `0:110` — первые 110."""
|
||||
lo, _, hi = spec.partition(":")
|
||||
lo_i = int(lo) if lo else 0
|
||||
hi_i = int(hi) if hi else None
|
||||
return [m for m in members if m[0] >= lo_i and (hi_i is None or m[0] < hi_i)]
|
||||
|
||||
|
||||
def split(members: list, per_chunk: int) -> list[list]:
|
||||
"""Подряд идущие шарды группами: каждая группа — одна «запись»."""
|
||||
return [members[i:i + per_chunk] for i in range(0, len(members), per_chunk)]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def chunk(tar_path: str | Path, members: list, workdir: str | None = None):
|
||||
"""Распаковать шарды во временный каталог, отдать его путь, потом удалить."""
|
||||
d = Path(tempfile.mkdtemp(prefix="fg_nd_", dir=workdir))
|
||||
try:
|
||||
with open(tar_path, "rb") as src:
|
||||
for _, name, off, size in members:
|
||||
src.seek(off)
|
||||
with open(d / name, "wb") as dst:
|
||||
left = size
|
||||
while left:
|
||||
buf = src.read(min(left, 1 << 22))
|
||||
if not buf:
|
||||
raise OSError(f"архив обрезан на {name}")
|
||||
dst.write(buf)
|
||||
left -= len(buf)
|
||||
yield d
|
||||
finally:
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
"""Общий ключ `--set имя=значение` для любых полей `Params`.
|
||||
|
||||
У `evaluate.py` и `make_benchmark.py` уже по два десятка отдельных флагов, по
|
||||
одному на каждую настройку конвейера, и каждый новый ключ в `Params` требовал
|
||||
ещё двух правок в каждом инструменте. Через `--set` любое поле меняется без
|
||||
правки инструментов: `--set h_lo_core=0.16 --set lying_exempt=1`.
|
||||
|
||||
Тип берётся из описания поля, поэтому опечатка в имени или в значении падает
|
||||
сразу, с понятным сообщением, а не тихо превращается в строку.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
|
||||
_TRUE = {"1", "true", "yes", "on", "да"}
|
||||
_FALSE = {"0", "false", "no", "off", "нет"}
|
||||
|
||||
|
||||
def add_argument(ap) -> None:
|
||||
ap.add_argument("--set", action="append", default=[], metavar="ИМЯ=ЗНАЧЕНИЕ",
|
||||
help="поменять любое поле Params; можно несколько раз")
|
||||
|
||||
|
||||
def apply(over: dict, sets: list[str], params_cls) -> dict:
|
||||
"""Дописать в `over` значения из `--set`, приведя их к типу поля."""
|
||||
types = {f.name: str(f.type) for f in dataclasses.fields(params_cls)}
|
||||
for item in sets:
|
||||
if "=" not in item:
|
||||
raise SystemExit(f"--set ждёт имя=значение, а получил: {item!r}")
|
||||
name, raw = (s.strip() for s in item.split("=", 1))
|
||||
if name not in types:
|
||||
near = sorted(n for n in types if name.split("_")[0] in n)[:6]
|
||||
raise SystemExit(f"в Params нет поля {name!r}"
|
||||
+ (f"; похожие: {', '.join(near)}" if near else ""))
|
||||
kind = types[name]
|
||||
if kind == "bool":
|
||||
low = raw.lower()
|
||||
if low not in _TRUE | _FALSE:
|
||||
raise SystemExit(f"{name}: ждал да/нет, а получил {raw!r}")
|
||||
over[name] = low in _TRUE
|
||||
elif kind == "int":
|
||||
over[name] = int(raw)
|
||||
elif kind == "float":
|
||||
over[name] = float(raw)
|
||||
else:
|
||||
over[name] = raw
|
||||
return over
|
||||
|
|
@ -12,7 +12,6 @@ import argparse
|
|||
import numpy as np
|
||||
|
||||
import _bootstrap as B # noqa: F401
|
||||
import _params as PS
|
||||
from _metrics import auc
|
||||
from flyguard.bag import Bag
|
||||
from flyguard.mushroom_body import MushroomBody
|
||||
|
|
@ -30,7 +29,6 @@ def main() -> None:
|
|||
"не видевшую этот бэг: artifacts/mbon_folds/mbon_roundT_doubleT.npz")
|
||||
ap.add_argument("--limit", type=int, default=200)
|
||||
ap.add_argument("--verbose", action="store_true")
|
||||
PS.add_argument(ap)
|
||||
ap.add_argument("--tracks", action="store_true",
|
||||
help="сравнить признаки трека у настоящего объекта и у "
|
||||
"ложных треков: единственная проверка, где метка "
|
||||
|
|
@ -43,8 +41,7 @@ def main() -> None:
|
|||
from flyguard.mbon_readout import MbonReadout
|
||||
readout = MbonReadout.load(args.mbon)
|
||||
print(f"считывание MBON: {args.mbon}")
|
||||
fg = FlyGuard(Params(**PS.apply({}, args.set, Params)), memory=memory,
|
||||
readout=readout)
|
||||
fg = FlyGuard(Params(), memory=memory, readout=readout)
|
||||
bag = Bag(args.bag)
|
||||
|
||||
n = cand_hit = track_hit = other = 0
|
||||
|
|
|
|||
|
|
@ -1,83 +0,0 @@
|
|||
"""Парное сравнение двух прогонов полигона на одних и тех же вставках.
|
||||
|
||||
Полигон шумит сильнее, чем меняет его большинство правок: две реализации
|
||||
случайности при одинаковых настройках расходятся на P@150 на 0.07, на P@100
|
||||
у ящика — на 0.11 (EXPERIMENTS п. 16.1). Поэтому сравнивать итоговые таблицы
|
||||
двух прогонов нельзя.
|
||||
|
||||
Но у каждого сценария свой генератор (`make_benchmark.scenario_rng`), и от
|
||||
решений конвейера он не зависит: при другой настройке на вход идут те же
|
||||
кадры с теми же вставками, до луча. Значит, разница двух прогонов — только
|
||||
от настройки, и её видно по отдельным наблюдениям: сколько было 0 и стало 1
|
||||
и наоборот. Перевороты в одну сторону — настоящий эффект, даже когда он
|
||||
меньше межзернового шума; поровну в обе стороны — шум.
|
||||
|
||||
python tools/compare_benchmark.py artifacts/benchmark.json new.json
|
||||
python tools/compare_benchmark.py a.json b.json --objects человек_лежа,ящик
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
|
||||
MIN_VISIBLE_RAYS = 2 # как в plot_benchmark: меньше — предмет не освещён
|
||||
|
||||
|
||||
def load(path: str) -> dict:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return {(r["bag"], r["obj"], r["lateral"]): r for r in json.load(f)}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("before", help="прогон «было» (json из make_benchmark)")
|
||||
ap.add_argument("after", help="прогон «стало»")
|
||||
ap.add_argument("--objects", default="",
|
||||
help="через запятую; пусто — все предметы")
|
||||
ap.add_argument("--edges", default="0,15,40,90,130,170,230",
|
||||
help="границы полос дальности, м")
|
||||
args = ap.parse_args()
|
||||
|
||||
a, b = load(args.before), load(args.after)
|
||||
if set(a) != set(b):
|
||||
raise SystemExit("разный состав сценариев — прогоны не парные")
|
||||
edges = [float(x) for x in args.edges.split(",")]
|
||||
objs = ([o for o in args.objects.split(",") if o] if args.objects
|
||||
else sorted({k[1] for k in a}))
|
||||
|
||||
print(f"{'предмет':14s}{'полоса':>12}{'набл.':>7}{'было':>7}{'стало':>7}"
|
||||
f"{'0→1':>6}{'1→0':>6}")
|
||||
total_up = total_down = 0
|
||||
for obj in objs:
|
||||
obj_up = obj_down = 0
|
||||
for lo, hi in zip(edges[:-1], edges[1:]):
|
||||
n = ha = hb = up = down = 0
|
||||
for k, ra in a.items():
|
||||
if k[1] != obj:
|
||||
continue
|
||||
rb = b[k]
|
||||
da = np.asarray(ra["d"])
|
||||
if da.shape != np.shape(rb["d"]) or not np.allclose(da, rb["d"]):
|
||||
raise SystemExit(f"вход разошёлся в {k}: прогоны не парные")
|
||||
m = (da >= lo) & (da < hi) & (np.asarray(ra["rays"]) >= MIN_VISIBLE_RAYS)
|
||||
x, y = np.asarray(ra["hit"])[m], np.asarray(rb["hit"])[m]
|
||||
n += int(m.sum())
|
||||
ha += int(x.sum())
|
||||
hb += int(y.sum())
|
||||
up += int(((x == 0) & (y == 1)).sum())
|
||||
down += int(((x == 1) & (y == 0)).sum())
|
||||
obj_up += up
|
||||
obj_down += down
|
||||
if n >= 20:
|
||||
print(f"{obj:14s}{f'{lo:.0f}–{hi:.0f} м':>12}{n:7d}{ha / n:7.2f}"
|
||||
f"{hb / n:7.2f}{up:6d}{down:6d}")
|
||||
print(f"{obj:14s}{'всего':>12}{'':21s}{obj_up:6d}{obj_down:6d}")
|
||||
total_up += obj_up
|
||||
total_down += obj_down
|
||||
print(f"\nвсе предметы: 0→1 {total_up}, 1→0 {total_down}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
"""Ложные тревоги на участке `new_data`, которого считывание не видело.
|
||||
|
||||
Прокси незнакомой линии в `evaluate.py --no-memory` — пять коротких записей,
|
||||
вместе чуть больше километра пути, и все они сняты в один день. `new_data` —
|
||||
двадцать минут непрерывной езды другим днём. Если учить считывание на
|
||||
вставках в первую половину, а ложные тревоги мерить на второй, проверка
|
||||
остаётся честной при любом ответе на вопрос, та ли это линия: второй половины
|
||||
модель не видела ни в каком виде.
|
||||
|
||||
Вторая половина идёт одним непрерывным потоком — состояние конвейера (поза,
|
||||
ось, треки) переносится между кусками, как в `stream_new_data.py`. Памяти
|
||||
тоннеля нет. Несколько вариантов считывания гоняются по одним и тем же
|
||||
кадрам, поэтому разница между ними — только от считывания. Вариант может
|
||||
нести и свои настройки конвейера через `@`: так сравниваются модели при
|
||||
одинаковой строгости (`@mbon_power=2`), а не только при умолчаниях.
|
||||
|
||||
python tools/eval_new_data.py --shards 110: \\
|
||||
--readout было=artifacts/mbon_readout.npz --readout стало=new.npz \\
|
||||
--readout строже=artifacts/mbon_readout.npz@mbon_power=2 \\
|
||||
--out nd_eval.json
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
|
||||
import _bootstrap as B # noqa: F401
|
||||
import _new_data as ND
|
||||
import _params as PS
|
||||
from flyguard.bag import Bag
|
||||
from flyguard.mbon_readout import MbonReadout
|
||||
from flyguard.pipeline import FlyGuard, Params
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--tar", default=ND.DEFAULT_TAR)
|
||||
ap.add_argument("--shards", default="110:",
|
||||
help="номера шардов срезом; по умолчанию вторая половина")
|
||||
ap.add_argument("--per-chunk", type=int, default=5,
|
||||
help="сколько шардов распаковывать за раз")
|
||||
ap.add_argument("--readout", action="append", default=[],
|
||||
metavar="ИМЯ=ПУТЬ[@ПОЛЕ=ЗНАЧЕНИЕ...]",
|
||||
help="вариант считывания, после @ — свои поля Params; можно несколько")
|
||||
ap.add_argument("--out", required=True)
|
||||
PS.add_argument(ap)
|
||||
args = ap.parse_args()
|
||||
if not args.readout:
|
||||
raise SystemExit("нужен хотя бы один --readout ИМЯ=ПУТЬ")
|
||||
|
||||
members = ND.pick(ND.shards(args.tar), args.shards)
|
||||
pipes, stats = {}, {}
|
||||
for item in args.readout:
|
||||
name, _, rest = item.partition("=")
|
||||
path, *own = rest.split("@")
|
||||
params = Params(**PS.apply({}, args.set + own, Params))
|
||||
pipes[name] = FlyGuard(params, memory=None, readout=MbonReadout.load(path))
|
||||
stats[name] = dict(frames=0, path_m=0.0, back_m=0.0, alarm_frames=0,
|
||||
fp_objects=0, tracks=set())
|
||||
print(f"шарды {members[0][0]}–{members[-1][0]} ({len(members)} шт.), "
|
||||
f"вариантов считывания {len(pipes)}")
|
||||
|
||||
t0 = time.time()
|
||||
for part in ND.split(members, args.per_chunk):
|
||||
with ND.chunk(args.tar, part) as d:
|
||||
for _, pc in Bag(d).frames():
|
||||
for name, fg in pipes.items():
|
||||
res = fg.process(pc)
|
||||
if res is None:
|
||||
continue
|
||||
s = stats[name]
|
||||
s["frames"] += 1
|
||||
ds = res.ego.ds if res.ego else 0.0
|
||||
s["path_m"] += ds
|
||||
s["back_m"] += max(-ds, 0.0)
|
||||
objs = res.decision.objects
|
||||
if objs:
|
||||
s["alarm_frames"] += 1
|
||||
s["fp_objects"] += len(objs)
|
||||
s["tracks"].update(o.track_id for o in objs)
|
||||
line = " | ".join(f"{n}: {len(s['tracks'])} треков на {s['path_m'] / 1000:.2f} км"
|
||||
for n, s in stats.items())
|
||||
print(f" шарды до {part[-1][0]:3d}: {line} | {(time.time() - t0) / 60:5.1f} мин",
|
||||
flush=True)
|
||||
|
||||
rows = []
|
||||
for name, s in stats.items():
|
||||
km = max(s["path_m"] / 1000.0, 1e-6)
|
||||
rows.append(dict(readout=name, frames=s["frames"], path_m=s["path_m"],
|
||||
back_m=s["back_m"], alarm_frames=s["alarm_frames"],
|
||||
alarm_rate=s["alarm_frames"] / max(s["frames"], 1),
|
||||
fp_objects=s["fp_objects"], fp_tracks=len(s["tracks"]),
|
||||
fp_per_km=len(s["tracks"]) / km))
|
||||
print(f"{name:12s} кадров {s['frames']:6d}, путь {s['path_m'] / 1000:6.2f} км "
|
||||
f"(назад {s['back_m']:.0f} м) | кадров с тревогой {rows[-1]['alarm_rate']:6.1%} | "
|
||||
f"ложных треков {len(s['tracks']):4d} = {rows[-1]['fp_per_km']:5.1f} на км")
|
||||
with open(args.out, "w", encoding="utf-8") as f:
|
||||
json.dump(rows, f, ensure_ascii=False, indent=1)
|
||||
print("сохранено:", args.out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,187 +0,0 @@
|
|||
"""Оценка на синтетике организаторов: какие из вставленных предметов найдены.
|
||||
|
||||
Разметки к `cloud_with_fake_obj` нет, но она и не нужна: вставленные точки не
|
||||
лежат на элевациях колец (отклонение больше 0.002°, у настоящих — до 0.0001°).
|
||||
Кластеры таких точек, связанные от кадра к кадру по дальности, и есть эталон:
|
||||
предметы нумеруются в порядке появления, то есть в порядке из объявления
|
||||
организаторов. Положение вдоль пути для связывания не годится — на остановке
|
||||
одометрия замирает и соседние предметы сливаются. Методика — EXPERIMENTS п. 18.2.
|
||||
|
||||
python tools/eval_org_synth.py
|
||||
python tools/eval_org_synth.py --set half_width=1.6 --set h_top=0
|
||||
python tools/eval_org_synth.py --no-memory --out run.json
|
||||
|
||||
Эталон кэшируется в `data/cache/<бэг>_truth.json`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
import _bootstrap as B
|
||||
import _params as PS
|
||||
from flyguard.bag import Bag
|
||||
from flyguard.mbon_readout import MbonReadout
|
||||
from flyguard.mushroom_body import MushroomBody
|
||||
from flyguard.pipeline import FlyGuard, Params
|
||||
from flyguard.retina import _ring_elevations, _xyz64
|
||||
|
||||
# объявление организаторов, 24.09; False — предмет вне габарита, тревоги быть не должно
|
||||
OBJECTS = [("2×2 посередине", True), ("0.3 посередине", True), ("0.3 на рельсе", True),
|
||||
("0.3 у края", True), ("0.3 вне габарита", False), ("2×2 у края внутри", True),
|
||||
("2×2 вне габарита", False), ("2×2 сверху", True), ("2×0.2 на рельсах", True),
|
||||
("стержень с потолка", True)]
|
||||
OFF_RING_DEG = 0.002
|
||||
MATCH_D = 4.0
|
||||
|
||||
|
||||
def truth(bag_path: Path) -> list[dict]:
|
||||
"""Кластеры вставленных точек по кадрам."""
|
||||
ref = [pc for _, pc in Bag(bag_path).frames(start=1100, stop=1106)]
|
||||
asc = np.sort(_ring_elevations(ref))
|
||||
rows, t0 = [], None
|
||||
for k, (_, pc) in enumerate(Bag(bag_path).frames()):
|
||||
t0 = pc.stamp if t0 is None else t0
|
||||
x, y, z, ok = _xyz64(pc)
|
||||
x, y, z = x[ok], y[ok], z[ok]
|
||||
el = np.degrees(np.arctan2(z, np.hypot(x, y)))
|
||||
i = np.clip(np.searchsorted(asc, el), 1, asc.size - 1)
|
||||
off = np.minimum(np.abs(el - asc[i - 1]), np.abs(asc[i] - el)) > OFF_RING_DEG
|
||||
objs = []
|
||||
if off.any():
|
||||
fx, fd, fz = x[off], -y[off], z[off]
|
||||
o = np.argsort(fd)
|
||||
fx, fd, fz = fx[o], fd[o], fz[o]
|
||||
cut = np.flatnonzero(np.diff(fd) > 3.0) + 1
|
||||
for a, b in zip(np.r_[0, cut], np.r_[cut, fd.size]):
|
||||
objs.append(dict(d=float(np.median(fd[a:b])), x0=float(fx[a:b].min()),
|
||||
x1=float(fx[a:b].max()), z0=float(fz[a:b].min()),
|
||||
z1=float(fz[a:b].max()), n=int(b - a)))
|
||||
rows.append(dict(k=k, t=pc.stamp - t0, objs=objs))
|
||||
return rows
|
||||
|
||||
|
||||
def link(rows: list[dict]) -> list[dict]:
|
||||
"""Кластеры → предметы: связь с треком из последних кадров по дальности."""
|
||||
tracks = []
|
||||
for r in rows:
|
||||
for o in r["objs"]:
|
||||
if o["n"] < 3 or o["d"] > 130:
|
||||
continue
|
||||
best = None
|
||||
for t in tracks:
|
||||
if 0 < r["k"] - t["last_k"] <= 15 and -3.0 < t["last_d"] - o["d"] < 25.0:
|
||||
if best is None or abs(t["last_d"] - o["d"]) < abs(best["last_d"] - o["d"]):
|
||||
best = t
|
||||
if best is None:
|
||||
best = {"frames": {}}
|
||||
tracks.append(best)
|
||||
best["frames"][r["k"]] = o
|
||||
best["last_k"], best["last_d"] = r["k"], o["d"]
|
||||
tracks = [t for t in tracks if len(t["frames"]) >= 8]
|
||||
tracks.sort(key=lambda t: min(t["frames"]))
|
||||
return tracks
|
||||
|
||||
|
||||
def run(bag_path: Path, params: Params, memory, readout) -> list[dict]:
|
||||
fg = FlyGuard(params, memory=memory, readout=readout)
|
||||
out, t0 = [], None
|
||||
for k, (_, pc) in enumerate(Bag(bag_path).frames()):
|
||||
t0 = pc.stamp if t0 is None else t0
|
||||
res = fg.process(pc)
|
||||
if res is None:
|
||||
continue
|
||||
out.append(dict(k=k, t=pc.stamp - t0, ms=res.total_ms,
|
||||
objs=[dict(d=o.distance, u=o.lateral, h=o.height, w=o.width,
|
||||
sv=o.size_v, p=o.confidence, tid=o.track_id)
|
||||
for o in res.decision.objects]))
|
||||
return out
|
||||
|
||||
|
||||
def score(tracks: list[dict], det: list[dict]) -> tuple[int, list, list]:
|
||||
by_k = {r["k"]: r for r in det}
|
||||
hits = {i: [] for i in range(len(tracks))}
|
||||
used, tid_obj = set(), {}
|
||||
for i, t in enumerate(tracks):
|
||||
for k, o in t["frames"].items():
|
||||
for j, ob in enumerate(by_k.get(k, {}).get("objs", [])):
|
||||
if abs(ob["d"] - o["d"]) < MATCH_D:
|
||||
hits[i].append((k, by_k[k]["t"], ob))
|
||||
used.add((k, j))
|
||||
tid_obj.setdefault(ob["tid"], i)
|
||||
false = []
|
||||
for r in det:
|
||||
for j, ob in enumerate(r["objs"]):
|
||||
if (r["k"], j) in used:
|
||||
continue
|
||||
if ob["tid"] in tid_obj: # тот же трек вплотную, где эталон уже кончился
|
||||
hits[tid_obj[ob["tid"]]].append((r["k"], r["t"], ob))
|
||||
else:
|
||||
false.append((r["k"], r["t"], ob))
|
||||
lines, ok = [], 0
|
||||
for i, t in enumerate(tracks):
|
||||
name, obst = OBJECTS[i] if i < len(OBJECTS) else ("?", True)
|
||||
hs = hits[i]
|
||||
found = bool(hs)
|
||||
ok += found == obst
|
||||
verdict = ("найден" if found else "ПРОПУЩЕН") if obst else \
|
||||
("ЛОЖНАЯ тревога" if found else "верно молчит")
|
||||
far = max(o["d"] for o in t["frames"].values())
|
||||
first = max((h[2]["d"] for h in hs), default=float("nan"))
|
||||
pmax = max((h[2]["p"] for h in hs), default=0.0)
|
||||
lines.append(f"{i + 1:>2} {name:20s} виден с {far:5.0f} м | кадров {len(hs):4d}, "
|
||||
f"первое на {first:6.1f} м, p ≤ {pmax:.2f} | {verdict}")
|
||||
return ok, lines, false
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--bag", default=str(B.DATA / "cloud_with_fake_obj"))
|
||||
ap.add_argument("--memory", default=str(B.ARTIFACTS / "mushroom_body.npz"))
|
||||
ap.add_argument("--mbon", default=str(B.ARTIFACTS / "mbon_readout.npz"))
|
||||
ap.add_argument("--no-memory", action="store_true")
|
||||
ap.add_argument("--out", default="", help="сохранить покадровые решения (json)")
|
||||
PS.add_argument(ap)
|
||||
args = ap.parse_args()
|
||||
|
||||
bag = Path(args.bag)
|
||||
cache = B.CACHE / f"{bag.name}_truth.json"
|
||||
if cache.exists():
|
||||
rows = json.loads(cache.read_text(encoding="utf-8"))
|
||||
else:
|
||||
t = time.time()
|
||||
rows = truth(bag)
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache.write_text(json.dumps(rows), encoding="utf-8")
|
||||
print(f"эталон собран за {time.time() - t:.0f} с → {cache}")
|
||||
tracks = link(rows)
|
||||
|
||||
params = Params(**PS.apply({}, args.set, Params))
|
||||
memory = None if args.no_memory else MushroomBody.load(args.memory)
|
||||
readout = MbonReadout.load(args.mbon) if args.mbon else None
|
||||
det = run(bag, params, memory, readout)
|
||||
if args.out:
|
||||
Path(args.out).write_text(json.dumps(det, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
ok, lines, false = score(tracks, det)
|
||||
print("\n".join(lines))
|
||||
print(f"верно по {ok} из {len(tracks)}")
|
||||
ftr = {}
|
||||
for k, t, ob in false:
|
||||
ftr.setdefault(ob["tid"], []).append((t, ob))
|
||||
print(f"ложных треков вне эталона: {len(ftr)} (кадров {len(false)})")
|
||||
for lst in sorted(ftr.values(), key=lambda v: v[0][0]):
|
||||
ds = [ob["d"] for _, ob in lst]
|
||||
ob = lst[len(lst) // 2][1]
|
||||
print(f" {lst[0][0]:5.1f}…{lst[-1][0]:5.1f} с, {max(ds):.0f}→{min(ds):.0f} м, "
|
||||
f"u {ob['u']:+.2f} h {ob['h']:.2f}, p ≤ {max(o['p'] for _, o in lst):.2f}")
|
||||
ms = np.array([r["ms"] for r in det])
|
||||
print(f"кадр {np.median(ms):.1f} / {np.percentile(ms, 95):.1f} мс (медиана / p95)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -21,7 +21,6 @@ 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, MushroomBodyConfig
|
||||
from flyguard.pipeline import FlyGuard, Params
|
||||
|
|
@ -107,8 +106,7 @@ def main() -> None:
|
|||
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="auto",
|
||||
help="устройство вычислений ('auto', 'cuda', 'cpu')")
|
||||
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,
|
||||
|
|
@ -173,7 +171,6 @@ def main() -> None:
|
|||
help="совсем без памяти тоннеля — так выглядит первый проезд по новой линии")
|
||||
ap.add_argument("--out", default=str(B.ARTIFACTS / "generalisation.json"))
|
||||
P.add_argument(ap)
|
||||
PS.add_argument(ap)
|
||||
args = ap.parse_args()
|
||||
|
||||
d = np.load(args.cache, allow_pickle=True)
|
||||
|
|
@ -238,7 +235,7 @@ def main() -> None:
|
|||
over["track_score"] = args.track_score
|
||||
if args.track_gate is not None:
|
||||
over["track_gate"] = args.track_gate
|
||||
params = Params(**PS.apply(over, args.set, Params))
|
||||
params = Params(**over)
|
||||
fold_paths: dict[str, str] = {}
|
||||
if args.mbon_dir:
|
||||
from pathlib import Path as _P
|
||||
|
|
|
|||
|
|
@ -1,216 +0,0 @@
|
|||
"""
|
||||
ROS 2 узел для биоинспирированного пайплайна FlyGuard (ЛЦТ-2026 Кейс 05).
|
||||
Связывает входные данные 3D-лидара с ядром FlyGuard и публикует детекции/маркеры.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
|
||||
# Автоматически добавляем корень репозитория в sys.path
|
||||
ROOT_DIR = Path(__file__).resolve().parent.parent
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
|
||||
# Импорт сообщений ROS 2
|
||||
from sensor_msgs.msg import PointCloud2
|
||||
from std_msgs.msg import String
|
||||
from vision_msgs.msg import Detection3DArray, Detection3D, BoundingBox3D
|
||||
from visualization_msgs.msg import MarkerArray, Marker
|
||||
from geometry_msgs.msg import Vector3
|
||||
|
||||
import sensor_msgs_py.point_cloud2 as pc2
|
||||
|
||||
# Импорт ядра FlyGuard
|
||||
try:
|
||||
from flyguard.pipeline import FlyGuard, Params
|
||||
from flyguard.mushroom_body import MushroomBody
|
||||
except ImportError as e:
|
||||
FlyGuard = None
|
||||
Params = None
|
||||
MushroomBody = None
|
||||
|
||||
|
||||
class FlyGuardNode(Node):
|
||||
def __init__(self):
|
||||
super().__init__('flyguard_node')
|
||||
|
||||
# --- Объявление параметров ---
|
||||
self.declare_parameter('lidar_topic', '/pandar_points')
|
||||
self.declare_parameter('frame_id', 'pandar')
|
||||
self.declare_parameter('memory_path', '')
|
||||
self.declare_parameter('fov_deg', 30.0)
|
||||
|
||||
self.lidar_topic = self.get_parameter('lidar_topic').get_parameter_value().string_value
|
||||
self.frame_id = self.get_parameter('frame_id').get_parameter_value().string_value
|
||||
memory_path = self.get_parameter('memory_path').get_parameter_value().string_value
|
||||
fov_deg = self.get_parameter('fov_deg').get_parameter_value().double_value
|
||||
|
||||
# --- Инициализация ядра FlyGuard ---
|
||||
if FlyGuard is not None:
|
||||
self.get_logger().info("Инициализация ядра FlyGuard...")
|
||||
memory = MushroomBody.load(memory_path) if memory_path else None
|
||||
params = Params(fov_deg=fov_deg)
|
||||
self.fg = FlyGuard(params, memory=memory)
|
||||
else:
|
||||
self.get_logger().error("Модуль flyguard.pipeline не найден!")
|
||||
self.fg = None
|
||||
|
||||
# --- Подписчики и Публикаторы ---
|
||||
self.sub_cloud = self.create_subscription(
|
||||
PointCloud2,
|
||||
self.lidar_topic,
|
||||
self.pointcloud_callback,
|
||||
10
|
||||
)
|
||||
|
||||
self.pub_threat = self.create_publisher(String, 'flyguard/threat_level', 10)
|
||||
self.pub_boxes = self.create_publisher(Detection3DArray, 'flyguard/bounding_boxes', 10)
|
||||
self.pub_markers = self.create_publisher(MarkerArray, 'flyguard/markers', 10)
|
||||
|
||||
self.get_logger().info(f"FlyGuard ROS 2 Node запущен! Топик: {self.lidar_topic}")
|
||||
|
||||
def pointcloud_callback(self, msg: PointCloud2):
|
||||
"""Обработка каждого кадра лидара (10 Гц)"""
|
||||
start_time = self.get_clock().now()
|
||||
|
||||
# 1. Читаем точки из PointCloud2 в structured numpy array
|
||||
# (сохраняет стандартную структуру PointCloud)
|
||||
pc_data = pc2.read_points(msg, skip_nans=True)
|
||||
if pc_data.size == 0:
|
||||
return
|
||||
|
||||
# 2. Вызываем ядро FlyGuard
|
||||
if self.fg is None:
|
||||
return
|
||||
|
||||
res = self.fg.process(pc_data)
|
||||
if res is None:
|
||||
return
|
||||
|
||||
d = res.decision
|
||||
tracks = getattr(self.fg.cx, 'tracks', [])
|
||||
|
||||
# 3. Определяем уровень угрозы
|
||||
if d.detected:
|
||||
threat_level = "EMERGENCY" if getattr(d, 'emergency', False) else "WARNING"
|
||||
else:
|
||||
threat_level = "CLEAR"
|
||||
|
||||
# 4. Публикация статуса
|
||||
threat_msg = String()
|
||||
threat_msg.data = threat_level
|
||||
self.pub_threat.publish(threat_msg)
|
||||
|
||||
# 5. Публикация маркеров и рамок
|
||||
stamp = msg.header.stamp
|
||||
self.publish_detections(tracks, stamp)
|
||||
self.publish_rviz_markers(tracks, threat_level, d, stamp)
|
||||
|
||||
elapsed_ms = (self.get_clock().now() - start_time).nanoseconds / 1e6
|
||||
self.get_logger().debug(f"Кадр за {elapsed_ms:.1f} мс | Статус: {threat_level} | Треков: {len(tracks)}")
|
||||
|
||||
def publish_detections(self, tracks, stamp):
|
||||
"""Формирование vision_msgs/Detection3DArray"""
|
||||
det_array = Detection3DArray()
|
||||
det_array.header.stamp = stamp
|
||||
det_array.header.frame_id = self.frame_id
|
||||
|
||||
for trk in tracks:
|
||||
det = Detection3D()
|
||||
det.header = det_array.header
|
||||
|
||||
bbox = BoundingBox3D()
|
||||
bbox.center.position.x = float(getattr(trk, 'x', 0.0))
|
||||
bbox.center.position.y = float(getattr(trk, 'y', 0.0))
|
||||
bbox.center.position.z = float(getattr(trk, 'z', 0.0))
|
||||
|
||||
bbox.size.x = float(getattr(trk, 'dx', 0.5))
|
||||
bbox.size.y = float(getattr(trk, 'dy', 0.5))
|
||||
bbox.size.z = float(getattr(trk, 'dz', 1.5))
|
||||
|
||||
det.bbox = bbox
|
||||
det_array.detections.append(det)
|
||||
|
||||
self.pub_boxes.publish(det_array)
|
||||
|
||||
def publish_rviz_markers(self, tracks, threat_level: str, decision, stamp):
|
||||
"""Генерация маркеров для RViz2"""
|
||||
marker_array = MarkerArray()
|
||||
|
||||
delete_all = Marker()
|
||||
delete_all.action = Marker.DELETEALL
|
||||
marker_array.markers.append(delete_all)
|
||||
|
||||
for i, trk in enumerate(tracks):
|
||||
# Куб вокруг объекта
|
||||
box_marker = Marker()
|
||||
box_marker.header.stamp = stamp
|
||||
box_marker.header.frame_id = self.frame_id
|
||||
box_marker.ns = "flyguard_boxes"
|
||||
box_marker.id = i
|
||||
box_marker.type = Marker.CUBE
|
||||
box_marker.action = Marker.ADD
|
||||
|
||||
x = float(getattr(trk, 'x', 0.0))
|
||||
y = float(getattr(trk, 'y', 0.0))
|
||||
z = float(getattr(trk, 'z', 0.0))
|
||||
|
||||
box_marker.pose.position.x = x
|
||||
box_marker.pose.position.y = y
|
||||
box_marker.pose.position.z = z
|
||||
|
||||
dx = float(getattr(trk, 'dx', 0.5))
|
||||
dy = float(getattr(trk, 'dy', 0.5))
|
||||
dz = float(getattr(trk, 'dz', 1.5))
|
||||
box_marker.scale = Vector3(x=dx, y=dy, z=dz)
|
||||
|
||||
if threat_level == "EMERGENCY":
|
||||
box_marker.color.r, box_marker.color.g, box_marker.color.b, box_marker.color.a = 1.0, 0.0, 0.0, 0.6
|
||||
elif threat_level == "WARNING":
|
||||
box_marker.color.r, box_marker.color.g, box_marker.color.b, box_marker.color.a = 1.0, 0.9, 0.0, 0.5
|
||||
else:
|
||||
box_marker.color.r, box_marker.color.g, box_marker.color.b, box_marker.color.a = 0.0, 1.0, 0.0, 0.4
|
||||
|
||||
marker_array.markers.append(box_marker)
|
||||
|
||||
# Текст с дистанцией над объектом
|
||||
text_marker = Marker()
|
||||
text_marker.header.stamp = stamp
|
||||
text_marker.header.frame_id = self.frame_id
|
||||
text_marker.ns = "flyguard_labels"
|
||||
text_marker.id = 1000 + i
|
||||
text_marker.type = Marker.TEXT_VIEW_FACING
|
||||
text_marker.action = Marker.ADD
|
||||
|
||||
text_marker.pose.position.x = x
|
||||
text_marker.pose.position.y = y
|
||||
text_marker.pose.position.z = z + dz / 2.0 + 0.3
|
||||
text_marker.scale.z = 0.4
|
||||
|
||||
dist = getattr(trk, 'distance', np.hypot(x, y))
|
||||
text_marker.text = f"D: {dist:.1f}m"
|
||||
|
||||
text_marker.color.r, text_marker.color.g, text_marker.color.b, text_marker.color.a = 1.0, 1.0, 1.0, 1.0
|
||||
marker_array.markers.append(text_marker)
|
||||
|
||||
self.pub_markers.publish(marker_array)
|
||||
|
||||
|
||||
def main(args=None):
|
||||
rclpy.init(args=args)
|
||||
node = FlyGuardNode()
|
||||
try:
|
||||
rclpy.spin(node)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
node.destroy_node()
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
@ -17,14 +17,11 @@ 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
|
||||
|
|
@ -34,47 +31,14 @@ 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:
|
||||
|
|
@ -86,7 +50,7 @@ def _work(task):
|
|||
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)
|
||||
track_readout=track_rd)
|
||||
|
||||
|
||||
def ego_track(bag: Bag, params: Params, limit: int | None):
|
||||
|
|
@ -105,17 +69,7 @@ def ego_track(bag: Bag, params: Params, limit: int | None):
|
|||
|
||||
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))
|
||||
|
||||
want_tracks: bool = False, track_readout=None):
|
||||
bag = Bag(bag_path)
|
||||
s_track, _ = ego_track(bag, params, limit)
|
||||
have = [x for x in s_track if x is not None]
|
||||
|
|
@ -125,6 +79,9 @@ def run_bag(bag_path, params: Params, memory, limit: int, d_start: float,
|
|||
|
||||
cat = catalogue()
|
||||
scen = [(name, lat) for name in cat for lat in laterals]
|
||||
pipes = [FlyGuard(params, memory=memory, readout=readout,
|
||||
track_readout=track_readout) for _ in scen]
|
||||
rng = np.random.default_rng(seed)
|
||||
records = [[] for _ in scen]
|
||||
# Выборка для считывания по трекам: описание каждого живого трека на
|
||||
# каждом кадре и метка «это вставленный предмет». Собирается здесь, а не
|
||||
|
|
@ -133,25 +90,6 @@ def run_bag(bag_path, params: Params, memory, limit: int, d_start: float,
|
|||
# пороги. Обученное на другой обстановке считывание нечего и мерить.
|
||||
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)
|
||||
|
||||
|
|
@ -161,35 +99,17 @@ def run_bag(bag_path, params: Params, memory, limit: int, d_start: float,
|
|||
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
|
||||
for i, (name, lat) in enumerate(scen):
|
||||
d_true = d_start - s_now
|
||||
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)
|
||||
u_obj = float(gres.corridor.centre(np.array([d_true], np.float32))[0]) + lat
|
||||
pc2, lab = inject(pc, guide.layout_full, gres.plane, cat[name],
|
||||
Placement(d=d_true, u=u_obj), rng=rng, env=env)
|
||||
res = pipes[i].process(pc2)
|
||||
if res is None:
|
||||
continue
|
||||
tol = max(3.0, 0.12 * d_true)
|
||||
|
|
@ -201,7 +121,7 @@ def run_bag(bag_path, params: Params, memory, limit: int, d_start: float,
|
|||
# фона, нет трека — к сопоставлению по кадрам, нет решения —
|
||||
# к порогу. Без этого разбиения улучшать нечего, кроме удачи.
|
||||
cand = any(abs(c.d - d_true) < tol for c in res.candidates)
|
||||
cx = sc.pipe.cx
|
||||
cx = pipes[i].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"],
|
||||
|
|
@ -293,25 +213,7 @@ def main() -> 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)
|
||||
|
|
@ -373,8 +275,7 @@ def main() -> 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))
|
||||
params = Params(**over)
|
||||
laterals = tuple(float(x) for x in args.laterals.split(","))
|
||||
|
||||
tasks = []
|
||||
|
|
@ -388,17 +289,8 @@ def main() -> None:
|
|||
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))
|
||||
args.memory or "", rd, bool(args.tracks_out), trk))
|
||||
|
||||
# Печатается по готовности, собирается по номеру задачи: порядок сценариев
|
||||
# в файле не должен зависеть от того, какой бэг досчитался первым.
|
||||
|
|
|
|||
|
|
@ -23,11 +23,6 @@
|
|||
независимой проверкой того, что модель выучила предмет, а не «синтетику».
|
||||
|
||||
python tools/make_training_set.py --out data/cache/training_set.npz
|
||||
|
||||
Выборку можно собрать и из большого бэга `new_data`, кусками прямо из архива
|
||||
(`--new-data 0:110` — первые 110 шардов, по пять на «запись»): двадцать
|
||||
минут езды другим днём дают считыванию обстановку, которой нет в пяти
|
||||
коротких записях (EXPERIMENTS п. 17).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -36,9 +31,7 @@ import argparse
|
|||
import numpy as np
|
||||
|
||||
import _bootstrap as B # noqa: F401
|
||||
import _new_data as ND
|
||||
import _parallel as P
|
||||
import _params as PS
|
||||
from flyguard.bag import Bag, find_bags
|
||||
from flyguard.mushroom_body import FEATURES, describe
|
||||
from flyguard.pipeline import FlyGuard, Params
|
||||
|
|
@ -49,14 +42,9 @@ MIN_OVERLAP = 0.5 # доля лучей ядра, пришедш
|
|||
|
||||
|
||||
def _work(task):
|
||||
"""Одна задача — один бэг или кусок `new_data`. Верхнего уровня: иначе не
|
||||
передать в процесс. Кусок распаковывается внутри воркера и удаляется за ним."""
|
||||
src, params, limit, d_starts, laterals, seed = task
|
||||
if isinstance(src, tuple):
|
||||
tar, members = src
|
||||
with ND.chunk(tar, members) as d:
|
||||
return collect_bag(d, params, limit, d_starts, laterals, seed)
|
||||
return collect_bag(src, params, limit, d_starts, laterals, seed)
|
||||
"""Одна задача — один бэг. Верхнего уровня: иначе не передать в процесс."""
|
||||
path, params, limit, d_starts, laterals, seed = task
|
||||
return collect_bag(path, params, limit, d_starts, laterals, seed)
|
||||
|
||||
|
||||
def ego_track(bag: Bag, params: Params, limit: int):
|
||||
|
|
@ -152,35 +140,21 @@ def main() -> None:
|
|||
# красивая и бессмысленная, а в тоннеле у оси полно штатных конструкций.
|
||||
ap.add_argument("--laterals", default="0,-0.6,0.6,-1.2,1.2")
|
||||
ap.add_argument("--seed", type=int, default=20260921)
|
||||
ap.add_argument("--new-data", default="",
|
||||
help="срез номеров шардов new_data, например 0:110: собирать "
|
||||
"выборку из кусков большого бэга вместо записей --root")
|
||||
ap.add_argument("--tar", default=ND.DEFAULT_TAR, help="архив new_data")
|
||||
ap.add_argument("--per-chunk", type=int, default=5,
|
||||
help="шардов в куске new_data; кусок считается одной записью")
|
||||
P.add_argument(ap)
|
||||
PS.add_argument(ap)
|
||||
args = ap.parse_args()
|
||||
|
||||
d_starts = tuple(float(x) for x in args.d_starts.split(","))
|
||||
laterals = tuple(float(x) for x in args.laterals.split(","))
|
||||
params = Params(**PS.apply({}, args.set, Params))
|
||||
params = Params()
|
||||
B.CACHE.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if args.new_data:
|
||||
chunks = ND.split(ND.pick(ND.shards(args.tar), args.new_data), args.per_chunk)
|
||||
names = [f"new_data_{c[0][0]:03d}_{c[-1][0]:03d}" for c in chunks]
|
||||
sources = [(args.tar, c) for c in chunks]
|
||||
else:
|
||||
bags = [p for p in find_bags(args.root) if p.name != HOLDOUT]
|
||||
names = [p.name for p in bags]
|
||||
sources = bags
|
||||
tasks = [(s, params, args.limit, d_starts, laterals, args.seed) for s in sources]
|
||||
tasks = [(p, params, args.limit, d_starts, laterals, args.seed) for p in bags]
|
||||
# Печатается по готовности, складывается по номеру задачи: порядок бэгов
|
||||
# в файле не должен зависеть от того, какой из них досчитался первым.
|
||||
slots: list = [None] * len(tasks)
|
||||
for i, task, got, secs in P.run(_work, tasks, args.jobs):
|
||||
name = names[i]
|
||||
name = task[0].name
|
||||
if got is None:
|
||||
print(f" {name:42s} пропущен")
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -12,15 +12,12 @@ import numpy as np
|
|||
import _bootstrap as B # noqa: F401
|
||||
from flyguard.bag import Bag, find_bags
|
||||
from flyguard.mushroom_body import MushroomBody
|
||||
from flyguard.mbon_readout import MbonReadout
|
||||
from flyguard.pipeline import FlyGuard, Params
|
||||
from flyguard.track_readout import TrackReadout
|
||||
|
||||
|
||||
def run(bag_path, params: Params, memory, readout=None, track_readout=None,
|
||||
limit: int | None = None, verbose: bool = False) -> dict:
|
||||
def run(bag_path, params: Params, memory, limit: int | None, verbose: bool) -> dict:
|
||||
bag = Bag(bag_path)
|
||||
fg = FlyGuard(params, memory=memory, readout=readout, track_readout=track_readout)
|
||||
fg = FlyGuard(params, memory=memory)
|
||||
stages: dict[str, list[float]] = {}
|
||||
n_det = n_frames = 0
|
||||
dists, speeds, ncand = [], [], []
|
||||
|
|
@ -68,31 +65,15 @@ def main() -> None:
|
|||
ap.add_argument("--all", action="store_true")
|
||||
ap.add_argument("--limit", type=int, default=150)
|
||||
ap.add_argument("--memory", default=None)
|
||||
ap.add_argument("--readout", default=None, help="модель MBON (mbon_readout.npz)")
|
||||
ap.add_argument("--track-readout", default=None, help="модель TrackReadout (track_readout.npz)")
|
||||
ap.add_argument("--device", default="auto", choices=["auto", "cuda", "cpu"],
|
||||
help="устройство вычислений ('auto', 'cuda', 'cpu')")
|
||||
ap.add_argument("--fov", type=float, default=30.0)
|
||||
ap.add_argument("--verbose", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
memory = MushroomBody.load(args.memory) if args.memory else None
|
||||
readout = MbonReadout.load(args.readout) if args.readout else None
|
||||
track_readout = TrackReadout.load(args.track_readout) if args.track_readout else None
|
||||
|
||||
if not args.all and not args.bag:
|
||||
args.all = True
|
||||
|
||||
params = Params(fov_deg=args.fov, device=args.device)
|
||||
bag_root = (B.DATA / "for_hackathon") if (B.DATA / "for_hackathon").exists() else B.DATA
|
||||
bags = find_bags(bag_root) if args.all else ([args.bag] if args.bag else [])
|
||||
if not bags:
|
||||
print(f"Внимание: бэги не найдены в {bag_root}. Убедитесь, что каталог смонтирован в FLYGUARD_DATA.")
|
||||
return
|
||||
|
||||
params = Params(fov_deg=args.fov)
|
||||
bags = find_bags(B.DATA / "for_hackathon") if args.all else [args.bag]
|
||||
for b in bags:
|
||||
run(b, params, memory, readout=readout, track_readout=track_readout,
|
||||
limit=args.limit, verbose=args.verbose)
|
||||
run(b, params, memory, args.limit, args.verbose)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -36,17 +36,9 @@ BANDS = ((0, 30), (30, 55), (55, 80), (80, 110), (110, 160), (160, 230))
|
|||
|
||||
|
||||
def loo(names, X, Y, cfg, n_pn, args, keep_models=False):
|
||||
"""Обучение на всех бэгах кроме проверяемого. Возвращает список результатов.
|
||||
|
||||
Проверяемыми бывают не все записи: куски `new_data` нужны только для
|
||||
обучения, и отдельная складка на каждый из них — лишние полчаса
|
||||
(`--train-only`).
|
||||
"""
|
||||
skip = tuple(p for p in args.train_only.split(",") if p)
|
||||
"""Обучение на всех бэгах кроме проверяемого. Возвращает список результатов."""
|
||||
out = []
|
||||
for held in names:
|
||||
if skip and held.startswith(skip):
|
||||
continue
|
||||
tr = [n for n in names if n != held]
|
||||
Xtr = np.concatenate([X[n] for n in tr])
|
||||
ytr = np.concatenate([Y[n] for n in tr])
|
||||
|
|
@ -61,24 +53,18 @@ def loo(names, X, Y, cfg, n_pn, args, keep_models=False):
|
|||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--data", action="append", default=None,
|
||||
help="выборка make_training_set; можно несколько — записи "
|
||||
"объединяются (по умолчанию data/cache/training_set.npz)")
|
||||
ap.add_argument("--data", default=str(B.CACHE / "training_set.npz"))
|
||||
ap.add_argument("--out", default=str(B.ARTIFACTS / "mbon_readout.npz"))
|
||||
ap.add_argument("--n-kc", type=int, default=0,
|
||||
help="ёмкость итоговой модели; 0 — взять лучшую из развёртки")
|
||||
ap.add_argument("--sweep-kc", default="4000,8000,20000,50000",
|
||||
ap.add_argument("--sweep-kc", default="4000,8000,20000",
|
||||
help="ёмкости для развёртки; пусто — не разворачивать")
|
||||
ap.add_argument("--active", type=int, default=100,
|
||||
help="активных клеток после торможения APL")
|
||||
ap.add_argument("--train-only", default="",
|
||||
help="префиксы записей через запятую, которые только учат и "
|
||||
"своей складки не получают, например new_data_")
|
||||
ap.add_argument("--epochs", type=int, default=60)
|
||||
ap.add_argument("--lr", type=float, default=4.0)
|
||||
ap.add_argument("--l2", type=float, default=1e-5)
|
||||
ap.add_argument("--device", default="auto",
|
||||
help="устройство обучения ('auto', 'cuda', 'cpu')")
|
||||
ap.add_argument("--device", default="cpu")
|
||||
ap.add_argument("--baseline", action="store_true",
|
||||
help="сравнить с градиентным бустингом по сырым признакам")
|
||||
ap.add_argument("--save-folds", default=str(B.ARTIFACTS / "mbon_folds"),
|
||||
|
|
@ -86,20 +72,12 @@ def main() -> None:
|
|||
"оценка нечестна — считывание увидит проверяемый бэг")
|
||||
args = ap.parse_args()
|
||||
|
||||
names, X, Y, D, feats = [], {}, {}, {}, None
|
||||
for path in args.data or [str(B.CACHE / "training_set.npz")]:
|
||||
d = np.load(path, allow_pickle=True)
|
||||
f_here = [str(f) for f in d["features"]]
|
||||
if feats is not None and f_here != feats:
|
||||
raise SystemExit(f"{path}: другой набор признаков — выборки несовместимы")
|
||||
feats = f_here
|
||||
for n in (str(n) for n in d["names"]):
|
||||
if n in X:
|
||||
raise SystemExit(f"запись {n} встречается в двух выборках")
|
||||
names.append(n)
|
||||
X[n] = d[f"X_{n}"].astype(np.float32)
|
||||
Y[n] = d[f"y_{n}"].astype(np.int8)
|
||||
D[n] = d[f"d_{n}"].astype(np.float32)
|
||||
d = np.load(args.data, allow_pickle=True)
|
||||
names = [str(n) for n in d["names"]]
|
||||
X = {n: d[f"X_{n}"].astype(np.float32) for n in names}
|
||||
Y = {n: d[f"y_{n}"].astype(np.int8) for n in names}
|
||||
D = {n: d[f"d_{n}"].astype(np.float32) for n in names}
|
||||
feats = [str(f) for f in d["features"]]
|
||||
n_pn = next(iter(X.values())).shape[1]
|
||||
tot = sum(v.shape[0] for v in X.values())
|
||||
pos = sum(int(v.sum()) for v in Y.values())
|
||||
|
|
@ -170,12 +148,6 @@ def main() -> None:
|
|||
f"обстановки при 95% предметов {fpr_at_tpr(s, Y[held], 0.95):6.2%}")
|
||||
if args.baseline:
|
||||
import lightgbm as lgb
|
||||
dev_type = "gpu" if (args.device == "cuda" or (args.device == "auto" and B.Path("/dev/nvidia0").exists())) else "cpu"
|
||||
try:
|
||||
g = lgb.LGBMClassifier(n_estimators=400, learning_rate=0.05,
|
||||
num_leaves=63, device=dev_type, verbose=-1)
|
||||
g.fit(Xtr, ytr)
|
||||
except Exception:
|
||||
g = lgb.LGBMClassifier(n_estimators=400, learning_rate=0.05,
|
||||
num_leaves=63, verbose=-1)
|
||||
g.fit(Xtr, ytr)
|
||||
|
|
|
|||
|
|
@ -63,8 +63,7 @@ def main() -> None:
|
|||
ap.add_argument("--n-kc", type=int, default=_d.n_kc)
|
||||
ap.add_argument("--claws", type=int, default=_d.claws)
|
||||
ap.add_argument("--sparsity", type=float, default=_d.sparsity)
|
||||
ap.add_argument("--device", default="auto",
|
||||
help="устройство вычислений ('auto', 'cuda', 'cpu')")
|
||||
ap.add_argument("--device", default="cpu")
|
||||
ap.add_argument("--reuse-cache", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
|
|
|
|||
|
|
@ -71,10 +71,8 @@ def main() -> None:
|
|||
ap.add_argument("--sweep-kc", default="500,1000,2000,4000")
|
||||
ap.add_argument("--epochs", type=int, default=200)
|
||||
ap.add_argument("--lr", type=float, default=2.0)
|
||||
ap.add_argument("--device", default="auto",
|
||||
help="устройство обучения ('auto', 'cuda', 'cpu')")
|
||||
ap.add_argument("--baseline", action="store_true",
|
||||
help="сравнить с GPU/CPU градиентным бустингом (LightGBM)")
|
||||
ap.add_argument("--l2", type=float, default=1e-4)
|
||||
ap.add_argument("--device", default="cpu")
|
||||
ap.add_argument("--drop", default="",
|
||||
help="признаки, выброшенные из обучения — это РЕЖИМ ЗАМЕРА, "
|
||||
"модель при этом не сохраняется: конвейер считает "
|
||||
|
|
@ -174,32 +172,9 @@ def main() -> None:
|
|||
for held, s, _ in res:
|
||||
print(f" {held:<42}AUC {auc(s, Y[held]):.4f}")
|
||||
|
||||
table_cols = {"улика: AUC": ev_loo, "модель: AUC": s_loo}
|
||||
if args.baseline:
|
||||
import lightgbm as lgb
|
||||
s_lgb = []
|
||||
for held in names:
|
||||
tr = [n for n in names if n != held]
|
||||
Xtr = np.concatenate([X[n] for n in tr])
|
||||
ytr = np.concatenate([Y[n] for n in tr])
|
||||
dev_type = "gpu" if (args.device == "cuda" or (args.device == "auto" and B.Path("/dev/nvidia0").exists())) else "cpu"
|
||||
try:
|
||||
g = lgb.LGBMClassifier(n_estimators=300, learning_rate=0.05, num_leaves=31,
|
||||
device=dev_type, verbose=-1)
|
||||
g.fit(Xtr, ytr)
|
||||
except Exception:
|
||||
g = lgb.LGBMClassifier(n_estimators=300, learning_rate=0.05, num_leaves=31,
|
||||
verbose=-1)
|
||||
g.fit(Xtr, ytr)
|
||||
s_lgb.append(g.predict_proba(X[held])[:, 1])
|
||||
s_lgb_arr = np.concatenate(s_lgb)
|
||||
print(f"\nбустинг leave-one-bag-out: AUC {auc(s_lgb_arr, y_loo):.4f}, "
|
||||
f"ложных при 95 % пойманных {fpr_at_tpr(s_lgb_arr, y_loo):.3f}")
|
||||
table_cols["бустинг: AUC"] = s_lgb_arr
|
||||
|
||||
# ------------------------------- главное: обгоняет ли модель саму улику
|
||||
print("\nмодель против улики, по полосам дальности:")
|
||||
band_table(d_loo, y_loo, table_cols)
|
||||
band_table(d_loo, y_loo, {"улика: AUC": ev_loo, "модель: AUC": s_loo})
|
||||
|
||||
# --------------------------------------------------------------- хранение
|
||||
if drop:
|
||||
|
|
|
|||
Loading…
Reference in a new issue