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"