working really nicely

This commit is contained in:
rattatwinko
2025-05-26 15:59:10 +02:00
parent 24ac4ccd51
commit 2ebaf16006
2 changed files with 298 additions and 33 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
mucapy_config.json

View File

@@ -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__":