Compare commits

..

No commits in common. "2f454738910856083a43ea98b6ddf173536b6751" and "657cd5406f4e4d16de49dbdc8e394e6c8b7a68b0" have entirely different histories.

46 changed files with 233 additions and 6237 deletions

View file

@ -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
View file

@ -6,7 +6,3 @@ data/
artifacts/mbon_folds/
docs/figures/*.png
docs/figures/*.mp4
# Project memory & chat transfer
PROJECT_MEMORY.md
CHAT_MEMORY.md

View file

@ -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"]

View file

@ -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"]

View file

@ -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

View file

@ -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.

View file

@ -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'

View file

@ -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

View file

@ -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

View file

@ -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 | выше → сильнее подавление знакомого, но риск заглушить и настоящее |
---

View file

@ -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 без падения программы.

File diff suppressed because it is too large Load diff

View file

@ -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`.

View file

@ -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))

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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())

View file

@ -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-контраст диспаритета в «насколько ближе окружения», м.

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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:

View file

@ -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),

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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())

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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

View file

@ -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()

View file

@ -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))
# Печатается по готовности, собирается по номеру задачи: порядок сценариев
# в файле не должен зависеть от того, какой бэг досчитался первым.

View file

@ -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

View file

@ -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__":

View file

@ -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)

View file

@ -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()

View file

@ -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: