diff --git a/CameraDisplay.py b/CameraDisplay.py deleted file mode 100644 index e69de29..0000000 diff --git a/mucapy/AboutWindow.py b/mucapy/AboutWindow.py new file mode 100644 index 0000000..77949e7 --- /dev/null +++ b/mucapy/AboutWindow.py @@ -0,0 +1,300 @@ +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) +import todopackage.todo as todo +from utility import getpath +import cv2 +import sys +import psutil +import numpy as np +import requests +from initqt import initQT + +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"
{todo_text}")
+ 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"{todo_archive_text}")
+ 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"{info_text}")
+ 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"{cam_text}")
+ 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}"
\ No newline at end of file
diff --git a/mucapy/AlertWorker.py b/mucapy/AlertWorker.py
index dca223d..03cde72 100644
--- a/mucapy/AlertWorker.py
+++ b/mucapy/AlertWorker.py
@@ -6,6 +6,7 @@ except ImportError:
sa = None
sa = None # Force it to not use it cause it fucks stuff up
import os
+import subprocess
import time
import sys
from PyQt5.QtCore import QThread, pyqtSignal
@@ -87,7 +88,7 @@ class AlertWorker(QThread):
# On failure, break to try alternative backends
ws_error = str(e)
break
- time.sleep(0.002)
+ time.sleep(0.001)
else:
# Completed all 4 plays
self.finished.emit(True, "Alert played")
diff --git a/mucapy/CameraDisplay.py b/mucapy/CameraDisplay.py
new file mode 100644
index 0000000..35d049c
--- /dev/null
+++ b/mucapy/CameraDisplay.py
@@ -0,0 +1,127 @@
+from PyQt5.QtCore import Qt, QDateTime, QRect
+from PyQt5.QtGui import (QColor, QPainter,
+ QPen, QBrush)
+from PyQt5.QtWidgets import (QApplication, QLabel, QFileDialog, QMessageBox)
+from utility import getpath
+from Config import Config
+from PopoutWindow import PopoutWindow
+import os
+
+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)
diff --git a/mucapy/CameraSelectorDialog.py b/mucapy/CameraSelectorDialog.py
new file mode 100644
index 0000000..f8008d2
--- /dev/null
+++ b/mucapy/CameraSelectorDialog.py
@@ -0,0 +1,317 @@
+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)
+import NetworkCameraDialog
+from todopackage.todo import todo
+import os
+
+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.get_instructions_CaSeDi_QLabel())
+ print(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()
diff --git a/mucapy/CollpsibleDock.py b/mucapy/CollpsibleDock.py
new file mode 100644
index 0000000..df62a07
--- /dev/null
+++ b/mucapy/CollpsibleDock.py
@@ -0,0 +1,85 @@
+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)
+
+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
+
diff --git a/mucapy/NetworkCameraDialog.py b/mucapy/NetworkCameraDialog.py
new file mode 100644
index 0000000..61558c7
--- /dev/null
+++ b/mucapy/NetworkCameraDialog.py
@@ -0,0 +1,143 @@
+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)
+
+from todopackage.todo import todo
+
+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.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()
\ No newline at end of file
diff --git a/mucapy/PopoutWindow.py b/mucapy/PopoutWindow.py
index 473f09a..973bcfd 100644
--- a/mucapy/PopoutWindow.py
+++ b/mucapy/PopoutWindow.py
@@ -1,27 +1,80 @@
-from PyQt5.QtCore import Qt, QTimer, QDateTime, QRect, QEvent
+from PyQt5.QtCore import Qt, QTimer, QDateTime, QRect, QEvent, QPointF, QPoint, QThread, pyqtSignal
from PyQt5.QtGui import (QImage, QPixmap, QColor, QKeySequence, QPainter,
- QPen, QBrush)
+ QPen, QBrush, QFont)
from PyQt5.QtWidgets import (QMainWindow, QVBoxLayout, QHBoxLayout,
- QWidget, QLabel, QScrollArea, QToolButton, QShortcut)
+ QWidget, QLabel, QScrollArea, QToolButton,
+ QShortcut, QFileDialog, QMessageBox)
+import math
+import os
+
+class SaveWorker(QThread):
+ """Worker thread for saving snapshots and recordings"""
+ finished = pyqtSignal(bool, str)
+ progress = pyqtSignal(int, int)
+
+ def __init__(self, frames, folder, cam_id, is_recording=False):
+ super().__init__()
+ self.frames = frames
+ self.folder = folder
+ self.cam_id = cam_id
+ self.is_recording = is_recording
+
+ def run(self):
+ try:
+ timestamp = QDateTime.currentDateTime().toString('yyyyMMdd_hhmmss')
+
+ if self.is_recording:
+ for i, frame in enumerate(self.frames):
+ filename = os.path.join(self.folder, f"cam_{self.cam_id}_rec_{timestamp}_frame_{i:04d}.png")
+ frame.save(filename)
+ self.progress.emit(i + 1, len(self.frames))
+ self.finished.emit(True, f"Saved {len(self.frames)} frames")
+ else:
+ filename = os.path.join(self.folder, f"camera_{self.cam_id}_snapshot_{timestamp}.png")
+ self.frames[0].save(filename)
+ self.finished.emit(True, f"Saved to: {filename}")
+
+ except Exception as e:
+ self.finished.emit(False, str(e))
+
class PopoutWindow(QMainWindow):
- """Enhanced popout window with zoom, pan, overlays and guard-friendly controls"""
+ """Enhanced popout window with touch support, pinch zoom, and security guard features"""
def __init__(self, source_display: QLabel, cam_id=None, parent=None):
super().__init__(parent)
self.setWindowTitle(f"Camera {cam_id}" if cam_id is not None else "Camera")
- self.source_display = source_display # QLabel providing pixmap updates
+ self.source_display = source_display
self.cam_id = cam_id
self.zoom_factor = 1.0
self.min_zoom = 0.2
- self.max_zoom = 5.0
+ self.max_zoom = 10.0
self.paused = False
self.show_grid = False
self.show_timestamp = True
+ self.show_crosshair = False
+ self.enhance_mode = 0
+ self.recording = False
+ self.record_frames = []
self.setMinimumSize(640, 480)
- # Drag-to-pan state
- self.dragging = False
- self.last_mouse_pos = None
+
+ # Touch gesture state
+ self.setAttribute(Qt.WA_AcceptTouchEvents, True)
+ self.gesture_type = None # 'pinch', 'pan', or None
+
+ # Pinch zoom state
+ self.pinch_initial_distance = 0
+ self.pinch_initial_zoom = 1.0
+
+ # Pan state (both touch and mouse)
+ self.pan_active = False
+ self.pan_last_pos = None
+
+ # Worker thread for saving
+ self.save_worker = None
+
+ # Snapshot history
+ self.snapshot_count = 0
# Central area: toolbar + scrollable image label
central = QWidget()
@@ -29,42 +82,86 @@ class PopoutWindow(QMainWindow):
vbox.setContentsMargins(4, 4, 4, 4)
vbox.setSpacing(4)
- # Toolbar with guard-friendly controls
+ # Main toolbar
toolbar = QHBoxLayout()
+
+ # Zoom controls
self.btn_zoom_in = QToolButton()
self.btn_zoom_in.setText("+")
+ self.btn_zoom_in.setMinimumSize(44, 44)
+
self.btn_zoom_out = QToolButton()
self.btn_zoom_out.setText("-")
+ self.btn_zoom_out.setMinimumSize(44, 44)
+
self.btn_zoom_reset = QToolButton()
self.btn_zoom_reset.setText("100%")
+ self.btn_zoom_reset.setMinimumSize(44, 44)
+
+ # Playback controls
self.btn_pause = QToolButton()
self.btn_pause.setText("Pause")
+ self.btn_pause.setMinimumSize(60, 44)
+
self.btn_snapshot = QToolButton()
self.btn_snapshot.setText("Snapshot")
+ self.btn_snapshot.setMinimumSize(60, 44)
+
+ # Overlay controls
self.btn_grid = QToolButton()
self.btn_grid.setText("Grid")
+ self.btn_grid.setMinimumSize(60, 44)
+
self.btn_time = QToolButton()
self.btn_time.setText("Time")
+ self.btn_time.setMinimumSize(60, 44)
+
+ self.btn_crosshair = QToolButton()
+ self.btn_crosshair.setText("Crosshair")
+ self.btn_crosshair.setMinimumSize(60, 44)
+
+ self.btn_enhance = QToolButton()
+ self.btn_enhance.setText("Enhance")
+ self.btn_enhance.setMinimumSize(60, 44)
+
+ self.btn_record = QToolButton()
+ self.btn_record.setText("Record")
+ self.btn_record.setMinimumSize(60, 44)
+
self.btn_full = QToolButton()
self.btn_full.setText("Fullscreen")
+ self.btn_full.setMinimumSize(60, 44)
- for b in [self.btn_zoom_out, self.btn_zoom_in, self.btn_zoom_reset, self.btn_pause, self.btn_snapshot,
- self.btn_grid, self.btn_time, self.btn_full]:
+ for b in [self.btn_zoom_out, self.btn_zoom_in, self.btn_zoom_reset,
+ self.btn_pause, self.btn_snapshot, self.btn_grid,
+ self.btn_time, self.btn_crosshair, self.btn_enhance,
+ self.btn_record, self.btn_full]:
toolbar.addWidget(b)
toolbar.addStretch(1)
vbox.addLayout(toolbar)
- # Scroll area for panning when zoomed
+ # Status bar
+ status_layout = QHBoxLayout()
+ self.status_label = QLabel(f"Camera {cam_id if cam_id else 'View'} | Zoom: 100%")
+ self.status_label.setStyleSheet("color: #666; font-size: 10px;")
+ status_layout.addWidget(self.status_label)
+ status_layout.addStretch(1)
+ vbox.addLayout(status_layout)
+
+ # Scroll area for panning
self.image_label = QLabel()
self.image_label.setAlignment(Qt.AlignCenter)
+ self.image_label.setAttribute(Qt.WA_AcceptTouchEvents, True)
+
self.scroll = QScrollArea()
self.scroll.setWidget(self.image_label)
self.scroll.setWidgetResizable(True)
+ self.scroll.setAttribute(Qt.WA_AcceptTouchEvents, True)
vbox.addWidget(self.scroll, 1)
self.setCentralWidget(central)
- # Shortcuts
+ # Keyboard shortcuts
QShortcut(QKeySequence("+"), self, activated=self.zoom_in)
QShortcut(QKeySequence("-"), self, activated=self.zoom_out)
QShortcut(QKeySequence("0"), self, activated=self.reset_zoom)
@@ -74,6 +171,7 @@ class PopoutWindow(QMainWindow):
QShortcut(QKeySequence("Space"), self, activated=self.toggle_pause)
QShortcut(QKeySequence("G"), self, activated=self.toggle_grid)
QShortcut(QKeySequence("T"), self, activated=self.toggle_timestamp)
+ QShortcut(QKeySequence("C"), self, activated=self.toggle_crosshair)
# Connect buttons
self.btn_zoom_in.clicked.connect(self.zoom_in)
@@ -83,6 +181,9 @@ class PopoutWindow(QMainWindow):
self.btn_snapshot.clicked.connect(self.take_snapshot)
self.btn_grid.clicked.connect(self.toggle_grid)
self.btn_time.clicked.connect(self.toggle_timestamp)
+ self.btn_crosshair.clicked.connect(self.toggle_crosshair)
+ self.btn_enhance.clicked.connect(self.cycle_enhance)
+ self.btn_record.clicked.connect(self.toggle_recording)
self.btn_full.clicked.connect(self.toggle_fullscreen)
# Timer to refresh from source display
@@ -90,8 +191,9 @@ class PopoutWindow(QMainWindow):
self.timer.timeout.connect(self.refresh_frame)
self.timer.start(40)
- # Mouse wheel zoom support
+ # Event filter
self.image_label.installEventFilter(self)
+ self.scroll.viewport().installEventFilter(self)
# Initial render
self.refresh_frame()
@@ -99,6 +201,8 @@ class PopoutWindow(QMainWindow):
def closeEvent(self, event):
if hasattr(self, 'timer') and self.timer:
self.timer.stop()
+ if self.save_worker and self.save_worker.isRunning():
+ self.save_worker.wait()
return super().closeEvent(event)
def toggle_fullscreen(self):
@@ -112,74 +216,222 @@ class PopoutWindow(QMainWindow):
def toggle_pause(self):
self.paused = not self.paused
self.btn_pause.setText("Resume" if self.paused else "Pause")
+ self.update_status()
def toggle_grid(self):
self.show_grid = not self.show_grid
+ self.btn_grid.setStyleSheet("background-color: #4CAF50;" if self.show_grid else "")
def toggle_timestamp(self):
self.show_timestamp = not self.show_timestamp
+ self.btn_time.setStyleSheet("background-color: #4CAF50;" if self.show_timestamp else "")
+
+ def toggle_crosshair(self):
+ self.show_crosshair = not self.show_crosshair
+ self.btn_crosshair.setStyleSheet("background-color: #4CAF50;" if self.show_crosshair else "")
+
+ def cycle_enhance(self):
+ self.enhance_mode = (self.enhance_mode + 1) % 4
+ enhance_names = ["Off", "Sharpen", "Edges", "Denoise"]
+ self.btn_enhance.setText(f"Enhance: {enhance_names[self.enhance_mode]}")
+ if self.enhance_mode == 0:
+ self.btn_enhance.setStyleSheet("")
+ else:
+ self.btn_enhance.setStyleSheet("background-color: #2196F3;")
+ self.update_status()
+
+ def toggle_recording(self):
+ self.recording = not self.recording
+ if self.recording:
+ self.record_frames = []
+ self.btn_record.setText("Stop Rec")
+ self.btn_record.setStyleSheet("background-color: #f44336;")
+ else:
+ self.btn_record.setText("Record")
+ self.btn_record.setStyleSheet("")
+ if self.record_frames:
+ self.save_recording()
+ self.update_status()
+
+ def save_recording(self):
+ if not self.record_frames:
+ return
+
+ try:
+ reply = QMessageBox.question(
+ self,
+ "Save Recording",
+ f"Save {len(self.record_frames)} recorded frames as images?",
+ QMessageBox.Yes | QMessageBox.No
+ )
+
+ if reply == QMessageBox.Yes:
+ folder = QFileDialog.getExistingDirectory(self, "Select Folder for Recording")
+ if folder:
+ self.save_worker = SaveWorker(self.record_frames.copy(), folder, self.cam_id, True)
+ self.save_worker.finished.connect(self.on_save_finished)
+ self.save_worker.progress.connect(self.on_save_progress)
+ self.save_worker.start()
+ self.status_label.setText("Saving recording...")
+ except Exception as e:
+ print(f"Error saving recording: {e}")
+
+ self.record_frames = []
+
+ def on_save_progress(self, current, total):
+ self.status_label.setText(f"Saving: {current}/{total} frames")
+
+ def on_save_finished(self, success, message):
+ if success:
+ QMessageBox.information(self, "Recording Saved", message)
+ else:
+ QMessageBox.warning(self, "Save Error", f"Error saving: {message}")
+ self.update_status()
def take_snapshot(self):
- # Prefer using source_display method if available
if hasattr(self.source_display, 'take_screenshot'):
self.source_display.take_screenshot()
return
+ pm = self.current_pixmap()
+ if pm and not pm.isNull():
+ try:
+ self.snapshot_count += 1
+ timestamp = QDateTime.currentDateTime().toString('yyyyMMdd_hhmmss')
+ filename = f"camera_{self.cam_id}_snapshot_{timestamp}.png"
+ file_path, _ = QFileDialog.getSaveFileName(self, "Save Snapshot", filename, "Images (*.png *.jpg)")
+ if file_path:
+ pm.save(file_path)
+ QMessageBox.information(self, "Snapshot Saved", f"Saved to: {file_path}")
+ except Exception as e:
+ print(f"Error saving snapshot: {e}")
+
def current_pixmap(self):
- pm = self.source_display.pixmap()
- return pm
+ return self.source_display.pixmap()
def refresh_frame(self):
if self.paused:
return
+
pm = self.current_pixmap()
- if not pm:
+ if not pm or pm.isNull():
return
- # Create a copy to draw overlays without touching original
- image = pm.toImage().convertToFormat(QImage.Format_ARGB32)
- painter = QPainter(image)
- painter.setRenderHint(QPainter.Antialiasing)
- # Timestamp overlay
- if self.show_timestamp:
- ts = QDateTime.currentDateTime().toString('yyyy-MM-dd hh:mm:ss')
- text = ts
- metrics = painter.fontMetrics()
- w = metrics.width(text) + 14
- h = metrics.height() + 8
- rect = QRect(10, 10, w, h)
- painter.setPen(Qt.NoPen)
- painter.setBrush(QBrush(QColor(0, 0, 0, 160)))
- painter.drawRoundedRect(rect, 6, 6)
- painter.setPen(QPen(QColor(255, 255, 255)))
- painter.drawText(rect, Qt.AlignCenter, text)
+ try:
+ # Store frame for recording
+ if self.recording:
+ self.record_frames.append(pm.copy())
+ if len(self.record_frames) > 300:
+ self.record_frames.pop(0)
- # Grid overlay (rule-of-thirds)
- if self.show_grid:
- painter.setPen(QPen(QColor(255, 255, 255, 120), 1))
- img_w = image.width()
- img_h = image.height()
- for i in range(1, 3):
- x = int(img_w * i / 3)
- y = int(img_h * i / 3)
- painter.drawLine(x, 0, x, img_h)
- painter.drawLine(0, y, img_w, y)
- painter.end()
+ # Create overlays
+ image = pm.toImage().convertToFormat(QImage.Format_ARGB32)
+ painter = QPainter(image)
+ painter.setRenderHint(QPainter.Antialiasing)
- composed = QPixmap.fromImage(image)
- if self.zoom_factor != 1.0:
- target_w = int(composed.width() * self.zoom_factor)
- target_h = int(composed.height() * self.zoom_factor)
- composed = composed.scaled(target_w, target_h, Qt.KeepAspectRatio, Qt.SmoothTransformation)
- self.image_label.setPixmap(composed)
- # Update cursor based on ability to pan at this zoom/size
- self.update_cursor()
+ # Timestamp overlay
+ if self.show_timestamp:
+ ts = QDateTime.currentDateTime().toString('yyyy-MM-dd hh:mm:ss')
+ cam_text = f"Cam {self.cam_id} | {ts}" if self.cam_id else ts
+
+ font = QFont()
+ font.setPointSize(11)
+ font.setBold(True)
+ painter.setFont(font)
+
+ metrics = painter.fontMetrics()
+ w = metrics.width(cam_text) + 16
+ h = metrics.height() + 10
+ rect = QRect(10, 10, w, h)
+ painter.setPen(Qt.NoPen)
+ painter.setBrush(QBrush(QColor(0, 0, 0, 180)))
+ painter.drawRoundedRect(rect, 6, 6)
+ painter.setPen(QPen(QColor(255, 255, 255)))
+ painter.drawText(rect, Qt.AlignCenter, cam_text)
+
+ # Grid overlay
+ if self.show_grid:
+ painter.setPen(QPen(QColor(255, 255, 255, 120), 2))
+ img_w = image.width()
+ img_h = image.height()
+
+ for i in range(1, 3):
+ x = int(img_w * i / 3)
+ y = int(img_h * i / 3)
+ painter.drawLine(x, 0, x, img_h)
+ painter.drawLine(0, y, img_w, y)
+
+ painter.setPen(QPen(QColor(255, 255, 0, 100), 1, Qt.DashLine))
+ painter.drawLine(img_w // 2, 0, img_w // 2, img_h)
+ painter.drawLine(0, img_h // 2, img_w, img_h // 2)
+
+ # Crosshair overlay
+ if self.show_crosshair:
+ painter.setPen(QPen(QColor(255, 0, 0, 200), 2))
+ img_w = image.width()
+ img_h = image.height()
+ center_x = img_w // 2
+ center_y = img_h // 2
+ size = 30
+
+ painter.drawLine(center_x - size, center_y, center_x + size, center_y)
+ painter.drawLine(center_x, center_y - size, center_x, center_y + size)
+
+ painter.setPen(QPen(QColor(255, 0, 0, 150), 1))
+ painter.drawEllipse(QPoint(center_x, center_y), 5, 5)
+
+ # Recording indicator
+ if self.recording:
+ painter.setPen(Qt.NoPen)
+ painter.setBrush(QBrush(QColor(255, 0, 0, 200)))
+ painter.drawEllipse(image.width() - 30, 10, 15, 15)
+
+ painter.setPen(QPen(QColor(255, 255, 255)))
+ font = QFont()
+ font.setPointSize(9)
+ font.setBold(True)
+ painter.setFont(font)
+ painter.drawText(QRect(image.width() - 100, 25, 90, 20),
+ Qt.AlignRight, f"REC {len(self.record_frames)}")
+
+ painter.end()
+
+ composed = QPixmap.fromImage(image)
+
+ # Apply zoom
+ if self.zoom_factor != 1.0:
+ target_w = int(composed.width() * self.zoom_factor)
+ target_h = int(composed.height() * self.zoom_factor)
+ composed = composed.scaled(target_w, target_h, Qt.KeepAspectRatio, Qt.SmoothTransformation)
+
+ self.image_label.setPixmap(composed)
+ self.update_cursor()
+
+ except Exception as e:
+ print(f"Error in refresh_frame: {e}")
+
+ def update_status(self):
+ try:
+ zoom_pct = int(self.zoom_factor * 100)
+ status_parts = [f"Camera {self.cam_id if self.cam_id else 'View'}", f"Zoom: {zoom_pct}%"]
+
+ if self.paused:
+ status_parts.append("PAUSED")
+ if self.recording:
+ status_parts.append(f"RECORDING ({len(self.record_frames)} frames)")
+ if self.enhance_mode != 0:
+ enhance_names = ["Off", "Sharpen", "Edges", "Denoise"]
+ status_parts.append(f"Enhance: {enhance_names[self.enhance_mode]}")
+
+ self.status_label.setText(" | ".join(status_parts))
+ except Exception as e:
+ print(f"Error updating status: {e}")
def zoom_in(self):
- self.set_zoom(self.zoom_factor * 1.2)
+ self.set_zoom(self.zoom_factor * 1.3)
def zoom_out(self):
- self.set_zoom(self.zoom_factor / 1.2)
+ self.set_zoom(self.zoom_factor / 1.3)
def reset_zoom(self):
self.set_zoom(1.0)
@@ -189,10 +441,10 @@ class PopoutWindow(QMainWindow):
if abs(z - self.zoom_factor) > 1e-4:
self.zoom_factor = z
self.refresh_frame()
+ self.update_status()
self.update_cursor()
def can_pan(self):
- # Allow panning when the pixmap is larger than the viewport (zoomed)
if not self.image_label.pixmap():
return False
vp = self.scroll.viewport().size()
@@ -201,46 +453,121 @@ class PopoutWindow(QMainWindow):
def update_cursor(self):
if self.can_pan():
- self.image_label.setCursor(Qt.OpenHandCursor if not self.dragging else Qt.ClosedHandCursor)
+ self.image_label.setCursor(Qt.OpenHandCursor if not self.pan_active else Qt.ClosedHandCursor)
else:
self.image_label.setCursor(Qt.ArrowCursor)
- def eventFilter(self, obj, event):
- if obj is self.image_label:
- # Mouse wheel zoom centered on cursor
- if event.type() == QEvent.Wheel:
- delta = event.angleDelta().y()
- if delta > 0:
- self.zoom_in()
- else:
- self.zoom_out()
- return True
- # Start drag
- if event.type() == QEvent.MouseButtonPress and event.button() == Qt.LeftButton and self.can_pan():
- self.dragging = True
- self.last_mouse_pos = event.pos()
- self.update_cursor()
- return True
- # Dragging
- if event.type() == QEvent.MouseMove and self.dragging and self.last_mouse_pos is not None:
- delta = event.pos() - self.last_mouse_pos
- hbar = self.scroll.horizontalScrollBar()
- vbar = self.scroll.verticalScrollBar()
- hbar.setValue(hbar.value() - delta.x())
- vbar.setValue(vbar.value() - delta.y())
- self.last_mouse_pos = event.pos()
- return True
- # End drag
- if event.type() == QEvent.MouseButtonRelease and event.button() == Qt.LeftButton:
- if self.dragging:
- self.dragging = False
- self.last_mouse_pos = None
+ def distance(self, p1: QPointF, p2: QPointF) -> float:
+ dx = p2.x() - p1.x()
+ dy = p2.y() - p1.y()
+ return math.sqrt(dx * dx + dy * dy)
+
+ def event(self, event):
+ """Handle touch events"""
+ try:
+ if event.type() == QEvent.TouchBegin:
+ points = event.touchPoints()
+
+ if len(points) == 2:
+ self.gesture_type = 'pinch'
+ p1 = points[0].pos()
+ p2 = points[1].pos()
+ self.pinch_initial_distance = self.distance(p1, p2)
+ self.pinch_initial_zoom = self.zoom_factor
+
+ elif len(points) == 1 and self.can_pan():
+ self.gesture_type = 'pan'
+ self.pan_active = True
+ self.pan_last_pos = points[0].pos()
self.update_cursor()
- return True
- if event.type() == QEvent.Enter or event.type() == QEvent.Leave:
- # Update cursor when entering/leaving the label
- if event.type() == QEvent.Leave:
- self.dragging = False
- self.last_mouse_pos = None
+
+ event.accept()
+ return True
+
+ elif event.type() == QEvent.TouchUpdate:
+ points = event.touchPoints()
+
+ if self.gesture_type == 'pinch' and len(points) == 2:
+ p1 = points[0].pos()
+ p2 = points[1].pos()
+ current_distance = self.distance(p1, p2)
+
+ if self.pinch_initial_distance > 10:
+ scale_factor = current_distance / self.pinch_initial_distance
+ new_zoom = self.pinch_initial_zoom * scale_factor
+ self.set_zoom(new_zoom)
+
+ elif self.gesture_type == 'pan' and len(points) == 1 and self.can_pan():
+ current_pos = points[0].pos()
+ if self.pan_last_pos is not None:
+ delta = current_pos - self.pan_last_pos
+ hbar = self.scroll.horizontalScrollBar()
+ vbar = self.scroll.verticalScrollBar()
+ hbar.setValue(int(hbar.value() - delta.x()))
+ vbar.setValue(int(vbar.value() - delta.y()))
+
+ self.pan_last_pos = current_pos
+
+ event.accept()
+ return True
+
+ elif event.type() in (QEvent.TouchEnd, QEvent.TouchCancel):
+ self.gesture_type = None
+ self.pan_active = False
+ self.pan_last_pos = None
+ self.pinch_initial_distance = 0
self.update_cursor()
+
+ event.accept()
+ return True
+
+ except Exception as e:
+ print(f"Error in touch event: {e}")
+
+ return super().event(event)
+
+ def eventFilter(self, obj, event):
+ """Handle mouse events"""
+ try:
+ if obj is self.image_label or obj is self.scroll.viewport():
+ if event.type() == QEvent.Wheel:
+ delta = event.angleDelta().y()
+ if delta > 0:
+ self.zoom_in()
+ else:
+ self.zoom_out()
+ return True
+
+ if event.type() == QEvent.MouseButtonPress and event.button() == Qt.LeftButton:
+ if self.can_pan():
+ self.pan_active = True
+ self.pan_last_pos = event.pos()
+ self.update_cursor()
+ return True
+
+ if event.type() == QEvent.MouseMove and self.pan_active:
+ if self.pan_last_pos is not None:
+ delta = event.pos() - self.pan_last_pos
+ hbar = self.scroll.horizontalScrollBar()
+ vbar = self.scroll.verticalScrollBar()
+ hbar.setValue(hbar.value() - delta.x())
+ vbar.setValue(vbar.value() - delta.y())
+ self.pan_last_pos = event.pos()
+ return True
+
+ if event.type() == QEvent.MouseButtonRelease and event.button() == Qt.LeftButton:
+ if self.pan_active:
+ self.pan_active = False
+ self.pan_last_pos = None
+ self.update_cursor()
+ return True
+
+ if event.type() == QEvent.Leave:
+ self.pan_active = False
+ self.pan_last_pos = None
+ self.update_cursor()
+
+ except Exception as e:
+ print(f"Error in eventFilter: {e}")
+
return super().eventFilter(obj, event)
\ No newline at end of file
diff --git a/mucapy/initqt.py b/mucapy/initqt.py
new file mode 100644
index 0000000..94b53f2
--- /dev/null
+++ b/mucapy/initqt.py
@@ -0,0 +1,51 @@
+import os
+import platform
+
+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
diff --git a/mucapy/main.py b/mucapy/main.py
index 0da20a5..d3c51a2 100644
--- a/mucapy/main.py
+++ b/mucapy/main.py
@@ -1,14 +1,29 @@
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
+from CameraDisplay import CameraDisplay
+from CameraSelectorDialog import CameraSelectorDialog
+from NetworkCameraDialog import NetworkCameraDialog
+from CollpsibleDock import CollapsibleDock
+from AboutWindow import AboutWindow
+from utility import getpath
+from utility import conversion
+from utility import darkmodechildren
+from initqt import initQT
+# i dont want to rewrite code so this is fine
+# its a staticmethod
+bytes_to_human = conversion.bytes_to_human
+
try:
import winreg
except ImportError:
pass
+
import os
import platform
import subprocess
@@ -34,19 +49,9 @@ try:
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"
+ print("Reinstall PyQt5 wheels: pip install --upgrade --force-reinstall PyQt5==5.15.11\n"
f"Original error: {e}")
-import todopackage.todo as todo # This shit will fail eventually | Or not IDK
# Audio alert dependencies
import wave
@@ -55,954 +60,6 @@ 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"{todo_text}")
- 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"{todo_archive_text}")
- 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"{info_text}")
- 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"{cam_text}")
- 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):
@@ -2074,120 +1131,6 @@ class MainWindow(QMainWindow):
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":
@@ -2200,7 +1143,8 @@ if __name__ == "__main__":
# Try to set the AppIcon
try:
- app.setWindowIcon(QIcon(getpath.resource_path("styling/icon.png")))
+ app.setWindowIcon(QIcon(getpath.resource_path("styling/logo.png")))
+ app.setWindowIcon(QIcon(getpath.resource_path("styling/logo.ico")))
except FileNotFoundError:
pass
diff --git a/mucapy/styling/logo.ico b/mucapy/styling/logo.ico
new file mode 100644
index 0000000..d5bf171
Binary files /dev/null and b/mucapy/styling/logo.ico differ
diff --git a/mucapy/utility.py b/mucapy/utility.py
new file mode 100644
index 0000000..47ef125
--- /dev/null
+++ b/mucapy/utility.py
@@ -0,0 +1,94 @@
+import os
+import platform
+import winreg
+import ctypes
+from PyQt5.QtWidgets import QWidget, QApplication
+from PyQt5.QtCore import QEvent
+
+class conversion:
+ _symbols = ("B", "KiB", "MiB", "GiB", "TiB", "PiB")
+ _thresholds = [1 << (10 * i) for i in range(len(_symbols))]
+
+ @staticmethod
+ def bytes_to_human(n: int) -> str:
+ try:
+ n = int(n)
+ except Exception:
+ return str(n)
+
+ if n < 1024:
+ return f"{n} B"
+
+ thresholds = conversion._thresholds
+ symbols = conversion._symbols
+ i = min(len(thresholds) - 1, (n.bit_length() - 1) // 10)
+ val = n / thresholds[i]
+
+ # Pick a faster formatting branch
+ if val >= 100:
+ return f"{val:.0f} {symbols[i]}"
+ elif val >= 10:
+ return f"{val:.1f} {symbols[i]}"
+ else:
+ return f"{val:.2f} {symbols[i]}"
+
+class getpath:
+ @staticmethod
+ def resource_path(relative_path: str):
+ base_path = os.path.dirname(os.path.abspath(__file__))
+ return os.path.join(base_path, relative_path)
+
+class windows:
+ @staticmethod
+ 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
+
+ @staticmethod
+ 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 windows.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)
+
+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:
+ windows.set_dark_titlebar(receiver)
+ return super().notify(receiver, event)
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index c213556..b5dd871 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -5,4 +5,5 @@ requests==2.32.3
psutil==7.0.0
pytest==8.4.0
comtypes==1.4.13
-rtsp==1.1.12
\ No newline at end of file
+rtsp==1.1.12
+#pynvcodec==0.0.6