18 Commits

Author SHA1 Message Date
rattatwinko
633af882ca requirements file
Some checks failed
Build MuCaPy Executable / build-and-package (push) Failing after 9s
2025-06-03 18:20:27 +02:00
rattatwinko
d2864fd337 hopefully run tests 2025-06-03 18:19:44 +02:00
rattatwinko
08431ee6ca removed unused shit and added a logo
All checks were successful
Build MuCaPy Executable / build-and-package (push) Successful in 1m41s
2025-06-02 14:46:37 +02:00
rattatwinko
7dcf03970e think this would work
All checks were successful
Build MuCaPy Executable / build-and-package (push) Successful in 1m46s
2025-06-02 14:33:11 +02:00
rattatwinko
44f2797a5c i cant do shit
All checks were successful
Build MuCaPy Executable / build-and-package (push) Successful in 1m41s
2025-06-02 14:25:08 +02:00
rattatwinko
98ad6242fa hopefully it works now, made a function that gets the cwd and then joins it!
All checks were successful
Build MuCaPy Executable / build-and-package (push) Successful in 1m42s
2025-06-02 14:21:55 +02:00
rattatwinko
b3b3d77394 moved the styling into seperate files that are now contained in the styling directory ; workflow should work with this!
All checks were successful
Build MuCaPy Executable / build-and-package (push) Successful in 1m43s
2025-06-02 14:10:19 +02:00
rattatwinko
e50ab9d03c systeminfo updated in the about section!
All checks were successful
Build MuCaPy Executable / build-and-package (push) Successful in 1m41s
2025-05-30 22:32:32 +02:00
rattatwinko
c9c60f7237 more info about this bullshit
All checks were successful
Build MuCaPy Executable / build-and-package (push) Successful in 1m46s
2025-05-30 22:12:02 +02:00
rattatwinko
de156a5c33 stoopid
All checks were successful
Build MuCaPy Executable / build-and-package (push) Successful in 1m42s
2025-05-30 22:06:58 +02:00
rattatwinko
e4ec7fe244 video on how to run the GACC (gitactionscompiledcode)
All checks were successful
Build MuCaPy Executable / build-and-package (push) Successful in 1m43s
2025-05-30 22:03:30 +02:00
rattatwinko
1546528550 added a license and readme
All checks were successful
Build MuCaPy Executable / build-and-package (push) Successful in 1m49s
2025-05-30 21:43:27 +02:00
rattatwinko
e7b4cc0f92 new cool detection dis/enable
All checks were successful
Build MuCaPy Executable / build-and-package (push) Successful in 1m44s
2025-05-28 20:23:16 +02:00
rattatwinko
199d81891f cool HW monitor!
All checks were successful
Build MuCaPy Executable / build-and-package (push) Successful in 1m38s
2025-05-28 20:13:54 +02:00
rattatwinko
ea1f0bcd85 my cock is very very small
All checks were successful
Build MuCaPy Executable / build-and-package (push) Successful in 1m36s
2025-05-28 15:10:04 +02:00
rattatwinko
9ae940585a my cock is very small
All checks were successful
Build MuCaPy Executable / build-and-package (push) Successful in 1m39s
2025-05-28 15:02:58 +02:00
rattatwinko
78403395c1 testing this shit
Some checks failed
Run MuCaPy / build-and-run (push) Failing after 3m16s
2025-05-28 14:57:52 +02:00
rattatwinko
b313fc7629 seoerated 2025-05-27 20:47:53 +02:00
19 changed files with 896 additions and 157 deletions

View File

@@ -0,0 +1,45 @@
name: Build MuCaPy Executable
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build-and-package:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up Python 3.10
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run tests
run: make test
- name: Install PyInstaller
run: pip install pyinstaller
- name: Build executable with PyInstaller
run: |
pyinstaller --onefile --windowed mucapy/main.py \
--add-data "mucapy/styling:styling" \
--add-data "mucapy/models:models"
- name: Upload executable artifact
uses: actions/upload-artifact@v3
with:
name: mucapy-executable
path: dist/

9
.gitignore vendored
View File

@@ -1 +1,10 @@
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

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"makefile.configureOnOpen": false
}

41
LICENSE Normal file
View File

@@ -0,0 +1,41 @@
MuCaPy Proprietary Software License
Version 1.0 — May 30, 2025
Copyright © 2025 Rattatwinko
All Rights Reserved.
This software, including all source code, binaries, documentation, and associated files (collectively, the "Software"), is the exclusive property of the copyright holder.
The Software is proprietary and confidential. It is licensed, not sold, solely for internal, non-commercial use by the copyright holder or explicitly authorized individuals.
1. License Grant
The copyright holder does not grant any rights to use, copy, modify, distribute, sublicense, or create derivative works from the Software except as explicitly authorized in writing.
2. Restrictions
You shall not, under any circumstances:
Redistribute the Software in any form (source or binary).
Publish, transmit, upload, or make the Software publicly available.
Reverse engineer, decompile, or disassemble the Software.
Use the Software for any commercial or revenue-generating purpose.
Share the Software with third parties, contractors, or external collaborators.
Access to the Software must remain confined to private, secure environments under the control of the copyright holder.
3. Ownership
All title, ownership rights, and intellectual property rights in and to the Software remain solely with the copyright holder.
4. Termination
Any unauthorized use of the Software automatically terminates any implied rights and may result in legal action.
5. Disclaimer of Warranty
The Software is provided "AS IS" without warranty of any kind, express or implied, including but not limited to warranties of merchantability, fitness for a particular purpose, or non-infringement.
6. Limitation of Liability
In no event shall the copyright holder be liable for any damages, including direct, indirect, incidental, special, or consequential damages arising out of or related to the use or inability to use the Software.
This license is not OSI-approved and must not be interpreted as open-source. For internal use only.

2
Makefile Normal file
View File

@@ -0,0 +1,2 @@
test:
PYTHONPATH=. pytest tests

169
README.md Normal file
View File

@@ -0,0 +1,169 @@
# MuCaPy: Multi-Camera Python🎥🧠
[![Python](https://img.shields.io/badge/Python-3.8+-blue.svg)](https://www.python.org/)
[![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20Linux%20%7C%20macOS-lightgrey)]()
[![OpenCV](https://img.shields.io/badge/OpenCV-4.x-brightgreen)](https://opencv.org/)
[![Qt](https://img.shields.io/badge/PyQt5-Used-informational)](https://riverbankcomputing.com/software/pyqt/)
[![YOLO](https://img.shields.io/badge/YOLOv4-Supported-orange)]()
---
## 📌 Overview
**MuCaPy** (Multi-Camera Python) is a modern, robust, and user-friendly real-time multi-camera object detection platform powered by **YOLOv4** and **OpenCV**, designed with a professional PyQt5 GUI. It supports both **local** and **network IP cameras**, allows dynamic camera selection, advanced configuration, and comes with a beautiful dark-themed UI. CUDA support ensures high performance where available.
---
## ✨ Features
- 🔁 Connect multiple **local USB or IP cameras**
- 💡 Real-time **YOLOv4 object detection**
- 🎛️ Intelligent UI with **PyQt5**, collapsible dock widgets, and tabbed views
- 📸 Take **screenshots** per camera feed
- ⚙️ **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
- 💾 Persistent **configuration management**
- 🧪 **Camera connectivity test tools**
---
## 📦 Requirements
```bash
pip install -r requirements.txt
```
<details>
<summary><strong>Dependencies:</strong></summary>
- Python 3.8+
- OpenCV (cv2)
- PyQt5
- NumPy
- Requests
- psutil
</details>
---
## 🚀 Getting Started
```bash
python main.py
```
> ✅ Make sure your YOLOv4 model directory contains `.weights`, `.cfg`, and `.names` files.
---
## 📁 Model Directory Structure
This is important! If you dont have the correct directory structure, the model loader won't work.
Models are Included in the Git Repository!
```bash
model/
├── yolov4.cfg
├── yolov4.weights
└── coco.names
```
---
## 🎮 UI Highlights
- **Model Loader**: Easily select model directory
- **Camera Selection**: Mix and match local/network sources
- **Layouts**: Switch between 1, 2, 3, 4, or grid layouts
- **FPS Control**: Adjustable frame rate
- **Dark Theme**: Developer-friendly aesthetic
- **Screenshot Button**: Save current camera frame
- **Hardware Stats**: Monitor CPU load in real-time
---
## Gitea Worflow Download
You can actually download Gitea Workflow compiled code that will run on your local machine. But for this to work you need to enter one of the following commands in your terminal:
```sh
chmod a+x main # or the name of your compiled file
```
>Note: _This will only work on Linux! **( This is cause of the Gitea Runner (Which runs on Ubuntu!))** If you are on Windows you can compile it yourself with PyInstaller_
[![Watch the demo video](https://img.shields.io/badge/▶️-Click%20to%20Watch-red)](run.mp4)
---
## 🔗 Recommended YOLOv4 Model Links
- [YOLOv4.cfg](https://github.com/AlexeyAB/darknet/blob/master/cfg/yolov4.cfg)
- [YOLOv4.weights](https://github.com/AlexeyAB/darknet/releases/download/yolov4/yolov4.weights)
- [COCO.names](https://github.com/pjreddie/darknet/blob/master/data/coco.names)
---
## 🔐 Network Camera Example
Supports URLs like:
- `http://192.168.1.101:8080/video`
- `http://username:password@ip:port/stream`
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
This is also Very Important if you have multiple cameras or want to save / delete your settings. You can save your settings to a file and load them later.
Settings (last model, selected cameras, layout, FPS, etc.) are saved to:
- **Linux/macOS**: `~/.config/mucapy/config.json`
- **Windows**: `%APPDATA%\MuCaPy\config.json`
---
## 📸 Screenshot Storage
Default directory: `~/Pictures/MuCaPy` (can be changed from the UI)
---
## 📖 About
> _I built MuCaPy to learn about OpenCV, Python, and to have a simple, easy-to-use camera viewer for my Camera Server, since ContaCam doesn't work on my system (not well atleast!)._
---
## 🔮 To Be Added
- **YOLOv5 / YOLOv8 / YOLO-NAS support**
- **RTSP stream handling** _( Currently works but is fuzzy / crashes easily)_
- **Real-time performance dashboards**
- **WebSocket remote monitoring**
- **Not-so-laggy UI improvements**
- **Better CUDA implementation**
---
## 📄 License
This Project is currently under a Proprietary Licsence and shouldnt be distributed!
---
## 🧑‍💻 Maintainers
- 👤 Rattatwinko

0
__init__.py Normal file
View File

View File

@@ -4,20 +4,28 @@ import cv2
import json import json
import urllib.parse import urllib.parse
import numpy as np import numpy as np
import psutil # Add psutil import
from PyQt5.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout, from PyQt5.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout,
QWidget, QLabel, QPushButton, QComboBox, QSpinBox, QWidget, QLabel, QPushButton, QComboBox, QSpinBox,
QFileDialog, QMessageBox, QMenu, QAction, QMenuBar, QFileDialog, QMessageBox, QMenu, QAction, QMenuBar,
QActionGroup, QSizePolicy, QGridLayout, QGroupBox, QActionGroup, QSizePolicy, QGridLayout, QGroupBox,
QDockWidget, QScrollArea, QToolButton, QDialog, QDockWidget, QScrollArea, QToolButton, QDialog,
QShortcut, QListWidget, QFormLayout, QLineEdit, QShortcut, QListWidget, QFormLayout, QLineEdit,
QCheckBox, QTabWidget, QListWidgetItem, QSplitter) QCheckBox, QTabWidget, QListWidgetItem, QSplitter,
from PyQt5.QtCore import Qt, QTimer, QDir, QSize, QSettings, QDateTime, QRect, QThread, pyqtSignal, QMutex 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, from PyQt5.QtGui import (QImage, QPixmap, QIcon, QColor, QKeySequence, QPainter,
QPen, QBrush) QPen, QBrush)
import platform
import time import time
import requests import requests
import subprocess import subprocess
class getpath:
def resource_path(relative_path):
base_path = getattr(sys, '_MEIPASS', os.path.abspath("."))
return os.path.join(base_path, relative_path)
class Config: class Config:
def __init__(self): def __init__(self):
# Use platform-specific user directory for config # Use platform-specific user directory for config
@@ -73,7 +81,6 @@ class Config:
def load_setting(self, key, default=None): def load_setting(self, key, default=None):
"""Load a setting from configuration""" """Load a setting from configuration"""
return self.settings.get(key, default) return self.settings.get(key, default)
class CameraThread(QThread): class CameraThread(QThread):
"""Thread class for handling camera connections and frame grabbing""" """Thread class for handling camera connections and frame grabbing"""
frame_ready = pyqtSignal(int, np.ndarray) # Signal to emit when new frame is ready (camera_index, frame) frame_ready = pyqtSignal(int, np.ndarray) # Signal to emit when new frame is ready (camera_index, frame)
@@ -298,9 +305,9 @@ class CameraThread(QThread):
if self.cap: if self.cap:
self.cap.release() self.cap.release()
self.running = False self.running = False
class MultiCamYOLODetector(QObject):
class MultiCamYOLODetector: def __init__(self, parent=None):
def __init__(self): super().__init__(parent)
self.cameras = [] self.cameras = []
self.camera_threads = {} # Dictionary to store camera threads self.camera_threads = {} # Dictionary to store camera threads
self.net = None self.net = None
@@ -487,7 +494,10 @@ class MultiCamYOLODetector:
print(f"Warning: Could not read frame from camera {cam_path}") print(f"Warning: Could not read frame from camera {cam_path}")
frame = np.zeros((720, 1280, 3), dtype=np.uint8) frame = np.zeros((720, 1280, 3), dtype=np.uint8)
else: else:
frame = self.get_detections(frame) # 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) frames.append(frame)
except: except:
frame = np.zeros((720, 1280, 3), dtype=np.uint8) frame = np.zeros((720, 1280, 3), dtype=np.uint8)
@@ -547,7 +557,6 @@ class MultiCamYOLODetector:
print(f"Detection error: {e}") print(f"Detection error: {e}")
return frame return frame
class CameraDisplay(QLabel): class CameraDisplay(QLabel):
"""Custom QLabel for displaying camera feed with fullscreen support""" """Custom QLabel for displaying camera feed with fullscreen support"""
def __init__(self, parent=None): def __init__(self, parent=None):
@@ -701,7 +710,6 @@ class CameraDisplay(QLabel):
# Draw text # Draw text
painter.setPen(QPen(QColor(255, 255, 255))) painter.setPen(QPen(QColor(255, 255, 255)))
painter.drawText(rect, Qt.AlignCenter, self.camera_name) painter.drawText(rect, Qt.AlignCenter, self.camera_name)
class CollapsibleDock(QDockWidget): class CollapsibleDock(QDockWidget):
"""Custom dock widget with collapse/expand functionality""" """Custom dock widget with collapse/expand functionality"""
def __init__(self, title, parent=None): def __init__(self, title, parent=None):
@@ -756,91 +764,186 @@ class CollapsibleDock(QDockWidget):
self.resize(self.original_size) self.resize(self.original_size)
self.toggle_button.setIcon(QIcon.fromTheme("arrow-left")) self.toggle_button.setIcon(QIcon.fromTheme("arrow-left"))
self.collapsed = False self.collapsed = False
class AboutWindow(QDialog): class AboutWindow(QDialog):
def __init__(self, parent=None): # Add parent parameter with default None def __init__(self, parent=None):
super().__init__(parent) # Pass parent to QDialog super().__init__(parent)
self.setWindowTitle("About Multi-Camera YOLO Detection") self.setWindowTitle("About Multi-Camera YOLO Detection")
self.setWindowIcon(QIcon.fromTheme("help-about")) self.setWindowIcon(QIcon.fromTheme("help-about"))
self.resize(400, 300) self.resize(450, 420)
# Make it modal and stay on top
self.setWindowModality(Qt.ApplicationModal) self.setWindowModality(Qt.ApplicationModal)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint) self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint)
layout = QVBoxLayout() layout = QVBoxLayout()
layout.setAlignment(Qt.AlignCenter) layout.setAlignment(Qt.AlignTop)
layout.setSpacing(20) layout.setSpacing(20)
# Application icon/logo (placeholder) # App icon
icon_label = QLabel() icon_label = QLabel()
icon_label.setPixmap(QIcon.fromTheme("camera-web").pixmap(64, 64)) icon_label.setPixmap(QIcon.fromTheme("camera-web").pixmap(64, 64))
icon_label.setAlignment(Qt.AlignCenter) icon_label.setAlignment(Qt.AlignCenter)
layout.addWidget(icon_label) layout.addWidget(icon_label)
# Application title # Title
title_label = QLabel("MuCaPy - 1") title_label = QLabel("MuCaPy - 1")
title_label.setStyleSheet("font-size: 18px; font-weight: bold;") title_label.setStyleSheet("font-size: 18px; font-weight: bold;")
title_label.setAlignment(Qt.AlignCenter) title_label.setAlignment(Qt.AlignCenter)
layout.addWidget(title_label) layout.addWidget(title_label)
# Version information # Version label
version_label = QLabel("Version 1.0") version_label = QLabel("Version 1.0")
version_label.setAlignment(Qt.AlignCenter) version_label.setAlignment(Qt.AlignCenter)
layout.addWidget(version_label) layout.addWidget(version_label)
# Description
desc_label = QLabel(
"MuCaPy\n"
"Multiple Camera Python\n"
"Using CV2"
)
desc_label.setAlignment(Qt.AlignCenter)
desc_label.setWordWrap(True)
layout.addWidget(desc_label)
# Close Button # Get system info
info = self.get_system_info()
self.important_keys = ["Python", "OpenCV", "Memory", "CUDA"]
self.full_labels = {}
# === System Info Group ===
self.sysinfo_box = QGroupBox()
sysinfo_main_layout = QVBoxLayout()
sysinfo_main_layout.setContentsMargins(8, 8, 8, 8)
# Header layout: title + triangle button
header_layout = QHBoxLayout()
header_label = QLabel("System Information")
header_label.setStyleSheet("font-weight: bold;")
header_layout.addWidget(header_label)
header_layout.addStretch()
self.toggle_btn = QToolButton()
self.toggle_btn.setText("")
self.toggle_btn.setCheckable(True)
self.toggle_btn.setChecked(False)
self.toggle_btn.setStyleSheet("""
QToolButton {
border: none;
background: transparent;
font-size: 14px;
color: #DDD;
}
""")
self.toggle_btn.toggled.connect(self.toggle_expand)
header_layout.addWidget(self.toggle_btn)
sysinfo_main_layout.addLayout(header_layout)
# Details layout
self.sysinfo_layout = QVBoxLayout()
self.sysinfo_layout.setSpacing(5)
for key, value in info.items():
if key == "MemoryGB":
continue
label = QLabel(f"{key}: {value}")
self.style_label(label, key, value)
self.sysinfo_layout.addWidget(label)
self.full_labels[key] = label
if key not in self.important_keys:
label.setVisible(False)
sysinfo_main_layout.addLayout(self.sysinfo_layout)
self.sysinfo_box.setLayout(sysinfo_main_layout)
layout.addWidget(self.sysinfo_box)
# Close button
close_btn = QPushButton("Close") close_btn = QPushButton("Close")
close_btn.clicked.connect(self.accept) close_btn.clicked.connect(self.accept)
close_btn.setFixedWidth(100) close_btn.setFixedWidth(100)
layout.addWidget(close_btn, alignment=Qt.AlignCenter) layout.addWidget(close_btn, alignment=Qt.AlignCenter)
self.setStyleSheet(""" # Set Styling for About Section
QDialog { style_file = getpath.resource_path("styling/about.qss")
background-color: #2D2D2D; with open(style_file,"r") as aboutstyle:
color: #DDD; self.setStyleSheet(aboutstyle.read())
}
QLabel {
color: #DDD;
}
QGroupBox {
border: 1px solid #555;
border-radius: 4px;
margin-top: 10px;
padding-top: 15px;
background-color: #252525;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 10px;
padding: 0 3px;
color: #DDD;
}
QPushButton {
background-color: #3A3A3A;
color: #DDD;
border: 1px solid #555;
border-radius: 4px;
padding: 5px;
min-width: 80px;
}
QPushButton:hover {
background-color: #4A4A4A;
}
""")
self.setLayout(layout) self.setLayout(layout)
def toggle_expand(self, checked):
for key, label in self.full_labels.items():
if key not in self.important_keys:
label.setVisible(checked)
self.toggle_btn.setText("" if checked else "")
def style_label(self, label, key, value):
if key == "Python":
label.setStyleSheet("color: #7FDBFF;")
elif key == "OpenCV":
label.setStyleSheet("color: #FF851B;")
elif key == "CUDA":
label.setStyleSheet("color: green;" if value == "Yes" else "color: red;")
elif key == "NumPy":
label.setStyleSheet("color: #B10DC9;")
elif key == "Requests":
label.setStyleSheet("color: #0074D9;")
elif key == "Memory":
try:
ram = int(value.split()[0])
if ram < 8:
label.setStyleSheet("color: red;")
elif ram < 16:
label.setStyleSheet("color: yellow;")
elif ram < 32:
label.setStyleSheet("color: lightgreen;")
else:
label.setStyleSheet("color: #90EE90;")
except:
label.setStyleSheet("color: gray;")
elif key == "CPU Usage":
try:
usage = float(value.strip('%'))
if usage > 80:
label.setStyleSheet("color: red;")
elif usage > 50:
label.setStyleSheet("color: yellow;")
else:
label.setStyleSheet("color: lightgreen;")
except:
label.setStyleSheet("color: gray;")
elif key in ("CPU Cores", "Logical CPUs"):
label.setStyleSheet("color: lightgreen;")
elif key in ("CPU", "Architecture", "OS"):
label.setStyleSheet("color: lightgray;")
else:
label.setStyleSheet("color: #DDD;")
def get_system_info(self):
import platform
info = {}
info['Python'] = sys.version.split()[0]
info['OS'] = f"{platform.system()} {platform.release()}"
info['Architecture'] = platform.machine()
info['OpenCV'] = cv2.__version__
info['CUDA'] = "Yes" if cv2.cuda.getCudaEnabledDeviceCount() > 0 else "No"
info['NumPy'] = np.__version__
info['Requests'] = requests.__version__
mem = psutil.virtual_memory()
info['MemoryGB'] = mem.total // (1024**3)
info['Memory'] = f"{info['MemoryGB']} GB RAM"
info['CPU Cores'] = psutil.cpu_count(logical=False)
info['Logical CPUs'] = psutil.cpu_count(logical=True)
info['CPU Usage'] = f"{psutil.cpu_percent()}%"
try:
if sys.platform == "win32":
info['CPU'] = platform.processor()
elif sys.platform == "linux":
info['CPU'] = subprocess.check_output("lscpu", shell=True).decode().split("\n")[0]
elif sys.platform == "darwin":
info['CPU'] = subprocess.check_output(["sysctl", "-n", "machdep.cpu.brand_string"]).decode().strip()
except Exception:
info['CPU'] = "Unknown"
return info
class NetworkCameraDialog(QDialog): class NetworkCameraDialog(QDialog):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -976,7 +1079,6 @@ class NetworkCameraDialog(QDialog):
if self.detector: if self.detector:
self.detector.remove_network_camera(name) self.detector.remove_network_camera(name)
self.load_cameras() self.load_cameras()
class CameraSelectorDialog(QDialog): class CameraSelectorDialog(QDialog):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -1305,11 +1407,10 @@ class CameraSelectorDialog(QDialog):
dialog = NetworkCameraDialog(self) dialog = NetworkCameraDialog(self)
if dialog.exec_() == QDialog.Accepted: if dialog.exec_() == QDialog.Accepted:
self.refresh_cameras() self.refresh_cameras()
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.setWindowTitle("Multi-Camera YOLO Detection") self.setWindowTitle("MuCaPy - V1")
self.setGeometry(100, 100, 1200, 800) self.setGeometry(100, 100, 1200, 800)
# Initialize configuration # Initialize configuration
@@ -1317,93 +1418,24 @@ class MainWindow(QMainWindow):
# Initialize default values # Initialize default values
self.current_layout = 0 # Default to single camera layout self.current_layout = 0 # Default to single camera layout
self.detector = MultiCamYOLODetector() self.detector = MultiCamYOLODetector(self) # Pass self as parent
self.camera_settings = {} self.camera_settings = {}
self.detection_enabled = True # Add detection toggle flag
# Load saved settings first # Load saved settings first
self.load_saved_settings() self.load_saved_settings()
self.setWindowIcon(QIcon(getpath.resource_path("styling/logo.png"))) # Convert from SVG beforehand
# Initialize hardware monitor timer
self.hw_timer = QTimer()
self.hw_timer.timeout.connect(self.update_hardware_stats)
self.hw_timer.start(1000) # Update every second
# Set dark theme style # Set dark theme style
self.setStyleSheet(""" style_file = getpath.resource_path("styling/mainwindow.qss")
QMainWindow, QWidget { with open(style_file, "r") as mainstyle:
background-color: #2D2D2D; self.setStyleSheet(mainstyle.read())
color: #DDD;
}
QLabel {
color: #DDD;
}
QPushButton {
background-color: #3A3A3A;
color: #DDD;
border: 1px solid #555;
border-radius: 4px;
padding: 5px;
}
QPushButton:hover {
background-color: #4A4A4A;
}
QPushButton:pressed {
background-color: #2A2A2A;
}
QPushButton:disabled {
background-color: #2A2A2A;
color: #777;
}
QComboBox, QSpinBox {
background-color: #3A3A3A;
color: #DDD;
border: 1px solid #555;
border-radius: 4px;
padding: 3px;
}
QGroupBox {
border: 1px solid #555;
border-radius: 4px;
margin-top: 10px;
padding-top: 15px;
background-color: #252525;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 10px;
padding: 0 3px;
color: #DDD;
}
QMenuBar {
background-color: #252525;
color: #DDD;
}
QMenuBar::item {
background-color: transparent;
padding: 5px 10px;
}
QMenuBar::item:selected {
background-color: #3A3A3A;
}
QMenu {
background-color: #252525;
border: 1px solid #444;
color: #DDD;
}
QMenu::item:selected {
background-color: #3A3A3A;
}
QScrollArea {
border: none;
}
QDockWidget {
titlebar-close-icon: url(none);
titlebar-normal-icon: url(none);
}
QDockWidget::title {
background: #252525;
padding-left: 5px;
}
QToolButton {
background-color: transparent;
border: none;
}
""")
# Set palette for better dark mode support # Set palette for better dark mode support
palette = self.palette() palette = self.palette()
@@ -1518,6 +1550,14 @@ class MainWindow(QMainWindow):
self.toggle_sidebar_action.triggered.connect(self.toggle_sidebar_visibility) self.toggle_sidebar_action.triggered.connect(self.toggle_sidebar_visibility)
view_menu.addAction(self.toggle_sidebar_action) view_menu.addAction(self.toggle_sidebar_action)
# Add toggle detection action
self.toggle_detection_action = QAction('Enable Detection', self)
self.toggle_detection_action.setCheckable(True)
self.toggle_detection_action.setChecked(True)
self.toggle_detection_action.setShortcut('Ctrl+D')
self.toggle_detection_action.triggered.connect(self.toggle_detection)
view_menu.addAction(self.toggle_detection_action)
# Camera menu # Camera menu
self.camera_menu = menubar.addMenu('Cameras') self.camera_menu = menubar.addMenu('Cameras')
@@ -1897,6 +1937,49 @@ class MainWindow(QMainWindow):
self.display_area.setLayout(self.display_layout) self.display_area.setLayout(self.display_layout)
main_layout.addWidget(self.display_area) main_layout.addWidget(self.display_area)
# Hardware Monitor section
hw_monitor_group = QGroupBox("Hardware Monitor")
hw_monitor_layout = QVBoxLayout()
# CPU Usage
cpu_layout = QHBoxLayout()
cpu_layout.addWidget(QLabel("CPU Usage:"))
self.cpu_progress = QProgressBar()
self.cpu_progress.setRange(0, 100)
self.cpu_progress.setTextVisible(True)
self.cpu_progress.setFormat("%p%")
# Set Styling from cpu progress QSS file
style_file = getpath.resource_path("styling/cpu_progress.qss")
with open(style_file,"r") as cpu_progress_style:
self.cpu_progress.setStyleSheet(cpu_progress_style.read())
cpu_layout.addWidget(self.cpu_progress)
hw_monitor_layout.addLayout(cpu_layout)
# Per-core CPU Usage
cores_layout = QGridLayout()
self.core_bars = []
num_cores = psutil.cpu_count()
for i in range(num_cores):
core_label = QLabel(f"Core {i}:")
core_bar = QProgressBar()
core_bar.setRange(0, 100)
core_bar.setTextVisible(True)
core_bar.setFormat("%p%")
core_file = getpath.resource_path("styling/core_bar.qss")
with open(core_file,"r") as core_bar_styling:
core_bar.setStyleSheet(core_bar_styling.read())
cores_layout.addWidget(core_label, i, 0)
cores_layout.addWidget(core_bar, i, 1)
self.core_bars.append(core_bar)
hw_monitor_layout.addLayout(cores_layout)
hw_monitor_group.setLayout(hw_monitor_layout)
sidebar_layout.addWidget(hw_monitor_group)
main_widget.setLayout(main_layout) main_widget.setLayout(main_layout)
self.setCentralWidget(main_widget) self.setCentralWidget(main_widget)
@@ -2102,11 +2185,57 @@ class MainWindow(QMainWindow):
else: else:
self.sidebar.collapse() self.sidebar.collapse()
def update_hardware_stats(self):
"""Update hardware statistics"""
# Update overall CPU usage
cpu_percent = psutil.cpu_percent()
self.cpu_progress.setValue(int(cpu_percent))
# Update per-core CPU usage
per_core = psutil.cpu_percent(percpu=True)
for i, usage in enumerate(per_core):
if i < len(self.core_bars):
self.core_bars[i].setValue(int(usage))
# Set color based on usage
for bar in [self.cpu_progress] + self.core_bars:
value = bar.value()
if value < 60:
u60 = getpath.resource_path("styling/bar/u60.qss")
with open(u60,"r") as u60_style:
bar.setStyleSheet(u60_style.read())
elif value < 85:
u85 = getpath.resource_path("styling/bar/a85.qss")
with open(u85,"r") as a85_styling:
bar.setStyleSheet(a85_styling.read())
else:
else_file = getpath.resource_path("styling/bar/else.qss")
with open(else_file, "r") as else_style:
bar.setStyleSheet(else_style.read())
def toggle_detection(self):
"""Toggle detection enabled/disabled"""
self.detection_enabled = self.toggle_detection_action.isChecked()
if self.detection_enabled:
self.start_btn.setEnabled(True)
self.stop_btn.setEnabled(True)
else:
self.start_btn.setEnabled(False)
self.stop_btn.setEnabled(False)
if __name__ == "__main__": if __name__ == "__main__":
# Set the env
os.environ["XDG_SESSION_TYPE"] = "xcb"
app = QApplication(sys.argv) app = QApplication(sys.argv)
# Set application style to Fusion for better dark mode support # Set application style to Fusion for better dark mode support
app.setStyle("Fusion") app.setStyle("Fusion")
window = MainWindow() window = MainWindow()
window.show() window.show()

36
mucapy/styling/about.qss Normal file
View File

@@ -0,0 +1,36 @@
QDialog {
background-color: #2D2D2D;
color: #DDD;
}
QLabel {
color: #DDD;
}
QGroupBox {
border: 1px solid #555;
border-radius: 6px;
margin-top: 10px;
padding: 4px;
background-color: #252525;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 10px;
padding: 0 3px;
color: #DDD;
}
QPushButton {
background-color: #3A3A3A;
color: #DDD;
border: 1px solid #555;
border-radius: 4px;
padding: 5px;
min-width: 80px;
}
QPushButton:hover {
background-color: #4A4A4A;
}

View File

@@ -0,0 +1,11 @@
QProgressBar {
border: 1px solid #555;
border-radius: 2px;
text-align: center;
background-color: #2A2A2A;
}
QProgressBar::chunk {
background-color: #E5A823;
width: 1px;
}

View File

@@ -0,0 +1,11 @@
QProgressBar {
border: 1px solid #555;
border-radius: 2px;
text-align: center;
background-color: #2A2A2A;
}
QProgressBar::chunk {
background-color: #A23535;
width: 1px;
}

View File

@@ -0,0 +1,11 @@
QProgressBar {
border: 1px solid #555;
border-radius: 2px;
text-align: center;
background-color: #2A2A2A;
}
QProgressBar::chunk {
background-color: #3A6EA5;
width: 1px;
}

View File

@@ -0,0 +1,12 @@
QProgressBar {
border: 1px solid #555;
border-radius: 2px;
text-align: center;
background-color: #2A2A2A;
max-height: 12px;
}
QProgressBar::chunk {
background-color: #3A6EA5;
width: 1px;
}

View File

@@ -0,0 +1,11 @@
QProgressBar {
border: 1px solid #555;
border-radius: 2px;
text-align: center;
background-color: #2A2A2A;
}
QProgressBar::chunk {
background-color: #3A6EA5;
width: 1px;
}

BIN
mucapy/styling/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,95 @@
QMainWindow, QWidget {
background-color: #2D2D2D;
color: #DDD;
}
QLabel {
color: #DDD;
}
QPushButton {
background-color: #3A3A3A;
color: #DDD;
border: 1px solid #555;
border-radius: 4px;
padding: 5px;
}
QPushButton:hover {
background-color: #4A4A4A;
}
QPushButton:pressed {
background-color: #2A2A2A;
}
QPushButton:disabled {
background-color: #2A2A2A;
color: #777;
}
QComboBox, QSpinBox {
background-color: #3A3A3A;
color: #DDD;
border: 1px solid #555;
border-radius: 4px;
padding: 3px;
}
QGroupBox {
border: 1px solid #555;
border-radius: 4px;
margin-top: 10px;
padding-top: 15px;
background-color: #252525;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 10px;
padding: 0 3px;
color: #DDD;
}
QMenuBar {
background-color: #252525;
color: #DDD;
}
QMenuBar::item {
background-color: transparent;
padding: 5px 10px;
}
QMenuBar::item:selected {
background-color: #3A3A3A;
}
QMenu {
background-color: #252525;
border: 1px solid #444;
color: #DDD;
}
QMenu::item:selected {
background-color: #3A3A3A;
}
QScrollArea {
border: none;
}
QDockWidget {
titlebar-close-icon: url(none);
titlebar-normal-icon: url(none);
}
QDockWidget::title {
background: #252525;
padding-left: 5px;
}
QToolButton {
background-color: transparent;
border: none;
}

View File

@@ -1,4 +1,6 @@
opencv-python>=4.5.0 opencv-python==4.11.0.86
opencv-contrib-python>=4.5.0 numpy==2.2.6
numpy>=1.19.0 PyQt5==5.15.11
PyQt5>=5.15.0 requests==2.32.3
psutil==7.0.0
pytest==8.4.0

BIN
run.mp4 Normal file

Binary file not shown.

152
tests/test_main.py Normal file
View File

@@ -0,0 +1,152 @@
import os
import sys
import json
import tempfile
import shutil
import unittest
from unittest.mock import patch, MagicMock
import numpy as np
# Import the classes to test
from mucapy.main import Config, CameraThread, MultiCamYOLODetector
class TestConfig(unittest.TestCase):
def setUp(self):
# Create a temporary directory for config
self.temp_dir = tempfile.mkdtemp()
self.config_file = os.path.join(self.temp_dir, 'config.json')
self.expanduser_patcher = patch('os.path.expanduser', return_value=self.temp_dir)
self.makedirs_patcher = patch('os.makedirs', return_value=None)
self.expanduser_patcher.start()
self.makedirs_patcher.start()
# Patch environment variables that may affect config path
self.env_patches = []
for var in ['APPDATA', 'USERPROFILE']:
self.env_patches.append(patch.dict(os.environ, {var: self.temp_dir}))
self.env_patches[-1].start()
self.config = Config()
self.config.config_file = self.config_file
def tearDown(self):
self.expanduser_patcher.stop()
self.makedirs_patcher.stop()
for p in self.env_patches:
p.stop()
shutil.rmtree(self.temp_dir)
def test_save_and_load_setting(self):
self.config.save_setting('test_key', 'test_value')
self.assertEqual(self.config.load_setting('test_key'), 'test_value')
# Reload config to check persistence
self.config.settings['test_key'] = None
self.config.load_config()
self.assertEqual(self.config.load_setting('test_key'), 'test_value')
def test_default_settings(self):
self.assertIn('network_cameras', self.config.settings)
self.assertIn('last_model_dir', self.config.settings)
def test_load_nonexistent_key(self):
self.assertIsNone(self.config.load_setting('nonexistent_key'))
self.assertEqual(self.config.load_setting('nonexistent_key', default=123), 123)
def test_save_invalid_json(self):
# Simulate corrupted config file
with open(self.config_file, 'w') as f:
f.write("{invalid json")
# Should not raise, should print error and keep defaults
self.config.load_config()
self.assertIn('network_cameras', self.config.settings)
class TestCameraThread(unittest.TestCase):
def setUp(self):
self.thread = CameraThread(0, {'url': 'http://192.168.1.2:4747'})
@patch('mucapy.main.CameraThread.validate_url')
def test_validate_url(self, mock_validate_url):
mock_validate_url.side_effect = lambda url: 'http://192.168.1.2:4747/video' if ':4747' in url else url
url = '192.168.1.2:4747'
validated = self.thread.validate_url(url)
self.assertEqual(validated, 'http://192.168.1.2:4747/video')
url2 = 'http://example.com/stream'
validated2 = self.thread.validate_url(url2)
self.assertEqual(validated2, url2)
def test_construct_camera_url_no_auth(self):
info = {'url': 'http://example.com/stream'}
url = self.thread.construct_camera_url(info)
self.assertEqual(url, 'http://example.com/stream')
def test_construct_camera_url_with_auth(self):
info = {'url': 'http://example.com/stream', 'username': 'user', 'password': 'pass'}
url = self.thread.construct_camera_url(info)
self.assertTrue(url.startswith('http://user:pass@'))
def test_construct_camera_url_invalid_url(self):
# Should handle invalid URL gracefully
info = {'url': '!!!not_a_url'}
url = self.thread.construct_camera_url(info)
self.assertIsInstance(url, str) # Should still return a string, possibly normalized
def test_construct_camera_url_non_dict(self):
url = self.thread.construct_camera_url('http://example.com/stream')
self.assertEqual(url, 'http://example.com/stream')
class TestMultiCamYOLODetector(unittest.TestCase):
def setUp(self):
self.detector = MultiCamYOLODetector()
def test_add_and_remove_network_camera(self):
self.detector.add_network_camera('testcam', 'http://test')
self.assertIn('testcam', self.detector.network_cameras)
self.detector.remove_network_camera('testcam')
self.assertNotIn('testcam', self.detector.network_cameras)
@patch('os.listdir', side_effect=FileNotFoundError)
def test_load_yolo_model_dir_not_found(self, mock_listdir):
result = self.detector.load_yolo_model('/nonexistent')
self.assertFalse(result)
@patch('os.listdir', return_value=['model.weights', 'model.cfg', 'model.names'])
@patch('cv2.dnn.readNet', side_effect=Exception("Failed to load net"))
def test_load_yolo_model_readnet_exception(self, mock_readnet, mock_listdir):
result = self.detector.load_yolo_model('/some/dir')
self.assertFalse(result)
@patch('os.path.exists', return_value=False)
@patch('cv2.dnn.readNet', return_value=MagicMock())
@patch('os.listdir', return_value=[])
def test_load_yolo_model_no_files(self, mock_listdir, mock_readnet, mock_exists):
result = self.detector.load_yolo_model('/nonexistent')
self.assertFalse(result)
@patch('cv2.VideoCapture')
def test_scan_for_cameras(self, mock_vc):
# Simulate one camera available
mock_instance = MagicMock()
mock_instance.isOpened.return_value = True
mock_vc.return_value = mock_instance
cams = self.detector.scan_for_cameras(max_to_check=1)
self.assertTrue(any(c.isdigit() for c in cams))
@patch('cv2.VideoCapture')
def test_scan_for_cameras_none_available(self, mock_vc):
# Simulate no cameras available
mock_instance = MagicMock()
mock_instance.isOpened.return_value = False
mock_vc.return_value = mock_instance
cams = self.detector.scan_for_cameras(max_to_check=1)
self.assertFalse(any(c.isdigit() for c in cams))
def test_connect_cameras_empty(self):
# Should not fail if given empty list
result = self.detector.connect_cameras([])
self.assertFalse(result)
def test_disconnect_cameras_noop(self):
# Should not raise if no cameras connected
self.detector.disconnect_cameras() # Should not raise
if __name__ == '__main__':
unittest.main()