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