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.
main
AlexsandrSnytkin 2 days ago
parent 4340aa0790
commit 52a203a5a2

@ -30,3 +30,11 @@ rollback_*.csv
!best.pt !best.pt
!docs/superpowers/specs/2026-06-29-docker-offline-gpu-proto-udp-design.md !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 !docs/superpowers/plans/2026-06-29-docker-offline-gpu-proto-udp-implementation.md
runtime-data/
.superpowers/
.git/
.vscode/
0/
data/
USB/
tests/

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

2
.gitattributes vendored

@ -0,0 +1,2 @@
*.sh text eol=lf
Dockerfile text eol=lf

26
.gitignore vendored

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

@ -31,13 +31,17 @@ RUN python3 -m pip install --upgrade pip setuptools wheel && \
COPY . /app 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 chmod +x /app/docker/entrypoint.sh
ENV FPV_MODEL_PATH=/app/best.pt \ ENV FPV_MODEL_PATH=/app/best.pt \
FPV_SHOW_OUTPUT=0 \ FPV_SHOW_OUTPUT=0 \
FPV_SAVE_INFER_VIDEO=1 \ FPV_SAVE_INFER_VIDEO=1 \
FPV_OUT_VIDEO_PATH=/data/out/out_infer.mp4 \ 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_ENABLE=1 \
FPV_GUIDANCE_EXPORT_PATH=/data/guidance/guidance_state.json \ FPV_GUIDANCE_EXPORT_PATH=/data/guidance/guidance_state.json \
FPV_AUTOPILOT_ENABLE=1 \ FPV_AUTOPILOT_ENABLE=1 \

@ -4,7 +4,7 @@
Проект умеет: Проект умеет:
- принимать видео с файла, камеры, RTSP и UDP-потока; - принимать видео с файла, камеры, RTSP, UDP-потока и готового UDP-дампа камеры;
- обнаруживать и сопровождать цель; - обнаруживать и сопровождать цель;
- удерживать цель при кратковременной потере детекции; - удерживать цель при кратковременной потере детекции;
- строить команды наведения в экранных координатах; - строить команды наведения в экранных координатах;
@ -41,6 +41,19 @@ docker compose build
docker compose up 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) - [Полный runbook](docs/RUNBOOK_RU.md)
@ -66,6 +79,8 @@ python main.py
## Основные файлы ## Основные файлы
- `main.py` — основной цикл обработки видео. - `main.py` — основной цикл обработки видео.
- `hdmi_usb_bridge.py` — мост Windows DirectShow → MJPEG для Docker.
- `start-hdmi-bridge.ps1` — фоновый запуск HDMI-моста.
- `config.py` — настройки источника, детектора, трекинга, ROI и вывода. - `config.py` — настройки источника, детектора, трекинга, ROI и вывода.
- `config_intercept.py` — настройки наведения, range/PN/FSM и backend'ов. - `config_intercept.py` — настройки наведения, range/PN/FSM и backend'ов.
- `guidance.py` — экранный guidance и экспорт `guidance_state.json`. - `guidance.py` — экранный guidance и экспорт `guidance_state.json`.
@ -78,8 +93,8 @@ python main.py
В зависимости от настроек проект может создавать: В зависимости от настроек проект может создавать:
- `out_infer_*.mp4` — видео с наложениями; - `out_infer_*.mp4` — видео с наложениями;
- `track_log_*.csv` — покадровый лог; - `runtime-data/track-logs/track_log_*.csv` — покадровый лог;
- `track_summary_*.json` — сводка по прогону; - `runtime-data/track-summaries/track_summary_*.json` — сводка по прогону;
- `guidance_state.json` — текущее состояние guidance; - `guidance_state.json` — текущее состояние guidance;
- `autopilot_cmd.json` — команды автопилота при backend `json`. - `autopilot_cmd.json` — команды автопилота при backend `json`.
@ -93,3 +108,46 @@ python main.py
- `requirements-docker.txt` - `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-порт, ширина `168192`, высота `168192` и FPS `1240`. В 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"}
```

@ -26,10 +26,16 @@ docker compose build
docker compose up docker compose up
``` ```
По умолчанию поднимается UI на `http://localhost:8080`; модель запускается кнопкой «Старт». Отдельный headless-процесс без UI:
```bash
docker compose --profile standalone up fpv-tracker
```
## Основные env-переменные ## Основные env-переменные
- `FPV_SOURCE` - `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_MODEL_PATH`
- `FPV_SHOW_OUTPUT` - `FPV_SHOW_OUTPUT`
- `FPV_SAVE_INFER_VIDEO` - `FPV_SAVE_INFER_VIDEO`

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

@ -50,73 +50,18 @@ def iou_matrix(tracks_tlbr, dets_tlbr):
def hungarian(cost): def hungarian(cost):
cost = cost.copy()
n, m = cost.shape n, m = cost.shape
size = max(n, m) pairs = [(float(cost[i, j]), i, j) for i in range(n) for j in range(m)]
pairs.sort(key=lambda x: x[0])
pad = np.zeros((size, size), dtype=np.float32) used_rows = set()
pad[:n, :m] = cost used_cols = set()
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]
matches = [] matches = []
for row, col in enumerate(row_to_col): for _, row, col in pairs:
if 0 <= col < m: if row in used_rows or col in used_cols:
matches.append((row, int(col))) continue
matches.append((row, col))
used_rows.add(row)
used_cols.add(col)
return matches return matches

@ -1,10 +1,20 @@
from pathlib import Path
import cv2 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) # Configuration and tunables (extracted from original script)
# ========================= # =========================
# ВАЖНО: укажите путь к новой YOLO11s-P2-CBAM модели (best.pt, imgsz=1280) # ВАЖНО: укажите путь к новой 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 DEVICE = 0
USE_HALF = True USE_HALF = True
@ -17,15 +27,21 @@ USE_HALF = True
# RTSP: # RTSP:
# SOURCE = "rtsp://192.168.1.10:8554/live" # SOURCE = "rtsp://192.168.1.10:8554/live"
# UDP: # UDP:
# SOURCE = "udp://@0.0.0.0:5600" SOURCE = "udp://@0.0.0.0:59004"
SOURCE = r"C:\Users\Legion\Desktop\Видео для МАИ ФУЛЛ\Группа 1\23-01_10один.mp4" #SOURCE = r"D:\123.mp4"
VIDEO_REALTIME = True #SOURCE = _default_source()
VIDEO_REALTIME = False
# Windows: # Windows:
CAP_BACKEND = cv2.CAP_DSHOW # или cv2.CAP_MSMF CAP_BACKEND = cv2.CAP_FFMPEG # или cv2.CAP_MSMF
# Linux обычно: # Linux обычно:
# CAP_BACKEND = cv2.CAP_V4L2 # CAP_BACKEND = cv2.CAP_V4L2
CAMERA_WIDTH = 0
CAMERA_HEIGHT = 0
CAMERA_FPS = 0
CAMERA_FOURCC = "MJPG"
SHOW_OUTPUT = True SHOW_OUTPUT = True
WINDOW_NAME = "FPV LOCK PAL + ByteTrack" WINDOW_NAME = "FPV LOCK PAL + ByteTrack"
@ -58,8 +74,9 @@ ASPECT_RANGE = (0.15, 6.0)
# FSM # FSM
CONFIRM_HITS = 2 CONFIRM_HITS = 2
MAX_MISSES = 20 MAX_MISSES = 20
RECOVER_FULLSCAN_EVERY = 30 RECOVER_FULLSCAN_EVERY = 12
RECOVER_FORCED_DET_EVERY = 2 RECOVER_FORCED_DET_EVERY = 1
UNVERIFIED_FORCE_FULLSCAN = True
# ROI sizing (in effective pixels) # ROI sizing (in effective pixels)
BASE_RADIUS = 150 BASE_RADIUS = 150
@ -116,7 +133,7 @@ KALMAN_Q_DT_BOOST_SLOPE = 16.0
KALMAN_Q_DT_BOOST_MAX = 4.0 KALMAN_Q_DT_BOOST_MAX = 4.0
# Appearance gate # Appearance gate
USE_HSV_GATE = False USE_HSV_GATE = True
HSV_HIST_BINS = (16, 16) HSV_HIST_BINS = (16, 16)
HSV_GATE_MIN_SIM = 0.06 HSV_GATE_MIN_SIM = 0.06
HSV_UPDATE_EVERY = 8 HSV_UPDATE_EVERY = 8
@ -125,20 +142,33 @@ HSV_UPDATE_EVERY = 8
YOLO_QUEUE_MAX = 1 YOLO_QUEUE_MAX = 1
# Debug / draw # Debug / draw
DEBUG = True DEBUG = False
DRAW_ALL_BOXES = True DRAW_ALL_BOXES = True
DRAW_RAW_YOLO_BOXES = True
RAW_YOLO_DRAW_TTL_FRAMES = 5
DRAW_LOCK_BOX = True DRAW_LOCK_BOX = True
DRAW_KALMAN = True DRAW_KALMAN = True
DRAW_KLT_POINTS = False DRAW_KLT_POINTS = True
DRAW_BT_TRACKS = True DRAW_BT_TRACKS = True
BT_DRAW_ONLY_CONFIRMED = False BT_DRAW_ONLY_CONFIRMED = True
# Output recording # Output recording
SAVE_INFER_VIDEO = True 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_ENABLE = True
TRACK_LOG_PATH = "track_log.csv" TRACK_LOG_PATH = "runtime-data/track-logs/track_log.csv"
TRACK_SUMMARY_PATH = "track_summary.json" TRACK_SUMMARY_PATH = "runtime-data/track-summaries/track_summary.json"
TRACK_LOG_FLUSH_EVERY = 30 TRACK_LOG_FLUSH_EVERY = 30
# Screen-space lock/guidance layer for simulator integration # Screen-space lock/guidance layer for simulator integration
@ -147,6 +177,18 @@ DRAW_GUIDANCE = True
GUIDANCE_EXPORT_ENABLE = True GUIDANCE_EXPORT_ENABLE = True
GUIDANCE_EXPORT_PATH = "guidance_state.json" GUIDANCE_EXPORT_PATH = "guidance_state.json"
GUIDANCE_EXPORT_EVERY = 1 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_LEAD_SEC = 0.12
GUIDANCE_MAX_LEAD_PX = 90.0 GUIDANCE_MAX_LEAD_PX = 90.0
GUIDANCE_BOX_BIAS_Y = 0.0 GUIDANCE_BOX_BIAS_Y = 0.0
@ -164,13 +206,13 @@ GUIDANCE_OVERRIDE_MISS_GE = 2
GUIDANCE_OVERRIDE_TTL = 2 GUIDANCE_OVERRIDE_TTL = 2
# Trajectory drawing # Trajectory drawing
DRAW_TRAJ = False DRAW_TRAJ = True
TRAIL_SECONDS = 1.2 TRAIL_SECONDS = 1.2
TRAIL_ALPHA = 0.25 TRAIL_ALPHA = 0.25
TRAIL_DRAW_EVERY_N = 2 TRAIL_DRAW_EVERY_N = 2
TRAIL_MIN_STEP_PX = 3 TRAIL_MIN_STEP_PX = 3
CLEAR_TRAJ_ON_RECOVER = True CLEAR_TRAJ_ON_RECOVER = True
DRAW_TRAJ_ONLY_WHEN_LOCKED = True DRAW_TRAJ_ONLY_WHEN_LOCKED = False
DRAW_KALMAN_WHEN_MISS_LE = 8 DRAW_KALMAN_WHEN_MISS_LE = 8
@ -314,9 +356,11 @@ PRE_UNSHARP = 0.12
# OSD false positives rejection # OSD false positives rejection
REJECT_OSD_ZONES = True REJECT_OSD_ZONES = True
REJECT_OSD_SMALL_AREA_MAX = 2600.0 REJECT_OSD_SMALL_AREA_MAX = 2600.0
REJECT_OSD_EDGE_ANCHORED = True
REJECT_OSD_EDGE_MARGIN = 4
REJECT_OSD_ZONES_NORM = [ REJECT_OSD_ZONES_NORM = [
(0.00, 0.00, 0.20, 0.80), (0.00, 0.00, 0.24, 0.90),
(0.80, 0.00, 1.00, 0.80), (0.76, 0.00, 1.00, 0.90),
(0.00, 0.00, 1.00, 0.08), (0.00, 0.00, 1.00, 0.08),
(0.35, 0.90, 0.65, 1.00), (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_MAX_ACTIVE_ZONE_RATIO = 0.55
MOTION_ROI_MARGIN = 28 MOTION_ROI_MARGIN = 28
MOTION_ROI_MIN_SIZE = 80 MOTION_ROI_MIN_SIZE = 80
DRAW_MOTION_ROI = False DRAW_MOTION_ROI = True
MOTION_TURN_THR_GAIN = 2.0 MOTION_TURN_THR_GAIN = 2.0
MOTION_TURN_THR_MAX_BONUS = 10 MOTION_TURN_THR_MAX_BONUS = 10
MOTION_ZONE_HEAT_DECAY = 0.88 MOTION_ZONE_HEAT_DECAY = 0.88
@ -406,7 +450,7 @@ WAVELET_ROI_PRED_SCALE = 3.0
WAVELET_ROI_MIN_PEAK_RATIO = 1.5 WAVELET_ROI_MIN_PEAK_RATIO = 1.5
# AutoGaze assist (optional ROI prior for recover) # 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_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_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 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_MIN_ACTIVE_CELLS = 1
AUTOGAZE_ROI_MARGIN_CELLS = 1 AUTOGAZE_ROI_MARGIN_CELLS = 1
AUTOGAZE_ROI_MIN_SIDE = 90 AUTOGAZE_ROI_MIN_SIDE = 90
DRAW_AUTOGAZE_ROI = False DRAW_AUTOGAZE_ROI = True
MODULE_TIME_BUDGET_MS = 30.0 MODULE_TIME_BUDGET_MS = 30.0
AUTOGAZE_COOLDOWN_FRAMES = 12 AUTOGAZE_COOLDOWN_FRAMES = 12
WAVELET_COOLDOWN_FRAMES = 8 WAVELET_COOLDOWN_FRAMES = 8
@ -632,7 +676,7 @@ HSV_GATE_MIN_SIM = 0.10
HSV_UPDATE_EVERY = 4 HSV_UPDATE_EVERY = 4
# Wavelet чаще цепляет текстуру земли на chase-сценах. # Wavelet чаще цепляет текстуру земли на chase-сценах.
WAVELET_ROI_ENABLE = False WAVELET_ROI_ENABLE = True
# AutoGaze подключаем только на более явном срыве. # AutoGaze подключаем только на более явном срыве.
AUTOGAZE_MISS_GE = 4 AUTOGAZE_MISS_GE = 4
@ -683,7 +727,7 @@ APPROACH_CLOSE_SWITCH_EXTRA_MISS = 0
APPROACH_CLOSE_SWITCH_EXTRA_HITS = 0 APPROACH_CLOSE_SWITCH_EXTRA_HITS = 0
SWITCH_FAST_MANEUVER_EXTRA_MISS = 0 SWITCH_FAST_MANEUVER_EXTRA_MISS = 0
SWITCH_FAST_MANEUVER_EXTRA_HITS = 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_ENABLE = True
STALE_LOCK_BREAK_DIST_DIAG = 0.8 STALE_LOCK_BREAK_DIST_DIAG = 0.8
STALE_LOCK_BREAK_MIN_MISS = 1 STALE_LOCK_BREAK_MIN_MISS = 1
@ -798,6 +842,14 @@ TRAJ_REANCHOR_DIST_DIAG = 4.2
TRAJ_USE_PRIMARY_FOR_HOLD = True TRAJ_USE_PRIMARY_FOR_HOLD = True
TRAJ_HOLD_MISS_GE = 1 TRAJ_HOLD_MISS_GE = 1
DRAW_TRAJ_PREDICTIONS = True 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 # 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. # 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_WHEN_KLT_INVALID = True
CLOSE_FULLSCAN_TARGET_ABSENT_GE = 2 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 from runtime_env import apply_config_env_overrides
apply_config_env_overrides(globals()) apply_config_env_overrides(globals())
YOLO_CONF_EFFECTIVE = min(CONF, BT_LOW)

@ -163,7 +163,7 @@ IMM_R_POS = 16.0
IMM_R_SIZE = 25.0 IMM_R_SIZE = 25.0
# Заменить стандартный Kalman8D на IMM (если True, main.py использует IMM) # Заменить стандартный Kalman8D на IMM (если True, main.py использует IMM)
IMM_REPLACE_KALMAN = False # включить после тестирования IMM_REPLACE_KALMAN = True # включить после тестирования
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
# 6. DRAW / DEBUG для новых модулей # 6. DRAW / DEBUG для новых модулей

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

@ -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("<u2") if encoding == "gray16" else np.dtype(np.uint8)
expected = int(np.prod(shapes[encoding]) * dtype.itemsize)
if len(data) != expected:
raise ValueError(f"raw frame size {len(data)} != {expected}")
pixels = np.frombuffer(data, dtype=dtype).reshape(shapes[encoding])
if encoding == "bgr24":
return pixels.copy()
if encoding == "rgb24":
return cv2.cvtColor(pixels, cv2.COLOR_RGB2BGR)
if encoding == "yuyv422":
return cv2.cvtColor(pixels, cv2.COLOR_YUV2BGR_YUY2)
if encoding == "gray16":
pixels = (pixels >> 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()

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

@ -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: services:
fpv-tracker: fpv-tracker:
profiles: ["standalone"]
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
PYTORCH_INDEX_URL: https://download.pytorch.org/whl/cu128 PYTORCH_INDEX_URL: ${PYTORCH_INDEX_URL:-https://download.pytorch.org/whl/cu128}
image: fpv-tracker:cu128-offline 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 gpus: all
restart: unless-stopped restart: unless-stopped
stdin_open: true command: ["python3", "ui_server.py"]
tty: true
environment: environment:
FPV_MODEL_PATH: /app/best.pt <<: *fpv-env
FPV_SOURCE: "0" FPV_DATA_DIR: /data
FPV_SHOW_OUTPUT: "0" FPV_UI_HOST: ${FPV_UI_HOST:-0.0.0.0}
FPV_SAVE_INFER_VIDEO: "1" FPV_UI_PORT: 8080
FPV_OUT_VIDEO_PATH: /data/out/out_infer.mp4 FPV_UI_LOG_PATH: /data/logs/main.log
FPV_GUIDANCE_EXPORT_ENABLE: "1" FPV_UI_FRAME_PATH: ${FPV_UI_FRAME_PATH:-/dev/shm/fpv-latest.jpg}
FPV_GUIDANCE_EXPORT_PATH: /data/guidance/guidance_state.json FPV_UI_GUIDANCE_PATH: /data/guidance/guidance_state.json
FPV_AUTOPILOT_ENABLE: "1" FPV_UI_OUT_DIR: /data/out
FPV_AUTOPILOT_BACKEND: "json" FPV_UI_INPUT_DIR: /data/input
FPV_AUTOPILOT_JSON_PATH: /data/autopilot/autopilot_cmd.json FPV_CAMERA_BRIDGE_URL: ${FPV_CAMERA_BRIDGE_URL:-http://host.docker.internal:8091/stream.mjpg}
FPV_PROTO_UDP_ENABLE: "0"
FPV_PROTO_UDP_HOST: "192.168.1.10"
FPV_PROTO_UDP_PORT: "5005"
volumes: 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: ports:
- "5600:5600/udp" - "${FPV_UI_BIND:-127.0.0.1}:${FPV_UI_PORT:-8080}:8080/tcp"
# Для Linux-камеры можно раскомментировать:
# devices:
# - /dev/video0:/dev/video0

@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail 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 if [[ -n "${FPV_OUT_VIDEO_PATH:-}" ]]; then
mkdir -p "$(dirname "${FPV_OUT_VIDEO_PATH}")" mkdir -p "$(dirname "${FPV_OUT_VIDEO_PATH}")"

@ -203,8 +203,8 @@ FSM фаз:
Выход: Выход:
- `track_log_*.csv` - `runtime-data/track-logs/track_log_*.csv`
- `track_summary_*.json` - `runtime-data/track-summaries/track_summary_*.json`
## 11. Что не относится к основному runtime ## 11. Что не относится к основному runtime

@ -143,6 +143,11 @@
- `FPV_OUT_VIDEO_PATH` - `FPV_OUT_VIDEO_PATH`
- `FPV_GUIDANCE_EXPORT_ENABLE` - `FPV_GUIDANCE_EXPORT_ENABLE`
- `FPV_GUIDANCE_EXPORT_PATH` - `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` ### Переопределения `config_intercept.py`

@ -318,8 +318,8 @@ devices:
## 11. Какие файлы появляются после запуска ## 11. Какие файлы появляются после запуска
- `out_infer_*.mp4` — видео с отрисовкой - `out_infer_*.mp4` — видео с отрисовкой
- `track_log_*.csv` — покадровый лог логики выбора цели - `runtime-data/track-logs/track_log_*.csv` — покадровый лог логики выбора цели
- `track_summary_*.json` — итоговая сводка - `runtime-data/track-summaries/track_summary_*.json` — итоговая сводка
- `guidance_state.json` — состояние guidance - `guidance_state.json` — состояние guidance
- `autopilot_cmd.json` — внешний файл команд для backend `json` - `autopilot_cmd.json` — внешний файл команд для backend `json`

@ -74,7 +74,7 @@ print(torch.cuda.is_available())
Проверки: Проверки:
1. Что в overlay пишется в строках `miss`, `KLT valid`, `G HOLD` или `G REACQ`. 1. Что в overlay пишется в строках `miss`, `KLT valid`, `G HOLD` или `G REACQ`.
2. Есть ли свежий `track_log_*.csv`. 2. Есть ли свежий `runtime-data/track-logs/track_log_*.csv`.
3. Не слишком ли жесткие условия handoff/switch. 3. Не слишком ли жесткие условия handoff/switch.
## 7. Не создается `guidance_state.json` ## 7. Не создается `guidance_state.json`
@ -167,6 +167,6 @@ Permission denied: guidance_state.json.tmp
1. какой источник видео использовался; 1. какой источник видео использовался;
2. локальный запуск или Docker; 2. локальный запуск или Docker;
3. backend команд; 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; 5. скриншот overlay;
6. точный текст ошибки. 6. точный текст ошибки.

@ -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("<BBBhhbbb")
def build_error_payload(
state,
*,
units="px",
hfov_deg=90.0,
vfov_deg=60.0,
range_m=0.0,
object_id=1,
timestamp=None,
):
frame_w = max(1.0, float(state.get("frame_w") or 1.0))
frame_h = max(1.0, float(state.get("frame_h") or 1.0))
x_norm = float(clamp(float(state.get("error_x") or 0.0), -1.0, 1.0))
y_norm = float(clamp(float(state.get("error_y") or 0.0), -1.0, 1.0))
x_px = x_norm * 0.5 * frame_w
y_px = y_norm * 0.5 * frame_h
hfov_rad = math.radians(float(hfov_deg))
vfov_rad = math.radians(float(vfov_deg))
x_rad = math.atan(math.tan(0.5 * hfov_rad) * x_norm)
y_rad = math.atan(math.tan(0.5 * vfov_rad) * y_norm)
x_deg = math.degrees(x_rad)
y_deg = math.degrees(y_rad)
distance_m = float(range_m or 0.0)
meter_valid = distance_m > 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

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

@ -222,14 +222,14 @@ class ScreenGuidanceController:
return state return state
def draw_overlay(self, frame_bgr, state, sx, sy): 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 return
frame_h, frame_w = frame_bgr.shape[:2] frame_h, frame_w = frame_bgr.shape[:2]
cx = int(0.5 * frame_w) cx = int(0.5 * frame_w)
cy = int(0.5 * frame_h) 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) cv2.drawMarker(frame_bgr, (cx, cy), color, markerType=cv2.MARKER_CROSS, markerSize=18, thickness=1)
aim_x = state.get("aim_x", None) aim_x = state.get("aim_x", None)

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

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

@ -1,7 +1,9 @@
import cv2 import cv2
import numpy as np import numpy as np
import time
from config import * from config import *
from ffmpeg_capture import FFmpegCapture
# Helpers and utility functions # Helpers and utility functions
# ========================= # =========================
@ -172,7 +174,48 @@ def in_osd_zone(center_x, center_y, frame_w, frame_h):
return False 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 = [] dets_out = []
if result.boxes is None or len(result.boxes) == 0: if result.boxes is None or len(result.boxes) == 0:
return dets_out return dets_out
@ -189,6 +232,17 @@ def filter_yolo_boxes_with_scores(result, frame_w, frame_h, offset_x=0, offset_y
continue continue
x1, y1, x2, y2 = map(float, b) 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) ww = max(0.0, x2 - x1)
hh = max(0.0, y2 - y1) hh = max(0.0, y2 - y1)
if ww <= 1.0 or hh <= 1.0: 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 gx1, gy1, gx2, gy2 = x1 + offset_x, y1 + offset_y, x2 + offset_x, y2 + offset_y
if REJECT_OSD_ZONES: if REJECT_OSD_ZONES:
cx = 0.5 * (gx1 + gx2) if box_is_osd_candidate(
cy = 0.5 * (gy1 + gy2) [gx1, gy1, gx2, gy2],
if (a <= REJECT_OSD_SMALL_AREA_MAX) and in_osd_zone(cx, cy, frame_w, frame_h): frame_w,
frame_h,
):
continue continue
dets_out.append(np.array([gx1, gy1, gx2, gy2, score], dtype=np.float32)) 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 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): def make_focus_roi_from_box(box, frame_w, frame_h, margin=72, min_side=90):
b = clip_box(box, frame_w, frame_h) b = clip_box(box, frame_w, frame_h)
cx, cy = box_center(b) 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)) 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): def get_effective_frame(orig_bgr):
oh, ow = orig_bgr.shape[:2] oh, ow = orig_bgr.shape[:2]
if not FORCE_EFFECTIVE_PAL: 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): if is_int_source(source):
cap = cv2.VideoCapture(int(source), backend) cap = cv2.VideoCapture(int(source), backend)
return cap, "camera" return cap, "camera"
if isinstance(source, str): if isinstance(source, str):
if is_stream_source(source): if is_stream_source(source):
cap = cv2.VideoCapture(source) cap = ReconnectingVideoCapture(source)
return cap, "stream" 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" return cap, "file"
raise ValueError(f"Unsupported SOURCE type: {type(source)}") raise ValueError(f"Unsupported SOURCE type: {type(source)}")

1008
main.py

File diff suppressed because it is too large Load Diff

@ -1,5 +1,11 @@
--extra-index-url https://download.pytorch.org/whl/cu128
numpy==1.26.4 numpy==1.26.4
opencv-python==4.10.0.84 opencv-python==4.10.0.84
torch==2.11.0+cu128
torchvision==0.26.0+cu128
ultralytics==8.4.75 ultralytics==8.4.75
netron==9.2.8
pymavlink==2.4.49 pymavlink==2.4.49
pyserial==3.5 pyserial==3.5
imageio-ffmpeg==0.6.0

@ -1,5 +1,7 @@
import os import os
import cv2
def _parse_bool(value): def _parse_bool(value):
if isinstance(value, bool): if isinstance(value, bool):
@ -12,10 +14,26 @@ def _parse_int(value):
return int(str(value).strip()) return int(str(value).strip())
def _parse_float(value):
return float(str(value).strip())
def _parse_str(value): def _parse_str(value):
return 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): def _parse_source(value):
text = str(value).strip() text = str(value).strip()
if text and text.lstrip("+-").isdigit(): if text and text.lstrip("+-").isdigit():
@ -35,13 +53,49 @@ def apply_config_env_overrides(namespace):
namespace, namespace,
{ {
"FPV_MODEL_PATH": ("MODEL_PATH", _parse_str), "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_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_VIDEO_REALTIME": ("VIDEO_REALTIME", _parse_bool),
"FPV_SHOW_OUTPUT": ("SHOW_OUTPUT", _parse_bool), "FPV_SHOW_OUTPUT": ("SHOW_OUTPUT", _parse_bool),
"FPV_SAVE_INFER_VIDEO": ("SAVE_INFER_VIDEO", _parse_bool), "FPV_SAVE_INFER_VIDEO": ("SAVE_INFER_VIDEO", _parse_bool),
"FPV_OUT_VIDEO_PATH": ("OUT_VIDEO_PATH", _parse_str), "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_ENABLE": ("GUIDANCE_EXPORT_ENABLE", _parse_bool),
"FPV_GUIDANCE_EXPORT_PATH": ("GUIDANCE_EXPORT_PATH", _parse_str), "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),
}, },
) )

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

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

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

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

@ -0,0 +1,79 @@
import unittest
import numpy as np
from ballistic_trajectory import predict_ballistic
from helpers import box_wh
def trajectory_observations(with_outlier=False):
observations = []
for index, timestamp in enumerate(np.linspace(-0.4, 0.0, 9)):
center = np.array([
100.0 + 50.0 * timestamp + 10.0 * timestamp * timestamp,
80.0 - 10.0 * timestamp + 3.0 * timestamp * timestamp,
])
if with_outlier and index == 3:
center += np.array([90.0, -70.0])
size = np.array([
20.0 * np.exp(0.4 * timestamp),
10.0 * np.exp(0.4 * timestamp),
])
observations.append({
"ts": float(timestamp),
"center": center,
"box": [
center[0] - 0.5 * size[0],
center[1] - 0.5 * size[1],
center[0] + 0.5 * size[0],
center[1] + 0.5 * size[1],
],
})
return observations
class BallisticTrajectoryTests(unittest.TestCase):
def test_robust_fit_ignores_single_bad_observation(self):
prediction = predict_ballistic(
trajectory_observations(with_outlier=True),
0.2,
320,
240,
)
np.testing.assert_allclose(prediction["center"], [110.4, 78.12], atol=0.2)
np.testing.assert_allclose(prediction["velocity"], [50.0, -10.0], atol=0.5)
np.testing.assert_allclose(prediction["acceleration"], [20.0, 6.0], atol=1.0)
def test_approach_growth_predicts_larger_box(self):
observations = trajectory_observations()
prediction = predict_ballistic(observations, 0.2, 320, 240)
last_size = box_wh(observations[-1]["box"])
self.assertGreater(box_wh(prediction["box"])[0], last_size[0])
self.assertGreater(box_wh(prediction["box"])[1], last_size[1])
def test_prediction_horizon_is_limited(self):
prediction = predict_ballistic(
trajectory_observations(),
2.0,
320,
240,
max_horizon_sec=0.55,
)
self.assertAlmostEqual(prediction["horizon"], 0.55)
def test_too_short_history_returns_no_prediction(self):
prediction = predict_ballistic(
trajectory_observations()[:3],
0.2,
320,
240,
)
self.assertIsNone(prediction)
if __name__ == "__main__":
unittest.main()

@ -0,0 +1,37 @@
import sys
import unittest
from pathlib import Path
import numpy as np
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT))
from bytetrack_min_aggressive import BYTETracker, hungarian
class ByteTrackAssignmentTests(unittest.TestCase):
def test_hungarian_matches_single_pair(self):
self.assertEqual(hungarian(np.array([[0.0]], dtype=np.float32)), [(0, 0)])
def test_bytetrack_keeps_id_for_same_box(self):
tracker = BYTETracker(
track_high_thresh=0.02,
track_low_thresh=0.01,
new_track_thresh=0.02,
match_thresh=0.10,
min_hits=1,
)
det = np.array([[10.0, 10.0, 40.0, 40.0, 0.05]], dtype=np.float32)
first = tracker.update(det, dt=0.04)
second = tracker.update(det, dt=0.04)
self.assertEqual(len(first), 1)
self.assertEqual(len(second), 1)
self.assertEqual(second[0].track_id, first[0].track_id)
self.assertEqual(second[0].hits, 2)
if __name__ == "__main__":
unittest.main()

@ -0,0 +1,207 @@
import socket
import unittest
from configurable_udp_capture import (
DEFAULT_PACKET_LAYOUT,
ConfigurablePacketAssembler,
ConfigurableUdpCapture,
normalize_packet_layout,
normalize_packet_schema,
packet_layout_from_schema,
packet_schema_from_layout,
)
def mik_style_packet(flags, sequence, packet_number, value, data=b""):
return bytes((0, flags, sequence, packet_number)) + int(value).to_bytes(4, "little") + data
class ConfigurableUdpCaptureTests(unittest.TestCase):
def test_visual_layout_calculates_offsets_and_arbitrary_field_sizes(self):
layout = [
{"role": "skip", "size": 2, "label": "magic"},
{"role": "sequence", "size": 3, "label": "frame id"},
{"role": "flags", "size": 2, "label": "flags"},
{"role": "value", "size": 4, "label": "length"},
]
schema = packet_schema_from_layout(layout, {"byte_order": "big"})
self.assertEqual(schema["header_size"], 11)
self.assertEqual((schema["sequence_offset"], schema["sequence_size"]), (2, 3))
self.assertEqual((schema["flags_offset"], schema["flags_size"]), (5, 2))
self.assertEqual((schema["value_offset"], schema["value_size"]), (7, 4))
self.assertEqual(schema["byte_order"], "big")
def test_old_offset_schema_converts_to_visual_layout(self):
layout = packet_layout_from_schema({
"header_size": 10,
"flags_offset": 2,
"flags_size": 1,
"sequence_offset": 4,
"sequence_size": 2,
"packet_number_offset": -1,
"value_offset": 6,
"value_size": 4,
})
self.assertEqual(sum(field["size"] for field in layout), 10)
self.assertEqual([field["role"] for field in layout], ["skip", "flags", "skip", "sequence", "value"])
rebuilt = packet_schema_from_layout(layout)
self.assertEqual(rebuilt["flags_offset"], 2)
self.assertEqual(rebuilt["sequence_offset"], 4)
self.assertEqual(rebuilt["value_offset"], 6)
def test_empty_visual_layout_means_zero_byte_header(self):
self.assertEqual(normalize_packet_layout([]), [])
self.assertEqual(packet_schema_from_layout([], {"assembly": "datagram"})["header_size"], 0)
self.assertEqual(sum(field["size"] for field in DEFAULT_PACKET_LAYOUT), 8)
def test_named_visual_field_is_read_as_integer(self):
schema = packet_schema_from_layout(
[
{"role": "field", "size": 2, "label": "temperature"},
{"role": "skip", "size": 1, "label": "reserved"},
],
{"assembly": "datagram", "byte_order": "big"},
)
assembler = ConfigurablePacketAssembler(schema)
self.assertEqual(assembler.push(b"\x01\x02\xffpayload"), b"payload")
self.assertEqual(assembler.last_fields, {"temperature": 0x0102})
def test_schema_disables_fields_outside_header(self):
schema = normalize_packet_schema({
"header_size": 2,
"flags_offset": 1,
"sequence_offset": 2,
"packet_number_offset": 9,
"value_offset": 4,
"start_mask": "0x80",
})
self.assertEqual(schema["flags_offset"], 1)
self.assertEqual(schema["sequence_offset"], -1)
self.assertEqual(schema["packet_number_offset"], -1)
self.assertEqual(schema["value_offset"], -1)
self.assertEqual(schema["start_mask"], 0x80)
def test_fragmented_packet_schema_reassembles_raw_frame(self):
frame_data = bytes(range(8))
cap = ConfigurableUdpCapture(
host="127.0.0.1",
port=0,
width=4,
height=2,
encoding="gray8",
)
sender = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
address = ("127.0.0.1", cap.port)
sender.sendto(mik_style_packet(2, 7, 0, 8, frame_data[:3]), address)
sender.sendto(mik_style_packet(0, 7, 1, 3, frame_data[3:6]), address)
sender.sendto(mik_style_packet(1, 7, 2, 6, frame_data[6:]), address)
ok, frame = cap.read()
self.assertTrue(ok)
self.assertEqual(frame.shape, (2, 4, 3))
self.assertEqual(int(frame[-1, -1, 0]), 7)
finally:
sender.close()
cap.release()
def test_datagram_schema_removes_header(self):
frame_data = bytes(range(24))
cap = ConfigurableUdpCapture(
host="127.0.0.1",
port=0,
width=4,
height=2,
encoding="bgr24",
schema={"assembly": "datagram", "header_size": 2},
)
sender = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
sender.sendto(b"\xaa\x55" + frame_data, ("127.0.0.1", cap.port))
ok, frame = cap.read()
self.assertTrue(ok)
self.assertEqual(frame.shape, (2, 4, 3))
self.assertEqual(int(frame[0, 0, 0]), 0)
finally:
sender.close()
cap.release()
def test_custom_transport_can_decode_mik_video_array(self):
width, height = 4, 2
mik_array = (
(0).to_bytes(4, "little")
+ width.to_bytes(2, "little")
+ height.to_bytes(2, "little")
+ bytes((1, 0, 0, 0))
+ bytes(range(width * height))
)
cap = ConfigurableUdpCapture(
host="127.0.0.1",
port=0,
schema={
"assembly": "datagram",
"payload_format": "mik",
"header_size": 0,
},
)
sender = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
sender.sendto(mik_array, ("127.0.0.1", cap.port))
ok, frame = cap.read()
self.assertTrue(ok)
self.assertEqual(frame.shape, (height, width, 3))
finally:
sender.close()
cap.release()
def test_stream_layout_reads_named_header_and_fixed_raw_frame(self):
layout = [{"role": "field", "size": 2, "label": "camera_id"}]
cap = ConfigurableUdpCapture(
host="127.0.0.1",
port=0,
width=4,
height=2,
encoding="gray8",
schema=packet_schema_from_layout(layout, {"assembly": "stream"}),
)
sender = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
sender.sendto(b"\x2a\x00" + bytes(range(8)), ("127.0.0.1", cap.port))
ok, frame = cap.read()
self.assertTrue(ok)
self.assertEqual(frame.shape, (2, 4, 3))
self.assertEqual(cap.last_packet_fields, {"camera_id": 42})
finally:
sender.close()
cap.release()
def test_bad_fragment_is_dropped_and_next_start_recovers(self):
assembler = ConfigurablePacketAssembler()
self.assertIsNone(assembler.push(mik_style_packet(2, 1, 0, 4, b"\x01\x02")))
self.assertIsNone(assembler.push(mik_style_packet(1, 1, 9, 2, b"\x03\x04")))
result = assembler.push(mik_style_packet(3, 2, 0, 4, b"\x05\x06\x07\x08"))
self.assertEqual(result, b"\x05\x06\x07\x08")
self.assertEqual(assembler.dropped_arrays, 1)
def test_multibyte_big_endian_flags_and_short_packet_reset(self):
assembler = ConfigurablePacketAssembler({
"header_size": 2,
"byte_order": "big",
"flags_offset": 0,
"flags_size": 2,
"start_mask": 0x8000,
"end_mask": 0x4000,
"sequence_offset": -1,
"packet_number_offset": -1,
"value_offset": -1,
"value_mode": "unused",
})
self.assertIsNone(assembler.push(b"\x80\x00first"))
with self.assertRaises(ValueError):
assembler.push(b"\x00")
self.assertIsNone(assembler.push(b"\x40\x00ignored"))
self.assertEqual(assembler.push(b"\xc0\x00next"), b"next")
self.assertEqual(assembler.dropped_arrays, 1)
if __name__ == "__main__":
unittest.main()

@ -0,0 +1,90 @@
import socket
import tempfile
import unittest
from pathlib import Path
from delimited_frame_capture import DelimitedFrameCapture
class DelimitedFrameCaptureTests(unittest.TestCase):
WIDTH = 4
HEIGHT = 2
SEPARATOR = 255
def frame_bytes(self, offset=0):
size = self.WIDTH * self.HEIGHT * 3
return bytes((offset + index) % 200 for index in range(size))
def test_reads_raw_frames_from_delimited_log(self):
first = self.frame_bytes()
second = self.frame_bytes(20)
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "frames.dump"
path.write_bytes(first + bytes((self.SEPARATOR,)) + second)
cap = DelimitedFrameCapture(
source=path,
separator=self.SEPARATOR,
encoding="bgr24",
width=self.WIDTH,
height=self.HEIGHT,
)
try:
ok1, frame1 = cap.read()
ok2, frame2 = cap.read()
self.assertTrue(ok1)
self.assertTrue(ok2)
self.assertEqual(frame1.shape, (self.HEIGHT, self.WIDTH, 3))
self.assertEqual(int(frame2[0, 0, 0]), 20)
finally:
cap.release()
def test_reads_raw_frame_from_live_udp_stream(self):
frame_data = self.frame_bytes()
cap = DelimitedFrameCapture(
host="127.0.0.1",
port=0,
separator=self.SEPARATOR,
encoding="bgr24",
width=self.WIDTH,
height=self.HEIGHT,
)
sender = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
sender.sendto(
frame_data + bytes((self.SEPARATOR,)),
("127.0.0.1", cap.port),
)
ok, frame = cap.read()
self.assertTrue(ok)
self.assertEqual(frame.shape, (self.HEIGHT, self.WIDTH, 3))
finally:
sender.close()
cap.release()
def test_live_raw_frame_may_contain_separator_byte(self):
frame_data = bytes(range(self.WIDTH * self.HEIGHT))
cap = DelimitedFrameCapture(
host="127.0.0.1",
port=0,
separator=0,
encoding="gray8",
width=self.WIDTH,
height=self.HEIGHT,
)
sender = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
sender.sendto(frame_data[:3], ("127.0.0.1", cap.port))
sender.sendto(frame_data[3:], ("127.0.0.1", cap.port))
sender.sendto(b"\x00", ("127.0.0.1", cap.port))
ok, frame = cap.read()
self.assertTrue(ok)
self.assertEqual(frame.shape, (self.HEIGHT, self.WIDTH, 3))
self.assertEqual(int(frame[0, 0, 0]), 0)
self.assertEqual(int(frame[-1, -1, 0]), 7)
finally:
sender.close()
cap.release()
if __name__ == "__main__":
unittest.main()

@ -0,0 +1,161 @@
import math
import socket
import struct
import sys
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT))
from error_output import (
ErrorOutputSender,
build_error_payload,
decode_guidance_v1_response,
encode_error_payload,
)
class ErrorOutputTests(unittest.TestCase):
def test_pixel_units_from_normalized_guidance_error(self):
payload = build_error_payload(
{"frame_id": 7, "frame_w": 1280, "frame_h": 720, "error_x": 0.25, "error_y": -0.5},
units="px",
timestamp=1.0,
)
self.assertEqual(payload["unit"], "px")
self.assertEqual(payload["x"], 160.0)
self.assertEqual(payload["y"], -180.0)
self.assertAlmostEqual(payload["mag"], math.hypot(160.0, -180.0))
def test_degree_units_use_fov(self):
payload = build_error_payload(
{"frame_w": 100, "frame_h": 100, "error_x": 1.0, "error_y": 0.0},
units="deg",
hfov_deg=90,
vfov_deg=60,
timestamp=1.0,
)
self.assertEqual(payload["unit"], "deg")
self.assertAlmostEqual(payload["x"], 45.0, places=5)
self.assertAlmostEqual(payload["y"], 0.0, places=5)
def test_meter_units_need_range(self):
payload = build_error_payload(
{"frame_w": 100, "frame_h": 100, "error_x": 1.0, "error_y": 0.0},
units="m",
hfov_deg=90,
range_m=10,
timestamp=1.0,
)
self.assertTrue(payload["valid"])
self.assertAlmostEqual(payload["x"], 10.0, places=5)
def test_binary_packet_magic(self):
payload = build_error_payload({"frame_id": 3, "frame_w": 100, "frame_h": 100}, timestamp=1.0)
data = encode_error_payload(payload, "bin")
self.assertEqual(data[:4], b"FPVE")
self.assertEqual(struct.unpack("<I", data[4:8])[0], 3)
def test_csv_packet_has_selected_unit(self):
payload = build_error_payload({"frame_id": 3, "frame_w": 100, "frame_h": 100}, units="norm", timestamp=1.0)
text = encode_error_payload(payload, "csv").decode("ascii")
self.assertIn(",norm,", text)
def test_guidance_v1_packet_matches_document_layout(self):
payload = build_error_payload(
{
"active": True,
"det_count": 2,
"frame_w": 100,
"frame_h": 100,
"error_x": 0.5,
"error_y": -0.4,
"box_w": 20,
"box_h": 10,
},
object_id=7,
timestamp=1.0,
)
data = encode_error_payload(payload, "guidance_v1")
self.assertEqual(len(data), 10)
self.assertEqual(
struct.unpack("<BBBhhbbb", data),
(1, 7, 2, 20, 25, 40, 50, 2),
)
def test_guidance_v1_no_target_zeros_measurements(self):
payload = build_error_payload(
{
"active": False,
"frame_w": 100,
"frame_h": 100,
"error_x": 1.0,
"error_y": 1.0,
},
object_id=3,
timestamp=1.0,
)
self.assertEqual(
struct.unpack("<BBBhhbbb", encode_error_payload(payload, "guidance_v1")),
(1, 3, 0, 0, 0, 0, 0, 0),
)
def test_guidance_v1_response_validation(self):
self.assertEqual(
decode_guidance_v1_response(bytes([2, 1])),
{"descriptor": 2, "response": 1},
)
self.assertIsNone(decode_guidance_v1_response(bytes([1, 1])))
self.assertIsNone(decode_guidance_v1_response(bytes([2, 3])))
def test_sender_uses_selected_udp_port(self):
receiver = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
receiver.bind(("127.0.0.1", 0))
receiver.settimeout(1.0)
port = receiver.getsockname()[1]
try:
with patch.multiple(
"error_output",
ERROR_OUTPUT_ENABLE=True,
ERROR_OUTPUT_PROTOCOL="guidance_v1",
ERROR_OUTPUT_HOST="127.0.0.1",
ERROR_OUTPUT_PORT=port,
ERROR_OUTPUT_OBJECT_ID=9,
ERROR_OUTPUT_EVERY=1,
):
sender = ErrorOutputSender()
sender.start()
try:
sender.send(
{
"frame_id": 1,
"active": True,
"det_count": 1,
"frame_w": 100,
"frame_h": 100,
"error_x": 0.0,
"error_y": 0.0,
"box_w": 10,
"box_h": 10,
}
)
data, _ = receiver.recvfrom(64)
finally:
sender.close()
self.assertEqual(len(data), 10)
self.assertEqual(data[:3], bytes([1, 9, 1]))
finally:
receiver.close()
def test_sender_skips_unverified_target(self):
sender = ErrorOutputSender()
sender.enabled = True
sender._sock = Mock()
self.assertIsNone(sender.send({"frame_id": 1, "active": False}))
sender._sock.sendto.assert_not_called()
if __name__ == "__main__":
unittest.main()

@ -0,0 +1,64 @@
import unittest
from io import BytesIO
from types import SimpleNamespace
from unittest.mock import patch
import cv2
from ffmpeg_capture import FFmpegCapture, _parse_float, _parse_int, _parse_rate
from helpers import open_source
class FFmpegCaptureTests(unittest.TestCase):
def test_parse_fractional_rate(self):
self.assertAlmostEqual(_parse_rate("30000/1001"), 29.97002997)
def test_parse_invalid_rate(self):
self.assertEqual(_parse_rate("0/0"), 0.0)
self.assertEqual(_parse_rate("unknown"), 0.0)
def test_unknown_frame_count_and_duration_are_zero(self):
self.assertEqual(_parse_int("N/A"), 0)
self.assertEqual(_parse_int(None), 0)
self.assertEqual(_parse_float("N/A"), 0.0)
def test_raw_reader_does_not_duplicate_frames(self):
cap = FFmpegCapture.__new__(FFmpegCapture)
cap.source = "source.mp4"
cap.width = 640
cap.height = 480
with patch("ffmpeg_capture.subprocess.Popen") as popen:
cap._start()
command = popen.call_args.args[0]
self.assertEqual(command[command.index("-vsync") + 1], "0")
def test_reads_buffered_frame_after_ffmpeg_process_exits(self):
cap = FFmpegCapture.__new__(FFmpegCapture)
cap.width = 1
cap.height = 1
cap.frames_read = 0
cap.process = SimpleNamespace(
stdout=BytesIO(b"\x01\x02\x03"),
poll=lambda: 0,
)
ok, frame = cap.read()
self.assertTrue(ok)
self.assertEqual(frame.shape, (1, 1, 3))
self.assertEqual(frame.tolist(), [[[1, 2, 3]]])
@patch("helpers.FFmpegCapture")
def test_open_source_creates_ffmpeg_reader_with_file_path_only(self, capture):
reader = SimpleNamespace(isOpened=lambda: True)
capture.return_value = reader
opened, source_kind = open_source("clip.avi", cv2.CAP_FFMPEG)
capture.assert_called_once_with("clip.avi")
self.assertIs(opened, reader)
self.assertEqual(source_kind, "file")
if __name__ == "__main__":
unittest.main()

@ -2,6 +2,8 @@ from pathlib import Path
import importlib import importlib
import sys import sys
import numpy as np
PROJECT_ROOT = Path(__file__).resolve().parents[1] PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path: if str(PROJECT_ROOT) not in sys.path:
@ -16,3 +18,28 @@ def test_local_bytetrack_module_is_importable_from_project_root():
def test_local_bytetrack_module_file_exists_in_project_root(): def test_local_bytetrack_module_file_exists_in_project_root():
assert (PROJECT_ROOT / "bytetrack_min_aggressive.py").exists() assert (PROJECT_ROOT / "bytetrack_min_aggressive.py").exists()
def test_hungarian_matches_single_pair():
module = importlib.import_module("bytetrack_min_aggressive")
assert module.hungarian(np.array([[0.0]], dtype=np.float32)) == [(0, 0)]
def test_bytetrack_keeps_id_for_same_box():
module = importlib.import_module("bytetrack_min_aggressive")
tracker = module.BYTETracker(
track_high_thresh=0.02,
track_low_thresh=0.01,
new_track_thresh=0.02,
match_thresh=0.10,
min_hits=1,
)
det = np.array([[10.0, 10.0, 40.0, 40.0, 0.05]], dtype=np.float32)
first = tracker.update(det, dt=0.04)
second = tracker.update(det, dt=0.04)
assert len(first) == 1
assert len(second) == 1
assert second[0].track_id == first[0].track_id
assert second[0].hits == 2

@ -0,0 +1,60 @@
import sys
import unittest
from pathlib import Path
from types import SimpleNamespace
import numpy as np
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT))
import config
from helpers import filter_drone_candidates, verified_drone_track_box
class LowConfidenceTrackingProfileTests(unittest.TestCase):
def test_best_pt_profile_requires_temporal_confirmation(self):
self.assertGreaterEqual(config.YOLO_CONF_EFFECTIVE, 0.03)
self.assertLessEqual(config.BT_LOW, config.YOLO_CONF_EFFECTIVE)
self.assertGreaterEqual(config.BT_NEW, 0.05)
self.assertGreaterEqual(config.TRACK_SCORE_MIN_ACQUIRE, config.BT_NEW)
self.assertEqual(config.CONFIRM_HITS, 3)
self.assertEqual(config.UNVERIFIED_TARGET_SWITCH_CONFIRM_HITS, 3)
self.assertGreaterEqual(config.TARGET_SWITCH_CONFIRM_HITS, 8)
self.assertFalse(config.FAST_HANDOFF_ENABLE)
def test_weak_candidate_needs_motion(self):
weak = np.array([10, 10, 20, 20, 0.05], dtype=np.float32)
still = np.zeros((100, 100), dtype=np.uint8)
moving = still.copy()
moving[12:16, 12:16] = 255
self.assertEqual(filter_drone_candidates([weak], still, 100, 100), [])
self.assertEqual(len(filter_drone_candidates([weak], moving, 100, 100)), 1)
def test_strong_candidate_does_not_require_motion(self):
strong = np.array([10, 10, 20, 20, 0.13], dtype=np.float32)
self.assertEqual(
len(filter_drone_candidates([strong], None, 100, 100)),
1,
)
def test_red_box_requires_fresh_verified_track(self):
track = SimpleNamespace(
tlbr=np.array([10, 10, 20, 20], dtype=np.float32),
score=0.11,
hits=1,
time_since_update=0,
)
self.assertIsNotNone(verified_drone_track_box(track, None, 100, 100))
track.time_since_update = 1
self.assertIsNone(verified_drone_track_box(track, None, 100, 100))
track.time_since_update = 0
track.hits = 0
self.assertIsNone(verified_drone_track_box(track, None, 100, 100))
if __name__ == "__main__":
unittest.main()

@ -16,6 +16,9 @@ def test_apply_config_env_overrides_parses_source_device_and_flags(monkeypatch):
"SHOW_OUTPUT": True, "SHOW_OUTPUT": True,
"SAVE_INFER_VIDEO": True, "SAVE_INFER_VIDEO": True,
"OUT_VIDEO_PATH": "out.mp4", "OUT_VIDEO_PATH": "out.mp4",
"ARCHIVE_RECORD_MODE": "full",
"DETECTION_CLIP_MAX_GAP_SEC": 15.0,
"ERROR_OUTPUT_OBJECT_ID": 1,
} }
monkeypatch.setenv("FPV_SOURCE", "0") monkeypatch.setenv("FPV_SOURCE", "0")
@ -23,6 +26,9 @@ def test_apply_config_env_overrides_parses_source_device_and_flags(monkeypatch):
monkeypatch.setenv("FPV_SHOW_OUTPUT", "false") monkeypatch.setenv("FPV_SHOW_OUTPUT", "false")
monkeypatch.setenv("FPV_SAVE_INFER_VIDEO", "0") monkeypatch.setenv("FPV_SAVE_INFER_VIDEO", "0")
monkeypatch.setenv("FPV_OUT_VIDEO_PATH", "/outputs/run.mp4") monkeypatch.setenv("FPV_OUT_VIDEO_PATH", "/outputs/run.mp4")
monkeypatch.setenv("FPV_ARCHIVE_RECORD_MODE", "fragments")
monkeypatch.setenv("FPV_DETECTION_CLIP_MAX_GAP_SEC", "7.5")
monkeypatch.setenv("FPV_ERROR_OUTPUT_OBJECT_ID", "17")
apply_config_env_overrides(namespace) apply_config_env_overrides(namespace)
@ -31,6 +37,9 @@ def test_apply_config_env_overrides_parses_source_device_and_flags(monkeypatch):
assert namespace["SHOW_OUTPUT"] is False assert namespace["SHOW_OUTPUT"] is False
assert namespace["SAVE_INFER_VIDEO"] is False assert namespace["SAVE_INFER_VIDEO"] is False
assert namespace["OUT_VIDEO_PATH"] == "/outputs/run.mp4" assert namespace["OUT_VIDEO_PATH"] == "/outputs/run.mp4"
assert namespace["ARCHIVE_RECORD_MODE"] == "fragments"
assert namespace["DETECTION_CLIP_MAX_GAP_SEC"] == 7.5
assert namespace["ERROR_OUTPUT_OBJECT_ID"] == 17
def test_apply_intercept_env_overrides_switches_to_proto_udp(monkeypatch): def test_apply_intercept_env_overrides_switches_to_proto_udp(monkeypatch):

@ -0,0 +1,41 @@
import unittest
from unittest.mock import patch
from helpers import ReconnectingVideoCapture
class FakeCapture:
def __init__(self, frames):
self.frames = list(frames)
self.released = False
def isOpened(self):
return not self.released
def read(self):
if not self.frames:
return False, None
return self.frames.pop(0)
def get(self, _prop):
return 30.0
def set(self, _prop, _value):
return True
def release(self):
self.released = True
class ReconnectingVideoCaptureTests(unittest.TestCase):
def test_reopens_stream_after_read_failure(self):
broken = FakeCapture([(False, None)])
recovered = FakeCapture([(True, "frame")])
with patch("helpers.cv2.VideoCapture", side_effect=[broken, recovered]):
cap = ReconnectingVideoCapture("http://camera", retries=1, retry_delay=0)
self.assertEqual(cap.read(), (True, "frame"))
self.assertTrue(broken.released)
if __name__ == "__main__":
unittest.main()

@ -0,0 +1,130 @@
import unittest
import cv2
import numpy as np
from helpers import acquisition_step_is_plausible
from target_physics import analyze_motion_group
from helpers import box_is_edge_osd, box_is_osd_candidate, in_osd_zone
def frame_with_points(points, shape=(240, 320)):
frame = np.zeros(shape, dtype=np.uint8)
for x, y in points:
cv2.circle(frame, (int(round(x)), int(round(y))), 2, 255, -1)
return frame
class TargetPhysicsTests(unittest.TestCase):
def test_provisional_target_cannot_jump_across_the_frame(self):
previous = np.array([350, 290, 380, 310], dtype=np.float32)
jumped = np.array([395, 220, 430, 240], dtype=np.float32)
self.assertFalse(
acquisition_step_is_plausible(
previous,
jumped,
np.array([[1, 0, 0], [0, 1, 0]], dtype=np.float32),
0.08,
720,
576,
)
)
def test_provisional_target_allows_camera_compensated_motion(self):
previous = np.array([350, 290, 380, 310], dtype=np.float32)
current = np.array([370, 300, 400, 320], dtype=np.float32)
affine = np.array([[1, 0, 20], [0, 1, 10]], dtype=np.float32)
self.assertTrue(
acquisition_step_is_plausible(
previous,
current,
affine,
0.08,
720,
576,
)
)
def test_edge_anchored_osd_box_is_rejected(self):
self.assertTrue(box_is_edge_osd([0, 4, 80, 60], 720, 576))
self.assertFalse(box_is_edge_osd([300, 200, 380, 260], 720, 576))
self.assertTrue(in_osd_zone(150, 468, 720, 576))
self.assertTrue(box_is_osd_candidate([294, 538, 332, 560], 720, 576))
def test_camera_motion_is_not_independent_target_motion(self):
points = np.array(
[(125, 85), (135, 85), (145, 85), (125, 95), (135, 95),
(145, 95), (125, 105), (135, 105), (145, 105)],
dtype=np.float32,
)
affine = np.array([[1, 0, 3], [0, 1, 2]], dtype=np.float32)
previous = frame_with_points(points)
current = cv2.warpAffine(previous, affine, (320, 240))
evidence = analyze_motion_group(
previous, current, [115, 75, 155, 115], affine=affine, dt=0.04
)
self.assertTrue(evidence.reliable)
self.assertFalse(evidence.valid)
self.assertLess(evidence.residual_px, 0.25)
def test_coherent_expanding_group_is_valid_target_motion(self):
points = np.array(
[(125, 85), (135, 85), (145, 85), (125, 95), (135, 95),
(145, 95), (125, 105), (135, 105), (145, 105)],
dtype=np.float32,
)
center = np.array([135, 95], dtype=np.float32)
moved = (points - center) * 1.08 + center + np.array([4, 1], dtype=np.float32)
evidence = analyze_motion_group(
frame_with_points(points),
frame_with_points(moved),
[115, 75, 155, 115],
dt=0.04,
)
self.assertTrue(evidence.reliable)
self.assertTrue(evidence.valid)
self.assertGreaterEqual(evidence.coherent_count, 3)
self.assertGreater(evidence.scale_ratio, 1.01)
def test_screen_fixed_osd_is_not_a_target_after_camera_compensation(self):
points = np.array(
[(25, 15), (35, 15), (45, 15), (25, 25), (35, 25),
(45, 25), (25, 35), (35, 35), (45, 35)],
dtype=np.float32,
)
frame = frame_with_points(points)
affine = np.array([[1, 0, 4], [0, 1, 2]], dtype=np.float32)
evidence = analyze_motion_group(
frame, frame, [15, 5, 55, 45], affine=affine, dt=0.04
)
self.assertTrue(evidence.reliable)
self.assertTrue(evidence.screen_static)
self.assertFalse(evidence.valid)
def test_distant_target_moving_out_of_frame_is_rejected(self):
points = np.array(
[(3, 90), (8, 90), (13, 90), (3, 98), (8, 98),
(13, 98), (3, 106), (8, 106), (13, 106)],
dtype=np.float32,
)
evidence = analyze_motion_group(
frame_with_points(points),
frame_with_points(points + np.array([-2, 0], dtype=np.float32)),
[0, 82, 18, 112],
dt=0.04,
)
self.assertTrue(evidence.reliable)
self.assertTrue(evidence.edge_violation)
self.assertFalse(evidence.valid)
if __name__ == "__main__":
unittest.main()

@ -6,7 +6,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path: if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT)) sys.path.insert(0, str(PROJECT_ROOT))
from track_score_policy import required_track_score, track_passes_score_gate from track_score_policy import initial_candidate_score, required_track_score, track_passes_score_gate
def test_required_track_score_is_stricter_for_switch_than_for_acquire(): def test_required_track_score_is_stricter_for_switch_than_for_acquire():
@ -64,3 +64,18 @@ def test_switch_candidate_must_clear_switch_floor():
is_switch_candidate=True, is_switch_candidate=True,
weak_reacq_guard=False, weak_reacq_guard=False,
) )
def test_initial_selection_prefers_persistent_moving_target():
distant_target = initial_candidate_score(
track_score=0.06,
track_hits=6,
residual_motion=True,
)
one_frame_false_positive = initial_candidate_score(
track_score=0.14,
track_hits=1,
residual_motion=False,
)
assert distant_target > one_frame_false_positive

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

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

@ -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('<img id="frame"', HTML)
self.assertIn('<img id="frameFallback"', HTML)
self.assertEqual(HTML.count('id="startRun"'), 1)
def test_model_panel_and_themes_are_available(self):
self.assertIn('data-tab="model"', HTML)
self.assertIn('id="modelTab"', HTML)
self.assertIn('id="modelSelect"', HTML)
self.assertNotIn('id="modelInspect"', HTML)
self.assertNotIn('id="architectureList"', HTML)
self.assertIn('id="modelNetron"', HTML)
self.assertIn('id="netronFrame"', HTML)
self.assertIn('/api/model/netron', HTML)
self.assertIn('id="modelConf"', HTML)
self.assertIn('value="light"', HTML)
self.assertIn('value="amber"', HTML)
self.assertIn('value="midnight"', HTML)
self.assertIn('value="forest"', HTML)
self.assertIn('value="rose"', HTML)
self.assertIn('id="accentSelect"', HTML)
self.assertIn('id="accentCustom"', HTML)
self.assertIn('id="accentIntensity"', HTML)
self.assertIn('value="violet"', HTML)
self.assertIn('value="lime"', HTML)
for value in ("graphite", "solarized", "ocean", "neon", "copper", "mono", "indigo", "teal", "gold", "sky", "coral"):
self.assertIn(f'value="{value}"', HTML)
self.assertIn('fpv-accent-intensity', HTML)
self.assertIn('@keyframes page-enter', HTML)
self.assertIn('@keyframes form-field-in', HTML)
self.assertIn('showFormBlock', HTML)
self.assertIn('@keyframes form-group-out', HTML)
self.assertIn('data-section="source"', HTML)
self.assertIn('initCollapsibleSections', HTML)
self.assertIn('configResetLayout', HTML)
self.assertIn('configRestoreToggle', HTML)
self.assertIn('config-canvas', HTML)
self.assertIn('setConfigBlockState', HTML)
self.assertIn('finishConfigDrag', HTML)
self.assertIn('fpv-config-layout-v1', HTML)
self.assertIn('config-trash', HTML)
self.assertNotIn('block-actions', HTML)
self.assertNotIn('block-resize', HTML)
self.assertIn('config-block-deleted', HTML)
self.assertIn('Compact form layer', HTML)
self.assertIn('prefers-reduced-motion', HTML)
self.assertIn('color-mix', HTML)
def test_model_paths_are_confined_to_pt_files(self):
self.assertIsNotNone(resolve_model_path("best.pt"))
self.assertIsNone(resolve_model_path("../secret.pt"))
self.assertIsNone(resolve_model_path("best.onnx"))
self.assertIsNone(model_upload_path("../evil.pt"))
self.assertIsNone(model_upload_path("weights.onnx"))
def test_model_inference_settings_are_clamped(self):
state = normalize_control({
"device": -99,
"use_half": False,
"conf": 4,
"img_size_roi": 1,
"img_size_full": 99999,
"max_det": 9999,
})
self.assertEqual(state["device"], -1)
self.assertFalse(state["use_half"])
self.assertEqual(state["conf"], 1.0)
self.assertEqual(state["img_size_roi"], 128)
self.assertEqual(state["img_size_full"], 4096)
self.assertEqual(state["max_det"], 300)
def test_netron_starts_only_for_confined_model(self):
calls = []
fake_netron = types.SimpleNamespace(
status=lambda address: False,
stop=lambda address: calls.append(("stop", address)),
start=lambda path, address, browse: calls.append(("start", path, address, browse)),
)
previous = dict(ui_server.NETRON_STATE)
try:
with patch.dict(sys.modules, {"netron": fake_netron}):
payload = ui_server.ensure_netron("best.pt")
self.assertEqual(payload["url"], "/netron/")
self.assertEqual(calls[0][0], "stop")
self.assertEqual(calls[1][0], "start")
self.assertEqual(calls[1][3], False)
finally:
ui_server.NETRON_STATE.clear()
ui_server.NETRON_STATE.update(previous)
def test_udp_source_modes_and_formats_are_available(self):
state = normalize_control({"source_mode": "udp_dump", "file_path": "camera.ts"})
self.assertEqual(state["source_mode"], "udp_dump")
for mode in ("udp_mik_live", "udp_delimited_live", "udp_custom_live", "udp_delimited_file"):
self.assertEqual(normalize_control({"source_mode": mode})["source_mode"], mode)
self.assertIn(".ts", INPUT_EXTENSIONS)
self.assertIn(".h264", INPUT_EXTENSIONS)
self.assertIn(".udp", INPUT_EXTENSIONS)
self.assertIn(".avi", INPUT_EXTENSIONS)
self.assertIn("", INPUT_EXTENSIONS)
self.assertNotIn(".pcap", INPUT_EXTENSIONS)
self.assertIn('<option value="udp_mik_live">', HTML)
self.assertIn('<option value="udp_delimited_live">', HTML)
self.assertIn('<option value="udp_custom_live">', HTML)
self.assertIn('<option value="udp_dump">', HTML)
self.assertIn('<option value="udp_delimited_file">', HTML)
self.assertIn('<option value="512x640">', HTML)
self.assertIn('id="separatorByte"', HTML)
self.assertIn('id="frameEncoding"', HTML)
self.assertIn('id="probeUdp"', HTML)
self.assertIn('id="udpProbeDetails"', HTML)
self.assertIn('id="packetPreset"', HTML)
self.assertIn('<option value="auto">Автоопределение</option>', 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('<option value="custom">Произвольный</option>', 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()

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

@ -36,3 +36,23 @@ def track_passes_score_gate(
reacquire_floor=reacquire_floor, reacquire_floor=reacquire_floor,
switch_floor=switch_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)
)

@ -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("<u2").reshape(height, width), 0xFFFF)
elif pixel_id == self.PIXEL_INT16:
frame = self._mono_to_bgr(pixels.view("<i2").reshape(height, width), -1)
elif pixel_id == self.PIXEL_RGB888:
frame = cv2.cvtColor(pixels.reshape(height, width, 3), cv2.COLOR_RGB2BGR)
else:
frame = cv2.cvtColor(pixels.reshape(height, width, 2), cv2.COLOR_YUV2BGR_YUY2)
self.width = width
self.height = height
self.pixel_id = pixel_id
self.row_padding = padding
return frame
def _read_frame(self):
while True:
data = self._array()
if data is None:
return None
try:
return self._decode_array(data)
except ValueError as exc:
self.last_error = str(exc)
self.dropped_arrays += 1
def read(self):
if self._file is None:
return False, None
try:
frame = self._next_frame
self._next_frame = None
if frame is None:
frame = self._read_frame()
if frame is None:
return False, None
self.frames_read += 1
return True, frame
except (OSError, ValueError) as exc:
self.last_error = str(exc)
self.release()
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
class LiveMikUdpCapture(UdpDumpCapture):
"""Live UDP receiver for the MIK packet payload used by port 59004."""
def __init__(self, host="0.0.0.0", port=59004, fps=30.0, width=0, height=0):
self.path = None
self.host = str(host)
self.port = int(port)
self._init_decoder(fps, width, height)
self._file = None
self._next_frame = None
self._assembler = MikPacketAssembler()
self._socket = None
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 read(self):
while self._socket is not None:
try:
payload, _address = self._socket.recvfrom(65535)
except socket.timeout:
continue
except OSError as exc:
self.last_error = str(exc)
return False, None
dropped_before = self._assembler.dropped_arrays
try:
data = self._assembler.push(payload)
except ValueError as exc:
self.last_error = str(exc)
continue
self.dropped_arrays += self._assembler.dropped_arrays - dropped_before
if data is None:
continue
try:
frame = self._decode_array(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()

@ -0,0 +1,496 @@
import hashlib
import json
import socket
import time
from collections import Counter
from pathlib import Path
from udp_dump_capture import MikPacketAssembler
MAX_UDP_PAYLOAD = 65535
MAX_MIK_ARRAY = 256 * 1024 * 1024
MIK_PIXEL_BYTES = {
0x01: 1,
0x02: 2,
0x03: 3,
0x0A: 2,
0x12: 2,
}
RAW_ENCODINGS = {
"gray8": 1,
"gray16": 2,
"yuyv422": 2,
"bgr24": 3,
"rgb24": 3,
}
def inspect_mik_array(data):
if len(data) < 12:
return None
label_count = int.from_bytes(data[:4], "little")
video_offset = 4 + label_count * 40
if label_count > 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,
}

File diff suppressed because it is too large Load Diff

@ -8,29 +8,82 @@ import torch
from config import * from config import *
from helpers import clip_box, crop_roi, preprocess_for_yolo, filter_yolo_boxes_with_scores 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 # 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: class YOLOWorker:
def __init__(self, model): def __init__(self, model, full_frame_shape=None):
self.model = model self.model = model
self.full_frame_shape = full_frame_shape
self.req = deque(maxlen=YOLO_QUEUE_MAX) self.req = deque(maxlen=YOLO_QUEUE_MAX)
self.res = deque(maxlen=1) self.res = deque(maxlen=1)
self.lock = threading.Lock() 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.running = False
self.thread = threading.Thread(target=self._loop, daemon=True) self.thread = threading.Thread(target=self._loop, daemon=True)
def start(self): def start(self):
self.running = True self.running = True
self.thread.start() 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): def stop(self):
self.running = False 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): def submit(self, frame_eff_bgr, roi_box_eff, mode, ts):
with self.lock: with self.lock:
self.req.append((frame_eff_bgr, roi_box_eff, mode, ts)) self.req.append((frame_eff_bgr, roi_box_eff, mode, ts))
self.request_event.set()
def try_get(self): def try_get(self):
with self.lock: with self.lock:
@ -39,7 +92,17 @@ class YOLOWorker:
return self.res.pop() return self.res.pop()
def _loop(self): 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: while self.running:
self.request_event.wait(timeout=0.2)
self.request_event.clear()
item = None item = None
with self.lock: with self.lock:
if self.req: if self.req:
@ -47,13 +110,13 @@ class YOLOWorker:
self.req.clear() self.req.clear()
if item is None: if item is None:
time.sleep(0.001)
continue continue
frame, roi_box, mode, ts = item frame, roi_box, mode, ts = item
h, w = frame.shape[:2] h, w = frame.shape[:2]
frame_infer = preprocess_for_yolo(frame) frame_infer = preprocess_for_yolo(frame)
dets = [] dets = []
raw_dets = []
infer_ms = 0.0 infer_ms = 0.0
used_roi = False used_roi = False
@ -63,32 +126,46 @@ class YOLOWorker:
crop, ox, oy = crop_roi(frame_infer, roi_box) crop, ox, oy = crop_roi(frame_infer, roi_box)
if crop.size > 0: if crop.size > 0:
used_roi = True used_roi = True
crop_model, input_scale, pad_x, pad_y = fixed_letterbox(crop, IMG_SIZE_ROI)
t0 = time.perf_counter() t0 = time.perf_counter()
with torch.inference_mode(): with torch.inference_mode():
r = self.model( r = self.model(
crop, crop_model,
conf=YOLO_CONF_EFFECTIVE, conf=YOLO_CONF_EFFECTIVE,
imgsz=IMG_SIZE_ROI, imgsz=IMG_SIZE_ROI,
verbose=False, verbose=False,
max_det=MAX_DET, max_det=MAX_DET,
device=DEVICE, device=INFERENCE_DEVICE,
half=USE_HALF half=INFERENCE_HALF
)[0] )[0]
infer_ms = (time.perf_counter() - t0) * 1000.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( dets = filter_yolo_boxes_with_scores(
r, r,
frame_w=w, frame_w=w,
frame_h=h, frame_h=h,
offset_x=ox, offset_x=ox,
offset_y=oy, 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: else:
sh, sw = frame_infer.shape[:2] sh, sw = frame_infer.shape[:2]
short = min(sh, sw) short = min(sh, sw)
scale = 1.0 scale = 1.0
target = IMG_SIZE_FULL target = INFERENCE_SIZE_FULL
if short > target: if short > target:
scale = target / float(short) scale = target / float(short)
small = cv2.resize( small = cv2.resize(
@ -104,13 +181,14 @@ class YOLOWorker:
r = self.model( r = self.model(
small, small,
conf=YOLO_CONF_EFFECTIVE, conf=YOLO_CONF_EFFECTIVE,
imgsz=IMG_SIZE_FULL, imgsz=INFERENCE_SIZE_FULL,
verbose=False, verbose=False,
max_det=MAX_DET, max_det=MAX_DET,
device=DEVICE, device=INFERENCE_DEVICE,
half=USE_HALF half=INFERENCE_HALF
)[0] )[0]
infer_ms = (time.perf_counter() - t0) * 1000.0 infer_ms = (time.perf_counter() - t0) * 1000.0
raw_small = raw_yolo_boxes(r)
dets_s = filter_yolo_boxes_with_scores( dets_s = filter_yolo_boxes_with_scores(
r, r,
@ -122,19 +200,48 @@ class YOLOWorker:
) )
if scale != 1.0: if scale != 1.0:
inv = 1.0 / scale 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 = [ dets = [
np.array([d[0] * inv, d[1] * inv, d[2] * inv, d[3] * inv, d[4]], dtype=np.float32) np.array([d[0] * inv, d[1] * inv, d[2] * inv, d[3] * inv, d[4]], dtype=np.float32)
for d in dets_s for d in dets_s
] ]
else: else:
raw_dets = raw_small
dets = dets_s dets = dets_s
except Exception: except Exception as exc:
dets = [] dets = []
raw_dets = []
infer_ms = 0.0 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: 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)
# ========================= # =========================

Loading…
Cancel
Save