327 lines
10 KiB
Python
327 lines
10 KiB
Python
|
|
import json
|
||
|
|
import socket
|
||
|
|
import threading
|
||
|
|
import time
|
||
|
|
import queue
|
||
|
|
from numcodecs import Blosc
|
||
|
|
|
||
|
|
|
||
|
|
class StreamSender:
|
||
|
|
_QUEUE_SENTINEL = object()
|
||
|
|
|
||
|
|
def __init__(self, state, frame_service):
|
||
|
|
self.state = state
|
||
|
|
self.frame_service = frame_service
|
||
|
|
|
||
|
|
self._lock = threading.RLock()
|
||
|
|
self._stop_event = threading.Event()
|
||
|
|
|
||
|
|
self._sock = None
|
||
|
|
self._thread_capture = None
|
||
|
|
self._thread_send = None
|
||
|
|
|
||
|
|
self._queue = queue.Queue(maxsize=2)
|
||
|
|
|
||
|
|
self._codec = None
|
||
|
|
self._codec_signature = None
|
||
|
|
|
||
|
|
self._t_json_pack = 0.0
|
||
|
|
self._t_send_header = 0.0
|
||
|
|
self._t_send_payload = 0.0
|
||
|
|
|
||
|
|
self._frames_dropped = 0
|
||
|
|
self._capture_errors = 0
|
||
|
|
self._send_errors = 0
|
||
|
|
self._last_sent_camera_frame_id = 0
|
||
|
|
|
||
|
|
@property
|
||
|
|
def is_running(self):
|
||
|
|
return not self._stop_event.is_set() and (
|
||
|
|
self._thread_capture is not None or self._thread_send is not None
|
||
|
|
)
|
||
|
|
|
||
|
|
def start(self, host: str, port: int, fps: float):
|
||
|
|
with self._lock:
|
||
|
|
if self.is_running:
|
||
|
|
raise RuntimeError("Stream já está em execução")
|
||
|
|
|
||
|
|
if not self.state.initialized:
|
||
|
|
raise RuntimeError("Módulo não inicializado")
|
||
|
|
|
||
|
|
self._stop_event.clear()
|
||
|
|
self._clear_queue()
|
||
|
|
|
||
|
|
self._t_json_pack = 0.0
|
||
|
|
self._t_send_header = 0.0
|
||
|
|
self._t_send_payload = 0.0
|
||
|
|
self._frames_dropped = 0
|
||
|
|
self._capture_errors = 0
|
||
|
|
self._send_errors = 0
|
||
|
|
|
||
|
|
self.state.streaming = True
|
||
|
|
self.state.stream_host = host
|
||
|
|
self.state.stream_port = port
|
||
|
|
self.state.stream_fps = fps
|
||
|
|
self._last_sent_camera_frame_id = 0
|
||
|
|
|
||
|
|
self._thread_capture = threading.Thread(
|
||
|
|
target=self._worker_capture,
|
||
|
|
args=(fps,),
|
||
|
|
daemon=True
|
||
|
|
)
|
||
|
|
self._thread_send = threading.Thread(
|
||
|
|
target=self._worker_send,
|
||
|
|
args=(host, port),
|
||
|
|
daemon=True
|
||
|
|
)
|
||
|
|
|
||
|
|
self._thread_capture.start()
|
||
|
|
self._thread_send.start()
|
||
|
|
|
||
|
|
def stop(self):
|
||
|
|
with self._lock:
|
||
|
|
self._stop_event.set()
|
||
|
|
|
||
|
|
try:
|
||
|
|
self._queue.put_nowait(self._QUEUE_SENTINEL)
|
||
|
|
except queue.Full:
|
||
|
|
try:
|
||
|
|
self._queue.get_nowait()
|
||
|
|
except queue.Empty:
|
||
|
|
pass
|
||
|
|
try:
|
||
|
|
self._queue.put_nowait(self._QUEUE_SENTINEL)
|
||
|
|
except queue.Full:
|
||
|
|
pass
|
||
|
|
|
||
|
|
sock = self._sock
|
||
|
|
self._sock = None
|
||
|
|
|
||
|
|
if sock is not None:
|
||
|
|
try:
|
||
|
|
sock.shutdown(socket.SHUT_RDWR)
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
try:
|
||
|
|
sock.close()
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
if self._thread_capture is not None:
|
||
|
|
self._thread_capture.join(timeout=2.0)
|
||
|
|
self._thread_capture = None
|
||
|
|
|
||
|
|
if self._thread_send is not None:
|
||
|
|
self._thread_send.join(timeout=2.0)
|
||
|
|
self._thread_send = None
|
||
|
|
|
||
|
|
self._clear_queue()
|
||
|
|
|
||
|
|
self.state.streaming = False
|
||
|
|
self.state.stream_host = None
|
||
|
|
self.state.stream_port = None
|
||
|
|
self.state.stream_fps = None
|
||
|
|
|
||
|
|
def _clear_queue(self):
|
||
|
|
while not self._queue.empty():
|
||
|
|
try:
|
||
|
|
self._queue.get_nowait()
|
||
|
|
except queue.Empty:
|
||
|
|
break
|
||
|
|
|
||
|
|
def _normalize_shuffle(self, shuffle_value):
|
||
|
|
if isinstance(shuffle_value, int):
|
||
|
|
return shuffle_value
|
||
|
|
|
||
|
|
mapping = {
|
||
|
|
"NOSHUFFLE": Blosc.NOSHUFFLE,
|
||
|
|
"SHUFFLE": Blosc.SHUFFLE,
|
||
|
|
"BITSHUFFLE": Blosc.BITSHUFFLE,
|
||
|
|
}
|
||
|
|
|
||
|
|
key = str(shuffle_value).upper()
|
||
|
|
if key not in mapping:
|
||
|
|
raise ValueError(f"shuffle inválido: {shuffle_value}")
|
||
|
|
|
||
|
|
return mapping[key]
|
||
|
|
|
||
|
|
def _build_codec_from_state(self):
|
||
|
|
family = self.state.codec_family
|
||
|
|
name = self.state.codec_name
|
||
|
|
params = dict(self.state.codec_params)
|
||
|
|
|
||
|
|
if family == "none":
|
||
|
|
return None
|
||
|
|
|
||
|
|
if family != "numcodecs":
|
||
|
|
raise ValueError(f"Família de codec não suportada: {family}")
|
||
|
|
|
||
|
|
if name == "blosc":
|
||
|
|
params["shuffle"] = self._normalize_shuffle(params.get("shuffle", "SHUFFLE"))
|
||
|
|
return Blosc(**params)
|
||
|
|
|
||
|
|
raise ValueError(f"Codec numcodecs não suportado: {name}")
|
||
|
|
|
||
|
|
def _get_codec_signature_from_state(self):
|
||
|
|
return (
|
||
|
|
self.state.codec_family,
|
||
|
|
self.state.codec_name,
|
||
|
|
tuple(sorted(self.state.codec_params.items()))
|
||
|
|
)
|
||
|
|
|
||
|
|
def _ensure_codec(self):
|
||
|
|
sig = self._get_codec_signature_from_state()
|
||
|
|
if self._codec is None or self._codec_signature != sig:
|
||
|
|
self._codec = self._build_codec_from_state()
|
||
|
|
self._codec_signature = sig
|
||
|
|
|
||
|
|
def _compress(self, frame_bytes: bytes) -> bytes:
|
||
|
|
self._ensure_codec()
|
||
|
|
|
||
|
|
if self.state.codec_family == "none":
|
||
|
|
return frame_bytes
|
||
|
|
|
||
|
|
return self._codec.encode(frame_bytes)
|
||
|
|
|
||
|
|
def _send_packet(self, sock: socket.socket, header: dict, payload: bytes):
|
||
|
|
t0 = time.perf_counter()
|
||
|
|
header_bytes = json.dumps(header, separators=(",", ":")).encode("utf-8")
|
||
|
|
t1 = time.perf_counter()
|
||
|
|
|
||
|
|
sock.sendall(len(header_bytes).to_bytes(4, "big"))
|
||
|
|
sock.sendall(header_bytes)
|
||
|
|
sock.sendall(len(payload).to_bytes(4, "big"))
|
||
|
|
t2 = time.perf_counter()
|
||
|
|
|
||
|
|
sock.sendall(payload)
|
||
|
|
t3 = time.perf_counter()
|
||
|
|
|
||
|
|
self._t_json_pack = t1 - t0
|
||
|
|
self._t_send_header = t2 - t1
|
||
|
|
self._t_send_payload = t3 - t2
|
||
|
|
|
||
|
|
def _worker_capture(self, fps: float):
|
||
|
|
frame_interval = 1.0 / fps if fps > 0 else 0.0
|
||
|
|
next_deadline = time.perf_counter()
|
||
|
|
last_frame_ts = None
|
||
|
|
|
||
|
|
while not self._stop_event.is_set():
|
||
|
|
t_loop0 = time.perf_counter()
|
||
|
|
|
||
|
|
try:
|
||
|
|
frame, meta = self.frame_service.capture_frame_raw()
|
||
|
|
except Exception as e:
|
||
|
|
self._capture_errors += 1
|
||
|
|
print(f"[WARN] Capture falhou: {e}")
|
||
|
|
time.sleep(0.05)
|
||
|
|
continue
|
||
|
|
|
||
|
|
if frame is None:
|
||
|
|
time.sleep(0.01)
|
||
|
|
continue
|
||
|
|
|
||
|
|
camera_frame_id = meta.get("camera_frame_id", 0)
|
||
|
|
if camera_frame_id == self._last_sent_camera_frame_id:
|
||
|
|
time.sleep(0.001)
|
||
|
|
continue
|
||
|
|
|
||
|
|
t_frame_ready = time.perf_counter()
|
||
|
|
dt_frame_period = 0.0 if last_frame_ts is None else (t_frame_ready - last_frame_ts)
|
||
|
|
last_frame_ts = t_frame_ready
|
||
|
|
|
||
|
|
t_bytes0 = time.perf_counter()
|
||
|
|
frame_bytes = frame.tobytes()
|
||
|
|
t_bytes1 = time.perf_counter()
|
||
|
|
|
||
|
|
t_comp0 = time.perf_counter()
|
||
|
|
comp_bytes = self._compress(frame_bytes)
|
||
|
|
t_comp1 = time.perf_counter()
|
||
|
|
|
||
|
|
codec_name = None if self.state.codec_family == "none" else self.state.codec_name
|
||
|
|
codec_params = {} if self.state.codec_family == "none" else dict(self.state.codec_params)
|
||
|
|
|
||
|
|
header = {
|
||
|
|
"frame_id": meta.get("frame_id"),
|
||
|
|
"codec_family": self.state.codec_family,
|
||
|
|
"codec_name": codec_name,
|
||
|
|
"codec_params": codec_params,
|
||
|
|
"dt_frame_period": dt_frame_period,
|
||
|
|
"payload_size_raw": len(frame_bytes),
|
||
|
|
"payload_size_comp": len(comp_bytes),
|
||
|
|
"dt_bytes": t_bytes1 - t_bytes0,
|
||
|
|
"dt_comp": t_comp1 - t_comp0,
|
||
|
|
**meta
|
||
|
|
}
|
||
|
|
|
||
|
|
queued = False
|
||
|
|
try:
|
||
|
|
self._queue.put_nowait((header, comp_bytes))
|
||
|
|
queued = True
|
||
|
|
except queue.Full:
|
||
|
|
self._frames_dropped += 1
|
||
|
|
try:
|
||
|
|
self._queue.get_nowait()
|
||
|
|
except queue.Empty:
|
||
|
|
pass
|
||
|
|
try:
|
||
|
|
self._queue.put_nowait((header, comp_bytes))
|
||
|
|
queued = True
|
||
|
|
except queue.Full:
|
||
|
|
self._frames_dropped += 1
|
||
|
|
|
||
|
|
if queued:
|
||
|
|
self._last_sent_camera_frame_id = camera_frame_id
|
||
|
|
|
||
|
|
if frame_interval > 0:
|
||
|
|
next_deadline += frame_interval
|
||
|
|
now = time.perf_counter()
|
||
|
|
|
||
|
|
if next_deadline < now - frame_interval:
|
||
|
|
next_deadline = now
|
||
|
|
|
||
|
|
sleep_time = next_deadline - now
|
||
|
|
if sleep_time > 0:
|
||
|
|
time.sleep(sleep_time)
|
||
|
|
|
||
|
|
def _worker_send(self, host: str, port: int):
|
||
|
|
try:
|
||
|
|
with socket.create_connection((host, port), timeout=5) as sock:
|
||
|
|
sock.settimeout(10)
|
||
|
|
with self._lock:
|
||
|
|
self._sock = sock
|
||
|
|
|
||
|
|
while not self._stop_event.is_set():
|
||
|
|
try:
|
||
|
|
item = self._queue.get(timeout=0.2)
|
||
|
|
except queue.Empty:
|
||
|
|
continue
|
||
|
|
|
||
|
|
if item is self._QUEUE_SENTINEL:
|
||
|
|
break
|
||
|
|
|
||
|
|
header, payload = item
|
||
|
|
|
||
|
|
header["dt_pack_prev"] = self._t_json_pack
|
||
|
|
header["dt_send_header_prev"] = self._t_send_header
|
||
|
|
header["dt_send_payload_prev"] = self._t_send_payload
|
||
|
|
header["stream_drops"] = self._frames_dropped
|
||
|
|
header["capture_errors"] = self._capture_errors
|
||
|
|
header["send_errors"] = self._send_errors
|
||
|
|
|
||
|
|
self._send_packet(sock, header, payload)
|
||
|
|
self.state.stream_frame_id = header["frame_id"]
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
self._send_errors += 1
|
||
|
|
if not self._stop_event.is_set():
|
||
|
|
print(f"[WARN] StreamSender encerrado com erro: {e}")
|
||
|
|
finally:
|
||
|
|
with self._lock:
|
||
|
|
self._sock = None
|
||
|
|
|
||
|
|
self.state.streaming = False
|
||
|
|
self.state.stream_host = None
|
||
|
|
self.state.stream_port = None
|
||
|
|
self.state.stream_fps = None
|
||
|
|
|
||
|
|
self._stop_event.set()
|