diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0698ebe --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +mucapy_config.json diff --git a/mucapy/main.py b/mucapy/main.py index 943bf37..b943622 100644 --- a/mucapy/main.py +++ b/mucapy/main.py @@ -1,14 +1,50 @@ import os import sys import cv2 +import json 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) +from PyQt5.QtCore import Qt, QTimer, QDir, QSize, QSettings, QDateTime +from PyQt5.QtGui import QImage, QPixmap, QIcon, QColor, QKeySequence + +class Config: + def __init__(self): + # Use JSON file in current working directory instead of QSettings + self.config_file = os.path.join(os.getcwd(), 'mucapy_config.json') + self.settings = {} + 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: + self.settings = json.load(f) + except Exception as e: + print(f"Error loading config: {e}") + self.settings = {} + + def save_config(self): + """Save configuration to JSON file""" + try: + 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 MultiCamYOLODetector: def __init__(self): @@ -22,6 +58,11 @@ class MultiCamYOLODetector: self.available_cameras = [] self.model_dir = "" self.cuda_available = self.check_cuda() + self.confidence_threshold = 0.35 + self.config = Config() + + # Load saved network cameras + self.network_cameras = self.config.load_setting('network_cameras', {}) def check_cuda(self): """Check if CUDA is available""" @@ -31,8 +72,19 @@ class MultiCamYOLODetector: except: return False + def add_network_camera(self, name, url): + """Add a network camera to the saved list""" + self.network_cameras[name] = url + self.config.save_setting('network_cameras', self.network_cameras) + + 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] + self.config.save_setting('network_cameras', self.network_cameras) + 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 @@ -45,7 +97,7 @@ class MultiCamYOLODetector: except: continue - # Also check direct device paths + # 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)] @@ -60,6 +112,10 @@ class MultiCamYOLODetector: except: continue + # Add saved network cameras + for name, url in self.network_cameras.items(): + self.available_cameras.append(f"net:{url}") + return self.available_cameras def load_yolo_model(self, model_dir): @@ -105,13 +161,22 @@ class MultiCamYOLODetector: return False def connect_cameras(self, camera_paths): - """Connect to multiple cameras with better error handling""" + """Connect to multiple cameras including network cameras""" self.disconnect_cameras() for cam_path in camera_paths: try: - if isinstance(cam_path, str) and cam_path.startswith('/dev/'): - cap = cv2.VideoCapture(cam_path, cv2.CAP_V4L2) + if isinstance(cam_path, str): + if cam_path.startswith('net:'): + # Handle network camera + url = cam_path[4:] # Remove 'net:' prefix + cap = cv2.VideoCapture(url) + elif cam_path.startswith('/dev/'): + # Handle device path + cap = cv2.VideoCapture(cam_path, cv2.CAP_V4L2) + else: + # Handle numeric index + cap = cv2.VideoCapture(int(cam_path), cv2.CAP_V4L2) else: cap = cv2.VideoCapture(int(cam_path), cv2.CAP_V4L2) @@ -124,7 +189,6 @@ class MultiCamYOLODetector: 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 @@ -172,7 +236,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 +247,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(): @@ -233,11 +297,49 @@ 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')) + + # 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 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: @@ -247,23 +349,35 @@ class CameraDisplay(QLabel): def show_fullscreen(self): """Show this camera in fullscreen mode""" - self.fullscreen_window = QMainWindow() + if not self.pixmap(): + return + + self.fullscreen_window = QMainWindow(self.window()) self.fullscreen_window.setWindowTitle(f"Camera {self.cam_id} - Fullscreen") - 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) + # Create fullscreen label label = QLabel() label.setAlignment(Qt.AlignCenter) - if self.pixmap(): - label.setPixmap(self.pixmap().scaled( - self.fullscreen_window.size(), - Qt.KeepAspectRatio, - Qt.SmoothTransformation - )) + label.setPixmap(self.pixmap().scaled( + QApplication.primaryScreen().size(), + Qt.KeepAspectRatio, + Qt.SmoothTransformation + )) + layout.addWidget(label) - self.fullscreen_window.setCentralWidget(label) - self.fullscreen_window.showFullScreen() + self.fullscreen_window.setCentralWidget(central_widget) + + # Add ESC shortcut + 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() @@ -271,6 +385,9 @@ class CameraDisplay(QLabel): lambda: self.update_fullscreen(label) ) self.fullscreen_timer.start(30) + + # Show fullscreen + self.fullscreen_window.showFullScreen() def update_fullscreen(self, label): """Update the fullscreen display""" @@ -416,12 +533,99 @@ 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(400, 300) + + 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" + ) + instructions.setWordWrap(True) + layout.addWidget(instructions) + + # Camera list + self.camera_list = QListWidget() + layout.addWidget(self.camera_list) + + # Input fields + form_layout = QFormLayout() + self.name_edit = QLineEdit() + self.url_edit = QLineEdit() + form_layout.addRow("Name:", self.name_edit) + form_layout.addRow("URL:", self.url_edit) + layout.addLayout(form_layout) + + # 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 load_cameras(self): + """Load saved network cameras into the list""" + if not self.detector: + return + + self.camera_list.clear() + for name, url in self.detector.network_cameras.items(): + self.camera_list.addItem(f"{name} ({url})") + + 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: + self.detector.add_network_camera(name, url) + self.load_cameras() + self.name_edit.clear() + self.url_edit.clear() + + 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 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,26 +727,46 @@ 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) @@ -552,13 +776,18 @@ class MainWindow(QMainWindow): 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 Network Camera 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) @@ -582,19 +811,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 +893,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 +948,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""" @@ -834,9 +1076,31 @@ class MainWindow(QMainWindow): display.setPixmap(pixmap.scaled(display.width(), display.height(), Qt.KeepAspectRatio, Qt.SmoothTransformation)) + 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 closeEvent(self, event): """Handle window close event""" self.stop_detection() + self.save_settings() super().closeEvent(event) if __name__ == "__main__":