From 52a203a5a2af1c56191e9814fc1fe3095cb88811 Mon Sep 17 00:00:00 2001 From: AlexsandrSnytkin Date: Wed, 16 Sep 2026 20:36:58 +0700 Subject: [PATCH] feat(ui): add editable network config canvas Add Netron inspection, persisted draggable config blocks, themes, and safer model/config input.\nKeep the Docker UI on one published port and fall back to CPU safely.\nIgnore local environments and runtime artifacts. --- .dockerignore | 8 + .env.example | 65 + .gitattributes | 2 + .gitignore | 26 + Dockerfile | 6 +- README.md | 64 +- README_docker.md | 8 +- ballistic_trajectory.py | 141 + bytetrack_min_aggressive.py | 75 +- config.py | 205 +- config_intercept.py | 2 +- configurable_udp_capture.py | 415 ++ delimited_frame_capture.py | 199 + docker-compose.usb.yml | 11 + docker-compose.yml | 113 +- docker/entrypoint.sh | 2 +- docs/ARCHITECTURE_RU.md | 4 +- docs/CONFIG_REFERENCE_RU.md | 5 + docs/RUNBOOK_RU.md | 4 +- docs/TROUBLESHOOTING_RU.md | 4 +- error_output.py | 236 + ffmpeg_capture.py | 166 + guidance.py | 4 +- guidance_state.json | 22 + hdmi_usb_bridge.py | 127 + helpers.py | 229 +- main.py | 1074 ++++- requirements-docker.txt | 6 + runtime_env.py | 54 + start-hdmi-bridge.ps1 | 28 + start_file_ui_run.ps1 | 58 + start_usb_turbo.ps1 | 84 + target_physics.py | 243 ++ ...lot_proto_udp.cpython-312-pytest-9.0.3.pyc | Bin 5686 -> 0 bytes ...logger_events.cpython-312-pytest-9.0.3.pyc | Bin 3427 -> 0 bytes ...uidance_reset.cpython-312-pytest-9.0.3.pyc | Bin 10470 -> 0 bytes ...etrack_module.cpython-312-pytest-9.0.3.pyc | Bin 2724 -> 0 bytes ...t_runtime_env.cpython-312-pytest-9.0.3.pyc | Bin 6025 -> 0 bytes ...arget_handoff.cpython-312-pytest-9.0.3.pyc | Bin 13733 -> 0 bytes ...k_score_gates.cpython-312-pytest-9.0.3.pyc | Bin 6583 -> 0 bytes tests/test_ballistic_trajectory.py | 79 + tests/test_bytetrack_assignment.py | 37 + tests/test_configurable_udp_capture.py | 207 + tests/test_delimited_frame_capture.py | 90 + tests/test_error_output.py | 161 + tests/test_ffmpeg_capture.py | 64 + tests/test_local_bytetrack_module.py | 27 + tests/test_low_confidence_tracking_profile.py | 60 + tests/test_runtime_env.py | 9 + tests/test_stream_capture.py | 41 + tests/test_target_physics.py | 130 + tests/test_track_score_gates.py | 17 +- tests/test_udp_dump_capture.py | 130 + tests/test_udp_probe.py | 74 + tests/test_ui_server.py | 444 ++ tests/test_yolo_worker_lifecycle.py | 77 + track_score_policy.py | 20 + udp_dump_capture.py | 339 ++ udp_probe.py | 496 +++ ui_server.py | 3794 +++++++++++++++++ yolo_worker.py | 133 +- 61 files changed, 9857 insertions(+), 262 deletions(-) create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 ballistic_trajectory.py create mode 100644 configurable_udp_capture.py create mode 100644 delimited_frame_capture.py create mode 100644 docker-compose.usb.yml create mode 100644 error_output.py create mode 100644 ffmpeg_capture.py create mode 100644 guidance_state.json create mode 100644 hdmi_usb_bridge.py create mode 100644 start-hdmi-bridge.ps1 create mode 100644 start_file_ui_run.ps1 create mode 100644 start_usb_turbo.ps1 create mode 100644 target_physics.py delete mode 100644 tests/__pycache__/test_autopilot_proto_udp.cpython-312-pytest-9.0.3.pyc delete mode 100644 tests/__pycache__/test_decision_logger_events.cpython-312-pytest-9.0.3.pyc delete mode 100644 tests/__pycache__/test_guidance_reset.cpython-312-pytest-9.0.3.pyc delete mode 100644 tests/__pycache__/test_local_bytetrack_module.cpython-312-pytest-9.0.3.pyc delete mode 100644 tests/__pycache__/test_runtime_env.cpython-312-pytest-9.0.3.pyc delete mode 100644 tests/__pycache__/test_target_handoff.cpython-312-pytest-9.0.3.pyc delete mode 100644 tests/__pycache__/test_track_score_gates.cpython-312-pytest-9.0.3.pyc create mode 100644 tests/test_ballistic_trajectory.py create mode 100644 tests/test_bytetrack_assignment.py create mode 100644 tests/test_configurable_udp_capture.py create mode 100644 tests/test_delimited_frame_capture.py create mode 100644 tests/test_error_output.py create mode 100644 tests/test_ffmpeg_capture.py create mode 100644 tests/test_low_confidence_tracking_profile.py create mode 100644 tests/test_stream_capture.py create mode 100644 tests/test_target_physics.py create mode 100644 tests/test_udp_dump_capture.py create mode 100644 tests/test_udp_probe.py create mode 100644 tests/test_ui_server.py create mode 100644 tests/test_yolo_worker_lifecycle.py create mode 100644 udp_dump_capture.py create mode 100644 udp_probe.py create mode 100644 ui_server.py diff --git a/.dockerignore b/.dockerignore index 918a453..779d54f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -30,3 +30,11 @@ rollback_*.csv !best.pt !docs/superpowers/specs/2026-06-29-docker-offline-gpu-proto-udp-design.md !docs/superpowers/plans/2026-06-29-docker-offline-gpu-proto-udp-implementation.md +runtime-data/ +.superpowers/ +.git/ +.vscode/ +0/ +data/ +USB/ +tests/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ad36e00 --- /dev/null +++ b/.env.example @@ -0,0 +1,65 @@ +PYTORCH_INDEX_URL=https://download.pytorch.org/whl/cu128 +FPV_IMAGE=fpv-tracker:cu128-offline +FPV_RESTART_POLICY=unless-stopped +FPV_MODEL_FUSE=0 +FPV_TORCH_CUDNN_BENCHMARK=1 +FPV_TORCH_MATMUL_PRECISION=high + +# Default Docker-friendly source: local file mounted to /data/input/source.mp4. +# Linux USB camera: set FPV_SOURCE=0 and enable devices in docker-compose.yml. +# Windows Docker Desktop: direct webcam index usually unavailable; use RTSP/UDP. +FPV_INPUT_FILE=./runtime-data/input/source.mp4 +FPV_SOURCE=/data/input/source.mp4 +FPV_CAP_BACKEND=v4l2 +FPV_CAMERA_WIDTH=1280 +FPV_CAMERA_HEIGHT=720 +FPV_CAMERA_FPS=30 +FPV_CAMERA_FOURCC=MJPG +FPV_CAMERA_READ_FAIL_RETRIES=120 +FPV_EFFECTIVE_W=720 +FPV_EFFECTIVE_H=576 +FPV_FORCE_EFFECTIVE_PAL=0 +FPV_IMG_SIZE_ROI=640 +FPV_IMG_SIZE_FULL=1280 +FPV_MAX_DET=60 +FPV_RECOVER_FORCED_DET_EVERY=2 +FPV_RECOVER_FULLSCAN_EVERY=30 +FPV_YOLO_FORCE_DET_WHEN_WEAK=1 +FPV_CLOSE_PERIODIC_FULLSCAN_EVERY=12 +FPV_ANALOG_FPV_MODE=1 +FPV_APPLY_YOLO_PREPROC=1 +FPV_PRE_BLUR_K=3 +FPV_PRE_UNSHARP=0.12 +FPV_DEBUG=1 +FPV_TARGET_OUT_FPS=0 +FPV_VIDEO_REALTIME=1 +FPV_VIDEO_UDP_PORT=5600 + +FPV_MODEL_FILE=./best.pt +FPV_DATA_DIR=./runtime-data +FPV_SHOW_OUTPUT=0 +FPV_SAVE_INFER_VIDEO=1 +FPV_OUT_VIDEO_PATH=/data/out/out_infer.mp4 +FPV_ARCHIVE_RECORD_MODE=fragments +FPV_DETECTION_CLIP_MAX_GAP_SEC=15 +FPV_INFER_VIDEO_MAX_W=0 +FPV_INFER_VIDEO_MAX_H=0 +FPV_UI_PORT=8080 +FPV_UI_FRAME_EXPORT_ENABLE=1 +FPV_UI_FRAME_EXPORT_PATH=/data/ui/latest.jpg +FPV_UI_FRAME_EXPORT_EVERY=1 +FPV_UI_FRAME_EXPORT_JPEG_QUALITY=90 + +FPV_GUIDANCE_EXPORT_ENABLE=1 +FPV_GUIDANCE_EXPORT_PATH=/data/guidance/guidance_state.json + +FPV_AUTOPILOT_ENABLE=1 +FPV_AUTOPILOT_BACKEND=json +FPV_AUTOPILOT_JSON_PATH=/data/autopilot/autopilot_cmd.json + +FPV_PROTO_UDP_ENABLE=0 +FPV_PROTO_UDP_HOST=192.168.1.10 +FPV_PROTO_UDP_PORT=5005 + +# Linux camera only, with docker-compose.yml devices block enabled: +FPV_CAMERA_DEVICE=/dev/video0 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f3f6bec --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.sh text eol=lf +Dockerfile text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c7c17b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Local Python environments and caches +.venv/ +venv/ +env/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# Runtime output and local browser state +runtime-data/ +.superpowers/ +*.log + +# Large local inputs/outputs +*.mp4 +*.udp +*.pt +!best.pt +1785156788883112336 +1785156930165011925_OHO +1785157010396359620 + +# Editor settings +.vscode/ diff --git a/Dockerfile b/Dockerfile index e21892d..df79509 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,13 +31,17 @@ RUN python3 -m pip install --upgrade pip setuptools wheel && \ COPY . /app -RUN mkdir -p /data/out /data/guidance /data/autopilot /app/docker && \ +RUN mkdir -p /data/input /data/out /data/guidance /data/autopilot /data/logs /data/ui /app/docker && \ chmod +x /app/docker/entrypoint.sh ENV FPV_MODEL_PATH=/app/best.pt \ FPV_SHOW_OUTPUT=0 \ FPV_SAVE_INFER_VIDEO=1 \ FPV_OUT_VIDEO_PATH=/data/out/out_infer.mp4 \ + FPV_UI_FRAME_EXPORT_ENABLE=1 \ + FPV_UI_FRAME_EXPORT_PATH=/data/ui/latest.jpg \ + FPV_UI_FRAME_EXPORT_EVERY=1 \ + FPV_UI_FRAME_EXPORT_JPEG_QUALITY=90 \ FPV_GUIDANCE_EXPORT_ENABLE=1 \ FPV_GUIDANCE_EXPORT_PATH=/data/guidance/guidance_state.json \ FPV_AUTOPILOT_ENABLE=1 \ diff --git a/README.md b/README.md index 95ac340..00ed844 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Проект умеет: -- принимать видео с файла, камеры, RTSP и UDP-потока; +- принимать видео с файла, камеры, RTSP, UDP-потока и готового UDP-дампа камеры; - обнаруживать и сопровождать цель; - удерживать цель при кратковременной потере детекции; - строить команды наведения в экранных координатах; @@ -41,6 +41,19 @@ docker compose build docker compose up ``` +Откройте `http://localhost:8080` и запустите обработку кнопкой «Старт». Отдельный трекер без UI: `docker compose --profile standalone up fpv-tracker`. + +### HDMI по USB в Docker Desktop + +Linux-контейнер Docker Desktop не видит Windows DirectShow-камеры как `/dev/video*`. Перед использованием режима `HDMI по USB` один раз запустите локальный мост: + +```powershell +powershell -ExecutionPolicy Bypass -File .\start-hdmi-bridge.ps1 +docker compose up -d +``` + +В UI выберите `HDMI по USB`, индекс Windows-устройства, качество и FPS. Мост принимает HDMI на Windows и передаёт контейнеру MJPEG через `host.docker.internal:8091`. Повторный запуск скрипта не создаёт второй процесс. + ## Комплект документации - [Полный runbook](docs/RUNBOOK_RU.md) @@ -66,6 +79,8 @@ python main.py ## Основные файлы - `main.py` — основной цикл обработки видео. +- `hdmi_usb_bridge.py` — мост Windows DirectShow → MJPEG для Docker. +- `start-hdmi-bridge.ps1` — фоновый запуск HDMI-моста. - `config.py` — настройки источника, детектора, трекинга, ROI и вывода. - `config_intercept.py` — настройки наведения, range/PN/FSM и backend'ов. - `guidance.py` — экранный guidance и экспорт `guidance_state.json`. @@ -78,8 +93,8 @@ python main.py В зависимости от настроек проект может создавать: - `out_infer_*.mp4` — видео с наложениями; -- `track_log_*.csv` — покадровый лог; -- `track_summary_*.json` — сводка по прогону; +- `runtime-data/track-logs/track_log_*.csv` — покадровый лог; +- `runtime-data/track-summaries/track_summary_*.json` — сводка по прогону; - `guidance_state.json` — текущее состояние guidance; - `autopilot_cmd.json` — команды автопилота при backend `json`. @@ -93,3 +108,46 @@ python main.py - `requirements-docker.txt` После сборки образ рассчитан на запуск без интернета. + +## UDP-источники + +В `Настройки → Источник → Режим` доступны пять отдельных вариантов: + +- `Камера UDP — кадры с разделительным байтом` — живой поток raw-кадров. Размер и FPS задаются произвольно; профиль `512x640 @ 50 FPS` соответствует 640 строкам по 512 значений. +- `Камера UDP МИК — протокол документа` — приём и сборка payload-пакетов МИК по `first/last`, sequence и offset. +- `Камера UDP — пользовательский пакет` — конструктор заголовка и способа сборки без изменения кода. +- `Файл UDP-лога МИК` — готовый лог с четырёхбайтовой обёрткой `порт + размер payload`; порт определяется по файлу и может отличаться от `59004`. +- `Файл UDP-лога — кадры с разделителем` — готовая последовательность кадров, разделённых одним байтом. + +Для live-режимов задаются bind-адрес, UDP-порт, ширина `16–8192`, высота `16–8192` и FPS `1–240`. В Docker поле адреса приёма обычно должно быть `0.0.0.0`, а не IP отправителя. Docker Compose публикует `59004/udp` для МИК, `59005/udp` и `40404/udp` для кадров с разделителем. Для raw-потока размер кадра определяет его границу, поэтому значения пикселей могут совпадать с разделителем; отдельный байт между кадрами отбрасывается. Поддержаны `JPEG/PNG`, `BGR24`, `RGB24`, `Gray 8-bit`, `Gray 16-bit` и `YUYV 4:2:2`. + +В поле `Способ разбора` доступны `Автоопределение`, `МИК по документу`, `Кадры с разделителем` и `Пользовательский пакет`. Автоопределение слушает выбранные адрес и порт 3 секунды, показывает фактический IP и порт отправителя, размеры датаграмм, HEX/ASCII и SHA-256 образцов, затем проверяет структуры МИК, RTP, MPEG-TS, JPEG/PNG, H.264/H.265, raw-кадров, JSON и текста. При строгом совпадении найденный режим применяется автоматически. Все UDP payload сохраняются без изменений в `runtime-data/udp-probes/*.udp`; рядом лежит JSON с временными метками, адресами отправителей и SHA-256 каждого пакета. + +Конструктор показывает заголовок как ленту отдельных байтов. Кнопка `+ байт` добавляет блок, крестик удаляет его вместе с байтами, стрелки меняют порядок. Для каждого блока задаются имя, длина и назначение: `не читать`, `читать как число`, `flags`, `sequence`, `номер фрагмента` или `размер/смещение`. Размер заголовка и offsets вычисляются автоматически; именованные числовые поля читаются с выбранным endian. Отдельно задаются маски начала/конца, смысл `value`, способ сборки (`фрагменты`, `один датаграмм`, `поток`) и содержимое результата (`кадр` или массив МИК`). Для кадра выбираются размер, FPS и `JPEG/PNG`, `BGR24`, `RGB24`, `Gray8`, `Gray16` либо `YUYV422`. Повреждённая цепочка отбрасывается до следующего пакета с флагом начала. + +Большие UDP-дампы загружаются напрямую в `/data/input` одним потоком, без второй временной копии в памяти или на диске. Интерфейс показывает процент загрузки и позволяет отменить операцию; незавершённый файл не попадает в список источников. + +Разделительный байт не должен встречаться внутри данных кадра. Если это невозможно гарантировать, используйте формат МИК с явной длиной массива. + +Готовое видео не ограничивается расширением файла: источник определяется FFmpeg по содержимому. Поддерживаются все контейнеры и кодеки, доступные в FFmpeg образа, включая `MP4`, `AVI`, `MOV`, `MKV`, `WebM`, `MPEG-TS`, `M2TS`, `MXF`, `WMV/ASF`, `FLV`, `VOB`, `OGV`, `3GP`, raw `H.264/H.265` и файлы с нестандартным расширением. В вебе любой успешно декодированный источник выводится через единый поток кадров, а обработанная запись сохраняется в совместимом MP4. + +Для файла `1785156788883112336` структура разобрана согласно документу «МИК. Описание передачи данных на порт 59004»: + +- `7c e6` — UDP-порт `59004` (`uint16 LE`), следующие 2 байта — длина payload; +- заголовок UDP payload: версия, флаги начала/конца, номер последовательности, номер пакета и `uint32 LE` размера/смещения; +- большой массив: `uint32 LE` количества меток, по 40 байт на метку, затем 8-байтный заголовок видеокадра; +- заголовок кадра задаёт ширину, высоту, Pixel ID и количество байт выравнивания каждой строки; +- в примере: `INT16`, `636x476`, по 8 padding-байт на строку; padding не передаётся модели как пиксели. + +Декодер проверяет sequence, циклический packet number, offsets и флаги начала/конца, пропускает повреждённые последовательности, извлекает метки и формирует BGR-кадр. Поддержаны форматы `GRAY8`, `GRAY16`, `RGB888`, `YCbCr422` и `INT16`. Частота кадров берётся из настройки сценария. `PCAP/PCAPNG` сначала нужно преобразовать, извлекая UDP payload. + +Переменные окружения для этих источников: + +```text +FPV_SOURCE_MODE=udp_mik_live|udp_delimited_live|udp_custom_live|udp_dump|udp_delimited_file +FPV_UDP_INPUT_HOST=0.0.0.0 +FPV_UDP_INPUT_PORT=59004 +FPV_FRAME_SEPARATOR_BYTE=0 +FPV_FRAME_ENCODING=auto|bgr24|rgb24|gray8|gray16|yuyv422 +FPV_UDP_PACKET_SCHEMA={"assembly":"fragmented","header_size":8,"byte_order":"little"} +``` diff --git a/README_docker.md b/README_docker.md index d0d6ca6..1b74158 100644 --- a/README_docker.md +++ b/README_docker.md @@ -26,10 +26,16 @@ docker compose build docker compose up ``` +По умолчанию поднимается UI на `http://localhost:8080`; модель запускается кнопкой «Старт». Отдельный headless-процесс без UI: + +```bash +docker compose --profile standalone up fpv-tracker +``` + ## Основные env-переменные - `FPV_SOURCE` - Примеры: `"0"`, `"rtsp://192.168.1.10:8554/live"`, `"udp://@0.0.0.0:5600"`, `"/data/input/test.mp4"` + Примеры: `"0"`, `"rtsp://192.168.1.10:8554/live"`, `"udp://@0.0.0.0:5600"`, `"/data/input/test.mp4"`, `"/data/input/camera.ts"` - `FPV_MODEL_PATH` - `FPV_SHOW_OUTPUT` - `FPV_SAVE_INFER_VIDEO` diff --git a/ballistic_trajectory.py b/ballistic_trajectory.py new file mode 100644 index 0000000..bc4ddfc --- /dev/null +++ b/ballistic_trajectory.py @@ -0,0 +1,141 @@ +import numpy as np + +from helpers import box_center, box_wh, clip_box + + +def _fit_motion(times, centers, max_speed, max_accel): + design = np.column_stack( + (np.ones_like(times), times, 0.5 * times * times) + ).astype(np.float64) + coefficients, *_ = np.linalg.lstsq(design, centers, rcond=None) + residuals = np.linalg.norm(centers - design @ coefficients, axis=1) + median = float(np.median(residuals)) + mad = float(np.median(np.abs(residuals - median))) + limit = median + max(1.0, 3.0 * 1.4826 * mad) + keep = residuals <= limit + if np.count_nonzero(keep) >= 4: + coefficients, *_ = np.linalg.lstsq( + design[keep], centers[keep], rcond=None + ) + residuals = np.linalg.norm( + centers[keep] - design[keep] @ coefficients, axis=1 + ) + + velocity = coefficients[1].astype(np.float64) + acceleration = coefficients[2].astype(np.float64) + speed = float(np.linalg.norm(velocity)) + accel = float(np.linalg.norm(acceleration)) + if speed > float(max_speed): + velocity *= float(max_speed) / max(speed, 1e-6) + if accel > float(max_accel): + acceleration *= float(max_accel) / max(accel, 1e-6) + rms = float(np.sqrt(np.mean(residuals * residuals))) if residuals.size else 0.0 + return coefficients[0], velocity, acceleration, rms, keep + + +def predict_ballistic( + observations, + now_ts, + frame_w, + frame_h, + *, + lookback=10, + min_observations=5, + min_span_sec=0.12, + max_horizon_sec=0.55, + max_speed=900.0, + max_accel=1200.0, + max_size_rate=1.2, + max_uncertainty=120.0, +): + recent = [] + for observation in list(observations or [])[-max(2, int(lookback)):]: + try: + ts = float(observation["ts"]) + center = np.asarray(observation["center"], dtype=np.float64) + box = np.asarray(observation["box"], dtype=np.float64) + except (KeyError, TypeError, ValueError): + continue + if center.shape != (2,) or box.shape != (4,) or not np.all(np.isfinite(center)): + continue + if recent and ts <= recent[-1][0]: + continue + width, height = box_wh(box) + if width <= 1.0 or height <= 1.0: + continue + recent.append((ts, center, np.array([width, height], dtype=np.float64))) + + if len(recent) < int(min_observations): + return None + last_ts = recent[-1][0] + first_ts = recent[0][0] + if last_ts - first_ts < float(min_span_sec): + return None + + times = np.asarray([row[0] - last_ts for row in recent], dtype=np.float64) + centers = np.asarray([row[1] for row in recent], dtype=np.float64) + sizes = np.asarray([row[2] for row in recent], dtype=np.float64) + origin, velocity, acceleration, rms, keep = _fit_motion( + times, centers, max_speed, max_accel + ) + + horizon = float(np.clip( + max(0.0, float(now_ts) - last_ts), + 0.0, + float(max_horizon_sec), + )) + predicted_center = ( + origin + + velocity * horizon + + 0.5 * acceleration * horizon * horizon + ) + + size_design = np.column_stack((np.ones_like(times), times)) + size_keep = keep if np.count_nonzero(keep) >= 3 else np.ones(len(times), dtype=bool) + log_sizes = np.log(np.maximum(sizes, 2.0)) + size_coefficients, *_ = np.linalg.lstsq( + size_design[size_keep], + log_sizes[size_keep], + rcond=None, + ) + size_rate = np.clip( + size_coefficients[1], + -float(max_size_rate), + float(max_size_rate), + ) + predicted_size = np.exp(size_coefficients[0] + size_rate * horizon) + last_size = sizes[-1] + predicted_size = np.clip(predicted_size, 0.65 * last_size, 1.80 * last_size) + + speed = float(np.linalg.norm(velocity)) + accel = float(np.linalg.norm(acceleration)) + uncertainty = float(np.clip( + 6.0 + rms + 0.08 * speed * horizon + 0.12 * accel * horizon * horizon, + 6.0, + float(max_uncertainty), + )) + confidence = float(np.clip( + np.exp(-rms / max(4.0, float(np.linalg.norm(last_size)))) + * (1.0 - 0.55 * horizon / max(float(max_horizon_sec), 1e-3)), + 0.0, + 1.0, + )) + + cx, cy = predicted_center + width, height = predicted_size + box = clip_box( + [cx - 0.5 * width, cy - 0.5 * height, + cx + 0.5 * width, cy + 0.5 * height], + frame_w, + frame_h, + ) + return { + "box": box, + "center": box_center(box), + "velocity": velocity.astype(np.float32), + "acceleration": acceleration.astype(np.float32), + "horizon": horizon, + "uncertainty": uncertainty, + "confidence": confidence, + "fit_rms": rms, + } diff --git a/bytetrack_min_aggressive.py b/bytetrack_min_aggressive.py index 2b230de..d518f87 100644 --- a/bytetrack_min_aggressive.py +++ b/bytetrack_min_aggressive.py @@ -50,73 +50,18 @@ def iou_matrix(tracks_tlbr, dets_tlbr): def hungarian(cost): - cost = cost.copy() n, m = cost.shape - size = max(n, m) - - pad = np.zeros((size, size), dtype=np.float32) - pad[:n, :m] = cost - big = float(cost.max() + 1.0) if cost.size else 1.0 - if n < size: - pad[n:, :] = big - if m < size: - pad[:, m:] = big - cost = pad - - size = cost.shape[0] - u = np.zeros(size, dtype=np.float32) - v = np.zeros(size, dtype=np.float32) - p = np.zeros(size, dtype=np.int32) - way = np.zeros(size, dtype=np.int32) - - for i in range(1, size): - p[0] = i - j0 = 0 - minv = np.full(size, np.inf, dtype=np.float32) - used = np.zeros(size, dtype=bool) - way.fill(0) - - while True: - used[j0] = True - i0 = p[j0] - delta = np.inf - j1 = 0 - for j in range(1, size): - if not used[j]: - cur = cost[i0, j] - u[i0] - v[j] - if cur < minv[j]: - minv[j] = cur - way[j] = j0 - if minv[j] < delta: - delta = minv[j] - j1 = j - for j in range(size): - if used[j]: - u[p[j]] += delta - v[j] -= delta - else: - minv[j] -= delta - j0 = j1 - if p[j0] == 0: - break - - while True: - j1 = way[j0] - p[j0] = p[j1] - j0 = j1 - if j0 == 0: - break - - assignment = -np.ones(size, dtype=np.int32) - for j in range(1, size): - if p[j] != 0: - assignment[p[j]] = j - - row_to_col = assignment[:n] + pairs = [(float(cost[i, j]), i, j) for i in range(n) for j in range(m)] + pairs.sort(key=lambda x: x[0]) + used_rows = set() + used_cols = set() matches = [] - for row, col in enumerate(row_to_col): - if 0 <= col < m: - matches.append((row, int(col))) + for _, row, col in pairs: + if row in used_rows or col in used_cols: + continue + matches.append((row, col)) + used_rows.add(row) + used_cols.add(col) return matches diff --git a/config.py b/config.py index 598a4b9..38e40eb 100644 --- a/config.py +++ b/config.py @@ -1,10 +1,20 @@ +from pathlib import Path + import cv2 + +def _default_source(): + for path in ("data/input/source.mp4", "runtime-data/input/source.mp4", "/data/input/source.mp4"): + candidate = Path(path) + if candidate.is_file() and candidate.stat().st_size > 0: + return path + return "data/input/source.mp4" + # Configuration and tunables (extracted from original script) # ========================= # ВАЖНО: укажите путь к новой YOLO11s-P2-CBAM модели (best.pt, imgsz=1280) -MODEL_PATH = r"D:\PycharmProjects\YOLOTrain\BYTETRACK\TEST\fpv_tracker_optimized\best.pt" +MODEL_PATH = r"D:\MAI\best.pt" DEVICE = 0 USE_HALF = True @@ -17,15 +27,21 @@ USE_HALF = True # RTSP: # SOURCE = "rtsp://192.168.1.10:8554/live" # UDP: -# SOURCE = "udp://@0.0.0.0:5600" -SOURCE = r"C:\Users\Legion\Desktop\Видео для МАИ ФУЛЛ\Группа 1\23-01_10один.mp4" +SOURCE = "udp://@0.0.0.0:59004" +#SOURCE = r"D:\123.mp4" -VIDEO_REALTIME = True +#SOURCE = _default_source() + +VIDEO_REALTIME = False # Windows: -CAP_BACKEND = cv2.CAP_DSHOW # или cv2.CAP_MSMF +CAP_BACKEND = cv2.CAP_FFMPEG # или cv2.CAP_MSMF # Linux обычно: # CAP_BACKEND = cv2.CAP_V4L2 +CAMERA_WIDTH = 0 +CAMERA_HEIGHT = 0 +CAMERA_FPS = 0 +CAMERA_FOURCC = "MJPG" SHOW_OUTPUT = True WINDOW_NAME = "FPV LOCK PAL + ByteTrack" @@ -58,8 +74,9 @@ ASPECT_RANGE = (0.15, 6.0) # FSM CONFIRM_HITS = 2 MAX_MISSES = 20 -RECOVER_FULLSCAN_EVERY = 30 -RECOVER_FORCED_DET_EVERY = 2 +RECOVER_FULLSCAN_EVERY = 12 +RECOVER_FORCED_DET_EVERY = 1 +UNVERIFIED_FORCE_FULLSCAN = True # ROI sizing (in effective pixels) BASE_RADIUS = 150 @@ -116,7 +133,7 @@ KALMAN_Q_DT_BOOST_SLOPE = 16.0 KALMAN_Q_DT_BOOST_MAX = 4.0 # Appearance gate -USE_HSV_GATE = False +USE_HSV_GATE = True HSV_HIST_BINS = (16, 16) HSV_GATE_MIN_SIM = 0.06 HSV_UPDATE_EVERY = 8 @@ -125,20 +142,33 @@ HSV_UPDATE_EVERY = 8 YOLO_QUEUE_MAX = 1 # Debug / draw -DEBUG = True +DEBUG = False DRAW_ALL_BOXES = True +DRAW_RAW_YOLO_BOXES = True +RAW_YOLO_DRAW_TTL_FRAMES = 5 DRAW_LOCK_BOX = True DRAW_KALMAN = True -DRAW_KLT_POINTS = False +DRAW_KLT_POINTS = True DRAW_BT_TRACKS = True -BT_DRAW_ONLY_CONFIRMED = False +BT_DRAW_ONLY_CONFIRMED = True # Output recording SAVE_INFER_VIDEO = True -OUT_VIDEO_PATH = "out_infer.mp4" +OUT_VIDEO_PATH = "runtime-data/out/out_infer.mp4" +ARCHIVE_RECORD_MODE = "full" # full, fragments +DETECTION_CLIP_MAX_GAP_SEC = 15.0 +UI_FRAME_EXPORT_ENABLE = True +UI_FRAME_EXPORT_PATH = "runtime-data/ui/latest.jpg" +UI_FRAME_EXPORT_EVERY = 1 +UI_FRAME_EXPORT_JPEG_QUALITY = 82 +UI_FRAME_EXPORT_MAX_FPS = 50 +REALTIME_SKIP_STALE_FRAMES = True +REALTIME_MAX_SKIP_FRAMES = 8 +REALTIME_PREVIEW_SKIPPED_FRAMES = True +REALTIME_ANALYSIS_EVERY = 2 TRACK_LOG_ENABLE = True -TRACK_LOG_PATH = "track_log.csv" -TRACK_SUMMARY_PATH = "track_summary.json" +TRACK_LOG_PATH = "runtime-data/track-logs/track_log.csv" +TRACK_SUMMARY_PATH = "runtime-data/track-summaries/track_summary.json" TRACK_LOG_FLUSH_EVERY = 30 # Screen-space lock/guidance layer for simulator integration @@ -147,6 +177,18 @@ DRAW_GUIDANCE = True GUIDANCE_EXPORT_ENABLE = True GUIDANCE_EXPORT_PATH = "guidance_state.json" GUIDANCE_EXPORT_EVERY = 1 + +# Separate UDP output for camera-center aiming error. +ERROR_OUTPUT_ENABLE = True +ERROR_OUTPUT_PROTOCOL = "guidance_v1" # guidance_v1, json, csv, bin +ERROR_OUTPUT_HOST = "127.0.0.1" +ERROR_OUTPUT_PORT = 5010 +ERROR_OUTPUT_OBJECT_ID = 1 +ERROR_OUTPUT_UNITS = "px" # norm, px, deg, m +ERROR_OUTPUT_EVERY = 1 +ERROR_OUTPUT_HFOV_DEG = 90.0 +ERROR_OUTPUT_VFOV_DEG = 60.0 +ERROR_OUTPUT_RANGE_M = 0.0 GUIDANCE_LEAD_SEC = 0.12 GUIDANCE_MAX_LEAD_PX = 90.0 GUIDANCE_BOX_BIAS_Y = 0.0 @@ -164,13 +206,13 @@ GUIDANCE_OVERRIDE_MISS_GE = 2 GUIDANCE_OVERRIDE_TTL = 2 # Trajectory drawing -DRAW_TRAJ = False +DRAW_TRAJ = True TRAIL_SECONDS = 1.2 TRAIL_ALPHA = 0.25 TRAIL_DRAW_EVERY_N = 2 TRAIL_MIN_STEP_PX = 3 CLEAR_TRAJ_ON_RECOVER = True -DRAW_TRAJ_ONLY_WHEN_LOCKED = True +DRAW_TRAJ_ONLY_WHEN_LOCKED = False DRAW_KALMAN_WHEN_MISS_LE = 8 @@ -314,9 +356,11 @@ PRE_UNSHARP = 0.12 # OSD false positives rejection REJECT_OSD_ZONES = True REJECT_OSD_SMALL_AREA_MAX = 2600.0 +REJECT_OSD_EDGE_ANCHORED = True +REJECT_OSD_EDGE_MARGIN = 4 REJECT_OSD_ZONES_NORM = [ - (0.00, 0.00, 0.20, 0.80), - (0.80, 0.00, 1.00, 0.80), + (0.00, 0.00, 0.24, 0.90), + (0.76, 0.00, 1.00, 0.90), (0.00, 0.00, 1.00, 0.08), (0.35, 0.90, 0.65, 1.00), ] @@ -347,7 +391,7 @@ MOTION_ZONE_MIN_RATIO = 0.0012 MOTION_MAX_ACTIVE_ZONE_RATIO = 0.55 MOTION_ROI_MARGIN = 28 MOTION_ROI_MIN_SIZE = 80 -DRAW_MOTION_ROI = False +DRAW_MOTION_ROI = True MOTION_TURN_THR_GAIN = 2.0 MOTION_TURN_THR_MAX_BONUS = 10 MOTION_ZONE_HEAT_DECAY = 0.88 @@ -406,7 +450,7 @@ WAVELET_ROI_PRED_SCALE = 3.0 WAVELET_ROI_MIN_PEAK_RATIO = 1.5 # AutoGaze assist (optional ROI prior for recover) -AUTOGAZE_ENABLE = False +AUTOGAZE_ENABLE = True AUTOGAZE_MODEL_ID = "nvidia/AutoGaze" # can be HF id or local folder AUTOGAZE_USE_FLASH_ATTN = False # keep False unless flash-attn is installed and stable AUTOGAZE_LOCAL_FILES_ONLY = True # avoids HF network retries; set False to download/update model @@ -421,7 +465,7 @@ AUTOGAZE_TOPK = 2 AUTOGAZE_MIN_ACTIVE_CELLS = 1 AUTOGAZE_ROI_MARGIN_CELLS = 1 AUTOGAZE_ROI_MIN_SIDE = 90 -DRAW_AUTOGAZE_ROI = False +DRAW_AUTOGAZE_ROI = True MODULE_TIME_BUDGET_MS = 30.0 AUTOGAZE_COOLDOWN_FRAMES = 12 WAVELET_COOLDOWN_FRAMES = 8 @@ -632,7 +676,7 @@ HSV_GATE_MIN_SIM = 0.10 HSV_UPDATE_EVERY = 4 # Wavelet чаще цепляет текстуру земли на chase-сценах. -WAVELET_ROI_ENABLE = False +WAVELET_ROI_ENABLE = True # AutoGaze подключаем только на более явном срыве. AUTOGAZE_MISS_GE = 4 @@ -683,7 +727,7 @@ APPROACH_CLOSE_SWITCH_EXTRA_MISS = 0 APPROACH_CLOSE_SWITCH_EXTRA_HITS = 0 SWITCH_FAST_MANEUVER_EXTRA_MISS = 0 SWITCH_FAST_MANEUVER_EXTRA_HITS = 0 -SWITCH_TRAJ_GATE_ENABLE = False +SWITCH_TRAJ_GATE_ENABLE = True STALE_LOCK_BREAK_ENABLE = True STALE_LOCK_BREAK_DIST_DIAG = 0.8 STALE_LOCK_BREAK_MIN_MISS = 1 @@ -798,6 +842,14 @@ TRAJ_REANCHOR_DIST_DIAG = 4.2 TRAJ_USE_PRIMARY_FOR_HOLD = True TRAJ_HOLD_MISS_GE = 1 DRAW_TRAJ_PREDICTIONS = True +BALLISTIC_PREDICT_ENABLE = True +BALLISTIC_MIN_OBSERVATIONS = 5 +BALLISTIC_MIN_SPAN_SEC = 0.12 +BALLISTIC_MIN_CONFIDENCE = 0.35 +BALLISTIC_MAX_SPEED_PX_S = 900.0 +BALLISTIC_MAX_SIZE_RATE_S = 1.2 +BALLISTIC_MAX_UNCERTAINTY_PX = 120.0 +TRAJ_FREEZE_MEAN_ABS_MAX = 0.20 # Trajectory evidence may adopt faster than a generic far re-anchor, but still requires # repeat confirmation unless the score is strong and close to a predicted hypothesis. @@ -816,6 +868,113 @@ CLOSE_FULLSCAN_MISS_GE = 2 CLOSE_FULLSCAN_WHEN_KLT_INVALID = True CLOSE_FULLSCAN_TARGET_ABSENT_GE = 2 +# ===== Precision profile for low-confidence best.pt detections ===== +# Keep weak detections available, but require motion and temporal confirmation +# before they can become a target. +BT_LOW = 0.030 +BT_HIGH = 0.050 +BT_NEW = 0.050 +BT_MATCH_IOU = 0.10 +YOLO_CONF_EFFECTIVE = min(CONF, BT_LOW) +TRACK_SCORE_MIN_ACQUIRE = 0.050 +TRACK_SCORE_MIN_REACQUIRE = 0.045 +TRACK_SCORE_MIN_SWITCH = 0.120 +CONFIRM_HITS = 3 +ACQUIRE_CONFIRM_SCORE = 6 +PREACQ_MIN_HITS = 3 +PREACQ_MIN_SCORE = 0.045 +RECOVER_MIN_SCORE = 0.040 +RECOVER_TINY_MIN_SCORE = 0.060 +RECOVER_TARGET_MIN_HITS = 3 +WEAK_REACQ_MIN_HITS = 4 +WEAK_REACQ_MIN_SCORE = 0.060 +WEAK_REACQ_ADOPT_HITS = 6 +TARGET_SWITCH_MISS_FRAMES = 4 +TARGET_SWITCH_CONFIRM_HITS = 8 +UNVERIFIED_TARGET_SWITCH_CONFIRM_HITS = 3 +TARGET_SWITCH_IOU_FLOOR = 0.10 +FAST_HANDOFF_ENABLE = False +FAST_HANDOFF_CONFIRM_HITS = 8 +MOTION_CONF_SWITCH_MIN_HITS = 4 +MOTION_CONF_SWITCH_MIN_SCORE = 0.12 +MOTION_CONF_SWITCH_CONFIRM_HITS = 8 +MOTION_CONF_SWITCH_IOU_FLOOR = 0.10 +MOTION_CONF_SWITCH_REQUIRE_RESIDUAL = True +YOLO_REANCHOR_MIN_SCORE = 0.055 +YOLO_REANCHOR_HITS = 3 +YOLO_REANCHOR_TRACK_HITS = 2 +TRAJ_REANCHOR_MIN_SCORE = 0.055 +USE_HSV_GATE = True +DRAW_RAW_YOLO_BOXES = False + +# Precision gate for the one-class "drone" model. +DRONE_CANDIDATE_GATE_ENABLE = True +DRONE_CANDIDATE_MIN_SCORE = 0.040 +DRONE_CANDIDATE_STRONG_SCORE = 0.120 +DRONE_CANDIDATE_MIN_MOTION_PIXELS = 5 +DRONE_CANDIDATE_MIN_MOTION_RATIO = 0.020 +DRONE_CANDIDATE_MAX_FRAME_AREA_RATIO = 0.20 +DRONE_CANDIDATE_NEAR_IOU = 0.02 +DRONE_CANDIDATE_NEAR_FACTOR = 1.5 +DRONE_CANDIDATE_NEAR_MIN = 20.0 + +# Red target box starts on an early verified detection and follows KLT through +# short detector gaps. +DRONE_RED_MIN_SCORE = 0.050 +# The outer FSM already requires three consistent candidates. Do not add +# another ByteTrack-ID delay: tiny drones often receive a new ID. +DRONE_RED_MIN_TRACK_HITS = 1 +DRONE_RED_MIN_MOTION_PIXELS = 5 +DRONE_RED_MIN_MOTION_RATIO = 0.020 +DRONE_RED_HOLD_MAX_MISS = 15 + +# Once a drone is verified, keep the physical target even when ByteTrack +# assigns new IDs. Distant detections cannot steal the lock until the current +# trajectory has genuinely been lost. +HARD_TARGET_LATCH_ENABLE = True +HARD_TARGET_LATCH_RELEASE_MISSES = 12 +HARD_TARGET_LATCH_DIST_DIAG = 2.5 +HARD_TARGET_LATCH_DIST_MIN = 48.0 +HARD_TARGET_LATCH_IOU_FLOOR = 0.01 +HARD_TARGET_LATCH_MAX_AREA_RATIO = 12.0 +HARD_TARGET_LATCH_MAX_ASPECT_RATIO = 8.0 + +# Physical target evidence: coherent KLT group after camera-motion removal. +PHYSICS_GATE_ENABLE = True +PHYSICS_MAX_POINTS = 64 +PHYSICS_MIN_POINTS = 4 +PHYSICS_MIN_COHERENT_POINTS = 3 +PHYSICS_QUALITY_LEVEL = 0.01 +PHYSICS_MIN_POINT_DISTANCE = 3 +PHYSICS_MAX_LK_ERROR = 32.0 +PHYSICS_BOX_PAD_RATIO = 0.30 +PHYSICS_BOX_PAD_MIN = 5 +PHYSICS_COHERENT_MIN_PX = 1.5 +PHYSICS_COHERENT_RESIDUAL_FACTOR = 0.45 +PHYSICS_MIN_COHERENCE = 0.55 +PHYSICS_MIN_RESIDUAL_PX = 0.75 +PHYSICS_FULL_RESIDUAL_PX = 4.0 +PHYSICS_MIN_RAW_MOTION_PX = 0.45 +PHYSICS_SCREEN_STATIC_SCALE_EPS = 0.008 +PHYSICS_GROWTH_START = 1.0 +PHYSICS_GROWTH_FULL = 1.08 +PHYSICS_MIN_GROWTH_RATIO = 1.01 +PHYSICS_FULL_SUPPORT_POINTS = 12 +PHYSICS_FULL_SPREAD_RATIO = 0.65 +PHYSICS_NEAR_AREA_RATIO = 0.04 +PHYSICS_FAR_MAX_SPEED_NORM_S = 0.65 +PHYSICS_NEAR_MAX_SPEED_NORM_S = 0.20 +PHYSICS_DISTANT_AREA_RATIO = 0.012 +PHYSICS_EDGE_MARGIN_RATIO = 0.012 +PHYSICS_EDGE_MARGIN_MIN = 4 +PHYSICS_INVALID_BYPASS_SCORE = 0.45 +PHYSICS_VALID_SCORE_BOOST = 0.08 +PHYSICS_SELECTION_BONUS = 0.45 +PHYSICS_BAD_LOCK_MAX = 4 +PHYSICS_VALID_CONFIRM_HITS = 3 +PHYSICS_UNKNOWN_CONFIRM_HITS = 6 + from runtime_env import apply_config_env_overrides apply_config_env_overrides(globals()) +YOLO_CONF_EFFECTIVE = min(CONF, BT_LOW) diff --git a/config_intercept.py b/config_intercept.py index 67fab53..4e3ab99 100644 --- a/config_intercept.py +++ b/config_intercept.py @@ -163,7 +163,7 @@ IMM_R_POS = 16.0 IMM_R_SIZE = 25.0 # Заменить стандартный Kalman8D на IMM (если True, main.py использует IMM) -IMM_REPLACE_KALMAN = False # включить после тестирования +IMM_REPLACE_KALMAN = True # включить после тестирования # ───────────────────────────────────────────────────────────── # 6. DRAW / DEBUG для новых модулей diff --git a/configurable_udp_capture.py b/configurable_udp_capture.py new file mode 100644 index 0000000..caa6d8e --- /dev/null +++ b/configurable_udp_capture.py @@ -0,0 +1,415 @@ +import socket + +import cv2 + +from delimited_frame_capture import decode_frame_data, raw_frame_size +from udp_dump_capture import UdpDumpCapture + + +DEFAULT_PACKET_SCHEMA = { + "assembly": "fragmented", + "payload_format": "frame", + "header_size": 8, + "byte_order": "little", + "flags_offset": 1, + "flags_size": 1, + "start_mask": 0x02, + "end_mask": 0x01, + "sequence_offset": 2, + "sequence_size": 1, + "packet_number_offset": 3, + "packet_number_size": 1, + "value_offset": 4, + "value_size": 4, + "value_mode": "total_then_offset", + "read_fields": [], +} + +ASSEMBLIES = {"fragmented", "datagram", "stream"} +PAYLOAD_FORMATS = {"frame", "mik"} +BYTE_ORDERS = {"little", "big"} +VALUE_MODES = {"total_then_offset", "total_size", "offset", "unused"} +PACKET_FIELD_ROLES = {"skip", "field", "flags", "sequence", "packet_number", "value"} +DEFAULT_PACKET_LAYOUT = [ + {"role": "skip", "size": 1, "label": "Version"}, + {"role": "flags", "size": 1, "label": "Flags"}, + {"role": "sequence", "size": 1, "label": "Sequence"}, + {"role": "packet_number", "size": 1, "label": "Packet"}, + {"role": "value", "size": 4, "label": "Value"}, +] +MAX_ARRAY_SIZE = 256 * 1024 * 1024 + + +def _bounded_int(value, default, minimum, maximum): + try: + number = int(str(value).strip(), 0) + except (TypeError, ValueError): + number = int(default) + return max(minimum, min(maximum, number)) + + +def normalize_packet_schema(value=None): + schema = DEFAULT_PACKET_SCHEMA.copy() + if isinstance(value, dict): + schema.update({key: item for key, item in value.items() if key in schema}) + + schema["assembly"] = str(schema["assembly"]).lower() + if schema["assembly"] not in ASSEMBLIES: + schema["assembly"] = DEFAULT_PACKET_SCHEMA["assembly"] + schema["payload_format"] = str(schema["payload_format"]).lower() + if schema["payload_format"] not in PAYLOAD_FORMATS: + schema["payload_format"] = DEFAULT_PACKET_SCHEMA["payload_format"] + schema["byte_order"] = str(schema["byte_order"]).lower() + if schema["byte_order"] not in BYTE_ORDERS: + schema["byte_order"] = DEFAULT_PACKET_SCHEMA["byte_order"] + schema["value_mode"] = str(schema["value_mode"]).lower() + if schema["value_mode"] not in VALUE_MODES: + schema["value_mode"] = DEFAULT_PACKET_SCHEMA["value_mode"] + + schema["header_size"] = _bounded_int(schema["header_size"], 8, 0, 1024) + for name in ("flags_offset", "sequence_offset", "packet_number_offset", "value_offset"): + schema[name] = _bounded_int(schema[name], -1, -1, 1023) + for name in ("flags_size", "sequence_size", "packet_number_size", "value_size"): + schema[name] = _bounded_int(schema[name], 1, 1, 8) + for name in ("start_mask", "end_mask"): + maximum = (1 << (8 * schema["flags_size"])) - 1 + schema[name] = _bounded_int(schema[name], DEFAULT_PACKET_SCHEMA[name], 0, maximum) + + fields = ( + ("flags_offset", schema["flags_size"]), + ("sequence_offset", schema["sequence_size"]), + ("packet_number_offset", schema["packet_number_size"]), + ("value_offset", schema["value_size"]), + ) + for offset_name, size in fields: + offset = schema[offset_name] + if offset >= 0 and offset + size > schema["header_size"]: + schema[offset_name] = -1 + read_fields = [] + for index, field in enumerate(schema.get("read_fields") if isinstance(schema.get("read_fields"), list) else []): + if not isinstance(field, dict): + continue + offset = _bounded_int(field.get("offset"), -1, -1, 1023) + size = _bounded_int(field.get("size"), 1, 1, 8) + if offset < 0 or offset + size > schema["header_size"]: + continue + name = str(field.get("name") or f"field_{index}").strip()[:64] or f"field_{index}" + read_fields.append({"name": name, "offset": offset, "size": size}) + schema["read_fields"] = read_fields[:64] + return schema + + +def normalize_packet_layout(value=None): + source = value if isinstance(value, list) else DEFAULT_PACKET_LAYOUT + layout = [] + remaining = 1024 + for item in source[:128]: + if not isinstance(item, dict) or remaining <= 0: + continue + role = str(item.get("role") or "skip").strip().lower() + if role not in PACKET_FIELD_ROLES: + role = "skip" + maximum = min(remaining, 8 if role != "skip" else 1024) + size = _bounded_int(item.get("size"), 1, 1, maximum) + label = str(item.get("label") or "").strip()[:64] + layout.append({"role": role, "size": size, "label": label}) + remaining -= size + return layout + + +def packet_schema_from_layout(layout, base=None): + fields = normalize_packet_layout(layout) + schema = normalize_packet_schema(base) + for name in ("flags_offset", "sequence_offset", "packet_number_offset", "value_offset"): + schema[name] = -1 + schema["read_fields"] = [] + + role_fields = { + "flags": ("flags_offset", "flags_size"), + "sequence": ("sequence_offset", "sequence_size"), + "packet_number": ("packet_number_offset", "packet_number_size"), + "value": ("value_offset", "value_size"), + } + used = set() + offset = 0 + for field in fields: + role = field["role"] + if role in role_fields and role not in used: + offset_name, size_name = role_fields[role] + schema[offset_name] = offset + schema[size_name] = field["size"] + used.add(role) + if role == "field": + schema["read_fields"].append({ + "name": field["label"] or f"field_{offset}", + "offset": offset, + "size": field["size"], + }) + offset += field["size"] + schema["header_size"] = offset + return normalize_packet_schema(schema) + + +def packet_layout_from_schema(value=None): + schema = normalize_packet_schema(value) + fields = [] + for role, offset_name, size_name in ( + ("flags", "flags_offset", "flags_size"), + ("sequence", "sequence_offset", "sequence_size"), + ("packet_number", "packet_number_offset", "packet_number_size"), + ("value", "value_offset", "value_size"), + ): + offset = schema[offset_name] + if offset >= 0: + label = { + "flags": "Flags", + "sequence": "Sequence", + "packet_number": "Packet", + "value": "Value", + }[role] + fields.append((offset, schema[size_name], role, label)) + for field in schema["read_fields"]: + fields.append((field["offset"], field["size"], "field", field["name"])) + fields.sort() + + layout = [] + cursor = 0 + for offset, size, role, label in fields: + if offset < cursor: + continue + if offset > cursor: + layout.append({"role": "skip", "size": offset - cursor, "label": ""}) + layout.append({"role": role, "size": size, "label": label}) + cursor = offset + size + if cursor < schema["header_size"]: + layout.append({"role": "skip", "size": schema["header_size"] - cursor, "label": ""}) + return normalize_packet_layout(layout) + + +class ConfigurablePacketAssembler: + def __init__(self, schema=None): + self.schema = normalize_packet_schema(schema) + self.current = None + self.expected_packet = None + self.expected_total = None + self.dropped_arrays = 0 + self.last_fields = {} + + def _field(self, payload, offset_name, size_name=None): + offset = self.schema[offset_name] + if offset < 0: + return None + size = self.schema[size_name] if size_name else 1 + if offset + size > len(payload): + raise ValueError(f"packet too short for {offset_name}") + return int.from_bytes(payload[offset:offset + size], self.schema["byte_order"]) + + def _drop(self): + if self.current is not None: + self.dropped_arrays += 1 + self.current = None + self.expected_packet = None + self.expected_total = None + + def _finish(self): + result = bytes(self.current["data"]) + self.current = None + self.expected_packet = None + self.expected_total = None + return result + + def push(self, payload): + schema = self.schema + if len(payload) < schema["header_size"]: + self._drop() + raise ValueError("UDP payload is shorter than configured header") + self.last_fields = { + field["name"]: int.from_bytes( + payload[field["offset"]:field["offset"] + field["size"]], + schema["byte_order"], + ) + for field in schema["read_fields"] + } + packet_data = payload[schema["header_size"]:] + if schema["assembly"] == "datagram": + return packet_data + if schema["assembly"] != "fragmented": + return None + + flags = self._field(payload, "flags_offset", "flags_size") or 0 + sequence = self._field(payload, "sequence_offset", "sequence_size") + packet_number = self._field(payload, "packet_number_offset", "packet_number_size") + value = self._field(payload, "value_offset", "value_size") + is_start = bool(flags & schema["start_mask"]) if schema["start_mask"] else self.current is None + is_end = bool(flags & schema["end_mask"]) if schema["end_mask"] else False + + if is_start: + if self.current is not None: + self._drop() + total = value if schema["value_mode"] in {"total_then_offset", "total_size"} else None + if total is not None and (total <= 0 or total > MAX_ARRAY_SIZE): + return None + if len(packet_data) > MAX_ARRAY_SIZE or (total is not None and len(packet_data) > total): + return None + self.current = {"sequence": sequence, "data": bytearray(packet_data)} + self.expected_total = total + if packet_number is not None: + modulo = 1 << (8 * schema["packet_number_size"]) + self.expected_packet = (packet_number + 1) % modulo + elif self.current is None: + return None + else: + if sequence is not None and self.current["sequence"] is not None and sequence != self.current["sequence"]: + self._drop() + return None + if packet_number is not None and self.expected_packet is not None and packet_number != self.expected_packet: + self._drop() + return None + if value is not None: + if schema["value_mode"] in {"total_then_offset", "offset"} and value != len(self.current["data"]): + self._drop() + return None + if schema["value_mode"] == "total_size" and value != self.expected_total: + self._drop() + return None + self.current["data"].extend(packet_data) + if packet_number is not None: + modulo = 1 << (8 * schema["packet_number_size"]) + self.expected_packet = (packet_number + 1) % modulo + + size = len(self.current["data"]) + if size > MAX_ARRAY_SIZE or (self.expected_total is not None and size > self.expected_total): + self._drop() + return None + if is_end: + if self.expected_total is not None and size != self.expected_total: + self._drop() + return None + return self._finish() + if not schema["end_mask"] and self.expected_total is not None and size == self.expected_total: + return self._finish() + return None + + +class ConfigurableUdpCapture(UdpDumpCapture): + """VideoCapture-compatible live UDP reader driven by a UI packet schema.""" + + MAX_FRAME_SIZE = 64 * 1024 * 1024 + + def __init__( + self, + host="0.0.0.0", + port=59005, + fps=30.0, + width=1280, + height=720, + encoding="auto", + separator=0, + schema=None, + ): + self.path = None + self.host = str(host) + self.port = int(port) + self.encoding = str(encoding or "auto").lower() + self.separator = bytes((int(separator) & 0xFF,)) + self.schema = normalize_packet_schema(schema) + self._init_decoder(fps, width, height) + self._file = None + self._next_frame = None + self._assembler = ConfigurablePacketAssembler(self.schema) + self._socket = None + self._stream_buffer = bytearray() + self._frames = [] + self.last_packet_fields = {} + try: + self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._socket.bind((self.host, self.port)) + self.port = int(self._socket.getsockname()[1]) + self._socket.settimeout(0.5) + except OSError as exc: + self.last_error = str(exc) + self.release() + + def isOpened(self): + return self._socket is not None + + def _stream_frames(self, payload): + self.last_packet_fields = { + field["name"]: int.from_bytes( + payload[field["offset"]:field["offset"] + field["size"]], + self.schema["byte_order"], + ) + for field in self.schema["read_fields"] + } + data = payload[self.schema["header_size"]:] + expected = raw_frame_size(self.encoding, self.width, self.height) + if data == self.separator: + if self._stream_buffer and not expected: + self._frames.append(bytes(self._stream_buffer)) + self._stream_buffer.clear() + elif self._stream_buffer and expected: + self._stream_buffer.clear() + self.dropped_arrays += 1 + return + self._stream_buffer.extend(data) + if expected: + while len(self._stream_buffer) >= expected: + self._frames.append(bytes(self._stream_buffer[:expected])) + del self._stream_buffer[:expected] + if len(self._stream_buffer) > self.MAX_FRAME_SIZE: + self._stream_buffer.clear() + self.dropped_arrays += 1 + + def _decode(self, data): + if self.schema["payload_format"] == "mik": + return self._decode_array(data) + frame = decode_frame_data(data, self.encoding, self.width, self.height) + self.height, self.width = frame.shape[:2] + return frame + + def read(self): + while self._socket is not None: + if not self._frames: + try: + payload, _address = self._socket.recvfrom(65535) + except socket.timeout: + continue + except OSError as exc: + self.last_error = str(exc) + return False, None + try: + if self.schema["assembly"] == "stream": + if len(payload) < self.schema["header_size"]: + raise ValueError("UDP payload is shorter than configured header") + self._stream_frames(payload) + else: + dropped_before = self._assembler.dropped_arrays + data = self._assembler.push(payload) + self.last_packet_fields = self._assembler.last_fields.copy() + self.dropped_arrays += self._assembler.dropped_arrays - dropped_before + if data is not None: + self._frames.append(data) + except ValueError as exc: + self.last_error = str(exc) + dropped_before = self._assembler.dropped_arrays + self._assembler._drop() + dropped = self._assembler.dropped_arrays - dropped_before + self.dropped_arrays += max(1, dropped) + continue + + data = self._frames.pop(0) + try: + frame = self._decode(data) + except ValueError as exc: + self.last_error = str(exc) + self.dropped_arrays += 1 + continue + self.frames_read += 1 + return True, frame + return False, None + + def release(self): + sock, self._socket = getattr(self, "_socket", None), None + if sock is not None: + sock.close() diff --git a/delimited_frame_capture.py b/delimited_frame_capture.py new file mode 100644 index 0000000..fe63964 --- /dev/null +++ b/delimited_frame_capture.py @@ -0,0 +1,199 @@ +import socket +from pathlib import Path + +import cv2 +import numpy as np + + +RAW_FRAME_CHANNELS = { + "bgr24": 3, + "rgb24": 3, + "gray8": 1, + "gray16": 2, + "yuyv422": 2, +} + + +def raw_frame_size(encoding, width, height): + channels = RAW_FRAME_CHANNELS.get(str(encoding or "").lower()) + return max(1, int(width)) * max(1, int(height)) * channels if channels else 0 + + +def decode_frame_data(data, encoding, width, height): + encoding = str(encoding or "auto").lower() + width = max(1, int(width)) + height = max(1, int(height)) + raw = np.frombuffer(data, dtype=np.uint8) + if encoding == "auto": + frame = cv2.imdecode(raw, cv2.IMREAD_COLOR) + if frame is None: + raise ValueError("frame is not JPEG/PNG") + return frame + + shapes = { + "bgr24": (height, width, 3), + "rgb24": (height, width, 3), + "gray8": (height, width), + "gray16": (height, width), + "yuyv422": (height, width, 2), + } + if encoding not in shapes: + raise ValueError(f"unsupported frame encoding: {encoding}") + dtype = np.dtype("> 8).astype(np.uint8) + return cv2.cvtColor(pixels, cv2.COLOR_GRAY2BGR) + + +class DelimitedFrameCapture: + """Reads encoded or raw frames separated by one byte.""" + + MAX_FRAME_SIZE = 64 * 1024 * 1024 + + def __init__( + self, + source=None, + host="0.0.0.0", + port=59005, + separator=0, + encoding="auto", + width=1280, + height=720, + fps=30.0, + ): + self.source = Path(source) if source is not None else None + self.host = str(host) + self.port = int(port) + self.separator = bytes((int(separator) & 0xFF,)) + self.encoding = str(encoding or "auto").lower() + self.width = max(1, int(width)) + self.height = max(1, int(height)) + self.fps = max(1.0, float(fps)) + self.frames_read = 0 + self.dropped_frames = 0 + self.last_error = "" + self._buffer = bytearray() + self._frames = [] + self._file = None + self._socket = None + try: + if self.source is not None: + self._file = self.source.open("rb", buffering=8 * 1024 * 1024) + else: + self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._socket.bind((self.host, self.port)) + self.port = int(self._socket.getsockname()[1]) + self._socket.settimeout(0.5) + except OSError as exc: + self.last_error = str(exc) + self.release() + + def isOpened(self): + return self._file is not None or self._socket is not None + + def _next_chunk(self): + if self._file is not None: + return self._file.read(1024 * 1024) + while self._socket is not None: + try: + return self._socket.recvfrom(65535)[0] + except socket.timeout: + continue + except OSError as exc: + self.last_error = str(exc) + return b"" + return b"" + + def _split(self, chunk): + expected = self._raw_frame_size() + if self._socket is not None and expected: + if chunk == self.separator: + if self._buffer: + self._buffer.clear() + self.dropped_frames += 1 + return + self._buffer.extend(chunk) + while len(self._buffer) >= expected: + self._frames.append(bytes(self._buffer[:expected])) + del self._buffer[:expected] + if self._buffer.startswith(self.separator): + del self._buffer[:1] + if len(self._buffer) > self.MAX_FRAME_SIZE: + self._buffer.clear() + self.dropped_frames += 1 + return + + self._buffer.extend(chunk) + parts = self._buffer.split(self.separator) + self._buffer = bytearray(parts.pop()) + self._frames.extend(part for part in parts if part) + if len(self._buffer) > self.MAX_FRAME_SIZE: + self._buffer.clear() + self.dropped_frames += 1 + + def _raw_frame_size(self): + return raw_frame_size(self.encoding, self.width, self.height) + + def _decode(self, data): + frame = decode_frame_data(data, self.encoding, self.width, self.height) + self.height, self.width = frame.shape[:2] + return frame + + def read(self): + while self.isOpened(): + if not self._frames: + chunk = self._next_chunk() + if not chunk: + if self._file is not None and self._buffer: + self._frames.append(bytes(self._buffer)) + self._buffer.clear() + else: + return False, None + else: + self._split(chunk) + continue + data = self._frames.pop(0) + try: + frame = self._decode(data) + except ValueError as exc: + self.last_error = str(exc) + self.dropped_frames += 1 + continue + self.frames_read += 1 + return True, frame + return False, None + + def get(self, prop): + if prop == cv2.CAP_PROP_FRAME_WIDTH: + return float(self.width) + if prop == cv2.CAP_PROP_FRAME_HEIGHT: + return float(self.height) + if prop == cv2.CAP_PROP_FPS: + return self.fps + if prop == cv2.CAP_PROP_POS_FRAMES: + return float(self.frames_read) + if prop == cv2.CAP_PROP_POS_MSEC: + return 1000.0 * self.frames_read / self.fps + return 0.0 + + def set(self, _prop, _value): + return False + + def release(self): + if self._file is not None: + self._file.close() + self._file = None + sock, self._socket = self._socket, None + if sock is not None: + sock.close() diff --git a/docker-compose.usb.yml b/docker-compose.usb.yml new file mode 100644 index 0000000..86c73e1 --- /dev/null +++ b/docker-compose.usb.yml @@ -0,0 +1,11 @@ +services: + fpv-tracker: + environment: + FPV_SOURCE: ${FPV_SOURCE:-0} + FPV_CAP_BACKEND: ${FPV_CAP_BACKEND:-v4l2} + devices: + - ${FPV_CAMERA_DEVICE:-/dev/video0}:/dev/video0 + fpv-ui: + environment: + FPV_SOURCE: ${FPV_SOURCE:-0} + FPV_CAP_BACKEND: ${FPV_CAP_BACKEND:-v4l2} diff --git a/docker-compose.yml b/docker-compose.yml index 46d28e1..7e041ac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,34 +1,101 @@ +name: mai-fpv + +x-fpv-env: &fpv-env + FPV_MODEL_PATH: /app/best.pt + FPV_SOURCE: ${FPV_SOURCE:-/data/input/source.mp4} + FPV_SOURCE_MODE: ${FPV_SOURCE_MODE:-file} + FPV_VIDEO_REALTIME: ${FPV_VIDEO_REALTIME:-1} + FPV_SHOW_OUTPUT: ${FPV_SHOW_OUTPUT:-0} + FPV_SAVE_INFER_VIDEO: ${FPV_SAVE_INFER_VIDEO:-1} + FPV_OUT_VIDEO_PATH: ${FPV_OUT_VIDEO_PATH:-/data/out/out_infer.mp4} + FPV_ARCHIVE_RECORD_MODE: ${FPV_ARCHIVE_RECORD_MODE:-fragments} + FPV_DETECTION_CLIP_MAX_GAP_SEC: ${FPV_DETECTION_CLIP_MAX_GAP_SEC:-15} + FPV_UI_FRAME_EXPORT_ENABLE: ${FPV_UI_FRAME_EXPORT_ENABLE:-1} + FPV_UI_FRAME_EXPORT_PATH: ${FPV_UI_FRAME_EXPORT_PATH:-/dev/shm/fpv-latest.jpg} + FPV_UI_FRAME_EXPORT_EVERY: ${FPV_UI_FRAME_EXPORT_EVERY:-1} + FPV_UI_FRAME_EXPORT_JPEG_QUALITY: ${FPV_UI_FRAME_EXPORT_JPEG_QUALITY:-82} + FPV_UI_FRAME_EXPORT_MAX_FPS: ${FPV_UI_FRAME_EXPORT_MAX_FPS:-50} + FPV_REALTIME_SKIP_STALE_FRAMES: ${FPV_REALTIME_SKIP_STALE_FRAMES:-1} + FPV_REALTIME_MAX_SKIP_FRAMES: ${FPV_REALTIME_MAX_SKIP_FRAMES:-8} + FPV_REALTIME_PREVIEW_SKIPPED_FRAMES: ${FPV_REALTIME_PREVIEW_SKIPPED_FRAMES:-1} + FPV_REALTIME_ANALYSIS_EVERY: ${FPV_REALTIME_ANALYSIS_EVERY:-2} + FPV_GUIDANCE_EXPORT_ENABLE: ${FPV_GUIDANCE_EXPORT_ENABLE:-1} + FPV_GUIDANCE_EXPORT_PATH: ${FPV_GUIDANCE_EXPORT_PATH:-/data/guidance/guidance_state.json} + FPV_ERROR_OUTPUT_ENABLE: ${FPV_ERROR_OUTPUT_ENABLE:-1} + FPV_ERROR_OUTPUT_PROTOCOL: ${FPV_ERROR_OUTPUT_PROTOCOL:-guidance_v1} + FPV_ERROR_OUTPUT_HOST: ${FPV_ERROR_OUTPUT_HOST:-host.docker.internal} + FPV_ERROR_OUTPUT_PORT: ${FPV_ERROR_OUTPUT_PORT:-5010} + FPV_ERROR_OUTPUT_OBJECT_ID: ${FPV_ERROR_OUTPUT_OBJECT_ID:-1} + FPV_ERROR_OUTPUT_UNITS: ${FPV_ERROR_OUTPUT_UNITS:-px} + FPV_ERROR_OUTPUT_EVERY: ${FPV_ERROR_OUTPUT_EVERY:-1} + FPV_ERROR_OUTPUT_HFOV_DEG: ${FPV_ERROR_OUTPUT_HFOV_DEG:-90} + FPV_ERROR_OUTPUT_VFOV_DEG: ${FPV_ERROR_OUTPUT_VFOV_DEG:-60} + FPV_ERROR_OUTPUT_RANGE_M: ${FPV_ERROR_OUTPUT_RANGE_M:-1} + FPV_AUTOPILOT_ENABLE: ${FPV_AUTOPILOT_ENABLE:-1} + FPV_AUTOPILOT_BACKEND: ${FPV_AUTOPILOT_BACKEND:-json} + FPV_AUTOPILOT_JSON_PATH: ${FPV_AUTOPILOT_JSON_PATH:-/data/autopilot/autopilot_cmd.json} + FPV_PROTO_UDP_ENABLE: ${FPV_PROTO_UDP_ENABLE:-1} + FPV_PROTO_UDP_HOST: ${FPV_PROTO_UDP_HOST:-192.168.1.10} + FPV_PROTO_UDP_PORT: ${FPV_PROTO_UDP_PORT:-5005} + services: fpv-tracker: + profiles: ["standalone"] build: context: . dockerfile: Dockerfile args: - PYTORCH_INDEX_URL: https://download.pytorch.org/whl/cu128 - image: fpv-tracker:cu128-offline + PYTORCH_INDEX_URL: ${PYTORCH_INDEX_URL:-https://download.pytorch.org/whl/cu128} + image: ${FPV_IMAGE:-fpv-tracker:cu128-offline} + init: true + gpus: all + restart: "no" + environment: *fpv-env + volumes: + - type: bind + source: . + target: /app + - type: bind + source: ${FPV_DATA_DIR:-./runtime-data} + target: /data + - type: bind + source: ${FPV_INPUT_FILE:-./runtime-data/input/source.mp4} + target: /data/input/source.mp4 + read_only: true + - type: bind + source: ${FPV_MODEL_FILE:-./best.pt} + target: /app/best.pt + read_only: true + command: > + sh -c "python3 main.py 2>&1 | tee /data/logs/main.log" + + fpv-ui: + image: ${FPV_IMAGE:-fpv-tracker:cu128-offline} + init: true gpus: all restart: unless-stopped - stdin_open: true - tty: true + command: ["python3", "ui_server.py"] environment: - FPV_MODEL_PATH: /app/best.pt - FPV_SOURCE: "0" - FPV_SHOW_OUTPUT: "0" - FPV_SAVE_INFER_VIDEO: "1" - FPV_OUT_VIDEO_PATH: /data/out/out_infer.mp4 - FPV_GUIDANCE_EXPORT_ENABLE: "1" - FPV_GUIDANCE_EXPORT_PATH: /data/guidance/guidance_state.json - FPV_AUTOPILOT_ENABLE: "1" - FPV_AUTOPILOT_BACKEND: "json" - FPV_AUTOPILOT_JSON_PATH: /data/autopilot/autopilot_cmd.json - FPV_PROTO_UDP_ENABLE: "0" - FPV_PROTO_UDP_HOST: "192.168.1.10" - FPV_PROTO_UDP_PORT: "5005" + <<: *fpv-env + FPV_DATA_DIR: /data + FPV_UI_HOST: ${FPV_UI_HOST:-0.0.0.0} + FPV_UI_PORT: 8080 + FPV_UI_LOG_PATH: /data/logs/main.log + FPV_UI_FRAME_PATH: ${FPV_UI_FRAME_PATH:-/dev/shm/fpv-latest.jpg} + FPV_UI_GUIDANCE_PATH: /data/guidance/guidance_state.json + FPV_UI_OUT_DIR: /data/out + FPV_UI_INPUT_DIR: /data/input + FPV_CAMERA_BRIDGE_URL: ${FPV_CAMERA_BRIDGE_URL:-http://host.docker.internal:8091/stream.mjpg} volumes: - - ./runtime-data:/data + - type: bind + source: . + target: /app + - type: bind + source: ${FPV_DATA_DIR:-./runtime-data} + target: /data + - type: bind + source: ${FPV_MODEL_FILE:-./best.pt} + target: /app/best.pt + read_only: true ports: - - "5600:5600/udp" - - # Для Linux-камеры можно раскомментировать: - # devices: - # - /dev/video0:/dev/video0 + - "${FPV_UI_BIND:-127.0.0.1}:${FPV_UI_PORT:-8080}:8080/tcp" diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 8525d6a..4afd822 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -mkdir -p /data/out /data/guidance /data/autopilot +mkdir -p /data/input /data/out /data/guidance /data/autopilot /data/logs /data/ui if [[ -n "${FPV_OUT_VIDEO_PATH:-}" ]]; then mkdir -p "$(dirname "${FPV_OUT_VIDEO_PATH}")" diff --git a/docs/ARCHITECTURE_RU.md b/docs/ARCHITECTURE_RU.md index aa90b7b..615ea73 100644 --- a/docs/ARCHITECTURE_RU.md +++ b/docs/ARCHITECTURE_RU.md @@ -203,8 +203,8 @@ FSM фаз: Выход: -- `track_log_*.csv` -- `track_summary_*.json` +- `runtime-data/track-logs/track_log_*.csv` +- `runtime-data/track-summaries/track_summary_*.json` ## 11. Что не относится к основному runtime diff --git a/docs/CONFIG_REFERENCE_RU.md b/docs/CONFIG_REFERENCE_RU.md index 9408143..52bf1af 100644 --- a/docs/CONFIG_REFERENCE_RU.md +++ b/docs/CONFIG_REFERENCE_RU.md @@ -143,6 +143,11 @@ - `FPV_OUT_VIDEO_PATH` - `FPV_GUIDANCE_EXPORT_ENABLE` - `FPV_GUIDANCE_EXPORT_PATH` +- `FPV_SOURCE_MODE` +- `FPV_UDP_INPUT_HOST` +- `FPV_UDP_INPUT_PORT` +- `FPV_FRAME_SEPARATOR_BYTE` +- `FPV_FRAME_ENCODING` ### Переопределения `config_intercept.py` diff --git a/docs/RUNBOOK_RU.md b/docs/RUNBOOK_RU.md index 3a8cc1f..5341a05 100644 --- a/docs/RUNBOOK_RU.md +++ b/docs/RUNBOOK_RU.md @@ -318,8 +318,8 @@ devices: ## 11. Какие файлы появляются после запуска - `out_infer_*.mp4` — видео с отрисовкой -- `track_log_*.csv` — покадровый лог логики выбора цели -- `track_summary_*.json` — итоговая сводка +- `runtime-data/track-logs/track_log_*.csv` — покадровый лог логики выбора цели +- `runtime-data/track-summaries/track_summary_*.json` — итоговая сводка - `guidance_state.json` — состояние guidance - `autopilot_cmd.json` — внешний файл команд для backend `json` diff --git a/docs/TROUBLESHOOTING_RU.md b/docs/TROUBLESHOOTING_RU.md index b27aec4..dfd9eb6 100644 --- a/docs/TROUBLESHOOTING_RU.md +++ b/docs/TROUBLESHOOTING_RU.md @@ -74,7 +74,7 @@ print(torch.cuda.is_available()) Проверки: 1. Что в overlay пишется в строках `miss`, `KLT valid`, `G HOLD` или `G REACQ`. -2. Есть ли свежий `track_log_*.csv`. +2. Есть ли свежий `runtime-data/track-logs/track_log_*.csv`. 3. Не слишком ли жесткие условия handoff/switch. ## 7. Не создается `guidance_state.json` @@ -167,6 +167,6 @@ Permission denied: guidance_state.json.tmp 1. какой источник видео использовался; 2. локальный запуск или Docker; 3. backend команд; -4. последние `track_log_*.csv` и `track_summary_*.json`; +4. последние `runtime-data/track-logs/track_log_*.csv` и `runtime-data/track-summaries/track_summary_*.json`; 5. скриншот overlay; 6. точный текст ошибки. diff --git a/error_output.py b/error_output.py new file mode 100644 index 0000000..d362308 --- /dev/null +++ b/error_output.py @@ -0,0 +1,236 @@ +import json +import math +import socket +import struct +import time + +from config import * +from helpers import clamp + + +UNIT_CODES = {"norm": 0, "px": 1, "deg": 2, "m": 3} +GUIDANCE_V1_REQUEST = 1 +GUIDANCE_V1_RESPONSE = 2 +GUIDANCE_V1_STRUCT = struct.Struct(" 0.0 + x_m = math.tan(x_rad) * distance_m if meter_valid else 0.0 + y_m = math.tan(y_rad) * distance_m if meter_valid else 0.0 + det_count = max(0, int(state.get("det_count") or 0)) + active = bool(state.get("active", False)) + if not active: + target_state = 0 + elif det_count > 1: + target_state = 2 + elif det_count == 1: + target_state = 1 + else: + target_state = 3 + + box_w = max(0.0, float(state.get("box_w") or 0.0)) + box_h = max(0.0, float(state.get("box_h") or 0.0)) + box_area_percent = int(clamp(round(100.0 * box_w * box_h / (frame_w * frame_h)), 0, 100)) + + units = str(units or "px").lower() + if units == "norm": + x, y, valid = x_norm, y_norm, True + elif units == "deg": + x, y, valid = x_deg, y_deg, True + elif units == "m": + x, y, valid = x_m, y_m, meter_valid + else: + units = "px" + x, y, valid = x_px, y_px, True + + return { + "type": "fpv_error", + "timestamp": time.time() if timestamp is None else float(timestamp), + "frame_id": int(state.get("frame_id") or 0), + "active": active, + "status": str(state.get("status") or "SEARCH"), + "target_id": state.get("target_id"), + "object_id": int(clamp(int(object_id), 1, 255)), + "target_state": target_state, + "det_count": det_count, + "box_area_percent": box_area_percent, + "confidence": float(state.get("confidence") or 0.0), + "unit": units, + "valid": bool(valid), + "x": float(x), + "y": float(y), + "mag": float(math.hypot(float(x), float(y))), + "x_norm": float(x_norm), + "y_norm": float(y_norm), + "x_px": float(x_px), + "y_px": float(y_px), + "x_deg": float(x_deg), + "y_deg": float(y_deg), + "x_m": float(x_m), + "y_m": float(y_m), + "range_m": distance_m if meter_valid else None, + "frame_w": int(frame_w), + "frame_h": int(frame_h), + } + + +def encode_error_payload(payload, protocol): + protocol = str(protocol or "json").lower() + if protocol == "guidance_v1": + if int(payload["target_state"]) == 0: + vertical_px = horizontal_px = vertical_percent = horizontal_percent = box_percent = 0 + else: + vertical_px = int(clamp(round(-float(payload["y_px"])), -32768, 32767)) + horizontal_px = int(clamp(round(float(payload["x_px"])), -32768, 32767)) + vertical_percent = int(clamp(round(-100.0 * float(payload["y_norm"])), -100, 100)) + horizontal_percent = int(clamp(round(100.0 * float(payload["x_norm"])), -100, 100)) + box_percent = int(clamp(int(payload["box_area_percent"]), 0, 100)) + return GUIDANCE_V1_STRUCT.pack( + GUIDANCE_V1_REQUEST, + int(payload["object_id"]), + int(payload["target_state"]), + vertical_px, + horizontal_px, + vertical_percent, + horizontal_percent, + box_percent, + ) + if protocol == "csv": + values = [ + payload["frame_id"], + f"{payload['timestamp']:.6f}", + int(payload["active"]), + int(payload["valid"]), + payload["unit"], + f"{payload['x']:.6f}", + f"{payload['y']:.6f}", + f"{payload['mag']:.6f}", + f"{payload['confidence']:.6f}", + payload["status"], + "" if payload["target_id"] is None else payload["target_id"], + ] + return (",".join(map(str, values)) + "\n").encode("ascii", errors="replace") + if protocol == "bin": + target_id = int(payload["target_id"] or 0) + return struct.pack( + "<4sIdBBffffi", + b"FPVE", + int(payload["frame_id"]), + float(payload["timestamp"]), + 1 if payload["active"] else 0, + UNIT_CODES.get(payload["unit"], 1), + float(payload["x"]), + float(payload["y"]), + float(payload["mag"]), + float(payload["confidence"]), + target_id, + ) + return json.dumps(payload, ensure_ascii=True, separators=(",", ":")).encode("utf-8") + + +def decode_guidance_v1_response(data): + if len(data) != 2 or data[0] != GUIDANCE_V1_RESPONSE or data[1] not in (0, 1, 2): + return None + return {"descriptor": data[0], "response": data[1]} + + +class ErrorOutputSender: + def __init__(self): + self.enabled = bool(ERROR_OUTPUT_ENABLE) + self.protocol = str(ERROR_OUTPUT_PROTOCOL).lower().strip() + self.units = str(ERROR_OUTPUT_UNITS).lower().strip() + self.host = str(ERROR_OUTPUT_HOST) + self.port = int(ERROR_OUTPUT_PORT) + self.every = max(1, int(ERROR_OUTPUT_EVERY)) + self.hfov_deg = float(ERROR_OUTPUT_HFOV_DEG) + self.vfov_deg = float(ERROR_OUTPUT_VFOV_DEG) + self.range_m = float(ERROR_OUTPUT_RANGE_M) + self.object_id = int(clamp(int(ERROR_OUTPUT_OBJECT_ID), 1, 255)) + self._sock = None + self._last_response = None + + def start(self): + if not self.enabled: + return + try: + self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self._sock.setblocking(False) + except OSError as exc: + self._sock = None + print(f"[error-output] socket failed: {exc}", flush=True) + + def close(self): + if self._sock is not None: + try: + self._sock.close() + except OSError: + pass + self._sock = None + + def status_line(self): + if not self.enabled: + return "Error output disabled" + object_text = f" object={self.object_id}" if self.protocol == "guidance_v1" else "" + return f"Error output UDP {self.protocol}: {self.host}:{self.port} units={self.units}{object_text}" + + def _poll_response(self): + if self.protocol != "guidance_v1" or self._sock is None: + return + while True: + try: + data, _address = self._sock.recvfrom(64) + except BlockingIOError: + return + except OSError: + return + response = decode_guidance_v1_response(data) + if response is not None and response["response"] != self._last_response: + self._last_response = response["response"] + print(f"[error-output] guidance_v1 response={self._last_response}", flush=True) + + def send(self, state): + if (not self.enabled) or self._sock is None or (not state.get("active", False)): + return None + frame_id = int(state.get("frame_id") or 0) + if frame_id % self.every != 0: + return None + payload = build_error_payload( + state, + units=self.units, + hfov_deg=self.hfov_deg, + vfov_deg=self.vfov_deg, + range_m=self.range_m, + object_id=self.object_id, + ) + data = encode_error_payload(payload, self.protocol) + try: + self._sock.sendto(data, (self.host, self.port)) + except OSError: + pass + self._poll_response() + return payload diff --git a/ffmpeg_capture.py b/ffmpeg_capture.py new file mode 100644 index 0000000..32f4d74 --- /dev/null +++ b/ffmpeg_capture.py @@ -0,0 +1,166 @@ +import json +import shutil +import subprocess + +import cv2 +import numpy as np + + +def _parse_rate(value): + try: + numerator, denominator = str(value).split("/", 1) + denominator = float(denominator) + return float(numerator) / denominator if denominator else 0.0 + except (TypeError, ValueError): + return 0.0 + + +def _parse_int(value): + try: + return int(value) + except (TypeError, ValueError): + return 0 + + +def _parse_float(value): + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + + +class FFmpegCapture: + """Sequential file reader using FFmpeg's more tolerant decoder.""" + + def __init__(self, source): + self.source = str(source) + self.width = 0 + self.height = 0 + self.fps = 0.0 + self.frame_count = 0 + self.frames_read = 0 + self.process = None + + if not shutil.which("ffmpeg") or not shutil.which("ffprobe"): + return + try: + probe = subprocess.run( + [ + "ffprobe", + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=width,height,avg_frame_rate,nb_frames,duration", + "-of", + "json", + self.source, + ], + check=True, + capture_output=True, + text=True, + timeout=10, + ) + streams = json.loads(probe.stdout).get("streams") or [] + if not streams: + raise ValueError("no video stream") + stream = streams[0] + self.width = _parse_int(stream.get("width")) + self.height = _parse_int(stream.get("height")) + if self.width <= 0 or self.height <= 0: + raise ValueError("invalid video dimensions") + self.fps = _parse_rate(stream.get("avg_frame_rate")) + self.frame_count = _parse_int(stream.get("nb_frames")) + if not self.frame_count and self.fps > 0.0: + self.frame_count = int(round(_parse_float(stream.get("duration")) * self.fps)) + self._start() + except (KeyError, ValueError, OSError, subprocess.SubprocessError, json.JSONDecodeError): + self.release() + + def _start(self): + self.process = subprocess.Popen( + [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "fatal", + "-err_detect", + "ignore_err", + "-i", + self.source, + "-map", + "0:v:0", + "-an", + "-sn", + "-dn", + "-vsync", + "0", + "-pix_fmt", + "bgr24", + "-f", + "rawvideo", + "pipe:1", + ], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + bufsize=max(1024 * 1024, self.width * self.height * 3), + ) + + def isOpened(self): + return ( + self.process is not None + and self.process.stdout is not None + and not self.process.stdout.closed + ) + + def read(self): + if not self.isOpened() or self.process.stdout is None: + return False, None + expected = self.width * self.height * 3 + data = bytearray() + while len(data) < expected: + chunk = self.process.stdout.read(expected - len(data)) + if not chunk: + return False, None + data.extend(chunk) + self.frames_read += 1 + frame = np.frombuffer(data, dtype=np.uint8).reshape(self.height, self.width, 3) + return True, frame + + def get(self, prop): + if prop == cv2.CAP_PROP_FRAME_WIDTH: + return float(self.width) + if prop == cv2.CAP_PROP_FRAME_HEIGHT: + return float(self.height) + if prop == cv2.CAP_PROP_FPS: + return float(self.fps) + if prop == cv2.CAP_PROP_FRAME_COUNT: + return float(self.frame_count) + if prop == cv2.CAP_PROP_POS_FRAMES: + return float(self.frames_read) + if prop == cv2.CAP_PROP_POS_MSEC and self.fps > 0.0: + return 1000.0 * self.frames_read / self.fps + return 0.0 + + def set(self, prop, value): + if prop == cv2.CAP_PROP_POS_FRAMES and int(value) == 0: + self.release() + self.frames_read = 0 + self._start() + return self.isOpened() + return prop == cv2.CAP_PROP_BUFFERSIZE + + def release(self): + process, self.process = self.process, None + if process is None: + return + if process.stdout is not None: + process.stdout.close() + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=1) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=1) diff --git a/guidance.py b/guidance.py index fcd28a9..1611365 100644 --- a/guidance.py +++ b/guidance.py @@ -222,14 +222,14 @@ class ScreenGuidanceController: return state def draw_overlay(self, frame_bgr, state, sx, sy): - if (not self.enabled) or (not DRAW_GUIDANCE): + if (not self.enabled) or (not DRAW_GUIDANCE) or (not state.get("active", False)): return frame_h, frame_w = frame_bgr.shape[:2] cx = int(0.5 * frame_w) cy = int(0.5 * frame_h) - color = (0, 220, 255) if state.get("active", False) else (120, 120, 120) + color = (0, 220, 255) cv2.drawMarker(frame_bgr, (cx, cy), color, markerType=cv2.MARKER_CROSS, markerSize=18, thickness=1) aim_x = state.get("aim_x", None) diff --git a/guidance_state.json b/guidance_state.json new file mode 100644 index 0000000..811a3d4 --- /dev/null +++ b/guidance_state.json @@ -0,0 +1,22 @@ +{ + "frame_id": 3540, + "active": false, + "status": "SEARCH", + "target_id": null, + "frame_w": 720, + "frame_h": 576, + "aim_x": null, + "aim_y": null, + "box_w": null, + "box_h": null, + "error_x": 0.0, + "error_y": 0.0, + "cmd_x": 0.0, + "cmd_y": 0.0, + "steer_x": 0.0, + "steer_y": 0.0, + "look_dx": 0.0, + "look_dy": 0.0, + "confidence": 0.0, + "on_target": false +} \ No newline at end of file diff --git a/hdmi_usb_bridge.py b/hdmi_usb_bridge.py new file mode 100644 index 0000000..b01eb7a --- /dev/null +++ b/hdmi_usb_bridge.py @@ -0,0 +1,127 @@ +import argparse +import json +import os +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlparse + +import cv2 + + +CAMERA_LOCK = threading.Lock() + + +def clamp_query(query, name, default, minimum, maximum): + try: + value = int(query.get(name, [default])[0]) + except (TypeError, ValueError): + value = default + return max(minimum, min(maximum, value)) + + +def open_camera(index, width, height, fps, attempts=10): + backend = cv2.CAP_DSHOW if os.name == "nt" else cv2.CAP_ANY + for _ in range(max(1, attempts)): + cap = cv2.VideoCapture(index, backend) + cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"MJPG")) + cap.set(cv2.CAP_PROP_FRAME_WIDTH, width) + cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height) + cap.set(cv2.CAP_PROP_FPS, fps) + cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) + ok, frame = cap.read() + if cap.isOpened() and ok and frame is not None: + return cap, frame + cap.release() + time.sleep(0.5) + return None, None + + +class Handler(BaseHTTPRequestHandler): + server_version = "HDMIUSBBridge/1.0" + + def do_GET(self): + parsed = urlparse(self.path) + if parsed.path == "/health": + body = json.dumps({"ok": True, "pid": os.getpid()}).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + if parsed.path != "/stream.mjpg": + self.send_error(404) + return + if not CAMERA_LOCK.acquire(blocking=False): + self.send_error(409, "camera is already in use") + return + + query = parse_qs(parsed.query) + index = clamp_query(query, "index", 0, 0, 16) + width = clamp_query(query, "width", 1920, 160, 3840) + height = clamp_query(query, "height", 1080, 120, 2160) + fps = clamp_query(query, "fps", 30, 1, 120) + quality = clamp_query(query, "quality", 85, 40, 95) + cap, frame = open_camera(index, width, height, fps) + try: + if cap is None: + self.send_error(503, f"camera {index} did not return a frame") + return + + self.send_response(200) + self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame") + self.send_header("Cache-Control", "no-store") + self.end_headers() + interval = 1.0 / fps + next_frame_at = time.perf_counter() + read_failures = 0 + while True: + now = time.perf_counter() + if now < next_frame_at: + time.sleep(next_frame_at - now) + elif now - next_frame_at > 3.0 * interval: + next_frame_at = now + next_frame_at += interval + encoded, jpeg = cv2.imencode( + ".jpg", + frame, + [cv2.IMWRITE_JPEG_QUALITY, quality], + ) + if encoded: + payload = jpeg.tobytes() + self.wfile.write(b"--frame\r\n") + self.wfile.write(b"Content-Type: image/jpeg\r\n") + self.wfile.write(f"Content-Length: {len(payload)}\r\n\r\n".encode("ascii")) + self.wfile.write(payload) + self.wfile.write(b"\r\n") + ok, next_frame = cap.read() + if ok and next_frame is not None: + frame = next_frame + read_failures = 0 + else: + read_failures += 1 + if read_failures >= 60: + break + except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError): + pass + finally: + if cap is not None: + cap.release() + CAMERA_LOCK.release() + + def log_message(self, format_text, *args): + print(f"[hdmi-bridge] {self.address_string()} {format_text % args}", flush=True) + + +def main(): + parser = argparse.ArgumentParser(description="Windows HDMI USB to MJPEG bridge") + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=8091) + args = parser.parse_args() + print(f"HDMI USB bridge listening on http://{args.host}:{args.port}", flush=True) + ThreadingHTTPServer((args.host, args.port), Handler).serve_forever() + + +if __name__ == "__main__": + main() diff --git a/helpers.py b/helpers.py index 7d41ba4..7574c81 100644 --- a/helpers.py +++ b/helpers.py @@ -1,7 +1,9 @@ import cv2 import numpy as np +import time from config import * +from ffmpeg_capture import FFmpegCapture # Helpers and utility functions # ========================= @@ -172,7 +174,48 @@ def in_osd_zone(center_x, center_y, frame_w, frame_h): return False -def filter_yolo_boxes_with_scores(result, frame_w, frame_h, offset_x=0, offset_y=0, min_conf=0.12): +def box_is_edge_osd(box, frame_w, frame_h): + if not (REJECT_OSD_ZONES and REJECT_OSD_EDGE_ANCHORED): + return False + b = clip_box(box, frame_w, frame_h) + cx, cy = box_center(b) + margin = float(max(0, REJECT_OSD_EDGE_MARGIN)) + touches_edge = bool( + b[0] <= margin + or b[1] <= margin + or b[2] >= float(frame_w) - margin + or b[3] >= float(frame_h) - margin + ) + return touches_edge and in_osd_zone(float(cx), float(cy), frame_w, frame_h) + + +def box_is_osd_candidate(box, frame_w, frame_h): + if not REJECT_OSD_ZONES: + return False + b = clip_box(box, frame_w, frame_h) + cx, cy = box_center(b) + return bool( + box_is_edge_osd(b, frame_w, frame_h) + or ( + box_area(b) <= float(REJECT_OSD_SMALL_AREA_MAX) + and in_osd_zone(float(cx), float(cy), frame_w, frame_h) + ) + ) + + +def filter_yolo_boxes_with_scores( + result, + frame_w, + frame_h, + offset_x=0, + offset_y=0, + min_conf=0.12, + input_scale=1.0, + pad_x=0.0, + pad_y=0.0, + content_w=None, + content_h=None, +): dets_out = [] if result.boxes is None or len(result.boxes) == 0: return dets_out @@ -189,6 +232,17 @@ def filter_yolo_boxes_with_scores(result, frame_w, frame_h, offset_x=0, offset_y continue x1, y1, x2, y2 = map(float, b) + inv_scale = 1.0 / max(float(input_scale), 1e-9) + x1 = (x1 - float(pad_x)) * inv_scale + y1 = (y1 - float(pad_y)) * inv_scale + x2 = (x2 - float(pad_x)) * inv_scale + y2 = (y2 - float(pad_y)) * inv_scale + if content_w is not None: + x1 = float(np.clip(x1, 0.0, float(content_w))) + x2 = float(np.clip(x2, 0.0, float(content_w))) + if content_h is not None: + y1 = float(np.clip(y1, 0.0, float(content_h))) + y2 = float(np.clip(y2, 0.0, float(content_h))) ww = max(0.0, x2 - x1) hh = max(0.0, y2 - y1) if ww <= 1.0 or hh <= 1.0: @@ -206,9 +260,11 @@ def filter_yolo_boxes_with_scores(result, frame_w, frame_h, offset_x=0, offset_y gx1, gy1, gx2, gy2 = x1 + offset_x, y1 + offset_y, x2 + offset_x, y2 + offset_y if REJECT_OSD_ZONES: - cx = 0.5 * (gx1 + gx2) - cy = 0.5 * (gy1 + gy2) - if (a <= REJECT_OSD_SMALL_AREA_MAX) and in_osd_zone(cx, cy, frame_w, frame_h): + if box_is_osd_candidate( + [gx1, gy1, gx2, gy2], + frame_w, + frame_h, + ): continue dets_out.append(np.array([gx1, gy1, gx2, gy2, score], dtype=np.float32)) @@ -530,6 +586,69 @@ def recover_det_is_valid(det, motion_mask): return True +def filter_drone_candidates(dets, motion_mask, frame_w, frame_h, reference_box=None): + if not DRONE_CANDIDATE_GATE_ENABLE: + return list(dets) + + accepted = [] + frame_area = max(1.0, float(frame_w) * float(frame_h)) + ref = None if reference_box is None else clip_box(reference_box, frame_w, frame_h) + ref_center = None if ref is None else box_center(ref) + ref_diag = 1.0 if ref is None else max(1.0, float(np.linalg.norm(box_wh(ref)))) + + for det in dets: + score = float(det[4]) + box = clip_box(det[:4], frame_w, frame_h) + if score < float(DRONE_CANDIDATE_MIN_SCORE): + continue + if box_area(box) / frame_area > float(DRONE_CANDIDATE_MAX_FRAME_AREA_RATIO): + continue + + near_locked_target = False + if ref_center is not None: + distance = float(np.linalg.norm(box_center(box) - ref_center)) + near_locked_target = ( + iou(box, ref) >= float(DRONE_CANDIDATE_NEAR_IOU) + or distance <= max( + float(DRONE_CANDIDATE_NEAR_MIN), + float(DRONE_CANDIDATE_NEAR_FACTOR) * ref_diag, + ) + ) + + if score >= float(DRONE_CANDIDATE_STRONG_SCORE) or near_locked_target: + accepted.append(det) + continue + + motion_pixels, motion_ratio = box_motion_stats(motion_mask, box) + if ( + motion_pixels >= int(DRONE_CANDIDATE_MIN_MOTION_PIXELS) + or motion_ratio >= float(DRONE_CANDIDATE_MIN_MOTION_RATIO) + ): + accepted.append(det) + + return accepted + + +def verified_drone_track_box(track, motion_mask, frame_w, frame_h): + if track is None or int(getattr(track, "time_since_update", 1)) != 0: + return None + if int(getattr(track, "hits", 0)) < int(DRONE_RED_MIN_TRACK_HITS): + return None + + score = float(getattr(track, "score", 0.0)) + box = clip_box(track.tlbr, frame_w, frame_h) + if score >= float(DRONE_RED_MIN_SCORE): + return box + + motion_pixels, motion_ratio = box_motion_stats(motion_mask, box) + if ( + motion_pixels >= int(DRONE_RED_MIN_MOTION_PIXELS) + or motion_ratio >= float(DRONE_RED_MIN_MOTION_RATIO) + ): + return box + return None + + def make_focus_roi_from_box(box, frame_w, frame_h, margin=72, min_side=90): b = clip_box(box, frame_w, frame_h) cx, cy = box_center(b) @@ -572,6 +691,56 @@ def track_residual_motion_ok(track, motion_mask, frame_w, frame_h): return (px >= int(EGO_RESIDUAL_MIN_PIXELS)) or (ratio >= float(EGO_RESIDUAL_MIN_RATIO)) +def acquisition_step_is_plausible(previous_box, current_box, affine, dt, frame_w, frame_h): + if previous_box is None or current_box is None: + return True + + previous_center = box_center(previous_box) + expected_center = previous_center + if affine is not None: + matrix = np.asarray(affine, dtype=np.float32) + if matrix.shape == (2, 3) and np.all(np.isfinite(matrix)): + expected_center = np.array( + [ + matrix[0, 0] * previous_center[0] + + matrix[0, 1] * previous_center[1] + + matrix[0, 2], + matrix[1, 0] * previous_center[0] + + matrix[1, 1] * previous_center[1] + + matrix[1, 2], + ], + dtype=np.float32, + ) + + residual = float(np.linalg.norm(box_center(current_box) - expected_center)) + frame_area = max(1.0, float(frame_w * frame_h)) + area_ratio = box_area(current_box) / frame_area + near_factor = float( + np.clip( + np.sqrt(area_ratio / max(1e-6, float(PHYSICS_NEAR_AREA_RATIO))), + 0.0, + 1.0, + ) + ) + max_speed = ( + (1.0 - near_factor) * float(PHYSICS_FAR_MAX_SPEED_NORM_S) + + near_factor * float(PHYSICS_NEAR_MAX_SPEED_NORM_S) + ) + previous_diag = float(np.linalg.norm(box_wh(previous_box))) + allowed = max( + 10.0, + max_speed * min(frame_w, frame_h) * max(float(dt), float(DT_MIN_SEC)), + 0.65 * previous_diag, + ) + + previous_area = max(1.0, box_area(previous_box)) + size_ratio = max( + box_area(current_box) / previous_area, + previous_area / max(1.0, box_area(current_box)), + ) + return residual <= allowed and size_ratio <= 3.0 + + def get_effective_frame(orig_bgr): oh, ow = orig_bgr.shape[:2] if not FORCE_EFFECTIVE_PAL: @@ -608,16 +777,62 @@ def is_stream_source(src): ) -def open_source(source, backend=cv2.CAP_DSHOW): +class ReconnectingVideoCapture: + def __init__(self, source, retries=20, retry_delay=0.2): + self.source = source + self.retries = max(1, int(retries)) + self.retry_delay = max(0.0, float(retry_delay)) + self.closed = False + self.cap = cv2.VideoCapture(source) + + def isOpened(self): + return not self.closed and self.cap is not None and self.cap.isOpened() + + def read(self): + if self.closed: + return False, None + ok, frame = self.cap.read() + if ok and frame is not None: + return True, frame + for _ in range(self.retries): + self.cap.release() + if self.retry_delay: + time.sleep(self.retry_delay) + if self.closed: + return False, None + self.cap = cv2.VideoCapture(self.source) + if not self.cap.isOpened(): + continue + ok, frame = self.cap.read() + if ok and frame is not None: + return True, frame + return False, None + + def get(self, prop): + return self.cap.get(prop) if self.cap is not None else 0.0 + + def set(self, prop, value): + return bool(self.cap is not None and self.cap.set(prop, value)) + + def release(self): + self.closed = True + if self.cap is not None: + self.cap.release() + + +def open_source(source, backend=cv2.CAP_FFMPEG): if is_int_source(source): cap = cv2.VideoCapture(int(source), backend) return cap, "camera" if isinstance(source, str): if is_stream_source(source): - cap = cv2.VideoCapture(source) + cap = ReconnectingVideoCapture(source) return cap, "stream" - cap = cv2.VideoCapture(source) + cap = FFmpegCapture(source) + if cap.isOpened(): + return cap, "file" + cap = cv2.VideoCapture(source, cv2.CAP_FFMPEG) return cap, "file" raise ValueError(f"Unsupported SOURCE type: {type(source)}") diff --git a/main.py b/main.py index 7bb552f..2b79238 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,9 @@ +import json +import os +import signal import sys +import queue +import threading from pathlib import Path import cv2 import numpy as np @@ -29,8 +34,22 @@ from motion_saliency import MotionSaliency from stationary_killer import StationaryKiller from decision_logger import TrackingDecisionLogger from guidance import ScreenGuidanceController +from error_output import ErrorOutputSender +from udp_dump_capture import LiveMikUdpCapture, UdpDumpCapture +from delimited_frame_capture import DelimitedFrameCapture +from configurable_udp_capture import ConfigurableUdpCapture +from target_physics import analyze_motion_group, match_motion_evidence +from ballistic_trajectory import predict_ballistic + + +STOP_REQUESTED = False + + +def request_stop(_signum=None, _frame=None): + global STOP_REQUESTED + STOP_REQUESTED = True from template_matching import tm_update_template, tm_search -from track_score_policy import track_passes_score_gate +from track_score_policy import initial_candidate_score, track_passes_score_gate from target_handoff import ( compute_fast_handoff_hits, evaluate_stale_lock, @@ -66,6 +85,237 @@ def build_unique_out_video_path(base_path: str) -> str: return str(candidate) +def export_ui_frame(frame_bgr, path: Path, quality: int) -> bool: + path.parent.mkdir(parents=True, exist_ok=True) + temp = path.with_name(path.name + ".tmp.jpg") + if not cv2.imwrite(str(temp), frame_bgr, [int(cv2.IMWRITE_JPEG_QUALITY), int(quality)]): + return False + temp.replace(path) + return True + + +def draw_cached_detection_overlay(frame_bgr, overlay, guidance_ctrl) -> None: + if not overlay: + return + + if DRAW_RAW_YOLO_BOXES: + for box, score in overlay.get("raw_boxes", ()): + x1, y1, x2, y2 = map(int, clip_box(box, frame_bgr.shape[1], frame_bgr.shape[0])) + cv2.rectangle(frame_bgr, (x1, y1), (x2, y2), (80, 170, 255), 1) + cv2.putText( + frame_bgr, + f"YOLO {score:.2f}", + (x1, min(frame_bgr.shape[0] - 4, y2 + 16)), + cv2.FONT_HERSHEY_SIMPLEX, + 0.5, + (80, 170, 255), + 1, + ) + + if DRAW_ALL_BOXES: + for box, score in overlay.get("accepted_boxes", ()): + x1, y1, x2, y2 = map(int, clip_box(box, frame_bgr.shape[1], frame_bgr.shape[0])) + cv2.rectangle(frame_bgr, (x1, y1), (x2, y2), (0, 255, 0), 1) + cv2.putText( + frame_bgr, + f"{score:.2f}", + (x1, max(0, y1 - 6)), + cv2.FONT_HERSHEY_SIMPLEX, + 0.5, + (0, 255, 0), + 2, + ) + + verified_box = overlay.get("verified_drone_box") + if DRAW_LOCK_BOX and verified_box is not None: + x1, y1, x2, y2 = map(int, clip_box(verified_box, frame_bgr.shape[1], frame_bgr.shape[0])) + cv2.rectangle(frame_bgr, (x1, y1), (x2, y2), (0, 0, 255), 2) + cv2.putText( + frame_bgr, + f"DRONE ID={overlay.get('target_id')}", + (x1, max(0, y1 - 10)), + cv2.FONT_HERSHEY_SIMPLEX, + 0.7, + (0, 0, 255), + 2, + ) + + cv2.putText( + frame_bgr, + overlay.get("status", "RECOVER"), + (20, 40), + cv2.FONT_HERSHEY_SIMPLEX, + 1.0, + (0, 255, 255), + 2, + ) + guidance_state = overlay.get("guidance_state") + if guidance_state is not None: + guidance_ctrl.draw_overlay( + frame_bgr, + guidance_state, + overlay.get("sx", 1.0), + overlay.get("sy", 1.0), + ) + + +class LatestFrameExporter: + def __init__(self, path, quality, max_fps): + self.path = path + self.quality = quality + self.period = 1.0 / max(1.0, float(max_fps)) + self.frames = queue.Queue(maxsize=1) + self.stop_event = threading.Event() + self.thread = threading.Thread(target=self._run, name="ui-frame-export", daemon=True) + + def start(self): + self.thread.start() + + def submit(self, frame_bgr): + try: + self.frames.put_nowait(frame_bgr.copy()) + except queue.Full: + try: + self.frames.get_nowait() + except queue.Empty: + pass + self.frames.put_nowait(frame_bgr.copy()) + + def _run(self): + next_export_at = time.perf_counter() + while not self.stop_event.is_set() or not self.frames.empty(): + try: + frame = self.frames.get(timeout=0.1) + except queue.Empty: + continue + wait_sec = next_export_at - time.perf_counter() + if wait_sec > 0.0: + self.stop_event.wait(wait_sec) + try: + while True: + frame = self.frames.get_nowait() + except queue.Empty: + pass + export_ui_frame(frame, self.path, self.quality) + next_export_at = max(next_export_at + self.period, time.perf_counter()) + + def stop(self): + self.stop_event.set() + if self.thread.is_alive(): + self.thread.join(timeout=5.0) + + +class RealtimeFramePump: + def __init__( + self, + cap, + source_kind, + input_fps, + target_fps, + overlay_getter, + guidance_ctrl, + publish_frame, + ): + self.cap = cap + self.source_kind = source_kind + self.input_fps = input_fps + self.target_fps = target_fps + self.overlay_getter = overlay_getter + self.guidance_ctrl = guidance_ctrl + self.publish_frame = publish_frame + self.frames = queue.Queue(maxsize=1) + self.stop_event = threading.Event() + self.thread = threading.Thread(target=self._run, name="realtime-capture", daemon=True) + self.finished = False + self.error = None + self.read_frames = 0 + self.dropped_analysis_frames = 0 + self.output_times = deque(maxlen=120) + + def start(self): + self.thread.start() + + def get(self, timeout=0.2): + try: + return self.frames.get(timeout=timeout) + except queue.Empty: + return None + + def output_fps(self): + if len(self.output_times) < 2: + return 0.0 + return (len(self.output_times) - 1) / max( + self.output_times[-1] - self.output_times[0], + 1e-6, + ) + + def _run(self): + started_at = None + frame_id = 0 + try: + while not self.stop_event.is_set() and not STOP_REQUESTED: + ret, frame_orig = self.cap.read() + loop_ts = time.perf_counter() + if not ret: + break + frame_ts, dt_source = get_frame_timestamp_seconds( + self.cap, + self.source_kind, + frame_id, + self.input_fps, + loop_ts, + ) + + item = (frame_orig, frame_id, frame_ts, loop_ts, dt_source) + try: + self.frames.put_nowait(item) + except queue.Full: + try: + self.frames.get_nowait() + self.dropped_analysis_frames += 1 + except queue.Empty: + pass + self.frames.put_nowait(item) + + display_frame = frame_orig.copy() + draw_cached_detection_overlay( + display_frame, + self.overlay_getter(), + self.guidance_ctrl, + ) + self.publish_frame(display_frame, frame_ts, frame_id) + self.read_frames += 1 + self.output_times.append(time.perf_counter()) + + if self.target_fps > 0.0: + if started_at is None: + started_at = loop_ts - (frame_id / self.target_fps) + deadline = started_at + ((frame_id + 1) / self.target_fps) + lag = time.perf_counter() - deadline + if lag > 0.5: + started_at = time.perf_counter() - (frame_id / self.target_fps) + deadline = started_at + ((frame_id + 1) / self.target_fps) + wait_sec = max(0.0, deadline - time.perf_counter()) + if wait_sec > 0.0: + self.stop_event.wait(wait_sec) + frame_id += 1 + except Exception as exc: + self.error = exc + finally: + self.finished = True + + def stop(self): + self.stop_event.set() + try: + self.cap.release() + except Exception as exc: + print(f"WARN: capture release failed: {exc}") + if self.thread.is_alive(): + self.thread.join(timeout=2.0) + if self.thread.is_alive(): + print("WARN: realtime capture thread did not stop") + + # ─── Motion saliency re-weighting ──────────────────────────── # Параметры тюнинга (подбираются по логам): MS_BOOST_WEIGHT = 0.6 # сила буста для движущихся детектов @@ -101,9 +351,6 @@ def reweight_dets_by_motion(dets, motion_sal): return out - - - def pick_soft_yolo_handoff_det( dets, ref_box, @@ -264,7 +511,9 @@ def _trajectory_velocity_from_history(obs_hist, fallback_vx, fallback_vy): return vx, vy, ax, ay -def build_maneuver_hypotheses(obs_hist, ref_box, kf, miss_streak, dt, ew, eh): +def build_maneuver_hypotheses( + obs_hist, ref_box, kf, miss_streak, dt, ew, eh, now_ts=None +): """Build short-horizon trajectory hypotheses for detector loss.""" if ref_box is None or kf is None or (not getattr(kf, "initialized", False)): return [] @@ -284,29 +533,82 @@ def build_maneuver_hypotheses(obs_hist, ref_box, kf, miss_streak, dt, ew, eh): bh2 = float(bh) * grow hypotheses = [] - def add(label, px, py, weight): - b = make_box_from_center_wh(px, py, bw2, bh2, ew, eh) - hypotheses.append({"label": str(label), "box": b, "center": box_center(b), "weight": float(weight)}) - - add("cv", cx + vx * horizon, cy + vy * horizon, 1.00) - if TRAJ_USE_ACCEL: - add("ca", cx + vx * horizon + 0.5 * ax * horizon * horizon, - cy + vy * horizon + 0.5 * ay * horizon * horizon, 0.95) - add("damp", cx + 0.55 * vx * horizon, cy + 0.55 * vy * horizon, 0.72) - - if speed >= float(TRAJ_MIN_SPEED_FOR_MANEUVER): - ux = vx / max(speed, 1e-6) - uy = vy / max(speed, 1e-6) - px1, py1 = -uy, ux - px2, py2 = uy, -ux - lat = min(float(TRAJ_LATERAL_ACCEL_MAX), max(float(TRAJ_LATERAL_ACCEL_MIN), speed * float(TRAJ_LATERAL_ACCEL_SPEED_GAIN))) - shift = 0.5 * lat * horizon * horizon - add("turnL", cx + vx * horizon + px1 * shift, cy + vy * horizon + py1 * shift, 0.82) - add("turnR", cx + vx * horizon + px2 * shift, cy + vy * horizon + py2 * shift, 0.82) - vert = min(float(TRAJ_VERTICAL_ACCEL_MAX), max(float(TRAJ_VERTICAL_ACCEL_MIN), speed * float(TRAJ_VERTICAL_ACCEL_SPEED_GAIN))) - vshift = 0.5 * vert * horizon * horizon - add("up", cx + vx * horizon, cy + vy * horizon - vshift, 0.65) - add("down", cx + vx * horizon, cy + vy * horizon + vshift, 0.65) + def add(label, px, py, weight, width=bw2, height=bh2, uncertainty=0.0): + b = make_box_from_center_wh(px, py, width, height, ew, eh) + hypotheses.append({ + "label": str(label), + "box": b, + "center": box_center(b), + "weight": float(weight), + "uncertainty": float(uncertainty), + }) + + ballistic = None + if BALLISTIC_PREDICT_ENABLE and now_ts is not None: + ballistic = predict_ballistic( + obs_hist, + now_ts, + ew, + eh, + lookback=TRAJ_HISTORY_LOOKBACK, + min_observations=BALLISTIC_MIN_OBSERVATIONS, + min_span_sec=BALLISTIC_MIN_SPAN_SEC, + max_horizon_sec=TRAJ_HORIZON_MAX_SEC, + max_speed=BALLISTIC_MAX_SPEED_PX_S, + max_accel=TRAJ_MAX_ACCEL_PX_S2, + max_size_rate=BALLISTIC_MAX_SIZE_RATE_S, + max_uncertainty=BALLISTIC_MAX_UNCERTAINTY_PX, + ) + if ( + ballistic is not None + and float(ballistic["confidence"]) < float(BALLISTIC_MIN_CONFIDENCE) + ): + ballistic = None + if ballistic is not None: + ballistic_box = ballistic["box"] + ballistic_center = ballistic["center"] + ballistic_w, ballistic_h = box_wh(ballistic_box) + vx, vy = map(float, ballistic["velocity"]) + ax, ay = map(float, ballistic["acceleration"]) + speed = float(np.hypot(vx, vy)) + horizon = float(ballistic["horizon"]) + cx, cy = map(float, ballistic_center) + bw2, bh2 = float(ballistic_w), float(ballistic_h) + add( + "ballistic", + cx, + cy, + 1.15 * float(ballistic["confidence"]), + bw2, + bh2, + ballistic["uncertainty"], + ) + corridor = 0.55 * float(ballistic["uncertainty"]) + if corridor >= float(TRAJ_DUP_CENTER_DIST): + add("corrL", cx - corridor, cy, 0.58, bw2, bh2, corridor) + add("corrR", cx + corridor, cy, 0.58, bw2, bh2, corridor) + add("corrU", cx, cy - corridor, 0.52, bw2, bh2, corridor) + add("corrD", cx, cy + corridor, 0.52, bw2, bh2, corridor) + else: + add("cv", cx + vx * horizon, cy + vy * horizon, 1.00) + if TRAJ_USE_ACCEL: + add("ca", cx + vx * horizon + 0.5 * ax * horizon * horizon, + cy + vy * horizon + 0.5 * ay * horizon * horizon, 0.95) + add("damp", cx + 0.55 * vx * horizon, cy + 0.55 * vy * horizon, 0.72) + + if speed >= float(TRAJ_MIN_SPEED_FOR_MANEUVER): + ux = vx / max(speed, 1e-6) + uy = vy / max(speed, 1e-6) + px1, py1 = -uy, ux + px2, py2 = uy, -ux + lat = min(float(TRAJ_LATERAL_ACCEL_MAX), max(float(TRAJ_LATERAL_ACCEL_MIN), speed * float(TRAJ_LATERAL_ACCEL_SPEED_GAIN))) + shift = 0.5 * lat * horizon * horizon + add("turnL", cx + vx * horizon + px1 * shift, cy + vy * horizon + py1 * shift, 0.82) + add("turnR", cx + vx * horizon + px2 * shift, cy + vy * horizon + py2 * shift, 0.82) + vert = min(float(TRAJ_VERTICAL_ACCEL_MAX), max(float(TRAJ_VERTICAL_ACCEL_MIN), speed * float(TRAJ_VERTICAL_ACCEL_SPEED_GAIN))) + vshift = 0.5 * vert * horizon * horizon + add("up", cx + vx * horizon, cy + vy * horizon - vshift, 0.65) + add("down", cx + vx * horizon, cy + vy * horizon + vshift, 0.65) filtered = [] for h in hypotheses: @@ -539,51 +841,120 @@ def klt_anchor_accepts_box( return True, "ok" def main(): + global STOP_REQUESTED + STOP_REQUESTED = False + signal.signal(signal.SIGTERM, request_stop) + signal.signal(signal.SIGINT, request_stop) + if hasattr(signal, "SIGBREAK"): + signal.signal(signal.SIGBREAK, request_stop) + + show_output = bool(SHOW_OUTPUT) + if show_output and os.name != "nt" and not ( + os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY") + ): + show_output = False + print("Headless runtime detected: OpenCV window disabled") + print("Loading YOLO...") model = YOLO(MODEL_PATH) - if torch.cuda.is_available(): + if torch.cuda.is_available() and int(DEVICE) >= 0: model.to(f"cuda:{DEVICE}") - if torch.cuda.is_available(): - # Warmup на обоих размерах — критично для dynamic CBAM - # чтобы MLP построились на обоих scales до реального инференса. - dummy_roi = np.zeros((IMG_SIZE_ROI, IMG_SIZE_ROI, 3), dtype=np.uint8) - dummy_full = np.zeros((IMG_SIZE_FULL, IMG_SIZE_FULL, 3), dtype=np.uint8) - with torch.inference_mode(): - for _ in range(3): - _ = model( - dummy_roi, - conf=YOLO_CONF_EFFECTIVE, - imgsz=IMG_SIZE_ROI, - verbose=False, - max_det=MAX_DET, - device=DEVICE, - half=USE_HALF - ) - for _ in range(2): - _ = model( - dummy_full, - conf=YOLO_CONF_EFFECTIVE, - imgsz=IMG_SIZE_FULL, - verbose=False, - max_det=MAX_DET, - device=DEVICE, - half=USE_HALF - ) - print(f"Model warmed up at {IMG_SIZE_ROI} and {IMG_SIZE_FULL}") - - cap, source_kind = open_source(SOURCE, CAP_BACKEND) + source_mode = os.environ.get("FPV_SOURCE_MODE", "").strip().lower() + custom_udp_dump = False + udp_input_host = os.environ.get("FPV_UDP_INPUT_HOST", "0.0.0.0").strip() or "0.0.0.0" + udp_input_port = int(os.environ.get("FPV_UDP_INPUT_PORT", "59004")) + separator_byte = int(os.environ.get("FPV_FRAME_SEPARATOR_BYTE", "0"), 0) & 0xFF + frame_encoding = os.environ.get("FPV_FRAME_ENCODING", "auto").strip().lower() or "auto" + try: + packet_schema = json.loads(os.environ.get("FPV_UDP_PACKET_SCHEMA", "{}")) + except json.JSONDecodeError as exc: + print(f"Invalid FPV_UDP_PACKET_SCHEMA, defaults used: {exc}") + packet_schema = {} + if source_mode == "udp_dump": + dump_cap = UdpDumpCapture(SOURCE, fps=CAMERA_FPS or INPUT_FPS_FALLBACK) + if dump_cap.isOpened(): + cap, source_kind = dump_cap, "file" + custom_udp_dump = True + else: + cap, source_kind = open_source(SOURCE, CAP_BACKEND) + elif source_mode == "udp_mik_live": + cap = LiveMikUdpCapture( + host=udp_input_host, + port=udp_input_port, + fps=CAMERA_FPS or INPUT_FPS_FALLBACK, + width=CAMERA_WIDTH, + height=CAMERA_HEIGHT, + ) + source_kind = "stream" + elif source_mode == "udp_custom_live": + cap = ConfigurableUdpCapture( + host=udp_input_host, + port=udp_input_port, + fps=CAMERA_FPS or INPUT_FPS_FALLBACK, + width=CAMERA_WIDTH, + height=CAMERA_HEIGHT, + encoding=frame_encoding, + separator=separator_byte, + schema=packet_schema, + ) + source_kind = "stream" + elif source_mode in {"udp_delimited_live", "udp_delimited_file"}: + cap = DelimitedFrameCapture( + source=SOURCE if source_mode == "udp_delimited_file" else None, + host=udp_input_host, + port=udp_input_port, + separator=separator_byte, + encoding=frame_encoding, + width=CAMERA_WIDTH, + height=CAMERA_HEIGHT, + fps=CAMERA_FPS or INPUT_FPS_FALLBACK, + ) + source_kind = "file" if source_mode == "udp_delimited_file" else "stream" + else: + cap, source_kind = open_source(SOURCE, CAP_BACKEND) if not cap.isOpened(): print(f"Capture open failed: {SOURCE}") return + if source_kind == "camera": + fourcc = str(CAMERA_FOURCC or "").strip() + if len(fourcc) >= 4: + cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*fourcc[:4])) + if int(CAMERA_WIDTH) > 0: + cap.set(cv2.CAP_PROP_FRAME_WIDTH, int(CAMERA_WIDTH)) + if int(CAMERA_HEIGHT) > 0: + cap.set(cv2.CAP_PROP_FRAME_HEIGHT, int(CAMERA_HEIGHT)) + if int(CAMERA_FPS) > 0: + cap.set(cv2.CAP_PROP_FPS, int(CAMERA_FPS)) + try: cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) except Exception: pass - print(f"Opened source: {SOURCE} ({source_kind})") + source_labels = { + "udp_mik_live": f"udp_mik_live://{udp_input_host}:{udp_input_port}", + "udp_delimited_live": ( + f"udp_delimited_live://{udp_input_host}:{udp_input_port}" + f"?separator={separator_byte}&encoding={frame_encoding}" + ), + "udp_custom_live": ( + f"udp_custom_live://{udp_input_host}:{udp_input_port}" + f"?assembly={packet_schema.get('assembly', 'fragmented')}" + f"&encoding={frame_encoding}" + ), + "udp_delimited_file": ( + f"udp_delimited_file?separator={separator_byte}&encoding={frame_encoding}" + ), + } + source_label = ( + "udp_dump_mik" + if custom_udp_dump + else source_labels.get(source_mode, source_kind) + ) + print(f"Opened source: {SOURCE} ({source_label})") input_fps = float(cap.get(cv2.CAP_PROP_FPS)) if (not np.isfinite(input_fps)) or (input_fps <= 1.0): @@ -601,10 +972,18 @@ def main(): else: print("Pacing disabled (show as fast as processing allows)") - if SHOW_OUTPUT: + if show_output: cv2.namedWindow(WINDOW_NAME, cv2.WINDOW_NORMAL) writer = None + out_video_path = None + active_video_marker = None + archive_mode = str(ARCHIVE_RECORD_MODE).strip().lower() + if archive_mode not in {"full", "fragments"}: + archive_mode = "full" + archive_gap = max(0.0, float(DETECTION_CLIP_MAX_GAP_SEC)) + last_archive_hit_ts = -1e9 + archive_written_frames = 0 if SAVE_INFER_VIDEO: ow = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) oh = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) @@ -613,14 +992,46 @@ def main(): fpsw = target_out_fps if target_out_fps > 0 else 30.0 fourcc = cv2.VideoWriter_fourcc(*"mp4v") out_video_path = build_unique_out_video_path(OUT_VIDEO_PATH) + active_video_marker = Path(out_video_path).parent / ".active_video" + active_video_marker.unlink(missing_ok=True) writer = cv2.VideoWriter(out_video_path, fourcc, fpsw, (ow, oh)) if not writer.isOpened(): print(f"WARN: video writer open failed: {out_video_path}") writer = None else: + active_video_marker.write_text(Path(out_video_path).name, encoding="utf-8") print(f"Saving inference video to: {out_video_path}") + print(f"Archive mode: {archive_mode}, detection gap: {archive_gap:.1f}s") + + ui_frame_path = Path(UI_FRAME_EXPORT_PATH) + ui_frame_every = max(1, int(UI_FRAME_EXPORT_EVERY)) + ui_jpeg_quality = int(np.clip(UI_FRAME_EXPORT_JPEG_QUALITY, 1, 100)) + ui_exporter = None + if UI_FRAME_EXPORT_ENABLE: + ui_exporter = LatestFrameExporter( + ui_frame_path, + ui_jpeg_quality, + UI_FRAME_EXPORT_MAX_FPS, + ) + ui_exporter.start() - yolo_worker = YOLOWorker(model) + def publish_frame(frame_bgr, timestamp_sec, current_frame_id): + nonlocal archive_written_frames + if writer is not None and ( + archive_mode == "full" or (float(timestamp_sec) - last_archive_hit_ts) <= archive_gap + ): + writer.write(frame_bgr) + archive_written_frames += 1 + + if ui_exporter is not None and current_frame_id % ui_frame_every == 0: + ui_exporter.submit(frame_bgr) + + full_shape = ( + (int(EFFECTIVE_H), int(EFFECTIVE_W)) + if FORCE_EFFECTIVE_PAL + else (int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)), int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))) + ) + yolo_worker = YOLOWorker(model, full_frame_shape=full_shape) yolo_worker.start() autogaze_worker = AutoGazeROIWorker() autogaze_worker.start() @@ -634,6 +1045,9 @@ def main(): print(track_logger.status_line()) guidance_ctrl = ScreenGuidanceController() print(guidance_ctrl.status_line()) + error_output = ErrorOutputSender() + error_output.start() + print(error_output.status_line()) kf = SafeKalman8D() klt = HybridTracker() @@ -671,6 +1085,14 @@ def main(): perf_hist = deque(maxlen=120) yolo_hist = deque(maxlen=120) + analysis_frames = 0 + last_perf_log_ts = -1e9 + latest_detection_overlay = None + verified_drone_latched = False + realtime_analysis_every = max(1, int(REALTIME_ANALYSIS_EVERY)) + stream_fps = target_out_fps or input_fps or float(CAMERA_FPS) or float(INPUT_FPS_FALLBACK) + analysis_period = realtime_analysis_every / max(1.0, stream_fps) + next_analysis_at = 0.0 traj = deque(maxlen=max(10, int(TRAIL_SECONDS * (input_fps if input_fps > 0 else 60.0)))) traj_frame_i = 0 @@ -678,6 +1100,7 @@ def main(): guidance_override_memory_ttl = 0 prev_gray_global = None + physics_bad_lock_streak = 0 template_gray = None # --- Motion saliency --- @@ -715,17 +1138,54 @@ def main(): # Short history of accepted target centers for multi-hypothesis prediction. trajectory_obs_hist = deque(maxlen=max(6, int(TRAJ_HISTORY_LEN))) + realtime_pump = None + if VIDEO_REALTIME: + realtime_pump = RealtimeFramePump( + cap=cap, + source_kind=source_kind, + input_fps=input_fps, + target_fps=target_out_fps, + overlay_getter=lambda: latest_detection_overlay, + guidance_ctrl=guidance_ctrl, + publish_frame=publish_frame, + ) + realtime_pump.start() + print("Realtime capture active: latest-frame analysis queue") + print("Start") while True: - ret, frame_orig = cap.read() - loop_ts = time.perf_counter() - if not ret: + if STOP_REQUESTED: + print("Stop requested; finalizing outputs...") break + if realtime_pump is not None: + wait_for_analysis = next_analysis_at - time.perf_counter() + if wait_for_analysis > 0.0: + time.sleep(wait_for_analysis) + item = realtime_pump.get() + if item is None: + if realtime_pump.finished: + if realtime_pump.error is not None: + print(f"Realtime capture failed: {realtime_pump.error}") + break + continue + frame_orig, frame_id, frame_ts, loop_ts, dt_source = item + else: + ret, frame_orig = cap.read() + loop_ts = time.perf_counter() + if not ret: + break + frame_ts, dt_source = get_frame_timestamp_seconds( + cap, + source_kind, + frame_id, + input_fps, + loop_ts, + ) + iter_start = time.perf_counter() frame_eff, sx, sy = get_effective_frame(frame_orig) eh, ew = frame_eff.shape[:2] - frame_ts, dt_source = get_frame_timestamp_seconds(cap, source_kind, frame_id, input_fps, loop_ts) dt = sanitize_dt(frame_ts, prev_frame_ts, nominal_dt) prev_frame_ts = frame_ts @@ -740,6 +1200,11 @@ def main(): autogaze_rois_eff = [] autogaze_info = None gray_now = cv2.cvtColor(frame_eff, cv2.COLOR_BGR2GRAY) + frame_frozen = bool( + prev_gray_global is not None + and float(cv2.mean(cv2.absdiff(prev_gray_global, gray_now))[0]) + <= float(TRAJ_FREEZE_MEAN_ABS_MAX) + ) target_track = None pred_box_eff = None best_score = None @@ -783,8 +1248,11 @@ def main(): trajectory_hypotheses = [] trajectory_roi_eff = None trajectory_primary_box_eff = None + trajectory_uncertainty = 0.0 trajectory_det_label = "-" trajectory_reanchor_used = False + physics_prev_gray = prev_gray_global + physics_entries = [] A = None ms_outliers = np.zeros((0, 2), dtype=np.float32) if prev_gray_global is not None and USE_CAM_MOTION_COMP: @@ -839,8 +1307,6 @@ def main(): prev_gray_global = gray_now - iter_start = time.perf_counter() - pred_box_eff = None if kf.initialized: kf_q_scale = 1.0 @@ -945,13 +1411,25 @@ def main(): ): traj_ref_box = pred_box_eff if pred_box_eff is not None else locked_box_eff trajectory_hypotheses = build_maneuver_hypotheses( - trajectory_obs_hist, traj_ref_box, kf, miss_streak, dt, ew, eh + trajectory_obs_hist, + traj_ref_box, + kf, + miss_streak, + dt, + ew, + eh, + now_ts=frame_ts, ) if trajectory_hypotheses: trajectory_primary_box_eff = trajectory_hypotheses[0]["box"] + trajectory_uncertainty = max( + float(h.get("uncertainty", 0.0)) + for h in trajectory_hypotheses + ) trajectory_roi_eff = make_union_roi_from_boxes( [h["box"] for h in trajectory_hypotheses], ew, eh, - margin=TRAJ_ROI_MARGIN, min_side=TRAJ_ROI_MIN_SIDE + margin=max(float(TRAJ_ROI_MARGIN), trajectory_uncertainty), + min_side=TRAJ_ROI_MIN_SIDE, ) wavelet_roi_allowed = ( @@ -1001,6 +1479,8 @@ def main(): run_det = (frame_id - last_det_frame) >= det_every need_fullscan = (not confirmed) and (frame_id - last_fullscan_frame) >= RECOVER_FULLSCAN_EVERY + if UNVERIFIED_FORCE_FULLSCAN and not verified_drone_latched: + need_fullscan = True # Close-stage detector policy: # older builds forced FULL-CLOSE on every detector pass while ch=close. @@ -1175,20 +1655,71 @@ def main(): yolo = yolo_worker.try_get() dets_eff = [] infer_ms = 0.0 - used_roi = False + used_roi = True have_yolo = False yolo_mode = "NONE" yolo_raw_count = 0 yolo_ts = None + raw_yolo_dets_eff = [] merged_part_count = 0 if yolo is not None: have_yolo = True - dets_eff, yolo_ts, yolo_mode, infer_ms, used_roi = yolo + if len(yolo) >= 6: + dets_eff, yolo_ts, yolo_mode, infer_ms, used_roi, raw_yolo_dets_eff = yolo + else: + dets_eff, yolo_ts, yolo_mode, infer_ms, used_roi = yolo # Motion-aware re-weighting: буст движущимся детектам, # штраф статичным (горизонт, ЛЭП, деревья). Защита # от wrong target lock на статических объектах. dets_eff = reweight_dets_by_motion(dets_eff, motion_sal) yolo_raw_count = len(dets_eff) + dets_eff = filter_drone_candidates( + dets_eff, + motion_mask, + ew, + eh, + reference_box=locked_box_eff if confirmed else None, + ) + if PHYSICS_GATE_ENABLE and physics_prev_gray is not None: + physical_dets = [] + for det in dets_eff: + det_box = clip_box(det[:4], ew, eh) + evidence = analyze_motion_group( + physics_prev_gray, + gray_now, + det_box, + affine=A, + dt=dt, + ) + physics_entries.append((det_box.copy(), evidence)) + near_lock = bool( + confirmed + and locked_box_eff is not None + and ( + iou(det_box, locked_box_eff) >= float(HARD_TARGET_LATCH_IOU_FLOOR) + or np.linalg.norm(box_center(det_box) - box_center(locked_box_eff)) + <= max( + float(HARD_TARGET_LATCH_DIST_MIN), + float(HARD_TARGET_LATCH_DIST_DIAG) + * max(1.0, float(np.linalg.norm(box_wh(locked_box_eff)))), + ) + ) + ) + raw_score = float(det[4]) + if ( + evidence.reliable + and not evidence.valid + and not near_lock + ): + continue + physical_det = np.asarray(det, dtype=np.float32).copy() + if evidence.valid: + physical_det[4] = min( + 0.99, + raw_score + float(PHYSICS_VALID_SCORE_BOOST) * evidence.score, + ) + physical_dets.append(physical_det) + dets_eff = physical_dets if RECOVER_FILTER_ENABLE and (not confirmed) and (yolo_mode in ("ROI-MOTION", "ROI-WAVE", "ROI-MOTION-C", "ROI-GAZE")) and yolo_raw_count > 0: raw_dets = dets_eff filtered = [d for d in raw_dets if recover_det_is_valid(d, motion_mask)] @@ -1505,6 +2036,33 @@ def main(): eligible_tracks = [t for t in candidate_tracks if t.hits >= int(RECOVER_TARGET_MIN_HITS)] if len(eligible_tracks) == 0: eligible_tracks = candidate_tracks + if REJECT_OSD_ZONES: + eligible_tracks = [ + t for t in eligible_tracks + if ( + confirmed + and target_id is not None + and int(t.track_id) == int(target_id) + ) + or not box_is_osd_candidate(t.tlbr, ew, eh) + ] + track_physics = { + int(t.track_id): match_motion_evidence( + clip_box(t.tlbr, ew, eh), + physics_entries, + ) + for t in eligible_tracks + } + if PHYSICS_GATE_ENABLE: + eligible_tracks = [ + t for t in eligible_tracks + if not ( + (track_physics.get(int(t.track_id)) is not None) + and track_physics[int(t.track_id)].reliable + and not track_physics[int(t.track_id)].valid + and ((not confirmed) or (target_id is None) or (int(t.track_id) != int(target_id))) + ) + ] weak_reacq_guard = ( WEAK_REACQ_GUARD_ENABLE @@ -1550,6 +2108,12 @@ def main(): for t in eligible_tracks: b = clip_box(t.tlbr, ew, eh) c = box_center(b) + physics_evidence = track_physics.get(int(t.track_id)) + physics_bonus = ( + float(PHYSICS_SELECTION_BONUS) * physics_evidence.score + if physics_evidence is not None and physics_evidence.valid + else 0.0 + ) approach_bonus = 0.0 is_switch_candidate = confirmed and (target_id is not None) and (t.track_id != target_id) if not track_passes_score_gate( @@ -1562,9 +2126,12 @@ def main(): switch_floor=float(TRACK_SCORE_MIN_SWITCH), ): continue - residual_ok = False - if is_switch_candidate and EGO_RESIDUAL_GATE_ENABLE and motion_mask is not None: - residual_ok = track_residual_motion_ok(t, motion_mask, ew, eh) + candidate_motion_ok = bool( + EGO_RESIDUAL_GATE_ENABLE + and motion_mask is not None + and track_residual_motion_ok(t, motion_mask, ew, eh) + ) + residual_ok = bool(is_switch_candidate and candidate_motion_ok) dist = 0.0 i = 0.0 @@ -1733,9 +2300,16 @@ def main(): mn_ratio = float(wv_hits) / float(max(1, int(WAVELET_MN_WINDOW))) wv_bonus += 0.08 * mn_ratio - s = (2.2 * i) + (1.0 / (1.0 + dist)) + (0.25 * float(t.score)) + (0.9 * app) + wv_bonus + approach_bonus + s = (2.2 * i) + (1.0 / (1.0 + dist)) + (0.25 * float(t.score)) + (0.9 * app) + wv_bonus + approach_bonus + physics_bonus if pred_ref is None: - s = (0.35 * float(t.score)) + (0.0005 * box_area(b)) + (0.9 * app) + wv_bonus + s = initial_candidate_score( + track_score=t.score, + track_hits=t.hits, + residual_motion=candidate_motion_ok, + appearance=app, + wavelet_bonus=wv_bonus, + physics_bonus=physics_bonus, + ) if target_id is not None and t.track_id == target_id: s += TARGET_STICKY_SCORE_BONUS @@ -1801,7 +2375,11 @@ def main(): motion_switch_mode=bool(motion_switch_mode), approach_extra_hits=int(approach_switch_extra_hits) if approach_active else 0, fast_maneuver_extra_hits=int(max(0, SWITCH_FAST_MANEUVER_EXTRA_HITS)) if fast_maneuver_guard else 0, - default_switch_hits=int(TARGET_SWITCH_CONFIRM_HITS), + default_switch_hits=( + int(TARGET_SWITCH_CONFIRM_HITS) + if verified_drone_latched + else int(UNVERIFIED_TARGET_SWITCH_CONFIRM_HITS) + ), fast_handoff_hits=int(FAST_HANDOFF_CONFIRM_HITS), motion_switch_hits=int(MOTION_CONF_SWITCH_CONFIRM_HITS), ) @@ -1915,6 +2493,45 @@ def main(): yolo_reanchor_last_reason = yolo_reanchor_reason suppress_target_id_update = False + if ( + HARD_TARGET_LATCH_ENABLE + and verified_drone_latched + and confirmed + and locked_box_eff is not None + and miss_streak < int(HARD_TARGET_LATCH_RELEASE_MISSES) + ): + proposed_latch_box = None + if target_track is not None: + proposed_latch_box = clip_box(target_track.tlbr, ew, eh) + elif chosen is not None: + proposed_latch_box = clip_box(chosen, ew, eh) + + if proposed_latch_box is not None: + latch_ok, _ = klt_anchor_accepts_box( + proposed_latch_box, + locked_box_eff, + ew, + eh, + dist_diag=float(HARD_TARGET_LATCH_DIST_DIAG), + dist_min=float(HARD_TARGET_LATCH_DIST_MIN), + iou_floor=float(HARD_TARGET_LATCH_IOU_FLOOR), + max_area_ratio=float(HARD_TARGET_LATCH_MAX_AREA_RATIO), + max_aspect_ratio=float(HARD_TARGET_LATCH_MAX_ASPECT_RATIO), + ) + if not latch_ok: + target_track = None + chosen = None + soft_yolo_adopted = False + yolo_reanchor_adopted = False + switch_candidate_id = None + switch_candidate_hits = 0 + elif ( + target_track is not None + and target_id is not None + and int(target_track.track_id) != int(target_id) + ): + suppress_target_id_update = True + klt_anchor_reject_reason = "" if ( BT_KLT_ANCHOR_GUARD_ENABLE @@ -1976,6 +2593,23 @@ def main(): if not suppress_target_id_update: target_id = target_track.track_id chosen = clip_box(target_track.tlbr, ew, eh) + if ( + not confirmed + and locked_box_eff is not None + and not acquisition_step_is_plausible( + locked_box_eff, + chosen, + A, + dt, + ew, + eh, + ) + ): + chosen = None + target_track = None + target_id = None + hit_streak = 0 + acquire_score = max(0, acquire_score - int(ACQUIRE_MISS_PENALTY)) switch_candidate_id = None switch_candidate_hits = 0 @@ -2020,7 +2654,31 @@ def main(): acquire_score = int(clamp(acquire_score + bonus, 0, 999)) acquire_miss = 0 - if not confirmed and ((hit_streak >= CONFIRM_HITS) or (acquire_score >= ACQUIRE_CONFIRM_SCORE)): + temporal_confirmed = ( + hit_streak >= CONFIRM_HITS + and acquire_score >= ACQUIRE_CONFIRM_SCORE + ) + physics_confirmed = True + if PHYSICS_GATE_ENABLE and target_track is not None: + evidence = track_physics.get(int(target_track.track_id)) + track_hits = int(getattr(target_track, "hits", 0)) + if evidence is not None and evidence.reliable: + physics_confirmed = bool( + evidence.valid + and track_hits >= int(PHYSICS_VALID_CONFIRM_HITS) + ) + else: + physics_confirmed = bool( + track_hits >= int(PHYSICS_UNKNOWN_CONFIRM_HITS) + and track_residual_motion_ok( + target_track, + motion_mask, + ew, + eh, + ) + ) + + if not confirmed and temporal_confirmed and physics_confirmed: confirmed = True if locked_box_eff is not None: klt.init(frame_eff, locked_box_eff) @@ -2086,7 +2744,13 @@ def main(): guidance_reset_event = "stale_lock_break" guidance_force_neutral = True - if TURN_SAFE_ENABLE and have_yolo and dets_eff and (miss_streak >= TURN_SAFE_MISS_BEFORE_RESET): + if ( + TURN_SAFE_ENABLE + and confirmed + and have_yolo + and dets_eff + and (miss_streak >= TURN_SAFE_MISS_BEFORE_RESET) + ): best_det, best_conf = pick_best_det_with_score(dets_eff, ew, eh) if best_det is not None and float(best_conf) >= float(TURN_SAFE_MIN_SCORE): adopted_track_id = None @@ -2242,11 +2906,88 @@ def main(): preacq_hist.clear() preacq_hits = 0 + if PHYSICS_GATE_ENABLE and confirmed and locked_box_eff is not None: + lock_physics = match_motion_evidence(locked_box_eff, physics_entries) + if lock_physics is None: + lock_physics = analyze_motion_group( + physics_prev_gray, + gray_now, + locked_box_eff, + affine=A, + dt=dt, + ) + if lock_physics.reliable: + hard_violation = bool( + lock_physics.edge_violation or lock_physics.speed_violation + ) + physics_bad_lock_streak = ( + physics_bad_lock_streak + 1 if hard_violation else 0 + ) + if physics_bad_lock_streak >= int(PHYSICS_BAD_LOCK_MAX): + print( + f"[physics_gate] release tid={target_id} " + f"coherence={lock_physics.coherence:.2f} " + f"residual={lock_physics.residual_px:.2f}px " + f"scale={lock_physics.scale_ratio:.3f} " + f"speed={lock_physics.speed_norm_s:.3f} edge={int(lock_physics.edge_violation)}" + ) + confirmed = False + verified_drone_latched = False + locked_box_eff = None + ref_hist = None + template_gray = None + target_id = None + target_absent_frames = 0 + switch_candidate_id = None + switch_candidate_hits = 0 + klt.reset() + kf = SafeKalman8D() + miss_streak = 0 + hit_streak = 0 + acquire_score = 0 + acquire_miss = 0 + preacq_hist.clear() + preacq_hits = 0 + traj.clear() + physics_bad_lock_streak = 0 + else: + physics_bad_lock_streak = 0 + locked_box_orig = None if locked_box_eff is not None: locked_box_orig = unscale_box(locked_box_eff, sx, sy) locked_box_orig = clip_box(locked_box_orig, frame_orig.shape[1], frame_orig.shape[0]) + verified_drone_box_eff = None + verified_drone_box_orig = None + verified_drone_fresh = False + if confirmed: + verified_drone_box_eff = verified_drone_track_box( + target_track, + motion_mask, + ew, + eh, + ) + if verified_drone_box_eff is not None: + verified_drone_fresh = True + verified_drone_latched = True + elif ( + verified_drone_latched + and locked_box_eff is not None + and miss_streak <= int(DRONE_RED_HOLD_MAX_MISS) + ): + verified_drone_box_eff = clip_box(locked_box_eff, ew, eh) + else: + verified_drone_latched = False + if verified_drone_box_eff is not None: + verified_drone_box_orig = clip_box( + unscale_box(verified_drone_box_eff, sx, sy), + frame_orig.shape[1], + frame_orig.shape[0], + ) + else: + verified_drone_latched = False + if GUIDANCE_OVERRIDE_ENABLE: guidance_override_candidate_box_eff = pick_guidance_override_box( track_candidate_box=guidance_candidate_box_eff, @@ -2277,6 +3018,21 @@ def main(): guidance_override_memory_box_eff = None guidance_override_memory_ttl = 0 + if DRAW_RAW_YOLO_BOXES and raw_yolo_dets_eff: + for d in raw_yolo_dets_eff: + b_orig = clip_box(unscale_box(d[:4], sx, sy), frame_orig.shape[1], frame_orig.shape[0]) + x1, y1, x2, y2 = map(int, b_orig) + cv2.rectangle(frame_orig, (x1, y1), (x2, y2), (80, 170, 255), 1) + cv2.putText( + frame_orig, + f"YOLO {float(d[4]):.2f}", + (x1, min(frame_orig.shape[0] - 4, y2 + 16)), + cv2.FONT_HERSHEY_SIMPLEX, + 0.5, + (80, 170, 255), + 1, + ) + if DRAW_ALL_BOXES and dets_eff: for d in dets_eff: b_eff = d[:4] @@ -2302,11 +3058,10 @@ def main(): (x1, max(0, y1 - 8)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 0), 2) - if DRAW_LOCK_BOX and locked_box_orig is not None: - x1, y1, x2, y2 = map(int, locked_box_orig) - if miss_streak <= int(DRAW_KALMAN_WHEN_MISS_LE): - cv2.rectangle(frame_orig, (x1, y1), (x2, y2), (0, 0, 255), 2) - cv2.putText(frame_orig, f"ID={target_id}", (x1, max(0, y1 - 10)), + if DRAW_LOCK_BOX and verified_drone_box_orig is not None: + x1, y1, x2, y2 = map(int, verified_drone_box_orig) + cv2.rectangle(frame_orig, (x1, y1), (x2, y2), (0, 0, 255), 2) + cv2.putText(frame_orig, f"DRONE ID={target_id}", (x1, max(0, y1 - 10)), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2) if DRAW_KALMAN and kf.initialized: @@ -2386,7 +3141,7 @@ def main(): cv2.putText(frame_orig, str(h.get("label", "tr")), (hx1, max(0, hy1 - 4)), cv2.FONT_HERSHEY_SIMPLEX, 0.4, (180, 0, 255), 1) - status = "CONFIRMED" if confirmed else "RECOVER" + status = "CONFIRMED" if verified_drone_box_eff is not None else ("TRACKING" if confirmed else "RECOVER") cv2.putText(frame_orig, status, (20, 40), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 255, 255), 2) ag_ready = int(getattr(autogaze_worker, "ready", False)) @@ -2402,10 +3157,10 @@ def main(): frame_id=frame_id, frame_w=ew, frame_h=eh, - confirmed=bool(confirmed and (not guidance_force_neutral)), - locked_box=None if guidance_force_neutral else locked_box_eff, - pred_box=None if guidance_force_neutral else pred_box_eff, - override_box=None if guidance_force_neutral else guidance_override_box_eff, + confirmed=bool(verified_drone_box_eff is not None and (not guidance_force_neutral)), + locked_box=None if guidance_force_neutral else verified_drone_box_eff, + pred_box=None, + override_box=None, target_id=target_id, target_track_score=target_track_score, klt_valid=klt_valid, @@ -2414,6 +3169,10 @@ def main(): vx=vx_guid, vy=vy_guid, ) + guidance_state["det_count"] = 1 if guidance_state["active"] and verified_drone_fresh else 0 + error_output.send(guidance_state) + if guidance_state["active"] or yolo_raw_count > 0: + last_archive_hit_ts = float(frame_ts) if DEBUG: cv2.putText(frame_orig, f"src={source_kind} p={ACTIVE_ANTI_FP_PROFILE} eff={ew}x{eh} miss={miss_streak} hit={hit_streak} acq={acquire_score} pH={preacq_hits} tAbs={target_absent_frames} swHit={switch_candidate_hits} rHit={yolo_reanchor_memory_hits} rAd={int(yolo_reanchor_adopted)} trH={len(trajectory_hypotheses)} trA={int(trajectory_reanchor_used)} trL={trajectory_det_label} stale={int(stale_lock_active)} fastH={int(fast_handoff_active)} detEvery={det_every} yNoDet={yolo_no_det} mZones={motion_active_zones} fT={flash_ttl} wRT={wavelet_roi_ttl} wRP={wavelet_roi_peak:.2f} aR={ag_ready} aS={ag_stale} aAge={ag_age} aC={ag_cells} aK={ag_rois}", @@ -2424,6 +3183,51 @@ def main(): (20, 135), cv2.FONT_HERSHEY_SIMPLEX, 0.65, (0, 255, 255), 2) guidance_ctrl.draw_overlay(frame_orig, guidance_state, sx, sy) + if have_yolo: + raw_boxes_orig = [ + ( + clip_box( + unscale_box(d[:4], sx, sy), + frame_orig.shape[1], + frame_orig.shape[0], + ).copy(), + float(d[4]), + ) + for d in raw_yolo_dets_eff + ] + elif latest_detection_overlay is not None: + raw_boxes_orig = latest_detection_overlay.get("raw_boxes", []) + else: + raw_boxes_orig = [] + if have_yolo: + accepted_boxes_orig = [ + ( + clip_box( + unscale_box(d[:4], sx, sy), + frame_orig.shape[1], + frame_orig.shape[0], + ).copy(), + float(d[4]), + ) + for d in dets_eff + ] + elif latest_detection_overlay is not None: + accepted_boxes_orig = latest_detection_overlay.get("accepted_boxes", []) + else: + accepted_boxes_orig = [] + latest_detection_overlay = { + "raw_boxes": raw_boxes_orig, + "accepted_boxes": accepted_boxes_orig, + "verified_drone_box": ( + None if verified_drone_box_orig is None else verified_drone_box_orig.copy() + ), + "target_id": target_id, + "status": status, + "guidance_state": guidance_state.copy(), + "sx": float(sx), + "sy": float(sy), + } + track_logger.log_frame( frame_id=frame_id, timestamp_sec=frame_ts, @@ -2483,12 +3287,22 @@ def main(): if TRAJ_PREDICT_ENABLE: if confirmed and locked_box_eff is not None: + fresh_track_measurement = bool( + target_track is not None + and int(getattr(target_track, "time_since_update", 1)) == 0 + ) src_ok = bool( - (hit_streak > 0) - or klt_valid - or soft_yolo_adopted - or yolo_reanchor_adopted - or (miss_streak <= int(TRAJ_HISTORY_KEEP_MISS_LE)) + (not frame_frozen) + and ( + fresh_track_measurement + or klt_valid + or soft_yolo_adopted + or yolo_reanchor_adopted + ) + and ( + verified_drone_box_eff is not None + or klt_valid + ) ) if src_ok: trajectory_obs_hist.append({ @@ -2500,42 +3314,58 @@ def main(): else: trajectory_obs_hist.clear() - if writer is not None: - writer.write(frame_orig) - - if SHOW_OUTPUT: - cv2.imshow(WINDOW_NAME, frame_orig) - - iter_ms = (time.perf_counter() - iter_start) * 1000.0 - perf_hist.append(iter_ms) + if realtime_pump is None: + publish_frame(frame_orig, frame_ts, frame_id) - elapsed = time.perf_counter() - iter_start - wait_ms = 1 - if VIDEO_REALTIME and target_out_fps > 0.0: - target_dt = 1.0 / target_out_fps - if elapsed < target_dt: - wait_ms = int((target_dt - elapsed) * 1000.0) - wait_ms = clamp(wait_ms, 1, 50) + iter_ms = (time.perf_counter() - iter_start) * 1000.0 + perf_hist.append(iter_ms) + analysis_frames += 1 + if realtime_pump is not None: + next_analysis_at = time.perf_counter() + max(0.0, analysis_period - iter_ms / 1000.0) - key = cv2.waitKey(int(wait_ms)) & 0xFF + if show_output: + cv2.imshow(WINDOW_NAME, frame_orig) + key = cv2.waitKey(1) & 0xFF if key == 27: break - if frame_id % 60 == 0 and perf_hist: + now = time.perf_counter() + if now - last_perf_log_ts >= 2.0 and perf_hist: + last_perf_log_ts = now p50 = np.percentile(perf_hist, 50) p95 = np.percentile(perf_hist, 95) - fps = 1000.0 / max(np.mean(perf_hist), 1e-6) + if realtime_pump is not None: + fps = realtime_pump.output_fps() + skipped = realtime_pump.dropped_analysis_frames + passed = max(0, realtime_pump.read_frames - analysis_frames) + analysis_every = max( + 1, + round(realtime_pump.read_frames / max(1, analysis_frames)), + ) + else: + fps = 1000.0 / max(float(np.mean(perf_hist)), 1e-6) + skipped = 0 + passed = 0 + analysis_every = 1 if yolo_hist: yp50 = np.percentile(yolo_hist, 50) yp95 = np.percentile(yolo_hist, 95) else: yp50, yp95 = 0.0, 0.0 - print(f"[perf] fps~{fps:.1f} iter p50={p50:.1f} p95={p95:.1f} | yolo p50={yp50:.1f} p95={yp95:.1f}") + print( + f"[perf] fps~{fps:.1f} iter p50={p50:.1f} p95={p95:.1f} | " + f"yolo p50={yp50:.1f} p95={yp95:.1f} " + f"skip={skipped} pass={passed} analysisEvery={analysis_every}" + ) - frame_id += 1 + if realtime_pump is None: + frame_id += 1 + if realtime_pump is not None: + realtime_pump.stop() autogaze_worker.stop() yolo_worker.stop() + error_output.close() track_summary = track_logger.close() if track_summary is not None: print( @@ -2545,8 +3375,22 @@ def main(): ) if writer is not None: writer.release() + if archive_written_frames > 0: + print(f"Saved inference video: {out_video_path}, frames={archive_written_frames}") + else: + try: + Path(out_video_path).unlink(missing_ok=True) + except OSError as exc: + print(f"WARN: empty recording cleanup failed: {exc}") + else: + print(f"Skipped empty inference video: {out_video_path}") + if ui_exporter is not None: + ui_exporter.stop() + if active_video_marker is not None: + active_video_marker.unlink(missing_ok=True) cap.release() - cv2.destroyAllWindows() + if show_output: + cv2.destroyAllWindows() if __name__ == "__main__": diff --git a/requirements-docker.txt b/requirements-docker.txt index 6a625a3..625ca26 100644 --- a/requirements-docker.txt +++ b/requirements-docker.txt @@ -1,5 +1,11 @@ +--extra-index-url https://download.pytorch.org/whl/cu128 + numpy==1.26.4 opencv-python==4.10.0.84 +torch==2.11.0+cu128 +torchvision==0.26.0+cu128 ultralytics==8.4.75 +netron==9.2.8 pymavlink==2.4.49 pyserial==3.5 +imageio-ffmpeg==0.6.0 diff --git a/runtime_env.py b/runtime_env.py index ced9c3c..4e07f15 100644 --- a/runtime_env.py +++ b/runtime_env.py @@ -1,5 +1,7 @@ import os +import cv2 + def _parse_bool(value): if isinstance(value, bool): @@ -12,10 +14,26 @@ def _parse_int(value): return int(str(value).strip()) +def _parse_float(value): + return float(str(value).strip()) + + def _parse_str(value): return str(value) +def _parse_backend(value): + text = str(value).strip().lower() + if text.lstrip("+-").isdigit(): + return int(text) + return { + "any": cv2.CAP_ANY, + "dshow": cv2.CAP_DSHOW, + "msmf": cv2.CAP_MSMF, + "v4l2": cv2.CAP_V4L2, + }.get(text, cv2.CAP_DSHOW) + + def _parse_source(value): text = str(value).strip() if text and text.lstrip("+-").isdigit(): @@ -35,13 +53,49 @@ def apply_config_env_overrides(namespace): namespace, { "FPV_MODEL_PATH": ("MODEL_PATH", _parse_str), + "FPV_DEVICE": ("DEVICE", _parse_int), + "FPV_USE_HALF": ("USE_HALF", _parse_bool), + "FPV_CONF": ("CONF", _parse_float), + "FPV_IMG_SIZE_ROI": ("IMG_SIZE_ROI", _parse_int), + "FPV_IMG_SIZE_FULL": ("IMG_SIZE_FULL", _parse_int), + "FPV_MAX_DET": ("MAX_DET", _parse_int), "FPV_SOURCE": ("SOURCE", _parse_source), + "FPV_CAP_BACKEND": ("CAP_BACKEND", _parse_backend), + "FPV_CAMERA_WIDTH": ("CAMERA_WIDTH", _parse_int), + "FPV_CAMERA_HEIGHT": ("CAMERA_HEIGHT", _parse_int), + "FPV_CAMERA_FPS": ("CAMERA_FPS", _parse_int), + "FPV_CAMERA_FOURCC": ("CAMERA_FOURCC", _parse_str), "FPV_VIDEO_REALTIME": ("VIDEO_REALTIME", _parse_bool), "FPV_SHOW_OUTPUT": ("SHOW_OUTPUT", _parse_bool), "FPV_SAVE_INFER_VIDEO": ("SAVE_INFER_VIDEO", _parse_bool), "FPV_OUT_VIDEO_PATH": ("OUT_VIDEO_PATH", _parse_str), + "FPV_ARCHIVE_RECORD_MODE": ("ARCHIVE_RECORD_MODE", _parse_str), + "FPV_DETECTION_CLIP_MAX_GAP_SEC": ("DETECTION_CLIP_MAX_GAP_SEC", _parse_float), + "FPV_TARGET_OUT_FPS": ("TARGET_OUT_FPS", _parse_int), + "FPV_FORCE_EFFECTIVE_PAL": ("FORCE_EFFECTIVE_PAL", _parse_bool), + "FPV_EFFECTIVE_W": ("EFFECTIVE_W", _parse_int), + "FPV_EFFECTIVE_H": ("EFFECTIVE_H", _parse_int), + "FPV_UI_FRAME_EXPORT_ENABLE": ("UI_FRAME_EXPORT_ENABLE", _parse_bool), + "FPV_UI_FRAME_EXPORT_PATH": ("UI_FRAME_EXPORT_PATH", _parse_str), + "FPV_UI_FRAME_EXPORT_EVERY": ("UI_FRAME_EXPORT_EVERY", _parse_int), + "FPV_UI_FRAME_EXPORT_JPEG_QUALITY": ("UI_FRAME_EXPORT_JPEG_QUALITY", _parse_int), + "FPV_UI_FRAME_EXPORT_MAX_FPS": ("UI_FRAME_EXPORT_MAX_FPS", _parse_float), + "FPV_REALTIME_SKIP_STALE_FRAMES": ("REALTIME_SKIP_STALE_FRAMES", _parse_bool), + "FPV_REALTIME_MAX_SKIP_FRAMES": ("REALTIME_MAX_SKIP_FRAMES", _parse_int), + "FPV_REALTIME_PREVIEW_SKIPPED_FRAMES": ("REALTIME_PREVIEW_SKIPPED_FRAMES", _parse_bool), + "FPV_REALTIME_ANALYSIS_EVERY": ("REALTIME_ANALYSIS_EVERY", _parse_int), "FPV_GUIDANCE_EXPORT_ENABLE": ("GUIDANCE_EXPORT_ENABLE", _parse_bool), "FPV_GUIDANCE_EXPORT_PATH": ("GUIDANCE_EXPORT_PATH", _parse_str), + "FPV_ERROR_OUTPUT_ENABLE": ("ERROR_OUTPUT_ENABLE", _parse_bool), + "FPV_ERROR_OUTPUT_PROTOCOL": ("ERROR_OUTPUT_PROTOCOL", _parse_str), + "FPV_ERROR_OUTPUT_HOST": ("ERROR_OUTPUT_HOST", _parse_str), + "FPV_ERROR_OUTPUT_PORT": ("ERROR_OUTPUT_PORT", _parse_int), + "FPV_ERROR_OUTPUT_OBJECT_ID": ("ERROR_OUTPUT_OBJECT_ID", _parse_int), + "FPV_ERROR_OUTPUT_UNITS": ("ERROR_OUTPUT_UNITS", _parse_str), + "FPV_ERROR_OUTPUT_EVERY": ("ERROR_OUTPUT_EVERY", _parse_int), + "FPV_ERROR_OUTPUT_HFOV_DEG": ("ERROR_OUTPUT_HFOV_DEG", _parse_float), + "FPV_ERROR_OUTPUT_VFOV_DEG": ("ERROR_OUTPUT_VFOV_DEG", _parse_float), + "FPV_ERROR_OUTPUT_RANGE_M": ("ERROR_OUTPUT_RANGE_M", _parse_float), }, ) diff --git a/start-hdmi-bridge.ps1 b/start-hdmi-bridge.ps1 new file mode 100644 index 0000000..b28049b --- /dev/null +++ b/start-hdmi-bridge.ps1 @@ -0,0 +1,28 @@ +$ErrorActionPreference = "Stop" +$Root = Split-Path -Parent $MyInvocation.MyCommand.Path +$Python = Join-Path $Root ".venv\Scripts\python.exe" +$PidFile = Join-Path $Root "runtime-data\hdmi_bridge.pid" +$LogDir = Join-Path $Root "runtime-data\logs" +$Stdout = Join-Path $LogDir "hdmi_bridge.log" +$Stderr = Join-Path $LogDir "hdmi_bridge.err.log" + +New-Item -ItemType Directory -Force -Path $LogDir | Out-Null +if (Test-Path -LiteralPath $PidFile) { + $RunningPid = [int](Get-Content -LiteralPath $PidFile -Raw) + if (Get-Process -Id $RunningPid -ErrorAction SilentlyContinue) { + Write-Output "HDMI USB bridge already running: PID $RunningPid" + exit 0 + } +} + +$Process = Start-Process ` + -FilePath $Python ` + -ArgumentList @("-u", (Join-Path $Root "hdmi_usb_bridge.py"), "--port", "8091") ` + -WorkingDirectory $Root ` + -WindowStyle Hidden ` + -RedirectStandardOutput $Stdout ` + -RedirectStandardError $Stderr ` + -PassThru + +Set-Content -LiteralPath $PidFile -Value $Process.Id -Encoding ascii +Write-Output "HDMI USB bridge started: PID $($Process.Id), http://localhost:8091" diff --git a/start_file_ui_run.ps1 b/start_file_ui_run.ps1 new file mode 100644 index 0000000..ddfc86f --- /dev/null +++ b/start_file_ui_run.ps1 @@ -0,0 +1,58 @@ +$ErrorActionPreference = "Stop" + +$root = Split-Path -Parent $MyInvocation.MyCommand.Path + +Get-CimInstance Win32_Process | + Where-Object { $_.Name -like "python*" -and ($_.CommandLine -match "main\.py" -or $_.CommandLine -match "ui_server\.py") } | + ForEach-Object { Stop-Process -Id $_.ProcessId -Force } + +New-Item -ItemType Directory -Force -Path ` + "$root\runtime-data\logs", ` + "$root\runtime-data\ui", ` + "$root\runtime-data\guidance", ` + "$root\runtime-data\autopilot", ` + "$root\runtime-data\out" | Out-Null + +$env:PYTHONUNBUFFERED = "1" +$env:FPV_DATA_DIR = "$root\runtime-data" +$env:FPV_UI_PORT = "8080" +$env:FPV_UI_LOG_PATH = "$root\runtime-data\logs\main.log" +$env:FPV_UI_FRAME_PATH = "$root\runtime-data\ui\latest.jpg" +$env:FPV_UI_GUIDANCE_PATH = "$root\runtime-data\guidance\guidance_state.json" +$env:FPV_UI_OUT_DIR = "$root\runtime-data\out" + +$ui = Start-Process ` + -FilePath "$root\.venv\Scripts\python.exe" ` + -ArgumentList "ui_server.py" ` + -WorkingDirectory $root ` + -RedirectStandardOutput "$root\runtime-data\logs\ui.log" ` + -RedirectStandardError "$root\runtime-data\logs\ui.err.log" ` + -WindowStyle Hidden ` + -PassThru + +$env:FPV_MODEL_PATH = "$root\best.pt" +$env:FPV_SOURCE = "$root\runtime-data\input\source.mp4" +$env:FPV_SHOW_OUTPUT = "0" +$env:FPV_VIDEO_REALTIME = "1" +$env:FPV_SAVE_INFER_VIDEO = "1" +$env:FPV_OUT_VIDEO_PATH = "$root\runtime-data\out\out_infer.mp4" +$env:FPV_UI_FRAME_EXPORT_ENABLE = "1" +$env:FPV_UI_FRAME_EXPORT_PATH = "$root\runtime-data\ui\latest.jpg" +$env:FPV_UI_FRAME_EXPORT_EVERY = "1" +$env:FPV_UI_FRAME_EXPORT_JPEG_QUALITY = "80" +$env:FPV_GUIDANCE_EXPORT_ENABLE = "1" +$env:FPV_GUIDANCE_EXPORT_PATH = "$root\runtime-data\guidance\guidance_state.json" +$env:FPV_AUTOPILOT_ENABLE = "1" +$env:FPV_AUTOPILOT_BACKEND = "json" +$env:FPV_AUTOPILOT_JSON_PATH = "$root\runtime-data\autopilot\autopilot_cmd.json" + +$main = Start-Process ` + -FilePath "$root\.venv\Scripts\python.exe" ` + -ArgumentList "-u", "main.py" ` + -WorkingDirectory $root ` + -RedirectStandardOutput "$root\runtime-data\logs\main.log" ` + -RedirectStandardError "$root\runtime-data\logs\main.err.log" ` + -WindowStyle Hidden ` + -PassThru + +"ui_pid=$($ui.Id) main_pid=$($main.Id) url=http://localhost:8080" diff --git a/start_usb_turbo.ps1 b/start_usb_turbo.ps1 new file mode 100644 index 0000000..a3c1039 --- /dev/null +++ b/start_usb_turbo.ps1 @@ -0,0 +1,84 @@ +$ErrorActionPreference = "Stop" + +$root = Split-Path -Parent $MyInvocation.MyCommand.Path + +$running = Get-CimInstance Win32_Process | + Where-Object { $_.Name -like "python*" -and $_.CommandLine -match "main\.py" } +if ($running) { + $running | Select-Object ProcessId, Name, CommandLine + throw "main.py already running; stop it before starting another copy." +} + +New-Item -ItemType Directory -Force -Path ` + "$root\runtime-data\logs", ` + "$root\runtime-data\ui", ` + "$root\runtime-data\guidance", ` + "$root\runtime-data\autopilot", ` + "$root\runtime-data\out" | Out-Null + +$env:PYTHONUNBUFFERED = "1" +$env:FPV_MODEL_PATH = "$root\best.pt" +$env:FPV_MODEL_FUSE = "0" +$env:FPV_TORCH_CUDNN_BENCHMARK = "1" +$env:FPV_TORCH_MATMUL_PRECISION = "high" + +$env:FPV_SOURCE = "0" +$env:FPV_CAP_BACKEND = "dshow" +$env:FPV_CAP_AUTO_RES = "0" +$env:FPV_CAP_WIDTH = "1920" +$env:FPV_CAP_HEIGHT = "1080" +$env:FPV_CAP_FPS = "30" +$env:FPV_CAP_FOURCC = "MJPG" +$env:FPV_CAMERA_READ_FAIL_RETRIES = "120" + +$env:FPV_FORCE_EFFECTIVE_PAL = "1" +$env:FPV_EFFECTIVE_W = "640" +$env:FPV_EFFECTIVE_H = "360" +$env:FPV_IMG_SIZE_ROI = "320" +$env:FPV_IMG_SIZE_FULL = "320" +$env:FPV_MAX_DET = "5" +$env:FPV_RECOVER_FORCED_DET_EVERY = "3" +$env:FPV_RECOVER_FULLSCAN_EVERY = "45" +$env:FPV_CLOSE_PERIODIC_FULLSCAN_EVERY = "24" +$env:FPV_YOLO_FORCE_DET_WHEN_WEAK = "1" + +$env:FPV_ANALOG_FPV_MODE = "0" +$env:FPV_APPLY_YOLO_PREPROC = "0" +$env:FPV_PRE_BLUR_K = "0" +$env:FPV_PRE_UNSHARP = "0.0" +$env:FPV_DEBUG = "0" + +$env:FPV_TARGET_OUT_FPS = "30" +$env:FPV_VIDEO_REALTIME = "1" +$env:FPV_SHOW_OUTPUT = "0" + +$env:FPV_SAVE_INFER_VIDEO = "1" +$env:FPV_OUT_VIDEO_PATH = "$root\runtime-data\out\out_infer.mp4" +$env:FPV_DETECTION_CLIP_MAX_GAP_SEC = "15" +$env:FPV_INFER_VIDEO_MAX_W = "960" +$env:FPV_INFER_VIDEO_MAX_H = "540" + +$env:FPV_UI_FRAME_EXPORT_ENABLE = "1" +$env:FPV_UI_FRAME_EXPORT_PATH = "$root\runtime-data\ui\latest.jpg" +$env:FPV_UI_FRAME_EXPORT_EVERY = "1" +$env:FPV_UI_FRAME_EXPORT_MAX_W = "960" +$env:FPV_UI_FRAME_EXPORT_MAX_H = "540" +$env:FPV_UI_FRAME_EXPORT_JPEG_QUALITY = "65" +$env:FPV_UI_FRAME_EXPORT_ASYNC = "1" + +$env:FPV_GUIDANCE_EXPORT_ENABLE = "1" +$env:FPV_GUIDANCE_EXPORT_PATH = "$root\runtime-data\guidance\guidance_state.json" +$env:FPV_AUTOPILOT_ENABLE = "1" +$env:FPV_AUTOPILOT_BACKEND = "json" +$env:FPV_AUTOPILOT_JSON_PATH = "$root\runtime-data\autopilot\autopilot_cmd.json" + +$process = Start-Process ` + -FilePath "$root\.venv\Scripts\python.exe" ` + -ArgumentList "-u", "main.py" ` + -WorkingDirectory $root ` + -RedirectStandardOutput "$root\runtime-data\logs\main.log" ` + -RedirectStandardError "$root\runtime-data\logs\main.err.log" ` + -WindowStyle Hidden ` + -PassThru + +"started pid=$($process.Id) ui=http://localhost:8080" diff --git a/target_physics.py b/target_physics.py new file mode 100644 index 0000000..856a045 --- /dev/null +++ b/target_physics.py @@ -0,0 +1,243 @@ +from dataclasses import dataclass + +import cv2 +import numpy as np + +from config import * +from helpers import box_area, box_center, box_wh, clamp, clip_box, iou + + +@dataclass(frozen=True) +class MotionGroupEvidence: + reliable: bool = False + valid: bool = False + point_count: int = 0 + coherent_count: int = 0 + coherence: float = 0.0 + residual_px: float = 0.0 + residual_x: float = 0.0 + residual_y: float = 0.0 + raw_motion_px: float = 0.0 + speed_norm_s: float = 0.0 + scale_ratio: float = 1.0 + spread_ratio: float = 0.0 + edge_violation: bool = False + speed_violation: bool = False + screen_static: bool = False + score: float = 0.0 + + +def _transform_points(points, affine): + if affine is None: + return points.copy() + linear = np.asarray(affine[:, :2], dtype=np.float32) + offset = np.asarray(affine[:, 2], dtype=np.float32) + return points @ linear.T + offset + + +def analyze_motion_group(prev_gray, gray, box, affine=None, dt=0.04): + if prev_gray is None or gray is None or prev_gray.shape != gray.shape: + return MotionGroupEvidence() + + frame_h, frame_w = gray.shape[:2] + b = clip_box(box, frame_w, frame_h) + bw, bh = box_wh(b) + pad_x = max(float(PHYSICS_BOX_PAD_MIN), float(bw) * float(PHYSICS_BOX_PAD_RATIO)) + pad_y = max(float(PHYSICS_BOX_PAD_MIN), float(bh) * float(PHYSICS_BOX_PAD_RATIO)) + sample_box = clip_box( + [b[0] - pad_x, b[1] - pad_y, b[2] + pad_x, b[3] + pad_y], + frame_w, + frame_h, + ) + + mask = np.zeros_like(prev_gray, dtype=np.uint8) + x1, y1, x2, y2 = map(int, sample_box) + mask[y1:y2, x1:x2] = 255 + points = cv2.goodFeaturesToTrack( + prev_gray, + maxCorners=int(PHYSICS_MAX_POINTS), + qualityLevel=float(PHYSICS_QUALITY_LEVEL), + minDistance=float(PHYSICS_MIN_POINT_DISTANCE), + mask=mask, + blockSize=3, + ) + if points is None or len(points) < int(PHYSICS_MIN_POINTS): + return MotionGroupEvidence(point_count=0 if points is None else int(len(points))) + + next_points, status, errors = cv2.calcOpticalFlowPyrLK( + prev_gray, + gray, + points, + None, + winSize=(21, 21), + maxLevel=3, + criteria=(cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 30, 0.01), + ) + if next_points is None or status is None: + return MotionGroupEvidence() + + good = status.reshape(-1).astype(bool) + if errors is not None: + good &= errors.reshape(-1) <= float(PHYSICS_MAX_LK_ERROR) + old = points.reshape(-1, 2)[good].astype(np.float32) + new = next_points.reshape(-1, 2)[good].astype(np.float32) + inside = ( + (new[:, 0] >= 0) + & (new[:, 0] < frame_w) + & (new[:, 1] >= 0) + & (new[:, 1] < frame_h) + ) + old = old[inside] + new = new[inside] + point_count = int(len(new)) + if point_count < int(PHYSICS_MIN_POINTS): + return MotionGroupEvidence(point_count=point_count) + + ego_pred = _transform_points(old, affine) + residuals = new - ego_pred + raw_motion_px = float(np.linalg.norm(np.median(new - old, axis=0))) + median_residual = np.median(residuals, axis=0) + residual_px = float(np.linalg.norm(median_residual)) + deviations = np.linalg.norm(residuals - median_residual[None, :], axis=1) + coherent_limit = max( + float(PHYSICS_COHERENT_MIN_PX), + float(PHYSICS_COHERENT_RESIDUAL_FACTOR) * residual_px, + ) + coherent = deviations <= coherent_limit + coherent_count = int(np.count_nonzero(coherent)) + coherence = float(coherent_count / max(1, point_count)) + + coherent_new = new[coherent] + coherent_pred = ego_pred[coherent] + spread_ratio = 0.0 + scale_ratio = 1.0 + if coherent_count >= 3: + span = np.ptp(coherent_new, axis=0) + spread_ratio = float( + np.linalg.norm(span) / max(1.0, np.linalg.norm([float(bw), float(bh)])) + ) + pred_center = np.median(coherent_pred, axis=0) + new_center = np.median(coherent_new, axis=0) + old_radius = np.linalg.norm(coherent_pred - pred_center[None, :], axis=1) + new_radius = np.linalg.norm(coherent_new - new_center[None, :], axis=1) + usable = old_radius >= 1.0 + if np.count_nonzero(usable) >= 3: + scale_ratio = float(np.median(new_radius[usable] / old_radius[usable])) + + frame_diag = max(1.0, float(np.hypot(frame_w, frame_h))) + speed_norm_s = residual_px / max(1e-3, float(dt)) / frame_diag + area_ratio = float(box_area(b) / max(1.0, float(frame_w * frame_h))) + near_factor = float(clamp(np.sqrt(area_ratio / max(1e-6, PHYSICS_NEAR_AREA_RATIO)), 0.0, 1.0)) + max_speed = ( + (1.0 - near_factor) * float(PHYSICS_FAR_MAX_SPEED_NORM_S) + + near_factor * float(PHYSICS_NEAR_MAX_SPEED_NORM_S) + ) + speed_valid = bool( + speed_norm_s <= max_speed + or scale_ratio >= float(PHYSICS_GROWTH_FULL) + ) + screen_static = bool( + raw_motion_px < float(PHYSICS_MIN_RAW_MOTION_PX) + and abs(scale_ratio - 1.0) < float(PHYSICS_SCREEN_STATIC_SCALE_EPS) + ) + + edge_margin = max( + float(PHYSICS_EDGE_MARGIN_MIN), + float(PHYSICS_EDGE_MARGIN_RATIO) * min(frame_w, frame_h), + ) + moving_outward = bool( + (b[0] <= edge_margin and median_residual[0] < -float(PHYSICS_MIN_RESIDUAL_PX)) + or (b[1] <= edge_margin and median_residual[1] < -float(PHYSICS_MIN_RESIDUAL_PX)) + or (b[2] >= (frame_w - edge_margin) and median_residual[0] > float(PHYSICS_MIN_RESIDUAL_PX)) + or (b[3] >= (frame_h - edge_margin) and median_residual[1] > float(PHYSICS_MIN_RESIDUAL_PX)) + ) + edge_violation = bool( + area_ratio <= float(PHYSICS_DISTANT_AREA_RATIO) + and moving_outward + ) + + support = float(clamp(coherent_count / max(1.0, PHYSICS_FULL_SUPPORT_POINTS), 0.0, 1.0)) + independence = float( + clamp( + (residual_px - float(PHYSICS_MIN_RESIDUAL_PX)) + / max(1e-6, float(PHYSICS_FULL_RESIDUAL_PX) - float(PHYSICS_MIN_RESIDUAL_PX)), + 0.0, + 1.0, + ) + ) + growth = float( + clamp( + (scale_ratio - float(PHYSICS_GROWTH_START)) + / max(1e-6, float(PHYSICS_GROWTH_FULL) - float(PHYSICS_GROWTH_START)), + 0.0, + 1.0, + ) + ) + spread = float(clamp(spread_ratio / max(1e-6, PHYSICS_FULL_SPREAD_RATIO), 0.0, 1.0)) + reliable = bool( + point_count >= int(PHYSICS_MIN_POINTS) + and coherent_count >= int(PHYSICS_MIN_COHERENT_POINTS) + and coherence >= float(PHYSICS_MIN_COHERENCE) + ) + valid = bool( + reliable + and speed_valid + and not edge_violation + and not screen_static + and ( + residual_px >= float(PHYSICS_MIN_RESIDUAL_PX) + or scale_ratio >= float(PHYSICS_MIN_GROWTH_RATIO) + ) + ) + score = ( + 0.30 * coherence + + 0.20 * support + + 0.25 * independence + + 0.15 * spread + + 0.10 * growth + ) + if not speed_valid: + score -= 0.35 + if edge_violation: + score -= 0.50 + if screen_static: + score -= 0.35 + + return MotionGroupEvidence( + reliable=reliable, + valid=valid, + point_count=point_count, + coherent_count=coherent_count, + coherence=coherence, + residual_px=residual_px, + residual_x=float(median_residual[0]), + residual_y=float(median_residual[1]), + raw_motion_px=raw_motion_px, + speed_norm_s=speed_norm_s, + scale_ratio=scale_ratio, + spread_ratio=spread_ratio, + edge_violation=edge_violation, + speed_violation=not speed_valid, + screen_static=screen_static, + score=float(clamp(score, 0.0, 1.0)), + ) + + +def match_motion_evidence(box, entries): + if not entries: + return None + b = np.asarray(box, dtype=np.float32) + center = box_center(b) + diag = max(1.0, float(np.linalg.norm(box_wh(b)))) + best = None + best_score = -1.0 + for candidate_box, evidence in entries: + overlap = float(iou(b, candidate_box)) + distance = float(np.linalg.norm(center - box_center(candidate_box))) + if overlap <= 0.0 and distance > max(12.0, 0.75 * diag): + continue + match_score = overlap + 1.0 / (1.0 + distance) + if match_score > best_score: + best_score = match_score + best = evidence + return best diff --git a/tests/__pycache__/test_autopilot_proto_udp.cpython-312-pytest-9.0.3.pyc b/tests/__pycache__/test_autopilot_proto_udp.cpython-312-pytest-9.0.3.pyc deleted file mode 100644 index 5b1fe02195388d58d3638760ea8867e7d337532f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5686 zcmeGgOKcn0@h!PrenskQ$!SukLDXoA!ZP)7oWKe!sg4y}vJ_W`h83__ahKB4OD^$t zWt%EhD20Kf=ppH)aex5rv4V5(H8S(s%;fDpi^hwazB=3IYkS! z2=XA#o0&H=`*z>F`RMm8EeQhS!_;^&9VO&1NVr32BKz$eAs-WkD4b4m9GgR>a6ZJ5 z5}%83xDwKZQZyH3IH$*QF(wty#Y04ms);*b0Y|ahnS=07lPLBCxel&`PXdx_0V`>> z#kHcwi{E})_BuyjK6<72ou{Q0@}$i>AxDP20k5}}+H>utj$B8nGuH{c$SsoVQUrh} zlqkUNDksMFQ00oq+5D_*E$!huiebBTY}qm@nr>JVMyVv16}x+3S<@A%LJiB1mX(TB zk*m5PEA~sKs;hY`uwuz{QMDw~k}Wmw?8t#Jn8NVePr$LjVQ{PP(N{UE`C$pEks9|| z*nN%F5Uhr4p<4J_KOw7pjW_>jp*|Z$-o^Y`0ht1)L{O&?y3>?U1Pk248AaT~q2V6h zbz+_j*k?}lq^RYQ?lID!M#@|Ekf$}u9PGz`iz9Su#I^n$IqoaUg}$O(*p*YFpGH>T zj8-E`Oo`Vb8pkNX%D68U*jcN~^XHo2T5{)~!IirS=M6s&ej)hrt6?SaTn|Wo;{nch zEgt15Uk?vR9UB8$zVU#svj`uZ!?GoqRJNfYTE?M3XgX!d)mqsSQ)&F}XHOfcrKXo`u|-cd*Cb;ky={Wv<2M&3DfS8%q3$Pk}HZzi@LEO>rz=Rsdij4<${WA zJ3Yqsp&8{1lp56TkO~GZ$(E$vtmtxCwqQk!+DUwZv*m1%7zz?2LE>zXc+J_OV>)~K z32M;9Z`JFJgZvNPn4hiYmtpMDr@EW@ptDg%v~LuxI901;fHhc zh01lwqH_M4N+qLWX(eq%Rp#+SnG8y@TWh7?^zK2inP_XLZn^^}v1phyG z1z%(z&XeR3xC5OOujKv-2q_;EzqoYEJSaK18j-xBQwagptego^!a#ZD85ER6YL=0( z76B^aK1dOOa{CLSM1k_kOeF@CS87@vu8v>YLCWiLxy2JM=Fd=eae+cOaVQCXVqsIk zM)3s~^JgW=3gOhDLb%0x$(1>(`*6Py6vABr`@OD?Ki9mHk_IJT_9a}*mxRK%C1CX^ zS-${He;R5Qpc$n#P=MkcW+PzfyDrb4pILxH(FqBs?aCe=6$|3p-{7>he}FqIGX2?G z=N)8Yp@|U9MczJt5I;9QKEAZHurMyhm_CKx#Idxe0Jirl1jyUN{%#K)izWfAb1Uu5 zOy%aEAxI~&<#GiBqp}{NEg*(jXXqdu$T}k+1Qo6c0Vg@pyKT>bg#?H~X4)kbD;ZSnyh?-3r5 zctrT%EKEJZLlRF3kCE~B_C2!S;jb{}BX{L7u6Wdg4wC9*0-*1x`mBHyJb+30z>{YN z|B2cZZ$KV?-azlhAa-03gl;a)Q@q8|iN&kuq~p(8nxNa3@6>OTvD z{1ZVi@^1z~fd#?np&%Ih9|yr$Aaorc1mj*1Oh7>QyYT-`5KLWRMYl%aFtsf>dn&@Q zxlxV9t>XbkH9CL*FF(y97(y_NUls66ix{9JyEnB9Yn!P%6>B7XE zbaiHC4#X`A%?_Bc1?X0w2eMoJGJSz+%A(2=63Zj3{b3b0i^c3f)(Lwd7TphG=SqgM ztgCO*AHim@_%iW-V-La{$NhHZ&D-Z1-Tm9V_?YLp Z&_e>?Q98^`bB|KpoUlKf=1#L+{R`Vyh#>#~ diff --git a/tests/__pycache__/test_decision_logger_events.cpython-312-pytest-9.0.3.pyc b/tests/__pycache__/test_decision_logger_events.cpython-312-pytest-9.0.3.pyc deleted file mode 100644 index 5c0775965107e9ff9d8c3b61147504bb381aba19..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3427 zcmeHJ&u<$=6rS~NY{z!urU|8yP%%*~vJy&5N@>*=L~tAegg8olsby)|c*n`Q>s@PR zW9s0b6jkbhOF2=bN}N$HT=*-v*s>#s;eZ6<&>JWchn{$|yJNd0cZ!tJdggm?zWL_I zn_a)3#>Qd@in@8DG8aPVcW#Ih4#4r>0KP#6G9(KXB(DusZsNiVj6~P#^b&1TJ+*5gV zz!Jzn&uSjPu6glRu2^TP6NL%DBll6^Y(rARPk85=DluhUcekJL{eW>UPxR6q)81Ob zC6k(tz3OajVN&8`Q6d0j_#HongM{Og_K++3P$ihQ?*OnTHBnQlqrFfwM1OQgL|;V& zLO7r`fK!$XnU5%k4ivv@XiRj79Ka?L2Wcp7T=Z8G0u5RbnmVG>Lz?3QhCqWI32ibw z;)GTFdxR~)dIq~aVN6pJ>c+@pc^A%OPd36vq$!({XDRMPe?eAZcC)r!EJ;J}is?1~<$g9h| zy0Sa2Q;Lb}4Y{gP?!5ZU%mnMyvDsu?fG+lEsv2g_koTRWzq+a;_KOtGusI<{Rk z=1s+7gV|Z^y;{S?AfOXAp_O#Yf)O=Ra;i0*U^c2zy^OhsC4i}I?wFQq+LVQ1^SVXZ zd5vy6JC@;%(zYyTQ@1o*uVOa7=&c|A?z4nAgiUH?hg5Y~%iWr#+qw%q>IjQ-S%0gS zhK;v|jRoKE1>PPuu2Ft^DPEqIm#!Dr8>MZXRM&}9!6la#Z|7EXkbanU@%`KRY<^=g z^Kmhsy_GMPYjw@#c?l+(Q*+I#xr>b=KMLyAnh_)`5N4jO_(w?RYYp~>cVN%=mmJ%L zd1yZrWfNNlB|NR-Sp)qI(xH8H6u;0?GToVLy_rmVCIh$y9su!7PvOs@@c3rOGkx5z z0|k!{1g_@cE|6yeojw@=aQ?e_Q!ORioxR?h&9-N=fLrlY3qU;EQ}}ZzJigiSOdt2_ zK*8e!fvb7A3*^~Ar%wg|oTK<_EoHfzzR^oBx6{jjTi^i@U+yXVITRk>?0BY+`*on; z@qxhAJlq9xI?(Bp0f7G)LXFf(78S1wnF0r!*8KNu-oNT__vZ=zjF>LGV|^Ek1U|wO zG#05r5^b5AgeS&KrVYxXnpQU9)1z@Jb*wt(ECMe#Wr_8T+(+3=Ufam!@<4Y9Q@8>P z!>xob2}?eAWxcO~cR#;JBn5Mj3s60c-mE&tj)mVPm%!vXJpB}^KV?ahensd$Iz;Dx zL9xHW=*-l^m9M_&CDZL>x|4jpm%P|cUi?97Cl~e;Kg;3$W=EcSl=*Ju+m*IFd!$4j itlV39kh_RZ?u-rV z?zSX~6i6e*{K8a`;#QT4g{8P^r4k7Vq9P?xsp`ifJ5x(doAiOyA2j^qSI?c9J2Pu< zvC~jgl#FK2oO9=#bMHNK=gzs`-QVTqxhOa`<{b$69TfFB7RX`OnENi8qApQ9#nWM` zk5;ahh&5oLsR+|&qwyU}*dB58ITW4_JNul9R!*PGLJ1DReHA?5BeYSyn(0+d2vwL% zRi3wEgT8$5QXu3LFM=~vxmtIWMpfDGgto5M=BZ4dS1qcJ(gP-394YB50p9io)mO^f zftK+Opc@jj*LfGKc6w=}RhkbM zEBx-?%fUgFw7^gl2}6pK@O|Y7F@!U&Jk47MEt1w;2q}3?h6*)pCEZCSBfN24qc%J5SbuHg!PAtf-#C&hjuZ`Tajqx?F=dXI9uGq=W#yIjq!^R` zEOAZQEL75(WRf-`!YMnH(Me0vPGX*NBpu_lc$;_fIZ_d+B~(l}<4T_SC?#%`%1C)= z1L2IT(Jm4TWVY}anuJ?z5v8F$MlD%vk~HzoyVl+EGLz3NEl*O!Co*eht6kTGmby${ zv*}uh*C#H2Mb{4qSGiXytS4o_Xhl1` z;&v_^4uoT(;7ZkCgC?Q{jHcB9T4E#?lZFJIjmLsf$(7os)}w3J8S2f%12CG0owy!^ zxP(?o9aOxnQt8`XrIk>H)?VN=qq;h<8VJWYsiEGL@+zlniN<9nAffP-Teij%J20uo zqydvAOm9SW^eXoEtV6uRp!&XULe@_0fPi17bSz{Ug=6Tfc2yoCs-B*!uoR4n zvOOFNaA8q)L8&B&63Zdk!-_+(kuc99A&%I=aIBvTvr#T0$T_UY4G6ec&fl*L-C!)* zj!+EACAiWEC$YlWc$kZF65Nq(N<5}=P%iF@8`rEpzB;qeXcl&vh23gvHJkbjsWC&^ zsY+@a>Y7DZk5QH6cyRhq%c*0Dzz~Nb$51RJ1SIj)$*vc>dJz|lo;q-{x4pM}|G}fD zdfR(?PYuLJSqX80VF9tRxD<>8#{~Wq4vwf$c2HXsLVO_D+v5p&hZ4HXa=06e2Bjbu z278g!ACvMjqIzf_&Pl{y!Kgk*ExWfaxU0_{TCUjqUd`p2$@VKvll!h0->CSYB3*Il z+@Um6zU--Z&w1H7xph*S-1O@Mzv=j8N7~bdZB-}cIa`#4DR?&FJ$ao%B$zg@2uEEsH>IevbRSq*rwg}N0} z&?GEqg7?*`(+6}_!h-4*zgjvEZ9cu)=(f=k!h+GqExHl(!r(JIqLzdDG6|I*hpJ|- zyk?g1PglKPGliL%Iw02%-01kA1DL6rnYwEo>Dt4<<1p3C)*fDPLrDGeZfL4;-eLSW zOt@gdVAG5rhsjU_9&B-JG)=;SCU{@1I$fo!5*AdiXqxl5y{nC`Lu@twgaxB(9cn2U zK`#tGvm6X3aW#jyrv@s$t~-ahRhh(HEzfhV>V$Jf9u z0Am=mJmR&5i+4)}q=}(I!Wq{L+@ge(he`=&T#fY<$;Tk?H7v5=1*l&B7hkmlVzi;QvJVR~>Hg9{c6HqG?lG{#T^ z9&B;$qG=KqG?g@Fr7F%{G*!ZaDo$f?={(aj)2gyM1^>5@DnUyK3r07`)KV~-t?f4W z%#Nt#&>hqO4JxWpn=~K$F?i7)Up;#)<=PI zvTblo>zfa2`f@x%U(WUWg1+YP3=gRVpUdY?=Uvs{RY-kkJ|7S2l!Y%ySz#l^It(vQ zib*nJSkjZUP?ofbFHAB6G+%^m1}s;#2$^iMK)A*Scnv-MbH^9IYf(UF8(+egCT&4l zk?h6>iOExzO&$Y^A}NptL1Xxtfvu;qlm%k2L5YC_`r;Udi6_PIyKC~MoCB@WLI(?``SD|;hzR7{KfP*jd>bM;ezK8x;@=@`6&Hqk){@t5{-5B6j z5&1^1=(S+9x(<`Zv3wIA4Ujp)v!2@9p|^a1-UcJ=SyjIFmd|T>U}@cpUIHDj#cidS=fbYajhnZc&GVxa|XP-L&TW`L3iP*X5(!V2C#J$);v-G4Ic&&HjaWZ1S5dO z_h0~UVSzP<8R`eGxbxy(cvFmw{IlG`caB~-x>QuWR9L!HSh7^SVY#I2QuD>;`OQ1E z^tBb27dwGD-!$Gi=YAfzNdcF4P6{9{GTNzWObTkr0ya?Dd919n zL_s%Hna5q(c=?5WvrN^*%RtWWn|OK74J!$J-ce;$SipkH&SPbjHB-=lravlgX{Jh9 zS9r^1C)sFGR+;ifR^4UUu1*@TMY0_UXJACaT%{Mu4y{aCkIIfX{4FgU>_>RbAzQ@+ zOi|I1NIaqL4>?(OAPB>l#b#nGJSt!dBt#`q&OO%M)zN;im+kKA>IG8?!A=@@lkKoH zg?&faNnRFpos2gnlx3;%CQMOEFO+xdfwMId<43~6J|uz?-i;NbK<-&-n*I|-y+Pff zJb$EIpHs#EvQven6Hn4#wi>d)nVYPFwBE?7$^4!-BV z?4PqfyTmx&^uOkRv+K34@%lYlnuG(e8V1-n{qx-t)|F zd_SMhAQ+^3s{A`f=r=x*BiR=A{s!R=GLeZLRKkKQ6?Iv`sG^k;n6D^KvXUyLgp8f8 zQkSqwm(mKdQdZ_3#O9-XGURc3uRSUE#I>Pps;59GlCE6TUE~<3O7?ywxYXlI74gq$yx3{Pz?HaR+(r zQlZ#($hfhIWf49!&BtCst~O8u%OrfIp|BrK)zmy5U5s*#)^*)jKS!kNF;vx`;Pa=_~n{a44>0ANxuj>8ZJ(XQ}R49yOLfB^A3$jH_3v#hATj z1=*=93o{FR#iHxy48|<#_4NgRNp`MfRkT@b1>Ive1cVV%7t6r(!RGk%a-HJIUf zG)Pul^O|GnTA?#YA zj=O9)EXaV?vzSK=8uSph>Rxk9LaiDlR~>iBa7fjtSV5XFW5wd?p!=ez!gi}Ol)5xH zN>*H2F+5^juQ^85@L)yPXqrDV%O4q5JrbXs4JV=F7~`9@5607#_upQeuP?6}v@%cK zvbF57#Vf^2Md+YiT@<~%d~y1t#f6zq78X}(YeY2NqQtFvcEw(|%tc-c6O@PwN$lN- z_6^qR!8@YjgV%}8NZ37N$+5@^bt|M6CPS#}dbAg^<%f^m2C{*E&Y#%LpKj()Z}-2~ z%1>^5@N4hDwx-`6-yGYz(8}r{H?#V#7X5bkYN%*MV#nfvrf-gQmZkVm)3+{!S}@q& z9})BrX%F)G53KqgPzoR&`^e))Vl>b?l;PtBJ_xydfVYMs5yw{>D!XecrWzqwSzZ`2 zAaH3f5Q=XBG0Ni7k+YCTdB{@*2o-r8glU}s(gW~8eZEhGB`>h+;KK{-I#^(8Z5+|O zf=n#tdr0ac-q2*;lDBb0UibU=fG{ z$kI0?AY}v~Wzf2A!x_o=0~}p)^OuVs&P*?m%f;dXoLl~?IG~Ksd|MjV)0E%8K{9lt zyf(_glXA4AM>#ph$#G6j0MQfi(>@%Zj8cB@$2DVdFqmNchU8$-M_=NGj_a zFe?I$+ILJ}2XCGv^MP!jN4*o<+SKh!+r1Ob92d896T8{TW_A)}psnnb5Tf4qjMY1eeOl<1IvZSU`YT2S8x30)Yl-tN^7i;b+UYTE= zT`D0#6;2T#hoXSnOI;v9)f)w0@_+QQ6e*CM9vYwlirkX==2PG7?3$}Ms%)S+q*^ZD zy!YleZ|2P`IltMzo;lOZ!S#oh8{2>Jaoj(tVP9TLdB*V^_eW0VWIoAl@Mh^sxf3p) zOQ8)9PitIBZ_2mfGj%@Mw9(|^6rbXM3N7ii-Nj0$d}+WzNn;k#LSL@u_`MX>1TCnT32vF$?8X|6V}|A z{lnaIZumYU9&@gF*(JN-gJjRAo?X7kZ9CCs6ONO;vad1zV=zs!|3pl)ES!jGd4;~t z$gPbyI?cztioCsoJXhr90A1T%mmJ)67hOg7!!fW>5ot*sa%b1J{p{HeDCM>y+T!JQ z+Gfl36tOBtnMB6*yB@hy?kajz-n6{BU`KaEYO#HdvgoxV z+2wEG7xN*%>w+(WjCr}+%HMxV^Y5|pKg;sZz1r%ZGgrU&d%ybUPj&WvR`#u@@!8LF zq5hSEe+M>)OgNRDF8J;%njX&T@q#at)2S=uA>Os<`s&RHY5r++DRM`QEykCLu(lli znHXJ<$JXN{xVCsVBHmuP67CNh{PamBE{D#qNq$7=D7ZQnL}Q5ExHoWF@JM zTiug@iFb72*^-i)LOME^o6KX)xG?DK{8 zk+E!kGNcVIEDTb0I;8Eph#P!VOh0dxG)=*}SvM2XsB;GUX&A~;Zte5LrE1PNs%FQ* z^xqlNzvGaPcr`_bjTqD2gfw5z=5?55QNpB6Oi0P3h?Oi(WKvlPE5t8q(w0I!q;=8k zUd^N<7-ul)5VtZoCBd%!VKynHB^_$^eMCrQ(hrqh(9f*6fG+y6e2)JBFkXH zXfpLihQR19afm4vQsz8Ad{jblkoZ{Bz>9*M6m z-u%gCJhB$w+{*5VI+hX-6)a}5x|&i8io8ja)=VkFl7LsC^dp?jlXuvB9Lrlom>Esc zL@kp8+9JxzjtYg8mc^~4^uC6}Fi<+xS|2R$BkuF|{(AQt^`7(fzQKC$@YjO3&HL!~ zS3%AbtfB5Q>V90@TdSf_9R-+e^jtDRZ&%R|9iFk$r7D_oIIfhmD!S@$yk%VZQ59YP z0u4OLL$uF@&c{D53j+ok-Wvj82m^aVWns96=zVB8m{lK#K$@BxW3DcdjisqleL&CiR%>|Mxd;bf~^~p^QT6ql3 z^~=pCVg$M6M9dKidrV&eIoODEEX{4RwmSZ`XimFHb2`3bn$!7$Ce`0{ z06+7P;Np5b8oLc_MvSa1-nbJXZ4J*2pf!<|B@&FSM&nU&eJN&xS{t6_=vtgO>{xU) z&d6#bkQEx~IH!;IRO~o}TW7pWKl;bf2a3OLA|gXJCb21?XEP=Un))q_cRF%|eaG^c^JZpQC` z*7Xn9`-bYhBlX^am-(I1bEULXMKcbv7XD0t{omnuna-7dc@*(h>24KGe}RVfLREAb z=$%b+CQ3^NnxG_S$q*)Lh~9^mgIN{RV)_9BJX4(#ASTHfD+?h5&6VzfFoaO)URju{ zA$lKL4rWzMi|Gdx&s3)bh=Yp#Pe;k9*jy=Wj&igVJ~~QpI7T_z7^UU@wo$T>;nGC| zg@N3aE|vvA)BDhJFsou(Og}(?XR1?HQ5cWI^AYMIP4{zZQdiR&X`#oT7uGqEow~y8 zKm;eJFjhXmstzZv7cSzneCDG~d~l?CkW}wu`reSZwY)}}L~%<^DxyfCS|+)pP!v|u zI&=uX$sb2<#>LfWG!Aeb6GS5rFAN8ckwkDDd(5*S`x(*Gi}{G}b7mIiujP5uJRFI0 zN#z~u*c|KPZfxkK*k-?`?myqq10$8drCMOT92ozM zF9)U`wfxQPeN?QtJD=SA^YWjT%kGPHXZK^82XW1>Zz{qnY*m}vFq z3SA5Cfij#xEwy^WsYQ=Y?B8fREwi5LB@+iWT5D2eowozRhPPbFHsl(I8*@#=O}Xab z=3H_(nQIwt$+Zr*qCE7jG~A|zA=|YGwxmSxp?$c@@_iJ&;4lM!MsntRrn2}R; z59k(`QnIC7PMhNo=$)j(R{cOauN6i|FX$r~BU8v*dqxVm$thD;N7II>j*C)tJYyPG zvp${9PNh+1nCYypW(yU zol}zVjQ5uT1}F|9_6 z-$~rYZ;6hQRFo#{H!O05EaCc6pePpuQ&K5d3>tr9hMe*V=Xd91;H=dagQK!mM^}sn zZlg7HX!T}HX!wQk-?5VtXXh6-Eg3Do`V*B_lqyQjZ;bgZ>$bAY%ecSS>T0&KQ|mT% z0mn{j_$WAw=PxN*qt;YZGBQtvOzH`K_-bl$^1O2~z0pEfc)j6kS+#c&c3Sj1-)mlPwEmiUBaGgNpf}o{ z^v1Sh=r`6IGg7Mk($LFed_?rm_Wyz_b6@U%5QR2Yv+~!I=W2d*e(Zal@BQbOzxVg& zMY)vde?S2Di&1|3#K~Sfzff<*Mhf}S49n@7l^jn`1KF8wjH@Gnc7V32TWw~Vjp+pT zhFvDm*=lrcR4s$qVDg5Qm}I(!RCT{%NquVyx;bM- zD3K{lSk8tj{UIW1y#xk{Ca`_k>Kq(3{C$*Xj4tIeP5ziadQ0<>U3 zwgMSrL7qL^dw6o@SjwpRx~+7b>mu!F$~Ypfqq$8@kC>Thy{6QaF?jLGlwn0Rd!}3n zT0vYv;uwjOAJ?S~LuV#8Jq=T2o_3!vUogeX#S9$CEhJ94+ah5`FTMgIg75SceAXdcRFw5n3*&S@@xJ=}i4*?Bagh+!o$@En z_|1;`6UPi1C|&mP@z*Xqd*$-X$atFNF0;afK4KbI-W>eB!6BB;I`ovDRMGn32A>e>~0YX+C9J5V@hIx;z9y};jT6=H<9`_@zj zBW?X~Q!(yT-fl#?yxrnYjj zz1)y2@9!$_=`444mpeMkT?dhTbfBRjJU6gF!(V?t{9B3@Guw6@@*1O7EMFqBryYnpx>4j8q3`V>YFQJ)x3CSm3f{%-T0xtnf zsO8d-AVPDm_cL;s7?GT@x6Tm_`Y(qB{l|GmRG*Qz#~MhGpTg*tFS8y}50ap1hkcgB z=SX}WVnO~maPFKl6>q`^-1)cboFEC9o>?ERPK8CC&r@~hNPL0Bc@q5)2Dy-89%3&+ zwO-j`uHZA1I+86Iy2s4a>*IPJ^94;4YQ|8<^>o%8pJA_}Hfq0dVEv47u)Md^nK4>@ zGe*aDGscncwk#?~XvSz+j2~H1=yTuB5GhwKDMx6=Ad6*By~mA3_FVvFt# zCBAPdzHd?KzQrIGLZAC~hDf<`N$I9LlEt#8-s8q1`z}@I+Pex&*hRdk z3gjkl9hfaWq)5U##S?;B=uY@HaVZ=nNehf(#4&bEe ztVQ6H%374J@qAM)X4*9(Qa~(a-BA%K@anG;Q>cg(c$Qnn6c8zh`C47g){PWsH6I1I z&mYv{TA~=VBLIqNM^q%qR}&4nbw^Qg{A}lJM*spP8RNPZ9u6O_59r-JpmCCluwyt` zO|~CkT+bj5q~;2mZZ)STCs`qlq<#bROa!Cad3-9D&g)YMIr&SPHGMQa1)syNgU7BK zH-@v;-HOcED`*4!gC^Sjg6|V35GPN?7(EU7!fFPfuqQ#Q9AHWZE6Ix)Asr}{Z^;) z8eWp;6|gr@Dc#igIs`~(oFm7+uJZnla(55Oz5B}h_CKnRCc<+UR+CZ^_>jDLV!rQ# zvnvYdVDi35ii9hdC|9JGJ@uZFoClku%lx0aN^;}yq=CVQ=i0{MNwi9)0Xi1A6S_?= z2FK>%y#R)vGpG>g2xIyV6oY&aD@Dclfp3DMPFr_i5G#BTwed5Hy{8u&&OC~T zx$oM*eb)m^@dJy>0OWGK8_Htu07>Y*$Q)Qv=yTu65lL4rDFeh2$Yj}5?~(NZk$sn6 z>FK?8jUq?LpC|(x5h;#m;D~e?^b<=^G6(^J*|d0|1xg?c2$U@_hQ7MLi zQ#Q6B%o;ZSmzF7flV=t3;2;(j4EAhI7)&%w9L|Vh{wt~whmBW&7DRk^sd9}+Hny?z zkjJAr)#~$*XKOnbdA7N)OE$K#bC)Hyv2$@si+&X4h!-(yoj2{fwi^0IoStybHpV&2 zAB1y3!a96Ir7-X=1~T-3UK2;Xg)pO7kd?GCiie|ksFQ|Ol8V}WVN_Zy!(1T@X>~qnKG%xIV*bNe&Bg_N9J=ImM3A_|nKGjt%l*~&{QR!xFD09vqb%NBH!Fg6Kj!UrvqE@) z$NT}@tRSA`F+GV_eLdRD+r@?365e_*pWAN%`oo_Z^b=w|^9l6(qIo_Ttu{sc?m&NZ z$Iu^jAm2G}1^s~ka4BNLKO+_(Nbe}L*&o}04T8#7hW_7!Hk+UaoP>aX%;RM|R7N<< z-Xg(B7$k_HRAB1K3QRp^5TM)aw9DqFr~RNxtlphP-2Ek2b7~+Wv8Cx~##ehnud@H9j{zqpA?*xHu=I6xrjRYrKC#WBZeV>@n6{T;=R(vmb|P@( zLRUoKKuS3u1j0~%@7PxkvA4;5j>P&1T*U(P$@P)AXF2R_zW)u8ifq4P8 zDTsv_&m!Q~D-C`)Hpxo3vH+b{-eD?=u-XDB_@I9`RCjQ<=nzV1&=XVF^yPYyRzccs= zV718iv1`FQ%BOR71!#{*Pi>H`rgGS>M9ffycVf&Asw- zg2%RUW6Ngnc*wt{0`Pdq-4Y=b+ED>`baqtOs%u2sDeOzI;lfefVkoF!ci=JAWksER zr+oUd_NZc~>JbqI!P*`6z+Pt=>{O1qS+t>)=W(8IMJDk-E!oUfM(&XnG-j|-S5-$d zSe8=B)F@=9b+TfZlME|y`L)3>UVLdteQj`X2<9e3ycjiDVeEdz7GA5)j=|b%UVJG| zEbJ346b=RjBEeD30yK+I|vjUF>e;j=t8yu#x`WM)D zP!8uAuwd&r;1V!%{6X)rujTn;90z4Vs@-yT>B9xN-7_Xpk{cz^Ki!TA^d+_?4pa@*m1O6su^kpl=W t$bq$Fi+pVDXj1N3I}(>$*7nEbSLL<#s2pBvwMFMzYg|q~+z-JU{C`DO?Fj$? diff --git a/tests/__pycache__/test_track_score_gates.cpython-312-pytest-9.0.3.pyc b/tests/__pycache__/test_track_score_gates.cpython-312-pytest-9.0.3.pyc deleted file mode 100644 index c6cbd07ae9e303b9c30aee96585daf683f41c76a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6583 zcmeHL&2JmW6`$oUcljk#mMFYefcA`-MEfZp>-|6}EkwgB5UV2iU!0NW>2YLv6D9Tty1^7_(z1dyvQlxA) zAHCEo@$I~sH#2YEym|B6_q(2+7=h;>2gggFh=lwd8Sep}JzFA2$ooVkDyNeskvNR{LGN(u8A|awlT67(H;!_%M-a)$VGNr*_ty5O{{ZN=oz$iUh z!W%`4ln%7Z-s0$y&Ml?G?b0e~9dkY8$S}5Z3)v~Ey}4ARnkt)ADcqEeLX~Rvki*mz!_W+;PQIa-TEUsp zp%T1!_-)zD%BE{E0@lXAk3#F%3QwH`+IW?3Y2z%;A~#le@tOblQdqJ$Z^vj*GSMp4Dp}!FQI)YW^J-L$S$vUWoM0wB-2*G{_3He$C3y3C?|0$6)I-Z87r*-Y z8gEAn)yhJVmNnJxFM?0pE}Df!xu8_kq6(g2_ukf&n=;ju!khAqC55Vaa5e5ij$7G# zEL&SXnKRNC(pVbL8Fs?$zMxmD)b4>k=z}t2!6|u=M>4*$2R)FQtm7nfgv!ZWl!ifZ zJN9aA+0+bER;axfG|_dKzD5hxa!mmrPC&DKeW|FM#fo8zdKDxLd!KA9R+n^@iOM(h z>UBkzD@s|jqq3naXgGqs=OUZ6Sgl;96cp^27pk)X0FWSsZy-WfA5X_<@~ja zmwq^(zx?C;{6g)Pb2>FDS8HamTwKxAd0e`|Xz=K=V``kJE!!8FZ8{xq8TZem#exX~ zf}-ALnu|(>@$P=mL!dAEzVQnntK?6q>}KE4=7HhO{f8b)!hpE?qsK9Vm4llH(oSVc zT)omrki@=wqZ{$e0|Ea(6EZ(NcW=~Tnsfte9qztgY&QR1$ar0#*()}ip#TzFpciQg zN_ePQQ2H|jhxdblhf_lkcz8c3sazfE>QGzf{lj5F5U@fP|B>K@m%3mH#=q4tgc;mG z_LRIU#;bsLAk6GHqh3R)Lzpq$&<%auph<&0I)t0{9&Xs_R-=KZYh?MF1SLCY;!9%K z35?oFwi!;JAs6!O@C-S=OSxt+gRm&FKo|}LLVs^M1;Vg5hxf)n_!jsI{6g^K>w+4s z!v$neoby@C0x5F(ea6rjVRQa`yALQ@{EC-&XE?_sK8aB~+>o z78nB*;j9BoyDTK@t|UFm)(H#9?OfEUr!DHtu8SH2o58BqB&I)o0ZpB8S8@tfKVu89 zrjv}0uQ`B)*?^`{7C$Oz9}@h8phHOZA;FJ_T!N0kD+nsagUn=*ITd8iIH1SD9x!_V zmP`ei(?RB})5%3IV=df7%Yc&x#&`x`TtW|`qFIJC{M~08yo6s4JNBDi0P%0b&$tOU zAOMQN;|x0hpg0JCVn|$_Y4no*p+-0y78@ctc%&g=DMG$=s1b$Ir|~13siS_MXgDD@ z2sC^_p!6>+{WJEEY{#MS$m1TzN#f|XE9D6IRa>4^YoA7x42*2^&d5$5zp6QMLjq;D znX=w6jWB4v&9rV-@u2=Ts?StK1sictm#M@^b3mqquWDlt$-LM=Enw9jsvD`}jQMNd z;O-VmzOb1JST!2%b*6>6S;;jRO!+i}^=5o6P+>5bTlLip7KH^dv-+m^3O4M0x((S9 z*hCb+r@O|9fC}4iBHaCO$3Mr3&v7Dn5;e@=8C2MrJd(>xkj5$Kn$pZguv8Fz6-GLVqq%sYKhu_LXu%7^O}uc} z#|vq{&o_z}hTHLiQ`NlQcfkw8?Reqvm&FSsSr;#)Kb+i1W!msUn}rZB1g!eM#0y;* zEJ{YQUEzhUZ1~^dh1{SW@$!cZw%hw|*3Q%$Z_^Q`gu3S}@bnOD21tH8k8ATus zp5wT`5ORlnM)v)Y#J&(oZ|d&!&wlbCd2Azj?0z!yAo=n}^5u`YjpX=h;t#yIYTf5k z_b&Z<`d8B%{K%#ty*K^N^m{Y!%-p^BbK~B*`$O4Jh1}=l$|EtsWgi{t one_frame_false_positive diff --git a/tests/test_udp_dump_capture.py b/tests/test_udp_dump_capture.py new file mode 100644 index 0000000..d756803 --- /dev/null +++ b/tests/test_udp_dump_capture.py @@ -0,0 +1,130 @@ +import socket +import tempfile +import unittest +from pathlib import Path + +import cv2 + +from udp_dump_capture import LiveMikUdpCapture, UdpDumpCapture + + +def payload(flags, sequence, number, value, data=b""): + header = bytes((0, flags, sequence, number)) + value.to_bytes(4, "little") + return header + data + + +def packet(flags, sequence, number, value, data=b"", port=59004): + data = payload(flags, sequence, number, value, data) + return int(port).to_bytes(2, "little") + len(data).to_bytes(2, "little") + data + + +def frame_array(width=4, height=2, padding=2, labels=()): + rows = [] + value = 100 + for _ in range(height): + row = b"".join((value + index * 100).to_bytes(2, "little") for index in range(width)) + rows.append(row + b"\xff" * padding) + value += width * 100 + label_data = b"".join(labels) + video_header = ( + width.to_bytes(2, "little") + + height.to_bytes(2, "little") + + bytes((UdpDumpCapture.PIXEL_INT16, 0, padding, 0)) + ) + return len(labels).to_bytes(4, "little") + label_data + video_header + b"".join(rows) + + +def dump_for(data, port=59004): + split = min(13, len(data)) + return b"".join(( + packet(2, 7, 0, len(data), port=port), + packet(0, 7, 1, 0, data[:split], port=port), + packet(0, 7, 2, split, data[split:], port=port), + packet(1, 7, 3, len(data), port=port), + )) + + +class UdpDumpCaptureTests(unittest.TestCase): + def test_live_receiver_uses_same_mik_packet_assembly(self): + data = frame_array() + split = min(13, len(data)) + packets = ( + payload(2, 7, 0, len(data)), + payload(0, 7, 1, 0, data[:split]), + payload(0, 7, 2, split, data[split:]), + payload(1, 7, 3, len(data)), + ) + cap = LiveMikUdpCapture("127.0.0.1", 0, fps=25, width=4, height=2) + sender = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + for item in packets: + sender.sendto(item, ("127.0.0.1", cap.port)) + ok, frame = cap.read() + self.assertTrue(ok) + self.assertEqual(frame.shape, (2, 4, 3)) + self.assertEqual(cap.frames_read, 1) + finally: + sender.close() + cap.release() + + def test_reads_spec_packet_log_and_strips_row_padding(self): + label = bytes(range(40)) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "camera-dump" + path.write_bytes(dump_for(frame_array(labels=(label,)))) + cap = UdpDumpCapture(path, fps=25) + + self.assertTrue(cap.isOpened()) + self.assertEqual(cap.get(cv2.CAP_PROP_FRAME_WIDTH), 4) + self.assertEqual(cap.get(cv2.CAP_PROP_FRAME_HEIGHT), 2) + self.assertEqual(cap.pixel_id, UdpDumpCapture.PIXEL_INT16) + self.assertEqual(cap.row_padding, 2) + self.assertEqual(cap.last_labels, [label]) + ok, frame = cap.read() + self.assertTrue(ok) + self.assertEqual(frame.shape, (2, 4, 3)) + self.assertLess(int(frame[0, 0, 0]), int(frame[-1, -1, 0])) + self.assertEqual(cap.get(cv2.CAP_PROP_POS_MSEC), 40) + self.assertEqual(cap.read(), (False, None)) + cap.release() + + def test_accepts_consistent_mik_dump_from_another_port(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "camera-40404.udp" + path.write_bytes(dump_for(frame_array(), port=40404)) + cap = UdpDumpCapture(path) + + self.assertTrue(cap.isOpened()) + self.assertEqual(cap.port, 40404) + self.assertTrue(cap.read()[0]) + cap.release() + + def test_packet_gap_drops_array_and_recovers_at_next_start(self): + data = frame_array() + broken = packet(2, 1, 0, len(data)) + packet(0, 1, 2, 0, data) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "recover.dump" + path.write_bytes(broken + dump_for(data)) + cap = UdpDumpCapture(path) + self.assertTrue(cap.isOpened()) + self.assertEqual(cap.dropped_arrays, 1) + cap.release() + + def test_truncated_packet_fails_without_exception(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "broken.dump" + path.write_bytes((59004).to_bytes(2, "little") + b"\x10\x00\x00") + cap = UdpDumpCapture(path) + self.assertFalse(cap.isOpened()) + self.assertIn("truncated", cap.last_error) + + def test_unknown_port_is_not_opened(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "not-a-dump" + path.write_bytes(b"nope") + cap = UdpDumpCapture(path) + self.assertFalse(cap.isOpened()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_udp_probe.py b/tests/test_udp_probe.py new file mode 100644 index 0000000..0cf89e2 --- /dev/null +++ b/tests/test_udp_probe.py @@ -0,0 +1,74 @@ +import tempfile +import unittest +from pathlib import Path + +from udp_probe import analyze_udp_records, save_udp_records + + +def mik_payload(flags, sequence, packet_number, value, data=b""): + return bytes((0, flags, sequence, packet_number)) + value.to_bytes(4, "little") + data + + +def mik_frame(width=4, height=2): + pixels = bytes(range(width * height)) + image_header = width.to_bytes(2, "little") + height.to_bytes(2, "little") + bytes((1, 0, 0, 0)) + return (0).to_bytes(4, "little") + image_header + pixels + + +def record(index, payload, source=("192.168.0.10", 40000)): + return { + "timestamp_ns": 1_000_000_000 + index * 1_000_000, + "address": source, + "payload": payload, + } + + +class UdpProbeTests(unittest.TestCase): + def test_detects_complete_mik_video_array(self): + frame = mik_frame() + split = 9 + payloads = [ + mik_payload(2, 7, 0, len(frame)), + mik_payload(0, 7, 1, 0, frame[:split]), + mik_payload(0, 7, 2, split, frame[split:]), + mik_payload(1, 7, 3, len(frame)), + ] + + result = analyze_udp_records([record(index, payload) for index, payload in enumerate(payloads)]) + + self.assertEqual(result["detected"]["kind"], "mik_video") + self.assertEqual(result["detected"]["confidence"], 100) + self.assertEqual(result["detected"]["frame"]["width"], 4) + self.assertEqual(result["detected"]["recommended"]["source_mode"], "udp_mik_live") + + def test_detects_selected_raw_frame_with_separator_packet(self): + payloads = [b"\x01\x02\x03\x04", b"\x05\x06\x07\x08", b"\xff"] + result = analyze_udp_records( + [record(index, payload) for index, payload in enumerate(payloads)], + width=4, + height=2, + separator=255, + frame_encoding="gray8", + ) + + self.assertEqual(result["detected"]["kind"], "raw_delimited") + self.assertEqual(result["detected"]["confidence"], 99) + self.assertEqual(result["detected"]["recommended"]["frame_encoding"], "gray8") + + def test_saved_dump_preserves_every_payload_byte(self): + records = [record(0, b"\x00\x01"), record(1, b"\xfe\xff")] + with tempfile.TemporaryDirectory() as tmp: + dump_path, report_path, report = save_udp_records(records, 40404, Path(tmp)) + expected = ( + (40404).to_bytes(2, "little") + (2).to_bytes(2, "little") + b"\x00\x01" + + (40404).to_bytes(2, "little") + (2).to_bytes(2, "little") + b"\xfe\xff" + ) + + self.assertEqual(dump_path.read_bytes(), expected) + self.assertTrue(report_path.is_file()) + self.assertEqual(report["dump_size"], len(expected)) + self.assertEqual(len(report["dump_sha256"]), 64) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ui_server.py b/tests/test_ui_server.py new file mode 100644 index 0000000..fcc4879 --- /dev/null +++ b/tests/test_ui_server.py @@ -0,0 +1,444 @@ +import unittest +import os +import sys +import types +from io import BytesIO +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import ui_server +from ui_server import HTML, INPUT_EXTENSIONS, MAX_JSON_BODY_BYTES, active_video_name, archive_files, archive_path, camera_bridge_source, cleanup_empty_recordings, content_disposition, copy_exact, h264_cache_path, input_upload_path, latest_file, model_upload_path, normalize_control, parse_perf, parse_quality, parse_range_header, parse_source, read_json_file, resolve_model_path, tail_text, udp_probe_path + + +class UIServerTests(unittest.TestCase): + def test_camera_bridge_source_contains_requested_capture_mode(self): + source = camera_bridge_source( + "http://host.docker.internal:8091/stream.mjpg", + 0, + 1920, + 1080, + 30, + ) + self.assertIn("index=0", source) + self.assertIn("width=1920", source) + self.assertIn("height=1080", source) + self.assertIn("fps=30", source) + + def test_html_has_separate_settings_and_log_drawer(self): + self.assertIn('id="settingsTab"', HTML) + self.assertIn('data-tab="settings"', HTML) + self.assertIn('id="logDrawer"', HTML) + self.assertIn('value="guidance_v1"', HTML) + self.assertIn('id="errorObjectId"', HTML) + self.assertIn('id="errorHost"', HTML) + self.assertIn('placeholder="192.168.1.10"', HTML) + self.assertIn('src = \'/stream.mjpg?t=\'', HTML) + self.assertIn('if (streamRunning && !wasRunning) reconnectFrame();', HTML) + self.assertIn('', HTML) + self.assertIn('', HTML) + self.assertIn('id="packetConstructorFields"', HTML) + self.assertIn('id="packetByteMap"', HTML) + self.assertIn('id="packetFieldList"', HTML) + self.assertIn('id="addPacketField"', HTML) + self.assertIn('data-action="remove"', HTML) + self.assertIn('id="packetHeaderSize"', HTML) + self.assertIn("postJson('/api/udp-probe'", HTML) + self.assertIn('Адрес привязки (обычно 0.0.0.0)', HTML) + + def test_custom_udp_packet_schema_is_normalized(self): + state = normalize_control({ + "source_mode": "udp_custom_live", + "packet_preset": "custom", + "packet_schema": { + "assembly": "datagram", + "header_size": 12, + "flags_offset": 99, + "start_mask": "0x80", + }, + }) + self.assertEqual(state["packet_preset"], "custom") + self.assertEqual(state["packet_schema"]["assembly"], "datagram") + self.assertEqual(state["packet_schema"]["header_size"], 12) + self.assertEqual(state["packet_schema"]["flags_offset"], -1) + self.assertEqual(state["packet_schema"]["start_mask"], 0x80) + + def test_visual_packet_layout_controls_parser_offsets(self): + state = normalize_control({ + "source_mode": "udp_custom_live", + "packet_preset": "custom", + "packet_layout": [ + {"role": "skip", "size": 2, "label": "magic"}, + {"role": "field", "size": 2, "label": "temperature"}, + {"role": "flags", "size": 1, "label": "flags"}, + {"role": "sequence", "size": 3, "label": "frame"}, + ], + }) + self.assertEqual(state["packet_schema"]["header_size"], 8) + self.assertEqual(state["packet_schema"]["flags_offset"], 4) + self.assertEqual(state["packet_schema"]["sequence_offset"], 5) + self.assertEqual(state["packet_schema"]["sequence_size"], 3) + self.assertEqual( + state["packet_schema"]["read_fields"], + [{"name": "temperature", "offset": 2, "size": 2}], + ) + + def test_custom_frame_size_and_fps_are_supported(self): + state = normalize_control({"quality": "1536x864", "fps": 47}) + self.assertEqual(state["quality"], "1536x864") + self.assertEqual(state["fps"], 47) + self.assertEqual(parse_quality("9000x2"), (8192, 16)) + self.assertIn('id="frameWidth"', HTML) + self.assertIn('id="frameHeight"', HTML) + self.assertIn('id="fps" type="number"', HTML) + self.assertIn('', HTML) + self.assertIn('udp_raw_gray16_40404', HTML) + + def test_large_upload_uses_streaming_ui_with_progress(self): + self.assertIn("new XMLHttpRequest()", HTML) + self.assertIn('id="uploadProgress"', HTML) + self.assertIn('id="cancelUpload"', HTML) + self.assertNotIn('accept=".mp4,.avi,.mov,.mkv,.m4v"', HTML) + self.assertNotIn("new FormData()", HTML) + + def test_copy_exact_streams_only_requested_bytes(self): + source = BytesIO(b"abcdefgh") + target = BytesIO() + self.assertEqual(copy_exact(source, target, 6, chunk_size=2), 6) + self.assertEqual(target.getvalue(), b"abcdef") + self.assertEqual(source.read(), b"gh") + + def test_upload_path_accepts_extensionless_dump_and_rejects_traversal(self): + self.assertEqual(input_upload_path("1785156788883112336").name, "1785156788883112336") + self.assertEqual(input_upload_path("camera.vendor-format").name, "camera.vendor-format") + self.assertIsNone(input_upload_path("../dump")) + self.assertIsNone(input_upload_path(r"..\dump")) + + def test_input_list_keeps_unknown_video_and_treats_transport_stream_as_video(self): + from tempfile import TemporaryDirectory + + old_input = ui_server.INPUT_DIR + try: + with TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "clip.ts").write_bytes(b"ts") + (root / "clip.vendor").write_bytes(b"video") + (root / "extensionless-video").write_bytes(b"\x1aE\xdf\xa3video") + mik_header = ( + (59004).to_bytes(2, "little") + + (8).to_bytes(2, "little") + + bytes((0, 2, 7, 0)) + + (256).to_bytes(4, "little") + ) + (root / "extensionless-mik").write_bytes(mik_header) + (root / "camera.udp").write_bytes(b"dump") + (root / ".partial.upload").write_bytes(b"partial") + ui_server.INPUT_DIR = root + + rows = {row["name"]: row["kind"] for row in ui_server.input_video_files()} + + self.assertEqual(rows["clip.ts"], "video") + self.assertEqual(rows["clip.vendor"], "video") + self.assertEqual(rows["extensionless-video"], "video") + self.assertEqual(rows["extensionless-mik"], "udp_dump") + self.assertEqual(rows["camera.udp"], "udp_dump") + self.assertNotIn(".partial.upload", rows) + finally: + ui_server.INPUT_DIR = old_input + + def test_tail_text_returns_last_lines(self): + from tempfile import TemporaryDirectory + from pathlib import Path + + with TemporaryDirectory() as tmp: + log = Path(tmp) / "main.log" + log.write_text("one\ntwo\nthree\n", encoding="utf-8") + self.assertEqual(tail_text(log, 2), "two\nthree") + + def test_read_json_file_returns_empty_dict_for_missing_file(self): + from tempfile import TemporaryDirectory + from pathlib import Path + + with TemporaryDirectory() as tmp: + self.assertEqual(read_json_file(Path(tmp) / "missing.json"), {}) + + def test_latest_file_returns_newest_match(self): + from tempfile import TemporaryDirectory + from pathlib import Path + import os + + with TemporaryDirectory() as tmp: + root = Path(tmp) + old = root / "out_1.mp4" + new = root / "out_2.mp4" + old.write_text("old", encoding="utf-8") + new.write_text("new", encoding="utf-8") + os.utime(old, (1, 1)) + os.utime(new, (2, 2)) + self.assertEqual(latest_file(root, "*.mp4"), new) + + def test_archive_path_rejects_path_traversal(self): + from tempfile import TemporaryDirectory + from pathlib import Path + + with TemporaryDirectory() as tmp: + root = Path(tmp) + self.assertIsNone(archive_path(root, "../evil.mp4")) + + def test_archive_path_accepts_url_encoded_name(self): + from tempfile import TemporaryDirectory + from pathlib import Path + + with TemporaryDirectory() as tmp: + root = Path(tmp) + self.assertEqual(archive_path(root, "clip%201.mp4"), root / "clip 1.mp4") + + def test_archive_path_rejects_symlink_outside_archive(self): + from tempfile import TemporaryDirectory + + with TemporaryDirectory() as tmp: + root = Path(tmp) / "archive" + outside = Path(tmp) / "secret.mp4" + root.mkdir() + outside.write_bytes(b"secret") + link = root / "public.mp4" + try: + os.symlink(outside, link) + except (OSError, NotImplementedError): + self.skipTest("symlinks unavailable") + self.assertIsNone(archive_path(root, "public.mp4")) + + def test_json_body_limit_rejects_oversized_request(self): + handler = ui_server.Handler.__new__(ui_server.Handler) + handler.headers = {"Content-Length": str(MAX_JSON_BODY_BYTES + 1)} + with self.assertRaisesRegex(ValueError, "too large"): + handler.read_json_body() + + def test_udp_probe_path_accepts_capture_and_rejects_traversal(self): + from tempfile import TemporaryDirectory + + with TemporaryDirectory() as tmp: + root = Path(tmp) + self.assertEqual(udp_probe_path(root, "capture.udp"), root / "capture.udp") + self.assertEqual(udp_probe_path(root, "capture.json"), root / "capture.json") + self.assertIsNone(udp_probe_path(root, "../capture.udp")) + self.assertIsNone(udp_probe_path(root, "capture.mp4")) + + def test_archive_files_lists_mp4_metadata(self): + from tempfile import TemporaryDirectory + from pathlib import Path + import os + + with TemporaryDirectory() as tmp: + root = Path(tmp) + video = root / "clip.mp4" + video.write_bytes(b"1234") + os.utime(video, (3, 3)) + + self.assertEqual(archive_files(root)[0]["name"], "clip.mp4") + self.assertEqual(archive_files(root)[0]["size"], 4) + + def test_cleanup_empty_recordings_removes_only_broken_mp4(self): + from tempfile import TemporaryDirectory + + with TemporaryDirectory() as tmp: + root = Path(tmp) + broken = root / "broken.mp4" + valid = root / "valid.mp4" + broken.write_bytes(b"x" * 44) + valid.write_bytes(b"x" * 1024) + + self.assertEqual(cleanup_empty_recordings(root), 1) + self.assertFalse(broken.exists()) + self.assertTrue(valid.exists()) + + def test_h264_cache_path_tracks_source_version(self): + from tempfile import TemporaryDirectory + from pathlib import Path + + with TemporaryDirectory() as tmp: + root = Path(tmp) + video = root / "clip.mp4" + video.write_bytes(b"1234") + + cache = h264_cache_path(video, root / ".downloads") + self.assertEqual(cache.parent, root / ".downloads") + self.assertTrue(cache.name.endswith(".h264.mp4")) + self.assertIn("clip", cache.name) + self.assertIn("4", cache.name) + + def test_content_disposition_keeps_utf8_filename(self): + header = content_disposition("тест.mp4") + self.assertIn("attachment;", header) + self.assertIn("filename*=", header) + self.assertIn("%D1%82%D0%B5%D1%81%D1%82.mp4", header) + + def test_active_video_name_uses_marker_only_when_file_exists(self): + from tempfile import TemporaryDirectory + from pathlib import Path + + with TemporaryDirectory() as tmp: + root = Path(tmp) + marker = root / ".active_video" + marker.write_text("clip.mp4", encoding="utf-8") + self.assertIsNone(active_video_name(root, marker)) + (root / "clip.mp4").write_bytes(b"1234") + self.assertEqual(active_video_name(root, marker), "clip.mp4") + + def test_control_payload_clears_stale_active_marker(self): + from tempfile import TemporaryDirectory + + old_out = ui_server.OUT_DIR + old_marker = ui_server.ACTIVE_VIDEO_PATH + old_process = ui_server.CONTROL_PROCESS + try: + with TemporaryDirectory() as tmp: + root = Path(tmp) + marker = root / ".active_video" + (root / "stale.mp4").write_bytes(b"1234") + marker.write_text("stale.mp4", encoding="utf-8") + ui_server.OUT_DIR = root + ui_server.ACTIVE_VIDEO_PATH = marker + ui_server.CONTROL_PROCESS = None + + payload = ui_server.control_payload() + + self.assertFalse(payload["running"]) + self.assertEqual(payload["active_video"], "") + self.assertFalse(marker.exists()) + finally: + ui_server.OUT_DIR = old_out + ui_server.ACTIVE_VIDEO_PATH = old_marker + ui_server.CONTROL_PROCESS = old_process + + def test_parse_range_header_supports_suffix_range(self): + self.assertEqual(parse_range_header("bytes=-4", 10), (6, 9)) + + def test_parse_range_header_rejects_out_of_range(self): + self.assertIsNone(parse_range_header("bytes=20-30", 10)) + + def test_parse_source_reads_opened_source_log(self): + log = "[entrypoint] source=0 backend=json\nOpened source: 0 (camera)\n" + self.assertEqual(parse_source(log), "0 (camera)") + + def test_parse_perf_reads_realtime_pass_counters(self): + log = "[perf] fps~58.0 iter p50=3.0 p95=28.0 | yolo p50=25.0 p95=34.0 skip=4 pass=120 analysisEvery=2\n" + perf = parse_perf(log) + self.assertEqual(perf["fps"], "58.0") + self.assertEqual(perf["skip"], "4") + self.assertEqual(perf["pass"], "120") + self.assertEqual(perf["analysis_every"], "2") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_yolo_worker_lifecycle.py b/tests/test_yolo_worker_lifecycle.py new file mode 100644 index 0000000..2659065 --- /dev/null +++ b/tests/test_yolo_worker_lifecycle.py @@ -0,0 +1,77 @@ +import time +import unittest +from unittest.mock import patch + +import numpy as np +import torch + +from helpers import filter_yolo_boxes_with_scores +from yolo_worker import INFERENCE_DEVICE, INFERENCE_HALF, INFERENCE_SIZE_FULL, YOLOWorker, fixed_letterbox, raw_yolo_boxes + + +class FailingModel: + def __call__(self, *args, **kwargs): + raise RuntimeError("test failure") + + +class Boxes: + xyxy = torch.tensor([[0.0, 160.0, 640.0, 480.0]]) + conf = torch.tensor([0.9]) + cls = torch.tensor([0.0]) + + def __len__(self): + return 1 + + +class Result: + boxes = Boxes() + + +class YOLOWorkerLifecycleTests(unittest.TestCase): + def test_cpu_inference_uses_cpu_precision_and_workable_size(self): + if torch.cuda.is_available(): + self.skipTest("CPU fallback is inactive on CUDA hosts") + self.assertEqual(INFERENCE_DEVICE, "cpu") + self.assertFalse(INFERENCE_HALF) + self.assertLessEqual(INFERENCE_SIZE_FULL, 640) + + def test_fixed_letterbox_maps_boxes_back_without_distortion(self): + image = np.zeros((100, 200, 3), dtype=np.uint8) + boxed, scale, pad_x, pad_y = fixed_letterbox(image, 640) + self.assertEqual(boxed.shape, (640, 640, 3)) + raw = raw_yolo_boxes(Result(), scale=1.0 / scale, pad_x=pad_x, pad_y=pad_y) + np.testing.assert_allclose(raw[0][:4], [0.0, 0.0, 200.0, 100.0]) + filtered = filter_yolo_boxes_with_scores( + Result(), + frame_w=200, + frame_h=100, + min_conf=0.1, + input_scale=scale, + pad_x=pad_x, + pad_y=pad_y, + content_w=200, + content_h=100, + ) + np.testing.assert_allclose(filtered[0][:4], [0.0, 0.0, 200.0, 100.0]) + + def test_worker_reports_failed_inference_without_hanging(self): + worker = YOLOWorker(FailingModel()) + with patch("yolo_worker.torch.cuda.is_available", return_value=False): + worker.start() + try: + worker.submit(np.zeros((16, 16, 3), dtype=np.uint8), None, "FULL", 1.0) + deadline = time.monotonic() + 2.0 + result = None + while result is None and time.monotonic() < deadline: + result = worker.try_get() + time.sleep(0.01) + self.assertIsNotNone(result) + self.assertEqual(result[0], []) + self.assertEqual(result[1], 1.0) + finally: + worker.stop() + self.assertFalse(worker.thread.is_alive()) + + +if __name__ == "__main__": + unittest.main() diff --git a/track_score_policy.py b/track_score_policy.py index 660426b..4fd6bbf 100644 --- a/track_score_policy.py +++ b/track_score_policy.py @@ -36,3 +36,23 @@ def track_passes_score_gate( reacquire_floor=reacquire_floor, switch_floor=switch_floor, ) + + +def initial_candidate_score( + *, + track_score, + track_hits, + residual_motion, + appearance=0.0, + wavelet_bonus=0.0, + physics_bonus=0.0, +): + persistence = min(max(int(track_hits) - 1, 0), 5) / 5.0 + return ( + 0.55 * float(track_score) + + 0.15 * persistence + + 0.12 * float(bool(residual_motion)) + + 0.90 * float(appearance) + + float(wavelet_bonus) + + float(physics_bonus) + ) diff --git a/udp_dump_capture.py b/udp_dump_capture.py new file mode 100644 index 0000000..1ee3165 --- /dev/null +++ b/udp_dump_capture.py @@ -0,0 +1,339 @@ +import socket +from pathlib import Path + +import cv2 +import numpy as np + + +class MikPacketAssembler: + HEADER_SIZE = 8 + FLAG_END = 0x01 + FLAG_START = 0x02 + MAX_ARRAY_SIZE = 256 * 1024 * 1024 + + def __init__(self): + self.current = None + self.expected_packet = 0 + self.expected_offset = 0 + self.dropped_arrays = 0 + + def push(self, payload): + if len(payload) < self.HEADER_SIZE: + raise ValueError("UDP payload is shorter than the MIK packet header") + flags = payload[1] + sequence = payload[2] + packet_number = payload[3] + value = int.from_bytes(payload[4:8], "little") + packet_data = payload[8:] + + if flags & self.FLAG_START: + if self.current is not None: + self.dropped_arrays += 1 + if value <= 0 or value > self.MAX_ARRAY_SIZE: + self.current = None + return None + self.current = { + "sequence": sequence, + "data": bytearray(value), + "size": value, + } + self.expected_packet = (packet_number + 1) & 0xFF + self.expected_offset = min(len(packet_data), value) + self.current["data"][:self.expected_offset] = packet_data[:self.expected_offset] + if flags & self.FLAG_END and self.expected_offset == value: + result = bytes(self.current["data"]) + self.current = None + return result + return None + + if self.current is None or sequence != self.current["sequence"]: + return None + if packet_number != self.expected_packet or value != self.expected_offset: + self.dropped_arrays += 1 + self.current = None + return None + + self.expected_packet = (self.expected_packet + 1) & 0xFF + end = self.expected_offset + len(packet_data) + if end > self.current["size"]: + self.dropped_arrays += 1 + self.current = None + return None + self.current["data"][self.expected_offset:end] = packet_data + self.expected_offset = end + + if flags & self.FLAG_END: + if self.expected_offset == self.current["size"]: + result = bytes(self.current["data"]) + self.current = None + return result + self.dropped_arrays += 1 + self.current = None + return None + + +class UdpDumpCapture: + """VideoCapture-compatible reader for framed MIK UDP packet logs.""" + + PORT = 59004 + PACKET_HEADER_SIZE = 8 + LABEL_SIZE = 40 + FLAG_END = 0x01 + FLAG_START = 0x02 + PIXEL_GRAY8 = 0x01 + PIXEL_GRAY16 = 0x02 + PIXEL_RGB888 = 0x03 + PIXEL_YCBCR422 = 0x0A + PIXEL_INT16 = 0x12 + MAX_ARRAY_SIZE = 256 * 1024 * 1024 + + def __init__(self, path, fps=30.0, port=None): + self.path = Path(path) + self.port = int(port) if port is not None else None + self._init_decoder(fps) + self._file = None + self._next_frame = None + self._assembler = MikPacketAssembler() + try: + self._file = self.path.open("rb", buffering=8 * 1024 * 1024) + envelope = self._file.read(4) + if len(envelope) != 4: + raise ValueError("not a framed UDP packet log") + observed_port = int.from_bytes(envelope[:2], "little") + if observed_port <= 0 or (self.port is not None and observed_port != self.port): + raise ValueError(f"unexpected UDP port: {observed_port}") + self.port = observed_port + self._file.seek(0) + self._next_frame = self._read_frame() + if self._next_frame is None: + raise ValueError(self.last_error or "no complete video frame in UDP log") + except (OSError, ValueError) as exc: + self.last_error = str(exc) + self.release() + + def _init_decoder(self, fps, width=0, height=0): + self.fps = max(1.0, float(fps)) + self.width = max(0, int(width)) + self.height = max(0, int(height)) + self.pixel_id = 0 + self.row_padding = 0 + self.frames_read = 0 + self.dropped_arrays = 0 + self.last_labels = [] + self.last_error = "" + self._contrast = None + + def isOpened(self): + return self._file is not None + + def _packet(self): + envelope = self._file.read(4) + if not envelope: + return None + if len(envelope) != 4: + raise ValueError("truncated UDP log envelope") + port = int.from_bytes(envelope[:2], "little") + size = int.from_bytes(envelope[2:4], "little") + if self.port is None: + self.port = port + elif port != self.port: + raise ValueError(f"unexpected UDP port: {port}") + if size < self.PACKET_HEADER_SIZE: + raise ValueError(f"invalid UDP payload size: {size}") + payload = self._file.read(size) + if len(payload) != size: + raise ValueError("truncated UDP packet") + return payload + + def _array(self): + while True: + payload = self._packet() + if payload is None: + return None + dropped_before = self._assembler.dropped_arrays + data = self._assembler.push(payload) + self.dropped_arrays += self._assembler.dropped_arrays - dropped_before + if data is not None: + return data + + def _mono_to_bgr(self, raw, invalid_value=None): + valid = np.ones(raw.shape, dtype=bool) if invalid_value is None else raw != invalid_value + sample = raw[::4, ::4][valid[::4, ::4]] + if sample.size < 16: + sample = raw[valid] + low, high = np.percentile(sample, (1.0, 99.0)) if sample.size else (0.0, 1.0) + if high <= low: + high = low + 1.0 + if self._contrast is None: + self._contrast = (float(low), float(high)) + else: + old_low, old_high = self._contrast + self._contrast = (0.9 * old_low + 0.1 * low, 0.9 * old_high + 0.1 * high) + low, high = self._contrast + gray = np.clip((raw.astype(np.float32) - low) * (255.0 / (high - low)), 0, 255).astype(np.uint8) + if not valid.all(): + median = cv2.medianBlur(gray, 3) + gray[~valid] = median[~valid] + return cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR) + + def _decode_array(self, data): + if len(data) < 12: + raise ValueError("UDP data array is too short") + label_count = int.from_bytes(data[:4], "little") + video_offset = 4 + label_count * self.LABEL_SIZE + if video_offset + 8 > len(data): + raise ValueError("invalid label array size") + self.last_labels = [ + data[4 + index * self.LABEL_SIZE:4 + (index + 1) * self.LABEL_SIZE] + for index in range(label_count) + ] + + header = data[video_offset:video_offset + 8] + width = int.from_bytes(header[0:2], "little") + height = int.from_bytes(header[2:4], "little") + pixel_id = header[4] + padding = header[6] + bytes_per_pixel = { + self.PIXEL_GRAY8: 1, + self.PIXEL_GRAY16: 2, + self.PIXEL_RGB888: 3, + self.PIXEL_YCBCR422: 2, + self.PIXEL_INT16: 2, + }.get(pixel_id) + if width <= 0 or height <= 0 or bytes_per_pixel is None: + raise ValueError(f"unsupported video format: {width}x{height}, pixel_id=0x{pixel_id:02x}") + + row_bytes = width * bytes_per_pixel + stride = row_bytes + padding + pixels_offset = video_offset + 8 + pixels_end = pixels_offset + stride * height + if pixels_end > len(data): + raise ValueError("truncated video frame") + rows = np.frombuffer(data[pixels_offset:pixels_end], dtype=np.uint8).reshape(height, stride) + pixels = rows[:, :row_bytes].copy() + + if pixel_id == self.PIXEL_GRAY8: + frame = self._mono_to_bgr(pixels.reshape(height, width)) + elif pixel_id == self.PIXEL_GRAY16: + frame = self._mono_to_bgr(pixels.view(" 1_000_000 or video_offset + 8 > len(data): + return None + header = data[video_offset:video_offset + 8] + width = int.from_bytes(header[:2], "little") + height = int.from_bytes(header[2:4], "little") + pixel_id = header[4] + padding = header[6] + bytes_per_pixel = MIK_PIXEL_BYTES.get(pixel_id) + if not bytes_per_pixel or not (1 <= width <= 8192 and 1 <= height <= 8192): + return None + expected = video_offset + 8 + (width * bytes_per_pixel + padding) * height + if expected > len(data): + return None + return { + "width": width, + "height": height, + "pixel_id": pixel_id, + "row_padding": padding, + "labels": label_count, + "array_bytes": len(data), + "expected_bytes": expected, + } + + +def _mik_candidate(payloads): + assembler = MikPacketAssembler() + header_count = 0 + starts = 0 + ends = 0 + arrays = [] + for payload in payloads: + if len(payload) < 8: + continue + flags = payload[1] + packet_number = payload[3] + value = int.from_bytes(payload[4:8], "little") + if flags & ~0x03: + continue + if flags & 0x02: + if packet_number != 0 or value <= 0 or value > MAX_MIK_ARRAY: + continue + starts += 1 + elif value > MAX_MIK_ARRAY: + continue + header_count += 1 + ends += int(bool(flags & 0x01)) + try: + array = assembler.push(payload) + except ValueError: + continue + if array is not None: + arrays.append(array) + + frames = [frame for frame in map(inspect_mik_array, arrays) if frame] + if frames: + frame = frames[0] + return { + "kind": "mik_video", + "confidence": 100, + "evidence": { + "matching_headers": header_count, + "start_packets": starts, + "end_packets": ends, + "complete_arrays": len(arrays), + "valid_video_arrays": len(frames), + "dropped_arrays": assembler.dropped_arrays, + }, + "frame": frame, + "recommended": { + "source_mode": "udp_mik_live", + "quality": f"{frame['width']}x{frame['height']}", + }, + } + ratio = header_count / max(1, len(payloads)) + if header_count >= 3 and ratio >= 0.7 and starts: + return { + "kind": "mik_fragments", + "confidence": min(92, round(65 + ratio * 25)), + "evidence": { + "matching_headers": header_count, + "start_packets": starts, + "end_packets": ends, + "complete_arrays": len(arrays), + "dropped_arrays": assembler.dropped_arrays, + }, + "recommended": {"source_mode": "udp_mik_live"}, + } + return None + + +def _rtp_parts(payload): + if len(payload) < 12 or payload[0] >> 6 != 2: + return None + cc = payload[0] & 0x0F + offset = 12 + cc * 4 + if offset > len(payload): + return None + if payload[0] & 0x10: + if offset + 4 > len(payload): + return None + words = int.from_bytes(payload[offset + 2:offset + 4], "big") + offset += 4 + words * 4 + if offset > len(payload): + return None + return { + "payload_type": payload[1] & 0x7F, + "sequence": int.from_bytes(payload[2:4], "big"), + "timestamp": int.from_bytes(payload[4:8], "big"), + "ssrc": int.from_bytes(payload[8:12], "big"), + "payload": payload[offset:], + } + + +def _is_mpeg_ts(data): + return len(data) >= 188 and len(data) % 188 == 0 and all( + data[index] == 0x47 for index in range(0, len(data), 188) + ) + + +def _rtp_candidate(payloads): + headers = [header for header in map(_rtp_parts, payloads) if header] + if len(headers) < 2 or len(headers) / max(1, len(payloads)) < 0.8: + return None + ssrc, ssrc_count = Counter(item["ssrc"] for item in headers).most_common(1)[0] + payload_type, type_count = Counter(item["payload_type"] for item in headers).most_common(1)[0] + sequential = sum( + ((current["sequence"] - previous["sequence"]) & 0xFFFF) == 1 + for previous, current in zip(headers, headers[1:]) + ) + ts_packets = sum(_is_mpeg_ts(item["payload"]) for item in headers) + confidence = 75 + if ssrc_count / len(headers) >= 0.9 and type_count / len(headers) >= 0.9: + confidence += 10 + if sequential / max(1, len(headers) - 1) >= 0.7: + confidence += 10 + return { + "kind": "rtp_mpeg_ts" if ts_packets else "rtp", + "confidence": min(98, confidence), + "evidence": { + "rtp_packets": len(headers), + "payload_type": payload_type, + "ssrc": f"0x{ssrc:08x}", + "sequential_pairs": sequential, + "mpeg_ts_payloads": ts_packets, + }, + } + + +def _encoded_candidate(payloads): + ts_packets = sum(_is_mpeg_ts(payload) for payload in payloads) + if ts_packets and ts_packets / len(payloads) >= 0.7: + return { + "kind": "mpeg_ts", + "confidence": 99, + "evidence": {"mpeg_ts_datagrams": ts_packets}, + } + + jpeg = sum( + payload.startswith(b"\xff\xd8\xff") and payload.rstrip().endswith(b"\xff\xd9") + for payload in payloads + ) + png = sum( + payload.startswith(b"\x89PNG\r\n\x1a\n") and b"IEND" in payload[-32:] + for payload in payloads + ) + if jpeg or png: + kind = "jpeg" if jpeg >= png else "png" + count = max(jpeg, png) + return { + "kind": kind, + "confidence": 100, + "evidence": {"complete_images": count}, + "recommended": { + "source_mode": "udp_delimited_live", + "frame_encoding": "auto", + }, + } + + start_code_packets = 0 + h264_packets = 0 + h265_packets = 0 + for payload in payloads: + offset = 4 if payload.startswith(b"\x00\x00\x00\x01") else 3 + if offset == 3 and not payload.startswith(b"\x00\x00\x01"): + continue + if len(payload) <= offset: + continue + start_code_packets += 1 + h264_packets += int(1 <= (payload[offset] & 0x1F) <= 23) + h265_packets += int(((payload[offset] >> 1) & 0x3F) <= 40) + if start_code_packets: + kind = "h264_annex_b" if h264_packets >= h265_packets else "h265_annex_b" + return { + "kind": kind, + "confidence": 90, + "evidence": {"start_code_datagrams": start_code_packets}, + } + return None + + +def _raw_candidate(payloads, width, height, separator, configured_encoding): + if width <= 0 or height <= 0: + return None + separator_payload = bytes((separator & 0xFF,)) + groups = [] + current = 0 + separator_packets = 0 + for payload in payloads: + if payload == separator_payload: + separator_packets += 1 + if current: + groups.append(current) + current = 0 + else: + current += len(payload) + if current: + groups.append(current) + + encodings = ( + {configured_encoding: RAW_ENCODINGS[configured_encoding]} + if configured_encoding in RAW_ENCODINGS + else RAW_ENCODINGS + ) + matches = [] + total = sum(len(payload) for payload in payloads if payload != separator_payload) + for encoding, bytes_per_pixel in encodings.items(): + expected = width * height * bytes_per_pixel + exact_groups = sum(size == expected for size in groups) + complete_frames = total // expected + remainder = total % expected + if exact_groups: + confidence = 99 + elif separator_packets and complete_frames and remainder <= max(map(len, payloads)): + confidence = 78 + elif not separator_packets and complete_frames: + confidence = 55 + else: + continue + matches.append((confidence, encoding, expected, exact_groups, complete_frames, remainder)) + if not matches: + return None + + matches.sort(reverse=True) + confidence, encoding, expected, exact_groups, complete_frames, remainder = matches[0] + same_size = sorted({ + candidate_encoding + for _, candidate_encoding, candidate_size, *_ in matches + if candidate_size == expected + }) + ambiguous = len(same_size) > 1 and configured_encoding not in RAW_ENCODINGS + recommended = { + "source_mode": "udp_delimited_live", + "quality": f"{width}x{height}", + "separator_byte": separator, + } + if not ambiguous: + recommended["frame_encoding"] = encoding + return { + "kind": "raw_delimited" if separator_packets else "raw_stream", + "confidence": confidence, + "evidence": { + "separator_packets": separator_packets, + "expected_frame_bytes": expected, + "exact_frame_groups": exact_groups, + "complete_frame_equivalents": complete_frames, + "trailing_bytes": remainder, + "possible_encodings": same_size, + }, + "frame": {"width": width, "height": height, "encoding": encoding}, + "recommended": recommended, + } + + +def _text_candidate(payloads): + if not payloads: + return None + sample = payloads[0][:8192] + try: + text = sample.decode("utf-8") + except UnicodeDecodeError: + return None + printable = sum(character.isprintable() or character in "\r\n\t" for character in text) + if not text or printable / len(text) < 0.9: + return None + try: + json.loads(text) + kind = "json" + confidence = 100 + except json.JSONDecodeError: + kind = "text" + confidence = 85 + return { + "kind": kind, + "confidence": confidence, + "evidence": {"preview": text[:160]}, + } + + +def analyze_udp_records(records, width=0, height=0, separator=0, frame_encoding="auto"): + payloads = [record["payload"] for record in records] + candidates = [ + candidate + for candidate in ( + _mik_candidate(payloads), + _rtp_candidate(payloads), + _encoded_candidate(payloads), + _raw_candidate(payloads, int(width), int(height), int(separator), frame_encoding), + _text_candidate(payloads), + ) + if candidate is not None + ] + candidates.sort(key=lambda candidate: candidate["confidence"], reverse=True) + detected = candidates[0] if candidates else { + "kind": "unknown", + "confidence": 0, + "evidence": {"reason": "no known structure matched"}, + } + return {"detected": detected, "candidates": candidates} + + +def capture_udp_records(host, port, duration=3.0, max_packets=4096, max_bytes=32 * 1024 * 1024): + host = str(host or "0.0.0.0").strip() or "0.0.0.0" + port = max(1, min(65535, int(port))) + duration = max(0.2, min(15.0, float(duration))) + max_packets = max(1, min(32768, int(max_packets))) + max_bytes = max(MAX_UDP_PAYLOAD, min(256 * 1024 * 1024, int(max_bytes))) + records = [] + total = 0 + truncated = False + started = time.monotonic() + deadline = started + duration + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 16 * 1024 * 1024) + sock.bind((host, port)) + bound_host, bound_port = sock.getsockname() + while len(records) < max_packets: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + sock.settimeout(min(0.25, remaining)) + try: + payload, address = sock.recvfrom(MAX_UDP_PAYLOAD) + except socket.timeout: + continue + if total + len(payload) > max_bytes: + truncated = True + break + records.append({ + "timestamp_ns": time.time_ns(), + "address": (str(address[0]), int(address[1])), + "payload": payload, + }) + total += len(payload) + truncated = truncated or len(records) >= max_packets + finally: + sock.close() + return records, { + "listen_host": bound_host, + "listen_port": bound_port, + "elapsed_sec": round(time.monotonic() - started, 3), + "truncated": truncated, + } + + +def save_udp_records(records, destination_port, output_dir): + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + stamp = time.strftime("%Y%m%d_%H%M%S") + suffix = f"{time.time_ns() % 1_000_000_000:09d}" + dump_path = output_dir / f"udp_probe_{stamp}_{suffix}.udp" + report_path = dump_path.with_suffix(".json") + digest = hashlib.sha256() + packet_meta = [] + offset = 0 + with dump_path.open("wb") as stream: + for index, record in enumerate(records): + payload = record["payload"] + envelope = int(destination_port).to_bytes(2, "little") + len(payload).to_bytes(2, "little") + stream.write(envelope) + stream.write(payload) + digest.update(envelope) + digest.update(payload) + packet_meta.append({ + "index": index, + "timestamp_ns": record["timestamp_ns"], + "source_ip": record["address"][0], + "source_port": record["address"][1], + "payload_size": len(payload), + "payload_sha256": hashlib.sha256(payload).hexdigest(), + "dump_offset": offset, + }) + offset += len(envelope) + len(payload) + report = { + "format": "uint16_le destination_port, uint16_le payload_size, payload bytes", + "destination_port": int(destination_port), + "packets": packet_meta, + "dump_name": dump_path.name, + "dump_size": dump_path.stat().st_size, + "dump_sha256": digest.hexdigest(), + } + report_path.write_text(json.dumps(report, indent=2), encoding="utf-8") + return dump_path, report_path, report + + +def run_udp_probe( + host, + port, + output_dir, + width=0, + height=0, + separator=0, + frame_encoding="auto", + duration=3.0, +): + records, capture = capture_udp_records(host, port, duration=duration) + analysis = analyze_udp_records(records, width, height, separator, frame_encoding) + sizes = Counter(len(record["payload"]) for record in records) + sources = Counter(f"{record['address'][0]}:{record['address'][1]}" for record in records) + if len(records) > 1: + span = (records[-1]["timestamp_ns"] - records[0]["timestamp_ns"]) / 1e9 + packet_rate = (len(records) - 1) / max(span, 1e-9) + else: + packet_rate = 0.0 + + sample_indexes = sorted(set( + list(range(min(3, len(records)))) + + ([len(records) - 1] if records else []) + )) + samples = [] + for index in sample_indexes: + payload = records[index]["payload"] + samples.append({ + "index": index, + "source": f"{records[index]['address'][0]}:{records[index]['address'][1]}", + "size": len(payload), + "sha256": hashlib.sha256(payload).hexdigest(), + "head_hex": payload[:64].hex(" "), + "tail_hex": payload[-32:].hex(" ") if len(payload) > 64 else "", + "ascii": "".join(chr(byte) if 32 <= byte < 127 else "." for byte in payload[:64]), + }) + + exact_capture = None + if records: + dump_path, report_path, report = save_udp_records(records, port, output_dir) + exact_capture = { + "dump_name": dump_path.name, + "report_name": report_path.name, + "bytes": report["dump_size"], + "sha256": report["dump_sha256"], + } + return { + **capture, + "packets": len(records), + "payload_bytes": sum(len(record["payload"]) for record in records), + "packets_per_sec": round(packet_rate, 1), + "sources": [{"address": address, "packets": count} for address, count in sources.most_common()], + "sizes": [{"bytes": size, "packets": count} for size, count in sizes.most_common(12)], + "samples": samples, + "exact_capture": exact_capture, + **analysis, + } diff --git a/ui_server.py b/ui_server.py new file mode 100644 index 0000000..2e57684 --- /dev/null +++ b/ui_server.py @@ -0,0 +1,3794 @@ +import http.client +import json +import mimetypes +import os +import re +import shutil +import socket +import subprocess +import sys +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, quote, unquote, urlencode, urlparse + +from configurable_udp_capture import ( + DEFAULT_PACKET_LAYOUT, + DEFAULT_PACKET_SCHEMA, + normalize_packet_layout, + normalize_packet_schema, + packet_layout_from_schema, + packet_schema_from_layout, +) +from udp_probe import run_udp_probe + +APP_DIR = Path(__file__).resolve().parent +DATA_DIR = Path(os.environ.get("FPV_DATA_DIR", "/data")) +HOST = os.environ.get("FPV_UI_HOST", "127.0.0.1") +PORT = int(os.environ.get("FPV_UI_PORT", "8080")) +MAX_JSON_BODY_BYTES = 1 * 1024 * 1024 +LOG_PATH = Path(os.environ.get("FPV_UI_LOG_PATH", str(DATA_DIR / "logs" / "main.log"))) +FRAME_PATH = Path(os.environ.get("FPV_UI_FRAME_PATH", str(DATA_DIR / "ui" / "latest.jpg"))) +GUIDANCE_PATH = Path(os.environ.get("FPV_UI_GUIDANCE_PATH", str(DATA_DIR / "guidance" / "guidance_state.json"))) +OUT_DIR = Path(os.environ.get("FPV_UI_OUT_DIR", str(DATA_DIR / "out"))) +DOWNLOAD_DIR = Path(os.environ.get("FPV_UI_DOWNLOAD_DIR", str(OUT_DIR / ".downloads"))) +ACTIVE_VIDEO_PATH = Path(os.environ.get("FPV_UI_ACTIVE_VIDEO_PATH", str(OUT_DIR / ".active_video"))) +INPUT_DIR = Path(os.environ.get("FPV_UI_INPUT_DIR", str(DATA_DIR / "input"))) +UDP_PROBE_DIR = Path(os.environ.get("FPV_UI_UDP_PROBE_DIR", str(DATA_DIR / "udp-probes"))) +CONTROL_PATH = Path(os.environ.get("FPV_UI_CONTROL_PATH", str(DATA_DIR / "ui" / "control_state.json"))) +MAIN_SCRIPT = Path(os.environ.get("FPV_UI_MAIN_SCRIPT", str(APP_DIR / "main.py"))) +PYTHON_EXE = os.environ.get("FPV_UI_PYTHON", sys.executable) +MODEL_PATH = os.environ.get("FPV_MODEL_PATH", str(APP_DIR / "best.pt")) +MODEL_DIR = Path(os.environ.get("FPV_UI_MODEL_DIR", str(DATA_DIR / "models"))) +MODEL_EXTENSIONS = {".pt"} +MODEL_INFO_LOCK = threading.Lock() +MODEL_INFO_CACHE = {} +NETRON_HOST = "127.0.0.1" +NETRON_PORT = int(os.environ.get("FPV_NETRON_PORT", "8092")) +NETRON_LOCK = threading.Lock() +NETRON_STATE = {"path": ""} +CONTROL_LOCK = threading.Lock() +UDP_PROBE_LOCK = threading.Lock() +CONTROL_PROCESS = None +CONTROL_SOURCE = "" + +QUALITIES = [ + {"label": "4K 3840x2160", "width": 3840, "height": 2160}, + {"label": "QHD 2560x1440", "width": 2560, "height": 1440}, + {"label": "Full HD 1920x1080", "width": 1920, "height": 1080}, + {"label": "HD 1280x720", "width": 1280, "height": 720}, + {"label": "XGA 1024x768", "width": 1024, "height": 768}, + {"label": "PAL 720x576", "width": 720, "height": 576}, + {"label": "VGA 640x480", "width": 640, "height": 480}, + {"label": "UDP-камера 512x640", "width": 512, "height": 640}, +] +FPS_OPTIONS = [120, 60, 50, 30, 25, 24, 15] +MIN_FRAME_SIZE = 16 +MAX_FRAME_SIZE = 8192 +MIN_CAPTURE_FPS = 1 +MAX_CAPTURE_FPS = 240 +VIDEO_EXTENSIONS = { + ".3g2", ".3gp", ".264", ".265", ".asf", ".avi", ".divx", ".dv", ".f4v", + ".flv", ".h264", ".h265", ".hevc", ".m2t", ".m2ts", ".m4v", ".mkv", ".mov", + ".mp4", ".mpeg", ".mpg", ".mts", ".mxf", ".ogm", ".ogv", ".rm", ".rmvb", + ".ts", ".vob", ".webm", ".wmv", ".y4m", +} +UDP_DUMP_EXTENSIONS = {"", ".udp", ".dump"} +INPUT_EXTENSIONS = VIDEO_EXTENSIONS | UDP_DUMP_EXTENSIONS +ERROR_PROTOCOLS = ["guidance_v1", "json", "csv", "bin"] +ERROR_UNITS = ["px", "norm", "deg", "m"] +ARCHIVE_RECORD_MODES = ["fragments", "full"] +FILE_SOURCE_MODES = {"file", "udp_dump", "udp_delimited_file"} +LIVE_SOURCE_MODES = {"udp_mik_live", "udp_delimited_live", "udp_custom_live"} +SOURCE_MODES = {"camera"} | FILE_SOURCE_MODES | LIVE_SOURCE_MODES +FRAME_ENCODINGS = {"auto", "bgr24", "rgb24", "gray8", "gray16", "yuyv422"} +PACKET_PRESETS = {"auto", "mik", "delimited", "custom"} +PACKET_PRESET_BY_SOURCE = { + "udp_mik_live": "mik", + "udp_delimited_live": "delimited", + "udp_custom_live": "custom", +} +H264_CACHE_VERSION = "v2" +DEFAULT_ERROR_HOST = os.environ.get("FPV_ERROR_OUTPUT_HOST", "127.0.0.1" if os.name == "nt" else "host.docker.internal") +DEFAULT_CONTROL = { + "model_path": MODEL_PATH, + "device": 0, + "use_half": True, + "conf": 0.25, + "img_size_roi": 640, + "img_size_full": 1280, + "max_det": 60, + "source_mode": "file", + "camera_index": 0, + "file_path": str(INPUT_DIR / "source.mp4"), + "input_host": "0.0.0.0", + "input_port": 59004, + "separator_byte": 0, + "frame_encoding": "auto", + "packet_preset": "auto", + "packet_layout": [field.copy() for field in DEFAULT_PACKET_LAYOUT], + "packet_schema": DEFAULT_PACKET_SCHEMA.copy(), + "quality": "1280x720", + "fps": 30, + "run_mode": "realtime", + "frame_mode": "hd", + "save": True, + "archive_mode": os.environ.get("FPV_ARCHIVE_RECORD_MODE", "fragments"), + "fragment_gap_sec": float(os.environ.get("FPV_DETECTION_CLIP_MAX_GAP_SEC", "15")), + "error_output": True, + "error_protocol": os.environ.get("FPV_ERROR_OUTPUT_PROTOCOL", "guidance_v1"), + "error_host": DEFAULT_ERROR_HOST, + "error_port": int(os.environ.get("FPV_ERROR_OUTPUT_PORT", "5010")), + "error_object_id": int(os.environ.get("FPV_ERROR_OUTPUT_OBJECT_ID", "1")), + "error_units": os.environ.get("FPV_ERROR_OUTPUT_UNITS", "px"), + "error_hfov": float(os.environ.get("FPV_ERROR_OUTPUT_HFOV_DEG", "90")), + "error_vfov": float(os.environ.get("FPV_ERROR_OUTPUT_VFOV_DEG", "60")), + "error_range_m": float(os.environ.get("FPV_ERROR_OUTPUT_RANGE_M", "0")), +} + + +def tail_text(path, lines=80): + if not path.exists(): + return "" + data = path.read_bytes()[-65536:] + text = data.decode("utf-8", errors="replace") + return "\n".join(text.splitlines()[-int(lines):]) + + +def read_json_file(path): + if not path.exists(): + return {} + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + + +def latest_file(root, pattern): + if not root.exists(): + return None + files = [p for p in root.glob(pattern) if p.is_file()] + if not files: + return None + return max(files, key=lambda p: p.stat().st_mtime) + + +def archive_path(root, name): + decoded = unquote(str(name)) + safe_name = Path(decoded).name + if safe_name != decoded or not safe_name.lower().endswith(".mp4"): + return None + path = root / safe_name + try: + path.resolve(strict=False).relative_to(Path(root).resolve()) + except (OSError, ValueError): + return None + return path + + +def udp_probe_path(root, name): + decoded = unquote(str(name)) + safe_name = Path(decoded).name + if safe_name != decoded or Path(safe_name).suffix.lower() not in {".udp", ".json"}: + return None + path = root / safe_name + try: + path.resolve(strict=False).relative_to(Path(root).resolve()) + except (OSError, ValueError): + return None + return path + + +def archive_files(root, active_name=None): + if not root.exists(): + return [] + rows = [] + for path in sorted(root.glob("*.mp4"), key=lambda p: p.stat().st_mtime, reverse=True): + stat = path.stat() + rows.append({ + "name": path.name, + "size": stat.st_size, + "mtime": int(stat.st_mtime), + "active": path.name == active_name, + }) + return rows + + +def cleanup_empty_recordings(root, max_bytes=128): + removed = 0 + if not root.exists(): + return removed + for path in root.glob("*.mp4"): + try: + if path.stat().st_size <= int(max_bytes): + path.unlink() + removed += 1 + except OSError: + pass + return removed + + +def active_video_name(root, marker_path=None): + marker_path = ACTIVE_VIDEO_PATH if marker_path is None else marker_path + try: + name = marker_path.read_text(encoding="utf-8").strip() + except OSError: + return None + path = archive_path(root, name) + if path and path.exists(): + return path.name + return None + + +def safe_download_filename(name): + filename = Path(str(name)).name + ascii_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", filename).strip("._") + return ascii_name or "video.mp4" + + +def content_disposition(filename): + ascii_name = safe_download_filename(filename) + encoded = quote(Path(str(filename)).name) + return f'attachment; filename="{ascii_name}"; filename*=UTF-8\'\'{encoded}' + + +def h264_download_name(path): + return f"{path.stem}_h264.mp4" + + +def h264_cache_path(path, cache_dir=DOWNLOAD_DIR): + stat = path.stat() + stem = re.sub(r"[^A-Za-z0-9_.-]+", "_", path.stem).strip("._") or "video" + return cache_dir / f"{stem}.{H264_CACHE_VERSION}.{stat.st_mtime_ns}.{stat.st_size}.h264.mp4" + + +def delete_h264_cache(path, cache_dir=DOWNLOAD_DIR): + stem = re.sub(r"[^A-Za-z0-9_.-]+", "_", path.stem).strip("._") or "video" + if not cache_dir.exists(): + return + for cached in cache_dir.glob(f"{stem}.*.h264.mp4"): + try: + cached.unlink() + except OSError: + pass + + +def h264_download_file(path): + ffmpeg = ffmpeg_executable() + if not ffmpeg: + raise RuntimeError("ffmpeg unavailable") + cache = h264_cache_path(path) + if cache.exists() and cache.stat().st_size > 0: + return cache + cache.parent.mkdir(parents=True, exist_ok=True) + temp = cache.with_name(f"{cache.name}.{threading.get_ident()}.tmp") + command = [ + ffmpeg, "-hide_banner", "-loglevel", "error", "-y", "-i", str(path), + "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "18", + "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-f", "mp4", str(temp), + ] + try: + result = subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True) + if result.returncode != 0: + raise RuntimeError((result.stderr or "ffmpeg failed").strip()) + temp.replace(cache) + return cache + finally: + try: + temp.unlink() + except OSError: + pass + + +def write_json_file(path, data): + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + except OSError: + pass + + +def read_control_state(): + state = DEFAULT_CONTROL.copy() + data = read_json_file(CONTROL_PATH) + if isinstance(data, dict): + state.update({k: v for k, v in data.items() if k in state}) + if "packet_preset" not in data: + state["packet_preset"] = PACKET_PRESET_BY_SOURCE.get(state.get("source_mode"), "auto") + if "packet_layout" not in data and "packet_schema" in data: + state["packet_layout"] = packet_layout_from_schema(data["packet_schema"]) + if state.get("source_mode") in FILE_SOURCE_MODES: + path = resolve_video_path(state.get("file_path")) + default_path = INPUT_DIR / "source.mp4" + if not path.is_file() and default_path.is_file(): + state["file_path"] = str(default_path) + return state + + +def looks_like_mik_dump(path): + try: + size = path.stat().st_size + with path.open("rb") as stream: + header = stream.read(12) + except OSError: + return False + if size < 12 or len(header) < 12: + return False + port = int.from_bytes(header[:2], "little") + payload_size = int.from_bytes(header[2:4], "little") + flags = header[5] + packet_number = header[7] + array_size = int.from_bytes(header[8:12], "little") + return ( + 0 < port <= 65535 + and 8 <= payload_size <= 65507 + and size >= 4 + payload_size + and flags & 0x02 + and not flags & ~0x03 + and packet_number == 0 + and 0 < array_size <= 256 * 1024 * 1024 + ) + + +def input_file_kind(path): + if path.suffix.lower() in {".udp", ".dump"} or looks_like_mik_dump(path): + return "udp_dump" + return "video" + + +def input_video_files(): + if not INPUT_DIR.exists(): + return [] + rows = [] + for path in sorted(INPUT_DIR.iterdir(), key=lambda p: p.stat().st_mtime if p.exists() else 0, reverse=True): + if not path.is_file() or path.name.startswith("."): + continue + stat = path.stat() + rows.append({ + "name": path.name, + "path": str(path), + "size": stat.st_size, + "mtime": int(stat.st_mtime), + "kind": input_file_kind(path), + }) + return rows + + +def parse_quality(value): + match = re.fullmatch(r"(\d{1,5})\s*[xх×]\s*(\d{1,5})", str(value or "").strip().lower()) + if not match: + return 1280, 720 + width = max(MIN_FRAME_SIZE, min(MAX_FRAME_SIZE, int(match.group(1)))) + height = max(MIN_FRAME_SIZE, min(MAX_FRAME_SIZE, int(match.group(2)))) + return width, height + + +def camera_bridge_source(base_url, camera_index, width, height, fps): + separator = "&" if "?" in base_url else "?" + query = urlencode({ + "index": int(camera_index), + "width": int(width), + "height": int(height), + "fps": int(fps), + "quality": int(os.environ.get("FPV_CAMERA_BRIDGE_JPEG_QUALITY", "85")), + }) + return f"{base_url}{separator}{query}" + + +def resolve_video_path(value): + path = Path(str(value or "").strip().strip('"')) + if not str(path): + path = INPUT_DIR / "source.mp4" + if not path.is_absolute(): + path = APP_DIR / path + return path + + +def normalize_control(data): + state = read_control_state() + if isinstance(data, dict): + state.update({k: v for k, v in data.items() if k in state}) + model_path = resolve_model_path(state.get("model_path")) + state["model_path"] = str(model_path or resolve_model_path(MODEL_PATH) or MODEL_PATH) + try: + state["device"] = max(-1, min(16, int(state.get("device", 0)))) + except (TypeError, ValueError): + state["device"] = 0 + raw_half = state.get("use_half", True) + state["use_half"] = raw_half if isinstance(raw_half, bool) else str(raw_half).strip().lower() in {"1", "true", "yes", "on"} + try: + state["conf"] = max(0.01, min(1.0, float(state.get("conf", 0.25)))) + except (TypeError, ValueError): + state["conf"] = 0.25 + for key, default in (("img_size_roi", 640), ("img_size_full", 1280)): + try: + state[key] = max(128, min(4096, int(state.get(key, default)))) + except (TypeError, ValueError): + state[key] = default + try: + state["max_det"] = max(1, min(300, int(state.get("max_det", 60)))) + except (TypeError, ValueError): + state["max_det"] = 60 + source_mode = str(state.get("source_mode") or "file").lower() + state["source_mode"] = source_mode if source_mode in SOURCE_MODES else "file" + state["camera_index"] = max(0, int(state.get("camera_index") or 0)) + state["file_path"] = str(resolve_video_path(state.get("file_path"))) + state["input_host"] = str(state.get("input_host") or "0.0.0.0").strip() or "0.0.0.0" + state["input_port"] = int(max(1, min(65535, int(state.get("input_port") or 59004)))) + state["separator_byte"] = int(max(0, min(255, int(state.get("separator_byte") or 0)))) + state["frame_encoding"] = str(state.get("frame_encoding") or "auto").lower() + if state["frame_encoding"] not in FRAME_ENCODINGS: + state["frame_encoding"] = "auto" + state["packet_preset"] = str(state.get("packet_preset") or "auto").lower() + if isinstance(data, dict) and "source_mode" in data and "packet_preset" not in data: + state["packet_preset"] = PACKET_PRESET_BY_SOURCE.get(state["source_mode"], "auto") + if state["packet_preset"] not in PACKET_PRESETS: + state["packet_preset"] = "auto" + if isinstance(data, dict) and "packet_schema" in data and "packet_layout" not in data: + state["packet_layout"] = packet_layout_from_schema(state["packet_schema"]) + state["packet_layout"] = normalize_packet_layout(state.get("packet_layout")) + state["packet_schema"] = packet_schema_from_layout( + state["packet_layout"], + normalize_packet_schema(state.get("packet_schema")), + ) + width, height = parse_quality(state.get("quality")) + state["quality"] = f"{width}x{height}" + fps = int(float(state.get("fps") or 30)) + state["fps"] = max(MIN_CAPTURE_FPS, min(MAX_CAPTURE_FPS, fps)) + state["run_mode"] = "fast" if state.get("run_mode") == "fast" else "realtime" + if state["source_mode"] in LIVE_SOURCE_MODES: + state["run_mode"] = "realtime" + state["frame_mode"] = "pal" if state.get("frame_mode") == "pal" else "hd" + state["save"] = bool(state.get("save", True)) + state["archive_mode"] = str(state.get("archive_mode") or "fragments").lower() + if state["archive_mode"] not in ARCHIVE_RECORD_MODES: + state["archive_mode"] = "fragments" + state["fragment_gap_sec"] = float(max(0.0, min(3600.0, float(state.get("fragment_gap_sec") or 15.0)))) + state["error_output"] = bool(state.get("error_output", False)) + state["error_protocol"] = str(state.get("error_protocol") or "guidance_v1").lower() + if state["error_protocol"] not in ERROR_PROTOCOLS: + state["error_protocol"] = "guidance_v1" + state["error_units"] = str(state.get("error_units") or "px").lower() + if state["error_units"] not in ERROR_UNITS: + state["error_units"] = "px" + state["error_host"] = str(state.get("error_host") or DEFAULT_ERROR_HOST).strip() or DEFAULT_ERROR_HOST + state["error_port"] = int(max(1, min(65535, int(state.get("error_port") or 5010)))) + state["error_object_id"] = int(max(1, min(255, int(state.get("error_object_id") or 1)))) + state["error_hfov"] = float(max(1.0, min(179.0, float(state.get("error_hfov") or 90.0)))) + state["error_vfov"] = float(max(1.0, min(179.0, float(state.get("error_vfov") or 60.0)))) + state["error_range_m"] = float(max(0.0, float(state.get("error_range_m") or 0.0))) + return state + + +def process_running(): + global CONTROL_PROCESS + if CONTROL_PROCESS is None: + return False + if CONTROL_PROCESS.poll() is None: + return True + CONTROL_PROCESS = None + return False + + +def control_payload(): + state = read_control_state() + running = process_running() + active_name = active_video_name(OUT_DIR) if running else None + if not running: + try: + ACTIVE_VIDEO_PATH.unlink(missing_ok=True) + except OSError: + pass + return { + **state, + "running": running, + "pid": CONTROL_PROCESS.pid if CONTROL_PROCESS and CONTROL_PROCESS.poll() is None else None, + "active_video": active_name or "", + "qualities": QUALITIES, + "fps_options": FPS_OPTIONS, + "error_protocols": ERROR_PROTOCOLS, + "error_units_options": ERROR_UNITS, + "archive_record_modes": ARCHIVE_RECORD_MODES, + "default_input": str(INPUT_DIR / "source.mp4"), + "default_model": str(resolve_model_path(MODEL_PATH) or MODEL_PATH), + } + + +def stop_control_process(): + global CONTROL_PROCESS, CONTROL_SOURCE + if CONTROL_PROCESS is None or CONTROL_PROCESS.poll() is not None: + CONTROL_PROCESS = None + CONTROL_SOURCE = "" + try: + ACTIVE_VIDEO_PATH.unlink(missing_ok=True) + except OSError: + pass + return False + pid = CONTROL_PROCESS.pid + if os.name == "nt": + subprocess.run( + ["taskkill", "/PID", str(pid), "/T", "/F"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + else: + CONTROL_PROCESS.terminate() + try: + CONTROL_PROCESS.wait(timeout=5) + except subprocess.TimeoutExpired: + CONTROL_PROCESS.kill() + CONTROL_PROCESS.wait(timeout=5) + CONTROL_PROCESS = None + CONTROL_SOURCE = "" + try: + ACTIVE_VIDEO_PATH.unlink(missing_ok=True) + except OSError: + pass + return True + + +def start_control_process(data): + global CONTROL_PROCESS, CONTROL_SOURCE + state = normalize_control(data) + if state["source_mode"] in FILE_SOURCE_MODES and not Path(state["file_path"]).is_file(): + raise FileNotFoundError(state["file_path"]) + + with CONTROL_LOCK: + if UDP_PROBE_LOCK.locked(): + raise RuntimeError("UDP probe is active") + stop_control_process() + DATA_DIR.mkdir(parents=True, exist_ok=True) + INPUT_DIR.mkdir(parents=True, exist_ok=True) + OUT_DIR.mkdir(parents=True, exist_ok=True) + LOG_PATH.parent.mkdir(parents=True, exist_ok=True) + FRAME_PATH.parent.mkdir(parents=True, exist_ok=True) + GUIDANCE_PATH.parent.mkdir(parents=True, exist_ok=True) + for path in (FRAME_PATH, GUIDANCE_PATH, ACTIVE_VIDEO_PATH): + try: + path.unlink(missing_ok=True) + except OSError: + pass + + width, height = parse_quality(state["quality"]) + env = os.environ.copy() + process_source_mode = state["source_mode"] + if state["source_mode"] == "camera": + bridge_url = os.environ.get("FPV_CAMERA_BRIDGE_URL", "").strip() + if bridge_url: + source = camera_bridge_source( + bridge_url, + state["camera_index"], + width, + height, + state["fps"], + ) + process_source_mode = "camera_bridge" + else: + source = str(state["camera_index"]) + elif state["source_mode"] in LIVE_SOURCE_MODES: + source = f"udp://{state['input_host']}:{state['input_port']}" + else: + source = str(state["file_path"]) + + env.update({ + "PYTHONUNBUFFERED": "1", + "FPV_MODEL_PATH": state["model_path"], + "FPV_DEVICE": str(state["device"]), + "FPV_USE_HALF": "1" if state["use_half"] else "0", + "FPV_CONF": str(state["conf"]), + "FPV_IMG_SIZE_ROI": str(state["img_size_roi"]), + "FPV_IMG_SIZE_FULL": str(state["img_size_full"]), + "FPV_MAX_DET": str(state["max_det"]), + "FPV_SOURCE": source, + "FPV_SOURCE_MODE": process_source_mode, + "FPV_UDP_INPUT_HOST": state["input_host"], + "FPV_UDP_INPUT_PORT": str(state["input_port"]), + "FPV_FRAME_SEPARATOR_BYTE": str(state["separator_byte"]), + "FPV_FRAME_ENCODING": state["frame_encoding"], + "FPV_UDP_PACKET_SCHEMA": json.dumps(state["packet_schema"], separators=(",", ":")), + "FPV_CAP_BACKEND": os.environ.get("FPV_CAP_BACKEND", "dshow" if os.name == "nt" else "v4l2"), + "FPV_CAMERA_WIDTH": str(width), + "FPV_CAMERA_HEIGHT": str(height), + "FPV_CAMERA_FPS": str(state["fps"]), + "FPV_CAMERA_FOURCC": "MJPG", + "FPV_TARGET_OUT_FPS": str(state["fps"]) if state["source_mode"] == "camera" else "0", + "FPV_VIDEO_REALTIME": "0" if state["run_mode"] == "fast" else "1", + "FPV_SHOW_OUTPUT": "0", + "FPV_SAVE_INFER_VIDEO": "1" if state["save"] else "0", + "FPV_OUT_VIDEO_PATH": str(OUT_DIR / "out_infer.mp4"), + "FPV_ARCHIVE_RECORD_MODE": state["archive_mode"], + "FPV_DETECTION_CLIP_MAX_GAP_SEC": str(state["fragment_gap_sec"]), + "FPV_FORCE_EFFECTIVE_PAL": "1" if state["frame_mode"] == "pal" else "0", + "FPV_EFFECTIVE_W": str(width), + "FPV_EFFECTIVE_H": str(height), + "FPV_UI_FRAME_EXPORT_ENABLE": "1", + "FPV_UI_FRAME_EXPORT_PATH": str(FRAME_PATH), + "FPV_UI_FRAME_EXPORT_EVERY": "1", + "FPV_UI_FRAME_EXPORT_JPEG_QUALITY": os.environ.get("FPV_UI_FRAME_EXPORT_JPEG_QUALITY", "82"), + "FPV_UI_FRAME_EXPORT_MAX_FPS": os.environ.get("FPV_UI_FRAME_EXPORT_MAX_FPS", "50"), + "FPV_REALTIME_SKIP_STALE_FRAMES": os.environ.get("FPV_REALTIME_SKIP_STALE_FRAMES", "1"), + "FPV_REALTIME_MAX_SKIP_FRAMES": os.environ.get("FPV_REALTIME_MAX_SKIP_FRAMES", "8"), + "FPV_REALTIME_PREVIEW_SKIPPED_FRAMES": os.environ.get("FPV_REALTIME_PREVIEW_SKIPPED_FRAMES", "1"), + "FPV_REALTIME_ANALYSIS_EVERY": os.environ.get("FPV_REALTIME_ANALYSIS_EVERY", "4"), + "FPV_GUIDANCE_EXPORT_ENABLE": "1", + "FPV_GUIDANCE_EXPORT_PATH": str(GUIDANCE_PATH), + "FPV_ERROR_OUTPUT_ENABLE": "1" if state["error_output"] else "0", + "FPV_ERROR_OUTPUT_PROTOCOL": state["error_protocol"], + "FPV_ERROR_OUTPUT_HOST": state["error_host"], + "FPV_ERROR_OUTPUT_PORT": str(state["error_port"]), + "FPV_ERROR_OUTPUT_OBJECT_ID": str(state["error_object_id"]), + "FPV_ERROR_OUTPUT_UNITS": state["error_units"], + "FPV_ERROR_OUTPUT_HFOV_DEG": str(state["error_hfov"]), + "FPV_ERROR_OUTPUT_VFOV_DEG": str(state["error_vfov"]), + "FPV_ERROR_OUTPUT_RANGE_M": str(state["error_range_m"]), + "FPV_AUTOPILOT_BACKEND": "json", + "FPV_AUTOPILOT_JSON_PATH": str(DATA_DIR / "autopilot" / "autopilot_cmd.json"), + }) + stdout = LOG_PATH.open("w", encoding="utf-8", buffering=1) + stderr = (LOG_PATH.parent / "main.err.log").open("w", encoding="utf-8", buffering=1) + creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) + try: + CONTROL_PROCESS = subprocess.Popen( + [PYTHON_EXE, "-u", str(MAIN_SCRIPT)], + cwd=str(APP_DIR), + env=env, + stdout=stdout, + stderr=stderr, + creationflags=creationflags, + ) + if process_source_mode == "camera_bridge" or state["source_mode"] in LIVE_SOURCE_MODES: + source_kind = "stream" + else: + source_kind = "camera" if state["source_mode"] == "camera" else "file" + CONTROL_SOURCE = f"{source} ({source_kind})" + finally: + stdout.close() + stderr.close() + write_json_file(CONTROL_PATH, state) + return control_payload() + + +def parse_range_header(range_header, size): + match = re.fullmatch(r"bytes=(\d*)-(\d*)", (range_header or "").strip()) + if not match or size <= 0: + return None + start_text, end_text = match.groups() + if not start_text and not end_text: + return None + if not start_text: + length = int(end_text) + if length <= 0: + return None + return max(0, size - length), size - 1 + start = int(start_text) + end = int(end_text) if end_text else size - 1 + if start >= size or start > end: + return None + return start, min(end, size - 1) + + +def parse_perf(log_text): + match = None + for line in log_text.splitlines(): + if "[perf]" in line: + match = line + if not match: + return {} + perf = re.search( + r"fps~([0-9.]+).*?iter p50=([0-9.]+) p95=([0-9.]+).*?" + r"yolo p50=([0-9.]+) p95=([0-9.]+)(?: skip=([0-9]+))?" + r"(?: pass=([0-9]+))?(?: analysisEvery=([0-9]+))?", + match, + ) + if not perf: + return {} + return { + "fps": perf.group(1), + "p50": perf.group(2), + "p95": perf.group(3), + "yolo_p50": perf.group(4), + "yolo_p95": perf.group(5), + "skip": perf.group(6) or "0", + "pass": perf.group(7) or "0", + "analysis_every": perf.group(8) or "1", + } + + +def parse_source(log_text): + for line in reversed(log_text.splitlines()): + match = re.search(r"Opened source: (.+?) \(([^)]+)\)", line) + if match: + return f"{match.group(1)} ({match.group(2)})" + for line in reversed(log_text.splitlines()): + match = re.search(r"\[entrypoint\] source=([^ ]+)", line) + if match: + return match.group(1) + return os.environ.get("FPV_SOURCE", "") + + +def status_payload(): + logs = tail_text(LOG_PATH) + parse_logs = tail_text(LOG_PATH, lines=500) + video = latest_file(OUT_DIR, "*.mp4") + control = control_payload() + active_name = control["active_video"] or None + return { + "logs": logs, + "guidance": read_json_file(GUIDANCE_PATH), + "perf": parse_perf(parse_logs), + "source": CONTROL_SOURCE if CONTROL_SOURCE and control["running"] else parse_source(parse_logs), + "frame_exists": FRAME_PATH.exists(), + "video": active_name or (video.name if video else ""), + "control": control, + } + + +def udp_probe_payload(data): + if not isinstance(data, dict): + raise ValueError("JSON object expected") + state = normalize_control(data) + width, height = parse_quality(state["quality"]) + duration = max(0.2, min(15.0, float(data.get("duration", 3.0)))) + result = run_udp_probe( + state["input_host"], + state["input_port"], + UDP_PROBE_DIR, + width=width, + height=height, + separator=state["separator_byte"], + frame_encoding=state["frame_encoding"], + duration=duration, + ) + capture = result.get("exact_capture") + if capture: + capture["dump_url"] = f"/udp-probe/{quote(capture['dump_name'])}" + capture["report_url"] = f"/udp-probe/{quote(capture['report_name'])}" + result["requested_host"] = state["input_host"] + result["requested_port"] = state["input_port"] + return result + + +def copy_bytes(src, dst, remaining): + while remaining > 0: + try: + chunk = src.read(min(1024 * 1024, remaining)) + except OSError: + return + if not chunk: + break + try: + dst.write(chunk) + except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError): + return + remaining -= len(chunk) + + +def copy_stream(src, dst): + while True: + try: + chunk = src.read(1024 * 1024) + except OSError: + return + if not chunk: + return + try: + dst.write(chunk) + except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError): + return + + +def copy_exact(src, dst, size, chunk_size=4 * 1024 * 1024): + remaining = int(size) + while remaining > 0: + chunk = src.read(min(int(chunk_size), remaining)) + if not chunk: + raise EOFError("upload interrupted") + dst.write(chunk) + remaining -= len(chunk) + return int(size) + + +def input_upload_path(filename): + name = str(filename or "").strip() + if ( + not name + or name in {".", ".."} + or "/" in name + or "\\" in name + or "\x00" in name + or len(name) > 240 + ): + return None + return INPUT_DIR / name + + +def _path_under(path, roots): + try: + resolved = Path(path).resolve(strict=False) + for root in roots: + resolved.relative_to(Path(root).resolve()) + return True + except (OSError, ValueError): + return False + return False + + +def resolve_model_path(value=None): + raw = str(value or MODEL_PATH).strip().strip('"') + path = Path(raw) + if not path.is_absolute(): + path = APP_DIR / path + if path.suffix.lower() not in MODEL_EXTENSIONS: + return None + roots = (APP_DIR, MODEL_DIR) + return path if _path_under(path, roots) else None + + +def model_upload_path(filename): + name = str(filename or "").strip() + if ( + not name + or name in {".", ".."} + or "/" in name + or "\\" in name + or "\x00" in name + or len(name) > 240 + or Path(name).suffix.lower() not in MODEL_EXTENSIONS + ): + return None + path = MODEL_DIR / name + return path if _path_under(path, (MODEL_DIR,)) else None + + +def model_catalog(): + rows = {} + for root in (APP_DIR, MODEL_DIR): + if not root.exists(): + continue + for path in root.glob("*.pt"): + if not path.is_file() or not _path_under(path, (root,)): + continue + try: + stat = path.stat() + except OSError: + continue + rows[str(path.resolve())] = { + "name": path.name, + "path": str(path), + "size": stat.st_size, + "mtime": int(stat.st_mtime), + } + return sorted(rows.values(), key=lambda item: item["name"].lower()) + + +def inspect_model(path): + path = resolve_model_path(path) + if path is None or not path.is_file(): + raise FileNotFoundError(str(path or "model")) + stat = path.stat() + key = (str(path.resolve()), stat.st_mtime_ns, stat.st_size) + with MODEL_INFO_LOCK: + cached = MODEL_INFO_CACHE.get(key) + if cached is not None: + return cached + import __main__ + import cbam_register + for name in ("ChannelAttentionDyn", "SpatialAttention", "CBAM"): + setattr(__main__, name, getattr(cbam_register, name)) + from ultralytics import YOLO + + loaded = YOLO(str(path)) + network = getattr(loaded, "model", loaded) + parameters = sum(parameter.numel() for parameter in network.parameters()) + trainable = sum(parameter.numel() for parameter in network.parameters() if parameter.requires_grad) + layers = [] + for index, (name, module) in enumerate(network.named_modules()): + if not name: + continue + layers.append({ + "index": index, + "name": name, + "type": type(module).__name__, + "params": sum(parameter.numel() for parameter in module.parameters(recurse=False)), + }) + if len(layers) >= 512: + break + names = getattr(loaded, "names", {}) + if isinstance(names, dict): + names = [names[key] for key in sorted(names)] + else: + names = list(names or []) + info = { + "name": path.name, + "path": str(path), + "size": stat.st_size, + "task": str(getattr(loaded, "task", "detect")), + "classes": names, + "parameters": parameters, + "trainable": trainable, + "layers": layers, + } + MODEL_INFO_CACHE.clear() + MODEL_INFO_CACHE[key] = info + return info + + +def model_payload(): + state = read_control_state() + selected = resolve_model_path(state.get("model_path")) or resolve_model_path(MODEL_PATH) + return { + "models": model_catalog(), + "selected": str(selected) if selected else "", + "default": str(resolve_model_path(MODEL_PATH) or MODEL_PATH), + } + + +def ensure_netron(path=None): + model = resolve_model_path(path or model_payload()["selected"]) + if model is None or not model.is_file(): + raise FileNotFoundError(str(model or "model")) + try: + import netron + except ImportError as exc: + raise RuntimeError("Netron не установлен в Docker-образе") from exc + + model_key = str(model.resolve()) + address = (NETRON_HOST, NETRON_PORT) + with NETRON_LOCK: + try: + running = netron.status(address) + except Exception: + running = False + if NETRON_STATE.get("path") != model_key or not running: + netron.stop(address) + netron.start(str(model), address=address, browse=False) + NETRON_STATE.update({"path": model_key}) + return {"url": "/netron/", "path": str(model), "port": NETRON_PORT} + + +def select_model(data): + if process_running(): + raise RuntimeError("остановите инференс перед сменой модели") + if not isinstance(data, dict): + raise ValueError("JSON object expected") + path = resolve_model_path(data.get("path")) + if path is None or not path.is_file(): + raise FileNotFoundError(str(data.get("path") or "model")) + state = read_control_state() + state["model_path"] = str(path) + for key in ("device", "use_half", "conf", "img_size_roi", "img_size_full", "max_det"): + if key in data: + state[key] = data[key] + state = normalize_control(state) + write_json_file(CONTROL_PATH, state) + return control_payload() + + +def ffmpeg_executable(): + exe = shutil.which("ffmpeg") + if exe: + return exe + try: + from imageio_ffmpeg import get_ffmpeg_exe + except ImportError: + return None + return get_ffmpeg_exe() + + +HTML = """ + + + + + FPV Панель + + + +
+

FPV

+ подключение... + + + + + + + +
+
+
+
+
+ + + + +
+
ожидание кадра...
+
+
+
+ + + +
+ + + + + +""" + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + parsed = urlparse(self.path) + path = parsed.path + if path == "/": + return self.send_bytes(HTML.encode("utf-8"), "text/html; charset=utf-8") + if path == "/api/status": + data = json.dumps(status_payload(), ensure_ascii=False).encode("utf-8") + return self.send_bytes(data, "application/json; charset=utf-8") + if path == "/api/control": + return self.send_json(control_payload()) + if path == "/api/models": + return self.send_json(model_payload()) + if path == "/api/model/netron": + try: + query = parse_qs(parsed.query) + requested = (query.get("path") or [None])[0] + return self.send_json({"ok": True, **ensure_netron(requested)}) + except FileNotFoundError as exc: + return self.send_json({"ok": False, "error": f"file not found: {exc}"}, status=404) + except RuntimeError as exc: + return self.send_json({"ok": False, "error": str(exc)}, status=503) + except Exception as exc: + return self.send_json({"ok": False, "error": str(exc)}, status=400) + if path == "/api/model/architecture": + if process_running(): + return self.send_json({"ok": False, "error": "остановите инференс перед загрузкой архитектуры"}, status=409) + try: + query = parse_qs(parsed.query) + requested = (query.get("path") or [None])[0] + return self.send_json({"ok": True, "model": inspect_model(requested or model_payload()["selected"])}) + except FileNotFoundError as exc: + return self.send_json({"ok": False, "error": f"file not found: {exc}"}, status=404) + except Exception as exc: + return self.send_json({"ok": False, "error": str(exc)}, status=400) + if path == "/netron": + self.send_response(301) + self.send_header("Location", "/netron/") + self.end_headers() + return + if path.startswith("/netron/"): + return self.proxy_netron(parsed) + if path == "/api/input-files": + return self.send_json(input_video_files()) + if path == "/api/archive": + active_name = control_payload()["active_video"] or None + data = json.dumps(archive_files(OUT_DIR, active_name), ensure_ascii=False).encode("utf-8") + return self.send_bytes(data, "application/json; charset=utf-8") + if path == "/frame.jpg": + return self.send_file(FRAME_PATH, "image/jpeg") + if path == "/stream.mjpg": + return self.send_mjpeg() + if path.startswith("/udp-probe/"): + probe = udp_probe_path(UDP_PROBE_DIR, path.removeprefix("/udp-probe/")) + if probe and probe.is_file(): + return self.send_file(probe, attachment=True, download_name=probe.name) + if path == "/video": + video = latest_file(OUT_DIR, "*.mp4") + if video: + return self.send_file(video) + if path.startswith("/archive-play/"): + archive = archive_path(OUT_DIR, path.removeprefix("/archive-play/")) + active_name = control_payload()["active_video"] or None + if not archive or not archive.exists(): + self.send_error(404) + return + if active_name and archive.name == active_name: + self.send_error(409, "active recording") + return + return self.send_transcoded_video(archive) + if path.startswith("/archive/"): + archive = archive_path(OUT_DIR, path.removeprefix("/archive/")) + if archive and archive.exists(): + download = parse_qs(parsed.query).get("download") == ["1"] + if download: + active_name = control_payload()["active_video"] or None + if active_name and archive.name == active_name: + self.send_error(409, "active recording") + return + return self.send_h264_download(archive) + return self.send_file(archive) + self.send_error(404) + + def do_POST(self): + parsed = urlparse(self.path) + path = parsed.path + if path == "/api/control/start": + try: + return self.send_json(start_control_process(self.read_json_body())) + except FileNotFoundError as exc: + return self.send_json({"ok": False, "error": f"file not found: {exc}"}, status=400) + except ValueError as exc: + return self.send_json({"ok": False, "error": str(exc)}, status=400) + except Exception as exc: + return self.send_json({"ok": False, "error": str(exc)}, status=500) + if path == "/api/control/stop": + with CONTROL_LOCK: + stopped = stop_control_process() + return self.send_json({"ok": True, "stopped": stopped, "control": control_payload()}) + if path == "/api/model/select": + try: + return self.send_json({"ok": True, "control": select_model(self.read_json_body())}) + except FileNotFoundError as exc: + return self.send_json({"ok": False, "error": f"file not found: {exc}"}, status=404) + except ValueError as exc: + return self.send_json({"ok": False, "error": str(exc)}, status=400) + except RuntimeError as exc: + return self.send_json({"ok": False, "error": str(exc)}, status=409) + if path == "/api/model-upload": + return self.handle_model_upload(parse_qs(parsed.query)) + if path == "/api/udp-probe": + if not UDP_PROBE_LOCK.acquire(blocking=False): + return self.send_json({"ok": False, "error": "UDP probe is already running"}, status=409) + try: + with CONTROL_LOCK: + if process_running(): + return self.send_json( + {"ok": False, "error": "stop the active stream before UDP probe"}, + status=409, + ) + return self.send_json({"ok": True, **udp_probe_payload(self.read_json_body())}) + except (OSError, ValueError) as exc: + return self.send_json({"ok": False, "error": str(exc)}, status=400) + finally: + UDP_PROBE_LOCK.release() + if path == "/api/upload": + return self.handle_upload(parse_qs(parsed.query)) + self.send_error(404) + + def do_DELETE(self): + path = urlparse(self.path).path + if path.startswith("/api/archive/"): + archive = archive_path(OUT_DIR, path.removeprefix("/api/archive/")) + active_name = control_payload()["active_video"] or None + if not archive or not archive.exists(): + self.send_error(404) + return + if active_name and archive.name == active_name: + self.send_error(409, "active recording") + return + archive.unlink() + delete_h264_cache(archive) + self.send_bytes(b'{"ok": true}', "application/json") + return + self.send_error(404) + + def log_message(self, fmt, *args): + return + + def proxy_netron(self, parsed): + try: + ensure_netron() + target_path = parsed.path.removeprefix("/netron") or "/" + target = target_path + (f"?{parsed.query}" if parsed.query else "") + upstream_conn = http.client.HTTPConnection(NETRON_HOST, NETRON_PORT, timeout=10) + upstream_conn.request("GET", target, headers={"Host": f"{NETRON_HOST}:{NETRON_PORT}"}) + upstream = upstream_conn.getresponse() + except (OSError, RuntimeError, FileNotFoundError) as exc: + self.send_error(502, f"Netron unavailable: {exc}") + return + + self.send_response(upstream.status, upstream.reason) + hop_by_hop = {"connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", "server", "date", "content-length"} + for key, value in upstream.getheaders(): + if key.lower() not in hop_by_hop: + self.send_header(key, value) + content_length = upstream.getheader("Content-Length") + if content_length: + self.send_header("Content-Length", content_length) + self.end_headers() + try: + while True: + chunk = upstream.read(1024 * 1024) + if not chunk: + break + self.wfile.write(chunk) + except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError): + pass + finally: + upstream_conn.close() + + def read_json_body(self): + try: + length = int(self.headers.get("Content-Length", "0") or "0") + except (TypeError, ValueError) as exc: + raise ValueError("invalid Content-Length") from exc + if length < 0 or length > MAX_JSON_BODY_BYTES: + raise ValueError("JSON request body too large") + if length <= 0: + return {} + data = self.rfile.read(length) + return json.loads(data.decode("utf-8")) + + def send_json(self, data, status=200): + body = json.dumps(data, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + try: + self.wfile.write(body) + except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError): + pass + + def handle_upload(self, params): + filename = str((params.get("name") or [""])[0]) + dst = input_upload_path(filename) + if dst is None: + return self.send_json({"ok": False, "error": "неподдерживаемое имя файла"}, status=400) + try: + size = int(self.headers.get("Content-Length", "0") or "0") + except ValueError: + return self.send_json({"ok": False, "error": "неверный размер файла"}, status=400) + if size <= 0: + return self.send_json({"ok": False, "error": "пустой файл"}, status=400) + max_size = int(os.environ.get("FPV_UI_MAX_UPLOAD_BYTES", str(64 * 1024**3))) + if size > max_size: + return self.send_json({"ok": False, "error": "файл превышает лимит загрузки"}, status=413) + + temp = dst.with_name(f".{dst.name}.{threading.get_ident()}.upload") + try: + INPUT_DIR.mkdir(parents=True, exist_ok=True) + with temp.open("wb", buffering=4 * 1024 * 1024) as out: + copy_exact(self.rfile, out, size) + temp.replace(dst) + state = read_control_state() + requested_mode = str((params.get("source_mode") or [""])[0]).lower() + if requested_mode in FILE_SOURCE_MODES: + state["source_mode"] = requested_mode + else: + state["source_mode"] = "udp_dump" if input_file_kind(dst) == "udp_dump" else "file" + state["file_path"] = str(dst) + write_json_file(CONTROL_PATH, state) + return self.send_json({"ok": True, "path": str(dst), "size": size, "control": control_payload()}) + except Exception as exc: + return self.send_json({"ok": False, "error": str(exc)}, status=500) + finally: + try: + temp.unlink() + except OSError: + pass + + def handle_model_upload(self, params): + if process_running(): + return self.send_json({"ok": False, "error": "остановите инференс перед загрузкой модели"}, status=409) + filename = str((params.get("name") or [""])[0]) + dst = model_upload_path(filename) + if dst is None: + return self.send_json({"ok": False, "error": "нужен файл модели .pt без пути"}, status=400) + try: + size = int(self.headers.get("Content-Length", "0") or "0") + except ValueError: + return self.send_json({"ok": False, "error": "неверный размер файла"}, status=400) + if size <= 0: + return self.send_json({"ok": False, "error": "пустой файл"}, status=400) + max_size = int(os.environ.get("FPV_UI_MAX_MODEL_UPLOAD_BYTES", str(8 * 1024**3))) + if size > max_size: + return self.send_json({"ok": False, "error": "модель превышает лимит загрузки"}, status=413) + + temp = dst.with_name(f".{dst.name}.{threading.get_ident()}.upload") + try: + MODEL_DIR.mkdir(parents=True, exist_ok=True) + with temp.open("wb", buffering=4 * 1024 * 1024) as out: + copy_exact(self.rfile, out, size) + temp.replace(dst) + state = read_control_state() + state["model_path"] = str(dst) + write_json_file(CONTROL_PATH, state) + return self.send_json({"ok": True, "path": str(dst), "size": size, "control": control_payload()}) + except Exception as exc: + return self.send_json({"ok": False, "error": str(exc)}, status=500) + finally: + try: + temp.unlink() + except OSError: + pass + + def send_bytes(self, data, content_type): + self.send_response(200) + self.send_header("Content-Type", content_type) + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + try: + self.wfile.write(data) + except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError): + pass + + def send_file(self, path, content_type=None, attachment=False, download_name=None): + try: + size = path.stat().st_size + except FileNotFoundError: + self.send_error(404) + return + ctype = content_type or mimetypes.guess_type(path.name)[0] or "application/octet-stream" + range_header = self.headers.get("Range") + if range_header: + parsed = parse_range_header(range_header, size) + if not parsed: + self.send_response(416) + self.send_header("Accept-Ranges", "bytes") + self.send_header("Content-Range", f"bytes */{size}") + self.end_headers() + return + start, end = parsed + self.send_response(206) + self.send_header("Content-Type", ctype) + self.send_header("Accept-Ranges", "bytes") + self.send_header("Content-Range", f"bytes {start}-{end}/{size}") + self.send_header("Content-Length", str(end - start + 1)) + if attachment: + self.send_header("Content-Disposition", content_disposition(download_name or path.name)) + self.end_headers() + try: + with path.open("rb") as f: + f.seek(start) + copy_bytes(f, self.wfile, end - start + 1) + except FileNotFoundError: + pass + return + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Accept-Ranges", "bytes") + self.send_header("Content-Length", str(size)) + if attachment: + self.send_header("Content-Disposition", content_disposition(download_name or path.name)) + self.end_headers() + try: + with path.open("rb") as f: + copy_bytes(f, self.wfile, size) + except FileNotFoundError: + pass + + def send_h264_download(self, path): + try: + download_path = h264_download_file(path) + except RuntimeError as exc: + self.send_error(500, str(exc)[:160]) + return + return self.send_file( + download_path, + content_type="video/mp4", + attachment=True, + download_name=h264_download_name(path), + ) + + def send_mjpeg(self): + try: + self.connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + except OSError: + pass + self.send_response(200) + self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame") + self.send_header("Cache-Control", "no-store, no-cache, must-revalidate") + self.send_header("Pragma", "no-cache") + self.end_headers() + last_jpeg = None + try: + while True: + try: + jpeg = FRAME_PATH.read_bytes() + if jpeg and jpeg != last_jpeg: + self.wfile.write( + b"--frame\r\n" + b"Content-Type: image/jpeg\r\n" + + f"Content-Length: {len(jpeg)}\r\n\r\n".encode("ascii") + + jpeg + + b"\r\n" + ) + self.wfile.flush() + last_jpeg = jpeg + except FileNotFoundError: + pass + time.sleep(0.01) + except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError): + return + + def send_transcoded_video(self, path): + ffmpeg = ffmpeg_executable() + if not ffmpeg: + self.send_error(500, "ffmpeg unavailable") + return + command = [ + ffmpeg, "-hide_banner", "-loglevel", "error", "-i", str(path), + "-an", "-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency", + "-crf", "18", + "-pix_fmt", "yuv420p", "-movflags", "frag_keyframe+empty_moov+default_base_moof", + "-f", "mp4", "pipe:1", + ] + try: + proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + except OSError: + self.send_error(500, "ffmpeg unavailable") + return + self.send_response(200) + self.send_header("Content-Type", "video/mp4") + self.send_header("Cache-Control", "no-store") + self.end_headers() + try: + copy_stream(proc.stdout, self.wfile) + finally: + if proc.poll() is None: + proc.kill() + proc.wait() + + +def main(): + removed = cleanup_empty_recordings(OUT_DIR) + if removed: + print(f"Removed empty recordings: {removed}", flush=True) + server = ThreadingHTTPServer((HOST, PORT), Handler) + print(f"UI listening on http://{HOST}:{PORT}", flush=True) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/yolo_worker.py b/yolo_worker.py index 12297f8..28bd0a9 100644 --- a/yolo_worker.py +++ b/yolo_worker.py @@ -8,29 +8,82 @@ import torch from config import * from helpers import clip_box, crop_roi, preprocess_for_yolo, filter_yolo_boxes_with_scores +INFERENCE_DEVICE = DEVICE if torch.cuda.is_available() and int(DEVICE) >= 0 else "cpu" +INFERENCE_SIZE_FULL = IMG_SIZE_FULL if torch.cuda.is_available() else min(IMG_SIZE_FULL, 640) +INFERENCE_HALF = bool(USE_HALF and torch.cuda.is_available()) + # Async YOLO worker # ========================= + +def raw_yolo_boxes(result, offset_x=0, offset_y=0, scale=1.0, pad_x=0.0, pad_y=0.0): + if result.boxes is None or len(result.boxes) == 0: + return [] + xyxy = result.boxes.xyxy.detach().cpu().numpy() + confs = result.boxes.conf.detach().cpu().numpy() + clss = result.boxes.cls.detach().cpu().numpy().astype(int) + out = [] + for b, c, cls_id in zip(xyxy, confs, clss): + x1, y1, x2, y2 = map(float, b) + out.append(np.array([ + (x1 - pad_x) * scale + offset_x, + (y1 - pad_y) * scale + offset_y, + (x2 - pad_x) * scale + offset_x, + (y2 - pad_y) * scale + offset_y, + float(c), + float(cls_id), + ], dtype=np.float32)) + return out + + +def fixed_letterbox(image, size): + h, w = image.shape[:2] + scale = min(float(size) / max(1, w), float(size) / max(1, h)) + new_w = max(1, min(int(size), int(round(w * scale)))) + new_h = max(1, min(int(size), int(round(h * scale)))) + interpolation = cv2.INTER_AREA if scale < 1.0 else cv2.INTER_LINEAR + resized = cv2.resize(image, (new_w, new_h), interpolation=interpolation) + left = (int(size) - new_w) // 2 + top = (int(size) - new_h) // 2 + canvas = np.full((int(size), int(size), 3), 114, dtype=np.uint8) + canvas[top:top + new_h, left:left + new_w] = resized + return canvas, scale, left, top + + class YOLOWorker: - def __init__(self, model): + def __init__(self, model, full_frame_shape=None): self.model = model + self.full_frame_shape = full_frame_shape self.req = deque(maxlen=YOLO_QUEUE_MAX) self.res = deque(maxlen=1) self.lock = threading.Lock() + self.request_event = threading.Event() + self.ready_event = threading.Event() + self.startup_error = None + self.last_error = None self.running = False self.thread = threading.Thread(target=self._loop, daemon=True) def start(self): self.running = True self.thread.start() + if not self.ready_event.wait(timeout=120.0): + self.stop() + raise RuntimeError("YOLO worker startup timed out") + if self.startup_error is not None: + raise RuntimeError(f"YOLO worker startup failed: {self.startup_error}") from self.startup_error def stop(self): self.running = False - self.thread.join(timeout=1.0) + with self.lock: + self.req.clear() + self.request_event.set() + self.thread.join(timeout=5.0) def submit(self, frame_eff_bgr, roi_box_eff, mode, ts): with self.lock: self.req.append((frame_eff_bgr, roi_box_eff, mode, ts)) + self.request_event.set() def try_get(self): with self.lock: @@ -39,7 +92,17 @@ class YOLOWorker: return self.res.pop() def _loop(self): + try: + self._warmup() + except Exception as exc: + self.startup_error = exc + self.ready_event.set() + return + self.ready_event.set() + while self.running: + self.request_event.wait(timeout=0.2) + self.request_event.clear() item = None with self.lock: if self.req: @@ -47,13 +110,13 @@ class YOLOWorker: self.req.clear() if item is None: - time.sleep(0.001) continue frame, roi_box, mode, ts = item h, w = frame.shape[:2] frame_infer = preprocess_for_yolo(frame) dets = [] + raw_dets = [] infer_ms = 0.0 used_roi = False @@ -63,32 +126,46 @@ class YOLOWorker: crop, ox, oy = crop_roi(frame_infer, roi_box) if crop.size > 0: used_roi = True + crop_model, input_scale, pad_x, pad_y = fixed_letterbox(crop, IMG_SIZE_ROI) t0 = time.perf_counter() with torch.inference_mode(): r = self.model( - crop, + crop_model, conf=YOLO_CONF_EFFECTIVE, imgsz=IMG_SIZE_ROI, verbose=False, max_det=MAX_DET, - device=DEVICE, - half=USE_HALF + device=INFERENCE_DEVICE, + half=INFERENCE_HALF )[0] infer_ms = (time.perf_counter() - t0) * 1000.0 + raw_dets = raw_yolo_boxes( + r, + offset_x=ox, + offset_y=oy, + scale=1.0 / input_scale, + pad_x=pad_x, + pad_y=pad_y, + ) dets = filter_yolo_boxes_with_scores( r, frame_w=w, frame_h=h, offset_x=ox, offset_y=oy, - min_conf=BT_LOW + min_conf=BT_LOW, + input_scale=input_scale, + pad_x=pad_x, + pad_y=pad_y, + content_w=crop.shape[1], + content_h=crop.shape[0], ) else: sh, sw = frame_infer.shape[:2] short = min(sh, sw) scale = 1.0 - target = IMG_SIZE_FULL + target = INFERENCE_SIZE_FULL if short > target: scale = target / float(short) small = cv2.resize( @@ -104,13 +181,14 @@ class YOLOWorker: r = self.model( small, conf=YOLO_CONF_EFFECTIVE, - imgsz=IMG_SIZE_FULL, + imgsz=INFERENCE_SIZE_FULL, verbose=False, max_det=MAX_DET, - device=DEVICE, - half=USE_HALF + device=INFERENCE_DEVICE, + half=INFERENCE_HALF )[0] infer_ms = (time.perf_counter() - t0) * 1000.0 + raw_small = raw_yolo_boxes(r) dets_s = filter_yolo_boxes_with_scores( r, @@ -122,19 +200,48 @@ class YOLOWorker: ) if scale != 1.0: inv = 1.0 / scale + raw_dets = [ + np.array([d[0] * inv, d[1] * inv, d[2] * inv, d[3] * inv, d[4], d[5]], dtype=np.float32) + for d in raw_small + ] dets = [ np.array([d[0] * inv, d[1] * inv, d[2] * inv, d[3] * inv, d[4]], dtype=np.float32) for d in dets_s ] else: + raw_dets = raw_small dets = dets_s - except Exception: + except Exception as exc: dets = [] + raw_dets = [] infer_ms = 0.0 + message = f"{type(exc).__name__}: {exc}" + if message != self.last_error: + print(f"[yolo] inference failed: {message}", flush=True) + self.last_error = message with self.lock: - self.res.append((dets, ts, mode, infer_ms, used_roi)) + self.res.append((dets, ts, mode, infer_ms, used_roi, raw_dets)) + + def _warmup(self): + if not torch.cuda.is_available(): + return + dummy_roi = np.zeros((IMG_SIZE_ROI, IMG_SIZE_ROI, 3), dtype=np.uint8) + full_h, full_w = self.full_frame_shape or (IMG_SIZE_FULL, IMG_SIZE_FULL) + dummy_full = np.zeros((max(1, int(full_h)), max(1, int(full_w)), 3), dtype=np.uint8) + with torch.inference_mode(): + for frame, size in ((dummy_roi, IMG_SIZE_ROI), (dummy_full, IMG_SIZE_FULL)): + self.model( + frame, + conf=YOLO_CONF_EFFECTIVE, + imgsz=size, + verbose=False, + max_det=MAX_DET, + device=INFERENCE_DEVICE, + half=USE_HALF, + ) + print(f"Model warmed up in YOLO worker at {IMG_SIZE_ROI} and {IMG_SIZE_FULL}", flush=True) # =========================