refactor
This commit is contained in:
300
mucapy/AboutWindow.py
Normal file
300
mucapy/AboutWindow.py
Normal file
@@ -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"<pre>{todo_text}</pre>")
|
||||
todo_label.setWordWrap(True)
|
||||
todo_label.setAlignment(Qt.AlignLeft)
|
||||
|
||||
# TODO: Fix this xD ; Fixing a TODO lol
|
||||
try:
|
||||
todo_style_path = getpath.resource_path("styling/todostyle.qss")
|
||||
with open(todo_style_path, "r") as tdf:
|
||||
todo_label.setStyleSheet(tdf.read())
|
||||
# here we have our wonderfull fix
|
||||
if True == True:
|
||||
todo_label.setStyleSheet("color: #f7ef02; font-style: italic;")
|
||||
else:
|
||||
pass
|
||||
except FileNotFoundError:
|
||||
print(f"Missing a Style File! => {todo_style_path}")
|
||||
pass
|
||||
|
||||
# Create the labels for the fucking trodo ass shit ?
|
||||
self.todo_archive_object = todo
|
||||
todo_archive_text = self.get_archive_text()
|
||||
todo_archive_label = QLabel(f"<pre>{todo_archive_text}</pre>")
|
||||
todo_archive_label.setWordWrap(True)
|
||||
todo_archive_label.setAlignment(Qt.AlignLeft)
|
||||
todo_archive_label.setStyleSheet("color: #02d1fa ;font-style: italic;")
|
||||
|
||||
self.info_obj = todo
|
||||
info_text = self.get_info_text()
|
||||
info_label = QLabel(f"<pre>{info_text}</pre>")
|
||||
info_label.setWordWrap(True)
|
||||
info_label.setAlignment(Qt.AlignCenter)
|
||||
info_label.setStyleSheet("color: #2ecc71 ; font-style: italic;")
|
||||
|
||||
self.camobj = todo
|
||||
cam_text = self.get_cam_text()
|
||||
cam_label = QLabel(f"<pre>{cam_text}</pre>")
|
||||
cam_label.setWordWrap(True)
|
||||
cam_label.setAlignment(Qt.AlignCenter)
|
||||
cam_label.setStyleSheet("color: #ffffff; font-style: italic;")
|
||||
|
||||
if True == True:
|
||||
layout.addWidget(info_label)
|
||||
layout.addWidget(todo_label)
|
||||
layout.addWidget(todo_archive_label)
|
||||
layout.addWidget(cam_label)
|
||||
else:
|
||||
pass
|
||||
|
||||
def toggle_expand(self, checked):
|
||||
for key, label in self.full_labels.items():
|
||||
if key not in self.important_keys:
|
||||
label.setVisible(checked)
|
||||
self.toggle_btn.setText("▼" if checked else "▶")
|
||||
|
||||
def style_label(self, label, key, value):
|
||||
if key == "Python":
|
||||
label.setStyleSheet("color: #7FDBFF;")
|
||||
elif key == "OpenCV":
|
||||
label.setStyleSheet("color: #FF851B;")
|
||||
elif key == "CUDA":
|
||||
label.setStyleSheet("color: green;" if value == "Yes" else "color: red;")
|
||||
elif key == "NumPy":
|
||||
label.setStyleSheet("color: #B10DC9;")
|
||||
elif key == "Requests":
|
||||
label.setStyleSheet("color: #0074D9;")
|
||||
elif key == "Memory":
|
||||
try:
|
||||
ram = int(value.split()[0])
|
||||
if ram < 8:
|
||||
label.setStyleSheet("color: red;")
|
||||
elif ram < 16:
|
||||
label.setStyleSheet("color: yellow;")
|
||||
elif ram < 32:
|
||||
label.setStyleSheet("color: lightgreen;")
|
||||
else:
|
||||
label.setStyleSheet("color: #90EE90;")
|
||||
except:
|
||||
label.setStyleSheet("color: gray;")
|
||||
elif key == "CPU Usage":
|
||||
try:
|
||||
usage = float(value.strip('%'))
|
||||
if usage > 80:
|
||||
label.setStyleSheet("color: red;")
|
||||
elif usage > 50:
|
||||
label.setStyleSheet("color: yellow;")
|
||||
else:
|
||||
label.setStyleSheet("color: lightgreen;")
|
||||
except:
|
||||
label.setStyleSheet("color: gray;")
|
||||
elif key in ("CPU Cores", "Logical CPUs"):
|
||||
label.setStyleSheet("color: lightgreen;")
|
||||
elif key in ("CPU", "Architecture", "OS"):
|
||||
label.setStyleSheet("color: lightgray;")
|
||||
else:
|
||||
label.setStyleSheet("color: #DDD;")
|
||||
|
||||
def get_system_info(self):
|
||||
import platform
|
||||
|
||||
info = {}
|
||||
info['Python'] = sys.version.split()[0]
|
||||
info['OS'] = f"{platform.system()} {platform.release()}"
|
||||
info['Architecture'] = platform.machine()
|
||||
info['OpenCV'] = cv2.__version__
|
||||
info['CUDA'] = "Yes" if cv2.cuda.getCudaEnabledDeviceCount() > 0 else "No"
|
||||
info['NumPy'] = np.__version__
|
||||
info['Requests'] = requests.__version__
|
||||
|
||||
# If we are on Linux we display the QTVAR
|
||||
if platform.system() == "Linux":
|
||||
info["XDG_ENVIROMENT_TYPE "] = initQT.getenv(self) # get the stupid env var of qt
|
||||
else:
|
||||
pass
|
||||
|
||||
mem = psutil.virtual_memory()
|
||||
info['MemoryGB'] = mem.total // (1024 ** 3)
|
||||
info['Memory'] = f"{info['MemoryGB']} GB RAM"
|
||||
|
||||
info['CPU Cores'] = psutil.cpu_count(logical=False)
|
||||
info['Logical CPUs'] = psutil.cpu_count(logical=True)
|
||||
info['CPU Usage'] = f"{psutil.cpu_percent()}%"
|
||||
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
info['CPU'] = platform.processor()
|
||||
elif sys.platform == "linux":
|
||||
info['CPU'] = subprocess.check_output("lscpu", shell=True).decode().split("\n")[0]
|
||||
elif sys.platform == "darwin":
|
||||
info['CPU'] = subprocess.check_output(["sysctl", "-n", "machdep.cpu.brand_string"]).decode().strip()
|
||||
except Exception:
|
||||
info['CPU'] = "Unknown"
|
||||
|
||||
return info
|
||||
|
||||
def get_todo_text(self):
|
||||
try:
|
||||
todo_text = self.todo_obj.todo.gettodo()
|
||||
if isinstance(todo_text, str):
|
||||
return todo_text.strip()
|
||||
else:
|
||||
return "Invalid TODO format."
|
||||
except Exception as e:
|
||||
return f"Error retrieving TODO: {e}"
|
||||
|
||||
def get_info_text(self):
|
||||
try:
|
||||
info_text = self.info_obj.todo.getinfo()
|
||||
if isinstance(info_text, str):
|
||||
return info_text.strip()
|
||||
else:
|
||||
return "Invalid"
|
||||
except Exception as e:
|
||||
return f"fuck you => {e}"
|
||||
|
||||
def get_archive_text(self):
|
||||
try:
|
||||
todo_archive_text = self.todo_archive_object.todo.getarchive()
|
||||
if isinstance(todo_archive_text, str):
|
||||
return todo_archive_text.strip()
|
||||
else:
|
||||
return "invalid format??"
|
||||
except Exception as e:
|
||||
return "?? ==> {e}"
|
||||
|
||||
def get_cam_text(self):
|
||||
try:
|
||||
cam_text = self.camobj.todo.getcams()
|
||||
if isinstance(cam_text, str):
|
||||
return cam_text.strip()
|
||||
else:
|
||||
return "invalid cam format"
|
||||
except Exception as e:
|
||||
return f"You are fuck you {e}"
|
||||
@@ -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")
|
||||
|
||||
127
mucapy/CameraDisplay.py
Normal file
127
mucapy/CameraDisplay.py
Normal file
@@ -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)
|
||||
317
mucapy/CameraSelectorDialog.py
Normal file
317
mucapy/CameraSelectorDialog.py
Normal file
@@ -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()
|
||||
85
mucapy/CollpsibleDock.py
Normal file
85
mucapy/CollpsibleDock.py
Normal file
@@ -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
|
||||
|
||||
143
mucapy/NetworkCameraDialog.py
Normal file
143
mucapy/NetworkCameraDialog.py
Normal file
@@ -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()
|
||||
@@ -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)
|
||||
51
mucapy/initqt.py
Normal file
51
mucapy/initqt.py
Normal file
@@ -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
|
||||
1092
mucapy/main.py
1092
mucapy/main.py
File diff suppressed because it is too large
Load Diff
BIN
mucapy/styling/logo.ico
Normal file
BIN
mucapy/styling/logo.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
94
mucapy/utility.py
Normal file
94
mucapy/utility.py
Normal file
@@ -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)
|
||||
@@ -6,3 +6,4 @@ psutil==7.0.0
|
||||
pytest==8.4.0
|
||||
comtypes==1.4.13
|
||||
rtsp==1.1.12
|
||||
#pynvcodec==0.0.6
|
||||
|
||||
Reference in New Issue
Block a user