Files
mucapy/mucapy/main.py

2217 lines
84 KiB
Python

import json
from PopoutWindow import PopoutWindow
from Config import Config
from CameraThread import CameraThread
from CameraScanThread import CameraScanThread
from AlertWorker import AlertWorker
from YoloClass import MultiCamYOLODetector
try:
import winreg
except ImportError:
pass
import os
import platform
import subprocess
import sys
import time
import urllib.parse
import ctypes
import shutil
import cv2
import numpy as np
import psutil # Add psutil import
import requests
try:
from PyQt5.QtCore import Qt, QTimer, QDir, QSize, QDateTime, QRect, QThread, pyqtSignal, QMutex, QObject, QEvent
from PyQt5.QtGui import (QImage, QPixmap, QIcon, QColor, QKeySequence, QPainter,
QPen, QBrush)
from PyQt5.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout,
QWidget, QLabel, QPushButton, QComboBox, QSpinBox,
QFileDialog, QMessageBox, QMenu, QAction, QActionGroup, QGridLayout, QGroupBox,
QDockWidget, QScrollArea, QToolButton, QDialog,
QShortcut, QListWidget, QFormLayout, QLineEdit,
QCheckBox, QTabWidget, QListWidgetItem, QSplitter,
QProgressBar, QSizePolicy) # Add QProgressBar and QSizePolicy
except ImportError as e:
print("Failed to import PyQt5 (QtCore DLL load error).\n"
"Common causes on Windows:\n"
"- Conflicting Qt packages installed (e.g., 'python-qt5' alongside 'PyQt5').\n"
"- Missing Microsoft Visual C++ Redistributable (x64).\n"
"- Incomplete or corrupted PyQt5 installation in the virtualenv.\n\n"
"How to fix:\n"
"1) Uninstall conflicting Qt packages: pip uninstall -y python-qt5 PySide2 PySide6\n"
"2) Reinstall PyQt5 wheels: pip install --upgrade --force-reinstall PyQt5==5.15.11\n"
"3) Install VC++ runtime: https://aka.ms/vs/17/release/vc_redist.x64.exe\n"
"4) Restart the app after reinstalling.\n\n"
f"Original error: {e}")
import todopackage.todo as todo # This shit will fail eventually | Or not IDK
# Audio alert dependencies
import wave
try:
import simpleaudio as sa
except Exception:
sa = None
# Force-disable simpleaudio to avoid potential native backend crashes; use OS-native players only
sa = None
def bytes_to_human(n: int) -> str:
"""Convert a byte value to a human-readable string using base 1024.
Examples: 1536 -> '1.5 MiB'
"""
try:
n = int(n)
except Exception:
return str(n)
symbols = ("B", "KiB", "MiB", "GiB", "TiB", "PiB")
if n < 1024:
return f"{n} B"
i = 0
val = float(n)
while val >= 1024.0 and i < len(symbols) - 1:
val /= 1024.0
i += 1
# Fewer decimals for larger numbers
if val >= 100:
return f"{val:.0f} {symbols[i]}"
if val >= 10:
return f"{val:.1f} {symbols[i]}"
return f"{val:.2f} {symbols[i]}"
class getpath:
@staticmethod
def resource_path(relative_path):
base_path = os.path.dirname(os.path.abspath(__file__))
return os.path.join(base_path, relative_path)
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.get_camera_display_style = getpath.resource_path("styling/camera_display.qss")
try:
with open(self.get_camera_display_style, "r") as cdst:
self.setStyleSheet(cdst.read())
except FileNotFoundError:
pass
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 a new window (enhanced popout)"""
if not self.pixmap():
return
# Create enhanced popout window
self.fullscreen_window = PopoutWindow(self, cam_id=self.cam_id, parent=self.window())
# Size and show
screen = QApplication.primaryScreen().availableGeometry()
self.fullscreen_window.resize(min(1280, int(screen.width() * 0.9)), min(720, int(screen.height() * 0.9)))
self.fullscreen_window.show()
# ESC shortcut already handled inside PopoutWindow
def update_fullscreen(self, label):
"""Kept for backward compatibility; PopoutWindow manages its own refresh."""
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:
self.fullscreen_window.close()
self.fullscreen_window = 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)
# Allow docking only on sides to avoid central area clipping
self.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea)
# Prefer keeping a minimum width but allow vertical expansion
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Expanding)
# Ensure the dock paints its own background (prevents visual bleed/clip)
self.setAttribute(Qt.WA_StyledBackground, True)
# Create a widget for the title bar that contains both toggle button and close button
title_widget = QWidget()
title_layout = QHBoxLayout(title_widget)
title_layout.setContentsMargins(0, 0, 0, 0)
title_layout.setSpacing(0)
# Ensure title bar doesn't force tiny width
title_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.toggle_button = QToolButton()
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)
title_layout.addWidget(self.toggle_button)
title_layout.addStretch()
self.setTitleBarWidget(title_widget)
self.collapsed = False
self.original_size = None
self.original_minimum_width = None
self.original_maximum_width = 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 (fully hide)."""
if not self.collapsed:
self.original_size = self.size()
self.original_minimum_width = self.minimumWidth()
self.original_maximum_width = self.maximumWidth()
# Fully hide the dock to avoid any clipping/overlap with camera panes
self.setVisible(False)
self.toggle_button.setIcon(QIcon.fromTheme("arrow-right"))
self.collapsed = True
def expand(self):
"""Expand (show) the dock widget"""
if self.collapsed:
# Restore previous constraints, falling back to sensible defaults
minw = self.original_minimum_width if self.original_minimum_width is not None else 250
self.setMinimumWidth(minw)
self.setMaximumWidth(self.original_maximum_width if self.original_maximum_width is not None else 16777215)
# Show and restore size
self.setVisible(True)
if self.original_size:
self.resize(self.original_size)
else:
self.resize(max(minw, 250), self.height())
# Make sure the dock is on top of central widgets
self.raise_()
self.toggle_button.setIcon(QIcon.fromTheme("arrow-left"))
self.collapsed = False
class AboutWindow(QDialog):
def __init__(self, parent=None):
global todo_style_path
super().__init__(parent)
self.setWindowTitle("About Multi-Camera YOLO Detection")
self.setWindowIcon(QIcon.fromTheme("help-about"))
self.resize(450, 420)
self.setWindowModality(Qt.ApplicationModal)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint)
layout = QVBoxLayout()
layout.setAlignment(Qt.AlignTop)
layout.setSpacing(20)
# App icon
icon_label = QLabel()
icon_label.setPixmap(QIcon.fromTheme("camera-web").pixmap(64, 64))
icon_label.setAlignment(Qt.AlignCenter)
layout.addWidget(icon_label)
# Title
title_label = QLabel("PySec")
title_label.setStyleSheet("font-size: 18px; font-weight: bold;")
title_label.setAlignment(Qt.AlignCenter)
layout.addWidget(title_label)
# Version label
version_label = QLabel("Version 1.0")
version_label.setAlignment(Qt.AlignCenter)
layout.addWidget(version_label)
# Get system info
info = self.get_system_info()
self.important_keys = ["Python", "OpenCV", "Memory", "CUDA"]
self.full_labels = {}
# === System Info Group ===
self.sysinfo_box = QGroupBox()
sysinfo_main_layout = QVBoxLayout()
sysinfo_main_layout.setContentsMargins(8, 8, 8, 8)
# Header layout: title + triangle button
header_layout = QHBoxLayout()
header_label = QLabel("System Information")
header_label.setStyleSheet("font-weight: bold;")
header_layout.addWidget(header_label)
header_layout.addStretch()
self.toggle_btn = QToolButton()
self.toggle_btn.setText("")
self.toggle_btn.setCheckable(True)
self.toggle_btn.setChecked(False)
toggle_btn_style = getpath.resource_path("styling/togglebtnabout.qss")
try:
with open(toggle_btn_style, "r") as tgbstyle:
self.toggle_btn.setStyleSheet(tgbstyle.read())
except FileNotFoundError:
pass
# Debug shit
#print("i did shit")
self.toggle_btn.toggled.connect(self.toggle_expand)
header_layout.addWidget(self.toggle_btn)
sysinfo_main_layout.addLayout(header_layout)
# Details layout
self.sysinfo_layout = QVBoxLayout()
self.sysinfo_layout.setSpacing(5)
for key, value in info.items():
if key == "MemoryGB":
continue
label = QLabel(f"{key}: {value}")
self.style_label(label, key, value)
self.sysinfo_layout.addWidget(label)
self.full_labels[key] = label
if key not in self.important_keys:
label.setVisible(False)
sysinfo_main_layout.addLayout(self.sysinfo_layout)
self.sysinfo_box.setLayout(sysinfo_main_layout)
layout.addWidget(self.sysinfo_box)
# Close button
close_btn = QPushButton("Close")
close_btn.clicked.connect(self.accept)
close_btn.setFixedWidth(100)
layout.addWidget(close_btn, alignment=Qt.AlignCenter)
# Set Styling for About Section
style_file = getpath.resource_path("styling/about.qss")
try:
with open(style_file, "r") as aboutstyle:
self.setStyleSheet(aboutstyle.read())
except FileNotFoundError:
pass
self.setLayout(layout)
# Todo Label Shit
self.todo_obj = todo
todo_text = self.get_todo_text()
todo_label = QLabel(f"<pre>{todo_text}</pre>")
todo_label.setWordWrap(True)
todo_label.setAlignment(Qt.AlignLeft)
# TODO: Fix this xD ; Fixing a TODO lol
try:
todo_style_path = getpath.resource_path("styling/todostyle.qss")
with open(todo_style_path, "r") as tdf:
todo_label.setStyleSheet(tdf.read())
# here we have our wonderfull fix
if True == True:
todo_label.setStyleSheet("color: #f7ef02; font-style: italic;")
else:
pass
except FileNotFoundError:
print(f"Missing a Style File! => {todo_style_path}")
pass
# Create the labels for the fucking trodo ass shit ?
self.todo_archive_object = todo
todo_archive_text = self.get_archive_text()
todo_archive_label = QLabel(f"<pre>{todo_archive_text}</pre>")
todo_archive_label.setWordWrap(True)
todo_archive_label.setAlignment(Qt.AlignLeft)
todo_archive_label.setStyleSheet("color: #02d1fa ;font-style: italic;")
self.info_obj = todo
info_text = self.get_info_text()
info_label = QLabel(f"<pre>{info_text}</pre>")
info_label.setWordWrap(True)
info_label.setAlignment(Qt.AlignCenter)
info_label.setStyleSheet("color: #2ecc71 ; font-style: italic;")
self.camobj = todo
cam_text = self.get_cam_text()
cam_label = QLabel(f"<pre>{cam_text}</pre>")
cam_label.setWordWrap(True)
cam_label.setAlignment(Qt.AlignCenter)
cam_label.setStyleSheet("color: #ffffff; font-style: italic;")
if True == True:
layout.addWidget(info_label)
layout.addWidget(todo_label)
layout.addWidget(todo_archive_label)
layout.addWidget(cam_label)
else:
pass
def toggle_expand(self, checked):
for key, label in self.full_labels.items():
if key not in self.important_keys:
label.setVisible(checked)
self.toggle_btn.setText("" if checked else "")
def style_label(self, label, key, value):
if key == "Python":
label.setStyleSheet("color: #7FDBFF;")
elif key == "OpenCV":
label.setStyleSheet("color: #FF851B;")
elif key == "CUDA":
label.setStyleSheet("color: green;" if value == "Yes" else "color: red;")
elif key == "NumPy":
label.setStyleSheet("color: #B10DC9;")
elif key == "Requests":
label.setStyleSheet("color: #0074D9;")
elif key == "Memory":
try:
ram = int(value.split()[0])
if ram < 8:
label.setStyleSheet("color: red;")
elif ram < 16:
label.setStyleSheet("color: yellow;")
elif ram < 32:
label.setStyleSheet("color: lightgreen;")
else:
label.setStyleSheet("color: #90EE90;")
except:
label.setStyleSheet("color: gray;")
elif key == "CPU Usage":
try:
usage = float(value.strip('%'))
if usage > 80:
label.setStyleSheet("color: red;")
elif usage > 50:
label.setStyleSheet("color: yellow;")
else:
label.setStyleSheet("color: lightgreen;")
except:
label.setStyleSheet("color: gray;")
elif key in ("CPU Cores", "Logical CPUs"):
label.setStyleSheet("color: lightgreen;")
elif key in ("CPU", "Architecture", "OS"):
label.setStyleSheet("color: lightgray;")
else:
label.setStyleSheet("color: #DDD;")
def get_system_info(self):
import platform
info = {}
info['Python'] = sys.version.split()[0]
info['OS'] = f"{platform.system()} {platform.release()}"
info['Architecture'] = platform.machine()
info['OpenCV'] = cv2.__version__
info['CUDA'] = "Yes" if cv2.cuda.getCudaEnabledDeviceCount() > 0 else "No"
info['NumPy'] = np.__version__
info['Requests'] = requests.__version__
# If we are on Linux we display the QTVAR
if platform.system() == "Linux":
info["XDG_ENVIROMENT_TYPE "] = initQT.getenv(self) # get the stupid env var of qt
else:
pass
mem = psutil.virtual_memory()
info['MemoryGB'] = mem.total // (1024 ** 3)
info['Memory'] = f"{info['MemoryGB']} GB RAM"
info['CPU Cores'] = psutil.cpu_count(logical=False)
info['Logical CPUs'] = psutil.cpu_count(logical=True)
info['CPU Usage'] = f"{psutil.cpu_percent()}%"
try:
if sys.platform == "win32":
info['CPU'] = platform.processor()
elif sys.platform == "linux":
info['CPU'] = subprocess.check_output("lscpu", shell=True).decode().split("\n")[0]
elif sys.platform == "darwin":
info['CPU'] = subprocess.check_output(["sysctl", "-n", "machdep.cpu.brand_string"]).decode().strip()
except Exception:
info['CPU'] = "Unknown"
return info
def get_todo_text(self):
try:
todo_text = self.todo_obj.todo.gettodo()
if isinstance(todo_text, str):
return todo_text.strip()
else:
return "Invalid TODO format."
except Exception as e:
return f"Error retrieving TODO: {e}"
def get_info_text(self):
try:
info_text = self.info_obj.todo.getinfo()
if isinstance(info_text, str):
return info_text.strip()
else:
return "Invalid"
except Exception as e:
return f"fuck you => {e}"
def get_archive_text(self):
try:
todo_archive_text = self.todo_archive_object.todo.getarchive()
if isinstance(todo_archive_text, str):
return todo_archive_text.strip()
else:
return "invalid format??"
except Exception as e:
return "?? ==> {e}"
def get_cam_text(self):
try:
cam_text = self.camobj.todo.getcams()
if isinstance(cam_text, str):
return cam_text.strip()
else:
return "invalid cam format"
except Exception as e:
return f"You are fuck you {e}"
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(todo.todo.get_instructions_CaSeDi_QLabel())
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
# Ensure URL has proper format for DroidCam
if ':4747' in url:
if not url.endswith('/video'):
url = url.rstrip('/') + '/video'
if not url.startswith('http://') and not url.startswith('https://'):
url = 'http://' + url
if self.detector:
print(f"Adding network camera: {name} with URL: {url}") # Debug print
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 CameraSelectorDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Camera Selector")
self.setModal(True)
self.resize(900, 650) # Increased size for better visibility
self.setSizeGripEnabled(True)
self.detector = parent.detector if parent else None
self.selected_cameras = []
# Main layout
layout = QVBoxLayout(self)
# Instructions with better formatting
instructions = QLabel(todo.todo.get_instructions_CaSeDi_QLabel())
print(todo.todo.get_instructions_CaSeDi_QLabel())
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)
splitter.setChildrenCollapsible(False)
splitter.setHandleWidth(6)
# Left side - Available Cameras
left_widget = QWidget()
left_layout = QVBoxLayout(left_widget)
left_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
# Local Cameras Group
local_group = QGroupBox("Local Cameras")
local_group.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
local_layout = QVBoxLayout()
self.local_list = QListWidget()
self.local_list.setSelectionMode(QListWidget.ExtendedSelection)
self.local_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
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_group.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
network_layout = QVBoxLayout()
self.network_list = QListWidget()
self.network_list.setSelectionMode(QListWidget.ExtendedSelection)
self.network_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
network_layout.addWidget(self.network_list)
network_group.setLayout(network_layout)
left_layout.addWidget(network_group)
# Camera management buttons
btn_layout = QHBoxLayout()
self.refresh_btn = QPushButton("Refresh")
self.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(self.refresh_btn)
btn_layout.addWidget(add_net_btn)
left_layout.addLayout(btn_layout)
# Make lists expand and buttons stay minimal in left pane
left_layout.setStretch(0, 1)
left_layout.setStretch(1, 1)
left_layout.setStretch(2, 0)
splitter.addWidget(left_widget)
splitter.setStretchFactor(0, 1)
# Right side - Selected Cameras Preview
right_widget = QWidget()
right_layout = QVBoxLayout(right_widget)
right_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
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)
self.preview_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
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)
# Make preview list expand and buttons stay minimal in right pane
right_layout.setStretch(0, 0)
right_layout.setStretch(1, 1)
right_layout.setStretch(2, 0)
splitter.addWidget(right_widget)
splitter.setStretchFactor(1, 1)
layout.addWidget(splitter)
# Bottom buttons
bottom_layout = QHBoxLayout()
select_all_btn = QPushButton("Select All")
select_all_btn.clicked.connect(self.select_all)
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.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([450, 450])
# 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 asynchronously"""
self.local_list.clear()
self.network_list.clear()
if not self.detector:
return
# Show placeholders and disable refresh while scanning
self.refresh_btn.setEnabled(False)
scanning_item_local = QListWidgetItem("Scanning for cameras…")
scanning_item_local.setFlags(Qt.NoItemFlags)
self.local_list.addItem(scanning_item_local)
scanning_item_net = QListWidgetItem("Loading network cameras…")
scanning_item_net.setFlags(Qt.NoItemFlags)
self.network_list.addItem(scanning_item_net)
# Start background scan
started = self.detector.start_camera_scan(10)
if not started:
# If a scan is already running, we'll just wait for its signal
pass
# Connect once to update lists when scan completes
try:
self.detector.cameras_scanned.disconnect(self._on_scan_finished_dialog)
except Exception:
pass
self.detector.cameras_scanned.connect(self._on_scan_finished_dialog)
def _on_scan_finished_dialog(self, cams, names):
# Re-enable refresh
self.refresh_btn.setEnabled(True)
# Rebuild lists
self.local_list.clear()
self.network_list.clear()
# Local cameras
for cam_path in cams:
if cam_path.startswith('net:'):
continue
if cam_path.startswith('/dev/'):
display = os.path.basename(cam_path)
else:
# Numeric index
pretty = names.get(cam_path)
display = f"{pretty} (#{cam_path})" if pretty else f"Camera {cam_path}"
item = QListWidgetItem(display)
item.setData(Qt.UserRole, cam_path)
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
item.setCheckState(Qt.Unchecked)
self.local_list.addItem(item)
# 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"Local: {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"Network: {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)
# Camera connection tests removed for performance reasons per user request.
def test_selected_cameras(self):
"""Deprecated: Camera tests are disabled to improve performance."""
QMessageBox.information(self, "Camera Tests Disabled",
"Camera connectivity tests have been removed to speed up the application.")
return
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("PySec")
self.setGeometry(100, 100, 1200, 800)
# Initialize configuration
self.config = Config()
# Initialize default values
self.current_layout = 0 # Default to single camera layout
self.detector = MultiCamYOLODetector(self) # Pass self as parent
self.camera_settings = {}
self.detection_enabled = True # Add detection toggle flag
# Alert system state
self.alert_enabled = bool(self.config.load_setting('alert_enabled', True))
self._alert_playing = False
self._alert_cooldown = False
self._cooldown_timer = QTimer(self)
self._cooldown_timer.setSingleShot(True)
self._cooldown_timer.timeout.connect(self._on_cooldown_finished)
self._alert_worker = None
# Load saved settings first
self.load_saved_settings()
self.setWindowIcon(QIcon(getpath.resource_path("styling/logo.png"))) # Convert from SVG beforehand
# Initialize hardware monitor timer
self.hw_timer = QTimer()
self.hw_timer.timeout.connect(self.update_hardware_stats)
self.hw_timer.start(1000) # Update every second
# Set dark theme style
style_file = getpath.resource_path("styling/mainwindow.qss")
try:
with open(style_file, "r") as mainstyle:
self.setStyleSheet(mainstyle.read())
except FileNotFoundError:
pass
# 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)
# Initialize UI elements
self.init_ui()
# Create menus
self.create_menus()
# Initialize timer
self.init_timer()
# Apply saved settings to UI
self.apply_saved_settings()
# For the CPU Styling we have another one so we set this here!
self.sepstyleing = False
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 apply_saved_settings(self):
"""Apply loaded settings to UI elements"""
if hasattr(self, 'fps_spin'):
self.fps_spin.setValue(self.detector.target_fps)
if hasattr(self, 'layout_combo'):
self.layout_combo.setCurrentIndex(self.current_layout)
if hasattr(self, 'model_label') and self.detector.model_dir:
self.model_label.setText(f"Model: {os.path.basename(self.detector.model_dir)}")
# Ensure alert UI reflects backend availability and state
try:
self._update_alert_ui()
except Exception:
pass
def create_menus(self):
menubar = self.menuBar()
# File Menu
file_menu = menubar.addMenu('File')
# Save Settings action
save_settings_action = QAction('Save Settings...', self)
save_settings_action.setShortcut('Ctrl+S')
save_settings_action.setStatusTip('Save current settings to a file')
save_settings_action.triggered.connect(self.save_settings_to_file)
file_menu.addAction(save_settings_action)
# Load Settings action
load_settings_action = QAction('Load Settings...', self)
load_settings_action.setShortcut('Ctrl+O')
load_settings_action.setStatusTip('Load settings from a file')
load_settings_action.triggered.connect(self.load_settings_from_file)
file_menu.addAction(load_settings_action)
file_menu.addSeparator()
# Export Screenshots Directory action
export_screenshots_action = QAction('Export Screenshots Directory...', self)
export_screenshots_action.setStatusTip('Open the screenshots directory')
export_screenshots_action.triggered.connect(self.open_screenshots_directory)
file_menu.addAction(export_screenshots_action)
file_menu.addSeparator()
about_action = QAction("About", self)
about_action.triggered.connect(self.show_menu)
file_menu.addAction(about_action)
file_menu.addSeparator()
# Exit action
exit_action = QAction('Exit', self)
exit_action.setShortcut('Ctrl+Q')
exit_action.setStatusTip('Exit application')
exit_action.triggered.connect(self.close)
file_menu.addAction(exit_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)
# View menu
view_menu = menubar.addMenu('View')
self.toggle_sidebar_action = QAction('Show Sidebar', self)
self.toggle_sidebar_action.setCheckable(True)
self.toggle_sidebar_action.setChecked(True)
self.toggle_sidebar_action.setShortcut('Ctrl+B')
self.toggle_sidebar_action.triggered.connect(self.toggle_sidebar_visibility)
view_menu.addAction(self.toggle_sidebar_action)
# Add toggle detection action
self.toggle_detection_action = QAction('Enable Detection', self)
self.toggle_detection_action.setCheckable(True)
self.toggle_detection_action.setChecked(True)
self.toggle_detection_action.setShortcut('Ctrl+D')
self.toggle_detection_action.triggered.connect(self.toggle_detection)
view_menu.addAction(self.toggle_detection_action)
# Alert toggle moved to View menu
self.toggle_alert_action = QAction('Enable Alert', self)
self.toggle_alert_action.setCheckable(True)
self.toggle_alert_action.setChecked(bool(self.alert_enabled))
self.toggle_alert_action.setStatusTip('Play an audible alert when a person is detected')
self.toggle_alert_action.triggered.connect(self.set_alert_enabled)
view_menu.addAction(self.toggle_alert_action)
# Camera menu
self.camera_menu = menubar.addMenu('Cameras')
# Add Camera Selector action
select_cameras_action = QAction('Select Cameras...', self)
select_cameras_action.setIcon(QIcon.fromTheme('camera-web'))
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.setIcon(QIcon.fromTheme('network-wireless'))
network_camera_action.triggered.connect(self.show_network_camera_dialog)
self.camera_menu.addAction(network_camera_action)
self.camera_menu.addSeparator()
# Create camera groups
self.local_camera_menu = QMenu('Local Cameras', self)
self.network_camera_menu = QMenu('Network Cameras', self)
self.camera_menu.addMenu(self.local_camera_menu)
self.camera_menu.addMenu(self.network_camera_menu)
# Create action groups for each camera type
self.local_camera_group = QActionGroup(self)
self.local_camera_group.setExclusive(False)
self.network_camera_group = QActionGroup(self)
self.network_camera_group.setExclusive(False)
# Initial population
self.populate_camera_menu()
# Sync sidebar toggle label/state with current visibility
try:
self._on_sidebar_visibility_changed(self.sidebar.isVisible())
except Exception:
pass
def populate_camera_menu(self):
"""Populate the camera menu with available cameras asynchronously"""
# Clear existing camera actions
self.local_camera_menu.clear()
self.network_camera_menu.clear()
# Add refresh action to both menus
refresh_action = QAction('Refresh List', self)
refresh_action.triggered.connect(self.populate_camera_menu)
self.local_camera_menu.addAction(refresh_action)
self.local_camera_menu.addSeparator()
# Show scanning placeholders
scanning_local = QAction('Scanning...', self)
scanning_local.setEnabled(False)
self.local_camera_menu.addAction(scanning_local)
scanning_net = QAction('Loading network cameras...', self)
scanning_net.setEnabled(False)
self.network_camera_menu.addAction(scanning_net)
# Start background scan
started = self.detector.start_camera_scan(10)
# Connect handler to build menus on completion
try:
self.detector.cameras_scanned.disconnect(self._on_cameras_scanned_menu)
except Exception:
pass
self.detector.cameras_scanned.connect(self._on_cameras_scanned_menu)
def _on_cameras_scanned_menu(self, available_cams, names):
# Rebuild menus with results
self.local_camera_menu.clear()
self.network_camera_menu.clear()
refresh_action = QAction('Refresh List', self)
refresh_action.triggered.connect(self.populate_camera_menu)
self.local_camera_menu.addAction(refresh_action)
self.local_camera_menu.addSeparator()
local_cams_found = False
network_cams_found = False
for cam_path in available_cams:
if cam_path.startswith('net:'):
# Network camera
name = cam_path[4:]
action = QAction(name, self)
action.setCheckable(True)
action.setData(cam_path)
self.network_camera_group.addAction(action)
self.network_camera_menu.addAction(action)
network_cams_found = True
else:
# Local camera
if cam_path.startswith('/dev/'):
display_name = os.path.basename(cam_path)
else:
pretty = names.get(cam_path)
display_name = f"{pretty} (#{cam_path})" if pretty else f"Camera {cam_path}"
action = QAction(display_name, self)
action.setCheckable(True)
action.setData(cam_path)
self.local_camera_group.addAction(action)
self.local_camera_menu.addAction(action)
local_cams_found = True
# Add placeholder text if no cameras found
if not local_cams_found:
no_local = QAction('No local cameras found', self)
no_local.setEnabled(False)
self.local_camera_menu.addAction(no_local)
if not network_cams_found:
no_net = QAction('No network cameras found', self)
no_net.setEnabled(False)
self.network_camera_menu.addAction(no_net)
# Update the camera label
self.update_selection_labels()
def update_selection_labels(self):
"""Update the model and camera selection labels"""
selected_cams = []
# Check local cameras
for action in self.local_camera_group.actions():
if action.isChecked():
selected_cams.append(action.text())
# Check network cameras
for action in self.network_camera_group.actions():
if action.isChecked():
selected_cams.append(action.text())
if selected_cams:
self.cameras_label.setText(f"Selected Cameras: {', '.join(selected_cams)}")
else:
self.cameras_label.setText("Selected Cameras: None")
def start_detection(self):
"""Start the detection process (camera feed can run without a model)"""
# Get selected cameras
selected_cameras = []
# Get local cameras
for action in self.local_camera_group.actions():
if action.isChecked():
selected_cameras.append(action.data())
# Get network cameras
for action in self.network_camera_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 show_camera_selector(self):
"""Show the advanced CameraSelectorDialog (async scanning)"""
dialog = CameraSelectorDialog(self)
if dialog.exec_() == QDialog.Accepted:
# Apply the selection saved by the dialog to the menu actions
selected = self.detector.config.load_setting('last_selected_cameras', []) or []
# Uncheck all first
for action in self.local_camera_group.actions():
action.setChecked(False)
for action in self.network_camera_group.actions():
action.setChecked(False)
# Re-apply
for action in self.local_camera_group.actions():
if action.data() in selected:
action.setChecked(True)
for action in self.network_camera_group.actions():
if action.data() in selected:
action.setChecked(True)
self.update_selection_labels()
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")
# Constrain sidebar width to prevent overexpansion from long labels/content
self.sidebar.setMinimumWidth(250)
self.sidebar.setMaximumWidth(400)
# Keep View menu toggle in sync with actual visibility
try:
self.sidebar.visibilityChanged.disconnect()
except Exception:
pass
self.sidebar.visibilityChanged.connect(self._on_sidebar_visibility_changed)
# 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")
self.cameras_label.setWordWrap(True)
self.cameras_label.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum)
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)
# Button enablement determined dynamically based on backend availability
# 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)
# Ensure scroll area doesn't request excessive width
scroll.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
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)
# Hardware Monitor section
hw_monitor_group = QGroupBox("Hardware Monitor")
hw_monitor_layout = QVBoxLayout()
# CPU Usage
cpu_layout = QHBoxLayout()
cpu_layout.addWidget(QLabel("CPU Usage:"))
self.cpu_progress = QProgressBar()
self.cpu_progress.setRange(0, 100)
self.cpu_progress.setTextVisible(True)
self.cpu_progress.setFormat("%p%")
# Set Styling from cpu progress QSS file
style_file = getpath.resource_path("styling/cpu_progress.qss")
try:
with open(style_file, "r") as cpu_progress_style:
self.cpu_progress.setStyleSheet(cpu_progress_style.read())
except FileNotFoundError:
pass
cpu_layout.addWidget(self.cpu_progress)
hw_monitor_layout.addLayout(cpu_layout)
# Python Memory Usage
mem_layout = QHBoxLayout()
mem_layout.addWidget(QLabel("RAM-Usage:"))
self.mem_progress = QProgressBar()
self.mem_progress.setRange(0, 100)
self.mem_progress.setTextVisible(True)
self.mem_progress.setFormat("%p%")
try:
with open(style_file, "r") as mem_style:
self.mem_progress.setStyleSheet(mem_style.read())
except FileNotFoundError:
pass
mem_layout.addWidget(self.mem_progress)
hw_monitor_layout.addLayout(mem_layout)
# Per-core CPU Usage
cores_layout = QGridLayout()
self.core_bars = []
num_cores = psutil.cpu_count()
for i in range(num_cores):
core_label = QLabel(f"Core {i}:")
core_bar = QProgressBar()
core_bar.setRange(0, 100)
core_bar.setTextVisible(True)
core_bar.setFormat("%p%")
core_file = getpath.resource_path("styling/core_bar.qss")
try:
with open(core_file, "r") as core_bar_styling:
core_bar.setStyleSheet(core_bar_styling.read())
except FileNotFoundError:
pass
cores_layout.addWidget(core_label, i, 0)
cores_layout.addWidget(core_bar, i, 1)
self.core_bars.append(core_bar)
hw_monitor_layout.addLayout(cores_layout)
hw_monitor_group.setLayout(hw_monitor_layout)
sidebar_layout.addWidget(hw_monitor_group)
main_widget.setLayout(main_layout)
self.setCentralWidget(main_widget)
# Start with sidebar expanded
self.sidebar.expand()
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 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")
cleardisplaypath = getpath.resource_path("styling/cleardisplay.qss")
try:
with open(cleardisplaypath, "r") as cdstyle:
display.setStyleSheet(cdstyle.read())
except FileNotFoundError:
pass
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 save_settings_to_file(self):
"""Save current settings to a JSON file"""
file_path, _ = QFileDialog.getSaveFileName(
self,
"Save Settings",
os.path.expanduser("~"),
"JSON Files (*.json)"
)
if file_path:
try:
settings = {
'model_dir': self.detector.model_dir,
'fps': self.fps_spin.value(),
'layout': self.layout_combo.currentIndex(),
'network_cameras': self.detector.network_cameras,
'confidence_threshold': self.detector.confidence_threshold
}
with open(file_path, 'w') as f:
json.dump(settings, f, indent=4)
QMessageBox.information(self, "Success", "Settings saved successfully!")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to save settings: {str(e)}")
def load_settings_from_file(self):
"""Load settings from a JSON file"""
file_path, _ = QFileDialog.getOpenFileName(
self,
"Load Settings",
os.path.expanduser("~"),
"JSON Files (*.json)"
)
if file_path:
try:
with open(file_path, 'r') as f:
settings = json.load(f)
# Apply loaded settings
if 'model_dir' in settings and os.path.exists(settings['model_dir']):
self.detector.load_yolo_model(settings['model_dir'])
self.model_label.setText(f"Model: {os.path.basename(settings['model_dir'])}")
if 'fps' in settings:
self.fps_spin.setValue(settings['fps'])
if 'layout' in settings:
self.layout_combo.setCurrentIndex(settings['layout'])
if 'network_cameras' in settings:
self.detector.network_cameras = settings['network_cameras']
self.populate_camera_menu()
if 'confidence_threshold' in settings:
self.detector.confidence_threshold = settings['confidence_threshold']
QMessageBox.information(self, "Success", "Settings loaded successfully!")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to load settings: {str(e)}")
def open_screenshots_directory(self):
"""Open the screenshots directory in the system's file explorer"""
screenshot_dir = self.config.load_setting('screenshot_dir', os.path.expanduser('~/Pictures/MuCaPy'))
if not os.path.exists(screenshot_dir):
os.makedirs(screenshot_dir, exist_ok=True)
# Open directory using the appropriate command for the OS
try:
if sys.platform.startswith('win'):
os.startfile(screenshot_dir)
elif sys.platform.startswith('darwin'): # macOS
subprocess.run(['open', screenshot_dir])
else: # Linux and other Unix-like
subprocess.run(['xdg-open', screenshot_dir])
except Exception as e:
QMessageBox.warning(self, "Warning", f"Could not open directory: {str(e)}")
def toggle_sidebar_visibility(self):
"""Toggle the visibility of the sidebar, fully hiding when minimized."""
# Determine target state from action or current visibility
target_show = self.toggle_sidebar_action.isChecked()
if target_show:
self.sidebar.expand()
# Ensure z-order so it won't clip behind camera panes
self.sidebar.raise_()
self.toggle_sidebar_action.setText('Hide Sidebar')
self.toggle_sidebar_action.setChecked(True)
else:
self.sidebar.collapse()
self.toggle_sidebar_action.setText('Show Sidebar')
self.toggle_sidebar_action.setChecked(False)
def _on_sidebar_visibility_changed(self, visible):
"""Keep menu action in sync with actual sidebar visibility."""
if hasattr(self, 'toggle_sidebar_action'):
self.toggle_sidebar_action.setChecked(visible)
self.toggle_sidebar_action.setText('Hide Sidebar' if visible else 'Show Sidebar')
# If becoming visible, bring to front to avoid any overlap issues
if visible:
try:
self.sidebar.raise_()
except Exception:
pass
def update_hardware_stats(self):
"""Update hardware statistics"""
# Update overall CPU usage
cpu_percent = psutil.cpu_percent()
self.cpu_progress.setValue(int(cpu_percent))
# Update per-core CPU usage
per_core = psutil.cpu_percent(percpu=True)
for i, usage in enumerate(per_core):
if i < len(self.core_bars):
self.core_bars[i].setValue(int(usage))
# Update Python process memory usage and show relative to available system RAM
try:
proc = psutil.Process(os.getpid())
rss = proc.memory_info().rss # bytes used by current Python process (Resident Set Size)
vm = psutil.virtual_memory()
available = vm.available # bytes available to processes without swapping
total_ram = vm.total
# Calculate percent of available memory currently occupied by this process
mem_percent = int(min((rss / available) * 500, 100)) if available else 0
if hasattr(self, 'mem_progress'):
# Update bar value and dynamic text
self.mem_progress.setValue(mem_percent)
rss_h = bytes_to_human(rss)
avail_h = bytes_to_human(available)
self.mem_progress.setFormat(f"{rss_h}")
self.mem_progress.setToolTip(
f"Python Resident Set Size: {rss_h}\nAvailable: {avail_h}\nTotal RAM: {bytes_to_human(total_ram)}")
except Exception:
pass
# Set color based on usage
bars_to_update = [self.cpu_progress] + self.core_bars
if hasattr(self, 'mem_progress'):
bars_to_update = [self.mem_progress] + bars_to_update # include memory bar too
for bar in bars_to_update:
value = bar.value()
if value < 60:
# Here we load the Style File if the CPU Load is under 60%
if self.sepstyleing == False:
u60 = getpath.resource_path("styling/bar/u60.qss")
try:
with open(u60, "r") as u60_style:
bar.setStyleSheet(u60_style.read())
except FileNotFoundError:
print("Styling for CPU U60 not found!")
pass
else:
u60seperate = getpath.resource_path("styling/bar/seperate/u60.qss")
try:
with open(u60seperate, "r") as u60_seperate_styling:
bar.setStyleSheet(u60_seperate_styling.read())
except FileNotFoundError:
print("No Seperate Styling! Generate one!")
pass
elif value < 85:
# Here we load the Style File if the CPU Load is over 85%
if self.sepstyleing == False:
u85 = getpath.resource_path("styling/bar/a85.qss")
try:
with open(u85, "r") as a85_styling:
bar.setStyleSheet(a85_styling.read())
except FileNotFoundError:
print("Styling for CPU u85 not found")
pass
else:
u85sep = getpath.resource_path("styling/bar/seperate/a85.qss")
try:
with open(u85sep, "r") as u85style_sep:
bar.setStyleSheet(u85style_sep.read())
except FileNotFoundError:
print("No Seperate File Found for U85")
pass
else:
# Here we load the Style File if the CPU Load is over 85 or 100 or smth idk
if self.sepstyleing == False:
else_file = getpath.resource_path("styling/bar/else.qss")
try:
with open(else_file, "r") as else_style:
bar.setStyleSheet(else_style.read())
except FileNotFoundError:
print("No ElseStyling found!")
pass
else:
else_file_seperate = getpath.resource_path("styling/bar/seperate/else.qss")
try:
with open(else_file_seperate, "r") as efs:
bar.setStyleSheet(efs.read())
except FileNotFoundError:
print("No Sepearte Styling found")
pass
def toggle_detection(self):
"""Toggle detection enabled/disabled"""
self.detection_enabled = self.toggle_detection_action.isChecked()
if self.detection_enabled:
self.start_btn.setEnabled(True)
self.stop_btn.setEnabled(True)
else:
self.start_btn.setEnabled(False)
self.stop_btn.setEnabled(False)
# ===== Alert system methods =====
def set_alert_enabled(self, state: bool):
try:
self.alert_enabled = bool(state)
self.config.save_setting('alert_enabled', self.alert_enabled)
# Keep View menu action in sync
try:
if hasattr(self, 'toggle_alert_action'):
self.toggle_alert_action.setChecked(self.alert_enabled)
except Exception:
pass
except Exception:
pass
self._update_alert_ui()
def _has_audio_backend(self) -> bool:
"""Check if any audio backend is available for alerts."""
try:
if sys.platform.startswith('win'):
try:
import winsound as _ws # type: ignore
return hasattr(_ws, 'PlaySound')
except Exception:
return sa is not None
# macOS/Linux external players
if shutil.which('afplay') or shutil.which('paplay') or shutil.which('aplay') or shutil.which('ffplay'):
return True
return sa is not None
except Exception:
return sa is not None
def _update_alert_ui(self):
try:
backend_ok = self._has_audio_backend()
# Update status tip on the View->Enable Alert action
if hasattr(self, 'toggle_alert_action'):
tip = []
if not backend_ok:
tip.append("No audio backend found (winsound/afplay/paplay/aplay/ffplay/simpleaudio)")
if self._alert_cooldown:
tip.append("Alert cooldown active (30s)")
if self._alert_playing:
tip.append("Alert is playing")
if not self.alert_enabled:
tip.append("Alert disabled")
self.toggle_alert_action.setStatusTip(
"; ".join(tip) if tip else "Play an audible alert when a person is detected")
except Exception:
pass
def trigger_alert(self):
"""Trigger the alert sound if enabled and not in cooldown."""
try:
if not self.alert_enabled:
return
if self._alert_playing or self._alert_cooldown:
return
wav_path = getpath.resource_path("styling/sound/alert.wav")
if not os.path.exists(wav_path):
QMessageBox.warning(self, "Alert sound missing", f"Sound file not found:\n{wav_path}")
return
# Start worker (backend decided internally)
self._alert_worker = AlertWorker(wav_path, self)
self._alert_worker.finished.connect(self._on_alert_finished)
self._alert_playing = True
self._update_alert_ui()
self._alert_worker.start()
except Exception as e:
QMessageBox.critical(self, "Alert Error", f"Failed to trigger alert: {e}")
self._alert_playing = False
self._update_alert_ui()
def _on_alert_finished(self, success: bool, message: str):
# Clean state and start cooldown on success
try:
self._alert_playing = False
# Avoid holding a reference to finished thread
try:
if self._alert_worker:
self._alert_worker.deleteLater()
except Exception:
pass
self._alert_worker = None
if success:
self._alert_cooldown = True
# 30 seconds cooldown
self._cooldown_timer.start(30000)
else:
# Informative but non-fatal
try:
if message:
print(f"Alert playback failed: {message}")
except Exception:
pass
finally:
self._update_alert_ui()
def _on_cooldown_finished(self):
self._alert_cooldown = False
self._update_alert_ui()
def closeEvent(self, event):
"""Ensure background workers and timers are stopped to avoid crashes on exit."""
try:
# Stop periodic timers
try:
if hasattr(self, 'timer') and self.timer is not None:
self.timer.stop()
except Exception:
pass
try:
if hasattr(self, 'hw_timer') and self.hw_timer is not None:
self.hw_timer.stop()
except Exception:
pass
try:
if hasattr(self, '_cooldown_timer') and self._cooldown_timer is not None:
self._cooldown_timer.stop()
except Exception:
pass
# Stop alert worker if running
try:
if getattr(self, '_alert_worker', None) is not None:
try:
self._alert_worker.finished.disconnect(self._on_alert_finished)
except Exception:
pass
try:
if self._alert_worker.isRunning():
try:
self._alert_worker.stop()
except Exception:
pass
self._alert_worker.wait(2000)
except Exception:
pass
try:
self._alert_worker.deleteLater()
except Exception:
pass
self._alert_worker = None
except Exception:
pass
# Disconnect cameras and stop camera threads
try:
if hasattr(self, 'detector') and self.detector is not None:
self.detector.disconnect_cameras()
except Exception:
pass
finally:
try:
event.accept()
except Exception:
pass
class initQT:
"""
This is a QOL Change if you prefer to do it the hard way. Or you just like to get Fist Fucked then i suggest you remove the Function Calls in the
Main Call of the Class!
This is not needed for Windows as it does this Automatically (at least i think)
If some shit that is supposed to happen isnt happening. Step through this Class Via Debuggers!
"""
def __init__(self):
self.session_type = None # This is for QT #
#--------------------#
self.env = os.environ.copy() # This is for CV2 #
def getenv(self):
# If the OS is Linux get Qts Session Type
if platform.system() == "Linux":
self.session_type = os.getenv("XDG_SESSION_TYPE")
return self.session_type
else:
# If theres no Type then Exit 1
print(
"No XDG Session Type found!"
"echo $XDG_SESSION_TYPE"
"Run this command in bash!"
)
pass
def setenv(self):
# Set the Session Type to the one it got
if self.session_type:
os.environ["XDG_SESSION_TYPE"] = self.session_type
else:
# If this fails then just exit with 1
print(
"Setting the XDG_SESSION_TYPE failed!"
f"export XDG_SESSION_TYPE={self.session_type}"
"run this command in bash"
)
pass
@staticmethod
def shutupCV():
# This needs some fixing as this only works before importing CV2 ; too much refactoring work tho!
if platform.system() == "Linux":
os.environ["OPENCV_LOG_LEVEL"] = "ERROR"
else:
pass
"""
This is where windows fuckery starts, if you try to modify any of this then good luck,
this code is fragile but usually works, idk if it works in production but im pushing anyways,
fuck you.
Here we just try to set the windows titlebar to dark mode, this is done with HWND Handle
"""
def is_windows_darkmode() -> bool:
if platform.system() != "Windows":
return False
try:
key_path = r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path) as key:
# 0 = dark mode, 1 = light mode
value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme")
# print(f"AppsUseLightTheme: {value}") # optional debug
return value == 0
except Exception as e:
print(f"Could not read Windows registry for dark mode: {e}")
return False
class darkmodechildren(QApplication):
def notify(self, receiver, event):
# Only handle top-level windows
if isinstance(receiver, QWidget) and receiver.isWindow():
if event.type() == QEvent.WinIdChange:
set_dark_titlebar(receiver)
return super().notify(receiver, event)
def set_dark_titlebar(widget: QWidget):
"""Apply dark titlebar on Windows to any top-level window."""
if platform.system() != "Windows":
return
if not widget.isWindow(): # only top-level windows
return
if is_windows_darkmode():
try:
hwnd = int(widget.winId())
DWMWA_USE_IMMERSIVE_DARK_MODE = 20
value = ctypes.c_int(1)
res = ctypes.windll.dwmapi.DwmSetWindowAttribute(
hwnd,
DWMWA_USE_IMMERSIVE_DARK_MODE,
ctypes.byref(value),
ctypes.sizeof(value)
)
if res != 0:
# fallback for some Windows builds
DWMWA_USE_IMMERSIVE_DARK_MODE = 19
ctypes.windll.dwmapi.DwmSetWindowAttribute(
hwnd,
DWMWA_USE_IMMERSIVE_DARK_MODE,
ctypes.byref(value),
ctypes.sizeof(value)
)
except Exception as e:
print("Failed to set dark titlebar:", e)
if __name__ == "__main__":
# Initialize Qt if on Linux
if platform.system() == "Linux":
qt = initQT()
qt.getenv()
qt.setenv()
qt.shutupCV()
app = darkmodechildren(sys.argv)
# Try to set the AppIcon
try:
app.setWindowIcon(QIcon(getpath.resource_path("styling/icon.png")))
except FileNotFoundError:
pass
# Use Fusion style for consistent dark-mode palettes
app.setStyle("Fusion")
window = MainWindow()
window.show()
try:
sys.exit(app.exec_())
except Exception as e:
print(f"Exit Exception with: {e}")
pass