diff --git a/mucapy/main.py b/mucapy/main.py index b943622..cac7436 100644 --- a/mucapy/main.py +++ b/mucapy/main.py @@ -2,15 +2,18 @@ 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, - QShortcut, QListWidget, QFormLayout, QLineEdit) -from PyQt5.QtCore import Qt, QTimer, QDir, QSize, QSettings, QDateTime -from PyQt5.QtGui import QImage, QPixmap, QIcon, QColor, QKeySequence + QShortcut, QListWidget, QFormLayout, QLineEdit, + QCheckBox, QTabWidget, QListWidgetItem) +from PyQt5.QtCore import Qt, QTimer, QDir, QSize, QSettings, QDateTime, QRect +from PyQt5.QtGui import (QImage, QPixmap, QIcon, QColor, QKeySequence, QPainter, + QPen, QBrush) class Config: def __init__(self): @@ -72,9 +75,9 @@ class MultiCamYOLODetector: except: return False - def add_network_camera(self, name, url): + def add_network_camera(self, name, camera_info): """Add a network camera to the saved list""" - self.network_cameras[name] = url + self.network_cameras[name] = camera_info self.config.save_setting('network_cameras', self.network_cameras) def remove_network_camera(self, name): @@ -87,34 +90,37 @@ class MultiCamYOLODetector: """Check for available cameras including network cameras""" self.available_cameras = [] - # Check standard video devices + # Try numeric indices first (this works more reliably) 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 - # 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)] - - 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) + # Also check device paths as fallback + 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 + except: + continue # Add saved network cameras - for name, url in self.network_cameras.items(): - self.available_cameras.append(f"net:{url}") + 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 @@ -161,7 +167,7 @@ class MultiCamYOLODetector: return False def connect_cameras(self, camera_paths): - """Connect to multiple cameras including network cameras""" + """Connect to multiple cameras including network cameras with authentication""" self.disconnect_cameras() for cam_path in camera_paths: @@ -169,16 +175,30 @@ class MultiCamYOLODetector: if isinstance(cam_path, str): if cam_path.startswith('net:'): # Handle network camera - url = cam_path[4:] # Remove 'net:' prefix + camera_name = cam_path[4:] # Remove 'net:' prefix to get camera name + camera_info = self.network_cameras.get(camera_name) + + if isinstance(camera_info, dict): + url = camera_info['url'] + # Add authentication to URL if provided + if 'username' in camera_info and 'password' in camera_info: + # Parse URL and add authentication + parsed = urllib.parse.urlparse(url) + netloc = f"{camera_info['username']}:{camera_info['password']}@{parsed.netloc}" + url = parsed._replace(netloc=netloc).geturl() + else: + # Handle old format where camera_info was just the URL + url = camera_info + cap = cv2.VideoCapture(url) elif cam_path.startswith('/dev/'): # Handle device path - cap = cv2.VideoCapture(cam_path, cv2.CAP_V4L2) + cap = cv2.VideoCapture(cam_path) else: # Handle numeric index - cap = cv2.VideoCapture(int(cam_path), cv2.CAP_V4L2) + cap = cv2.VideoCapture(int(cam_path)) else: - cap = cv2.VideoCapture(int(cam_path), cv2.CAP_V4L2) + cap = cv2.VideoCapture(int(cam_path)) if not cap.isOpened(): print(f"Warning: Could not open camera {cam_path}") @@ -299,6 +319,7 @@ class CameraDisplay(QLabel): 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): @@ -308,6 +329,11 @@ class CameraDisplay(QLabel): """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(): @@ -406,6 +432,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""" @@ -539,7 +582,7 @@ class NetworkCameraDialog(QDialog): super().__init__(parent) self.setWindowTitle("Network Camera Settings") self.setModal(True) - self.resize(400, 300) + self.resize(500, 400) layout = QVBoxLayout(self) @@ -548,7 +591,8 @@ class NetworkCameraDialog(QDialog): "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" + "- For other IP cameras: Enter the full stream URL\n" + "- Enable authentication if the camera requires username/password" ) instructions.setWordWrap(True) layout.addWidget(instructions) @@ -559,12 +603,38 @@ class NetworkCameraDialog(QDialog): # 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") @@ -582,14 +652,32 @@ class NetworkCameraDialog(QDialog): 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, url in self.detector.network_cameras.items(): - self.camera_list.addItem(f"{name} ({url})") + 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""" @@ -601,10 +689,25 @@ class NetworkCameraDialog(QDialog): return if self.detector: - self.detector.add_network_camera(name, url) + 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""" @@ -617,6 +720,197 @@ class NetworkCameraDialog(QDialog): 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(600, 500) + + self.detector = parent.detector if parent else None + self.selected_cameras = [] + + # Main layout + layout = QVBoxLayout(self) + + # Create tab widget + tabs = QTabWidget() + + # Local Cameras Tab + local_tab = QWidget() + local_layout = QVBoxLayout(local_tab) + + # Local camera list + local_group = QGroupBox("Local Cameras") + local_list_layout = QVBoxLayout() + self.local_list = QListWidget() + self.local_list.setSelectionMode(QListWidget.MultiSelection) + local_list_layout.addWidget(self.local_list) + + # Refresh button for local cameras + refresh_btn = QPushButton("Refresh Local Cameras") + refresh_btn.clicked.connect(self.refresh_local_cameras) + local_list_layout.addWidget(refresh_btn) + + local_group.setLayout(local_list_layout) + local_layout.addWidget(local_group) + tabs.addTab(local_tab, "Local Cameras") + + # Network Cameras Tab + network_tab = QWidget() + network_layout = QVBoxLayout(network_tab) + + # Network camera list + network_group = QGroupBox("Network Cameras") + network_list_layout = QVBoxLayout() + self.network_list = QListWidget() + self.network_list.setSelectionMode(QListWidget.MultiSelection) + network_list_layout.addWidget(self.network_list) + + # Network camera controls + net_btn_layout = QHBoxLayout() + add_net_btn = QPushButton("Add Network Camera") + add_net_btn.clicked.connect(self.show_network_dialog) + remove_net_btn = QPushButton("Remove Selected") + remove_net_btn.clicked.connect(self.remove_network_camera) + net_btn_layout.addWidget(add_net_btn) + net_btn_layout.addWidget(remove_net_btn) + network_list_layout.addLayout(net_btn_layout) + + network_group.setLayout(network_list_layout) + network_layout.addWidget(network_group) + tabs.addTab(network_tab, "Network Cameras") + + layout.addWidget(tabs) + + # Selected cameras preview + preview_group = QGroupBox("Selected Cameras") + preview_layout = QVBoxLayout() + self.preview_list = QListWidget() + preview_layout.addWidget(self.preview_list) + preview_group.setLayout(preview_layout) + layout.addWidget(preview_group) + + # Buttons + btn_layout = QHBoxLayout() + select_all_btn = QPushButton("Select All") + select_all_btn.clicked.connect(self.select_all) + clear_btn = QPushButton("Clear Selection") + clear_btn.clicked.connect(self.clear_selection) + ok_btn = QPushButton("OK") + ok_btn.clicked.connect(self.accept) + cancel_btn = QPushButton("Cancel") + cancel_btn.clicked.connect(self.reject) + + btn_layout.addWidget(select_all_btn) + btn_layout.addWidget(clear_btn) + btn_layout.addStretch() + btn_layout.addWidget(ok_btn) + btn_layout.addWidget(cancel_btn) + layout.addLayout(btn_layout) + + # Connect selection change signals + self.local_list.itemSelectionChanged.connect(self.update_preview) + self.network_list.itemSelectionChanged.connect(self.update_preview) + + self.refresh_cameras() + + def refresh_cameras(self): + """Refresh both local and network camera lists""" + self.refresh_local_cameras() + self.refresh_network_cameras() + + def refresh_local_cameras(self): + """Refresh the local camera list""" + self.local_list.clear() + if self.detector: + # Get local cameras + for i in range(10): # Check first 10 indices + try: + cap = cv2.VideoCapture(i) + if cap.isOpened(): + self.local_list.addItem(QListWidgetItem(f"Camera {i}")) + cap.release() + except: + continue + + # 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(os.path.basename(device_path)) + item.setData(Qt.UserRole, device_path) + self.local_list.addItem(item) + cap.release() + except: + continue + + def refresh_network_cameras(self): + """Refresh the network camera list""" + self.network_list.clear() + if self.detector: + 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: + display_text = f"{name} ({camera_info})" + + item = QListWidgetItem(display_text) + item.setData(Qt.UserRole, f"net:{name}") + self.network_list.addItem(item) + + def show_network_dialog(self): + """Show the network camera configuration dialog""" + dialog = NetworkCameraDialog(self.parent()) + if dialog.exec_() == QDialog.Accepted: + self.refresh_network_cameras() + + def remove_network_camera(self): + """Remove selected network cameras""" + for item in self.network_list.selectedItems(): + cam_path = item.data(Qt.UserRole) + if cam_path.startswith('net:'): + name = cam_path[4:] + if self.detector: + self.detector.remove_network_camera(name) + self.refresh_network_cameras() + self.update_preview() + + def update_preview(self): + """Update the preview list with currently selected cameras""" + self.preview_list.clear() + self.selected_cameras = [] + + # Add selected local cameras + for item in self.local_list.selectedItems(): + cam_path = item.data(Qt.UserRole) if item.data(Qt.UserRole) else item.text().split()[-1] + self.selected_cameras.append(cam_path) + self.preview_list.addItem(f"✓ {item.text()}") + + # Add selected network cameras + for item in self.network_list.selectedItems(): + cam_path = item.data(Qt.UserRole) + self.selected_cameras.append(cam_path) + self.preview_list.addItem(f"✓ {item.text()}") + + def select_all(self): + """Select all cameras in both lists""" + self.local_list.selectAll() + self.network_list.selectAll() + + def clear_selection(self): + """Clear selection in both lists""" + self.local_list.clearSelection() + self.network_list.clearSelection() + class MainWindow(QMainWindow): def __init__(self): super().__init__() @@ -773,11 +1067,15 @@ class MainWindow(QMainWindow): # 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 Network Camera 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) @@ -790,19 +1088,22 @@ class MainWindow(QMainWindow): 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) @@ -1060,7 +1361,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 @@ -1075,6 +1376,20 @@ 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""" @@ -1097,6 +1412,24 @@ class MainWindow(QMainWindow): # 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()