Compare commits
14 Commits
experiment
...
frontend
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82f435b8c3 | ||
|
|
64b472ad8e | ||
|
|
3c847cee01 | ||
|
|
9ed0b768b8 | ||
|
|
4a06b759e3 | ||
|
|
2887e2927c | ||
| 6da4cd2b40 | |||
|
|
65be018700 | ||
|
|
5399cb8739 | ||
|
|
b80dd3f7d7 | ||
|
|
c264acac29 | ||
|
|
b24426c1d3 | ||
|
|
2ebaf16006 | ||
| 24ac4ccd51 |
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
BIN
__pycache__/web_server.cpython-313.pyc
Normal file
BIN
__pycache__/web_server.cpython-313.pyc
Normal file
Binary file not shown.
2403
logs/access.log
Normal file
2403
logs/access.log
Normal file
File diff suppressed because it is too large
Load Diff
2003
logs/error.log
Normal file
2003
logs/error.log
Normal file
File diff suppressed because it is too large
Load Diff
1527
mucapy/main.py
1527
mucapy/main.py
File diff suppressed because it is too large
Load Diff
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,14 @@
|
||||
opencv-python>=4.5.0
|
||||
opencv-contrib-python>=4.5.0
|
||||
numpy>=1.19.0
|
||||
PyQt5>=5.15.0
|
||||
# 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
|
||||
235
run_server.sh
Executable file
235
run_server.sh
Executable file
@@ -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"
|
||||
1132
templates/index.html
Normal file
1132
templates/index.html
Normal file
File diff suppressed because it is too large
Load Diff
788
web_server.py
Normal file
788
web_server.py
Normal file
@@ -0,0 +1,788 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user