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.
340 lines
12 KiB
Python
340 lines
12 KiB
Python
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()
|