diff --git a/.gitignore b/.gitignore index f343b1f..8542791 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,104 @@ -mucapy/seperate/__pycache__/__init__.cpython-313.pyc -mucapy/seperate/__pycache__/AboutWindow.cpython-313.pyc -mucapy/seperate/__pycache__/CameraDisplay.cpython-313.pyc -mucapy/seperate/__pycache__/CollapsibleDock.cpython-313.pyc -mucapy/seperate/__pycache__/Config.cpython-313.pyc -mucapy/seperate/__pycache__/main.cpython-313.pyc -mucapy/seperate/__pycache__/MainWindow.cpython-313.pyc -mucapy/seperate/__pycache__/MultiCamYOLODetector.cpython-313.pyc -mucapy/seperate/__pycache__/NetworkCameraDialog.cpython-313.pyc -mucapy/__pycache__/todo.cpython-313.pyc -mucapy/todopackage/__pycache__/__init__.cpython-313.pyc -mucapy/todopackage/__pycache__/todo.cpython-313.pyc -mucapy/todopackage/__pycache__/__init__.cpython-312.pyc -mucapy/todopackage/__pycache__/todo.cpython-312.pyc +# ============================ +# IDEs and Editors +# ============================ +.idea/ +.vscode/ +*.swp +*.swo + +# ============================ +# Python +# ============================ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual environments +.venv/ +venv/ +env/ +ENV/ +pip-wheel-metadata/ +pip-log.txt + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Jupyter Notebook +.ipynb_checkpoints + +# ============================ +# Logs / runtime / temp files +# ============================ +*.log +*.pid +*.seed +*.out +*.bak +*.tmp +*.temp +*.DS_Store +Thumbs.db + +# ============================ +# System and OS junk +# ============================ +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ +.DS_Store +.Spotlight-V100 +.Trashes +.idea_modules/ + +# ============================ +# Custom project folders +# ============================ +# Ignore project-specific compiled caches or outputs +mucapy/**/__pycache__/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..9f24587 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/material_theme_project_new.xml b/.idea/material_theme_project_new.xml new file mode 100644 index 0000000..260c374 --- /dev/null +++ b/.idea/material_theme_project_new.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..416433d --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..91bf769 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/mucapy.iml b/.idea/mucapy.iml new file mode 100644 index 0000000..d2d9b54 --- /dev/null +++ b/.idea/mucapy.iml @@ -0,0 +1,14 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index d1d2d72..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": "Python Debugger: Current File", - "type": "debugpy", - "request": "launch", - "program": "${file}", - "console": "externalTerminal" - }, - { - "name": "Python Debugger: Python File", - "type": "debugpy", - "request": "launch", - "program": "${file}" - } - ] -} diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 082b194..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "makefile.configureOnOpen": false -} \ No newline at end of file diff --git a/README.md b/README.md index 22c66d4..40da443 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,8 @@ - ⚙️ **Model loader** for dynamic YOLO weight/config/class sets - 🔌 **Network camera support** with authentication - 🖥️ **Hardware monitor** (CPU and per-core utilization via `psutil`) -- 🖼️ Fullscreen camera views & dynamic layout switcher +- 🖼️ Fullscreen/popout camera views with zoom, pan, grid & timestamp overlays, snapshots, and shortcuts - 💾 Persistent **configuration management** -- 🧪 **Camera connectivity test tools** --- @@ -35,6 +34,11 @@ pip install -r requirements.txt ``` +Troubleshooting (Windows): If you see "ImportError: DLL load failed while importing QtCore", +- Uninstall conflicting Qt packages: `pip uninstall -y python-qt5 PySide2 PySide6` +- Reinstall PyQt5: `pip install --upgrade --force-reinstall PyQt5==5.15.11` +- Install Microsoft VC++ x64 runtime: https://aka.ms/vs/17/release/vc_redist.x64.exe +
Dependencies: @@ -116,13 +120,6 @@ Authentication is optional and can be configured per-camera. --- -## 🧪 Camera Test Tools - -- Test connectivity to any selected camera -- Auto-handle reconnection on failures -- Preview all selected feeds with drag-and-drop reordering - ---- ## ⚙️ Configuration & Persistence diff --git a/mucapy/main.py b/mucapy/main.py index d7762ae..65d8c6e 100644 --- a/mucapy/main.py +++ b/mucapy/main.py @@ -1,26 +1,66 @@ -import os -import sys -import cv2 import json +import os +import platform +import subprocess +import sys +import time import urllib.parse + +import cv2 import numpy as np import psutil # Add psutil import -from PyQt5.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout, - QWidget, QLabel, QPushButton, QComboBox, QSpinBox, - QFileDialog, QMessageBox, QMenu, QAction, QMenuBar, - QActionGroup, QSizePolicy, QGridLayout, QGroupBox, - QDockWidget, QScrollArea, QToolButton, QDialog, - QShortcut, QListWidget, QFormLayout, QLineEdit, - QCheckBox, QTabWidget, QListWidgetItem, QSplitter, - QProgressBar) # Add QProgressBar -from PyQt5.QtCore import Qt, QTimer, QDir, QSize, QSettings, QDateTime, QRect, QThread, pyqtSignal, QMutex, QObject -from PyQt5.QtGui import (QImage, QPixmap, QIcon, QColor, QKeySequence, QPainter, - QPen, QBrush) -import platform -import todopackage.todo as todo # This shit will fail eventually | Or not IDK -import time import requests -import subprocess +try: + from PyQt5.QtCore import Qt, QTimer, QDir, QSize, QDateTime, QRect, QThread, pyqtSignal, QMutex, QObject, QEvent + from PyQt5.QtGui import (QImage, QPixmap, QIcon, QColor, QKeySequence, QPainter, + QPen, QBrush) + from PyQt5.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout, + QWidget, QLabel, QPushButton, QComboBox, QSpinBox, + QFileDialog, QMessageBox, QMenu, QAction, QActionGroup, QGridLayout, QGroupBox, + QDockWidget, QScrollArea, QToolButton, QDialog, + QShortcut, QListWidget, QFormLayout, QLineEdit, + QCheckBox, QTabWidget, QListWidgetItem, QSplitter, + QProgressBar, QSizePolicy) # Add QProgressBar and QSizePolicy +except ImportError as e: + print("Failed to import PyQt5 (QtCore DLL load error).\n" + "Common causes on Windows:\n" + "- Conflicting Qt packages installed (e.g., 'python-qt5' alongside 'PyQt5').\n" + "- Missing Microsoft Visual C++ Redistributable (x64).\n" + "- Incomplete or corrupted PyQt5 installation in the virtualenv.\n\n" + "How to fix:\n" + "1) Uninstall conflicting Qt packages: pip uninstall -y python-qt5 PySide2 PySide6\n" + "2) Reinstall PyQt5 wheels: pip install --upgrade --force-reinstall PyQt5==5.15.11\n" + "3) Install VC++ runtime: https://aka.ms/vs/17/release/vc_redist.x64.exe\n" + "4) Restart the app after reinstalling.\n\n" + f"Original error: {e}") + sys.exit(1) + +import todopackage.todo as todo # This shit will fail eventually | Or not IDK + + +def bytes_to_human(n: int) -> str: + """Convert a byte value to a human-readable string using base 1024. + Examples: 1536 -> '1.5 MiB' + """ + try: + n = int(n) + except Exception: + return str(n) + symbols = ("B", "KiB", "MiB", "GiB", "TiB", "PiB") + if n < 1024: + return f"{n} B" + i = 0 + val = float(n) + while val >= 1024.0 and i < len(symbols) - 1: + val /= 1024.0 + i += 1 + # Fewer decimals for larger numbers + if val >= 100: + return f"{val:.0f} {symbols[i]}" + if val >= 10: + return f"{val:.1f} {symbols[i]}" + return f"{val:.2f} {symbols[i]}" + class getpath: def resource_path(relative_path): @@ -310,7 +350,28 @@ class CameraThread(QThread): if self.cap: self.cap.release() self.running = False +class CameraScanThread(QThread): + scan_finished = pyqtSignal(list, dict) + def __init__(self, detector, max_to_check=10, parent=None): + super().__init__(parent) + self.detector = detector + self.max_to_check = max_to_check + def run(self): + try: + cams = self.detector.scan_for_cameras(self.max_to_check) + names = {} + if sys.platform.startswith('win'): + try: + names = self.detector.get_camera_names_windows(cams) + except Exception as e: + print(f"Failed to get Windows camera names: {e}") + names = {} + self.scan_finished.emit(cams, names) + except Exception as e: + print(f"CameraScanThread error: {e}") + self.scan_finished.emit([], {}) class MultiCamYOLODetector(QObject): + cameras_scanned = pyqtSignal(list, dict) # Emits (available_cameras, index_to_name) def __init__(self, parent=None): super().__init__(parent) self.cameras = [] @@ -327,6 +388,8 @@ class MultiCamYOLODetector(QObject): self.config = Config() self.latest_frames = {} # Store latest frames from each camera self.frame_lock = QMutex() # Mutex for thread-safe frame access + self.scan_thread = None # Background scanner thread + self.camera_names = {} # Mapping index->friendly name (best effort) # Load settings self.confidence_threshold = self.config.load_setting('confidence_threshold', 0.35) @@ -358,39 +421,137 @@ class MultiCamYOLODetector(QObject): del self.network_cameras[name] self.config.save_setting('network_cameras', self.network_cameras) + def get_platform_backend(self): + """Get appropriate video capture backend for current platform""" + try: + if sys.platform.startswith('win'): + return cv2.CAP_DSHOW + elif sys.platform.startswith('darwin'): + return cv2.CAP_AVFOUNDATION + else: + return cv2.CAP_V4L2 + except Exception: + # Fallback to auto-detect if constants are missing + return cv2.CAP_ANY + + def get_camera_names_windows(self, cams): + """Best-effort map of camera index -> device friendly name on Windows.""" + names = {} + try: + # Query camera-like PnP devices + ps_cmd = ( + "Get-CimInstance Win32_PnPEntity | " + "Where-Object { $_.PNPClass -in @('Camera','Image') -and $_.Status -eq 'OK' } | " + "Select-Object -ExpandProperty Name" + ) + result = subprocess.run([ + 'powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', ps_cmd + ], capture_output=True, text=True, timeout=5) + lines = [l.strip() for l in result.stdout.splitlines() if l.strip()] + # Assign names in order to numeric indices + idx_only = [c for c in cams if not c.startswith('net:') and not c.startswith('/dev/')] + for i, cam in enumerate(idx_only): + try: + names[cam] = lines[i] if i < len(lines) else None + except Exception: + names[cam] = None + except Exception as e: + print(f"get_camera_names_windows failed: {e}") + return names + + def start_camera_scan(self, max_to_check=10): + """Start background camera scan; emits cameras_scanned when done.""" + try: + if self.scan_thread and self.scan_thread.isRunning(): + # Already scanning; ignore + return False + self.scan_thread = CameraScanThread(self, max_to_check) + self.scan_thread.scan_finished.connect(self._on_scan_finished) + self.scan_thread.start() + return True + except Exception as e: + print(f"Failed to start camera scan: {e}") + return False + + def _on_scan_finished(self, cams, names): + # Store and forward via public signal + self.available_cameras = cams or [] + self.camera_names = names or {} + self.cameras_scanned.emit(self.available_cameras, self.camera_names) + + def scan_for_cameras_windows(self, max_to_check=10): + """Enhanced camera detection for Windows with multiple backend support""" + windows_cameras = [] + backends_to_try = [ + (cv2.CAP_DSHOW, "DSHOW"), + (cv2.CAP_MSMF, "MSMF"), + (cv2.CAP_ANY, "ANY") + ] + for backend, backend_name in backends_to_try: + print(f"Trying {backend_name} backend...") + for i in range(max_to_check): + try: + cap = cv2.VideoCapture(i, backend) + if cap.isOpened(): + ret, frame = cap.read() + if ret and frame is not None: + camera_id = f"{backend_name.lower()}:{i}" + if str(i) not in windows_cameras: + windows_cameras.append(str(i)) + print(f"Found camera {i} via {backend_name}") + cap.release() + else: + cap.release() + except Exception as e: + print(f"Error checking camera {i} with {backend_name}: {e}") + continue + return windows_cameras + def scan_for_cameras(self, max_to_check=10): - """Check for available cameras including network cameras""" + """Check for available cameras with platform-specific backends""" self.available_cameras = [] - # Check standard video devices - for i in range(max_to_check): - try: - cap = cv2.VideoCapture(i, cv2.CAP_V4L2) - if cap.isOpened(): - self.available_cameras.append(str(i)) - cap.release() - except: - continue + print(f"Scanning for cameras on {sys.platform}...") - # Check direct device paths - v4l_paths = [f"/dev/video{i}" for i in range(max_to_check)] - v4l_paths += [f"/dev/v4l/video{i}" for i in range(max_to_check)] - - for path in v4l_paths: - if os.path.exists(path): + # Platform-specific detection + if sys.platform.startswith('win'): + cameras_found = self.scan_for_cameras_windows(max_to_check) + self.available_cameras.extend(cameras_found) + else: + # Linux/Unix/macOS detection + backend = cv2.CAP_AVFOUNDATION if sys.platform.startswith('darwin') else cv2.CAP_V4L2 + for i in range(max_to_check): try: - cap = cv2.VideoCapture(path, cv2.CAP_V4L2) + cap = cv2.VideoCapture(i, backend) if cap.isOpened(): - if path not in self.available_cameras: - self.available_cameras.append(path) + ret, frame = cap.read() + if ret and frame is not None: + self.available_cameras.append(str(i)) cap.release() - except: + except Exception as e: + print(f"Error checking camera {i}: {e}") continue + + # Linux device paths + if sys.platform.startswith('linux'): + v4l_paths = [f"/dev/video{i}" for i in range(max_to_check)] + for path in v4l_paths: + if os.path.exists(path): + try: + cap = cv2.VideoCapture(path, cv2.CAP_V4L2) + if cap.isOpened() and path not in self.available_cameras: + self.available_cameras.append(path) + cap.release() + except Exception as e: + print(f"Error checking device {path}: {e}") - # Add saved network cameras + # Add network cameras + network_count = 0 for name, url in self.network_cameras.items(): self.available_cameras.append(f"net:{name}") + network_count += 1 + print(f"Scan complete: Found {len(self.available_cameras) - network_count} local and {network_count} network cameras") return self.available_cameras def load_yolo_model(self, model_dir): @@ -439,77 +600,90 @@ class MultiCamYOLODetector(QObject): return False def connect_cameras(self, camera_paths): - """Connect to multiple cameras including network cameras""" + """Connect to multiple cameras using background threads for smooth UI""" self.disconnect_cameras() - for cam_path in camera_paths: - try: - if isinstance(cam_path, str): - if cam_path.startswith('net:'): - # Handle network camera - name = cam_path[4:] # Remove 'net:' prefix - if name in self.network_cameras: - url = self.network_cameras[name] - print(f"Connecting to network camera URL: {url}") # Debug print - cap = cv2.VideoCapture(url, cv2.CAP_ANY) # Use CAP_ANY for network streams - else: - print(f"Network camera {name} not found in saved cameras") - continue - elif cam_path.startswith('/dev/'): - # Handle device path - cap = cv2.VideoCapture(cam_path, cv2.CAP_V4L2) - else: - # Handle numeric index - cap = cv2.VideoCapture(int(cam_path), cv2.CAP_V4L2) - else: - cap = cv2.VideoCapture(int(cam_path), cv2.CAP_V4L2) - - if not cap.isOpened(): - print(f"Warning: Could not open camera {cam_path}") - continue - - # Try to set properties but continue if they fail - try: - cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) - cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) - cap.set(cv2.CAP_PROP_FPS, self.target_fps) - cap.set(cv2.CAP_PROP_BUFFERSIZE, 2) - except: - pass - - self.cameras.append((cam_path, cap)) - except Exception as e: - print(f"Error opening camera {cam_path}: {e}") + # Prepare internal state + self.cameras = [] # store identifiers/paths only + self.latest_frames = {} - return len(self.cameras) > 0 + # Start one CameraThread per camera + for cam_index, cam_path in enumerate(camera_paths): + try: + thread = CameraThread(cam_index, cam_path, parent=self.parent()) + thread.set_fps(self.target_fps) + thread.frame_ready.connect(self._on_frame_ready) + thread.error_occurred.connect(self._on_camera_error) + self.camera_threads[cam_index] = thread + self.cameras.append(cam_path) + self.latest_frames[cam_index] = None + thread.start() + print(f"Started camera thread for {cam_path}") + except Exception as e: + print(f"Error starting camera thread for {cam_path}: {e}") + + success_count = len(self.camera_threads) + print(f"Camera connection summary: {success_count}/{len(camera_paths)} camera threads started") + return success_count > 0 def disconnect_cameras(self): - """Disconnect all cameras""" - for _, cam in self.cameras: + """Disconnect all cameras (stop threads)""" + # Stop and remove threads + for idx, thread in list(self.camera_threads.items()): try: - cam.release() - except: + thread.stop() + except Exception: pass + try: + thread.deleteLater() + except Exception: + pass + self.camera_threads.clear() self.cameras = [] + # Clear cached frames + self.frame_lock.lock() + try: + self.latest_frames = {} + finally: + self.frame_lock.unlock() + + def _on_frame_ready(self, cam_id, frame): + """Cache latest frame from a camera thread (non-blocking for UI).""" + self.frame_lock.lock() + try: + # Store a copy to avoid data races if producer reuses buffers + self.latest_frames[cam_id] = frame.copy() + finally: + self.frame_lock.unlock() + + def _on_camera_error(self, cam_id, message): + print(f"Camera {cam_id} error: {message}") def get_frames(self): - """Get frames from all cameras with error handling""" + """Return latest frames without blocking the GUI thread.""" frames = [] - for i, (cam_path, cam) in enumerate(self.cameras): - try: - ret, frame = cam.read() - if not ret: - print(f"Warning: Could not read frame from camera {cam_path}") - frame = np.zeros((720, 1280, 3), dtype=np.uint8) + # Snapshot current frames under lock + self.frame_lock.lock() + try: + for i, _ in enumerate(self.cameras): + frm = self.latest_frames.get(i) + if frm is None: + frames.append(np.zeros((720, 1280, 3), dtype=np.uint8)) else: - # Only perform detection if net is loaded and detection is requested - parent_window = self.parent() - if parent_window and self.net is not None and parent_window.detection_enabled: - frame = self.get_detections(frame) - frames.append(frame) - except: - frame = np.zeros((720, 1280, 3), dtype=np.uint8) - frames.append(frame) + frames.append(frm.copy()) + finally: + self.frame_lock.unlock() + + # Optionally run detection on the copies + parent_window = self.parent() + if parent_window and self.net is not None and parent_window.detection_enabled: + processed = [] + for f in frames: + try: + processed.append(self.get_detections(f)) + except Exception: + processed.append(f) + return processed return frames @@ -565,6 +739,245 @@ class MultiCamYOLODetector(QObject): print(f"Detection error: {e}") return frame +class PopoutWindow(QMainWindow): + """Enhanced popout window with zoom, pan, overlays and guard-friendly controls""" + 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.cam_id = cam_id + self.zoom_factor = 1.0 + self.min_zoom = 0.2 + self.max_zoom = 5.0 + self.paused = False + self.show_grid = False + self.show_timestamp = True + self.setMinimumSize(640, 480) + # Drag-to-pan state + self.dragging = False + self.last_mouse_pos = None + + # Central area: toolbar + scrollable image label + central = QWidget() + vbox = QVBoxLayout(central) + vbox.setContentsMargins(4, 4, 4, 4) + vbox.setSpacing(4) + + # Toolbar with guard-friendly controls + toolbar = QHBoxLayout() + self.btn_zoom_in = QToolButton() + self.btn_zoom_in.setText("+") + self.btn_zoom_out = QToolButton() + self.btn_zoom_out.setText("-") + self.btn_zoom_reset = QToolButton() + self.btn_zoom_reset.setText("100%") + self.btn_pause = QToolButton() + self.btn_pause.setText("Pause") + self.btn_snapshot = QToolButton() + self.btn_snapshot.setText("Snapshot") + self.btn_grid = QToolButton() + self.btn_grid.setText("Grid") + self.btn_time = QToolButton() + self.btn_time.setText("Time") + self.btn_full = QToolButton() + self.btn_full.setText("Fullscreen") + + 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]: + toolbar.addWidget(b) + toolbar.addStretch(1) + vbox.addLayout(toolbar) + + # Scroll area for panning when zoomed + self.image_label = QLabel() + self.image_label.setAlignment(Qt.AlignCenter) + self.scroll = QScrollArea() + self.scroll.setWidget(self.image_label) + self.scroll.setWidgetResizable(True) + vbox.addWidget(self.scroll, 1) + + self.setCentralWidget(central) + + # Shortcuts + QShortcut(QKeySequence("+"), self, activated=self.zoom_in) + QShortcut(QKeySequence("-"), self, activated=self.zoom_out) + QShortcut(QKeySequence("0"), self, activated=self.reset_zoom) + QShortcut(QKeySequence(Qt.Key_Escape), self, activated=self.close) + QShortcut(QKeySequence("F"), self, activated=self.toggle_fullscreen) + QShortcut(QKeySequence("Ctrl+S"), self, activated=self.take_snapshot) + QShortcut(QKeySequence("Space"), self, activated=self.toggle_pause) + QShortcut(QKeySequence("G"), self, activated=self.toggle_grid) + QShortcut(QKeySequence("T"), self, activated=self.toggle_timestamp) + + # Connect buttons + self.btn_zoom_in.clicked.connect(self.zoom_in) + self.btn_zoom_out.clicked.connect(self.zoom_out) + self.btn_zoom_reset.clicked.connect(self.reset_zoom) + self.btn_pause.clicked.connect(self.toggle_pause) + 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_full.clicked.connect(self.toggle_fullscreen) + + # Timer to refresh from source display + self.timer = QTimer(self) + self.timer.timeout.connect(self.refresh_frame) + self.timer.start(40) + + # Mouse wheel zoom support + self.image_label.installEventFilter(self) + + # Initial render + self.refresh_frame() + + def closeEvent(self, event): + if hasattr(self, 'timer') and self.timer: + self.timer.stop() + return super().closeEvent(event) + + def toggle_fullscreen(self): + if self.isFullScreen(): + self.showNormal() + self.btn_full.setText("Fullscreen") + else: + self.showFullScreen() + self.btn_full.setText("Windowed") + + def toggle_pause(self): + self.paused = not self.paused + self.btn_pause.setText("Resume" if self.paused else "Pause") + + def toggle_grid(self): + self.show_grid = not self.show_grid + + def toggle_timestamp(self): + self.show_timestamp = not self.show_timestamp + + def take_snapshot(self): + # Prefer using source_display method if available + if hasattr(self.source_display, 'take_screenshot'): + self.source_display.take_screenshot() + return + + def current_pixmap(self): + pm = self.source_display.pixmap() + return pm + + def refresh_frame(self): + if self.paused: + return + pm = self.current_pixmap() + if not pm: + 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) + + # 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() + + 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() + + def zoom_in(self): + self.set_zoom(self.zoom_factor * 1.2) + + def zoom_out(self): + self.set_zoom(self.zoom_factor / 1.2) + + def reset_zoom(self): + self.set_zoom(1.0) + + def set_zoom(self, z): + z = max(self.min_zoom, min(self.max_zoom, z)) + if abs(z - self.zoom_factor) > 1e-4: + self.zoom_factor = z + self.refresh_frame() + 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() + pm = self.image_label.pixmap().size() + return pm.width() > vp.width() or pm.height() > vp.height() + + def update_cursor(self): + if self.can_pan(): + self.image_label.setCursor(Qt.OpenHandCursor if not self.dragging 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 + 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 + self.update_cursor() + return super().eventFilter(obj, event) + class CameraDisplay(QLabel): """Custom QLabel for displaying camera feed with fullscreen support""" def __init__(self, parent=None): @@ -640,52 +1053,19 @@ class CameraDisplay(QLabel): self.close_fullscreen() def show_fullscreen(self): - """Show this camera in a new window""" + """Show this camera in a new window (enhanced popout)""" if not self.pixmap(): return - - self.fullscreen_window = QMainWindow(self.window()) - self.fullscreen_window.setWindowTitle(f"Camera {self.cam_id}") - - # Create central widget - central_widget = QWidget() - layout = QVBoxLayout(central_widget) - - # Create fullscreen label - self.fullscreen_label = QLabel() - self.fullscreen_label.setAlignment(Qt.AlignCenter) - self.fullscreen_label.setMinimumSize(640, 480) # Set minimum size - self.fullscreen_label.setPixmap(self.pixmap().scaled( - QSize(1280, 720), # Default HD size - Qt.KeepAspectRatio, - Qt.SmoothTransformation - )) - layout.addWidget(self.fullscreen_label) - - self.fullscreen_window.setCentralWidget(central_widget) - - # Add ESC shortcut to close - shortcut = QShortcut(QKeySequence(Qt.Key_Escape), self.fullscreen_window) - shortcut.activated.connect(self.close_fullscreen) - - # Add screenshot shortcut (Ctrl+S) - screenshot_shortcut = QShortcut(QKeySequence("Ctrl+S"), self.fullscreen_window) - screenshot_shortcut.activated.connect(self.take_screenshot) - - # Update fullscreen image when main window updates - self.fullscreen_timer = QTimer() - self.fullscreen_timer.timeout.connect( - lambda: self.update_fullscreen(self.fullscreen_label) - ) - self.fullscreen_timer.start(30) - - # Set window size and show + # 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, screen.width() * 0.8), min(720, screen.height() * 0.8)) + 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): - """Update the fullscreen display""" + """Kept for backward compatibility; PopoutWindow manages its own refresh.""" if self.pixmap(): label.setPixmap(self.pixmap().scaled( label.size(), @@ -696,11 +1076,8 @@ class CameraDisplay(QLabel): def close_fullscreen(self): """Close the fullscreen window""" if self.fullscreen_window: - if self.fullscreen_timer: - self.fullscreen_timer.stop() self.fullscreen_window.close() self.fullscreen_window = None - self.fullscreen_timer = None def paintEvent(self, event): """Override paint event to draw camera name overlay""" @@ -722,15 +1099,23 @@ 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) + 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")) @@ -745,6 +1130,7 @@ class CollapsibleDock(QDockWidget): 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""" @@ -754,26 +1140,36 @@ class CollapsibleDock(QDockWidget): self.collapse() def collapse(self): - """Collapse the dock widget""" + """Collapse the dock widget (fully hide).""" if not self.collapsed: self.original_size = self.size() self.original_minimum_width = self.minimumWidth() - self.setMinimumWidth(0) - self.setMaximumWidth(0) + 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 the dock widget""" + """Expand (show) the dock widget""" if self.collapsed: - self.setMinimumWidth(250) - self.setMaximumWidth(16777215) # Qt default maximum + # Restore previous constraints, falling back to sensible defaults + minw = self.original_minimum_width if self.original_minimum_width is not None else 250 + self.setMinimumWidth(minw) + self.setMaximumWidth(self.original_maximum_width if self.original_maximum_width is not None else 16777215) + # Show and restore size + self.setVisible(True) if self.original_size: self.resize(self.original_size) + else: + self.resize(max(minw, 250), self.height()) + # Make sure the dock is on top of central widgets + self.raise_() self.toggle_button.setIcon(QIcon.fromTheme("arrow-left")) self.collapsed = False class AboutWindow(QDialog): def __init__(self, parent=None): + global todo_style_path super().__init__(parent) self.setWindowTitle("About Multi-Camera YOLO Detection") self.setWindowIcon(QIcon.fromTheme("help-about")) @@ -1190,7 +1586,8 @@ class CameraSelectorDialog(QDialog): super().__init__(parent) self.setWindowTitle("Camera Selector") self.setModal(True) - self.resize(800, 600) # Increased size for better visibility + self.resize(900, 650) # Increased size for better visibility + self.setSizeGripEnabled(True) self.detector = parent.detector if parent else None self.selected_cameras = [] @@ -1199,7 +1596,7 @@ class CameraSelectorDialog(QDialog): layout = QVBoxLayout(self) # Instructions with better formatting - instructions = QLabel(todo.todo.get_instructions_CaSeDi_QLabel(str())) + instructions = QLabel(todo.todo.get_instructions_CaSeDi_QLabel()) print(todo.todo.get_instructions_CaSeDi_QLabel()) instructions.setStyleSheet("QLabel { background-color: #2A2A2A; padding: 10px; border-radius: 4px; }") @@ -1208,45 +1605,59 @@ class CameraSelectorDialog(QDialog): # 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() - refresh_btn = QPushButton("Refresh") - refresh_btn.clicked.connect(self.refresh_cameras) + 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(refresh_btn) + 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;") @@ -1255,6 +1666,7 @@ class CameraSelectorDialog(QDialog): 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 @@ -1268,22 +1680,25 @@ class CameraSelectorDialog(QDialog): 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) - test_selected_btn = QPushButton("Test Selected") - test_selected_btn.clicked.connect(self.test_selected_cameras) 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.addWidget(test_selected_btn) bottom_layout.addStretch() bottom_layout.addWidget(ok_btn) bottom_layout.addWidget(cancel_btn) @@ -1295,7 +1710,7 @@ class CameraSelectorDialog(QDialog): self.preview_list.model().rowsMoved.connect(self.update_camera_order) # Set splitter sizes - splitter.setSizes([400, 400]) + splitter.setSizes([450, 450]) # Initial camera refresh self.refresh_cameras() @@ -1307,45 +1722,59 @@ class CameraSelectorDialog(QDialog): self.restore_selection(last_selected) def refresh_cameras(self): - """Refresh both local and network camera lists""" + """Refresh both local and network camera lists asynchronously""" self.local_list.clear() self.network_list.clear() if not self.detector: return - # Add local cameras - for i in range(10): # Check first 10 indices - try: - cap = cv2.VideoCapture(i) - if cap.isOpened(): - item = QListWidgetItem(f"Camera {i}") - item.setData(Qt.UserRole, str(i)) - item.setFlags(item.flags() | Qt.ItemIsUserCheckable) - item.setCheckState(Qt.Unchecked) - self.local_list.addItem(item) - cap.release() - except Exception as e: - print(f"Error checking camera {i}: {e}") + # 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) - # Check device paths - if os.path.exists('/dev'): - for i in range(10): - device_path = f"/dev/video{i}" - if os.path.exists(device_path): - try: - cap = cv2.VideoCapture(device_path) - if cap.isOpened(): - item = QListWidgetItem(f"{os.path.basename(device_path)}") - item.setData(Qt.UserRole, device_path) - item.setFlags(item.flags() | Qt.ItemIsUserCheckable) - item.setCheckState(Qt.Unchecked) - self.local_list.addItem(item) - cap.release() - except Exception as e: - print(f"Error checking device {device_path}: {e}") + # 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 - # Add network cameras + # 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', '') @@ -1355,7 +1784,6 @@ class CameraSelectorDialog(QDialog): 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) @@ -1387,7 +1815,7 @@ class CameraSelectorDialog(QDialog): item = self.local_list.item(i) if item.checkState() == Qt.Checked: cam_id = item.data(Qt.UserRole) - preview_item = QListWidgetItem(f"📷 {item.text()}") + 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) @@ -1397,7 +1825,7 @@ class CameraSelectorDialog(QDialog): item = self.network_list.item(i) if item.checkState() == Qt.Checked: cam_id = item.data(Qt.UserRole) - preview_item = QListWidgetItem(f"🌐 {item.text()}") + 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) @@ -1444,64 +1872,11 @@ class CameraSelectorDialog(QDialog): 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): - """Test connection to selected cameras""" - selected_cameras = [] - for i in range(self.preview_list.count()): - selected_cameras.append(self.preview_list.item(i).data(Qt.UserRole)) - - if not selected_cameras: - QMessageBox.warning(self, "Warning", "No cameras selected to test!") - return - - # Create progress dialog - progress = QMessageBox(self) - progress.setIcon(QMessageBox.Information) - progress.setWindowTitle("Testing Cameras") - progress.setText("Testing camera connections...\nThis may take a few seconds.") - progress.setStandardButtons(QMessageBox.NoButton) - progress.show() - QApplication.processEvents() - - # Test each camera - results = [] - for cam_id in selected_cameras: - try: - if cam_id.startswith('net:'): - name = cam_id[4:] - camera_info = self.detector.network_cameras.get(name) - if isinstance(camera_info, dict): - url = camera_info['url'] - if 'username' in camera_info and 'password' in camera_info: - parsed = urllib.parse.urlparse(url) - netloc = f"{camera_info['username']}:{camera_info['password']}@{parsed.netloc}" - url = parsed._replace(netloc=netloc).geturl() - else: - url = camera_info - cap = cv2.VideoCapture(url) - else: - cap = cv2.VideoCapture(int(cam_id) if cam_id.isdigit() else cam_id) - - if cap.isOpened(): - ret, frame = cap.read() - if ret: - results.append(f"✅ {cam_id}: Connection successful") - else: - results.append(f"⚠️ {cam_id}: Connected but no frame received") - else: - results.append(f"❌ {cam_id}: Failed to connect") - cap.release() - except Exception as e: - results.append(f"❌ {cam_id}: Error - {str(e)}") - - progress.close() - - # Show results - result_dialog = QMessageBox(self) - result_dialog.setWindowTitle("Camera Test Results") - result_dialog.setText("\n".join(results)) - result_dialog.setIcon(QMessageBox.Information) - result_dialog.exec_() + """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""" @@ -1698,9 +2073,15 @@ class MainWindow(QMainWindow): # Initial population self.populate_camera_menu() + + # Sync sidebar toggle label/state with current visibility + try: + self._on_sidebar_visibility_changed(self.sidebar.isVisible()) + except Exception: + pass def populate_camera_menu(self): - """Populate the camera menu with available cameras""" + """Populate the camera menu with available cameras asynchronously""" # Clear existing camera actions self.local_camera_menu.clear() self.network_camera_menu.clear() @@ -1711,7 +2092,33 @@ class MainWindow(QMainWindow): self.local_camera_menu.addAction(refresh_action) self.local_camera_menu.addSeparator() - available_cams = self.detector.scan_for_cameras() + # Show scanning placeholders + scanning_local = QAction('Scanning...', self) + scanning_local.setEnabled(False) + self.local_camera_menu.addAction(scanning_local) + scanning_net = QAction('Loading network cameras...', self) + scanning_net.setEnabled(False) + self.network_camera_menu.addAction(scanning_net) + + # Start background scan + started = self.detector.start_camera_scan(10) + # Connect handler to build menus on completion + try: + self.detector.cameras_scanned.disconnect(self._on_cameras_scanned_menu) + except Exception: + pass + self.detector.cameras_scanned.connect(self._on_cameras_scanned_menu) + + def _on_cameras_scanned_menu(self, available_cams, names): + # Rebuild menus with results + self.local_camera_menu.clear() + self.network_camera_menu.clear() + + refresh_action = QAction('Refresh List', self) + refresh_action.triggered.connect(self.populate_camera_menu) + self.local_camera_menu.addAction(refresh_action) + self.local_camera_menu.addSeparator() + local_cams_found = False network_cams_found = False @@ -1730,8 +2137,8 @@ class MainWindow(QMainWindow): if cam_path.startswith('/dev/'): display_name = os.path.basename(cam_path) else: - display_name = f"Camera {cam_path}" - + pretty = names.get(cam_path) + display_name = f"{pretty} (#{cam_path})" if pretty else f"Camera {cam_path}" action = QAction(display_name, self) action.setCheckable(True) action.setData(cam_path) @@ -1773,11 +2180,7 @@ class MainWindow(QMainWindow): self.cameras_label.setText("Selected Cameras: None") def start_detection(self): - """Start the detection process""" - if not self.detector.model_dir: - QMessageBox.critical(self, "Error", "No model directory selected!") - return - + """Start the detection process (camera feed can run without a model)""" # Get selected cameras selected_cameras = [] @@ -1814,103 +2217,23 @@ class MainWindow(QMainWindow): self.timer.start(int(1000 / self.detector.target_fps)) def show_camera_selector(self): - """Show a simplified camera selector dialog""" - dialog = QDialog(self) - dialog.setWindowTitle("Select Cameras") - dialog.setModal(True) - layout = QVBoxLayout(dialog) - - # Create tabs for different camera types - tabs = QTabWidget() - local_tab = QWidget() - network_tab = QWidget() - - # Local cameras tab - local_layout = QVBoxLayout(local_tab) - local_list = QListWidget() - local_layout.addWidget(QLabel("Available Local Cameras:")) - local_layout.addWidget(local_list) - - # Network cameras tab - network_layout = QVBoxLayout(network_tab) - network_list = QListWidget() - network_layout.addWidget(QLabel("Available Network Cameras:")) - network_layout.addWidget(network_list) - - # Add tabs - tabs.addTab(local_tab, "Local Cameras") - tabs.addTab(network_tab, "Network Cameras") - layout.addWidget(tabs) - - # Populate lists - available_cams = self.detector.scan_for_cameras() - for cam_path in available_cams: - if cam_path.startswith('net:'): - name = cam_path[4:] - item = QListWidgetItem(name) - item.setData(Qt.UserRole, cam_path) - item.setFlags(item.flags() | Qt.ItemIsUserCheckable) - item.setCheckState(Qt.Unchecked) - network_list.addItem(item) - else: - display_name = os.path.basename(cam_path) if cam_path.startswith('/dev/') else f"Camera {cam_path}" - item = QListWidgetItem(display_name) - item.setData(Qt.UserRole, cam_path) - item.setFlags(item.flags() | Qt.ItemIsUserCheckable) - item.setCheckState(Qt.Unchecked) - local_list.addItem(item) - - # Check currently selected cameras - for action in self.local_camera_group.actions(): - if action.isChecked(): - for i in range(local_list.count()): - item = local_list.item(i) - if item.data(Qt.UserRole) == action.data(): - item.setCheckState(Qt.Checked) - - for action in self.network_camera_group.actions(): - if action.isChecked(): - for i in range(network_list.count()): - item = network_list.item(i) - if item.data(Qt.UserRole) == action.data(): - item.setCheckState(Qt.Checked) - - # Buttons - btn_layout = QHBoxLayout() - ok_btn = QPushButton("OK") - cancel_btn = QPushButton("Cancel") - btn_layout.addWidget(ok_btn) - btn_layout.addWidget(cancel_btn) - layout.addLayout(btn_layout) - - ok_btn.clicked.connect(dialog.accept) - cancel_btn.clicked.connect(dialog.reject) - + """Show the advanced CameraSelectorDialog (async scanning)""" + dialog = CameraSelectorDialog(self) if dialog.exec_() == QDialog.Accepted: - # Update camera selections + # Apply the selection saved by the dialog to the menu actions + selected = self.detector.config.load_setting('last_selected_cameras', []) or [] + # Uncheck all first for action in self.local_camera_group.actions(): action.setChecked(False) for action in self.network_camera_group.actions(): action.setChecked(False) - - # Update local camera selections - for i in range(local_list.count()): - item = local_list.item(i) - if item.checkState() == Qt.Checked: - cam_path = item.data(Qt.UserRole) - for action in self.local_camera_group.actions(): - if action.data() == cam_path: - action.setChecked(True) - - # Update network camera selections - for i in range(network_list.count()): - item = network_list.item(i) - if item.checkState() == Qt.Checked: - cam_path = item.data(Qt.UserRole) - for action in self.network_camera_group.actions(): - if action.data() == cam_path: - action.setChecked(True) - + # Re-apply + for action in self.local_camera_group.actions(): + if action.data() in selected: + action.setChecked(True) + for action in self.network_camera_group.actions(): + if action.data() in selected: + action.setChecked(True) self.update_selection_labels() def load_model_directory(self): @@ -1938,7 +2261,15 @@ class MainWindow(QMainWindow): # Create collapsible sidebar self.sidebar = CollapsibleDock("Controls") + # Constrain sidebar width to prevent overexpansion from long labels/content self.sidebar.setMinimumWidth(250) + self.sidebar.setMaximumWidth(400) + # Keep View menu toggle in sync with actual visibility + try: + self.sidebar.visibilityChanged.disconnect() + except Exception: + pass + self.sidebar.visibilityChanged.connect(self._on_sidebar_visibility_changed) # Sidebar content sidebar_content = QWidget() @@ -1963,6 +2294,8 @@ class MainWindow(QMainWindow): camera_layout = QVBoxLayout() self.cameras_label = QLabel("Selected Cameras: None") + self.cameras_label.setWordWrap(True) + self.cameras_label.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum) camera_layout.addWidget(self.cameras_label) refresh_cams_btn = QPushButton("Refresh Camera List") @@ -2025,6 +2358,8 @@ class MainWindow(QMainWindow): scroll.setWidget(sidebar_content) scroll.setWidgetResizable(True) scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + # Ensure scroll area doesn't request excessive width + scroll.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) self.sidebar.setWidget(scroll) self.addDockWidget(Qt.LeftDockWidgetArea, self.sidebar) @@ -2067,6 +2402,21 @@ class MainWindow(QMainWindow): cpu_layout.addWidget(self.cpu_progress) hw_monitor_layout.addLayout(cpu_layout) + # Python Memory Usage + mem_layout = QHBoxLayout() + mem_layout.addWidget(QLabel("Python Memory:")) + self.mem_progress = QProgressBar() + self.mem_progress.setRange(0, 100) + self.mem_progress.setTextVisible(True) + self.mem_progress.setFormat("%p%") + try: + with open(style_file, "r") as mem_style: + self.mem_progress.setStyleSheet(mem_style.read()) + except FileNotFoundError: + pass + mem_layout.addWidget(self.mem_progress) + hw_monitor_layout.addLayout(mem_layout) + # Per-core CPU Usage cores_layout = QGridLayout() self.core_bars = [] @@ -2290,11 +2640,31 @@ class MainWindow(QMainWindow): QMessageBox.warning(self, "Warning", f"Could not open directory: {str(e)}") def toggle_sidebar_visibility(self): - """Toggle the visibility of the sidebar""" - if self.toggle_sidebar_action.isChecked(): + """Toggle the visibility of the sidebar, fully hiding when minimized.""" + # Determine target state from action or current visibility + target_show = self.toggle_sidebar_action.isChecked() + if target_show: self.sidebar.expand() + # Ensure z-order so it won't clip behind camera panes + self.sidebar.raise_() + self.toggle_sidebar_action.setText('Hide Sidebar') + self.toggle_sidebar_action.setChecked(True) else: self.sidebar.collapse() + self.toggle_sidebar_action.setText('Show Sidebar') + self.toggle_sidebar_action.setChecked(False) + + def _on_sidebar_visibility_changed(self, visible): + """Keep menu action in sync with actual sidebar visibility.""" + if hasattr(self, 'toggle_sidebar_action'): + self.toggle_sidebar_action.setChecked(visible) + self.toggle_sidebar_action.setText('Hide Sidebar' if visible else 'Show Sidebar') + # If becoming visible, bring to front to avoid any overlap issues + if visible: + try: + self.sidebar.raise_() + except Exception: + pass def update_hardware_stats(self): """Update hardware statistics""" @@ -2308,8 +2678,30 @@ class MainWindow(QMainWindow): if i < len(self.core_bars): self.core_bars[i].setValue(int(usage)) + # Update Python process memory usage and show relative to available system RAM + try: + proc = psutil.Process(os.getpid()) + rss = proc.memory_info().rss # bytes used by current Python process (Resident Set Size) + vm = psutil.virtual_memory() + available = vm.available # bytes available to processes without swapping + total_ram = vm.total + # Calculate percent of available memory currently occupied by this process + mem_percent = int(min((rss / available) * 100, 100)) if available else 0 + if hasattr(self, 'mem_progress'): + # Update bar value and dynamic text + self.mem_progress.setValue(mem_percent) + rss_h = bytes_to_human(rss) + avail_h = bytes_to_human(available) + self.mem_progress.setFormat(f"{mem_percent}%") + self.mem_progress.setToolTip(f"Python RSS: {rss_h}\nAvailable: {avail_h}\nTotal RAM: {bytes_to_human(total_ram)}") + except Exception: + pass + # Set color based on usage - for bar in [self.cpu_progress] + self.core_bars: + bars_to_update = [self.cpu_progress] + self.core_bars + if hasattr(self, 'mem_progress'): + bars_to_update = [self.mem_progress] + bars_to_update # include memory bar too + for bar in bars_to_update: value = bar.value() if value < 60: # Here we load the Style File if the CPU Load is under 60% @@ -2379,7 +2771,7 @@ class MainWindow(QMainWindow): self.start_btn.setEnabled(False) self.stop_btn.setEnabled(False) -class initQT(): +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! @@ -2400,8 +2792,8 @@ class initQT(): else: # If theres no Type then Exit 1 print( - "No XDG Session Type found!" \ - "echo $XDG_SESSION_TYPE" \ + "No XDG Session Type found!" + "echo $XDG_SESSION_TYPE" "Run this command in bash!" ) exit(1) @@ -2413,12 +2805,13 @@ class initQT(): else: # If this fails then just exit with 1 print( - "Setting the XDG_SESSION_TYPE failed!" \ - f"export XDG_SESSION_TYPE={self.session_type}" \ + "Setting the XDG_SESSION_TYPE failed!" + f"export XDG_SESSION_TYPE={self.session_type}" "run this command in bash" ) exit(1) - def shutupCV(self): + @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"