diff --git a/__pycache__/web_server.cpython-313.pyc b/__pycache__/web_server.cpython-313.pyc new file mode 100644 index 0000000..c5a1b67 Binary files /dev/null and b/__pycache__/web_server.cpython-313.pyc differ diff --git a/logs/access.log b/logs/access.log new file mode 100644 index 0000000..f611b17 --- /dev/null +++ b/logs/access.log @@ -0,0 +1,17 @@ +127.0.0.1 - - [26/May/2025:20:25:21 +0200] "GET / HTTP/1.1" 200 12917 "-" "Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0" +127.0.0.1 - - [26/May/2025:20:25:22 +0200] "GET /cameras HTTP/1.1" 200 192 "http://localhost:5000/" "Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0" +127.0.0.1 - - [26/May/2025:20:25:22 +0200] "GET /favicon.ico HTTP/1.1" 404 207 "http://localhost:5000/" "Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0" +127.0.0.1 - - [26/May/2025:20:25:27 +0200] "GET /cameras HTTP/1.1" 200 192 "http://localhost:5000/" "Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0" +127.0.0.1 - - [26/May/2025:20:25:32 +0200] "GET /cameras HTTP/1.1" 200 192 "http://localhost:5000/" "Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0" +127.0.0.1 - - [26/May/2025:20:25:33 +0200] "GET /add_camera/0 HTTP/1.1" 200 17 "http://localhost:5000/" "Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0" +127.0.0.1 - - [26/May/2025:20:25:37 +0200] "GET /cameras HTTP/1.1" 200 3 "http://localhost:5000/" "Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0" +127.0.0.1 - - [26/May/2025:20:25:40 +0200] "GET /cameras HTTP/1.1" 200 3 "http://localhost:5000/" "Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0" +127.0.0.1 - - [26/May/2025:20:25:42 +0200] "GET /cameras HTTP/1.1" 200 3 "http://localhost:5000/" "Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0" +127.0.0.1 - - [26/May/2025:20:25:44 +0200] "GET /video_feed/0 HTTP/1.1" 200 8815858 "http://localhost:5000/" "Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0" +127.0.0.1 - - [26/May/2025:20:28:42 +0200] "GET / HTTP/1.1" 200 10364 "-" "Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0" +127.0.0.1 - - [26/May/2025:20:28:42 +0200] "GET /check_model HTTP/1.1" 404 207 "http://localhost:5000/" "Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0" +127.0.0.1 - - [26/May/2025:20:28:44 +0200] "GET /scan_cameras HTTP/1.1" 404 207 "http://localhost:5000/" "Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0" +127.0.0.1 - - [26/May/2025:20:28:46 +0200] "GET /scan_cameras HTTP/1.1" 404 207 "http://localhost:5000/" "Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0" +127.0.0.1 - - [26/May/2025:20:28:46 +0200] "GET /scan_cameras HTTP/1.1" 404 207 "http://localhost:5000/" "Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0" +127.0.0.1 - - [26/May/2025:20:28:46 +0200] "GET /scan_cameras HTTP/1.1" 404 207 "http://localhost:5000/" "Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0" +127.0.0.1 - - [26/May/2025:20:28:50 +0200] "GET /scan_cameras HTTP/1.1" 404 207 "http://localhost:5000/" "Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0" diff --git a/logs/error.log b/logs/error.log new file mode 100644 index 0000000..9c000d1 --- /dev/null +++ b/logs/error.log @@ -0,0 +1,88 @@ +[2025-05-26 20:24:22 +0200] [824587] [INFO] Starting gunicorn 23.0.0 +[2025-05-26 20:24:22 +0200] [824587] [INFO] Listening at: http://0.0.0.0:5000 (824587) +[2025-05-26 20:24:22 +0200] [824587] [INFO] Using worker: gthread +[2025-05-26 20:24:22 +0200] [825529] [INFO] Booting worker with pid: 825529 +[2025-05-26 20:24:22 +0200] [825544] [INFO] Booting worker with pid: 825544 +[2025-05-26 20:24:22 +0200] [825556] [INFO] Booting worker with pid: 825556 +[2025-05-26 20:24:22 +0200] [825571] [INFO] Booting worker with pid: 825571 +[2025-05-26 20:24:23 +0200] [825584] [INFO] Booting worker with pid: 825584 +[2025-05-26 20:24:23 +0200] [825600] [INFO] Booting worker with pid: 825600 +[2025-05-26 20:24:23 +0200] [825601] [INFO] Booting worker with pid: 825601 +[2025-05-26 20:24:23 +0200] [825602] [INFO] Booting worker with pid: 825602 +[2025-05-26 20:24:23 +0200] [825639] [INFO] Booting worker with pid: 825639 +[2025-05-26 20:24:23 +0200] [825651] [INFO] Booting worker with pid: 825651 +[2025-05-26 20:24:23 +0200] [825666] [INFO] Booting worker with pid: 825666 +Found YOLO model in directory: mucapy/models +[2025-05-26 20:24:23 +0200] [825682] [INFO] Booting worker with pid: 825682 +Found YOLO model in directory: mucapy/models +Found YOLO model in directory: mucapy/models +Found YOLO model in directory: mucapy/models +Found YOLO model in directory: mucapy/models +Found YOLO model in directory: mucapy/models +Found YOLO model in directory: mucapy/models +Found YOLO model in directory: mucapy/models +Found YOLO model in directory: mucapy/models +Found YOLO model in directory: mucapy/models +Found YOLO model in directory: mucapy/models +Found YOLO model in directory: mucapy/models +[ WARN:0@58.649] global cap_v4l.cpp:913 open VIDEOIO(V4L2:/dev/video1): can't open camera by index +[ERROR:0@58.649] global obsensor_uvc_stream_channel.cpp:158 getStreamChannelGroup Camera index out of range +[ WARN:0@64.731] global cap_v4l.cpp:913 open VIDEOIO(V4L2:/dev/video1): can't open camera by index +[ERROR:0@64.732] global obsensor_uvc_stream_channel.cpp:158 getStreamChannelGroup Camera index out of range +[ WARN:0@69.536] global cap_v4l.cpp:913 open VIDEOIO(V4L2:/dev/video1): can't open camera by index +[ERROR:0@69.537] global obsensor_uvc_stream_channel.cpp:158 getStreamChannelGroup Camera index out of range +[ WARN:0@74.431] global cap_v4l.cpp:913 open VIDEOIO(V4L2:/dev/video0): can't open camera by index +[ERROR:0@74.431] global obsensor_uvc_stream_channel.cpp:158 getStreamChannelGroup Camera index out of range +[ WARN:0@74.431] global cap_v4l.cpp:913 open VIDEOIO(V4L2:/dev/video1): can't open camera by index +[ERROR:0@74.431] global obsensor_uvc_stream_channel.cpp:158 getStreamChannelGroup Camera index out of range +[ WARN:0@76.868] global cap_v4l.cpp:913 open VIDEOIO(V4L2:/dev/video0): can't open camera by index +[ERROR:0@76.868] global obsensor_uvc_stream_channel.cpp:158 getStreamChannelGroup Camera index out of range +[ WARN:0@76.868] global cap_v4l.cpp:913 open VIDEOIO(V4L2:/dev/video1): can't open camera by index +[ERROR:0@76.868] global obsensor_uvc_stream_channel.cpp:158 getStreamChannelGroup Camera index out of range +[ WARN:0@79.438] global cap_v4l.cpp:913 open VIDEOIO(V4L2:/dev/video0): can't open camera by index +[ERROR:0@79.438] global obsensor_uvc_stream_channel.cpp:158 getStreamChannelGroup Camera index out of range +[ WARN:0@79.438] global cap_v4l.cpp:913 open VIDEOIO(V4L2:/dev/video1): can't open camera by index +[ERROR:0@79.438] global obsensor_uvc_stream_channel.cpp:158 getStreamChannelGroup Camera index out of range +[2025-05-26 20:25:47 +0200] [824587] [INFO] Handling signal: int +[2025-05-26 20:25:47 +0200] [825556] [INFO] Worker exiting (pid: 825556) +[2025-05-26 20:25:47 +0200] [825600] [INFO] Worker exiting (pid: 825600) +[2025-05-26 20:25:47 +0200] [825602] [INFO] Worker exiting (pid: 825602) +[2025-05-26 20:25:47 +0200] [825584] [INFO] Worker exiting (pid: 825584) +[2025-05-26 20:25:47 +0200] [825639] [INFO] Worker exiting (pid: 825639) +[2025-05-26 20:25:47 +0200] [825544] [INFO] Worker exiting (pid: 825544) +[2025-05-26 20:25:47 +0200] [825666] [INFO] Worker exiting (pid: 825666) +[2025-05-26 20:25:47 +0200] [825571] [INFO] Worker exiting (pid: 825571) +[2025-05-26 20:25:47 +0200] [825651] [INFO] Worker exiting (pid: 825651) +[2025-05-26 20:25:47 +0200] [825682] [INFO] Worker exiting (pid: 825682) +[2025-05-26 20:25:47 +0200] [825529] [INFO] Worker exiting (pid: 825529) +[2025-05-26 20:25:47 +0200] [825601] [INFO] Worker exiting (pid: 825601) +terminate called without an active exception +[2025-05-26 20:25:49 +0200] [824587] [ERROR] Worker (pid:825651) was sent code 134! +[2025-05-26 20:25:49 +0200] [824587] [INFO] Shutting down: Master +[2025-05-26 20:28:26 +0200] [833260] [INFO] Starting gunicorn 23.0.0 +[2025-05-26 20:28:26 +0200] [833260] [INFO] Listening at: http://0.0.0.0:5000 (833260) +[2025-05-26 20:28:26 +0200] [833260] [INFO] Using worker: gthread +[2025-05-26 20:28:26 +0200] [833415] [INFO] Booting worker with pid: 833415 +[2025-05-26 20:28:26 +0200] [833428] [INFO] Booting worker with pid: 833428 +[2025-05-26 20:28:26 +0200] [833448] [INFO] Booting worker with pid: 833448 +[2025-05-26 20:28:26 +0200] [833464] [INFO] Booting worker with pid: 833464 +[2025-05-26 20:28:26 +0200] [833481] [INFO] Booting worker with pid: 833481 +[2025-05-26 20:28:26 +0200] [833493] [INFO] Booting worker with pid: 833493 +[2025-05-26 20:28:26 +0200] [833508] [INFO] Booting worker with pid: 833508 +[2025-05-26 20:28:26 +0200] [833509] [INFO] Booting worker with pid: 833509 +Found YOLO model in directory: mucapy/models +[2025-05-26 20:28:26 +0200] [833537] [INFO] Booting worker with pid: 833537 +[2025-05-26 20:28:27 +0200] [833556] [INFO] Booting worker with pid: 833556 +Found YOLO model in directory: mucapy/models +[2025-05-26 20:28:27 +0200] [833570] [INFO] Booting worker with pid: 833570 +[2025-05-26 20:28:27 +0200] [833585] [INFO] Booting worker with pid: 833585 +Found YOLO model in directory: mucapy/models +Found YOLO model in directory: mucapy/models +Found YOLO model in directory: mucapy/models +Found YOLO model in directory: mucapy/models +Found YOLO model in directory: mucapy/models +Found YOLO model in directory: mucapy/models +Found YOLO model in directory: mucapy/models +Found YOLO model in directory: mucapy/models +Found YOLO model in directory: mucapy/models +Found YOLO model in directory: mucapy/models diff --git a/requirements.txt b/requirements.txt index 24c3e2a..b2ac9ff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,14 @@ -opencv-python>=4.5.0 -opencv-contrib-python>=4.5.0 -numpy>=1.19.0 -PyQt5>=5.15.0 \ No newline at end of file +# 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 \ No newline at end of file diff --git a/run_server.sh b/run_server.sh new file mode 100755 index 0000000..aed450e --- /dev/null +++ b/run_server.sh @@ -0,0 +1,235 @@ +#!/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" \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..447938f --- /dev/null +++ b/templates/index.html @@ -0,0 +1,321 @@ + + + + + + Camera Viewer + + + + +
+
+ Multi-Camera YOLO Detection +
+
_
+
+
×
+
+
+ +
+
+ +
Status: Initializing...
+
+ +
+ +
+
+ +
+ Ready + +
+
+ + + + + \ No newline at end of file diff --git a/web_server.py b/web_server.py new file mode 100644 index 0000000..4994584 --- /dev/null +++ b/web_server.py @@ -0,0 +1,655 @@ +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 + + def start(self): + """Start the camera stream""" + if self.running: + return + + 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) + 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.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 + 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 automatic 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: + logger.info(f"Attempting to reconnect camera {self.camera_id}") + self.last_reconnect_attempt = current_time + self.start() + 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/') +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('/cameras') +def get_cameras(): + """Get list of available cameras""" + # Scan for local cameras silently + 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})' + }) + + # 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) + +@app.route('/add_camera/') +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/') +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}) + +# 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) \ No newline at end of file