You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
MAI/configurable_udp_capture.py

416 lines
16 KiB
Python

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