Files
mucapy/mucapy/main.py
rattatwinko b80dd3f7d7 dirty shitt
2025-05-26 16:41:58 +02:00

1681 lines
64 KiB
Python

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,
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 JSON file in current working directory
self.config_file = os.path.join(os.getcwd(), 'mucapy_config.json')
self.settings = {
'network_cameras': {}, # Store network cameras configuration
'last_model_dir': '',
'last_screenshot_dir': os.path.expanduser('~/Pictures/MuCaPy'),
'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 = []
self.target_fps = 10
self.last_frame_time = 0
self.frame_interval = 1.0 / self.target_fps
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:
count = cv2.cuda.getCudaEnabledDeviceCount()
return count > 0
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 including network cameras"""
self.available_cameras = []
# Try numeric indices first (this works more reliably)
for i in range(max_to_check):
try:
cap = cv2.VideoCapture(i)
if cap.isOpened():
self.available_cameras.append(str(i))
cap.release()
except:
continue
# 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
# 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
def load_yolo_model(self, model_dir):
"""Load YOLO model from selected directory with better error handling"""
self.model_dir = model_dir
try:
# Find model files in the directory
weights = [f for f in os.listdir(model_dir) if f.endswith(('.weights', '.onnx'))]
configs = [f for f in os.listdir(model_dir) if f.endswith('.cfg')]
classes = [f for f in os.listdir(model_dir) if f.endswith('.names')]
if not weights or not configs or not classes:
return False
# Use the first found files
weights_path = os.path.join(model_dir, weights[0])
config_path = os.path.join(model_dir, configs[0])
classes_path = os.path.join(model_dir, classes[0])
self.net = cv2.dnn.readNet(weights_path, config_path)
# Set backend based on availability
if self.cuda_available:
try:
self.net.setPreferableBackend(cv2.dnn.DNN_BACKEND_CUDA)
self.net.setPreferableTarget(cv2.dnn.DNN_TARGET_CUDA)
except:
# Fall back to CPU if CUDA fails
self.net.setPreferableBackend(cv2.dnn.DNN_BACKEND_OPENCV)
self.net.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU)
else:
self.net.setPreferableBackend(cv2.dnn.DNN_BACKEND_OPENCV)
self.net.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU)
with open(classes_path, 'r') as f:
self.classes = f.read().strip().split('\n')
np.random.seed(42)
self.colors = np.random.randint(0, 255, size=(len(self.classes), 3), dtype='uint8')
return True
except Exception as e:
print(f"Error loading YOLO model: {e}")
return False
def connect_cameras(self, camera_paths):
"""Connect to multiple cameras using threads"""
self.disconnect_cameras()
success = True
for i, cam_path in enumerate(camera_paths):
try:
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 connecting to camera {cam_path}: {e}")
success = False
return success
def disconnect_cameras(self):
"""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"""
if self.net is None:
return frame
try:
blob = cv2.dnn.blobFromImage(frame, 1/255.0, (416, 416), swapRB=True, crop=False)
self.net.setInput(blob)
# Get output layer names compatible with different OpenCV versions
try:
layer_names = self.net.getLayerNames()
output_layers = [layer_names[i - 1] for i in self.net.getUnconnectedOutLayers()]
except:
output_layers = self.net.getUnconnectedOutLayersNames()
outputs = self.net.forward(output_layers)
boxes = []
confidences = []
class_ids = []
for output in outputs:
for detection in output:
scores = detection[5:]
class_id = np.argmax(scores)
confidence = scores[class_id]
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')
x = int(centerX - (width / 2))
y = int(centerY - (height / 2))
boxes.append([x, y, int(width), int(height)])
confidences.append(float(confidence))
class_ids.append(class_id)
indices = cv2.dnn.NMSBoxes(boxes, confidences, self.confidence_threshold, 0.4)
if len(indices) > 0:
for i in indices.flatten():
(x, y, w, h) = boxes[i]
color = [int(c) for c in self.colors[class_ids[i]]]
cv2.rectangle(frame, (x, y), (x + w, y + h), color, 2)
text = f"{self.classes[class_ids[i]]}: {confidences[i]:.2f}"
cv2.putText(frame, text, (x, y - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2)
except Exception as e:
print(f"Detection error: {e}")
return frame
class CameraDisplay(QLabel):
"""Custom QLabel for displaying camera feed with fullscreen support"""
def __init__(self, parent=None):
super().__init__(parent)
self.setAlignment(Qt.AlignCenter)
self.setText("No camera feed")
self.setStyleSheet("""
QLabel {
background-color: #1E1E1E;
color: #DDD;
border: 2px solid #444;
border-radius: 4px;
}
""")
self.setMinimumSize(320, 240)
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:
self.show_fullscreen()
elif self.fullscreen_window:
self.close_fullscreen()
def show_fullscreen(self):
"""Show this camera in fullscreen mode"""
if not self.pixmap():
return
self.fullscreen_window = QMainWindow(self.window())
self.fullscreen_window.setWindowTitle(f"Camera {self.cam_id} - Fullscreen")
# Create central widget
central_widget = QWidget()
layout = QVBoxLayout(central_widget)
# Create fullscreen label
label = QLabel()
label.setAlignment(Qt.AlignCenter)
label.setPixmap(self.pixmap().scaled(
QApplication.primaryScreen().size(),
Qt.KeepAspectRatio,
Qt.SmoothTransformation
))
layout.addWidget(label)
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()
self.fullscreen_timer.timeout.connect(
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"""
if self.pixmap():
label.setPixmap(self.pixmap().scaled(
label.size(),
Qt.KeepAspectRatio,
Qt.SmoothTransformation
))
def close_fullscreen(self):
"""Close the fullscreen window"""
if self.fullscreen_window:
if self.fullscreen_timer:
self.fullscreen_timer.stop()
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"""
def __init__(self, title, parent=None):
super().__init__(title, parent)
self.setFeatures(QDockWidget.DockWidgetClosable |
QDockWidget.DockWidgetMovable |
QDockWidget.DockWidgetFloatable)
self.toggle_button = QToolButton(self)
self.toggle_button.setIcon(QIcon.fromTheme("arrow-left"))
self.toggle_button.setIconSize(QSize(16, 16))
self.toggle_button.setStyleSheet("border: none;")
self.toggle_button.clicked.connect(self.toggle_collapse)
self.setTitleBarWidget(self.toggle_button)
self.collapsed = False
self.original_size = None
def toggle_collapse(self):
"""Toggle between collapsed and expanded states"""
if self.collapsed:
self.expand()
else:
self.collapse()
def collapse(self):
"""Collapse the dock widget"""
if not self.collapsed:
self.original_size = self.size()
self.setFixedWidth(40)
self.toggle_button.setIcon(QIcon.fromTheme("arrow-right"))
self.collapsed = True
def expand(self):
"""Expand the dock widget"""
if self.collapsed:
self.setMinimumWidth(250)
self.setMaximumWidth(16777215) # Qt default maximum
if self.original_size:
self.resize(self.original_size)
self.toggle_button.setIcon(QIcon.fromTheme("arrow-left"))
self.collapsed = False
class AboutWindow(QDialog):
def __init__(self, parent=None): # Add parent parameter with default None
super().__init__(parent) # Pass parent to QDialog
self.setWindowTitle("About Multi-Camera YOLO Detection")
self.setWindowIcon(QIcon.fromTheme("help-about"))
self.resize(400, 300)
# Make it modal and stay on top
self.setWindowModality(Qt.ApplicationModal)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint)
layout = QVBoxLayout()
layout.setAlignment(Qt.AlignCenter)
layout.setSpacing(20)
# Application icon/logo (placeholder)
icon_label = QLabel()
icon_label.setPixmap(QIcon.fromTheme("camera-web").pixmap(64, 64))
icon_label.setAlignment(Qt.AlignCenter)
layout.addWidget(icon_label)
# Application title
title_label = QLabel("MuCaPy - 1")
title_label.setStyleSheet("font-size: 18px; font-weight: bold;")
title_label.setAlignment(Qt.AlignCenter)
layout.addWidget(title_label)
# Version information
version_label = QLabel("Version 1.0")
version_label.setAlignment(Qt.AlignCenter)
layout.addWidget(version_label)
# Description
desc_label = QLabel(
"MuCaPy\n"
"Multiple Camera Python\n"
"Using CV2"
)
desc_label.setAlignment(Qt.AlignCenter)
desc_label.setWordWrap(True)
layout.addWidget(desc_label)
# Close Button
close_btn = QPushButton("Close")
close_btn.clicked.connect(self.accept)
close_btn.setFixedWidth(100)
layout.addWidget(close_btn, alignment=Qt.AlignCenter)
self.setStyleSheet("""
QDialog {
background-color: #2D2D2D;
color: #DDD;
}
QLabel {
color: #DDD;
}
QGroupBox {
border: 1px solid #555;
border-radius: 4px;
margin-top: 10px;
padding-top: 15px;
background-color: #252525;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 10px;
padding: 0 3px;
color: #DDD;
}
QPushButton {
background-color: #3A3A3A;
color: #DDD;
border: 1px solid #555;
border-radius: 4px;
padding: 5px;
min-width: 80px;
}
QPushButton:hover {
background-color: #4A4A4A;
}
""")
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 {
background-color: #2D2D2D;
color: #DDD;
}
QLabel {
color: #DDD;
}
QPushButton {
background-color: #3A3A3A;
color: #DDD;
border: 1px solid #555;
border-radius: 4px;
padding: 5px;
}
QPushButton:hover {
background-color: #4A4A4A;
}
QPushButton:pressed {
background-color: #2A2A2A;
}
QPushButton:disabled {
background-color: #2A2A2A;
color: #777;
}
QComboBox, QSpinBox {
background-color: #3A3A3A;
color: #DDD;
border: 1px solid #555;
border-radius: 4px;
padding: 3px;
}
QGroupBox {
border: 1px solid #555;
border-radius: 4px;
margin-top: 10px;
padding-top: 15px;
background-color: #252525;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 10px;
padding: 0 3px;
color: #DDD;
}
QMenuBar {
background-color: #252525;
color: #DDD;
}
QMenuBar::item {
background-color: transparent;
padding: 5px 10px;
}
QMenuBar::item:selected {
background-color: #3A3A3A;
}
QMenu {
background-color: #252525;
border: 1px solid #444;
color: #DDD;
}
QMenu::item:selected {
background-color: #3A3A3A;
}
QScrollArea {
border: none;
}
QDockWidget {
titlebar-close-icon: url(none);
titlebar-normal-icon: url(none);
}
QDockWidget::title {
background: #252525;
padding-left: 5px;
}
QToolButton {
background-color: transparent;
border: none;
}
""")
# Set palette for better dark mode support
palette = self.palette()
palette.setColor(palette.Window, QColor(45, 45, 45))
palette.setColor(palette.WindowText, QColor(221, 221, 221))
palette.setColor(palette.Base, QColor(35, 35, 35))
palette.setColor(palette.AlternateBase, QColor(45, 45, 45))
palette.setColor(palette.ToolTipBase, QColor(221, 221, 221))
palette.setColor(palette.ToolTipText, QColor(221, 221, 221))
palette.setColor(palette.Text, QColor(221, 221, 221))
palette.setColor(palette.Button, QColor(58, 58, 58))
palette.setColor(palette.ButtonText, QColor(221, 221, 221))
palette.setColor(palette.BrightText, Qt.red)
palette.setColor(palette.Link, QColor(42, 130, 218))
palette.setColor(palette.Highlight, QColor(42, 130, 218))
palette.setColor(palette.HighlightedText, Qt.black)
self.setPalette(palette)
self.detector = MultiCamYOLODetector()
self.camera_settings = {}
# Load saved settings
self.load_saved_settings()
self.create_menus()
self.init_ui()
self.init_timer()
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')
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')
# 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 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('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:
display_name = f"Camera {cam_path}"
action = QAction(display_name, self, checkable=True)
action.setData(cam_path)
self.camera_action_group.addAction(action)
self.camera_menu.addAction(action)
if not available_cams:
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",
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")
def init_ui(self):
"""Initialize the user interface with collapsible sidebar"""
main_widget = QWidget()
main_layout = QHBoxLayout()
# Create collapsible sidebar
self.sidebar = CollapsibleDock("Controls")
self.sidebar.setMinimumWidth(250)
# Sidebar content
sidebar_content = QWidget()
sidebar_layout = QVBoxLayout()
# Model section
model_group = QGroupBox("Model")
model_layout = QVBoxLayout()
self.model_label = QLabel("Model: Not loaded")
model_layout.addWidget(self.model_label)
load_model_btn = QPushButton("Load Model Directory...")
load_model_btn.clicked.connect(self.load_model_directory)
model_layout.addWidget(load_model_btn)
model_group.setLayout(model_layout)
sidebar_layout.addWidget(model_group)
# Camera section
camera_group = QGroupBox("Cameras")
camera_layout = QVBoxLayout()
self.cameras_label = QLabel("Selected Cameras: None")
camera_layout.addWidget(self.cameras_label)
refresh_cams_btn = QPushButton("Refresh Camera List")
refresh_cams_btn.clicked.connect(self.populate_camera_menu)
camera_layout.addWidget(refresh_cams_btn)
camera_group.setLayout(camera_layout)
sidebar_layout.addWidget(camera_group)
# Settings section
settings_group = QGroupBox("Settings")
settings_layout = QVBoxLayout()
# FPS control
fps_layout = QHBoxLayout()
fps_layout.addWidget(QLabel("FPS:"))
self.fps_spin = QSpinBox()
self.fps_spin.setRange(1, 60)
self.fps_spin.setValue(10)
fps_layout.addWidget(self.fps_spin)
settings_layout.addLayout(fps_layout)
# Layout selection
layout_layout = QHBoxLayout()
layout_layout.addWidget(QLabel("Layout:"))
self.layout_combo = QComboBox()
self.layout_combo.addItems(["1 Camera", "2 Cameras", "3 Cameras", "4 Cameras", "Grid Layout"])
self.layout_combo.currentIndexChanged.connect(self.change_camera_layout)
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)
# Control buttons
btn_layout = QHBoxLayout()
self.start_btn = QPushButton("Start")
self.start_btn.clicked.connect(self.start_detection)
btn_layout.addWidget(self.start_btn)
self.stop_btn = QPushButton("Stop")
self.stop_btn.clicked.connect(self.stop_detection)
self.stop_btn.setEnabled(False)
btn_layout.addWidget(self.stop_btn)
sidebar_layout.addLayout(btn_layout)
# Add stretch to push everything up
sidebar_layout.addStretch()
sidebar_content.setLayout(sidebar_layout)
# Add scroll area to sidebar
scroll = QScrollArea()
scroll.setWidget(sidebar_content)
scroll.setWidgetResizable(True)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.sidebar.setWidget(scroll)
self.addDockWidget(Qt.LeftDockWidgetArea, self.sidebar)
# Main display area
self.display_area = QWidget()
self.display_layout = QGridLayout()
self.camera_displays = []
# Initially create 4 camera displays
for i in range(4):
display = CameraDisplay()
display.set_cam_id(i+1)
self.camera_displays.append(display)
self.display_layout.addWidget(display, i//2, i%2)
self.display_area.setLayout(self.display_layout)
main_layout.addWidget(self.display_area)
main_widget.setLayout(main_layout)
self.setCentralWidget(main_widget)
# 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"""
# Clear the layout
for i in reversed(range(self.display_layout.count())):
self.display_layout.itemAt(i).widget().setParent(None)
num_cameras = index + 1 if index < 4 else 4
if index == 4: # Grid layout
rows = 2
cols = 2
for i in range(4):
self.display_layout.addWidget(self.camera_displays[i], i//cols, i%cols)
else:
if num_cameras == 1:
self.display_layout.addWidget(self.camera_displays[0], 0, 0, 1, 2)
elif num_cameras == 2:
self.display_layout.addWidget(self.camera_displays[0], 0, 0)
self.display_layout.addWidget(self.camera_displays[1], 0, 1)
elif num_cameras == 3:
self.display_layout.addWidget(self.camera_displays[0], 0, 0)
self.display_layout.addWidget(self.camera_displays[1], 0, 1)
self.display_layout.addWidget(self.camera_displays[2], 1, 0, 1, 2)
elif num_cameras == 4:
for i in range(4):
self.display_layout.addWidget(self.camera_displays[i], i//2, i%2)
# Hide unused displays
for i, display in enumerate(self.camera_displays):
display.setVisible(i < num_cameras)
def init_timer(self):
"""Initialize the timer for updating camera feeds"""
self.timer = QTimer()
self.timer.timeout.connect(self.update_feeds)
def start_detection(self):
"""Start the detection process"""
if not self.detector.model_dir:
QMessageBox.critical(self, "Error", "No model directory selected!")
return
# Get selected cameras
selected_cameras = []
for action in self.camera_action_group.actions():
if action.isChecked():
selected_cameras.append(action.data())
if not selected_cameras:
QMessageBox.critical(self, "Error", "No cameras selected!")
return
# Set FPS
self.detector.target_fps = self.fps_spin.value()
self.detector.frame_interval = 1.0 / self.detector.target_fps
# Connect to cameras
if not self.detector.connect_cameras(selected_cameras):
QMessageBox.critical(self, "Error", "Failed to connect to cameras!")
return
# Update UI
self.update_selection_labels()
self.start_btn.setEnabled(False)
self.stop_btn.setEnabled(True)
self.fps_spin.setEnabled(False)
# Start timer
self.timer.start(int(1000 / self.detector.target_fps))
def stop_detection(self):
"""Stop the detection process"""
self.timer.stop()
self.detector.disconnect_cameras()
# Update UI
self.start_btn.setEnabled(True)
self.stop_btn.setEnabled(False)
self.fps_spin.setEnabled(True)
# Clear displays
for display in self.camera_displays:
display.setText("No camera feed")
display.setStyleSheet("""
QLabel {
background-color: #1E1E1E;
color: #DDD;
border: 2px solid #444;
border-radius: 4px;
}
""")
def update_selection_labels(self):
"""Update the model and camera selection labels"""
# Update cameras label
selected_cams = []
for action in self.camera_action_group.actions():
if action.isChecked():
selected_cams.append(action.text())
self.cameras_label.setText(f"Selected Cameras: {', '.join(selected_cams) or 'None'}")
def update_feeds(self):
"""Update the camera feeds in the display"""
frames = self.detector.get_frames()
for i, (cam_path, frame) in enumerate(zip(self.detector.cameras, frames)):
if i >= len(self.camera_displays):
break
# Convert frame to QImage
rgb_image = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
h, w, ch = rgb_image.shape
bytes_per_line = ch * w
qt_image = QImage(rgb_image.data, w, h, bytes_per_line, QImage.Format_RGB888)
# Scale while maintaining aspect ratio
pixmap = QPixmap.fromImage(qt_image)
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__":
app = QApplication(sys.argv)
# Set application style to Fusion for better dark mode support
app.setStyle("Fusion")
window = MainWindow()
window.show()
sys.exit(app.exec_())