diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ + diff --git a/mucapy/main.py b/mucapy/main.py index 943bf37..fe9da44 100644 --- a/mucapy/main.py +++ b/mucapy/main.py @@ -1,18 +1,170 @@ import os import sys import cv2 +import json +import urllib.parse import numpy as np from PyQt5.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout, QWidget, QLabel, QPushButton, QComboBox, QSpinBox, QFileDialog, QMessageBox, QMenu, QAction, QMenuBar, QActionGroup, QSizePolicy, QGridLayout, QGroupBox, - QDockWidget, QScrollArea, QToolButton, QDialog) -from PyQt5.QtCore import Qt, QTimer, QDir, QSize -from PyQt5.QtGui import QImage, QPixmap, QIcon, QColor + QDockWidget, QScrollArea, QToolButton, QDialog, + QShortcut, QListWidget, QFormLayout, QLineEdit, + QCheckBox, QTabWidget, QListWidgetItem, QSplitter) +from PyQt5.QtCore import Qt, QTimer, QDir, QSize, QSettings, QDateTime, QRect, QThread, pyqtSignal, QMutex +from PyQt5.QtGui import (QImage, QPixmap, QIcon, QColor, QKeySequence, QPainter, + QPen, QBrush) +import time + +class Config: + def __init__(self): + # Use platform-specific user directory for config + if sys.platform.startswith('win'): + config_dir = os.path.join(os.environ.get('APPDATA', os.path.expanduser('~')), 'MuCaPy') + pictures_dir = os.path.join(os.environ.get('USERPROFILE', os.path.expanduser('~')), 'Pictures', 'MuCaPy') + else: + config_dir = os.path.join(os.path.expanduser('~'), '.config', 'mucapy') + pictures_dir = os.path.join(os.path.expanduser('~'), 'Pictures', 'MuCaPy') + + # Create config directory if it doesn't exist + os.makedirs(config_dir, exist_ok=True) + + self.config_file = os.path.join(config_dir, 'config.json') + self.settings = { + 'network_cameras': {}, # Store network cameras configuration + 'last_model_dir': '', + 'last_screenshot_dir': pictures_dir, + 'last_layout': 0, + 'last_fps': 10, + 'last_selected_cameras': [], + 'window_geometry': None, + 'confidence_threshold': 0.35, + } + self.load_config() + + def load_config(self): + """Load configuration from JSON file""" + try: + if os.path.exists(self.config_file): + with open(self.config_file, 'r') as f: + loaded_settings = json.load(f) + # Update settings while preserving default values for new keys + self.settings.update(loaded_settings) + except Exception as e: + print(f"Error loading config: {e}") + + def save_config(self): + """Save configuration to JSON file""" + try: + # Ensure the file's directory exists + os.makedirs(os.path.dirname(self.config_file), exist_ok=True) + with open(self.config_file, 'w') as f: + json.dump(self.settings, f, indent=4) + except Exception as e: + print(f"Error saving config: {e}") + + def save_setting(self, key, value): + """Save a setting to configuration""" + self.settings[key] = value + self.save_config() + + def load_setting(self, key, default=None): + """Load a setting from configuration""" + return self.settings.get(key, default) + +class CameraThread(QThread): + """Thread class for handling camera connections and frame grabbing""" + frame_ready = pyqtSignal(int, np.ndarray) # Signal to emit when new frame is ready (camera_index, frame) + error_occurred = pyqtSignal(int, str) # Signal to emit when error occurs (camera_index, error_message) + + def __init__(self, camera_id, camera_info, parent=None): + super().__init__(parent) + self.camera_id = camera_id + self.camera_info = camera_info + self.running = False + self.cap = None + self.mutex = QMutex() + self.frame_interval = 1.0 / 30 # Default to 30 FPS + + def set_fps(self, fps): + """Set the target FPS for frame capture""" + self.frame_interval = 1.0 / fps + + def run(self): + """Main thread loop""" + try: + # Connect to camera + if isinstance(self.camera_info, str) and self.camera_info.startswith('net:'): + name = self.camera_info[4:] + detector = self.parent().detector if self.parent() else None + if detector and name in detector.network_cameras: + camera_info = detector.network_cameras[name] + if isinstance(camera_info, dict): + url = camera_info['url'] + if 'username' in camera_info and 'password' in camera_info: + parsed = urllib.parse.urlparse(url) + netloc = f"{camera_info['username']}:{camera_info['password']}@{parsed.netloc}" + url = parsed._replace(netloc=netloc).geturl() + else: + url = camera_info + self.cap = cv2.VideoCapture(url) + else: + # Local camera + self.cap = cv2.VideoCapture(int(self.camera_info) if str(self.camera_info).isdigit() else self.camera_info) + + if not self.cap.isOpened(): + self.error_occurred.emit(self.camera_id, "Failed to open camera") + return + + self.running = True + last_frame_time = time.time() + + while self.running: + self.mutex.lock() + if not self.running: + self.mutex.unlock() + break + + # Check if enough time has passed since last frame + current_time = time.time() + if current_time - last_frame_time < self.frame_interval: + self.mutex.unlock() + time.sleep(0.001) # Small sleep to prevent CPU hogging + continue + + ret, frame = self.cap.read() + self.mutex.unlock() + + if ret: + self.frame_ready.emit(self.camera_id, frame) + last_frame_time = current_time + else: + self.error_occurred.emit(self.camera_id, "Failed to read frame") + break + + except Exception as e: + self.error_occurred.emit(self.camera_id, str(e)) + + finally: + self.cleanup() + + def stop(self): + """Stop the thread safely""" + self.mutex.lock() + self.running = False + self.mutex.unlock() + self.wait() + + def cleanup(self): + """Clean up camera resources""" + if self.cap: + self.cap.release() + self.running = False class MultiCamYOLODetector: def __init__(self): self.cameras = [] + self.camera_threads = {} # Dictionary to store camera threads self.net = None self.classes = [] self.colors = [] @@ -22,7 +174,21 @@ class MultiCamYOLODetector: self.available_cameras = [] self.model_dir = "" self.cuda_available = self.check_cuda() + self.config = Config() + self.latest_frames = {} # Store latest frames from each camera + self.frame_lock = QMutex() # Mutex for thread-safe frame access + # Load settings + self.confidence_threshold = self.config.load_setting('confidence_threshold', 0.35) + self.network_cameras = self.config.load_setting('network_cameras', {}) + self.target_fps = self.config.load_setting('last_fps', 10) + self.frame_interval = 1.0 / self.target_fps + + # Load last used model if available + last_model = self.config.load_setting('last_model_dir') + if last_model and os.path.exists(last_model): + self.load_yolo_model(last_model) + def check_cuda(self): """Check if CUDA is available""" try: @@ -31,34 +197,57 @@ class MultiCamYOLODetector: except: return False + def add_network_camera(self, name, camera_info): + """Add a network camera to the saved list""" + self.network_cameras[name] = camera_info + # Save to configuration immediately + self.config.settings['network_cameras'] = self.network_cameras + self.config.save_config() + + def remove_network_camera(self, name): + """Remove a network camera from the saved list""" + if name in self.network_cameras: + del self.network_cameras[name] + # Save to configuration immediately + self.config.settings['network_cameras'] = self.network_cameras + self.config.save_config() + def scan_for_cameras(self, max_to_check=10): - """Check for available cameras on Linux with better error handling""" + """Check for available cameras including network cameras""" self.available_cameras = [] - # Check standard video devices + # Try numeric indices first (this works on all platforms) for i in range(max_to_check): try: - cap = cv2.VideoCapture(i, cv2.CAP_V4L2) + cap = cv2.VideoCapture(i) if cap.isOpened(): self.available_cameras.append(str(i)) cap.release() except: continue - # Also check direct device paths - v4l_paths = [f"/dev/video{i}" for i in range(max_to_check)] - v4l_paths += [f"/dev/v4l/video{i}" for i in range(max_to_check)] + # Platform-specific device path checks + if sys.platform.startswith('linux'): + if os.path.exists('/dev'): + for i in range(max_to_check): + device_path = f"/dev/video{i}" + if os.path.exists(device_path) and device_path not in self.available_cameras: + try: + cap = cv2.VideoCapture(device_path) + if cap.isOpened(): + self.available_cameras.append(device_path) + cap.release() + except: + continue - for path in v4l_paths: - if os.path.exists(path): - try: - cap = cv2.VideoCapture(path, cv2.CAP_V4L2) - if cap.isOpened(): - if path not in self.available_cameras: - self.available_cameras.append(path) - cap.release() - except: - continue + # Add saved network cameras + for name, camera_info in self.network_cameras.items(): + if isinstance(camera_info, dict): + url = camera_info.get('url', '') + self.available_cameras.append(f"net:{name}") # Use name instead of URL for better identification + else: + # Handle old format where camera_info was just the URL + self.available_cameras.append(f"net:{name}") return self.available_cameras @@ -105,44 +294,64 @@ class MultiCamYOLODetector: return False def connect_cameras(self, camera_paths): - """Connect to multiple cameras with better error handling""" + """Connect to multiple cameras using threads""" self.disconnect_cameras() + success = True - for cam_path in camera_paths: + for i, cam_path in enumerate(camera_paths): try: - if isinstance(cam_path, str) and cam_path.startswith('/dev/'): - cap = cv2.VideoCapture(cam_path, cv2.CAP_V4L2) - else: - cap = cv2.VideoCapture(int(cam_path), cv2.CAP_V4L2) - - if not cap.isOpened(): - print(f"Warning: Could not open camera {cam_path}") - continue - - # Try to set properties but continue if they fail - try: - cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) - cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) - cap.set(cv2.CAP_PROP_FPS, self.target_fps) - cap.set(cv2.CAP_PROP_AUTOFOCUS, 0) - cap.set(cv2.CAP_PROP_BUFFERSIZE, 2) - except: - pass - - self.cameras.append((cam_path, cap)) + thread = CameraThread(i, cam_path) + thread.frame_ready.connect(self.handle_new_frame) + thread.error_occurred.connect(self.handle_camera_error) + thread.set_fps(self.target_fps) + self.camera_threads[i] = thread + self.cameras.append((cam_path, None)) # Store camera path for reference + thread.start() except Exception as e: - print(f"Error opening camera {cam_path}: {e}") + print(f"Error connecting to camera {cam_path}: {e}") + success = False - return len(self.cameras) > 0 + return success def disconnect_cameras(self): - """Disconnect all cameras""" - for _, cam in self.cameras: - try: - cam.release() - except: - pass - self.cameras = [] + """Disconnect all cameras and stop threads""" + for thread in self.camera_threads.values(): + thread.stop() + self.camera_threads.clear() + self.cameras.clear() + self.latest_frames.clear() + + def handle_new_frame(self, camera_index, frame): + """Handle new frame from camera thread""" + self.frame_lock.lock() + try: + # Apply YOLO detection + processed_frame = self.get_detections(frame) + self.latest_frames[camera_index] = processed_frame + finally: + self.frame_lock.unlock() + + def handle_camera_error(self, camera_index, error_message): + """Handle camera errors""" + print(f"Camera {camera_index} error: {error_message}") + # You might want to implement more sophisticated error handling here + + def get_frames(self): + """Get the latest frames from all cameras""" + self.frame_lock.lock() + try: + frames = [] + for i in range(len(self.cameras)): + frame = self.latest_frames.get(i) + if frame is None: + # Create blank frame if no frame is available + frame = np.zeros((720, 1280, 3), dtype=np.uint8) + cv2.putText(frame, "No Signal", (480, 360), + cv2.FONT_HERSHEY_SIMPLEX, 1.5, (255, 255, 255), 2) + frames.append(frame) + return frames + finally: + self.frame_lock.unlock() def get_detections(self, frame): """Perform YOLO object detection on a frame with error handling""" @@ -172,7 +381,7 @@ class MultiCamYOLODetector: class_id = np.argmax(scores) confidence = scores[class_id] - if confidence > 0.5: + if confidence > self.confidence_threshold: # Use configurable threshold box = detection[0:4] * np.array([frame.shape[1], frame.shape[0], frame.shape[1], frame.shape[0]]) (centerX, centerY, width, height) = box.astype('int') @@ -183,7 +392,7 @@ class MultiCamYOLODetector: confidences.append(float(confidence)) class_ids.append(class_id) - indices = cv2.dnn.NMSBoxes(boxes, confidences, 0.5, 0.4) + indices = cv2.dnn.NMSBoxes(boxes, confidences, self.confidence_threshold, 0.4) if len(indices) > 0: for i in indices.flatten(): @@ -196,24 +405,6 @@ class MultiCamYOLODetector: print(f"Detection error: {e}") return frame - - def get_frames(self): - """Get frames from all cameras with error handling""" - frames = [] - for i, (cam_path, cam) in enumerate(self.cameras): - try: - ret, frame = cam.read() - if not ret: - print(f"Warning: Could not read frame from camera {cam_path}") - frame = np.zeros((720, 1280, 3), dtype=np.uint8) - else: - frame = self.get_detections(frame) - frames.append(frame) - except: - frame = np.zeros((720, 1280, 3), dtype=np.uint8) - frames.append(frame) - - return frames class CameraDisplay(QLabel): """Custom QLabel for displaying camera feed with fullscreen support""" @@ -233,11 +424,55 @@ class CameraDisplay(QLabel): self.fullscreen_window = None self.cam_id = None self.fullscreen_timer = None + self.config = Config() + self.screenshot_dir = self.config.load_setting('screenshot_dir', os.path.expanduser('~/Pictures/MuCaPy')) + self.camera_name = None + + # Create screenshot directory if it doesn't exist + if not os.path.exists(self.screenshot_dir): + os.makedirs(self.screenshot_dir, exist_ok=True) def set_cam_id(self, cam_id): """Set camera identifier for this display""" self.cam_id = cam_id + def set_camera_name(self, name): + """Set the camera name for display""" + self.camera_name = name + self.update() + + def take_screenshot(self): + """Take a screenshot of the current frame""" + if not self.pixmap(): + return + + # Ask for screenshot directory if not set + if not self.screenshot_dir: + dir_path = QFileDialog.getExistingDirectory( + self, + "Select Screenshot Directory", + os.path.expanduser('~/Pictures'), + QFileDialog.ShowDirsOnly | QFileDialog.DontResolveSymlinks + ) + if dir_path: + self.screenshot_dir = dir_path + self.config.save_setting('screenshot_dir', dir_path) + if not os.path.exists(dir_path): + os.makedirs(dir_path, exist_ok=True) + else: + return + + # Generate filename with timestamp + timestamp = QDateTime.currentDateTime().toString('yyyy-MM-dd_hh-mm-ss') + filename = f"camera_{self.cam_id}_{timestamp}.png" + filepath = os.path.join(self.screenshot_dir, filename) + + # Save the image + if self.pixmap().save(filepath): + QMessageBox.information(self, "Success", f"Screenshot saved to:\n{filepath}") + else: + QMessageBox.critical(self, "Error", "Failed to save screenshot") + def mouseDoubleClickEvent(self, event): """Handle double click to toggle fullscreen""" if self.pixmap() and not self.fullscreen_window: @@ -246,31 +481,49 @@ class CameraDisplay(QLabel): self.close_fullscreen() def show_fullscreen(self): - """Show this camera in fullscreen mode""" - self.fullscreen_window = QMainWindow() - self.fullscreen_window.setWindowTitle(f"Camera {self.cam_id} - Fullscreen") + """Show this camera in a new window""" + if not self.pixmap(): + return + + self.fullscreen_window = QMainWindow(self.window()) + self.fullscreen_window.setWindowTitle(f"Camera {self.cam_id}") - screen = QApplication.primaryScreen().availableGeometry() - self.fullscreen_window.resize(int(screen.width() * 0.9), int(screen.height() * 0.9)) + # Create central widget + central_widget = QWidget() + layout = QVBoxLayout(central_widget) - label = QLabel() - label.setAlignment(Qt.AlignCenter) - if self.pixmap(): - label.setPixmap(self.pixmap().scaled( - self.fullscreen_window.size(), - Qt.KeepAspectRatio, - Qt.SmoothTransformation - )) + # Create fullscreen label + self.fullscreen_label = QLabel() + self.fullscreen_label.setAlignment(Qt.AlignCenter) + self.fullscreen_label.setMinimumSize(640, 480) # Set minimum size + self.fullscreen_label.setPixmap(self.pixmap().scaled( + QSize(1280, 720), # Default HD size + Qt.KeepAspectRatio, + Qt.SmoothTransformation + )) + layout.addWidget(self.fullscreen_label) - self.fullscreen_window.setCentralWidget(label) - self.fullscreen_window.showFullScreen() + self.fullscreen_window.setCentralWidget(central_widget) + + # Add ESC shortcut to close + shortcut = QShortcut(QKeySequence(Qt.Key_Escape), self.fullscreen_window) + shortcut.activated.connect(self.close_fullscreen) + + # Add screenshot shortcut (Ctrl+S) + screenshot_shortcut = QShortcut(QKeySequence("Ctrl+S"), self.fullscreen_window) + screenshot_shortcut.activated.connect(self.take_screenshot) # Update fullscreen image when main window updates self.fullscreen_timer = QTimer() self.fullscreen_timer.timeout.connect( - lambda: self.update_fullscreen(label) + lambda: self.update_fullscreen(self.fullscreen_label) ) self.fullscreen_timer.start(30) + + # Set window size and show + screen = QApplication.primaryScreen().availableGeometry() + self.fullscreen_window.resize(min(1280, screen.width() * 0.8), min(720, screen.height() * 0.8)) + self.fullscreen_window.show() def update_fullscreen(self, label): """Update the fullscreen display""" @@ -289,6 +542,23 @@ class CameraDisplay(QLabel): self.fullscreen_window.close() self.fullscreen_window = None self.fullscreen_timer = None + + def paintEvent(self, event): + """Override paint event to draw camera name overlay""" + super().paintEvent(event) + if self.camera_name and self.pixmap(): + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + + # Draw semi-transparent background + painter.setPen(Qt.NoPen) + painter.setBrush(QBrush(QColor(0, 0, 0, 180))) + rect = QRect(10, 10, painter.fontMetrics().width(self.camera_name) + 20, 30) + painter.drawRoundedRect(rect, 5, 5) + + # Draw text + painter.setPen(QPen(QColor(255, 255, 255))) + painter.drawText(rect, Qt.AlignCenter, self.camera_name) class CollapsibleDock(QDockWidget): """Custom dock widget with collapse/expand functionality""" @@ -416,12 +686,488 @@ class AboutWindow(QDialog): """) self.setLayout(layout) + +class NetworkCameraDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Network Camera Settings") + self.setModal(True) + self.resize(500, 400) + + layout = QVBoxLayout(self) + + # Instructions label + instructions = QLabel( + "Enter network camera details:\n" + "- For DroidCam: Use the IP and port shown in the app\n" + " Example: http://192.168.1.100:4747/video\n" + "- For other IP cameras: Enter the full stream URL\n" + "- Enable authentication if the camera requires username/password" + ) + instructions.setWordWrap(True) + layout.addWidget(instructions) + + # Camera list + self.camera_list = QListWidget() + layout.addWidget(self.camera_list) + + # Input fields + form_layout = QFormLayout() + + # Name and URL + self.name_edit = QLineEdit() + self.url_edit = QLineEdit() + form_layout.addRow("Name:", self.name_edit) + form_layout.addRow("URL:", self.url_edit) + + # Authentication group + auth_group = QGroupBox("Authentication") + auth_layout = QVBoxLayout() + + self.auth_checkbox = QCheckBox("Enable Authentication") + self.auth_checkbox.stateChanged.connect(self.toggle_auth_fields) + auth_layout.addWidget(self.auth_checkbox) + + auth_form = QFormLayout() + self.username_edit = QLineEdit() + self.password_edit = QLineEdit() + self.password_edit.setEchoMode(QLineEdit.Password) + auth_form.addRow("Username:", self.username_edit) + auth_form.addRow("Password:", self.password_edit) + auth_layout.addLayout(auth_form) + + auth_group.setLayout(auth_layout) + form_layout.addRow(auth_group) + + layout.addLayout(form_layout) + + # Initially disable auth fields + self.username_edit.setEnabled(False) + self.password_edit.setEnabled(False) + + # Buttons + btn_layout = QHBoxLayout() + add_btn = QPushButton("Add Camera") + add_btn.clicked.connect(self.add_camera) + remove_btn = QPushButton("Remove Camera") + remove_btn.clicked.connect(self.remove_camera) + close_btn = QPushButton("Close") + close_btn.clicked.connect(self.accept) + + btn_layout.addWidget(add_btn) + btn_layout.addWidget(remove_btn) + btn_layout.addWidget(close_btn) + layout.addLayout(btn_layout) + + self.detector = parent.detector if parent else None + self.load_cameras() + + def toggle_auth_fields(self, state): + """Enable/disable authentication fields based on checkbox state""" + enabled = state == Qt.Checked + self.username_edit.setEnabled(enabled) + self.password_edit.setEnabled(enabled) + if not enabled: + self.username_edit.clear() + self.password_edit.clear() + + def load_cameras(self): + """Load saved network cameras into the list""" + if not self.detector: + return + + self.camera_list.clear() + for name, camera_info in self.detector.network_cameras.items(): + if isinstance(camera_info, dict): + url = camera_info.get('url', '') + has_auth = camera_info.get('username') is not None + display_text = f"{name} ({url})" + if has_auth: + display_text += " [Auth]" + else: + # Handle old format where camera_info was just the URL + display_text = f"{name} ({camera_info})" + self.camera_list.addItem(display_text) + + def add_camera(self): + """Add a new network camera""" + name = self.name_edit.text().strip() + url = self.url_edit.text().strip() + + if not name or not url: + QMessageBox.warning(self, "Error", "Please enter both name and URL") + return + + if self.detector: + camera_info = {'url': url} + + # Add authentication if enabled + if self.auth_checkbox.isChecked(): + username = self.username_edit.text().strip() + password = self.password_edit.text().strip() + if username and password: + camera_info['username'] = username + camera_info['password'] = password + + self.detector.add_network_camera(name, camera_info) + self.load_cameras() + + # Clear fields + self.name_edit.clear() + self.url_edit.clear() + self.username_edit.clear() + self.password_edit.clear() + self.auth_checkbox.setChecked(False) + + def remove_camera(self): + """Remove selected network camera""" + current = self.camera_list.currentItem() + if not current: + return + + name = current.text().split(" (")[0] + if self.detector: + self.detector.remove_network_camera(name) + self.load_cameras() + +class CameraSelectorDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Camera Selector") + self.setModal(True) + self.resize(800, 600) # Increased size for better visibility + + self.detector = parent.detector if parent else None + self.selected_cameras = [] + + # Main layout + layout = QVBoxLayout(self) + + # Instructions with better formatting + instructions = QLabel( + "Camera Selection Guide:\n\n" + "• Local Cameras: Built-in and USB cameras\n" + "• Network Cameras: IP cameras, DroidCam, etc.\n" + "• Use checkboxes to select/deselect cameras\n" + "• Double-click a camera to test the connection\n" + "• Selected cameras will appear in the preview below" + ) + instructions.setStyleSheet("QLabel { background-color: #2A2A2A; padding: 10px; border-radius: 4px; }") + instructions.setWordWrap(True) + layout.addWidget(instructions) + + # Split view for cameras + splitter = QSplitter(Qt.Horizontal) + + # Left side - Available Cameras + left_widget = QWidget() + left_layout = QVBoxLayout(left_widget) + + # Local Cameras Group + local_group = QGroupBox("Local Cameras") + local_layout = QVBoxLayout() + self.local_list = QListWidget() + self.local_list.setSelectionMode(QListWidget.ExtendedSelection) + local_layout.addWidget(self.local_list) + local_group.setLayout(local_layout) + left_layout.addWidget(local_group) + + # Network Cameras Group + network_group = QGroupBox("Network Cameras") + network_layout = QVBoxLayout() + self.network_list = QListWidget() + self.network_list.setSelectionMode(QListWidget.ExtendedSelection) + network_layout.addWidget(self.network_list) + network_group.setLayout(network_layout) + left_layout.addWidget(network_group) + + # Camera management buttons + btn_layout = QHBoxLayout() + refresh_btn = QPushButton("Refresh") + refresh_btn.clicked.connect(self.refresh_cameras) + add_net_btn = QPushButton("Add Network Camera") + add_net_btn.clicked.connect(self.show_network_dialog) + + btn_layout.addWidget(refresh_btn) + btn_layout.addWidget(add_net_btn) + left_layout.addLayout(btn_layout) + + splitter.addWidget(left_widget) + + # Right side - Selected Cameras Preview + right_widget = QWidget() + right_layout = QVBoxLayout(right_widget) + + preview_label = QLabel("Selected Cameras Preview") + preview_label.setStyleSheet("font-weight: bold;") + right_layout.addWidget(preview_label) + + self.preview_list = QListWidget() + self.preview_list.setDragDropMode(QListWidget.InternalMove) + self.preview_list.setSelectionMode(QListWidget.ExtendedSelection) + right_layout.addWidget(self.preview_list) + + # Preview controls + preview_btn_layout = QHBoxLayout() + remove_btn = QPushButton("Remove Selected") + remove_btn.clicked.connect(self.remove_selected) + clear_btn = QPushButton("Clear All") + clear_btn.clicked.connect(self.clear_selection) + + preview_btn_layout.addWidget(remove_btn) + preview_btn_layout.addWidget(clear_btn) + right_layout.addLayout(preview_btn_layout) + + splitter.addWidget(right_widget) + layout.addWidget(splitter) + + # Bottom buttons + bottom_layout = QHBoxLayout() + select_all_btn = QPushButton("Select All") + select_all_btn.clicked.connect(self.select_all) + test_selected_btn = QPushButton("Test Selected") + test_selected_btn.clicked.connect(self.test_selected_cameras) + ok_btn = QPushButton("OK") + ok_btn.clicked.connect(self.accept) + cancel_btn = QPushButton("Cancel") + cancel_btn.clicked.connect(self.reject) + + bottom_layout.addWidget(select_all_btn) + bottom_layout.addWidget(test_selected_btn) + bottom_layout.addStretch() + bottom_layout.addWidget(ok_btn) + bottom_layout.addWidget(cancel_btn) + layout.addLayout(bottom_layout) + + # Connect signals + self.local_list.itemChanged.connect(self.update_preview) + self.network_list.itemChanged.connect(self.update_preview) + self.preview_list.model().rowsMoved.connect(self.update_camera_order) + + # Set splitter sizes + splitter.setSizes([400, 400]) + + # Initial camera refresh + self.refresh_cameras() + + # Restore last selection if available + if self.detector: + last_selected = self.detector.config.load_setting('last_selected_cameras', []) + if last_selected: + self.restore_selection(last_selected) + + def refresh_cameras(self): + """Refresh both local and network camera lists""" + self.local_list.clear() + self.network_list.clear() + + if not self.detector: + return + + # Add local cameras + for i in range(10): # Check first 10 indices + try: + cap = cv2.VideoCapture(i) + if cap.isOpened(): + item = QListWidgetItem(f"Camera {i}") + item.setData(Qt.UserRole, str(i)) + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + item.setCheckState(Qt.Unchecked) + self.local_list.addItem(item) + cap.release() + except Exception as e: + print(f"Error checking camera {i}: {e}") + + # Check device paths + if os.path.exists('/dev'): + for i in range(10): + device_path = f"/dev/video{i}" + if os.path.exists(device_path): + try: + cap = cv2.VideoCapture(device_path) + if cap.isOpened(): + item = QListWidgetItem(f"{os.path.basename(device_path)}") + item.setData(Qt.UserRole, device_path) + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + item.setCheckState(Qt.Unchecked) + self.local_list.addItem(item) + cap.release() + except Exception as e: + print(f"Error checking device {device_path}: {e}") + + # Add network cameras + for name, camera_info in self.detector.network_cameras.items(): + if isinstance(camera_info, dict): + url = camera_info.get('url', '') + has_auth = camera_info.get('username') is not None + display_text = f"{name} ({url})" + if has_auth: + display_text += " 🔒" + else: + display_text = f"{name} ({camera_info})" + + item = QListWidgetItem(display_text) + item.setData(Qt.UserRole, f"net:{name}") + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + item.setCheckState(Qt.Unchecked) + self.network_list.addItem(item) + + def restore_selection(self, last_selected): + """Restore previous camera selection""" + for cam_id in last_selected: + # Check local cameras + for i in range(self.local_list.count()): + item = self.local_list.item(i) + if item.data(Qt.UserRole) == cam_id: + item.setCheckState(Qt.Checked) + + # Check network cameras + for i in range(self.network_list.count()): + item = self.network_list.item(i) + if item.data(Qt.UserRole) == cam_id: + item.setCheckState(Qt.Checked) + + def update_preview(self): + """Update the preview list with currently selected cameras""" + self.preview_list.clear() + self.selected_cameras = [] + + # Get selected local cameras + for i in range(self.local_list.count()): + item = self.local_list.item(i) + if item.checkState() == Qt.Checked: + cam_id = item.data(Qt.UserRole) + preview_item = QListWidgetItem(f"📷 {item.text()}") + preview_item.setData(Qt.UserRole, cam_id) + self.preview_list.addItem(preview_item) + self.selected_cameras.append(cam_id) + + # Get selected network cameras + for i in range(self.network_list.count()): + item = self.network_list.item(i) + if item.checkState() == Qt.Checked: + cam_id = item.data(Qt.UserRole) + preview_item = QListWidgetItem(f"🌐 {item.text()}") + preview_item.setData(Qt.UserRole, cam_id) + self.preview_list.addItem(preview_item) + self.selected_cameras.append(cam_id) + + # Save the current selection to config + if self.detector: + self.detector.config.save_setting('last_selected_cameras', self.selected_cameras) + + def update_camera_order(self): + """Update the camera order based on preview list order""" + self.selected_cameras = [] + for i in range(self.preview_list.count()): + item = self.preview_list.item(i) + self.selected_cameras.append(item.data(Qt.UserRole)) + + # Save the new order + if self.detector: + self.detector.config.save_setting('last_selected_cameras', self.selected_cameras) + + def select_all(self): + """Select all cameras in both lists""" + for i in range(self.local_list.count()): + self.local_list.item(i).setCheckState(Qt.Checked) + for i in range(self.network_list.count()): + self.network_list.item(i).setCheckState(Qt.Checked) + + def clear_selection(self): + """Clear all selections""" + for i in range(self.local_list.count()): + self.local_list.item(i).setCheckState(Qt.Unchecked) + for i in range(self.network_list.count()): + self.network_list.item(i).setCheckState(Qt.Unchecked) + + def remove_selected(self): + """Remove selected items from the preview list""" + selected_items = self.preview_list.selectedItems() + for item in selected_items: + cam_id = item.data(Qt.UserRole) + # Uncheck corresponding items in source lists + for i in range(self.local_list.count()): + if self.local_list.item(i).data(Qt.UserRole) == cam_id: + self.local_list.item(i).setCheckState(Qt.Unchecked) + for i in range(self.network_list.count()): + if self.network_list.item(i).data(Qt.UserRole) == cam_id: + self.network_list.item(i).setCheckState(Qt.Unchecked) + + def test_selected_cameras(self): + """Test connection to selected cameras""" + selected_cameras = [] + for i in range(self.preview_list.count()): + selected_cameras.append(self.preview_list.item(i).data(Qt.UserRole)) + + if not selected_cameras: + QMessageBox.warning(self, "Warning", "No cameras selected to test!") + return + + # Create progress dialog + progress = QMessageBox(self) + progress.setIcon(QMessageBox.Information) + progress.setWindowTitle("Testing Cameras") + progress.setText("Testing camera connections...\nThis may take a few seconds.") + progress.setStandardButtons(QMessageBox.NoButton) + progress.show() + QApplication.processEvents() + + # Test each camera + results = [] + for cam_id in selected_cameras: + try: + if cam_id.startswith('net:'): + name = cam_id[4:] + camera_info = self.detector.network_cameras.get(name) + if isinstance(camera_info, dict): + url = camera_info['url'] + if 'username' in camera_info and 'password' in camera_info: + parsed = urllib.parse.urlparse(url) + netloc = f"{camera_info['username']}:{camera_info['password']}@{parsed.netloc}" + url = parsed._replace(netloc=netloc).geturl() + else: + url = camera_info + cap = cv2.VideoCapture(url) + else: + cap = cv2.VideoCapture(int(cam_id) if cam_id.isdigit() else cam_id) + + if cap.isOpened(): + ret, frame = cap.read() + if ret: + results.append(f"✅ {cam_id}: Connection successful") + else: + results.append(f"⚠️ {cam_id}: Connected but no frame received") + else: + results.append(f"❌ {cam_id}: Failed to connect") + cap.release() + except Exception as e: + results.append(f"❌ {cam_id}: Error - {str(e)}") + + progress.close() + + # Show results + result_dialog = QMessageBox(self) + result_dialog.setWindowTitle("Camera Test Results") + result_dialog.setText("\n".join(results)) + result_dialog.setIcon(QMessageBox.Information) + result_dialog.exec_() + + def show_network_dialog(self): + """Show the network camera configuration dialog""" + dialog = NetworkCameraDialog(self) + if dialog.exec_() == QDialog.Accepted: + self.refresh_cameras() + class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Multi-Camera YOLO Detection") self.setGeometry(100, 100, 1200, 800) + # Initialize configuration + self.config = Config() + # Set dark theme style self.setStyleSheet(""" QMainWindow, QWidget { @@ -523,57 +1269,89 @@ class MainWindow(QMainWindow): self.detector = MultiCamYOLODetector() self.camera_settings = {} + + # Load saved settings + self.load_saved_settings() + self.create_menus() self.init_ui() self.init_timer() - def show_menu(self): - about = AboutWindow(self) # Pass self as parent - about.exec_() # Use exec_() for modal dialog + def load_saved_settings(self): + """Load saved settings from configuration""" + # Load model directory + model_dir = self.config.load_setting('model_dir') + if model_dir and os.path.exists(model_dir): + self.detector.load_yolo_model(model_dir) + + # Load FPS setting + fps = self.config.load_setting('fps', 10) + self.detector.target_fps = int(fps) + self.detector.frame_interval = 1.0 / self.detector.target_fps + + # Load layout setting + self.current_layout = int(self.config.load_setting('layout', 0)) + + def save_settings(self): + """Save current settings to configuration""" + self.config.save_setting('model_dir', self.detector.model_dir) + self.config.save_setting('fps', self.fps_spin.value()) + self.config.save_setting('layout', self.layout_combo.currentIndex()) def create_menus(self): menubar = self.menuBar() - + # File Menu - file_menu = menubar.addMenu('File') + file_menu = menubar.addMenu('File') about_action = QAction("About", self) about_action.triggered.connect(self.show_menu) file_menu.addAction(about_action) - + # Model menu model_menu = menubar.addMenu('Model') - load_model_action = QAction('Load Model Directory...', self) load_model_action.triggered.connect(self.load_model_directory) model_menu.addAction(load_model_action) # Camera menu self.camera_menu = menubar.addMenu('Cameras') - self.refresh_cameras_action = QAction('Refresh Camera List', self) - self.refresh_cameras_action.triggered.connect(self.populate_camera_menu) - self.camera_menu.addAction(self.refresh_cameras_action) + + # Add Camera Selector action + select_cameras_action = QAction('Select Cameras...', self) + select_cameras_action.triggered.connect(self.show_camera_selector) + self.camera_menu.addAction(select_cameras_action) + + self.camera_menu.addSeparator() + + # Add Network Camera Settings action + network_camera_action = QAction('Network Camera Settings...', self) + network_camera_action.triggered.connect(self.show_network_camera_dialog) + self.camera_menu.addAction(network_camera_action) + self.camera_menu.addSeparator() self.camera_action_group = QActionGroup(self) self.camera_action_group.setExclusive(False) self.populate_camera_menu() - - + def populate_camera_menu(self): """Populate the camera menu with available cameras""" - # Clear existing camera actions (except refresh) - for action in self.camera_menu.actions()[2:]: + # Clear existing camera actions (except refresh and network camera settings) + for action in self.camera_menu.actions()[3:]: self.camera_menu.removeAction(action) available_cams = self.detector.scan_for_cameras() for cam_path in available_cams: # Display friendly name - if cam_path.startswith('/dev/'): - cam_name = os.path.basename(cam_path) + if cam_path.startswith('net:'): + name = cam_path[4:] # Use the camera name directly + display_name = f"{name}" + elif cam_path.startswith('/dev/'): + display_name = os.path.basename(cam_path) else: - cam_name = f"Camera {cam_path}" + display_name = f"Camera {cam_path}" - action = QAction(cam_name, self, checkable=True) + action = QAction(display_name, self, checkable=True) action.setData(cam_path) self.camera_action_group.addAction(action) self.camera_menu.addAction(action) @@ -582,19 +1360,21 @@ class MainWindow(QMainWindow): no_cam_action = QAction('No cameras found', self) no_cam_action.setEnabled(False) self.camera_menu.addAction(no_cam_action) - + def load_model_directory(self): """Open file dialog to select model directory""" + last_dir = self.config.load_setting('model_dir', QDir.homePath()) model_dir = QFileDialog.getExistingDirectory( self, "Select Model Directory", - QDir.homePath(), + last_dir, QFileDialog.ShowDirsOnly | QFileDialog.DontResolveSymlinks ) if model_dir: if self.detector.load_yolo_model(model_dir): self.model_label.setText(f"Model: {os.path.basename(model_dir)}") + self.config.save_setting('model_dir', model_dir) QMessageBox.information(self, "Success", "Model loaded successfully!") else: QMessageBox.critical(self, "Error", "Failed to load model from selected directory") @@ -662,6 +1442,11 @@ class MainWindow(QMainWindow): layout_layout.addWidget(self.layout_combo) settings_layout.addLayout(layout_layout) + # Add screenshot button to settings + screenshot_btn = QPushButton("Take Screenshot") + screenshot_btn.clicked.connect(self.take_screenshot) + settings_layout.addWidget(screenshot_btn) + settings_group.setLayout(settings_layout) sidebar_layout.addWidget(settings_group) @@ -712,6 +1497,12 @@ class MainWindow(QMainWindow): # Start with sidebar expanded self.sidebar.expand() + + # Set saved FPS value + self.fps_spin.setValue(self.detector.target_fps) + + # Set saved layout + self.layout_combo.setCurrentIndex(self.current_layout) def change_camera_layout(self, index): """Change the camera display layout""" @@ -818,7 +1609,7 @@ class MainWindow(QMainWindow): """Update the camera feeds in the display""" frames = self.detector.get_frames() - for i, frame in enumerate(frames): + for i, (cam_path, frame) in enumerate(zip(self.detector.cameras, frames)): if i >= len(self.camera_displays): break @@ -833,10 +1624,64 @@ class MainWindow(QMainWindow): display = self.camera_displays[i] display.setPixmap(pixmap.scaled(display.width(), display.height(), Qt.KeepAspectRatio, Qt.SmoothTransformation)) + + # Update camera name + cam_path = cam_path[0] if isinstance(cam_path, tuple) else cam_path + if isinstance(cam_path, str): + if cam_path.startswith('net:'): + # For network cameras, show the saved name + camera_name = cam_path[4:] # Get the name directly + display.set_camera_name(camera_name) + elif cam_path.startswith('/dev/'): + # For device paths, show the device name + display.set_camera_name(os.path.basename(cam_path)) + else: + # For numeric indices, show Camera N + display.set_camera_name(f"Camera {cam_path}") + + def take_screenshot(self): + """Take screenshot of active camera displays""" + active_displays = [d for d in self.camera_displays if d.isVisible() and d.pixmap()] + if not active_displays: + QMessageBox.warning(self, "Warning", "No active camera displays to capture!") + return + + for display in active_displays: + display.take_screenshot() + + def show_menu(self): + about = AboutWindow(self) # Pass self as parent + about.exec_() # Use exec_() for modal dialog + + def show_network_camera_dialog(self): + """Show the network camera management dialog""" + dialog = NetworkCameraDialog(self) + dialog.exec_() + # Refresh camera list after dialog closes + self.populate_camera_menu() + + def show_camera_selector(self): + """Show the camera selector dialog""" + dialog = CameraSelectorDialog(self) + if dialog.exec_() == QDialog.Accepted and dialog.selected_cameras: + # Stop current detection if running + was_running = False + if self.stop_btn.isEnabled(): + was_running = True + self.stop_detection() + + # Update selected cameras + for action in self.camera_action_group.actions(): + action.setChecked(action.data() in dialog.selected_cameras) + + # Restart detection if it was running + if was_running: + self.start_detection() def closeEvent(self, event): """Handle window close event""" self.stop_detection() + self.save_settings() super().closeEvent(event) if __name__ == "__main__": diff --git a/mucapy_config.json b/mucapy_config.json new file mode 100644 index 0000000..6317a28 --- /dev/null +++ b/mucapy_config.json @@ -0,0 +1,13 @@ +{ + "network_cameras": {}, + "last_model_dir": "", + "last_screenshot_dir": "/home/rattatwinko/Pictures/MuCaPy", + "last_layout": 0, + "last_fps": 10, + "last_selected_cameras": [], + "window_geometry": null, + "confidence_threshold": 0.35, + "model_dir": "/home/rattatwinko/Documents/mucapy/mucapy/mucapy/models", + "fps": 10, + "layout": 0 +} \ No newline at end of file