16 Commits

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

View File

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

9
.gitignore vendored
View File

@@ -1 +1,10 @@
mucapy/seperate/__pycache__/__init__.cpython-313.pyc
mucapy/seperate/__pycache__/AboutWindow.cpython-313.pyc
mucapy/seperate/__pycache__/CameraDisplay.cpython-313.pyc
mucapy/seperate/__pycache__/CollapsibleDock.cpython-313.pyc
mucapy/seperate/__pycache__/Config.cpython-313.pyc
mucapy/seperate/__pycache__/main.cpython-313.pyc
mucapy/seperate/__pycache__/MainWindow.cpython-313.pyc
mucapy/seperate/__pycache__/MultiCamYOLODetector.cpython-313.pyc
mucapy/seperate/__pycache__/NetworkCameraDialog.cpython-313.pyc

41
LICENSE Normal file
View File

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

169
README.md Normal file
View File

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

0
__init__.py Normal file
View File

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

BIN
mucapy/styling/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

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

View File

@@ -1,14 +1,5 @@
# Web framework and extensions
Flask>=3.0.0
Flask-Cors>=4.0.0
Werkzeug>=3.0.0
gunicorn>=21.2.0
# Core dependencies
opencv-python-headless>=4.8.0
# Flask dependencies
click>=8.1.7
itsdangerous>=2.1.2
Jinja2>=3.1.2
MarkupSafe>=2.1.3
opencv-python==4.11.0.86
numpy==2.2.6
PyQt5==5.15.11
requests==2.32.3
psutil==7.0.0

BIN
run.mp4 Normal file

Binary file not shown.

View File

@@ -1,235 +0,0 @@
#!/bin/bash
# Exit on error
set -e
# Function to print error messages
error() {
echo -e "\e[31mERROR:\e[0m $1" >&2
exit 1
}
# Function to print success messages
success() {
echo -e "\e[32mSUCCESS:\e[0m $1"
}
# Function to print info messages
info() {
echo -e "\e[34mINFO:\e[0m $1"
}
# Function to check if a command exists
check_command() {
if ! command -v "$1" &> /dev/null; then
error "Required command '$1' not found. Please install it first."
fi
}
# Function to compare version numbers
version_compare() {
if [[ "$1" == "$2" ]]; then
echo 0
return
fi
local IFS=.
local i ver1=($1) ver2=($2)
# Fill empty positions in ver1 with zeros
for ((i=${#ver1[@]}; i<${#ver2[@]}; i++)); do
ver1[i]=0
done
for ((i=0; i<${#ver1[@]}; i++)); do
# Fill empty positions in ver2 with zeros
if [[ -z ${ver2[i]} ]]; then
ver2[i]=0
fi
if ((10#${ver1[i]} > 10#${ver2[i]})); then
echo 1
return
fi
if ((10#${ver1[i]} < 10#${ver2[i]})); then
echo -1
return
fi
done
echo 0
}
# Check for required system commands
check_command python3
check_command pip3
# Check Python version
PYTHON_VERSION=$(python3 -c 'import sys; print(".".join(map(str, sys.version_info[:2])))')
MIN_VERSION="3.8"
if [ $(version_compare "$PYTHON_VERSION" "$MIN_VERSION") -lt 0 ]; then
error "Python version must be $MIN_VERSION or higher (found $PYTHON_VERSION)"
fi
success "Python version check passed (found $PYTHON_VERSION)"
# Function to install packages on Fedora
install_fedora_deps() {
info "Installing Fedora dependencies..."
# Check which packages need to be installed
local packages=()
local check_packages=(
"python3-devel"
"gcc"
"python3-pip"
"python3-setuptools"
"python3-numpy"
"python3-opencv"
"python3-flask"
"python3-gunicorn"
"bc"
"lsof"
)
for pkg in "${check_packages[@]}"; do
if ! rpm -q "$pkg" &>/dev/null; then
packages+=("$pkg")
fi
done
# Only run dnf if there are packages to install
if [ ${#packages[@]} -gt 0 ]; then
info "Installing missing packages: ${packages[*]}"
sudo dnf install -y "${packages[@]}" || error "Failed to install required packages"
else
info "All required system packages are already installed"
fi
}
# Function to install packages on Ubuntu/Debian
install_ubuntu_deps() {
info "Installing Ubuntu/Debian dependencies..."
sudo apt-get update || error "Failed to update package lists"
sudo apt-get install -y \
python3-dev \
python3-pip \
python3-venv \
python3-numpy \
python3-opencv \
python3-flask \
python3-gunicorn \
bc \
lsof \
|| error "Failed to install required packages"
}
# Check and install system dependencies based on distribution
install_system_deps() {
if [ -f /etc/os-release ]; then
. /etc/os-release
case $ID in
fedora)
install_fedora_deps
;;
ubuntu|debian)
install_ubuntu_deps
;;
*)
info "Unknown distribution. Please ensure you have the following packages installed:"
echo "- Python development package (python3-devel/python3-dev)"
echo "- GCC compiler"
echo "- Python pip"
echo "- Python venv"
echo "- Python numpy"
echo "- Python OpenCV"
echo "- Python Flask"
echo "- Python Gunicorn"
echo "- bc"
echo "- lsof"
;;
esac
fi
}
# Install system dependencies
install_system_deps
# Create and activate virtual environment
if [ ! -d "venv" ]; then
info "Creating virtual environment..."
# Create venv with system packages to use system numpy and opencv
python3 -m venv venv --system-site-packages || error "Failed to create virtual environment"
success "Virtual environment created successfully"
fi
# Ensure virtual environment is activated
if [ -z "$VIRTUAL_ENV" ]; then
info "Activating virtual environment..."
source venv/bin/activate || error "Failed to activate virtual environment"
fi
# Upgrade pip to latest version
info "Upgrading pip..."
python3 -m pip install --upgrade pip setuptools wheel || error "Failed to upgrade pip and setuptools"
# Create or update requirements.txt with compatible package versions
if [ ! -f "requirements.txt" ]; then
info "Creating requirements.txt..."
cat > requirements.txt << EOF
# Web framework and extensions
Flask>=3.0.0
Flask-Cors>=4.0.0
Werkzeug>=3.0.0
gunicorn>=21.2.0
# Core dependencies
opencv-python-headless>=4.8.0
# Flask dependencies
click>=8.1.7
itsdangerous>=2.1.2
Jinja2>=3.1.2
MarkupSafe>=2.1.3
EOF
success "Created requirements.txt"
fi
# Install requirements with better error handling
info "Installing/updating requirements..."
# First ensure pip is up to date
pip install --upgrade pip
# Install packages with specific options for better compatibility
PYTHONWARNINGS="ignore" pip install \
--no-cache-dir \
--prefer-binary \
--only-binary :all: \
-r requirements.txt || error "Failed to install requirements"
success "Requirements installed successfully"
# Create necessary directories
info "Creating required directories..."
mkdir -p logs || error "Failed to create logs directory"
success "Created required directories"
# Function to check if port is available
check_port() {
if lsof -Pi :5000 -sTCP:LISTEN -t >/dev/null ; then
error "Port 5000 is already in use. Please stop the other process first."
fi
}
# Check if port is available
check_port
# Start Gunicorn with modern settings
info "Starting server with Gunicorn..."
exec gunicorn web_server:app \
--bind 0.0.0.0:5000 \
--workers $(nproc) \
--worker-class gthread \
--threads 2 \
--timeout 120 \
--access-logfile logs/access.log \
--error-logfile logs/error.log \
--capture-output \
--log-level info \
--reload \
--max-requests 1000 \
--max-requests-jitter 50 \
|| error "Failed to start Gunicorn server"

File diff suppressed because it is too large Load Diff

View File

@@ -1,788 +0,0 @@
import os
import cv2
import json
import numpy as np
from flask import Flask, Response, render_template, jsonify, request
from flask_cors import CORS
import threading
import time
import queue
import urllib.parse
import glob
import logging
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
app = Flask(__name__)
CORS(app) # Enable CORS for all routes
def find_yolo_model():
"""Scan current directory and subdirectories for YOLO model files"""
# Look for common YOLO file patterns
weights_files = glob.glob('**/*.weights', recursive=True) + glob.glob('**/*.onnx', recursive=True)
cfg_files = glob.glob('**/*.cfg', recursive=True)
names_files = glob.glob('**/*.names', recursive=True)
# Find directories containing all required files
model_dirs = set()
for weights in weights_files:
directory = os.path.dirname(weights)
if not directory:
directory = '.'
# Check if this directory has all required files
has_cfg = any(cfg for cfg in cfg_files if os.path.dirname(cfg) == directory)
has_names = any(names for names in names_files if os.path.dirname(names) == directory)
if has_cfg and has_names:
model_dirs.add(directory)
# Return the first valid directory found, or None
return next(iter(model_dirs), None)
class YOLODetector:
def __init__(self):
self.net = None
self.classes = []
self.colors = []
self.confidence_threshold = 0.35
self.cuda_available = self.check_cuda()
self.model_loaded = False
self.current_model = None
def check_cuda(self):
"""Check if CUDA is available"""
try:
count = cv2.cuda.getCudaEnabledDeviceCount()
return count > 0
except:
return False
def scan_for_model(self):
"""Auto-scan for YOLO model files in current directory"""
try:
# Look for model files in current directory
weights = [f for f in os.listdir('.') if f.endswith(('.weights', '.onnx'))]
configs = [f for f in os.listdir('.') if f.endswith('.cfg')]
classes = [f for f in os.listdir('.') if f.endswith('.names')]
if weights and configs and classes:
self.load_yolo_model('.', weights[0], configs[0], classes[0])
return True
return False
except Exception as e:
print(f"Error scanning for model: {e}")
return False
def load_yolo_model(self, model_dir, weights_file=None, config_file=None, classes_file=None):
"""Load YOLO model with specified files or auto-detect"""
try:
if not weights_file:
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 and configs and classes):
return False
weights_file = weights[0]
config_file = configs[0]
classes_file = classes[0]
weights_path = os.path.join(model_dir, weights_file)
config_path = os.path.join(model_dir, config_file)
classes_path = os.path.join(model_dir, classes_file)
self.net = cv2.dnn.readNet(weights_path, config_path)
self.current_model = weights_file
if self.cuda_available:
try:
self.net.setPreferableBackend(cv2.dnn.DNN_BACKEND_CUDA)
self.net.setPreferableTarget(cv2.dnn.DNN_TARGET_CUDA)
except:
self.net.setPreferableBackend(cv2.dnn.DNN_BACKEND_OPENCV)
self.net.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU)
with open(classes_path, 'r') as f:
self.classes = f.read().strip().split('\n')
np.random.seed(42)
self.colors = np.random.randint(0, 255, size=(len(self.classes), 3), dtype='uint8')
self.model_loaded = True
return True
except Exception as e:
print(f"Error loading model: {e}")
self.model_loaded = False
return False
def get_camera_resolution(self, cap):
"""Get the optimal resolution for a camera"""
try:
# Common resolutions to try
resolutions = [
(1920, 1080), # Full HD
(1280, 720), # HD
(800, 600), # SVGA
(640, 480) # VGA
]
best_width = 640
best_height = 480
for width, height in resolutions:
cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
actual_width = cap.get(cv2.CAP_PROP_FRAME_WIDTH)
actual_height = cap.get(cv2.CAP_PROP_FRAME_HEIGHT)
if actual_width > 0 and actual_height > 0:
best_width = actual_width
best_height = actual_height
break
return int(best_width), int(best_height)
except:
return 640, 480
def scan_cameras(self):
"""Scan for available cameras, skipping video0"""
cameras = []
# Start from video1 since video0 is often empty or system camera
for i in range(1, 10):
try:
cap = cv2.VideoCapture(i)
if cap.isOpened():
# Get optimal resolution
width, height = self.get_camera_resolution(cap)
cameras.append({
'id': i,
'width': width,
'height': height
})
cap.release()
except:
continue
# Check device paths
for i in range(1, 10):
path = f"/dev/video{i}"
if os.path.exists(path):
try:
cap = cv2.VideoCapture(path)
if cap.isOpened():
width, height = self.get_camera_resolution(cap)
cameras.append({
'id': path,
'width': width,
'height': height
})
cap.release()
except:
continue
return cameras
def detect(self, frame):
"""Perform object detection on frame"""
if self.net is None or not self.model_loaded:
return frame
try:
height, width = frame.shape[:2]
blob = cv2.dnn.blobFromImage(frame, 1/255.0, (416, 416), swapRB=True, crop=False)
self.net.setInput(blob)
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)
# Process detections
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:
# Convert YOLO coords to screen coords
center_x = int(detection[0] * width)
center_y = int(detection[1] * height)
w = int(detection[2] * width)
h = int(detection[3] * height)
# Get top-left corner
x = max(0, int(center_x - w/2))
y = max(0, int(center_y - h/2))
boxes.append([x, y, w, h])
confidences.append(float(confidence))
class_ids.append(class_id)
# Apply non-maximum suppression
indices = cv2.dnn.NMSBoxes(boxes, confidences, self.confidence_threshold, 0.4)
if len(indices) > 0:
for i in indices.flatten():
try:
(x, y, w, h) = boxes[i]
# Ensure coordinates are within frame bounds
x = max(0, min(x, width - 1))
y = max(0, min(y, height - 1))
w = min(w, width - x)
h = min(h, height - y)
color = [int(c) for c in self.colors[class_ids[i]]]
cv2.rectangle(frame, (x, y), (x + w, y + h), color, 2)
# Draw label with background
text = f"{self.classes[class_ids[i]]}: {confidences[i]:.2f}"
font_scale = 0.5
font = cv2.FONT_HERSHEY_SIMPLEX
thickness = 1
(text_w, text_h), baseline = cv2.getTextSize(text, font, font_scale, thickness)
# Draw background rectangle for text
cv2.rectangle(frame, (x, y - text_h - baseline - 5), (x + text_w, y), color, -1)
# Draw text
cv2.putText(frame, text, (x, y - 5), font, font_scale, (255, 255, 255), thickness)
except Exception as e:
print(f"Error drawing detection {i}: {e}")
continue
return frame
except Exception as e:
print(f"Detection error: {e}")
return frame
class CameraStream:
def __init__(self, camera_id, detector):
self.camera_id = camera_id
self.cap = None
self.frame_queue = queue.Queue(maxsize=10)
self.running = False
self.thread = None
self.lock = threading.Lock()
self.detector = detector
self.is_network_camera = isinstance(camera_id, str) and camera_id.startswith('net:')
self.last_frame_time = time.time()
self.frame_timeout = 5.0 # Timeout after 5 seconds without frames
self.reconnect_interval = 5.0 # Try to reconnect every 5 seconds
self.last_reconnect_attempt = 0
self.connection_retries = 0
self.max_retries = 3
def start(self):
"""Start the camera stream with improved error handling"""
if self.running:
return False
try:
if self.is_network_camera:
# Handle network camera
name = self.camera_id[4:] # Remove 'net:' prefix
camera_info = camera_manager.network_cameras.get(name)
if not camera_info:
raise Exception(f"Network camera {name} not found")
if isinstance(camera_info, dict):
url = camera_info['url']
# Handle DroidCam URL formatting
if ':4747' in url and not url.endswith('/video'):
url = url.rstrip('/') + '/video'
if not url.startswith(('http://', 'https://')):
url = 'http://' + url
if 'username' in camera_info and 'password' in camera_info:
parsed = urllib.parse.urlparse(url)
netloc = f"{camera_info['username']}:{camera_info['password']}@{parsed.netloc}"
url = parsed._replace(netloc=netloc).geturl()
else:
url = camera_info
logger.info(f"Connecting to network camera: {url}")
self.cap = cv2.VideoCapture(url)
# Wait for connection
retry_count = 0
while not self.cap.isOpened() and retry_count < 3:
time.sleep(1)
retry_count += 1
self.cap.open(url)
if not self.cap.isOpened():
raise Exception(f"Failed to connect to network camera at {url}")
else:
# Handle local camera
self.cap = cv2.VideoCapture(self.camera_id)
if not self.cap.isOpened():
raise Exception(f"Failed to open camera {self.camera_id}")
# Set camera properties
self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)
self.cap.set(cv2.CAP_PROP_FPS, 30)
self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
self.running = True
self.connection_retries = 0
self.thread = threading.Thread(target=self._capture_loop)
self.thread.daemon = True
self.thread.start()
return True
except Exception as e:
logger.error(f"Error starting camera {self.camera_id}: {e}")
if self.cap:
self.cap.release()
self.cap = None
self.connection_retries += 1
return False
def stop(self):
"""Stop the camera stream"""
self.running = False
if self.thread:
self.thread.join()
if self.cap:
self.cap.release()
self.cap = None
while not self.frame_queue.empty():
try:
self.frame_queue.get_nowait()
except queue.Empty:
break
def _capture_loop(self):
"""Main capture loop with improved error handling and reconnection"""
while self.running:
try:
if not self.cap or not self.cap.isOpened():
current_time = time.time()
if current_time - self.last_reconnect_attempt >= self.reconnect_interval:
if self.connection_retries < self.max_retries:
logger.info(f"Attempting to reconnect camera {self.camera_id}")
self.last_reconnect_attempt = current_time
if self.start():
logger.info(f"Successfully reconnected camera {self.camera_id}")
else:
logger.warning(f"Failed to reconnect camera {self.camera_id}")
else:
logger.error(f"Max reconnection attempts reached for camera {self.camera_id}")
self.running = False
break
time.sleep(1)
continue
ret, frame = self.cap.read()
if not ret:
current_time = time.time()
if current_time - self.last_frame_time > self.frame_timeout:
logger.warning(f"No frames received from camera {self.camera_id} for {self.frame_timeout} seconds")
self.cap.release()
self.cap = None
time.sleep(0.1)
continue
self.last_frame_time = time.time()
# Apply object detection
if self.detector and self.detector.net is not None:
frame = self.detector.detect(frame)
# Convert to JPEG
_, jpeg = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
# Update queue
try:
self.frame_queue.put_nowait(jpeg.tobytes())
except queue.Full:
try:
self.frame_queue.get_nowait()
self.frame_queue.put_nowait(jpeg.tobytes())
except queue.Empty:
pass
except Exception as e:
logger.error(f"Error in capture loop for camera {self.camera_id}: {e}")
if self.cap:
self.cap.release()
self.cap = None
time.sleep(1)
def get_frame(self):
"""Get the latest frame"""
try:
return self.frame_queue.get_nowait()
except queue.Empty:
return None
class CameraManager:
def __init__(self):
self.cameras = {}
self.network_cameras = {}
self.lock = threading.Lock()
self.detector = YOLODetector()
# Auto-scan for model directory
model_dir = os.getenv('YOLO_MODEL_DIR')
if not model_dir or not os.path.exists(model_dir):
model_dir = find_yolo_model()
if model_dir:
print(f"Found YOLO model in directory: {model_dir}")
self.detector.load_yolo_model(model_dir)
else:
print("No YOLO model found in current directory")
def add_camera(self, camera_id):
"""Add a camera to the manager"""
with self.lock:
if camera_id not in self.cameras:
camera = CameraStream(camera_id, self.detector)
if camera.start():
self.cameras[camera_id] = camera
return True
return False
def remove_camera(self, camera_id):
"""Remove a camera from the manager"""
with self.lock:
if camera_id in self.cameras:
self.cameras[camera_id].stop()
del self.cameras[camera_id]
def get_camera(self, camera_id):
"""Get a camera by ID"""
return self.cameras.get(camera_id)
def get_all_cameras(self):
"""Get list of all camera IDs"""
return list(self.cameras.keys())
def add_network_camera(self, name, url, username=None, password=None):
"""Add a network camera"""
camera_info = {
'url': url,
'username': username,
'password': password
} if username and password else url
self.network_cameras[name] = camera_info
return True
def remove_network_camera(self, name):
"""Remove a network camera"""
if name in self.network_cameras:
camera_id = f"net:{name}"
if camera_id in self.cameras:
self.remove_camera(camera_id)
del self.network_cameras[name]
return True
return False
camera_manager = CameraManager()
def gen_frames(camera_id):
"""Generator function for camera frames"""
camera = camera_manager.get_camera(camera_id)
if not camera:
return
while True:
frame = camera.get_frame()
if frame is not None:
yield (b'--frame\r\n'
b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n')
else:
time.sleep(0.01)
@app.route('/')
def index():
"""Serve the main page"""
return render_template('index.html')
@app.route('/video_feed/<path:camera_id>')
def video_feed(camera_id):
"""Video streaming route"""
# Handle both local and network cameras
if camera_id.startswith('net:'):
camera_id = camera_id # Keep as string for network cameras
else:
try:
camera_id = int(camera_id) # Convert to int for local cameras
except ValueError:
camera_id = camera_id # Keep as string if not convertible
return Response(gen_frames(camera_id),
mimetype='multipart/x-mixed-replace; boundary=frame')
@app.route('/scan_cameras')
def scan_cameras_route():
"""Scan and return available cameras"""
cameras = scan_cameras_silently()
return jsonify({'cameras': cameras})
@app.route('/cameras')
def get_cameras():
"""Get list of available cameras"""
# Get local cameras
cameras = scan_cameras_silently()
# Add network cameras
for name, info in camera_manager.network_cameras.items():
url = info['url'] if isinstance(info, dict) else info
cameras.append({
'id': f'net:{name}',
'type': 'network',
'name': f'{name} ({url})',
'width': 1280, # Default width for network cameras
'height': 720 # Default height for network cameras
})
# Add status information for active cameras
for camera in cameras:
camera_stream = camera_manager.get_camera(camera['id'])
if camera_stream:
camera['active'] = True
camera['status'] = 'connected' if camera_stream.cap and camera_stream.cap.isOpened() else 'reconnecting'
else:
camera['active'] = False
camera['status'] = 'disconnected'
return jsonify({'cameras': cameras})
@app.route('/add_camera/<path:camera_id>')
def add_camera(camera_id):
"""Add a camera to the stream"""
if camera_id.startswith('net:'):
camera_id = camera_id # Keep as string for network cameras
else:
try:
camera_id = int(camera_id) # Convert to int for local cameras
except ValueError:
camera_id = camera_id # Keep as string if not convertible
success = camera_manager.add_camera(camera_id)
return jsonify({'success': success})
@app.route('/remove_camera/<path:camera_id>')
def remove_camera(camera_id):
"""Remove a camera from the stream"""
camera_manager.remove_camera(camera_id)
return jsonify({'success': True})
@app.route('/network_cameras', methods=['POST'])
def add_network_camera():
"""Add a network camera"""
data = request.json
name = data.get('name')
url = data.get('url')
username = data.get('username')
password = data.get('password')
if not name or not url:
return jsonify({'success': False, 'error': 'Name and URL required'})
try:
success = camera_manager.add_network_camera(name, url, username, password)
if success:
# Try to connect to the camera to verify it works
camera_id = f"net:{name}"
test_success = camera_manager.add_camera(camera_id)
if test_success:
camera_manager.remove_camera(camera_id) # Remove test connection
else:
camera_manager.remove_network_camera(name)
return jsonify({'success': False, 'error': 'Failed to connect to camera'})
return jsonify({'success': success})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@app.route('/load_model', methods=['POST'])
def load_model():
"""Load YOLO model"""
data = request.json
model_dir = data.get('model_dir')
if not model_dir or not os.path.exists(model_dir):
return jsonify({'success': False, 'error': 'Invalid model directory'})
success = camera_manager.detector.load_yolo_model(model_dir)
return jsonify({'success': success})
@app.route('/check_model')
def check_model():
"""Check YOLO model status"""
return jsonify({
'model_loaded': camera_manager.detector.model_loaded,
'model_name': camera_manager.detector.current_model,
'cuda_available': camera_manager.detector.cuda_available
})
@app.route('/model_info')
def model_info():
"""Get detailed model information"""
detector = camera_manager.detector
return jsonify({
'model_loaded': detector.model_loaded,
'model_name': detector.current_model,
'cuda_available': detector.cuda_available,
'classes': detector.classes,
'confidence_threshold': detector.confidence_threshold
})
@app.route('/update_model_settings', methods=['POST'])
def update_model_settings():
"""Update model settings"""
data = request.json
if 'confidence_threshold' in data:
try:
threshold = float(data['confidence_threshold'])
if 0 <= threshold <= 1:
camera_manager.detector.confidence_threshold = threshold
return jsonify({'success': True})
except ValueError:
pass
return jsonify({'success': False, 'error': 'Invalid settings'})
@app.route('/network_cameras/list')
def list_network_cameras():
"""List all configured network cameras"""
cameras = []
for name, info in camera_manager.network_cameras.items():
if isinstance(info, dict):
cameras.append({
'name': name,
'url': info['url'],
'username': info.get('username'),
'has_auth': bool(info.get('username') and info.get('password'))
})
else:
cameras.append({
'name': name,
'url': info,
'has_auth': False
})
return jsonify({'cameras': cameras})
@app.route('/network_cameras/remove/<name>', methods=['POST'])
def remove_network_camera(name):
"""Remove a network camera"""
success = camera_manager.remove_network_camera(name)
return jsonify({'success': success})
@app.route('/camera_settings/<path:camera_id>', methods=['GET', 'POST'])
def camera_settings(camera_id):
"""Get or update camera settings"""
camera = camera_manager.get_camera(camera_id)
if not camera:
return jsonify({'success': False, 'error': 'Camera not found'})
if request.method == 'POST':
data = request.json
try:
if 'width' in data and 'height' in data:
width = int(data['width'])
height = int(data['height'])
if camera.cap:
camera.cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
camera.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
if 'fps' in data:
fps = int(data['fps'])
if camera.cap:
camera.cap.set(cv2.CAP_PROP_FPS, fps)
if 'reconnect' in data and data['reconnect']:
# Force camera reconnection
camera.stop()
time.sleep(1) # Wait for cleanup
camera.start()
return jsonify({'success': True})
except Exception as e:
logger.error(f"Error updating camera settings: {e}")
return jsonify({'success': False, 'error': str(e)})
else:
if not camera.cap:
return jsonify({'success': False, 'error': 'Camera not connected'})
settings = {
'width': int(camera.cap.get(cv2.CAP_PROP_FRAME_WIDTH)),
'height': int(camera.cap.get(cv2.CAP_PROP_FRAME_HEIGHT)),
'fps': int(camera.cap.get(cv2.CAP_PROP_FPS)),
'is_network': camera.is_network_camera,
'status': 'connected' if camera.cap and camera.cap.isOpened() else 'disconnected'
}
return jsonify({'success': True, 'settings': settings})
# Reduce camera scanning noise
def scan_cameras_silently():
"""Scan for cameras while suppressing OpenCV warnings"""
import contextlib
with open(os.devnull, 'w') as devnull:
with contextlib.redirect_stderr(devnull):
cameras = []
# Check device paths first
for i in range(10):
device_path = f"/dev/video{i}"
if os.path.exists(device_path):
try:
cap = cv2.VideoCapture(device_path)
if cap.isOpened():
cameras.append({
'id': device_path,
'type': 'local',
'name': f'Camera {i} ({device_path})'
})
cap.release()
except Exception as e:
logger.debug(f"Error checking device {device_path}: {e}")
# Check numeric indices
for i in range(2): # Only check first two indices to reduce noise
try:
cap = cv2.VideoCapture(i)
if cap.isOpened():
cameras.append({
'id': str(i),
'type': 'local',
'name': f'Camera {i}'
})
cap.release()
except Exception as e:
logger.debug(f"Error checking camera {i}: {e}")
return cameras
if __name__ == '__main__':
# Create templates directory if it doesn't exist
os.makedirs('templates', exist_ok=True)
# Load model from environment variable if available
model_dir = os.getenv('YOLO_MODEL_DIR')
if model_dir and os.path.exists(model_dir):
camera_manager.detector.load_yolo_model(model_dir)
# Check if running with Gunicorn
if os.environ.get('GUNICORN_CMD_ARGS') is not None:
# Running with Gunicorn, let it handle the server
pass
else:
# Development server warning
logger.warning("Running in development mode. Use Gunicorn for production!")
app.run(host='0.0.0.0', port=5000, threaded=True)