Compare commits
59 Commits
experiment
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 96335af6ee | |||
| 877dd8ca70 | |||
| 1300c41172 | |||
| cffc59a285 | |||
| 7752eaaf9d | |||
| c227beeaca | |||
| 826e545652 | |||
| d30a55fb0b | |||
| d703f9cd9f | |||
| ad2d136e3f | |||
| 6354bd01e2 | |||
| 9d1ac9c3dd | |||
| 61c8f8bd90 | |||
|
|
3a420c7bbb | ||
|
|
a24e2d5cdc | ||
|
|
bce8cdaec6 | ||
|
|
24cb9b214c | ||
|
|
f2b37b8129 | ||
|
|
cc4acb3d3d | ||
|
|
1de630149d | ||
|
|
d57bd9f00f | ||
|
|
223b559869 | ||
|
|
40ad3143b5 | ||
|
|
acf50c199c | ||
|
|
dffeb995a0 | ||
|
|
003ea7ddcf | ||
|
|
9f5c3f014c | ||
|
|
562e2958b1 | ||
|
|
bd7c32cb52 | ||
|
|
633af882ca | ||
|
|
d2864fd337 | ||
|
|
08431ee6ca | ||
|
|
7dcf03970e | ||
|
|
44f2797a5c | ||
|
|
98ad6242fa | ||
|
|
b3b3d77394 | ||
|
|
e50ab9d03c | ||
|
|
c9c60f7237 | ||
|
|
de156a5c33 | ||
|
|
e4ec7fe244 | ||
|
|
1546528550 | ||
|
|
e7b4cc0f92 | ||
|
|
199d81891f | ||
|
|
ea1f0bcd85 | ||
|
|
9ae940585a | ||
|
|
78403395c1 | ||
|
|
b313fc7629 | ||
|
|
3c847cee01 | ||
|
|
9ed0b768b8 | ||
|
|
4a06b759e3 | ||
|
|
2887e2927c | ||
| 6da4cd2b40 | |||
|
|
65be018700 | ||
|
|
5399cb8739 | ||
|
|
b80dd3f7d7 | ||
|
|
c264acac29 | ||
|
|
b24426c1d3 | ||
|
|
2ebaf16006 | ||
| 24ac4ccd51 |
43
.gitea/workflows/run-mucapy.yml
Normal file
43
.gitea/workflows/run-mucapy.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
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 Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
- 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" \
|
||||
--add-data "mucapy/todopackage:todopackage"
|
||||
|
||||
- name: Upload executable artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: mucapy-executable
|
||||
path: dist/
|
||||
|
||||
|
||||
104
.gitignore
vendored
Normal file
104
.gitignore
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
|
||||
# ============================
|
||||
# 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__/
|
||||
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@@ -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
|
||||
13
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
13
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,13 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="PyPep8NamingInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||
<option name="ignoredErrors">
|
||||
<list>
|
||||
<option value="N812" />
|
||||
<option value="N802" />
|
||||
</list>
|
||||
</option>
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<settings>
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
</component>
|
||||
12
.idea/material_theme_project_new.xml
generated
Normal file
12
.idea/material_theme_project_new.xml
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="MaterialThemeProjectNewConfig">
|
||||
<option name="metadata">
|
||||
<MTProjectMetadataState>
|
||||
<option name="migrated" value="true" />
|
||||
<option name="pristineConfig" value="false" />
|
||||
<option name="userId" value="13ba7435:19917931603:-7ffa" />
|
||||
</MTProjectMetadataState>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
7
.idea/misc.xml
generated
Normal file
7
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Black">
|
||||
<option name="sdkName" value="Python 3.13 (mucapy)" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.13 (mucapy)" project-jdk-type="Python SDK" />
|
||||
</project>
|
||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/mucapy.iml" filepath="$PROJECT_DIR$/.idea/mucapy.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
14
.idea/mucapy.iml
generated
Normal file
14
.idea/mucapy.iml
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="Python 3.13 (mucapy)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
<component name="PyDocumentationSettings">
|
||||
<option name="format" value="PLAIN" />
|
||||
<option name="myDocStringFormat" value="Plain" />
|
||||
</component>
|
||||
</module>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
41
LICENSE
Normal file
41
LICENSE
Normal 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.
|
||||
166
README.md
Normal file
166
README.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# MuCaPy: Multi-Camera Python🎥🧠
|
||||
|
||||
[](https://www.python.org/)
|
||||
[]()
|
||||
[](https://opencv.org/)
|
||||
[](https://riverbankcomputing.com/software/pyqt/)
|
||||
[]()
|
||||
|
||||
---
|
||||
|
||||
## 📌 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/popout camera views with zoom, pan, grid & timestamp overlays, snapshots, and shortcuts
|
||||
- 💾 Persistent **configuration management**
|
||||
|
||||
---
|
||||
|
||||
## 📦 Requirements
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
<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_
|
||||
|
||||
[](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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## ⚙️ 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
0
__init__.py
Normal file
300
mucapy/AboutWindow.py
Normal file
300
mucapy/AboutWindow.py
Normal file
@@ -0,0 +1,300 @@
|
||||
from PyQt5.QtCore import Qt, QTimer, QDir, QSize, QDateTime, QRect, QThread, pyqtSignal, QMutex, QObject, QEvent
|
||||
from PyQt5.QtGui import (QImage, QPixmap, QIcon, QColor, QKeySequence, QPainter,
|
||||
QPen, QBrush)
|
||||
from PyQt5.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout,
|
||||
QWidget, QLabel, QPushButton, QComboBox, QSpinBox,
|
||||
QFileDialog, QMessageBox, QMenu, QAction, QActionGroup, QGridLayout, QGroupBox,
|
||||
QDockWidget, QScrollArea, QToolButton, QDialog,
|
||||
QShortcut, QListWidget, QFormLayout, QLineEdit,
|
||||
QCheckBox, QTabWidget, QListWidgetItem, QSplitter,
|
||||
QProgressBar, QSizePolicy)
|
||||
import todopackage.todo as todo
|
||||
from utility import getpath
|
||||
import cv2
|
||||
import sys
|
||||
import psutil
|
||||
import numpy as np
|
||||
import requests
|
||||
from initqt import initQT
|
||||
|
||||
class AboutWindow(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
global todo_style_path
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("About Multi-Camera YOLO Detection")
|
||||
self.setWindowIcon(QIcon.fromTheme("help-about"))
|
||||
self.resize(450, 420)
|
||||
|
||||
self.setWindowModality(Qt.ApplicationModal)
|
||||
self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.setAlignment(Qt.AlignTop)
|
||||
layout.setSpacing(20)
|
||||
|
||||
# App icon
|
||||
icon_label = QLabel()
|
||||
icon_label.setPixmap(QIcon.fromTheme("camera-web").pixmap(64, 64))
|
||||
icon_label.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(icon_label)
|
||||
|
||||
# Title
|
||||
title_label = QLabel("PySec")
|
||||
title_label.setStyleSheet("font-size: 18px; font-weight: bold;")
|
||||
title_label.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(title_label)
|
||||
|
||||
# Version label
|
||||
version_label = QLabel("Version 1.0")
|
||||
version_label.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(version_label)
|
||||
|
||||
# Get system info
|
||||
info = self.get_system_info()
|
||||
self.important_keys = ["Python", "OpenCV", "Memory", "CUDA"]
|
||||
self.full_labels = {}
|
||||
|
||||
# === System Info Group ===
|
||||
self.sysinfo_box = QGroupBox()
|
||||
sysinfo_main_layout = QVBoxLayout()
|
||||
sysinfo_main_layout.setContentsMargins(8, 8, 8, 8)
|
||||
|
||||
# Header layout: title + triangle button
|
||||
header_layout = QHBoxLayout()
|
||||
header_label = QLabel("System Information")
|
||||
header_label.setStyleSheet("font-weight: bold;")
|
||||
header_layout.addWidget(header_label)
|
||||
|
||||
header_layout.addStretch()
|
||||
|
||||
self.toggle_btn = QToolButton()
|
||||
self.toggle_btn.setText("▶")
|
||||
self.toggle_btn.setCheckable(True)
|
||||
self.toggle_btn.setChecked(False)
|
||||
toggle_btn_style = getpath.resource_path("styling/togglebtnabout.qss")
|
||||
try:
|
||||
with open(toggle_btn_style, "r") as tgbstyle:
|
||||
self.toggle_btn.setStyleSheet(tgbstyle.read())
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
# Debug shit
|
||||
#print("i did shit")
|
||||
|
||||
self.toggle_btn.toggled.connect(self.toggle_expand)
|
||||
header_layout.addWidget(self.toggle_btn)
|
||||
|
||||
sysinfo_main_layout.addLayout(header_layout)
|
||||
|
||||
# Details layout
|
||||
self.sysinfo_layout = QVBoxLayout()
|
||||
self.sysinfo_layout.setSpacing(5)
|
||||
|
||||
for key, value in info.items():
|
||||
if key == "MemoryGB":
|
||||
continue
|
||||
|
||||
label = QLabel(f"{key}: {value}")
|
||||
self.style_label(label, key, value)
|
||||
self.sysinfo_layout.addWidget(label)
|
||||
self.full_labels[key] = label
|
||||
|
||||
if key not in self.important_keys:
|
||||
label.setVisible(False)
|
||||
|
||||
sysinfo_main_layout.addLayout(self.sysinfo_layout)
|
||||
self.sysinfo_box.setLayout(sysinfo_main_layout)
|
||||
layout.addWidget(self.sysinfo_box)
|
||||
|
||||
# Close button
|
||||
close_btn = QPushButton("Close")
|
||||
close_btn.clicked.connect(self.accept)
|
||||
close_btn.setFixedWidth(100)
|
||||
layout.addWidget(close_btn, alignment=Qt.AlignCenter)
|
||||
|
||||
# Set Styling for About Section
|
||||
style_file = getpath.resource_path("styling/about.qss")
|
||||
try:
|
||||
with open(style_file, "r") as aboutstyle:
|
||||
self.setStyleSheet(aboutstyle.read())
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
# Todo Label Shit
|
||||
self.todo_obj = todo
|
||||
todo_text = self.get_todo_text()
|
||||
todo_label = QLabel(f"<pre>{todo_text}</pre>")
|
||||
todo_label.setWordWrap(True)
|
||||
todo_label.setAlignment(Qt.AlignLeft)
|
||||
|
||||
# TODO: Fix this xD ; Fixing a TODO lol
|
||||
try:
|
||||
todo_style_path = getpath.resource_path("styling/todostyle.qss")
|
||||
with open(todo_style_path, "r") as tdf:
|
||||
todo_label.setStyleSheet(tdf.read())
|
||||
# here we have our wonderfull fix
|
||||
if True == True:
|
||||
todo_label.setStyleSheet("color: #f7ef02; font-style: italic;")
|
||||
else:
|
||||
pass
|
||||
except FileNotFoundError:
|
||||
print(f"Missing a Style File! => {todo_style_path}")
|
||||
pass
|
||||
|
||||
# Create the labels for the fucking trodo ass shit ?
|
||||
self.todo_archive_object = todo
|
||||
todo_archive_text = self.get_archive_text()
|
||||
todo_archive_label = QLabel(f"<pre>{todo_archive_text}</pre>")
|
||||
todo_archive_label.setWordWrap(True)
|
||||
todo_archive_label.setAlignment(Qt.AlignLeft)
|
||||
todo_archive_label.setStyleSheet("color: #02d1fa ;font-style: italic;")
|
||||
|
||||
self.info_obj = todo
|
||||
info_text = self.get_info_text()
|
||||
info_label = QLabel(f"<pre>{info_text}</pre>")
|
||||
info_label.setWordWrap(True)
|
||||
info_label.setAlignment(Qt.AlignCenter)
|
||||
info_label.setStyleSheet("color: #2ecc71 ; font-style: italic;")
|
||||
|
||||
self.camobj = todo
|
||||
cam_text = self.get_cam_text()
|
||||
cam_label = QLabel(f"<pre>{cam_text}</pre>")
|
||||
cam_label.setWordWrap(True)
|
||||
cam_label.setAlignment(Qt.AlignCenter)
|
||||
cam_label.setStyleSheet("color: #ffffff; font-style: italic;")
|
||||
|
||||
if True == True:
|
||||
layout.addWidget(info_label)
|
||||
layout.addWidget(todo_label)
|
||||
layout.addWidget(todo_archive_label)
|
||||
layout.addWidget(cam_label)
|
||||
else:
|
||||
pass
|
||||
|
||||
def toggle_expand(self, checked):
|
||||
for key, label in self.full_labels.items():
|
||||
if key not in self.important_keys:
|
||||
label.setVisible(checked)
|
||||
self.toggle_btn.setText("▼" if checked else "▶")
|
||||
|
||||
def style_label(self, label, key, value):
|
||||
if key == "Python":
|
||||
label.setStyleSheet("color: #7FDBFF;")
|
||||
elif key == "OpenCV":
|
||||
label.setStyleSheet("color: #FF851B;")
|
||||
elif key == "CUDA":
|
||||
label.setStyleSheet("color: green;" if value == "Yes" else "color: red;")
|
||||
elif key == "NumPy":
|
||||
label.setStyleSheet("color: #B10DC9;")
|
||||
elif key == "Requests":
|
||||
label.setStyleSheet("color: #0074D9;")
|
||||
elif key == "Memory":
|
||||
try:
|
||||
ram = int(value.split()[0])
|
||||
if ram < 8:
|
||||
label.setStyleSheet("color: red;")
|
||||
elif ram < 16:
|
||||
label.setStyleSheet("color: yellow;")
|
||||
elif ram < 32:
|
||||
label.setStyleSheet("color: lightgreen;")
|
||||
else:
|
||||
label.setStyleSheet("color: #90EE90;")
|
||||
except:
|
||||
label.setStyleSheet("color: gray;")
|
||||
elif key == "CPU Usage":
|
||||
try:
|
||||
usage = float(value.strip('%'))
|
||||
if usage > 80:
|
||||
label.setStyleSheet("color: red;")
|
||||
elif usage > 50:
|
||||
label.setStyleSheet("color: yellow;")
|
||||
else:
|
||||
label.setStyleSheet("color: lightgreen;")
|
||||
except:
|
||||
label.setStyleSheet("color: gray;")
|
||||
elif key in ("CPU Cores", "Logical CPUs"):
|
||||
label.setStyleSheet("color: lightgreen;")
|
||||
elif key in ("CPU", "Architecture", "OS"):
|
||||
label.setStyleSheet("color: lightgray;")
|
||||
else:
|
||||
label.setStyleSheet("color: #DDD;")
|
||||
|
||||
def get_system_info(self):
|
||||
import platform
|
||||
|
||||
info = {}
|
||||
info['Python'] = sys.version.split()[0]
|
||||
info['OS'] = f"{platform.system()} {platform.release()}"
|
||||
info['Architecture'] = platform.machine()
|
||||
info['OpenCV'] = cv2.__version__
|
||||
info['CUDA'] = "Yes" if cv2.cuda.getCudaEnabledDeviceCount() > 0 else "No"
|
||||
info['NumPy'] = np.__version__
|
||||
info['Requests'] = requests.__version__
|
||||
|
||||
# If we are on Linux we display the QTVAR
|
||||
if platform.system() == "Linux":
|
||||
info["XDG_ENVIROMENT_TYPE "] = initQT.getenv(self) # get the stupid env var of qt
|
||||
else:
|
||||
pass
|
||||
|
||||
mem = psutil.virtual_memory()
|
||||
info['MemoryGB'] = mem.total // (1024 ** 3)
|
||||
info['Memory'] = f"{info['MemoryGB']} GB RAM"
|
||||
|
||||
info['CPU Cores'] = psutil.cpu_count(logical=False)
|
||||
info['Logical CPUs'] = psutil.cpu_count(logical=True)
|
||||
info['CPU Usage'] = f"{psutil.cpu_percent()}%"
|
||||
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
info['CPU'] = platform.processor()
|
||||
elif sys.platform == "linux":
|
||||
info['CPU'] = subprocess.check_output("lscpu", shell=True).decode().split("\n")[0]
|
||||
elif sys.platform == "darwin":
|
||||
info['CPU'] = subprocess.check_output(["sysctl", "-n", "machdep.cpu.brand_string"]).decode().strip()
|
||||
except Exception:
|
||||
info['CPU'] = "Unknown"
|
||||
|
||||
return info
|
||||
|
||||
def get_todo_text(self):
|
||||
try:
|
||||
todo_text = self.todo_obj.todo.gettodo()
|
||||
if isinstance(todo_text, str):
|
||||
return todo_text.strip()
|
||||
else:
|
||||
return "Invalid TODO format."
|
||||
except Exception as e:
|
||||
return f"Error retrieving TODO: {e}"
|
||||
|
||||
def get_info_text(self):
|
||||
try:
|
||||
info_text = self.info_obj.todo.getinfo()
|
||||
if isinstance(info_text, str):
|
||||
return info_text.strip()
|
||||
else:
|
||||
return "Invalid"
|
||||
except Exception as e:
|
||||
return f"fuck you => {e}"
|
||||
|
||||
def get_archive_text(self):
|
||||
try:
|
||||
todo_archive_text = self.todo_archive_object.todo.getarchive()
|
||||
if isinstance(todo_archive_text, str):
|
||||
return todo_archive_text.strip()
|
||||
else:
|
||||
return "invalid format??"
|
||||
except Exception as e:
|
||||
return "?? ==> {e}"
|
||||
|
||||
def get_cam_text(self):
|
||||
try:
|
||||
cam_text = self.camobj.todo.getcams()
|
||||
if isinstance(cam_text, str):
|
||||
return cam_text.strip()
|
||||
else:
|
||||
return "invalid cam format"
|
||||
except Exception as e:
|
||||
return f"You are fuck you {e}"
|
||||
179
mucapy/AlertWorker.py
Normal file
179
mucapy/AlertWorker.py
Normal file
@@ -0,0 +1,179 @@
|
||||
import shutil
|
||||
import wave
|
||||
try:
|
||||
import simpleaudio as sa
|
||||
except ImportError:
|
||||
sa = None
|
||||
sa = None # Force it to not use it cause it fucks stuff up
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
import sys
|
||||
from PyQt5.QtCore import QThread, pyqtSignal
|
||||
|
||||
|
||||
class AlertWorker(QThread):
|
||||
"""Worker thread to play an alert sound safely without blocking UI.
|
||||
Uses winsound on Windows, external system players on Unix (afplay/paplay/aplay/ffplay),
|
||||
and falls back to simpleaudio if available. Supports cooperative stop.
|
||||
"""
|
||||
finished = pyqtSignal(bool, str) # success, message
|
||||
|
||||
def __init__(self, wav_path: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self.wav_path = wav_path
|
||||
self._stop = False
|
||||
self._subproc = None
|
||||
self._play_obj = None
|
||||
|
||||
def stop(self):
|
||||
"""Request the worker to stop early."""
|
||||
try:
|
||||
self._stop = True
|
||||
if self._play_obj is not None:
|
||||
try:
|
||||
self._play_obj.stop()
|
||||
except Exception:
|
||||
pass
|
||||
if self._subproc is not None:
|
||||
try:
|
||||
self._subproc.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _find_unix_player(self):
|
||||
"""Return (cmd_list, name) for an available player on Unix or (None, None)."""
|
||||
try:
|
||||
if sys.platform.startswith('darwin'):
|
||||
if shutil.which('afplay'):
|
||||
return (['afplay'], 'afplay')
|
||||
# Linux and others
|
||||
if shutil.which('paplay'):
|
||||
return (['paplay'], 'paplay')
|
||||
if shutil.which('aplay'):
|
||||
return (['aplay', '-q'], 'aplay')
|
||||
if shutil.which('ffplay'):
|
||||
return (['ffplay', '-nodisp', '-autoexit', '-loglevel', 'error'], 'ffplay')
|
||||
except Exception:
|
||||
pass
|
||||
return (None, None)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
if not os.path.exists(self.wav_path):
|
||||
self.finished.emit(False, f"File not found: {self.wav_path}")
|
||||
return
|
||||
|
||||
# Windows path: prefer winsound (native, safe)
|
||||
if sys.platform.startswith('win'):
|
||||
ws_error = "unknown"
|
||||
try:
|
||||
import winsound as _ws # type: ignore
|
||||
# Resolve flags safely even if some attributes are missing
|
||||
SND_FILENAME = getattr(_ws, 'SND_FILENAME', 0x00020000)
|
||||
SND_SYNC = getattr(_ws, 'SND_SYNC', 0x0000) # 0 is synchronous by default
|
||||
flags = SND_FILENAME | SND_SYNC
|
||||
# Ensure PlaySound exists
|
||||
play_fn = getattr(_ws, 'PlaySound', None)
|
||||
if play_fn is None:
|
||||
raise RuntimeError('winsound.PlaySound not available')
|
||||
for _ in range(4):
|
||||
if self._stop:
|
||||
break
|
||||
try:
|
||||
play_fn(self.wav_path, flags)
|
||||
except Exception as e:
|
||||
# On failure, break to try alternative backends
|
||||
ws_error = str(e)
|
||||
break
|
||||
time.sleep(0.001)
|
||||
else:
|
||||
# Completed all 4 plays
|
||||
self.finished.emit(True, "Alert played")
|
||||
return
|
||||
# If here, winsound failed at some point; continue to fallbacks
|
||||
except Exception as e:
|
||||
ws_error = str(e)
|
||||
# Try simpleaudio on Windows as fallback
|
||||
if sa is not None:
|
||||
try:
|
||||
with wave.open(self.wav_path, 'rb') as wf:
|
||||
n_channels = max(1, wf.getnchannels())
|
||||
sampwidth = max(1, wf.getsampwidth())
|
||||
framerate = max(8000, wf.getframerate() or 44100)
|
||||
frames = wf.readframes(wf.getnframes())
|
||||
for _ in range(4):
|
||||
if self._stop:
|
||||
break
|
||||
self._play_obj = sa.play_buffer(frames, n_channels, sampwidth, framerate)
|
||||
self._play_obj.wait_done()
|
||||
time.sleep(0.002)
|
||||
self.finished.emit(True, "Alert played")
|
||||
return
|
||||
except Exception as e2:
|
||||
self.finished.emit(False, f"Playback error (winsound fallback -> simpleaudio): {e2}")
|
||||
return
|
||||
else:
|
||||
self.finished.emit(False, f"Audio backend not available (winsound failed: {ws_error})")
|
||||
return
|
||||
|
||||
# Non-Windows: try external players first
|
||||
cmd, name = self._find_unix_player()
|
||||
if cmd is not None:
|
||||
for _ in range(4):
|
||||
if self._stop:
|
||||
break
|
||||
try:
|
||||
self._subproc = subprocess.Popen(cmd + [self.wav_path], stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL)
|
||||
# Poll until done or stop requested
|
||||
while True:
|
||||
if self._stop:
|
||||
try:
|
||||
self._subproc.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
ret = self._subproc.poll()
|
||||
if ret is not None:
|
||||
break
|
||||
time.sleep(0.01)
|
||||
except Exception as e:
|
||||
# Try next backend
|
||||
cmd = None
|
||||
break
|
||||
finally:
|
||||
self._subproc = None
|
||||
time.sleep(0.002)
|
||||
if cmd is not None:
|
||||
self.finished.emit(True, "Alert played")
|
||||
return
|
||||
|
||||
# Fallback: simpleaudio if available
|
||||
if sa is not None:
|
||||
try:
|
||||
with wave.open(self.wav_path, 'rb') as wf:
|
||||
n_channels = max(1, wf.getnchannels())
|
||||
sampwidth = max(1, wf.getsampwidth())
|
||||
framerate = max(8000, wf.getframerate() or 44100)
|
||||
frames = wf.readframes(wf.getnframes())
|
||||
for _ in range(4):
|
||||
if self._stop:
|
||||
break
|
||||
self._play_obj = sa.play_buffer(frames, n_channels, sampwidth, framerate)
|
||||
self._play_obj.wait_done()
|
||||
time.sleep(0.002)
|
||||
self.finished.emit(True, "Alert played")
|
||||
return
|
||||
except Exception as e:
|
||||
self.finished.emit(False, f"Playback error (simpleaudio): {e}")
|
||||
return
|
||||
|
||||
self.finished.emit(False, "No audio backend available (afplay/paplay/aplay/ffplay/simpleaudio)")
|
||||
except Exception as e:
|
||||
try:
|
||||
self.finished.emit(False, str(e))
|
||||
except Exception:
|
||||
pass
|
||||
127
mucapy/CameraDisplay.py
Normal file
127
mucapy/CameraDisplay.py
Normal file
@@ -0,0 +1,127 @@
|
||||
from PyQt5.QtCore import Qt, QDateTime, QRect
|
||||
from PyQt5.QtGui import (QColor, QPainter,
|
||||
QPen, QBrush)
|
||||
from PyQt5.QtWidgets import (QApplication, QLabel, QFileDialog, QMessageBox)
|
||||
from utility import getpath
|
||||
from Config import Config
|
||||
from PopoutWindow import PopoutWindow
|
||||
import os
|
||||
|
||||
class CameraDisplay(QLabel):
|
||||
"""Custom QLabel for displaying camera feed with fullscreen support"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setAlignment(Qt.AlignCenter)
|
||||
self.setText("No camera feed")
|
||||
|
||||
self.get_camera_display_style = getpath.resource_path("styling/camera_display.qss")
|
||||
try:
|
||||
with open(self.get_camera_display_style, "r") as cdst:
|
||||
self.setStyleSheet(cdst.read())
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
self.setMinimumSize(320, 240)
|
||||
self.fullscreen_window = None
|
||||
self.cam_id = None
|
||||
self.fullscreen_timer = None
|
||||
self.config = Config()
|
||||
self.screenshot_dir = self.config.load_setting('screenshot_dir', os.path.expanduser('~/Pictures/MuCaPy'))
|
||||
self.camera_name = None
|
||||
|
||||
# Create screenshot directory if it doesn't exist
|
||||
if not os.path.exists(self.screenshot_dir):
|
||||
os.makedirs(self.screenshot_dir, exist_ok=True)
|
||||
|
||||
def set_cam_id(self, cam_id):
|
||||
"""Set camera identifier for this display"""
|
||||
self.cam_id = cam_id
|
||||
|
||||
def set_camera_name(self, name):
|
||||
"""Set the camera name for display"""
|
||||
self.camera_name = name
|
||||
self.update()
|
||||
|
||||
def take_screenshot(self):
|
||||
"""Take a screenshot of the current frame"""
|
||||
if not self.pixmap():
|
||||
return
|
||||
|
||||
# Ask for screenshot directory if not set
|
||||
if not self.screenshot_dir:
|
||||
dir_path = QFileDialog.getExistingDirectory(
|
||||
self,
|
||||
"Select Screenshot Directory",
|
||||
os.path.expanduser('~/Pictures'),
|
||||
QFileDialog.ShowDirsOnly | QFileDialog.DontResolveSymlinks
|
||||
)
|
||||
if dir_path:
|
||||
self.screenshot_dir = dir_path
|
||||
self.config.save_setting('screenshot_dir', dir_path)
|
||||
if not os.path.exists(dir_path):
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
else:
|
||||
return
|
||||
|
||||
# Generate filename with timestamp
|
||||
timestamp = QDateTime.currentDateTime().toString('yyyy-MM-dd_hh-mm-ss')
|
||||
filename = f"camera_{self.cam_id}_{timestamp}.png"
|
||||
filepath = os.path.join(self.screenshot_dir, filename)
|
||||
|
||||
# Save the image
|
||||
if self.pixmap().save(filepath):
|
||||
QMessageBox.information(self, "Success", f"Screenshot saved to:\n{filepath}")
|
||||
else:
|
||||
QMessageBox.critical(self, "Error", "Failed to save screenshot")
|
||||
|
||||
def mouseDoubleClickEvent(self, event):
|
||||
"""Handle double click to toggle fullscreen"""
|
||||
if self.pixmap() and not self.fullscreen_window:
|
||||
self.show_fullscreen()
|
||||
elif self.fullscreen_window:
|
||||
self.close_fullscreen()
|
||||
|
||||
def show_fullscreen(self):
|
||||
"""Show this camera in a new window (enhanced popout)"""
|
||||
if not self.pixmap():
|
||||
return
|
||||
# Create enhanced popout window
|
||||
self.fullscreen_window = PopoutWindow(self, cam_id=self.cam_id, parent=self.window())
|
||||
# Size and show
|
||||
screen = QApplication.primaryScreen().availableGeometry()
|
||||
self.fullscreen_window.resize(min(1280, int(screen.width() * 0.9)), min(720, int(screen.height() * 0.9)))
|
||||
self.fullscreen_window.show()
|
||||
# ESC shortcut already handled inside PopoutWindow
|
||||
|
||||
def update_fullscreen(self, label):
|
||||
"""Kept for backward compatibility; PopoutWindow manages its own refresh."""
|
||||
if self.pixmap():
|
||||
label.setPixmap(self.pixmap().scaled(
|
||||
label.size(),
|
||||
Qt.KeepAspectRatio,
|
||||
Qt.SmoothTransformation
|
||||
))
|
||||
|
||||
def close_fullscreen(self):
|
||||
"""Close the fullscreen window"""
|
||||
if self.fullscreen_window:
|
||||
self.fullscreen_window.close()
|
||||
self.fullscreen_window = None
|
||||
|
||||
def paintEvent(self, event):
|
||||
"""Override paint event to draw camera name overlay"""
|
||||
super().paintEvent(event)
|
||||
if self.camera_name and self.pixmap():
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
|
||||
# Draw semi-transparent background
|
||||
painter.setPen(Qt.NoPen)
|
||||
painter.setBrush(QBrush(QColor(0, 0, 0, 180)))
|
||||
rect = QRect(10, 10, painter.fontMetrics().width(self.camera_name) + 20, 30)
|
||||
painter.drawRoundedRect(rect, 5, 5)
|
||||
|
||||
# Draw text
|
||||
painter.setPen(QPen(QColor(255, 255, 255)))
|
||||
painter.drawText(rect, Qt.AlignCenter, self.camera_name)
|
||||
27
mucapy/CameraScanThread.py
Normal file
27
mucapy/CameraScanThread.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import sys
|
||||
|
||||
from PyQt5.QtCore import QThread, pyqtSignal
|
||||
|
||||
|
||||
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([], {})
|
||||
317
mucapy/CameraSelectorDialog.py
Normal file
317
mucapy/CameraSelectorDialog.py
Normal file
@@ -0,0 +1,317 @@
|
||||
from PyQt5.QtCore import Qt, QTimer, QDir, QSize, QDateTime, QRect, QThread, pyqtSignal, QMutex, QObject, QEvent
|
||||
from PyQt5.QtGui import (QImage, QPixmap, QIcon, QColor, QKeySequence, QPainter,
|
||||
QPen, QBrush)
|
||||
from PyQt5.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout,
|
||||
QWidget, QLabel, QPushButton, QComboBox, QSpinBox,
|
||||
QFileDialog, QMessageBox, QMenu, QAction, QActionGroup, QGridLayout, QGroupBox,
|
||||
QDockWidget, QScrollArea, QToolButton, QDialog,
|
||||
QShortcut, QListWidget, QFormLayout, QLineEdit,
|
||||
QCheckBox, QTabWidget, QListWidgetItem, QSplitter,
|
||||
QProgressBar, QSizePolicy)
|
||||
import NetworkCameraDialog
|
||||
from todopackage.todo import todo
|
||||
import os
|
||||
|
||||
class CameraSelectorDialog(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Camera Selector")
|
||||
self.setModal(True)
|
||||
self.resize(900, 650) # Increased size for better visibility
|
||||
self.setSizeGripEnabled(True)
|
||||
|
||||
self.detector = parent.detector if parent else None
|
||||
self.selected_cameras = []
|
||||
|
||||
# Main layout
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Instructions with better formatting
|
||||
instructions = QLabel(todo.get_instructions_CaSeDi_QLabel())
|
||||
print(todo.get_instructions_CaSeDi_QLabel())
|
||||
|
||||
instructions.setStyleSheet("QLabel { background-color: #2A2A2A; padding: 10px; border-radius: 4px; }")
|
||||
instructions.setWordWrap(True)
|
||||
layout.addWidget(instructions)
|
||||
|
||||
# Split view for cameras
|
||||
splitter = QSplitter(Qt.Horizontal)
|
||||
splitter.setChildrenCollapsible(False)
|
||||
splitter.setHandleWidth(6)
|
||||
|
||||
# Left side - Available Cameras
|
||||
left_widget = QWidget()
|
||||
left_layout = QVBoxLayout(left_widget)
|
||||
left_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
|
||||
# Local Cameras Group
|
||||
local_group = QGroupBox("Local Cameras")
|
||||
local_group.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
local_layout = QVBoxLayout()
|
||||
self.local_list = QListWidget()
|
||||
self.local_list.setSelectionMode(QListWidget.ExtendedSelection)
|
||||
self.local_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
local_layout.addWidget(self.local_list)
|
||||
local_group.setLayout(local_layout)
|
||||
left_layout.addWidget(local_group)
|
||||
|
||||
# Network Cameras Group
|
||||
network_group = QGroupBox("Network Cameras")
|
||||
network_group.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
network_layout = QVBoxLayout()
|
||||
self.network_list = QListWidget()
|
||||
self.network_list.setSelectionMode(QListWidget.ExtendedSelection)
|
||||
self.network_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
network_layout.addWidget(self.network_list)
|
||||
network_group.setLayout(network_layout)
|
||||
left_layout.addWidget(network_group)
|
||||
|
||||
# Camera management buttons
|
||||
btn_layout = QHBoxLayout()
|
||||
self.refresh_btn = QPushButton("Refresh")
|
||||
self.refresh_btn.clicked.connect(self.refresh_cameras)
|
||||
add_net_btn = QPushButton("Add Network Camera")
|
||||
add_net_btn.clicked.connect(self.show_network_dialog)
|
||||
|
||||
btn_layout.addWidget(self.refresh_btn)
|
||||
btn_layout.addWidget(add_net_btn)
|
||||
left_layout.addLayout(btn_layout)
|
||||
|
||||
# Make lists expand and buttons stay minimal in left pane
|
||||
left_layout.setStretch(0, 1)
|
||||
left_layout.setStretch(1, 1)
|
||||
left_layout.setStretch(2, 0)
|
||||
|
||||
splitter.addWidget(left_widget)
|
||||
splitter.setStretchFactor(0, 1)
|
||||
|
||||
# Right side - Selected Cameras Preview
|
||||
right_widget = QWidget()
|
||||
right_layout = QVBoxLayout(right_widget)
|
||||
right_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
|
||||
preview_label = QLabel("Selected Cameras Preview")
|
||||
preview_label.setStyleSheet("font-weight: bold;")
|
||||
right_layout.addWidget(preview_label)
|
||||
|
||||
self.preview_list = QListWidget()
|
||||
self.preview_list.setDragDropMode(QListWidget.InternalMove)
|
||||
self.preview_list.setSelectionMode(QListWidget.ExtendedSelection)
|
||||
self.preview_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
right_layout.addWidget(self.preview_list)
|
||||
|
||||
# Preview controls
|
||||
preview_btn_layout = QHBoxLayout()
|
||||
remove_btn = QPushButton("Remove Selected")
|
||||
remove_btn.clicked.connect(self.remove_selected)
|
||||
clear_btn = QPushButton("Clear All")
|
||||
clear_btn.clicked.connect(self.clear_selection)
|
||||
|
||||
preview_btn_layout.addWidget(remove_btn)
|
||||
preview_btn_layout.addWidget(clear_btn)
|
||||
right_layout.addLayout(preview_btn_layout)
|
||||
|
||||
# Make preview list expand and buttons stay minimal in right pane
|
||||
right_layout.setStretch(0, 0)
|
||||
right_layout.setStretch(1, 1)
|
||||
right_layout.setStretch(2, 0)
|
||||
|
||||
splitter.addWidget(right_widget)
|
||||
splitter.setStretchFactor(1, 1)
|
||||
layout.addWidget(splitter)
|
||||
|
||||
# Bottom buttons
|
||||
bottom_layout = QHBoxLayout()
|
||||
select_all_btn = QPushButton("Select All")
|
||||
select_all_btn.clicked.connect(self.select_all)
|
||||
ok_btn = QPushButton("OK")
|
||||
ok_btn.clicked.connect(self.accept)
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
|
||||
bottom_layout.addWidget(select_all_btn)
|
||||
bottom_layout.addStretch()
|
||||
bottom_layout.addWidget(ok_btn)
|
||||
bottom_layout.addWidget(cancel_btn)
|
||||
layout.addLayout(bottom_layout)
|
||||
|
||||
# Connect signals
|
||||
self.local_list.itemChanged.connect(self.update_preview)
|
||||
self.network_list.itemChanged.connect(self.update_preview)
|
||||
self.preview_list.model().rowsMoved.connect(self.update_camera_order)
|
||||
|
||||
# Set splitter sizes
|
||||
splitter.setSizes([450, 450])
|
||||
|
||||
# Initial camera refresh
|
||||
self.refresh_cameras()
|
||||
|
||||
# Restore last selection if available
|
||||
if self.detector:
|
||||
last_selected = self.detector.config.load_setting('last_selected_cameras', [])
|
||||
if last_selected:
|
||||
self.restore_selection(last_selected)
|
||||
|
||||
def refresh_cameras(self):
|
||||
"""Refresh both local and network camera lists asynchronously"""
|
||||
self.local_list.clear()
|
||||
self.network_list.clear()
|
||||
|
||||
if not self.detector:
|
||||
return
|
||||
|
||||
# Show placeholders and disable refresh while scanning
|
||||
self.refresh_btn.setEnabled(False)
|
||||
scanning_item_local = QListWidgetItem("Scanning for cameras…")
|
||||
scanning_item_local.setFlags(Qt.NoItemFlags)
|
||||
self.local_list.addItem(scanning_item_local)
|
||||
scanning_item_net = QListWidgetItem("Loading network cameras…")
|
||||
scanning_item_net.setFlags(Qt.NoItemFlags)
|
||||
self.network_list.addItem(scanning_item_net)
|
||||
|
||||
# Start background scan
|
||||
started = self.detector.start_camera_scan(10)
|
||||
if not started:
|
||||
# If a scan is already running, we'll just wait for its signal
|
||||
pass
|
||||
|
||||
# Connect once to update lists when scan completes
|
||||
try:
|
||||
self.detector.cameras_scanned.disconnect(self._on_scan_finished_dialog)
|
||||
except Exception:
|
||||
pass
|
||||
self.detector.cameras_scanned.connect(self._on_scan_finished_dialog)
|
||||
|
||||
def _on_scan_finished_dialog(self, cams, names):
|
||||
# Re-enable refresh
|
||||
self.refresh_btn.setEnabled(True)
|
||||
# Rebuild lists
|
||||
self.local_list.clear()
|
||||
self.network_list.clear()
|
||||
|
||||
# Local cameras
|
||||
for cam_path in cams:
|
||||
if cam_path.startswith('net:'):
|
||||
continue
|
||||
if cam_path.startswith('/dev/'):
|
||||
display = os.path.basename(cam_path)
|
||||
else:
|
||||
# Numeric index
|
||||
pretty = names.get(cam_path)
|
||||
display = f"{pretty} (#{cam_path})" if pretty else f"Camera {cam_path}"
|
||||
item = QListWidgetItem(display)
|
||||
item.setData(Qt.UserRole, cam_path)
|
||||
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
|
||||
item.setCheckState(Qt.Unchecked)
|
||||
self.local_list.addItem(item)
|
||||
|
||||
# Network cameras
|
||||
for name, camera_info in self.detector.network_cameras.items():
|
||||
if isinstance(camera_info, dict):
|
||||
url = camera_info.get('url', '')
|
||||
has_auth = camera_info.get('username') is not None
|
||||
display_text = f"{name} ({url})"
|
||||
if has_auth:
|
||||
display_text += " 🔒"
|
||||
else:
|
||||
display_text = f"{name} ({camera_info})"
|
||||
item = QListWidgetItem(display_text)
|
||||
item.setData(Qt.UserRole, f"net:{name}")
|
||||
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
|
||||
item.setCheckState(Qt.Unchecked)
|
||||
self.network_list.addItem(item)
|
||||
|
||||
def restore_selection(self, last_selected):
|
||||
"""Restore previous camera selection"""
|
||||
for cam_id in last_selected:
|
||||
# Check local cameras
|
||||
for i in range(self.local_list.count()):
|
||||
item = self.local_list.item(i)
|
||||
if item.data(Qt.UserRole) == cam_id:
|
||||
item.setCheckState(Qt.Checked)
|
||||
|
||||
# Check network cameras
|
||||
for i in range(self.network_list.count()):
|
||||
item = self.network_list.item(i)
|
||||
if item.data(Qt.UserRole) == cam_id:
|
||||
item.setCheckState(Qt.Checked)
|
||||
|
||||
def update_preview(self):
|
||||
"""Update the preview list with currently selected cameras"""
|
||||
self.preview_list.clear()
|
||||
self.selected_cameras = []
|
||||
|
||||
# Get selected local cameras
|
||||
for i in range(self.local_list.count()):
|
||||
item = self.local_list.item(i)
|
||||
if item.checkState() == Qt.Checked:
|
||||
cam_id = item.data(Qt.UserRole)
|
||||
preview_item = QListWidgetItem(f"Local: {item.text()}")
|
||||
preview_item.setData(Qt.UserRole, cam_id)
|
||||
self.preview_list.addItem(preview_item)
|
||||
self.selected_cameras.append(cam_id)
|
||||
|
||||
# Get selected network cameras
|
||||
for i in range(self.network_list.count()):
|
||||
item = self.network_list.item(i)
|
||||
if item.checkState() == Qt.Checked:
|
||||
cam_id = item.data(Qt.UserRole)
|
||||
preview_item = QListWidgetItem(f"Network: {item.text()}")
|
||||
preview_item.setData(Qt.UserRole, cam_id)
|
||||
self.preview_list.addItem(preview_item)
|
||||
self.selected_cameras.append(cam_id)
|
||||
|
||||
# Save the current selection to config
|
||||
if self.detector:
|
||||
self.detector.config.save_setting('last_selected_cameras', self.selected_cameras)
|
||||
|
||||
def update_camera_order(self):
|
||||
"""Update the camera order based on preview list order"""
|
||||
self.selected_cameras = []
|
||||
for i in range(self.preview_list.count()):
|
||||
item = self.preview_list.item(i)
|
||||
self.selected_cameras.append(item.data(Qt.UserRole))
|
||||
|
||||
# Save the new order
|
||||
if self.detector:
|
||||
self.detector.config.save_setting('last_selected_cameras', self.selected_cameras)
|
||||
|
||||
def select_all(self):
|
||||
"""Select all cameras in both lists"""
|
||||
for i in range(self.local_list.count()):
|
||||
self.local_list.item(i).setCheckState(Qt.Checked)
|
||||
for i in range(self.network_list.count()):
|
||||
self.network_list.item(i).setCheckState(Qt.Checked)
|
||||
|
||||
def clear_selection(self):
|
||||
"""Clear all selections"""
|
||||
for i in range(self.local_list.count()):
|
||||
self.local_list.item(i).setCheckState(Qt.Unchecked)
|
||||
for i in range(self.network_list.count()):
|
||||
self.network_list.item(i).setCheckState(Qt.Unchecked)
|
||||
|
||||
def remove_selected(self):
|
||||
"""Remove selected items from the preview list"""
|
||||
selected_items = self.preview_list.selectedItems()
|
||||
for item in selected_items:
|
||||
cam_id = item.data(Qt.UserRole)
|
||||
# Uncheck corresponding items in source lists
|
||||
for i in range(self.local_list.count()):
|
||||
if self.local_list.item(i).data(Qt.UserRole) == cam_id:
|
||||
self.local_list.item(i).setCheckState(Qt.Unchecked)
|
||||
for i in range(self.network_list.count()):
|
||||
if self.network_list.item(i).data(Qt.UserRole) == cam_id:
|
||||
self.network_list.item(i).setCheckState(Qt.Unchecked)
|
||||
|
||||
# Camera connection tests removed for performance reasons per user request.
|
||||
def test_selected_cameras(self):
|
||||
"""Deprecated: Camera tests are disabled to improve performance."""
|
||||
QMessageBox.information(self, "Camera Tests Disabled",
|
||||
"Camera connectivity tests have been removed to speed up the application.")
|
||||
return
|
||||
|
||||
def show_network_dialog(self):
|
||||
"""Show the network camera configuration dialog"""
|
||||
dialog = NetworkCameraDialog(self)
|
||||
if dialog.exec_() == QDialog.Accepted:
|
||||
self.refresh_cameras()
|
||||
642
mucapy/CameraThread.py
Normal file
642
mucapy/CameraThread.py
Normal file
@@ -0,0 +1,642 @@
|
||||
import time
|
||||
import urllib.parse
|
||||
from enum import Enum
|
||||
import logging
|
||||
import traceback
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import requests
|
||||
from PyQt5.QtCore import QThread, pyqtSignal, QMutex, QWaitCondition
|
||||
|
||||
try:
|
||||
import rtsp
|
||||
RTSP_LIB_AVAILABLE = True
|
||||
except ImportError:
|
||||
RTSP_LIB_AVAILABLE = False
|
||||
logging.info("rtsp library not available. Install with: pip install rtsp")
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StreamType(Enum):
|
||||
"""Enum for different stream types"""
|
||||
LOCAL = "local"
|
||||
RTSP = "rtsp"
|
||||
HTTP_MJPEG = "http_mjpeg"
|
||||
DROIDCAM = "droidcam"
|
||||
IP_CAMERA = "ip_camera"
|
||||
NETWORK = "network"
|
||||
|
||||
|
||||
class CameraThread(QThread):
|
||||
# Signals
|
||||
frame_ready = pyqtSignal(int, np.ndarray)
|
||||
error_occurred = pyqtSignal(int, str)
|
||||
connection_status = pyqtSignal(int, bool, str) # camera_id, connected, message
|
||||
stats_updated = pyqtSignal(int, dict) # camera_id, stats
|
||||
|
||||
def __init__(self, camera_id, camera_info, parent=None):
|
||||
super().__init__(parent)
|
||||
self.camera_id = camera_id
|
||||
self.camera_info = camera_info
|
||||
self.running = False
|
||||
self.paused = False
|
||||
self.cap = None
|
||||
self.rtsp_client = None
|
||||
self.mutex = QMutex()
|
||||
self.condition = QWaitCondition()
|
||||
|
||||
# Configuration with safe defaults
|
||||
self.frame_interval = 1.0 / 30 # Default to 30 FPS
|
||||
self.max_reconnect_attempts = 10
|
||||
self.reconnect_delay = 2
|
||||
self.reconnect_backoff = 1.5 # Exponential backoff factor
|
||||
self.read_timeout = 5.0
|
||||
self.connection_timeout = 10
|
||||
self.max_consecutive_failures = 15
|
||||
self.health_check_interval = 5.0
|
||||
|
||||
# State tracking
|
||||
self.stream_type = None
|
||||
self.use_rtsp_lib = RTSP_LIB_AVAILABLE
|
||||
self.last_successful_frame = 0
|
||||
self.consecutive_failures = 0
|
||||
self.total_failures = 0
|
||||
self.total_frames = 0
|
||||
self.last_health_check = 0
|
||||
self.connection_attempts = 0
|
||||
|
||||
# Statistics
|
||||
self.stats = {
|
||||
'fps': 0,
|
||||
'total_frames': 0,
|
||||
'total_failures': 0,
|
||||
'connection_attempts': 0,
|
||||
'uptime': 0,
|
||||
'start_time': 0,
|
||||
'last_frame_time': 0
|
||||
}
|
||||
|
||||
def set_fps(self, fps):
|
||||
"""Set the target FPS for frame capture"""
|
||||
try:
|
||||
if fps > 0 and fps <= 120: # Reasonable bounds
|
||||
self.frame_interval = 1.0 / fps
|
||||
logger.info(f"Camera {self.camera_id}: FPS set to {fps}")
|
||||
else:
|
||||
logger.warning(f"Camera {self.camera_id}: Invalid FPS value {fps}")
|
||||
except Exception as e:
|
||||
logger.error(f"Camera {self.camera_id}: Error setting FPS: {e}")
|
||||
|
||||
def safe_emit(self, signal, *args):
|
||||
try:
|
||||
if self.isRunning():
|
||||
signal.emit(*args)
|
||||
except Exception as e:
|
||||
logger.error(f"Camera {self.camera_id}: Signal emit failed: {e}")
|
||||
|
||||
def update_stats(self):
|
||||
try:
|
||||
current_time = time.time()
|
||||
if self.stats['last_frame_time'] > 0:
|
||||
time_diff = current_time - self.stats['last_frame_time']
|
||||
if time_diff < 5: # Only update FPS if we have recent frames
|
||||
self.stats['fps'] = 1.0 / time_diff if time_diff > 0 else 0
|
||||
|
||||
self.stats['total_frames'] = self.total_frames
|
||||
self.stats['total_failures'] = self.total_failures
|
||||
self.stats['connection_attempts'] = self.connection_attempts
|
||||
self.stats['uptime'] = current_time - self.stats['start_time'] if self.stats['start_time'] > 0 else 0
|
||||
|
||||
self.safe_emit(self.stats_updated, self.camera_id, self.stats.copy())
|
||||
except Exception as e:
|
||||
logger.debug(f"Camera {self.camera_id}: Stats update error: {e}")
|
||||
|
||||
def detect_stream_type(self, url_or_info):
|
||||
try:
|
||||
if isinstance(url_or_info, (int, str)):
|
||||
url_str = str(url_or_info).strip().lower()
|
||||
|
||||
if url_str.isdigit():
|
||||
return StreamType.LOCAL
|
||||
elif url_str.startswith('rtsp://'):
|
||||
return StreamType.RTSP
|
||||
elif url_str.startswith('net:'):
|
||||
return StreamType.NETWORK
|
||||
elif ':4747' in url_str or 'droidcam' in url_str:
|
||||
return StreamType.DROIDCAM
|
||||
elif url_str.startswith(('http://', 'https://')):
|
||||
return StreamType.HTTP_MJPEG
|
||||
else:
|
||||
# Try to parse as IP camera
|
||||
if any(x in url_str for x in ['.', ':']):
|
||||
return StreamType.IP_CAMERA
|
||||
return StreamType.LOCAL # Fallback
|
||||
|
||||
return StreamType.NETWORK
|
||||
except Exception as e:
|
||||
logger.error(f"Camera {self.camera_id}: Stream type detection failed: {e}")
|
||||
return StreamType.IP_CAMERA # Safe fallback
|
||||
|
||||
@staticmethod
|
||||
def validate_url(url):
|
||||
"""Safely validate and normalize URL format"""
|
||||
try:
|
||||
if not url or not isinstance(url, str):
|
||||
return None
|
||||
|
||||
url = url.strip()
|
||||
if not url:
|
||||
return None
|
||||
|
||||
# Parse the URL
|
||||
if not url.startswith(('http://', 'https://', 'rtsp://', 'rtmp://')):
|
||||
url = f"http://{url}"
|
||||
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
|
||||
if not parsed.netloc:
|
||||
return None
|
||||
|
||||
# Special handling for DroidCam
|
||||
if ':4747' in url and not url.endswith('/video'):
|
||||
base_url = f"{parsed.scheme}://{parsed.netloc}"
|
||||
return f"{base_url}/video"
|
||||
|
||||
return url
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"URL validation error: {e}")
|
||||
return None
|
||||
|
||||
def construct_camera_url(self, camera_info):
|
||||
"""Safely construct proper camera URL with authentication if needed"""
|
||||
try:
|
||||
if isinstance(camera_info, dict):
|
||||
url = camera_info.get('url', '')
|
||||
username = camera_info.get('username', '')
|
||||
password = camera_info.get('password', '')
|
||||
else:
|
||||
url = str(camera_info)
|
||||
username = ''
|
||||
password = ''
|
||||
|
||||
url = self.validate_url(url)
|
||||
if not url:
|
||||
return None
|
||||
|
||||
# Handle authentication
|
||||
if username and password:
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
if '@' not in parsed.netloc:
|
||||
auth = f"{urllib.parse.quote(username)}:{urllib.parse.quote(password)}"
|
||||
netloc = f"{auth}@{parsed.netloc}"
|
||||
url = urllib.parse.urlunparse(parsed._replace(netloc=netloc))
|
||||
|
||||
return url
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Camera {self.camera_id}: Error constructing camera URL: {e}")
|
||||
return None
|
||||
|
||||
def safe_capture_release(self):
|
||||
"""Safely release OpenCV capture"""
|
||||
try:
|
||||
if self.cap is not None:
|
||||
self.cap.release()
|
||||
self.cap = None
|
||||
logger.debug(f"Camera {self.camera_id}: Capture released")
|
||||
except Exception as e:
|
||||
logger.debug(f"Camera {self.camera_id}: Error releasing capture: {e}")
|
||||
finally:
|
||||
self.cap = None
|
||||
|
||||
def safe_rtsp_close(self):
|
||||
"""Safely close RTSP client"""
|
||||
try:
|
||||
if self.rtsp_client is not None:
|
||||
self.rtsp_client.close()
|
||||
self.rtsp_client = None
|
||||
logger.debug(f"Camera {self.camera_id}: RTSP client closed")
|
||||
except Exception as e:
|
||||
logger.debug(f"Camera {self.camera_id}: Error closing RTSP client: {e}")
|
||||
finally:
|
||||
self.rtsp_client = None
|
||||
|
||||
def configure_capture(self, cap, stream_type):
|
||||
"""Safely configure VideoCapture object based on stream type"""
|
||||
try:
|
||||
# Common settings
|
||||
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
||||
|
||||
if stream_type == StreamType.LOCAL:
|
||||
cap.set(cv2.CAP_PROP_FPS, 30)
|
||||
|
||||
elif stream_type in [StreamType.RTSP, StreamType.IP_CAMERA]:
|
||||
# RTSP/IP camera optimizations
|
||||
cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*'H264'))
|
||||
cap.set(cv2.CAP_PROP_OPEN_TIMEOUT_MSEC, 5000)
|
||||
cap.set(cv2.CAP_PROP_READ_TIMEOUT_MSEC, 5000)
|
||||
|
||||
elif stream_type in [StreamType.HTTP_MJPEG, StreamType.DROIDCAM]:
|
||||
cap.set(cv2.CAP_PROP_OPEN_TIMEOUT_MSEC, 5000)
|
||||
cap.set(cv2.CAP_PROP_READ_TIMEOUT_MSEC, 5000)
|
||||
|
||||
logger.debug(f"Camera {self.camera_id}: Capture configured for {stream_type.value}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Camera {self.camera_id}: Could not configure capture settings: {e}")
|
||||
|
||||
def test_network_endpoint(self, url, timeout=3):
|
||||
"""Safely test if a network endpoint is accessible"""
|
||||
try:
|
||||
response = requests.head(url, timeout=timeout, allow_redirects=True)
|
||||
accessible = response.status_code in [200, 401, 403] # 401/403 means it's there but needs auth
|
||||
logger.debug(f"Camera {self.camera_id}: Network test for {url}: {accessible}")
|
||||
return accessible
|
||||
except requests.exceptions.RequestException:
|
||||
try:
|
||||
response = requests.get(url, timeout=timeout, stream=True)
|
||||
response.close()
|
||||
accessible = response.status_code in [200, 401, 403]
|
||||
logger.debug(f"Camera {self.camera_id}: Network test (GET) for {url}: {accessible}")
|
||||
return accessible
|
||||
except Exception as e:
|
||||
logger.debug(f"Camera {self.camera_id}: Network test failed for {url}: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.debug(f"Camera {self.camera_id}: Network test error for {url}: {e}")
|
||||
return False
|
||||
|
||||
def connect_rtsp_with_library(self, url):
|
||||
"""Safely connect to RTSP stream using the rtsp library"""
|
||||
if not self.use_rtsp_lib:
|
||||
return False
|
||||
|
||||
try:
|
||||
logger.info(f"Camera {self.camera_id}: Attempting RTSP library connection...")
|
||||
self.rtsp_client = rtsp.Client(rtsp_server_uri=url, verbose=False)
|
||||
|
||||
# Test if connection works
|
||||
if self.rtsp_client.isOpened():
|
||||
# Try to read a frame with timeout
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < self.read_timeout:
|
||||
frame = self.rtsp_client.read()
|
||||
if frame is not None:
|
||||
logger.info(f"Camera {self.camera_id}: Successfully connected with rtsp library")
|
||||
return True
|
||||
time.sleep(0.1)
|
||||
|
||||
logger.warning(f"Camera {self.camera_id}: Failed to connect with rtsp library")
|
||||
self.safe_rtsp_close()
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Camera {self.camera_id}: RTSP library error: {e}")
|
||||
self.safe_rtsp_close()
|
||||
return False
|
||||
|
||||
def connect_rtsp_with_opencv(self, url):
|
||||
"""Safely connect to RTSP stream using OpenCV with different transport protocols"""
|
||||
import os
|
||||
|
||||
transports = ['tcp', 'udp', 'http']
|
||||
|
||||
for transport in transports:
|
||||
try:
|
||||
logger.info(f"Camera {self.camera_id}: Trying RTSP with {transport.upper()} transport...")
|
||||
|
||||
# Set FFMPEG options
|
||||
os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = (
|
||||
f"rtsp_transport;{transport}|"
|
||||
f"timeout;5000000|"
|
||||
f"stimeout;5000000|"
|
||||
f"buffer_size;1024000"
|
||||
)
|
||||
|
||||
self.cap = cv2.VideoCapture(url, cv2.CAP_FFMPEG)
|
||||
self.configure_capture(self.cap, StreamType.RTSP)
|
||||
|
||||
if not self.cap.isOpened():
|
||||
logger.debug(f"Camera {self.camera_id}: Failed to open with {transport}")
|
||||
self.safe_capture_release()
|
||||
continue
|
||||
|
||||
# Try to read a frame with timeout
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < self.read_timeout:
|
||||
ret, frame = self.cap.read()
|
||||
if ret and frame is not None and frame.size > 0:
|
||||
logger.info(f"Camera {self.camera_id}: Successfully connected with {transport.upper()}")
|
||||
return True
|
||||
time.sleep(0.1)
|
||||
|
||||
logger.debug(f"Camera {self.camera_id}: Failed to read frame with {transport}")
|
||||
self.safe_capture_release()
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Camera {self.camera_id}: Error with {transport}: {e}")
|
||||
self.safe_capture_release()
|
||||
|
||||
return False
|
||||
|
||||
def connect_to_camera(self):
|
||||
"""Safely attempt to connect to the camera with enhanced retry logic"""
|
||||
self.connection_attempts += 1
|
||||
|
||||
for attempt in range(self.max_reconnect_attempts):
|
||||
try:
|
||||
# Clean up existing connections
|
||||
self.safe_capture_release()
|
||||
self.safe_rtsp_close()
|
||||
|
||||
# Determine camera source
|
||||
if isinstance(self.camera_info, str) and self.camera_info.startswith('net:'):
|
||||
name = self.camera_info[4:]
|
||||
detector = self.parent().detector if self.parent() else None
|
||||
|
||||
if not detector or name not in getattr(detector, 'network_cameras', {}):
|
||||
self.safe_emit(self.connection_status, self.camera_id, False, f"Network camera {name} not found")
|
||||
time.sleep(self.reconnect_delay * (self.reconnect_backoff ** attempt))
|
||||
continue
|
||||
|
||||
camera_info = detector.network_cameras[name]
|
||||
url = self.construct_camera_url(camera_info)
|
||||
|
||||
if not url:
|
||||
self.safe_emit(self.connection_status, self.camera_id, False, f"Invalid URL for {name}")
|
||||
time.sleep(self.reconnect_delay * (self.reconnect_backoff ** attempt))
|
||||
continue
|
||||
|
||||
self.stream_type = self.detect_stream_type(url)
|
||||
camera_source = url
|
||||
|
||||
else:
|
||||
if isinstance(self.camera_info, dict):
|
||||
url = self.construct_camera_url(self.camera_info)
|
||||
if not url:
|
||||
self.safe_emit(self.connection_status, self.camera_id, False, "Invalid camera URL")
|
||||
time.sleep(self.reconnect_delay * (self.reconnect_backoff ** attempt))
|
||||
continue
|
||||
camera_source = url
|
||||
self.stream_type = self.detect_stream_type(url)
|
||||
else:
|
||||
camera_source = self.camera_info
|
||||
self.stream_type = self.detect_stream_type(camera_source)
|
||||
|
||||
if self.stream_type != StreamType.LOCAL:
|
||||
camera_source = self.validate_url(str(camera_source))
|
||||
if not camera_source:
|
||||
self.safe_emit(self.connection_status, self.camera_id, False, "Invalid camera source")
|
||||
time.sleep(self.reconnect_delay * (self.reconnect_backoff ** attempt))
|
||||
continue
|
||||
|
||||
logger.info(f"Camera {self.camera_id}: Attempt {attempt + 1}/{self.max_reconnect_attempts} connecting to {self.stream_type.value}...")
|
||||
|
||||
# Test network endpoint for HTTP streams
|
||||
if self.stream_type in [StreamType.HTTP_MJPEG, StreamType.DROIDCAM, StreamType.IP_CAMERA]:
|
||||
if not self.test_network_endpoint(camera_source):
|
||||
logger.warning(f"Camera {self.camera_id}: Network endpoint not accessible")
|
||||
time.sleep(self.reconnect_delay * (self.reconnect_backoff ** attempt))
|
||||
continue
|
||||
|
||||
# Connect based on stream type
|
||||
success = False
|
||||
|
||||
if self.stream_type == StreamType.LOCAL:
|
||||
try:
|
||||
self.cap = cv2.VideoCapture(int(camera_source))
|
||||
self.configure_capture(self.cap, self.stream_type)
|
||||
|
||||
if self.cap.isOpened():
|
||||
# Test frame reading
|
||||
ret, frame = self.cap.read()
|
||||
if ret and frame is not None:
|
||||
success = True
|
||||
except Exception as e:
|
||||
logger.warning(f"Camera {self.camera_id}: Local camera error: {e}")
|
||||
|
||||
elif self.stream_type == StreamType.RTSP:
|
||||
# Try rtsp library first if available
|
||||
if self.use_rtsp_lib and self.connect_rtsp_with_library(camera_source):
|
||||
success = True
|
||||
elif self.connect_rtsp_with_opencv(camera_source):
|
||||
success = True
|
||||
|
||||
else:
|
||||
# HTTP MJPEG, DroidCam, IP Camera
|
||||
try:
|
||||
self.cap = cv2.VideoCapture(camera_source, cv2.CAP_FFMPEG)
|
||||
self.configure_capture(self.cap, self.stream_type)
|
||||
|
||||
if self.cap.isOpened():
|
||||
# Test frame reading with timeout
|
||||
start_time = time.time()
|
||||
ret, frame = False, None
|
||||
while time.time() - start_time < self.read_timeout:
|
||||
ret, frame = self.cap.read()
|
||||
if ret and frame is not None and frame.size > 0:
|
||||
success = True
|
||||
break
|
||||
time.sleep(0.1)
|
||||
except Exception as e:
|
||||
logger.warning(f"Camera {self.camera_id}: Network camera error: {e}")
|
||||
|
||||
if success:
|
||||
logger.info(f"Camera {self.camera_id}: Successfully connected")
|
||||
self.safe_emit(self.connection_status, self.camera_id, True, "Connected")
|
||||
self.consecutive_failures = 0
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Camera {self.camera_id}: Connection attempt {attempt + 1} failed")
|
||||
self.safe_capture_release()
|
||||
self.safe_rtsp_close()
|
||||
|
||||
if attempt < self.max_reconnect_attempts - 1:
|
||||
delay = self.reconnect_delay * (self.reconnect_backoff ** attempt)
|
||||
logger.info(f"Camera {self.camera_id}: Retrying in {delay:.1f}s...")
|
||||
time.sleep(delay)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Camera {self.camera_id}: Connection attempt {attempt + 1} error: {e}")
|
||||
self.safe_capture_release()
|
||||
self.safe_rtsp_close()
|
||||
|
||||
if attempt < self.max_reconnect_attempts - 1:
|
||||
time.sleep(self.reconnect_delay * (self.reconnect_backoff ** attempt))
|
||||
|
||||
logger.error(f"Camera {self.camera_id}: All connection attempts failed")
|
||||
self.safe_emit(self.connection_status, self.camera_id, False, "Connection failed")
|
||||
self.safe_emit(self.error_occurred, self.camera_id, "Failed to connect after multiple attempts")
|
||||
return False
|
||||
|
||||
def run(self):
|
||||
self.stats['start_time'] = time.time()
|
||||
|
||||
try:
|
||||
logger.info(f"Camera {self.camera_id}: Thread starting")
|
||||
|
||||
if not self.connect_to_camera():
|
||||
logger.error(f"Camera {self.camera_id}: Initial connection failed")
|
||||
return
|
||||
|
||||
self.running = True
|
||||
last_frame_time = 0
|
||||
self.last_health_check = time.time()
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
# Check if paused
|
||||
if self.paused:
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
|
||||
# Frame rate limiting
|
||||
current_time = time.time()
|
||||
if current_time - last_frame_time < self.frame_interval:
|
||||
time.sleep(0.001)
|
||||
continue
|
||||
|
||||
# Health check
|
||||
if current_time - self.last_health_check > self.health_check_interval:
|
||||
if self.consecutive_failures > self.max_consecutive_failures / 2:
|
||||
logger.warning(f"Camera {self.camera_id}: Health check failed, reconnecting...")
|
||||
if not self.connect_to_camera():
|
||||
break
|
||||
self.last_health_check = current_time
|
||||
|
||||
# Read frame based on connection type
|
||||
frame = None
|
||||
ret = False
|
||||
|
||||
try:
|
||||
if self.rtsp_client and self.rtsp_client.isOpened():
|
||||
frame = self.rtsp_client.read()
|
||||
ret = frame is not None
|
||||
if ret:
|
||||
# Convert PIL Image to numpy array if needed
|
||||
if hasattr(frame, 'size'): # Likely PIL Image
|
||||
frame = np.array(frame)
|
||||
if len(frame.shape) == 3 and frame.shape[2] == 3:
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR)
|
||||
elif self.cap and self.cap.isOpened():
|
||||
ret, frame = self.cap.read()
|
||||
else:
|
||||
ret = False
|
||||
except Exception as e:
|
||||
logger.debug(f"Camera {self.camera_id}: Frame read error: {e}")
|
||||
ret = False
|
||||
|
||||
if ret and frame is not None and frame.size > 0:
|
||||
# Validate frame
|
||||
if (isinstance(frame, np.ndarray) and
|
||||
len(frame.shape) in [2, 3] and
|
||||
frame.shape[0] > 0 and frame.shape[1] > 0):
|
||||
|
||||
self.consecutive_failures = 0
|
||||
self.total_frames += 1
|
||||
self.stats['last_frame_time'] = current_time
|
||||
last_frame_time = current_time
|
||||
|
||||
self.safe_emit(self.frame_ready, self.camera_id, frame)
|
||||
self.update_stats()
|
||||
else:
|
||||
self.handle_frame_failure()
|
||||
else:
|
||||
self.handle_frame_failure()
|
||||
|
||||
# Brief sleep to prevent CPU overload
|
||||
time.sleep(0.001)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Camera {self.camera_id}: Main loop error: {e}")
|
||||
self.handle_frame_failure()
|
||||
time.sleep(0.1) # Longer sleep on error
|
||||
|
||||
except Exception as e:
|
||||
logger.critical(f"Camera {self.camera_id}: Critical thread error: {e}")
|
||||
self.safe_emit(self.error_occurred, self.camera_id, f"Thread crash: {str(e)}")
|
||||
finally:
|
||||
logger.info(f"Camera {self.camera_id}: Thread stopping")
|
||||
self.cleanup()
|
||||
|
||||
def handle_frame_failure(self):
|
||||
"""Handle frame reading failures with reconnection logic"""
|
||||
self.consecutive_failures += 1
|
||||
self.total_failures += 1
|
||||
|
||||
if self.consecutive_failures >= self.max_consecutive_failures:
|
||||
logger.warning(f"Camera {self.camera_id}: Too many failures, attempting reconnection...")
|
||||
self.safe_emit(self.connection_status, self.camera_id, False, "Reconnecting...")
|
||||
|
||||
if not self.connect_to_camera():
|
||||
logger.error(f"Camera {self.camera_id}: Reconnection failed, stopping thread")
|
||||
self.running = False
|
||||
else:
|
||||
self.consecutive_failures = 0
|
||||
|
||||
def stop(self):
|
||||
"""Safely stop the thread"""
|
||||
logger.info(f"Camera {self.camera_id}: Stopping thread...")
|
||||
|
||||
self.mutex.lock()
|
||||
self.running = False
|
||||
self.mutex.unlock()
|
||||
|
||||
# Wake up thread if it's waiting
|
||||
self.condition.wakeAll()
|
||||
|
||||
if not self.wait(3000): # 3 second timeout
|
||||
logger.warning(f"Camera {self.camera_id}: Thread did not stop gracefully, terminating...")
|
||||
try:
|
||||
self.terminate()
|
||||
if not self.wait(1000):
|
||||
logger.error(f"Camera {self.camera_id}: Thread termination failed")
|
||||
except Exception as e:
|
||||
logger.error(f"Camera {self.camera_id}: Error during termination: {e}")
|
||||
else:
|
||||
logger.info(f"Camera {self.camera_id}: Thread stopped gracefully")
|
||||
|
||||
def pause(self):
|
||||
"""Pause frame capture"""
|
||||
self.paused = True
|
||||
logger.info(f"Camera {self.camera_id}: Paused")
|
||||
|
||||
def resume(self):
|
||||
"""Resume frame capture"""
|
||||
self.paused = False
|
||||
logger.info(f"Camera {self.camera_id}: Resumed")
|
||||
|
||||
def cleanup(self):
|
||||
"""Comprehensive cleanup of all resources"""
|
||||
logger.info(f"Camera {self.camera_id}: Cleaning up resources...")
|
||||
|
||||
try:
|
||||
self.running = False
|
||||
self.safe_capture_release()
|
||||
self.safe_rtsp_close()
|
||||
|
||||
self.safe_emit(self.connection_status, self.camera_id, False, "Disconnected")
|
||||
self.update_stats()
|
||||
|
||||
logger.info(f"Camera {self.camera_id}: Cleanup completed")
|
||||
except Exception as e:
|
||||
logger.error(f"Camera {self.camera_id}: Cleanup error: {e}")
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""Get current camera status"""
|
||||
return {
|
||||
'running': self.running,
|
||||
'paused': self.paused,
|
||||
'connected': (self.cap is not None and self.cap.isOpened()) or
|
||||
(self.rtsp_client is not None and self.rtsp_client.isOpened()),
|
||||
'stream_type': self.stream_type.value if self.stream_type else 'unknown',
|
||||
'consecutive_failures': self.consecutive_failures,
|
||||
'total_frames': self.total_frames,
|
||||
'total_failures': self.total_failures,
|
||||
'stats': self.stats.copy()
|
||||
}
|
||||
85
mucapy/CollpsibleDock.py
Normal file
85
mucapy/CollpsibleDock.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from PyQt5.QtCore import Qt, QTimer, QDir, QSize, QDateTime, QRect, QThread, pyqtSignal, QMutex, QObject, QEvent
|
||||
from PyQt5.QtGui import (QImage, QPixmap, QIcon, QColor, QKeySequence, QPainter,
|
||||
QPen, QBrush)
|
||||
from PyQt5.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout,
|
||||
QWidget, QLabel, QPushButton, QComboBox, QSpinBox,
|
||||
QFileDialog, QMessageBox, QMenu, QAction, QActionGroup, QGridLayout, QGroupBox,
|
||||
QDockWidget, QScrollArea, QToolButton, QDialog,
|
||||
QShortcut, QListWidget, QFormLayout, QLineEdit,
|
||||
QCheckBox, QTabWidget, QListWidgetItem, QSplitter,
|
||||
QProgressBar, QSizePolicy)
|
||||
|
||||
class CollapsibleDock(QDockWidget):
|
||||
"""Custom dock widget with collapse/expand functionality"""
|
||||
|
||||
def __init__(self, title, parent=None):
|
||||
super().__init__(title, parent)
|
||||
self.setFeatures(QDockWidget.DockWidgetClosable |
|
||||
QDockWidget.DockWidgetMovable |
|
||||
QDockWidget.DockWidgetFloatable)
|
||||
# Allow docking only on sides to avoid central area clipping
|
||||
self.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea)
|
||||
# Prefer keeping a minimum width but allow vertical expansion
|
||||
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Expanding)
|
||||
# Ensure the dock paints its own background (prevents visual bleed/clip)
|
||||
self.setAttribute(Qt.WA_StyledBackground, True)
|
||||
|
||||
# Create a widget for the title bar that contains both toggle button and close button
|
||||
title_widget = QWidget()
|
||||
title_layout = QHBoxLayout(title_widget)
|
||||
title_layout.setContentsMargins(0, 0, 0, 0)
|
||||
title_layout.setSpacing(0)
|
||||
# Ensure title bar doesn't force tiny width
|
||||
title_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
|
||||
self.toggle_button = QToolButton()
|
||||
self.toggle_button.setIcon(QIcon.fromTheme("arrow-left"))
|
||||
self.toggle_button.setIconSize(QSize(16, 16))
|
||||
self.toggle_button.setStyleSheet("border: none;")
|
||||
self.toggle_button.clicked.connect(self.toggle_collapse)
|
||||
|
||||
title_layout.addWidget(self.toggle_button)
|
||||
title_layout.addStretch()
|
||||
|
||||
self.setTitleBarWidget(title_widget)
|
||||
self.collapsed = False
|
||||
self.original_size = None
|
||||
self.original_minimum_width = None
|
||||
self.original_maximum_width = None
|
||||
|
||||
def toggle_collapse(self):
|
||||
"""Toggle between collapsed and expanded states"""
|
||||
if self.collapsed:
|
||||
self.expand()
|
||||
else:
|
||||
self.collapse()
|
||||
|
||||
def collapse(self):
|
||||
"""Collapse the dock widget (fully hide)."""
|
||||
if not self.collapsed:
|
||||
self.original_size = self.size()
|
||||
self.original_minimum_width = self.minimumWidth()
|
||||
self.original_maximum_width = self.maximumWidth()
|
||||
# Fully hide the dock to avoid any clipping/overlap with camera panes
|
||||
self.setVisible(False)
|
||||
self.toggle_button.setIcon(QIcon.fromTheme("arrow-right"))
|
||||
self.collapsed = True
|
||||
|
||||
def expand(self):
|
||||
"""Expand (show) the dock widget"""
|
||||
if self.collapsed:
|
||||
# Restore previous constraints, falling back to sensible defaults
|
||||
minw = self.original_minimum_width if self.original_minimum_width is not None else 250
|
||||
self.setMinimumWidth(minw)
|
||||
self.setMaximumWidth(self.original_maximum_width if self.original_maximum_width is not None else 16777215)
|
||||
# Show and restore size
|
||||
self.setVisible(True)
|
||||
if self.original_size:
|
||||
self.resize(self.original_size)
|
||||
else:
|
||||
self.resize(max(minw, 250), self.height())
|
||||
# Make sure the dock is on top of central widgets
|
||||
self.raise_()
|
||||
self.toggle_button.setIcon(QIcon.fromTheme("arrow-left"))
|
||||
self.collapsed = False
|
||||
|
||||
61
mucapy/Config.py
Normal file
61
mucapy/Config.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import os
|
||||
import json
|
||||
import sys
|
||||
class Config:
|
||||
def __init__(self):
|
||||
# Use platform-specific user directory for config
|
||||
if sys.platform.startswith('win'):
|
||||
config_dir = os.path.join(os.environ.get('APPDATA', os.path.expanduser('~')), 'MuCaPy')
|
||||
pictures_dir = os.path.join(os.environ.get('USERPROFILE', os.path.expanduser('~')), 'Pictures', 'MuCaPy')
|
||||
else:
|
||||
config_dir = os.path.join(os.path.expanduser('~'), '.config', 'mucapy')
|
||||
pictures_dir = os.path.join(os.path.expanduser('~'), 'Pictures', 'MuCaPy')
|
||||
|
||||
# Create config directory if it doesn't exist
|
||||
os.makedirs(config_dir, exist_ok=True)
|
||||
|
||||
self.config_file = os.path.join(config_dir, 'config.json')
|
||||
self.settings = {
|
||||
'network_cameras': {}, # Store network cameras configuration
|
||||
'last_model_dir': '',
|
||||
'last_screenshot_dir': pictures_dir,
|
||||
'last_layout': 0,
|
||||
'last_fps': 10,
|
||||
'last_selected_cameras': [],
|
||||
'window_geometry': None,
|
||||
'confidence_threshold': 0.35,
|
||||
}
|
||||
self.load_config()
|
||||
|
||||
def load_config(self):
|
||||
"""Load configuration from JSON file"""
|
||||
try:
|
||||
if os.path.exists(self.config_file):
|
||||
with open(self.config_file, 'r') as f:
|
||||
loaded_settings = json.load(f)
|
||||
# Update settings while preserving default values for new keys
|
||||
self.settings.update(loaded_settings)
|
||||
except Exception as e:
|
||||
print(f"Error loading config: {e}")
|
||||
|
||||
def save_config(self):
|
||||
"""Save configuration to JSON file"""
|
||||
try:
|
||||
# Ensure the file's directory exists
|
||||
os.makedirs(os.path.dirname(self.config_file), exist_ok=True)
|
||||
try:
|
||||
with open(self.config_file, 'w') as f:
|
||||
json.dump(self.settings, f, indent=4)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"Error saving config: {e}")
|
||||
|
||||
def save_setting(self, key, value):
|
||||
"""Save a setting to configuration"""
|
||||
self.settings[key] = value
|
||||
self.save_config()
|
||||
|
||||
def load_setting(self, key, default=None):
|
||||
"""Load a setting from configuration"""
|
||||
return self.settings.get(key, default)
|
||||
143
mucapy/NetworkCameraDialog.py
Normal file
143
mucapy/NetworkCameraDialog.py
Normal file
@@ -0,0 +1,143 @@
|
||||
from PyQt5.QtCore import Qt, QTimer, QDir, QSize, QDateTime, QRect, QThread, pyqtSignal, QMutex, QObject, QEvent
|
||||
from PyQt5.QtGui import (QImage, QPixmap, QIcon, QColor, QKeySequence, QPainter,
|
||||
QPen, QBrush)
|
||||
from PyQt5.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout,
|
||||
QWidget, QLabel, QPushButton, QComboBox, QSpinBox,
|
||||
QFileDialog, QMessageBox, QMenu, QAction, QActionGroup, QGridLayout, QGroupBox,
|
||||
QDockWidget, QScrollArea, QToolButton, QDialog,
|
||||
QShortcut, QListWidget, QFormLayout, QLineEdit,
|
||||
QCheckBox, QTabWidget, QListWidgetItem, QSplitter,
|
||||
QProgressBar, QSizePolicy)
|
||||
|
||||
from todopackage.todo import todo
|
||||
|
||||
class NetworkCameraDialog(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Network Camera Settings")
|
||||
self.setModal(True)
|
||||
self.resize(500, 400)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Instructions label
|
||||
instructions = QLabel(todo.get_instructions_CaSeDi_QLabel())
|
||||
|
||||
instructions.setWordWrap(True)
|
||||
layout.addWidget(instructions)
|
||||
|
||||
# Camera list
|
||||
self.camera_list = QListWidget()
|
||||
layout.addWidget(self.camera_list)
|
||||
|
||||
# Input fields
|
||||
form_layout = QFormLayout()
|
||||
|
||||
# Name and URL
|
||||
self.name_edit = QLineEdit()
|
||||
self.url_edit = QLineEdit()
|
||||
form_layout.addRow("Name:", self.name_edit)
|
||||
form_layout.addRow("URL:", self.url_edit)
|
||||
|
||||
# Authentication group
|
||||
auth_group = QGroupBox("Authentication")
|
||||
auth_layout = QVBoxLayout()
|
||||
|
||||
self.auth_checkbox = QCheckBox("Enable Authentication")
|
||||
self.auth_checkbox.stateChanged.connect(self.toggle_auth_fields)
|
||||
auth_layout.addWidget(self.auth_checkbox)
|
||||
|
||||
auth_form = QFormLayout()
|
||||
self.username_edit = QLineEdit()
|
||||
self.password_edit = QLineEdit()
|
||||
self.password_edit.setEchoMode(QLineEdit.Password)
|
||||
auth_form.addRow("Username:", self.username_edit)
|
||||
auth_form.addRow("Password:", self.password_edit)
|
||||
auth_layout.addLayout(auth_form)
|
||||
|
||||
auth_group.setLayout(auth_layout)
|
||||
form_layout.addRow(auth_group)
|
||||
|
||||
layout.addLayout(form_layout)
|
||||
|
||||
# Initially disable auth fields
|
||||
self.username_edit.setEnabled(False)
|
||||
self.password_edit.setEnabled(False)
|
||||
|
||||
# Buttons
|
||||
btn_layout = QHBoxLayout()
|
||||
add_btn = QPushButton("Add Camera")
|
||||
add_btn.clicked.connect(self.add_camera)
|
||||
remove_btn = QPushButton("Remove Camera")
|
||||
remove_btn.clicked.connect(self.remove_camera)
|
||||
close_btn = QPushButton("Close")
|
||||
close_btn.clicked.connect(self.accept)
|
||||
|
||||
btn_layout.addWidget(add_btn)
|
||||
btn_layout.addWidget(remove_btn)
|
||||
btn_layout.addWidget(close_btn)
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
self.detector = parent.detector if parent else None
|
||||
self.load_cameras()
|
||||
|
||||
def toggle_auth_fields(self, state):
|
||||
"""Enable/disable authentication fields based on checkbox state"""
|
||||
enabled = state == Qt.Checked
|
||||
self.username_edit.setEnabled(enabled)
|
||||
self.password_edit.setEnabled(enabled)
|
||||
if not enabled:
|
||||
self.username_edit.clear()
|
||||
self.password_edit.clear()
|
||||
|
||||
def load_cameras(self):
|
||||
"""Load saved network cameras into the list"""
|
||||
if not self.detector:
|
||||
return
|
||||
|
||||
self.camera_list.clear()
|
||||
for name, camera_info in self.detector.network_cameras.items():
|
||||
if isinstance(camera_info, dict):
|
||||
url = camera_info.get('url', '')
|
||||
has_auth = camera_info.get('username') is not None
|
||||
display_text = f"{name} ({url})"
|
||||
if has_auth:
|
||||
display_text += " [Auth]"
|
||||
else:
|
||||
# Handle old format where camera_info was just the URL
|
||||
display_text = f"{name} ({camera_info})"
|
||||
self.camera_list.addItem(display_text)
|
||||
|
||||
def add_camera(self):
|
||||
"""Add a new network camera"""
|
||||
name = self.name_edit.text().strip()
|
||||
url = self.url_edit.text().strip()
|
||||
|
||||
if not name or not url:
|
||||
QMessageBox.warning(self, "Error", "Please enter both name and URL")
|
||||
return
|
||||
|
||||
# Ensure URL has proper format for DroidCam
|
||||
if ':4747' in url:
|
||||
if not url.endswith('/video'):
|
||||
url = url.rstrip('/') + '/video'
|
||||
if not url.startswith('http://') and not url.startswith('https://'):
|
||||
url = 'http://' + url
|
||||
|
||||
if self.detector:
|
||||
print(f"Adding network camera: {name} with URL: {url}") # Debug print
|
||||
self.detector.add_network_camera(name, url)
|
||||
self.load_cameras()
|
||||
self.name_edit.clear()
|
||||
self.url_edit.clear()
|
||||
|
||||
def remove_camera(self):
|
||||
"""Remove selected network camera"""
|
||||
current = self.camera_list.currentItem()
|
||||
if not current:
|
||||
return
|
||||
|
||||
name = current.text().split(" (")[0]
|
||||
if self.detector:
|
||||
self.detector.remove_network_camera(name)
|
||||
self.load_cameras()
|
||||
573
mucapy/PopoutWindow.py
Normal file
573
mucapy/PopoutWindow.py
Normal file
@@ -0,0 +1,573 @@
|
||||
from PyQt5.QtCore import Qt, QTimer, QDateTime, QRect, QEvent, QPointF, QPoint, QThread, pyqtSignal
|
||||
from PyQt5.QtGui import (QImage, QPixmap, QColor, QKeySequence, QPainter,
|
||||
QPen, QBrush, QFont)
|
||||
from PyQt5.QtWidgets import (QMainWindow, QVBoxLayout, QHBoxLayout,
|
||||
QWidget, QLabel, QScrollArea, QToolButton,
|
||||
QShortcut, QFileDialog, QMessageBox)
|
||||
import math
|
||||
import os
|
||||
|
||||
class SaveWorker(QThread):
|
||||
"""Worker thread for saving snapshots and recordings"""
|
||||
finished = pyqtSignal(bool, str)
|
||||
progress = pyqtSignal(int, int)
|
||||
|
||||
def __init__(self, frames, folder, cam_id, is_recording=False):
|
||||
super().__init__()
|
||||
self.frames = frames
|
||||
self.folder = folder
|
||||
self.cam_id = cam_id
|
||||
self.is_recording = is_recording
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
timestamp = QDateTime.currentDateTime().toString('yyyyMMdd_hhmmss')
|
||||
|
||||
if self.is_recording:
|
||||
for i, frame in enumerate(self.frames):
|
||||
filename = os.path.join(self.folder, f"cam_{self.cam_id}_rec_{timestamp}_frame_{i:04d}.png")
|
||||
frame.save(filename)
|
||||
self.progress.emit(i + 1, len(self.frames))
|
||||
self.finished.emit(True, f"Saved {len(self.frames)} frames")
|
||||
else:
|
||||
filename = os.path.join(self.folder, f"camera_{self.cam_id}_snapshot_{timestamp}.png")
|
||||
self.frames[0].save(filename)
|
||||
self.finished.emit(True, f"Saved to: {filename}")
|
||||
|
||||
except Exception as e:
|
||||
self.finished.emit(False, str(e))
|
||||
|
||||
|
||||
class PopoutWindow(QMainWindow):
|
||||
"""Enhanced popout window with touch support, pinch zoom, and security guard features"""
|
||||
|
||||
def __init__(self, source_display: QLabel, cam_id=None, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(f"Camera {cam_id}" if cam_id is not None else "Camera")
|
||||
self.source_display = source_display
|
||||
self.cam_id = cam_id
|
||||
self.zoom_factor = 1.0
|
||||
self.min_zoom = 0.2
|
||||
self.max_zoom = 10.0
|
||||
self.paused = False
|
||||
self.show_grid = False
|
||||
self.show_timestamp = True
|
||||
self.show_crosshair = False
|
||||
self.enhance_mode = 0
|
||||
self.recording = False
|
||||
self.record_frames = []
|
||||
self.setMinimumSize(640, 480)
|
||||
|
||||
# Touch gesture state
|
||||
self.setAttribute(Qt.WA_AcceptTouchEvents, True)
|
||||
self.gesture_type = None # 'pinch', 'pan', or None
|
||||
|
||||
# Pinch zoom state
|
||||
self.pinch_initial_distance = 0
|
||||
self.pinch_initial_zoom = 1.0
|
||||
|
||||
# Pan state (both touch and mouse)
|
||||
self.pan_active = False
|
||||
self.pan_last_pos = None
|
||||
|
||||
# Worker thread for saving
|
||||
self.save_worker = None
|
||||
|
||||
# Snapshot history
|
||||
self.snapshot_count = 0
|
||||
|
||||
# Central area: toolbar + scrollable image label
|
||||
central = QWidget()
|
||||
vbox = QVBoxLayout(central)
|
||||
vbox.setContentsMargins(4, 4, 4, 4)
|
||||
vbox.setSpacing(4)
|
||||
|
||||
# Main toolbar
|
||||
toolbar = QHBoxLayout()
|
||||
|
||||
# Zoom controls
|
||||
self.btn_zoom_in = QToolButton()
|
||||
self.btn_zoom_in.setText("+")
|
||||
self.btn_zoom_in.setMinimumSize(44, 44)
|
||||
|
||||
self.btn_zoom_out = QToolButton()
|
||||
self.btn_zoom_out.setText("-")
|
||||
self.btn_zoom_out.setMinimumSize(44, 44)
|
||||
|
||||
self.btn_zoom_reset = QToolButton()
|
||||
self.btn_zoom_reset.setText("100%")
|
||||
self.btn_zoom_reset.setMinimumSize(44, 44)
|
||||
|
||||
# Playback controls
|
||||
self.btn_pause = QToolButton()
|
||||
self.btn_pause.setText("Pause")
|
||||
self.btn_pause.setMinimumSize(60, 44)
|
||||
|
||||
self.btn_snapshot = QToolButton()
|
||||
self.btn_snapshot.setText("Snapshot")
|
||||
self.btn_snapshot.setMinimumSize(60, 44)
|
||||
|
||||
# Overlay controls
|
||||
self.btn_grid = QToolButton()
|
||||
self.btn_grid.setText("Grid")
|
||||
self.btn_grid.setMinimumSize(60, 44)
|
||||
|
||||
self.btn_time = QToolButton()
|
||||
self.btn_time.setText("Time")
|
||||
self.btn_time.setMinimumSize(60, 44)
|
||||
|
||||
self.btn_crosshair = QToolButton()
|
||||
self.btn_crosshair.setText("Crosshair")
|
||||
self.btn_crosshair.setMinimumSize(60, 44)
|
||||
|
||||
self.btn_enhance = QToolButton()
|
||||
self.btn_enhance.setText("Enhance")
|
||||
self.btn_enhance.setMinimumSize(60, 44)
|
||||
|
||||
self.btn_record = QToolButton()
|
||||
self.btn_record.setText("Record")
|
||||
self.btn_record.setMinimumSize(60, 44)
|
||||
|
||||
self.btn_full = QToolButton()
|
||||
self.btn_full.setText("Fullscreen")
|
||||
self.btn_full.setMinimumSize(60, 44)
|
||||
|
||||
for b in [self.btn_zoom_out, self.btn_zoom_in, self.btn_zoom_reset,
|
||||
self.btn_pause, self.btn_snapshot, self.btn_grid,
|
||||
self.btn_time, self.btn_crosshair, self.btn_enhance,
|
||||
self.btn_record, self.btn_full]:
|
||||
toolbar.addWidget(b)
|
||||
toolbar.addStretch(1)
|
||||
vbox.addLayout(toolbar)
|
||||
|
||||
# Status bar
|
||||
status_layout = QHBoxLayout()
|
||||
self.status_label = QLabel(f"Camera {cam_id if cam_id else 'View'} | Zoom: 100%")
|
||||
self.status_label.setStyleSheet("color: #666; font-size: 10px;")
|
||||
status_layout.addWidget(self.status_label)
|
||||
status_layout.addStretch(1)
|
||||
vbox.addLayout(status_layout)
|
||||
|
||||
# Scroll area for panning
|
||||
self.image_label = QLabel()
|
||||
self.image_label.setAlignment(Qt.AlignCenter)
|
||||
self.image_label.setAttribute(Qt.WA_AcceptTouchEvents, True)
|
||||
|
||||
self.scroll = QScrollArea()
|
||||
self.scroll.setWidget(self.image_label)
|
||||
self.scroll.setWidgetResizable(True)
|
||||
self.scroll.setAttribute(Qt.WA_AcceptTouchEvents, True)
|
||||
vbox.addWidget(self.scroll, 1)
|
||||
|
||||
self.setCentralWidget(central)
|
||||
|
||||
# Keyboard 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)
|
||||
QShortcut(QKeySequence("C"), self, activated=self.toggle_crosshair)
|
||||
|
||||
# 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_crosshair.clicked.connect(self.toggle_crosshair)
|
||||
self.btn_enhance.clicked.connect(self.cycle_enhance)
|
||||
self.btn_record.clicked.connect(self.toggle_recording)
|
||||
self.btn_full.clicked.connect(self.toggle_fullscreen)
|
||||
|
||||
# Timer to refresh from source display
|
||||
self.timer = QTimer(self)
|
||||
self.timer.timeout.connect(self.refresh_frame)
|
||||
self.timer.start(40)
|
||||
|
||||
# Event filter
|
||||
self.image_label.installEventFilter(self)
|
||||
self.scroll.viewport().installEventFilter(self)
|
||||
|
||||
# Initial render
|
||||
self.refresh_frame()
|
||||
|
||||
def closeEvent(self, event):
|
||||
if hasattr(self, 'timer') and self.timer:
|
||||
self.timer.stop()
|
||||
if self.save_worker and self.save_worker.isRunning():
|
||||
self.save_worker.wait()
|
||||
return super().closeEvent(event)
|
||||
|
||||
def toggle_fullscreen(self):
|
||||
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")
|
||||
self.update_status()
|
||||
|
||||
def toggle_grid(self):
|
||||
self.show_grid = not self.show_grid
|
||||
self.btn_grid.setStyleSheet("background-color: #4CAF50;" if self.show_grid else "")
|
||||
|
||||
def toggle_timestamp(self):
|
||||
self.show_timestamp = not self.show_timestamp
|
||||
self.btn_time.setStyleSheet("background-color: #4CAF50;" if self.show_timestamp else "")
|
||||
|
||||
def toggle_crosshair(self):
|
||||
self.show_crosshair = not self.show_crosshair
|
||||
self.btn_crosshair.setStyleSheet("background-color: #4CAF50;" if self.show_crosshair else "")
|
||||
|
||||
def cycle_enhance(self):
|
||||
self.enhance_mode = (self.enhance_mode + 1) % 4
|
||||
enhance_names = ["Off", "Sharpen", "Edges", "Denoise"]
|
||||
self.btn_enhance.setText(f"Enhance: {enhance_names[self.enhance_mode]}")
|
||||
if self.enhance_mode == 0:
|
||||
self.btn_enhance.setStyleSheet("")
|
||||
else:
|
||||
self.btn_enhance.setStyleSheet("background-color: #2196F3;")
|
||||
self.update_status()
|
||||
|
||||
def toggle_recording(self):
|
||||
self.recording = not self.recording
|
||||
if self.recording:
|
||||
self.record_frames = []
|
||||
self.btn_record.setText("Stop Rec")
|
||||
self.btn_record.setStyleSheet("background-color: #f44336;")
|
||||
else:
|
||||
self.btn_record.setText("Record")
|
||||
self.btn_record.setStyleSheet("")
|
||||
if self.record_frames:
|
||||
self.save_recording()
|
||||
self.update_status()
|
||||
|
||||
def save_recording(self):
|
||||
if not self.record_frames:
|
||||
return
|
||||
|
||||
try:
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Save Recording",
|
||||
f"Save {len(self.record_frames)} recorded frames as images?",
|
||||
QMessageBox.Yes | QMessageBox.No
|
||||
)
|
||||
|
||||
if reply == QMessageBox.Yes:
|
||||
folder = QFileDialog.getExistingDirectory(self, "Select Folder for Recording")
|
||||
if folder:
|
||||
self.save_worker = SaveWorker(self.record_frames.copy(), folder, self.cam_id, True)
|
||||
self.save_worker.finished.connect(self.on_save_finished)
|
||||
self.save_worker.progress.connect(self.on_save_progress)
|
||||
self.save_worker.start()
|
||||
self.status_label.setText("Saving recording...")
|
||||
except Exception as e:
|
||||
print(f"Error saving recording: {e}")
|
||||
|
||||
self.record_frames = []
|
||||
|
||||
def on_save_progress(self, current, total):
|
||||
self.status_label.setText(f"Saving: {current}/{total} frames")
|
||||
|
||||
def on_save_finished(self, success, message):
|
||||
if success:
|
||||
QMessageBox.information(self, "Recording Saved", message)
|
||||
else:
|
||||
QMessageBox.warning(self, "Save Error", f"Error saving: {message}")
|
||||
self.update_status()
|
||||
|
||||
def take_snapshot(self):
|
||||
if hasattr(self.source_display, 'take_screenshot'):
|
||||
self.source_display.take_screenshot()
|
||||
return
|
||||
|
||||
pm = self.current_pixmap()
|
||||
if pm and not pm.isNull():
|
||||
try:
|
||||
self.snapshot_count += 1
|
||||
timestamp = QDateTime.currentDateTime().toString('yyyyMMdd_hhmmss')
|
||||
filename = f"camera_{self.cam_id}_snapshot_{timestamp}.png"
|
||||
file_path, _ = QFileDialog.getSaveFileName(self, "Save Snapshot", filename, "Images (*.png *.jpg)")
|
||||
if file_path:
|
||||
pm.save(file_path)
|
||||
QMessageBox.information(self, "Snapshot Saved", f"Saved to: {file_path}")
|
||||
except Exception as e:
|
||||
print(f"Error saving snapshot: {e}")
|
||||
|
||||
def current_pixmap(self):
|
||||
return self.source_display.pixmap()
|
||||
|
||||
def refresh_frame(self):
|
||||
if self.paused:
|
||||
return
|
||||
|
||||
pm = self.current_pixmap()
|
||||
if not pm or pm.isNull():
|
||||
return
|
||||
|
||||
try:
|
||||
# Store frame for recording
|
||||
if self.recording:
|
||||
self.record_frames.append(pm.copy())
|
||||
if len(self.record_frames) > 300:
|
||||
self.record_frames.pop(0)
|
||||
|
||||
# Create overlays
|
||||
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')
|
||||
cam_text = f"Cam {self.cam_id} | {ts}" if self.cam_id else ts
|
||||
|
||||
font = QFont()
|
||||
font.setPointSize(11)
|
||||
font.setBold(True)
|
||||
painter.setFont(font)
|
||||
|
||||
metrics = painter.fontMetrics()
|
||||
w = metrics.width(cam_text) + 16
|
||||
h = metrics.height() + 10
|
||||
rect = QRect(10, 10, w, h)
|
||||
painter.setPen(Qt.NoPen)
|
||||
painter.setBrush(QBrush(QColor(0, 0, 0, 180)))
|
||||
painter.drawRoundedRect(rect, 6, 6)
|
||||
painter.setPen(QPen(QColor(255, 255, 255)))
|
||||
painter.drawText(rect, Qt.AlignCenter, cam_text)
|
||||
|
||||
# Grid overlay
|
||||
if self.show_grid:
|
||||
painter.setPen(QPen(QColor(255, 255, 255, 120), 2))
|
||||
img_w = image.width()
|
||||
img_h = image.height()
|
||||
|
||||
for i in range(1, 3):
|
||||
x = int(img_w * i / 3)
|
||||
y = int(img_h * i / 3)
|
||||
painter.drawLine(x, 0, x, img_h)
|
||||
painter.drawLine(0, y, img_w, y)
|
||||
|
||||
painter.setPen(QPen(QColor(255, 255, 0, 100), 1, Qt.DashLine))
|
||||
painter.drawLine(img_w // 2, 0, img_w // 2, img_h)
|
||||
painter.drawLine(0, img_h // 2, img_w, img_h // 2)
|
||||
|
||||
# Crosshair overlay
|
||||
if self.show_crosshair:
|
||||
painter.setPen(QPen(QColor(255, 0, 0, 200), 2))
|
||||
img_w = image.width()
|
||||
img_h = image.height()
|
||||
center_x = img_w // 2
|
||||
center_y = img_h // 2
|
||||
size = 30
|
||||
|
||||
painter.drawLine(center_x - size, center_y, center_x + size, center_y)
|
||||
painter.drawLine(center_x, center_y - size, center_x, center_y + size)
|
||||
|
||||
painter.setPen(QPen(QColor(255, 0, 0, 150), 1))
|
||||
painter.drawEllipse(QPoint(center_x, center_y), 5, 5)
|
||||
|
||||
# Recording indicator
|
||||
if self.recording:
|
||||
painter.setPen(Qt.NoPen)
|
||||
painter.setBrush(QBrush(QColor(255, 0, 0, 200)))
|
||||
painter.drawEllipse(image.width() - 30, 10, 15, 15)
|
||||
|
||||
painter.setPen(QPen(QColor(255, 255, 255)))
|
||||
font = QFont()
|
||||
font.setPointSize(9)
|
||||
font.setBold(True)
|
||||
painter.setFont(font)
|
||||
painter.drawText(QRect(image.width() - 100, 25, 90, 20),
|
||||
Qt.AlignRight, f"REC {len(self.record_frames)}")
|
||||
|
||||
painter.end()
|
||||
|
||||
composed = QPixmap.fromImage(image)
|
||||
|
||||
# Apply zoom
|
||||
if self.zoom_factor != 1.0:
|
||||
target_w = int(composed.width() * self.zoom_factor)
|
||||
target_h = int(composed.height() * self.zoom_factor)
|
||||
composed = composed.scaled(target_w, target_h, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
||||
|
||||
self.image_label.setPixmap(composed)
|
||||
self.update_cursor()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error in refresh_frame: {e}")
|
||||
|
||||
def update_status(self):
|
||||
try:
|
||||
zoom_pct = int(self.zoom_factor * 100)
|
||||
status_parts = [f"Camera {self.cam_id if self.cam_id else 'View'}", f"Zoom: {zoom_pct}%"]
|
||||
|
||||
if self.paused:
|
||||
status_parts.append("PAUSED")
|
||||
if self.recording:
|
||||
status_parts.append(f"RECORDING ({len(self.record_frames)} frames)")
|
||||
if self.enhance_mode != 0:
|
||||
enhance_names = ["Off", "Sharpen", "Edges", "Denoise"]
|
||||
status_parts.append(f"Enhance: {enhance_names[self.enhance_mode]}")
|
||||
|
||||
self.status_label.setText(" | ".join(status_parts))
|
||||
except Exception as e:
|
||||
print(f"Error updating status: {e}")
|
||||
|
||||
def zoom_in(self):
|
||||
self.set_zoom(self.zoom_factor * 1.3)
|
||||
|
||||
def zoom_out(self):
|
||||
self.set_zoom(self.zoom_factor / 1.3)
|
||||
|
||||
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_status()
|
||||
self.update_cursor()
|
||||
|
||||
def can_pan(self):
|
||||
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.pan_active else Qt.ClosedHandCursor)
|
||||
else:
|
||||
self.image_label.setCursor(Qt.ArrowCursor)
|
||||
|
||||
def distance(self, p1: QPointF, p2: QPointF) -> float:
|
||||
dx = p2.x() - p1.x()
|
||||
dy = p2.y() - p1.y()
|
||||
return math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
def event(self, event):
|
||||
"""Handle touch events"""
|
||||
try:
|
||||
if event.type() == QEvent.TouchBegin:
|
||||
points = event.touchPoints()
|
||||
|
||||
if len(points) == 2:
|
||||
self.gesture_type = 'pinch'
|
||||
p1 = points[0].pos()
|
||||
p2 = points[1].pos()
|
||||
self.pinch_initial_distance = self.distance(p1, p2)
|
||||
self.pinch_initial_zoom = self.zoom_factor
|
||||
|
||||
elif len(points) == 1 and self.can_pan():
|
||||
self.gesture_type = 'pan'
|
||||
self.pan_active = True
|
||||
self.pan_last_pos = points[0].pos()
|
||||
self.update_cursor()
|
||||
|
||||
event.accept()
|
||||
return True
|
||||
|
||||
elif event.type() == QEvent.TouchUpdate:
|
||||
points = event.touchPoints()
|
||||
|
||||
if self.gesture_type == 'pinch' and len(points) == 2:
|
||||
p1 = points[0].pos()
|
||||
p2 = points[1].pos()
|
||||
current_distance = self.distance(p1, p2)
|
||||
|
||||
if self.pinch_initial_distance > 10:
|
||||
scale_factor = current_distance / self.pinch_initial_distance
|
||||
new_zoom = self.pinch_initial_zoom * scale_factor
|
||||
self.set_zoom(new_zoom)
|
||||
|
||||
elif self.gesture_type == 'pan' and len(points) == 1 and self.can_pan():
|
||||
current_pos = points[0].pos()
|
||||
if self.pan_last_pos is not None:
|
||||
delta = current_pos - self.pan_last_pos
|
||||
hbar = self.scroll.horizontalScrollBar()
|
||||
vbar = self.scroll.verticalScrollBar()
|
||||
hbar.setValue(int(hbar.value() - delta.x()))
|
||||
vbar.setValue(int(vbar.value() - delta.y()))
|
||||
|
||||
self.pan_last_pos = current_pos
|
||||
|
||||
event.accept()
|
||||
return True
|
||||
|
||||
elif event.type() in (QEvent.TouchEnd, QEvent.TouchCancel):
|
||||
self.gesture_type = None
|
||||
self.pan_active = False
|
||||
self.pan_last_pos = None
|
||||
self.pinch_initial_distance = 0
|
||||
self.update_cursor()
|
||||
|
||||
event.accept()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error in touch event: {e}")
|
||||
|
||||
return super().event(event)
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
"""Handle mouse events"""
|
||||
try:
|
||||
if obj is self.image_label or obj is self.scroll.viewport():
|
||||
if event.type() == QEvent.Wheel:
|
||||
delta = event.angleDelta().y()
|
||||
if delta > 0:
|
||||
self.zoom_in()
|
||||
else:
|
||||
self.zoom_out()
|
||||
return True
|
||||
|
||||
if event.type() == QEvent.MouseButtonPress and event.button() == Qt.LeftButton:
|
||||
if self.can_pan():
|
||||
self.pan_active = True
|
||||
self.pan_last_pos = event.pos()
|
||||
self.update_cursor()
|
||||
return True
|
||||
|
||||
if event.type() == QEvent.MouseMove and self.pan_active:
|
||||
if self.pan_last_pos is not None:
|
||||
delta = event.pos() - self.pan_last_pos
|
||||
hbar = self.scroll.horizontalScrollBar()
|
||||
vbar = self.scroll.verticalScrollBar()
|
||||
hbar.setValue(hbar.value() - delta.x())
|
||||
vbar.setValue(vbar.value() - delta.y())
|
||||
self.pan_last_pos = event.pos()
|
||||
return True
|
||||
|
||||
if event.type() == QEvent.MouseButtonRelease and event.button() == Qt.LeftButton:
|
||||
if self.pan_active:
|
||||
self.pan_active = False
|
||||
self.pan_last_pos = None
|
||||
self.update_cursor()
|
||||
return True
|
||||
|
||||
if event.type() == QEvent.Leave:
|
||||
self.pan_active = False
|
||||
self.pan_last_pos = None
|
||||
self.update_cursor()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error in eventFilter: {e}")
|
||||
|
||||
return super().eventFilter(obj, event)
|
||||
468
mucapy/YoloClass.py
Normal file
468
mucapy/YoloClass.py
Normal file
@@ -0,0 +1,468 @@
|
||||
import numpy as np
|
||||
import cv2
|
||||
import time
|
||||
import platform
|
||||
import os
|
||||
import subprocess
|
||||
from PyQt5.QtCore import Qt, QTimer, QDir, QSize, QDateTime, QRect, QThread, pyqtSignal, QMutex, QObject, QEvent
|
||||
from PyQt5.QtGui import (QImage, QPixmap, QIcon, QColor, QKeySequence, QPainter,
|
||||
QPen, QBrush)
|
||||
from PyQt5.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout,
|
||||
QWidget, QLabel, QPushButton, QComboBox, QSpinBox,
|
||||
QFileDialog, QMessageBox, QMenu, QAction, QActionGroup, QGridLayout, QGroupBox,
|
||||
QDockWidget, QScrollArea, QToolButton, QDialog,
|
||||
QShortcut, QListWidget, QFormLayout, QLineEdit,
|
||||
QCheckBox, QTabWidget, QListWidgetItem, QSplitter,
|
||||
QProgressBar, QSizePolicy)
|
||||
|
||||
from CameraThread import CameraThread
|
||||
from Config import Config
|
||||
import sys
|
||||
from CameraScanThread import CameraScanThread
|
||||
class MultiCamYOLODetector(QObject):
|
||||
cameras_scanned = pyqtSignal(list, dict) # Emits (available_cameras, index_to_name)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.cameras = []
|
||||
self.camera_threads = {} # Dictionary to store camera threads
|
||||
self.net = None
|
||||
self.classes = []
|
||||
self.colors = []
|
||||
self.target_fps = 10
|
||||
self.last_frame_time = 0
|
||||
self.frame_interval = 1.0 / self.target_fps
|
||||
self.available_cameras = []
|
||||
self.model_dir = ""
|
||||
self.cuda_available = self.check_cuda()
|
||||
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)
|
||||
self.network_cameras = self.config.load_setting('network_cameras', {})
|
||||
self.target_fps = self.config.load_setting('last_fps', 10)
|
||||
self.frame_interval = 1.0 / self.target_fps
|
||||
|
||||
# Load last used model if available
|
||||
last_model = self.config.load_setting('last_model_dir')
|
||||
if last_model and os.path.exists(last_model):
|
||||
self.load_yolo_model(last_model)
|
||||
|
||||
def check_cuda(self):
|
||||
"""Check if CUDA is available"""
|
||||
try:
|
||||
count = cv2.cuda.getCudaEnabledDeviceCount()
|
||||
return count > 0
|
||||
except:
|
||||
return False
|
||||
|
||||
def add_network_camera(self, name, url):
|
||||
"""Add a network camera to the saved list"""
|
||||
self.network_cameras[name] = url
|
||||
self.config.save_setting('network_cameras', self.network_cameras)
|
||||
|
||||
def remove_network_camera(self, name):
|
||||
"""Remove a network camera from the saved list"""
|
||||
if name in self.network_cameras:
|
||||
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):
|
||||
"""Get camera names on Windows using DirectShow (COM)."""
|
||||
names = {}
|
||||
|
||||
import platform
|
||||
if platform.system().lower() != "windows":
|
||||
for c in cams:
|
||||
names[c] = None
|
||||
return names
|
||||
|
||||
try:
|
||||
import comtypes
|
||||
from comtypes import GUID, POINTER, HRESULT, COMMETHOD, BSTR
|
||||
from ctypes import c_ulong, byref
|
||||
from comtypes.automation import VARIANT
|
||||
|
||||
# GUIDs
|
||||
CLSID_SystemDeviceEnum = GUID("{62BE5D10-60EB-11D0-BD3B-00A0C911CE86}")
|
||||
CLSID_VideoInputDeviceCategory = GUID("{860BB310-5D01-11D0-BD3B-00A0C911CE86}")
|
||||
IID_ICreateDevEnum = GUID("{29840822-5B84-11D0-BD3B-00A0C911CE86}")
|
||||
IID_IPropertyBag = GUID("{55272A00-42CB-11CE-8135-00AA004BB851}")
|
||||
|
||||
# Interfaces
|
||||
class IEnumMoniker(comtypes.IUnknown):
|
||||
_iid_ = GUID("{00000102-0000-0000-C000-000000000046}")
|
||||
_methods_ = [
|
||||
COMMETHOD([], HRESULT, 'Next',
|
||||
(['in'], c_ulong, 'celt'),
|
||||
(['out'], POINTER(POINTER(comtypes.IUnknown)), 'rgelt'),
|
||||
(['out'], POINTER(c_ulong), 'pceltFetched')),
|
||||
]
|
||||
|
||||
class IPropertyBag(comtypes.IUnknown):
|
||||
_iid_ = IID_IPropertyBag
|
||||
_methods_ = [
|
||||
COMMETHOD([], HRESULT, 'Read',
|
||||
(['in'], BSTR, 'pszPropName'),
|
||||
(['in', 'out'], POINTER(VARIANT), 'pVar'),
|
||||
(['in'], POINTER(comtypes.IUnknown), 'pErrorLog')),
|
||||
]
|
||||
|
||||
class ICreateDevEnum(comtypes.IUnknown):
|
||||
_iid_ = IID_ICreateDevEnum
|
||||
_methods_ = [
|
||||
COMMETHOD([], HRESULT, "CreateClassEnumerator",
|
||||
(['in'], POINTER(GUID), 'clsidDeviceClass'),
|
||||
(['out'], POINTER(POINTER(IEnumMoniker)), 'ppEnumMoniker'))
|
||||
]
|
||||
|
||||
comtypes.CoInitialize()
|
||||
dev_enum = comtypes.CoCreateInstance(
|
||||
CLSID_SystemDeviceEnum,
|
||||
interface=ICreateDevEnum
|
||||
)
|
||||
|
||||
enum_moniker = POINTER(IEnumMoniker)()
|
||||
hr = dev_enum.CreateClassEnumerator(
|
||||
CLSID_VideoInputDeviceCategory, # pass GUID directly, no byref
|
||||
byref(enum_moniker) # output pointer is byref
|
||||
)
|
||||
|
||||
if hr != 0 or not enum_moniker:
|
||||
raise RuntimeError("No video devices found")
|
||||
|
||||
device_names = []
|
||||
fetched = c_ulong()
|
||||
moniker = POINTER(comtypes.IUnknown)()
|
||||
while enum_moniker.Next(1, byref(moniker), byref(fetched)) == 0:
|
||||
prop_bag = moniker.BindToStorage(None, None, IPropertyBag)
|
||||
if prop_bag:
|
||||
name_var = VARIANT()
|
||||
if prop_bag.Read("FriendlyName", byref(name_var), None) == 0:
|
||||
device_names.append(str(name_var.value))
|
||||
moniker = POINTER(comtypes.IUnknown)() # release
|
||||
|
||||
# map to cams
|
||||
idx_only = [c for c in cams if not c.startswith("net:") and not c.startswith("/dev/")]
|
||||
for i, cam in enumerate(idx_only):
|
||||
names[cam] = device_names[i] if i < len(device_names) else None
|
||||
|
||||
except Exception as e:
|
||||
print(f"get_camera_names_windows failed: {e}")
|
||||
for c in cams:
|
||||
names[c] = None
|
||||
|
||||
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 with platform-specific backends"""
|
||||
self.available_cameras = []
|
||||
|
||||
print(f"Scanning for cameras on {sys.platform}...")
|
||||
|
||||
# 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(i, backend)
|
||||
if cap.isOpened():
|
||||
ret, frame = cap.read()
|
||||
if ret and frame is not None:
|
||||
self.available_cameras.append(str(i))
|
||||
cap.release()
|
||||
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 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):
|
||||
"""Load YOLO model from selected directory with better error handling"""
|
||||
self.model_dir = model_dir
|
||||
try:
|
||||
# Find model files in the directory
|
||||
weights = [f for f in os.listdir(model_dir) if f.endswith(('.weights', '.onnx'))]
|
||||
configs = [f for f in os.listdir(model_dir) if f.endswith('.cfg')]
|
||||
classes = [f for f in os.listdir(model_dir) if f.endswith('.names')]
|
||||
|
||||
if not weights or not configs or not classes:
|
||||
return False
|
||||
|
||||
# Use the first found files
|
||||
weights_path = os.path.join(model_dir, weights[0])
|
||||
config_path = os.path.join(model_dir, configs[0])
|
||||
classes_path = os.path.join(model_dir, classes[0])
|
||||
|
||||
self.net = cv2.dnn.readNet(weights_path, config_path)
|
||||
|
||||
# Set backend based on availability
|
||||
if self.cuda_available:
|
||||
try:
|
||||
self.net.setPreferableBackend(cv2.dnn.DNN_BACKEND_CUDA)
|
||||
self.net.setPreferableTarget(cv2.dnn.DNN_TARGET_CUDA)
|
||||
except:
|
||||
# Fall back to CPU if CUDA fails
|
||||
self.net.setPreferableBackend(cv2.dnn.DNN_BACKEND_OPENCV)
|
||||
self.net.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU)
|
||||
else:
|
||||
self.net.setPreferableBackend(cv2.dnn.DNN_BACKEND_OPENCV)
|
||||
self.net.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU)
|
||||
|
||||
try:
|
||||
with open(classes_path, 'r') as f:
|
||||
self.classes = f.read().strip().split('\n')
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
np.random.seed(42)
|
||||
self.colors = np.random.randint(0, 255, size=(len(self.classes), 3), dtype='uint8')
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error loading YOLO model: {e}")
|
||||
return False
|
||||
|
||||
def connect_cameras(self, camera_paths):
|
||||
"""Connect to multiple cameras using background threads for smooth UI"""
|
||||
self.disconnect_cameras()
|
||||
|
||||
# Prepare internal state
|
||||
self.cameras = [] # store identifiers/paths only
|
||||
self.latest_frames = {}
|
||||
|
||||
# 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 (stop threads)"""
|
||||
# Stop and remove threads
|
||||
for idx, thread in list(self.camera_threads.items()):
|
||||
try:
|
||||
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):
|
||||
"""Return latest frames without blocking the GUI thread."""
|
||||
frames = []
|
||||
# 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:
|
||||
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
|
||||
|
||||
def get_detections(self, frame):
|
||||
"""Perform YOLO object detection on a frame with error handling"""
|
||||
if self.net is None:
|
||||
return frame
|
||||
|
||||
try:
|
||||
blob = cv2.dnn.blobFromImage(frame, 1 / 255.0, (416, 416), swapRB=True, crop=False)
|
||||
self.net.setInput(blob)
|
||||
|
||||
# Get output layer names compatible with different OpenCV versions
|
||||
try:
|
||||
layer_names = self.net.getLayerNames()
|
||||
output_layers = [layer_names[i - 1] for i in self.net.getUnconnectedOutLayers()]
|
||||
except:
|
||||
output_layers = self.net.getUnconnectedOutLayersNames()
|
||||
|
||||
outputs = self.net.forward(output_layers)
|
||||
|
||||
boxes = []
|
||||
confidences = []
|
||||
class_ids = []
|
||||
|
||||
for output in outputs:
|
||||
for detection in output:
|
||||
scores = detection[5:]
|
||||
class_id = np.argmax(scores)
|
||||
confidence = scores[class_id]
|
||||
|
||||
if confidence > self.confidence_threshold: # Use configurable threshold
|
||||
box = detection[0:4] * np.array([frame.shape[1], frame.shape[0],
|
||||
frame.shape[1], frame.shape[0]])
|
||||
(centerX, centerY, width, height) = box.astype('int')
|
||||
x = int(centerX - (width / 2))
|
||||
y = int(centerY - (height / 2))
|
||||
|
||||
boxes.append([x, y, int(width), int(height)])
|
||||
confidences.append(float(confidence))
|
||||
class_ids.append(class_id)
|
||||
|
||||
indices = cv2.dnn.NMSBoxes(boxes, confidences, self.confidence_threshold, 0.4)
|
||||
|
||||
person_detected = False
|
||||
if len(indices) > 0:
|
||||
for i in indices.flatten():
|
||||
(x, y, w, h) = boxes[i]
|
||||
color = [int(c) for c in self.colors[class_ids[i]]]
|
||||
cv2.rectangle(frame, (x, y), (x + w, y + h), color, 2)
|
||||
cls_name = self.classes[class_ids[i]] if 0 <= class_ids[i] < len(self.classes) else str(
|
||||
class_ids[i])
|
||||
text = f"{cls_name}: {confidences[i]:.2f}"
|
||||
cv2.putText(frame, text, (x, y - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2)
|
||||
if not person_detected and str(cls_name).lower() == 'person':
|
||||
person_detected = True
|
||||
# Auto-trigger alert if a person is detected on any camera and alerts are enabled
|
||||
try:
|
||||
if person_detected:
|
||||
parent_window = self.parent()
|
||||
if parent_window is not None:
|
||||
# trigger_alert() has its own internal guards (enabled, cooldown, playing)
|
||||
parent_window.trigger_alert()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"Detection error: {e}")
|
||||
|
||||
return frame
|
||||
46
mucapy/compile.py
Normal file
46
mucapy/compile.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import os
|
||||
from PIL import Image
|
||||
import PyInstaller.__main__
|
||||
import PyQt5
|
||||
|
||||
# Paths
|
||||
ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
MAIN_SCRIPT = os.path.join(ROOT_DIR, "main.py")
|
||||
STYLING_DIR = os.path.join(ROOT_DIR, "styling")
|
||||
|
||||
# Icon paths
|
||||
PNG_ICON = os.path.join(STYLING_DIR, "logo.png")
|
||||
ICO_ICON = os.path.join(STYLING_DIR, "logo.ico")
|
||||
|
||||
# Convert PNG to ICO
|
||||
img = Image.open(PNG_ICON)
|
||||
img.save(ICO_ICON, format="ICO", sizes=[(256,256), (128,128), (64,64), (32,32), (16,16)])
|
||||
print(f"Converted {PNG_ICON} to {ICO_ICON}")
|
||||
|
||||
# Detect PyQt5 platforms folder automatically
|
||||
pyqt_dir = os.path.dirname(PyQt5.__file__)
|
||||
platforms_path = None
|
||||
|
||||
# Walk recursively to find the 'platforms' folder
|
||||
for root, dirs, files in os.walk(pyqt_dir):
|
||||
if 'platforms' in dirs:
|
||||
platforms_path = os.path.join(root, 'platforms')
|
||||
break
|
||||
|
||||
if platforms_path is None or not os.path.exists(platforms_path):
|
||||
raise FileNotFoundError(f"Could not locate PyQt5 'platforms' folder under {pyqt_dir}")
|
||||
|
||||
print(f"Using PyQt5 platforms folder: {platforms_path}")
|
||||
|
||||
# Build EXE with PyInstaller
|
||||
PyInstaller.__main__.run([
|
||||
MAIN_SCRIPT,
|
||||
'--noconfirm',
|
||||
'--onefile',
|
||||
'--windowed',
|
||||
f'--icon={ICO_ICON}',
|
||||
# Only include the platforms folder (minimal requirement for PyQt5)
|
||||
'--add-data', f'{platforms_path};PyQt5/Qt/plugins/platforms',
|
||||
])
|
||||
|
||||
print("Build complete! Check the 'dist' folder for the executable.")
|
||||
51
mucapy/initqt.py
Normal file
51
mucapy/initqt.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import os
|
||||
import platform
|
||||
|
||||
class initQT:
|
||||
"""
|
||||
This is a QOL Change if you prefer to do it the hard way. Or you just like to get Fist Fucked then i suggest you remove the Function Calls in the
|
||||
Main Call of the Class!
|
||||
|
||||
This is not needed for Windows as it does this Automatically (at least i think)
|
||||
If some shit that is supposed to happen isnt happening. Step through this Class Via Debuggers!
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.session_type = None # This is for QT #
|
||||
#--------------------#
|
||||
self.env = os.environ.copy() # This is for CV2 #
|
||||
|
||||
def getenv(self):
|
||||
# If the OS is Linux get Qts Session Type
|
||||
if platform.system() == "Linux":
|
||||
self.session_type = os.getenv("XDG_SESSION_TYPE")
|
||||
return self.session_type
|
||||
else:
|
||||
# If theres no Type then Exit 1
|
||||
print(
|
||||
"No XDG Session Type found!"
|
||||
"echo $XDG_SESSION_TYPE"
|
||||
"Run this command in bash!"
|
||||
)
|
||||
pass
|
||||
|
||||
def setenv(self):
|
||||
# Set the Session Type to the one it got
|
||||
if self.session_type:
|
||||
os.environ["XDG_SESSION_TYPE"] = self.session_type
|
||||
else:
|
||||
# If this fails then just exit with 1
|
||||
print(
|
||||
"Setting the XDG_SESSION_TYPE failed!"
|
||||
f"export XDG_SESSION_TYPE={self.session_type}"
|
||||
"run this command in bash"
|
||||
)
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def shutupCV():
|
||||
# This needs some fixing as this only works before importing CV2 ; too much refactoring work tho!
|
||||
if platform.system() == "Linux":
|
||||
os.environ["OPENCV_LOG_LEVEL"] = "ERROR"
|
||||
else:
|
||||
pass
|
||||
1592
mucapy/main.py
1592
mucapy/main.py
File diff suppressed because it is too large
Load Diff
36
mucapy/styling/about.qss
Normal file
36
mucapy/styling/about.qss
Normal 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;
|
||||
}
|
||||
11
mucapy/styling/bar/a85.qss
Normal file
11
mucapy/styling/bar/a85.qss
Normal 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;
|
||||
}
|
||||
11
mucapy/styling/bar/else.qss
Normal file
11
mucapy/styling/bar/else.qss
Normal 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;
|
||||
}
|
||||
27
mucapy/styling/bar/seperate/a85.qss
Normal file
27
mucapy/styling/bar/seperate/a85.qss
Normal file
@@ -0,0 +1,27 @@
|
||||
QProgressBar {
|
||||
border: 2px solid #550000;
|
||||
border-radius: 50px;
|
||||
background-color: qradialgradient(
|
||||
cx:0.5, cy:0.5,
|
||||
fx:0.5, fy:0.5,
|
||||
radius:1.0,
|
||||
stop:0 #0d0d0d,
|
||||
stop:1 #1a1a1a
|
||||
);
|
||||
text-align: center;
|
||||
color: #cccccc;
|
||||
font-family: "Fira Code", "OCR A Std", monospace;
|
||||
font-style: italic;
|
||||
font-size: 14px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
QProgressBar::chunk {
|
||||
background: qlineargradient(
|
||||
x1:0, y1:0, x2:1, y2:1,
|
||||
stop:0 #ff0033,
|
||||
stop:1 #ff6666
|
||||
);
|
||||
border-radius: 50px;
|
||||
margin: 1px;
|
||||
}
|
||||
27
mucapy/styling/bar/seperate/else.qss
Normal file
27
mucapy/styling/bar/seperate/else.qss
Normal file
@@ -0,0 +1,27 @@
|
||||
QProgressBar {
|
||||
border: 2px solid #550000;
|
||||
border-radius: 50px;
|
||||
background-color: qradialgradient(
|
||||
cx:0.5, cy:0.5,
|
||||
fx:0.5, fy:0.5,
|
||||
radius:1.0,
|
||||
stop:0 #0d0d0d,
|
||||
stop:1 #1a1a1a
|
||||
);
|
||||
text-align: center;
|
||||
color: #cccccc;
|
||||
font-family: "Fira Code", "OCR A Std", monospace;
|
||||
font-style: italic;
|
||||
font-size: 14px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
QProgressBar::chunk {
|
||||
background: qlineargradient(
|
||||
x1:0, y1:0, x2:1, y2:1,
|
||||
stop:0 #ff0033,
|
||||
stop:1 #ff6666
|
||||
);
|
||||
border-radius: 50px;
|
||||
margin: 1px;
|
||||
}
|
||||
27
mucapy/styling/bar/seperate/u60.qss
Normal file
27
mucapy/styling/bar/seperate/u60.qss
Normal file
@@ -0,0 +1,27 @@
|
||||
QProgressBar {
|
||||
border: 2px solid #550000;
|
||||
border-radius: 50px;
|
||||
background-color: qradialgradient(
|
||||
cx:0.5, cy:0.5,
|
||||
fx:0.5, fy:0.5,
|
||||
radius:1.0,
|
||||
stop:0 #0d0d0d,
|
||||
stop:1 #1a1a1a
|
||||
);
|
||||
text-align: center;
|
||||
color: #cccccc;
|
||||
font-family: "Fira Code", "OCR A Std", monospace;
|
||||
font-style: italic;
|
||||
font-size: 14px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
QProgressBar::chunk {
|
||||
background: qlineargradient(
|
||||
x1:0, y1:0, x2:1, y2:1,
|
||||
stop:0 #ff0033,
|
||||
stop:1 #ff6666
|
||||
);
|
||||
border-radius: 50px;
|
||||
margin: 1px;
|
||||
}
|
||||
11
mucapy/styling/bar/u60.qss
Normal file
11
mucapy/styling/bar/u60.qss
Normal 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;
|
||||
}
|
||||
6
mucapy/styling/camera_display.qss
Normal file
6
mucapy/styling/camera_display.qss
Normal file
@@ -0,0 +1,6 @@
|
||||
QLabel {
|
||||
background-color: #1E1E1E;
|
||||
color: #DDD;
|
||||
border: 2px solid #444;
|
||||
border-radius: 4px;
|
||||
}
|
||||
6
mucapy/styling/cleardisplay.qss
Normal file
6
mucapy/styling/cleardisplay.qss
Normal file
@@ -0,0 +1,6 @@
|
||||
QLabel {
|
||||
background-color: #1E1E1E;
|
||||
color: #DDD;
|
||||
border: 2px solid #444;
|
||||
border-radius: 4px;
|
||||
}
|
||||
12
mucapy/styling/core_bar.qss
Normal file
12
mucapy/styling/core_bar.qss
Normal 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;
|
||||
}
|
||||
11
mucapy/styling/cpu_progress.qss
Normal file
11
mucapy/styling/cpu_progress.qss
Normal 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.ico
Normal file
BIN
mucapy/styling/logo.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
mucapy/styling/logo.png
Normal file
BIN
mucapy/styling/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
158
mucapy/styling/mainwindow.qss
Normal file
158
mucapy/styling/mainwindow.qss
Normal file
@@ -0,0 +1,158 @@
|
||||
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;
|
||||
}
|
||||
|
||||
QScrollBar:vertical {
|
||||
background: #1E1E1E; /* Scrollbar background */
|
||||
width: 10px;
|
||||
margin: 0px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:vertical {
|
||||
background: #3A3A3A; /* Scroll handle */
|
||||
min-height: 20px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:vertical:hover {
|
||||
background: #555555; /* Hover color */
|
||||
}
|
||||
|
||||
QScrollBar::handle:vertical:pressed {
|
||||
background: #6A6A6A; /* Active color */
|
||||
}
|
||||
|
||||
QScrollBar::add-line:vertical,
|
||||
QScrollBar::sub-line:vertical {
|
||||
height: 0px; /* Hide arrows */
|
||||
}
|
||||
|
||||
QScrollBar::add-page:vertical,
|
||||
QScrollBar::sub-page:vertical {
|
||||
background: none; /* No gap color */
|
||||
}
|
||||
|
||||
/* ===== Horizontal Scrollbar ===== */
|
||||
QScrollBar:horizontal {
|
||||
background: #1E1E1E;
|
||||
height: 10px;
|
||||
margin: 0px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:horizontal {
|
||||
background: #3A3A3A;
|
||||
min-width: 20px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:horizontal:hover {
|
||||
background: #555555;
|
||||
}
|
||||
|
||||
QScrollBar::handle:horizontal:pressed {
|
||||
background: #6A6A6A;
|
||||
}
|
||||
|
||||
QScrollBar::add-line:horizontal,
|
||||
QScrollBar::sub-line:horizontal {
|
||||
width: 0px;
|
||||
}
|
||||
|
||||
QScrollBar::add-page:horizontal,
|
||||
QScrollBar::sub-page:horizontal {
|
||||
background: none;
|
||||
}
|
||||
95
mucapy/styling/mw.qss
Normal file
95
mucapy/styling/mw.qss
Normal 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;
|
||||
}
|
||||
BIN
mucapy/styling/sound/alert.wav
Normal file
BIN
mucapy/styling/sound/alert.wav
Normal file
Binary file not shown.
5
mucapy/styling/todostyle.qss
Normal file
5
mucapy/styling/todostyle.qss
Normal file
@@ -0,0 +1,5 @@
|
||||
QLabel#todoLabel {
|
||||
color:rgb(21, 255, 0);
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
6
mucapy/styling/togglebtnabout.qss
Normal file
6
mucapy/styling/togglebtnabout.qss
Normal file
@@ -0,0 +1,6 @@
|
||||
QToolButton {
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
color: #DDD;
|
||||
}
|
||||
0
mucapy/todopackage/__init__.py
Normal file
0
mucapy/todopackage/__init__.py
Normal file
112
mucapy/todopackage/todo.py
Normal file
112
mucapy/todopackage/todo.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
Here we have a simple fucking class to get the fuckass Todo thing
|
||||
Probably will do none of them
|
||||
anyways, if you have suggestions pr this shit
|
||||
|
||||
idk if you are smarter than me then actually do them.
|
||||
|
||||
<-----------------------------------------------------------------
|
||||
I feel more like a Frontend Dev doing this shit.
|
||||
If you are a Frontend Dev then fuck you. Fuckass React Devs.
|
||||
----------------------------------------------------------------->
|
||||
|
||||
fuck you , if you can't read this then kys. This is designed to be very readable.
|
||||
-Rattatwinko 8.jun.25 (20:25)
|
||||
"""
|
||||
|
||||
class todo:
|
||||
def __init__(self):
|
||||
#------------------------------------------------------------------------------------------------------------------------------------------------#
|
||||
self.information : str = """
|
||||
|
||||
MuCaPy TODO List:
|
||||
|
||||
NOTE: If you want to run this in Visual Studio Codes built in Terminal you will get fucked in your tiny tight ass.
|
||||
NOTE: If you ran this from the Workflow Package, you cant do shit. fuck you.
|
||||
NOTE: If you compiled this yourself ; you are one Fucking Genious
|
||||
|
||||
Changes:
|
||||
Todo in About ; Seperate File (@todo.py)
|
||||
|
||||
|
||||
""" # This will center!
|
||||
#------------------------------------------------------------------------------------------------------------------------------------------------#
|
||||
"""
|
||||
!## This was the Quessadilla Bug at earlier 75. With next commit delete that out of the todo Variable ##!
|
||||
|
||||
This has nothing to do with the About Window
|
||||
It just happens to fit in here.
|
||||
I really dont like my Strings in my logic. It throws me off.
|
||||
I know it overcomplicates shit but Python is readable anyways.
|
||||
|
||||
"""
|
||||
self.instructions_CaSeDi_QLabel : str = """
|
||||
|
||||
Camera Selection Guide:\n
|
||||
• Local Cameras: Built-in and USB cameras
|
||||
• Network Cameras: IP cameras, DroidCam, etc.
|
||||
• Use checkboxes to select/deselect cameras
|
||||
• Double-click a camera to test the connection
|
||||
• Selected cameras will appear in the preview bellow
|
||||
"""
|
||||
#------------------------------------------------------------------------------------------------------------------------------------------------#
|
||||
self.todo : str="""
|
||||
TODO:
|
||||
[] - Fix Network Cameras from Crashing the Programm (This sometimes happens when only network cams are selected and it times out)
|
||||
[/] - Make Seperate Styling (unlikely that this will happen)
|
||||
- CPU seperate styling is available , set variable in QMainWindow
|
||||
[] - RTSP Camera Streaming
|
||||
|
||||
""" # This will display lefty (look in about window class)
|
||||
#------------------------------------------------------------------------------------------------------------------------------------------------#
|
||||
self.cameraURL : str = """
|
||||
Cameras:
|
||||
- http://pendelcam.kip.uni-heidelberg.de/mjpg/video.mjpg
|
||||
""" # This will also display centered
|
||||
#------------------------------------------------------------------------------------------------------------------------------------------------#
|
||||
self.beaver : str="""
|
||||
___
|
||||
.=" "=._.---.
|
||||
."" c ' Y'`p
|
||||
/ , `. w_/
|
||||
jgs | '-. / /
|
||||
_,..._| )_-\ \_=.\
|
||||
`-....-'`--------)))`=-'"`'"
|
||||
""" # The beaver isnt really angry anymore. Idk why, now hes chill
|
||||
#------------------------------------------------------------------------------------------------------------------------------------------------#
|
||||
self.archived_todo : str = """
|
||||
Archived Todo List:
|
||||
[X] - Fix Quesadilla Bug. @todo.py:75 , This has nothing to do with the About Window, but the Network Camera Dialog in @main.py:1038/1049
|
||||
- Fixed this. @todo.py now has a Singleton Initializer. Idk. "Brotha ew - Tsoding in May". Fuck you
|
||||
[] - Make MJPEG more stable and efficient. (maybe use c++ for this or rust)
|
||||
- Wont happen ; remove next commit, this is as efficient as it gets
|
||||
"""
|
||||
#------------------------------------------------------------------------------------------------------------------------------------------------#
|
||||
"""
|
||||
|
||||
For a change we actually declare the Types.
|
||||
I usually leave this to the Interpreter, it does it anyway
|
||||
|
||||
"""
|
||||
|
||||
# Return the Todo String
|
||||
def gettodo(self) -> str:
|
||||
return self.todo
|
||||
# Return the Information about the Programm
|
||||
def getinfo(self) -> str:
|
||||
return self.information
|
||||
|
||||
# Return the Camera URL Thing
|
||||
def getcams(self) -> str:
|
||||
return self.cameraURL
|
||||
# Get Network Camera Instructions (in the setup dialog)
|
||||
def get_instructions_CaSeDi_QLabel(self) -> str:
|
||||
return self.instructions_CaSeDi_QLabel
|
||||
# Get the fuckass beaver. very angwy :3
|
||||
def get_beaver(self) -> str:
|
||||
return self.beaver
|
||||
# Get the Archive.
|
||||
def getarchive(self) -> str:
|
||||
return self.archived_todo
|
||||
|
||||
todo = todo()
|
||||
97
mucapy/utility.py
Normal file
97
mucapy/utility.py
Normal file
@@ -0,0 +1,97 @@
|
||||
import os
|
||||
import platform
|
||||
try:
|
||||
import winreg
|
||||
except ImportError:
|
||||
pass
|
||||
import ctypes
|
||||
from PyQt5.QtWidgets import QWidget, QApplication
|
||||
from PyQt5.QtCore import QEvent
|
||||
|
||||
class conversion:
|
||||
_symbols = ("B", "KiB", "MiB", "GiB", "TiB", "PiB")
|
||||
_thresholds = [1 << (10 * i) for i in range(len(_symbols))]
|
||||
|
||||
@staticmethod
|
||||
def bytes_to_human(n: int) -> str:
|
||||
try:
|
||||
n = int(n)
|
||||
except Exception:
|
||||
return str(n)
|
||||
|
||||
if n < 1024:
|
||||
return f"{n} B"
|
||||
|
||||
thresholds = conversion._thresholds
|
||||
symbols = conversion._symbols
|
||||
i = min(len(thresholds) - 1, (n.bit_length() - 1) // 10)
|
||||
val = n / thresholds[i]
|
||||
|
||||
# Pick a faster formatting branch
|
||||
if val >= 100:
|
||||
return f"{val:.0f} {symbols[i]}"
|
||||
elif val >= 10:
|
||||
return f"{val:.1f} {symbols[i]}"
|
||||
else:
|
||||
return f"{val:.2f} {symbols[i]}"
|
||||
|
||||
class getpath:
|
||||
@staticmethod
|
||||
def resource_path(relative_path: str):
|
||||
base_path = os.path.dirname(os.path.abspath(__file__))
|
||||
return os.path.join(base_path, relative_path)
|
||||
|
||||
class windows:
|
||||
@staticmethod
|
||||
def is_windows_darkmode() -> bool:
|
||||
if platform.system() != "Windows":
|
||||
return False
|
||||
|
||||
try:
|
||||
key_path = r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"
|
||||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path) as key:
|
||||
# 0 = dark mode, 1 = light mode
|
||||
value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme")
|
||||
# print(f"AppsUseLightTheme: {value}") # optional debug
|
||||
return value == 0
|
||||
except Exception as e:
|
||||
print(f"Could not read Windows registry for dark mode: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def set_dark_titlebar(widget: QWidget):
|
||||
"""Apply dark titlebar on Windows to any top-level window."""
|
||||
if platform.system() != "Windows":
|
||||
return
|
||||
if not widget.isWindow(): # only top-level windows
|
||||
return
|
||||
if windows.is_windows_darkmode():
|
||||
try:
|
||||
hwnd = int(widget.winId())
|
||||
DWMWA_USE_IMMERSIVE_DARK_MODE = 20
|
||||
value = ctypes.c_int(1)
|
||||
res = ctypes.windll.dwmapi.DwmSetWindowAttribute(
|
||||
hwnd,
|
||||
DWMWA_USE_IMMERSIVE_DARK_MODE,
|
||||
ctypes.byref(value),
|
||||
ctypes.sizeof(value)
|
||||
)
|
||||
if res != 0:
|
||||
# fallback for some Windows builds
|
||||
DWMWA_USE_IMMERSIVE_DARK_MODE = 19
|
||||
ctypes.windll.dwmapi.DwmSetWindowAttribute(
|
||||
hwnd,
|
||||
DWMWA_USE_IMMERSIVE_DARK_MODE,
|
||||
ctypes.byref(value),
|
||||
ctypes.sizeof(value)
|
||||
)
|
||||
except Exception as e:
|
||||
print("Failed to set dark titlebar:", e)
|
||||
|
||||
class darkmodechildren(QApplication):
|
||||
def notify(self, receiver, event):
|
||||
# Only handle top-level windows
|
||||
if isinstance(receiver, QWidget) and receiver.isWindow():
|
||||
if event.type() == QEvent.WinIdChange:
|
||||
windows.set_dark_titlebar(receiver)
|
||||
return super().notify(receiver, event)
|
||||
13
mucapy_config.json
Normal file
13
mucapy_config.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"network_cameras": {},
|
||||
"last_model_dir": "",
|
||||
"last_screenshot_dir": "/home/rattatwinko/Pictures/MuCaPy",
|
||||
"last_layout": 0,
|
||||
"last_fps": 10,
|
||||
"last_selected_cameras": [],
|
||||
"window_geometry": null,
|
||||
"confidence_threshold": 0.35,
|
||||
"model_dir": "/home/rattatwinko/Documents/mucapy/mucapy/mucapy/models",
|
||||
"fps": 10,
|
||||
"layout": 0
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
opencv-python>=4.5.0
|
||||
opencv-contrib-python>=4.5.0
|
||||
numpy>=1.19.0
|
||||
PyQt5>=5.15.0
|
||||
opencv-python==4.11.0.86
|
||||
numpy==2.2.6
|
||||
PyQt5==5.15.11
|
||||
requests==2.32.3
|
||||
psutil==7.0.0
|
||||
pytest==8.4.0
|
||||
comtypes==1.4.13
|
||||
rtsp==1.1.12
|
||||
#pynvcodec==0.0.6
|
||||
|
||||
Reference in New Issue
Block a user