2217 lines
84 KiB
Python
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
|