Compare commits

...

2 Commits

Author SHA1 Message Date
651c150e23 test controllera
Co-authored-by: Copilot <copilot@github.com>
2026-05-10 21:52:34 +02:00
c38e71dec4 Add VideoStreamController and VideoStreamWorker classes for video processing
Co-authored-by: Copilot <copilot@github.com>
2026-05-10 21:35:48 +02:00
5 changed files with 380 additions and 151 deletions

View File

@@ -1,148 +0,0 @@
from PySide6.QtCore import QThread, QTimer, QMutex, QWaitCondition, Signal, QObject, QElapsedTimer
import cv2
import time
import logging
from dataclasses import dataclass
logger = logging.getLogger(__name__)
@dataclass
class VideoMetrics:
frames_processed: int
frames_dropped: int
last_frame_time: float
last_cap_time: float
fps_average: float
fps_last_time: float
fps_frame_count: float
def update_fps(self) -> None:
fps_now = time.perf_counter()
elypsed = fps_now - self.fps_last_time
if elypsed < 1.0:
return
self.fps_frame_count = self.frames_processed - self.fps_frame_count
self.fps_average = self.fps_frame_count / elypsed
self.fps_frame_count = self.frames_processed
self.fps_last_time = fps_now
class VideoStreamWorker(QObject):
# Emitowany z wątku roboczego - będzie przenoszony do głównego wątku przez VideoStream
_internal_frame = Signal(object)
def __init__(self, source):
super().__init__()
self.source = source
self.cap = None
self.fps = 30.0
self.width = 0
self.height = 0
self.running = False
self.metrics = VideoMetrics(
frames_processed=0,
frames_dropped=0,
last_frame_time=0.0,
last_cap_time=0.0,
fps_average=0.0,
fps_last_time=time.perf_counter(),
fps_frame_count=0.0
)
logger.debug(f"VideoStreamWorker initialized with source: {source}")
def set_source(self, source):
logger.debug(f"Setting new video source: {source}")
self.source = source
if self.running:
self.stop()
self.run()
def run(self):
"""Główna pętla wątku roboczego."""
if self.source is None:
logger.warning("No video source provided")
return
self.cap = cv2.VideoCapture(self.source)
if not self.cap.isOpened():
logger.error(f"Failed to open video source: {self.source}")
return
self.fps = self.cap.get(cv2.CAP_PROP_FPS)
if self.fps <= 0:
self.fps = 30.0
self.width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
self.height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
logger.debug(f"Video opened: {self.source} (fps: {self.fps}, size: {self.width}x{self.height})")
frame_interval = 1.0 / self.fps
self.running = True
frame_emit_time = time.perf_counter()
while self.running:
next_frame_time = frame_emit_time + frame_interval
ret, frame = self.cap.read()
if not ret:
logger.debug("End of video stream or read error")
break
current_time = time.perf_counter()
self.metrics.last_cap_time = current_time - frame_emit_time
if current_time < next_frame_time:
sleep_time = next_frame_time - current_time
time.sleep(sleep_time)
else:
self.metrics.frames_dropped += 1
logger.debug(f"Frame drops counted: {self.metrics.frames_dropped}")
frame_emit_time = time.perf_counter()
self._internal_frame.emit(frame)
self.metrics.frames_processed += 1
self.metrics.update_fps()
self.cap.release()
def stop(self):
self.running = False
self.mutex.lock()
self.condition.wakeAll()
self.mutex.unlock()
class VideoStream(QObject):
"""Klasa fasadowa do użycia w głównym wątku GUI."""
frame_ready = Signal(object) # To będzie emitowane z głównego wątku
def __init__(self, source):
super().__init__()
self.worker = VideoStreamWorker(source)
self.worker_thread = QThread()
# Przenosimy workera do osobnego wątku
self.worker.moveToThread(self.worker_thread)
# Łączymy sygnały
self.worker._internal_frame.connect(self._on_frame)
self.worker_thread.started.connect(self.worker.run)
self.worker_thread.finished.connect(self.worker.deleteLater)
self.worker_thread.finished.connect(self.worker_thread.deleteLater)
def start(self):
self.worker_thread.start()
def stop(self):
self.worker.stop()
self.worker_thread.quit()
self.worker_thread.wait()
def _on_frame(self, frame):
"""Slot wywoływany w głównym wątku po otrzymaniu klatki."""
# Tutaj możesz dodać korekcję ekspozycji jeśli nie zrobiłeś tego w workerze
self.frame_ready.emit(frame)

View File

@@ -2,10 +2,11 @@ from enum import Enum
from typing import Any
import logging
from PySide6.QtWidgets import QGridLayout, QHBoxLayout, QMainWindow, QStyle, QToolButton, QWidget, QVBoxLayout, QPushButton
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import QGridLayout, QHBoxLayout, QMainWindow, QStyle, QToolButton, QWidget, QVBoxLayout, QPushButton, QLabel
from PySide6.QtGui import QIcon, QPixmap
from PySide6.QtCore import Qt, QSize
from .video_stream.controller import VideoStreamController, FrameMetrics
logger = logging.getLogger(__name__)
class VideoMode(Enum):
@@ -70,13 +71,22 @@ class MainWindow(QMainWindow):
self.video_mode = VideoMode.STREAMING
logger.debug(f"Initial video mode: {self.video_mode}")
self.video_controller = VideoStreamController()
self.video_controller.change_source(0) # Start with default camera
self.video_controller.image_ready.connect(self.update_frame)
self.setup_ui()
self.video_controller.start()
def setup_ui(self):
self.central_widget = QWidget()
self.setCentralWidget(self.central_widget)
self.central_widget.setStyleSheet("background-color: #001e1e;")
self.video_label = QLabel(self.central_widget)
self.toolbar_widget = QWidget(self.central_widget)
self.toolbar_widget.setMinimumWidth(400)
self.toolbar_widget.setObjectName("bottomToolbar")
@@ -186,3 +196,11 @@ class MainWindow(QMainWindow):
logger.debug("Pausing video")
self.video_mode = VideoMode.STOPPED
set_icon(self.action_button, "play")
def closeEvent(self, event: Any) -> None:
logger.debug("Closing main window, cleaning up resources.")
self.video_controller.cleanup()
super().closeEvent(event)
def update_frame(self, frame):
self.video_label.setPixmap(QPixmap.fromImage(frame))

View File

@@ -0,0 +1,3 @@
from .controller import VideoStreamController
__all__ = ['VideoStreamController']

View File

@@ -0,0 +1,238 @@
import numpy as np
import cv2
from logging import getLogger
from collections import deque
from cv2_enumerate_cameras import enumerate_cameras
from PySide6.QtCore import QObject, QThread, Signal, Slot
from PySide6.QtGui import QImage
from dataclasses import dataclass, field
from .worker import VideoStreamWorker
logger = getLogger(__name__)
@dataclass
class FrameMetrics:
fr_start_delta_min: float = float('inf') # Minimum time between frame read starts
fr_start_delta_max: float = 0.0 # Maximum time between frame read starts
fr_start_delta_avg: float = 0.0 # Average time between frame read starts (calculated over a buffer of recent frames)
fr_time_min: float = float('inf') # Minimum time taken to read a frame
fr_time_max: float = 0.0 # Maximum time taken to read a frame
fr_time_avg: float = 0.0 # Average time taken to read a frame (calculated over a buffer of recent frames)
fps_average_1s: float = 0.0 # Average FPS calculated over the last 1 second (or a buffer of recent frames)
frame_count: int = 0 # Total number of frames processed
last_start_time: float = 0.0 # Timestamp of the last frame read start (used for calculating deltas)
buf_size: int = 30 # Size of the buffer for calculating average metrics
b_fr_start_delta: deque = field(init=False, repr=False) # Buffer for recent frame read start deltas (used for calculating average)
total_start_delta: float = 0.0 # Total accumulated frame read start delta (used for calculating average)
b_fr_time_delta: deque = field(init=False, repr=False) # Buffer for recent frame read time deltas (used for calculating average)
total_frame_time: float = 0.0 # Total accumulated frame read time delta (used for calculating average)
def __post_init__(self):
self.b_fr_start_delta = deque(maxlen=self.buf_size)
self.b_fr_time_delta = deque(maxlen=self.buf_size)
def calc_start_delta_min_max_avg(self, start_time: float) -> None:
if self.last_start_time == 0.0:
self.last_start_time = start_time
return
start_delta = start_time - self.last_start_time
self.fr_start_delta_min = min(self.fr_start_delta_min, start_delta)
self.fr_start_delta_max = max(self.fr_start_delta_max, start_delta)
if len(self.b_fr_start_delta) == self.buf_size:
self.total_start_delta -= self.b_fr_start_delta[0]
self.b_fr_start_delta.append(start_delta)
self.total_start_delta += start_delta
self.last_start_time = start_time
self.fr_start_delta_avg = self.total_start_delta / len(self.b_fr_start_delta)
def calc_frame_time_min_max_avg(self, start_time: float, end_time: float) -> None:
fr_time = end_time - start_time
self.fr_time_min = min(self.fr_time_min, fr_time)
self.fr_time_max = max(self.fr_time_max, fr_time)
if len(self.b_fr_time_delta) == self.buf_size:
self.total_frame_time -= self.b_fr_time_delta[0]
self.b_fr_time_delta.append(fr_time)
self.total_frame_time += fr_time
self.fr_time_avg = self.total_frame_time / len(self.b_fr_time_delta)
def calc_fps(self) -> None:
if self.frame_count < self.buf_size:
return
# fps = (len(self.b_fr_start_delta)) / self.total_start_delta if self.total_start_delta > 0 else 0.0
# self.fps_average_1s = fps
self.fps_average_1s = 1.0 / self.fr_start_delta_avg if self.fr_start_delta_avg > 0 else 0.0
def update_metrics(self, start_time: float, end_time: float) -> None:
self.frame_count += 1
self.calc_start_delta_min_max_avg(start_time)
self.calc_frame_time_min_max_avg(start_time, end_time)
self.calc_fps()
def reset_metrics(self) -> None:
self.fr_start_delta_min = float('inf')
self.fr_start_delta_max = 0.0
self.fr_start_delta_avg = 0.0
self.fr_time_min = float('inf')
self.fr_time_max = 0.0
self.fr_time_avg = 0.0
self.fps_average_1s = 0.0
self.frame_count = 0
self.last_start_time = 0.0
self.b_fr_start_delta.clear()
self.total_start_delta = 0.0
self.b_fr_time_delta.clear()
self.total_frame_time = 0.0
def get_metrics(self) -> "FrameMetrics":
return FrameMetrics(
fr_start_delta_min= round(self.fr_start_delta_min * 1000, 3), # Convert to milliseconds
fr_start_delta_max= round(self.fr_start_delta_max * 1000, 3),
fr_start_delta_avg= round(self.fr_start_delta_avg * 1000, 3),
fr_time_min= round(self.fr_time_min * 1000, 3),
fr_time_max= round(self.fr_time_max * 1000, 3),
fr_time_avg= round(self.fr_time_avg * 1000, 3),
fps_average_1s= round(self.fps_average_1s, 3),
frame_count=self.frame_count
)
class VideoStreamController(QObject):
"""
Facade class managing the VideoStreamWorker and its QThread.
Converts raw frames to QImage for the UI and can route them to other processors.
"""
image_ready = Signal(QImage) # Signal for the UI
raw_frame_ready = Signal(np.ndarray) # Signal for other processing modules
error_occurred = Signal(str)
status_changed = Signal(bool)
def __init__(self):
super().__init__()
self._thread = QThread()
self._worker = VideoStreamWorker()
self._worker.moveToThread(self._thread)
self._metrics: FrameMetrics | None = None
# Connect internal signals
self._worker.frame_ready.connect(self._handle_frame)
self._worker.frame_time.connect(self._handle_frame_time)
self._worker.error_occurred.connect(self.error_occurred)
self._worker.started.connect(self.started)
self._worker.stopped.connect(self.stopped)
# Cleanup
self._thread.finished.connect(self._worker.stop)
self._thread.start()
@staticmethod
def get_available_cameras():
"""
Returns a list of available camera devices using cv2_enumerate_cameras.
Each item is an object with properties like 'index' and 'name'.
"""
return list(enumerate_cameras())
@Slot()
def get_metrics(self) -> FrameMetrics | None:
"""
Returns the current frame metrics.
"""
return self._metrics.get_metrics() if self._metrics else None
@Slot(object)
def change_source(self, source):
"""
Change the video source (camera index or file path).
"""
self._worker.set_source(source)
@Slot()
def start(self):
logger.debug("Starting video stream worker thread.")
self._worker.start()
@Slot()
def stop(self):
logger.debug("Stopping video stream worker thread.")
self._worker.stop()
@Slot(float, tuple)
def started(self, fps: float, video_res: tuple):
logger.debug(f"Video stream worker started with FPS: {fps}, Resolution: {video_res[0]}x{video_res[1]}")
self._metrics = FrameMetrics()
self.status_changed.emit(True)
@Slot()
def stopped(self):
logger.debug("Video stream worker thread stopped.")
if self._metrics:
self._metrics.reset_metrics()
self.status_changed.emit(False)
def cleanup(self):
"""
Safely shuts down the worker and the thread.
Must be called before the application exits.
"""
logger.debug("Cleaning up video stream controller.")
self._worker.stop()
self._thread.quit()
if not self._thread.wait(2000): # Wait up to 2 seconds
self._thread.terminate()
self._thread.wait()
@Slot(np.ndarray)
def _handle_frame(self, frame):
"""
Internal handler for frames from the worker.
Handles conversion to QImage and routing.
"""
# 1. Emit raw frame for other processing (OCR, Detection, etc.)
self.raw_frame_ready.emit(frame)
# 2. Convert to QImage for UI
q_image = self._convert_to_qimage(frame)
self.image_ready.emit(q_image)
def _convert_to_qimage(self, frame):
"""
Converts a BGR numpy array to a RGB QImage.
"""
if frame is None:
return QImage()
# OpenCV uses BGR, PySide uses RGB
try:
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
height, width, channel = frame_rgb.shape
bytes_per_line = channel * width
return QImage(
frame_rgb.data,
width,
height,
bytes_per_line,
QImage.Format.Format_RGB888
).copy() # .copy() ensures the QImage owns the data and is safe to pass across threads
except Exception as e:
self.error_occurred.emit(f"Image conversion error: {str(e)}")
return QImage()
@Slot(float, float)
def _handle_frame_time(self, start_time, end_time):
"""
Optional: Handle frame timing information for performance monitoring.
"""
if self._metrics:
self._metrics.update_metrics(start_time, end_time)

118
app/video_stream/worker.py Normal file
View File

@@ -0,0 +1,118 @@
import cv2
import numpy as np
import time
from PySide6.QtCore import QObject, Signal, Slot, QTimer
from logging import getLogger
logger = getLogger(__name__)
class VideoStreamWorker(QObject):
"""
Worker class responsible for reading frames from OpenCV VideoCapture.
Runs in a separate QThread.
"""
frame_ready = Signal(np.ndarray)
frame_time = Signal(float, float) # (start_time, end_time)
error_occurred = Signal(str)
started = Signal(float, tuple) # (fps, (width, height))
stopped = Signal()
source_changed = Signal(object) # Nowy sygnał dla zmiany źródła
def __init__(self):
super().__init__()
self._cap = None
self._source = 0 # Default to first camera
self._is_running = False
self._timer = QTimer()
self._timer.timeout.connect(self._process_frame)
self.source_changed.connect(self._on_source_changed) # Łączymy sygnał ze slotem
@Slot(object)
def set_source(self, source):
"""
Sets the video source asynchronously.
source: int (camera index) or str (file path).
"""
self.source_changed.emit(source) # Emitujemy sygnał zamiast synchronicznej zmiany
@Slot(object)
def _on_source_changed(self, source):
"""
Slot obsługujący zmianę źródła w wątku roboczym.
"""
logger.debug(f"Changing video source to: {source}")
was_running = self._is_running
if was_running:
self.stop() # Zatrzymujemy synchronicznie w tym samym wątku
self._source = source
if was_running:
self.start() # Uruchamiamy ponownie w tym samym wątku
@Slot()
def start(self):
if self._is_running:
logger.warning("VideoStreamWorker is already running.")
return
if self._source is None:
logger.error("No video source specified.")
self.error_occurred.emit("No source specified.")
return
self._cap = cv2.VideoCapture(self._source)
if not self._cap.isOpened():
logger.error(f"Could not open source: {self._source}")
self.error_occurred.emit(f"Could not open source: {self._source}")
return
self._is_running = True
# Using a timer for consistent frame rate and to play nice with the event loop
# For files, we might want to calculate the interval from FPS.
fps = self._cap.get(cv2.CAP_PROP_FPS)
interval = int(1000 / fps) if fps > 0 else 30
video_res = (int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)), int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT)))
logger.debug(f"Video opened: {self._source} (fps: {fps}, width: {video_res[0]}, height: {video_res[1]})")
self._timer.start(interval)
self.started.emit(fps, video_res)
@Slot()
def stop(self):
logger.debug("Stopping VideoStreamWorker.")
self._is_running = False
self._timer.stop()
if self._cap:
self._cap.release()
self._cap = None
self.stopped.emit()
def _process_frame(self):
if not self._is_running:
logger.debug("VideoStreamWorker is not running. Skipping frame processing.")
return
if self._cap is None or not self._cap.isOpened():
logger.error("VideoCapture is not initialized or opened.")
self.error_occurred.emit("VideoCapture not initialized.")
self.stop()
return
try:
read_start_time = time.perf_counter()
ret, frame = self._cap.read()
if ret:
self.frame_ready.emit(frame)
else:
# End of video file or lost connection
if isinstance(self._source, str):
self.stop()
else:
logger.error("Lost connection to camera.")
self.error_occurred.emit("Lost connection to camera.")
self.stop()
read_end_time = time.perf_counter()
self.frame_time.emit(read_start_time, read_end_time)
except Exception as e:
logger.error(f"Error reading frame: {str(e)}")
self.error_occurred.emit(f"Error reading frame: {str(e)}")