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
parent
4340aa0790
commit
52a203a5a2
@ -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
|
||||
@ -0,0 +1,2 @@
|
||||
*.sh text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
@ -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/
|
||||
@ -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,
|
||||
}
|
||||
@ -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:
|
||||
fpv-tracker:
|
||||
profiles: ["standalone"]
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
PYTORCH_INDEX_URL: https://download.pytorch.org/whl/cu128
|
||||
image: fpv-tracker:cu128-offline
|
||||
PYTORCH_INDEX_URL: ${PYTORCH_INDEX_URL:-https://download.pytorch.org/whl/cu128}
|
||||
image: ${FPV_IMAGE:-fpv-tracker:cu128-offline}
|
||||
init: true
|
||||
gpus: all
|
||||
restart: "no"
|
||||
environment: *fpv-env
|
||||
volumes:
|
||||
- type: bind
|
||||
source: .
|
||||
target: /app
|
||||
- type: bind
|
||||
source: ${FPV_DATA_DIR:-./runtime-data}
|
||||
target: /data
|
||||
- type: bind
|
||||
source: ${FPV_INPUT_FILE:-./runtime-data/input/source.mp4}
|
||||
target: /data/input/source.mp4
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ${FPV_MODEL_FILE:-./best.pt}
|
||||
target: /app/best.pt
|
||||
read_only: true
|
||||
command: >
|
||||
sh -c "python3 main.py 2>&1 | tee /data/logs/main.log"
|
||||
|
||||
fpv-ui:
|
||||
image: ${FPV_IMAGE:-fpv-tracker:cu128-offline}
|
||||
init: true
|
||||
gpus: all
|
||||
restart: unless-stopped
|
||||
stdin_open: true
|
||||
tty: true
|
||||
command: ["python3", "ui_server.py"]
|
||||
environment:
|
||||
FPV_MODEL_PATH: /app/best.pt
|
||||
FPV_SOURCE: "0"
|
||||
FPV_SHOW_OUTPUT: "0"
|
||||
FPV_SAVE_INFER_VIDEO: "1"
|
||||
FPV_OUT_VIDEO_PATH: /data/out/out_infer.mp4
|
||||
FPV_GUIDANCE_EXPORT_ENABLE: "1"
|
||||
FPV_GUIDANCE_EXPORT_PATH: /data/guidance/guidance_state.json
|
||||
FPV_AUTOPILOT_ENABLE: "1"
|
||||
FPV_AUTOPILOT_BACKEND: "json"
|
||||
FPV_AUTOPILOT_JSON_PATH: /data/autopilot/autopilot_cmd.json
|
||||
FPV_PROTO_UDP_ENABLE: "0"
|
||||
FPV_PROTO_UDP_HOST: "192.168.1.10"
|
||||
FPV_PROTO_UDP_PORT: "5005"
|
||||
<<: *fpv-env
|
||||
FPV_DATA_DIR: /data
|
||||
FPV_UI_HOST: ${FPV_UI_HOST:-0.0.0.0}
|
||||
FPV_UI_PORT: 8080
|
||||
FPV_UI_LOG_PATH: /data/logs/main.log
|
||||
FPV_UI_FRAME_PATH: ${FPV_UI_FRAME_PATH:-/dev/shm/fpv-latest.jpg}
|
||||
FPV_UI_GUIDANCE_PATH: /data/guidance/guidance_state.json
|
||||
FPV_UI_OUT_DIR: /data/out
|
||||
FPV_UI_INPUT_DIR: /data/input
|
||||
FPV_CAMERA_BRIDGE_URL: ${FPV_CAMERA_BRIDGE_URL:-http://host.docker.internal:8091/stream.mjpg}
|
||||
volumes:
|
||||
- ./runtime-data:/data
|
||||
- type: bind
|
||||
source: .
|
||||
target: /app
|
||||
- type: bind
|
||||
source: ${FPV_DATA_DIR:-./runtime-data}
|
||||
target: /data
|
||||
- type: bind
|
||||
source: ${FPV_MODEL_FILE:-./best.pt}
|
||||
target: /app/best.pt
|
||||
read_only: true
|
||||
ports:
|
||||
- "5600:5600/udp"
|
||||
|
||||
# Для Linux-камеры можно раскомментировать:
|
||||
# devices:
|
||||
# - /dev/video0:/dev/video0
|
||||
- "${FPV_UI_BIND:-127.0.0.1}:${FPV_UI_PORT:-8080}:8080/tcp"
|
||||
|
||||
@ -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)
|
||||
@ -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,5 +1,11 @@
|
||||
--extra-index-url https://download.pytorch.org/whl/cu128
|
||||
|
||||
numpy==1.26.4
|
||||
opencv-python==4.10.0.84
|
||||
torch==2.11.0+cu128
|
||||
torchvision==0.26.0+cu128
|
||||
ultralytics==8.4.75
|
||||
netron==9.2.8
|
||||
pymavlink==2.4.49
|
||||
pyserial==3.5
|
||||
imageio-ffmpeg==0.6.0
|
||||
|
||||
@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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()
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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
Loading…
Reference in New Issue