This commit is contained in:
2025-11-02 15:55:13 +01:00
parent 1300c41172
commit 877dd8ca70
13 changed files with 1561 additions and 1171 deletions

View File

300
mucapy/AboutWindow.py Normal file
View 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}"

View File

@@ -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
View 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)

View 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
View 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

View 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()

View File

@@ -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
View 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

File diff suppressed because it is too large Load Diff

BIN
mucapy/styling/logo.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

94
mucapy/utility.py Normal file
View 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)

View File

@@ -6,3 +6,4 @@ psutil==7.0.0
pytest==8.4.0
comtypes==1.4.13
rtsp==1.1.12
#pynvcodec==0.0.6