update tracker
This commit is contained in:
parent
a54da904f7
commit
3a4a27ca68
7 changed files with 1405 additions and 84 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -13,3 +13,5 @@ no_frame_debug.log
|
||||||
|
|
||||||
feeder/
|
feeder/
|
||||||
.venv/
|
.venv/
|
||||||
|
.vscode/
|
||||||
|
dist/
|
||||||
|
|
7
app.py
7
app.py
|
@ -27,7 +27,7 @@ from websockets.exceptions import ConnectionClosedError
|
||||||
from ultralytics import YOLO
|
from ultralytics import YOLO
|
||||||
|
|
||||||
# Import shared pipeline functions
|
# Import shared pipeline functions
|
||||||
from siwatsystem.pympta import load_pipeline_from_zip, run_pipeline
|
from siwatsystem.pympta import load_pipeline_from_zip, run_pipeline, cleanup_camera_stability
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
|
@ -325,6 +325,7 @@ async def detect(websocket: WebSocket):
|
||||||
"type": "imageDetection",
|
"type": "imageDetection",
|
||||||
"subscriptionIdentifier": stream["subscriptionIdentifier"],
|
"subscriptionIdentifier": stream["subscriptionIdentifier"],
|
||||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S.%fZ", time.gmtime()),
|
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S.%fZ", time.gmtime()),
|
||||||
|
# "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime()) + f".{int(time.time() * 1000) % 1000:03d}Z",
|
||||||
"data": {
|
"data": {
|
||||||
"detection": detection_dict,
|
"detection": detection_dict,
|
||||||
"modelId": stream["modelId"],
|
"modelId": stream["modelId"],
|
||||||
|
@ -704,6 +705,7 @@ async def detect(websocket: WebSocket):
|
||||||
del camera_streams[camera_url]
|
del camera_streams[camera_url]
|
||||||
|
|
||||||
latest_frames.pop(subscription_id, None)
|
latest_frames.pop(subscription_id, None)
|
||||||
|
cleanup_camera_stability(subscription_id)
|
||||||
logger.info(f"Unsubscribed from camera {subscription_id}")
|
logger.info(f"Unsubscribed from camera {subscription_id}")
|
||||||
|
|
||||||
async def process_streams():
|
async def process_streams():
|
||||||
|
@ -1018,8 +1020,9 @@ async def detect(websocket: WebSocket):
|
||||||
else:
|
else:
|
||||||
logger.info(f"Shared stream for {camera_url} still has {shared_stream['ref_count']} references")
|
logger.info(f"Shared stream for {camera_url} still has {shared_stream['ref_count']} references")
|
||||||
|
|
||||||
# Clean up cached frame
|
# Clean up cached frame and stability tracking
|
||||||
latest_frames.pop(camera_id, None)
|
latest_frames.pop(camera_id, None)
|
||||||
|
cleanup_camera_stability(camera_id)
|
||||||
logger.info(f"Unsubscribed from camera {camera_id}")
|
logger.info(f"Unsubscribed from camera {camera_id}")
|
||||||
# Note: Keep models in memory for potential reuse
|
# Note: Keep models in memory for potential reuse
|
||||||
elif msg_type == "requestState":
|
elif msg_type == "requestState":
|
||||||
|
|
142
debug/test_camera_indices.py
Normal file
142
debug/test_camera_indices.py
Normal file
|
@ -0,0 +1,142 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test script to check available camera indices
|
||||||
|
"""
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("camera_index_test")
|
||||||
|
|
||||||
|
def test_camera_index(index):
|
||||||
|
"""Test if a camera index is available"""
|
||||||
|
try:
|
||||||
|
cap = cv2.VideoCapture(index)
|
||||||
|
if cap.isOpened():
|
||||||
|
ret, frame = cap.read()
|
||||||
|
if ret and frame is not None:
|
||||||
|
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||||
|
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||||
|
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||||
|
|
||||||
|
cap.release()
|
||||||
|
return True, f"{width}x{height} @ {fps}fps"
|
||||||
|
else:
|
||||||
|
cap.release()
|
||||||
|
return False, "Can open but cannot read frames"
|
||||||
|
else:
|
||||||
|
cap.release()
|
||||||
|
return False, "Cannot open camera"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Error: {str(e)}"
|
||||||
|
|
||||||
|
def get_windows_cameras_ffmpeg():
|
||||||
|
"""Get available cameras on Windows using FFmpeg"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(['ffmpeg', '-f', 'dshow', '-list_devices', 'true', '-i', 'dummy'],
|
||||||
|
capture_output=True, text=True, timeout=10, encoding='utf-8', errors='ignore')
|
||||||
|
output = result.stderr
|
||||||
|
|
||||||
|
lines = output.split('\n')
|
||||||
|
video_devices = []
|
||||||
|
|
||||||
|
# Parse the output - look for lines with (video) that contain device names in quotes
|
||||||
|
for line in lines:
|
||||||
|
if '[dshow @' in line and '(video)' in line and '"' in line:
|
||||||
|
# Extract device name between first pair of quotes
|
||||||
|
start = line.find('"') + 1
|
||||||
|
end = line.find('"', start)
|
||||||
|
if start > 0 and end > start:
|
||||||
|
device_name = line[start:end]
|
||||||
|
video_devices.append(device_name)
|
||||||
|
|
||||||
|
logger.info(f"FFmpeg detected video devices: {video_devices}")
|
||||||
|
return video_devices
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get Windows camera names: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def main():
|
||||||
|
logger.info("=== Camera Index Test ===")
|
||||||
|
|
||||||
|
# Check FFmpeg availability for Windows device detection
|
||||||
|
ffmpeg_available = False
|
||||||
|
try:
|
||||||
|
result = subprocess.run(['ffmpeg', '-version'], capture_output=True, text=True, timeout=5)
|
||||||
|
if result.returncode == 0:
|
||||||
|
ffmpeg_available = True
|
||||||
|
logger.info("FFmpeg is available")
|
||||||
|
except:
|
||||||
|
logger.info("FFmpeg not available")
|
||||||
|
|
||||||
|
# Get Windows camera names if possible
|
||||||
|
if sys.platform.startswith('win') and ffmpeg_available:
|
||||||
|
logger.info("\n=== Windows Camera Devices (FFmpeg) ===")
|
||||||
|
cameras = get_windows_cameras_ffmpeg()
|
||||||
|
if cameras:
|
||||||
|
for i, camera in enumerate(cameras):
|
||||||
|
logger.info(f"Device {i}: {camera}")
|
||||||
|
else:
|
||||||
|
logger.info("No cameras detected via FFmpeg")
|
||||||
|
|
||||||
|
# Test camera indices 0-9
|
||||||
|
logger.info("\n=== Testing Camera Indices ===")
|
||||||
|
available_cameras = []
|
||||||
|
|
||||||
|
for index in range(10):
|
||||||
|
logger.info(f"Testing camera index {index}...")
|
||||||
|
is_available, info = test_camera_index(index)
|
||||||
|
|
||||||
|
if is_available:
|
||||||
|
logger.info(f"✓ Camera {index}: AVAILABLE - {info}")
|
||||||
|
available_cameras.append(index)
|
||||||
|
else:
|
||||||
|
logger.info(f"✗ Camera {index}: NOT AVAILABLE - {info}")
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
logger.info("\n=== Summary ===")
|
||||||
|
if available_cameras:
|
||||||
|
logger.info(f"Available camera indices: {available_cameras}")
|
||||||
|
logger.info(f"Default camera index to use: {available_cameras[0]}")
|
||||||
|
|
||||||
|
# Test the first available camera more thoroughly
|
||||||
|
logger.info(f"\n=== Detailed Test for Camera {available_cameras[0]} ===")
|
||||||
|
cap = cv2.VideoCapture(available_cameras[0])
|
||||||
|
if cap.isOpened():
|
||||||
|
# Get properties
|
||||||
|
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||||
|
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||||
|
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||||
|
backend = cap.getBackendName()
|
||||||
|
|
||||||
|
logger.info(f"Resolution: {width}x{height}")
|
||||||
|
logger.info(f"FPS: {fps}")
|
||||||
|
logger.info(f"Backend: {backend}")
|
||||||
|
|
||||||
|
# Test frame capture
|
||||||
|
ret, frame = cap.read()
|
||||||
|
if ret and frame is not None:
|
||||||
|
logger.info(f"Frame capture: SUCCESS")
|
||||||
|
logger.info(f"Frame shape: {frame.shape}")
|
||||||
|
logger.info(f"Frame dtype: {frame.dtype}")
|
||||||
|
else:
|
||||||
|
logger.info(f"Frame capture: FAILED")
|
||||||
|
|
||||||
|
cap.release()
|
||||||
|
else:
|
||||||
|
logger.error("No cameras available!")
|
||||||
|
logger.info("Possible solutions:")
|
||||||
|
logger.info("1. Check if camera is connected and not used by another application")
|
||||||
|
logger.info("2. Check camera permissions")
|
||||||
|
logger.info("3. Try different camera indices")
|
||||||
|
logger.info("4. Install camera drivers")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
|
@ -17,6 +17,13 @@ from .database import DatabaseManager
|
||||||
# Create a logger specifically for this module
|
# Create a logger specifically for this module
|
||||||
logger = logging.getLogger("detector_worker.pympta")
|
logger = logging.getLogger("detector_worker.pympta")
|
||||||
|
|
||||||
|
# Global camera-aware stability tracking
|
||||||
|
# Structure: {camera_id: {model_id: {"track_stability_counters": {track_id: count}, "stable_tracks": set(), "session_state": {...}}}}
|
||||||
|
_camera_stability_tracking = {}
|
||||||
|
|
||||||
|
# Timer-based cooldown configuration (for testing)
|
||||||
|
_cooldown_duration_seconds = 30
|
||||||
|
|
||||||
def validate_redis_config(redis_config: dict) -> bool:
|
def validate_redis_config(redis_config: dict) -> bool:
|
||||||
"""Validate Redis configuration parameters."""
|
"""Validate Redis configuration parameters."""
|
||||||
required_fields = ["host", "port"]
|
required_fields = ["host", "port"]
|
||||||
|
@ -78,7 +85,7 @@ def load_pipeline_node(node_config: dict, mpta_dir: str, redis_client, db_manage
|
||||||
logger.info(f"Loading model for node {node_config['modelId']} from {model_path}")
|
logger.info(f"Loading model for node {node_config['modelId']} from {model_path}")
|
||||||
model = YOLO(model_path)
|
model = YOLO(model_path)
|
||||||
if torch.cuda.is_available():
|
if torch.cuda.is_available():
|
||||||
logger.info(f"CUDA available. Moving model {node_config['modelId']} to GPU")
|
logger.info(f"CUDA available. Moving model {node_config['modelId']} to GPU VRAM")
|
||||||
model.to("cuda")
|
model.to("cuda")
|
||||||
else:
|
else:
|
||||||
logger.info(f"CUDA not available. Using CPU for model {node_config['modelId']}")
|
logger.info(f"CUDA not available. Using CPU for model {node_config['modelId']}")
|
||||||
|
@ -92,6 +99,10 @@ def load_pipeline_node(node_config: dict, mpta_dir: str, redis_client, db_manage
|
||||||
if name in trigger_classes]
|
if name in trigger_classes]
|
||||||
logger.debug(f"Converted trigger classes to indices: {trigger_class_indices}")
|
logger.debug(f"Converted trigger classes to indices: {trigger_class_indices}")
|
||||||
|
|
||||||
|
# Extract stability threshold from tracking config
|
||||||
|
tracking_config = node_config.get("tracking", {"enabled": True, "reidConfigPath": "botsort.yaml"})
|
||||||
|
stability_threshold = tracking_config.get("stabilityThreshold", 1)
|
||||||
|
|
||||||
node = {
|
node = {
|
||||||
"modelId": node_config["modelId"],
|
"modelId": node_config["modelId"],
|
||||||
"modelFile": node_config["modelFile"],
|
"modelFile": node_config["modelFile"],
|
||||||
|
@ -105,6 +116,8 @@ def load_pipeline_node(node_config: dict, mpta_dir: str, redis_client, db_manage
|
||||||
"parallel": node_config.get("parallel", False),
|
"parallel": node_config.get("parallel", False),
|
||||||
"actions": node_config.get("actions", []),
|
"actions": node_config.get("actions", []),
|
||||||
"parallelActions": node_config.get("parallelActions", []),
|
"parallelActions": node_config.get("parallelActions", []),
|
||||||
|
"tracking": tracking_config,
|
||||||
|
"stabilityThreshold": stability_threshold,
|
||||||
"model": model,
|
"model": model,
|
||||||
"branches": [],
|
"branches": [],
|
||||||
"redis_client": redis_client,
|
"redis_client": redis_client,
|
||||||
|
@ -514,6 +527,342 @@ def resolve_field_mapping(value_template, branch_results, action_context):
|
||||||
logger.error(f"Error resolving field mapping '{value_template}': {e}")
|
logger.error(f"Error resolving field mapping '{value_template}': {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def run_detection_with_tracking(frame, node, context=None):
|
||||||
|
"""
|
||||||
|
Structured function for running YOLO detection with BoT-SORT tracking.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: Input frame/image
|
||||||
|
node: Pipeline node configuration with model and settings
|
||||||
|
context: Optional context information (camera info, session data, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (all_detections, regions_dict) where:
|
||||||
|
- all_detections: List of all detection objects
|
||||||
|
- regions_dict: Dict mapping class names to highest confidence detections
|
||||||
|
|
||||||
|
Configuration options in node:
|
||||||
|
- model: YOLO model instance
|
||||||
|
- triggerClassIndices: List of class indices to detect (None for all classes)
|
||||||
|
- minConfidence: Minimum confidence threshold
|
||||||
|
- multiClass: Whether to enable multi-class detection mode
|
||||||
|
- expectedClasses: List of expected class names for multi-class validation
|
||||||
|
- tracking: Dict with tracking configuration
|
||||||
|
- enabled: Boolean to enable/disable tracking
|
||||||
|
- reidConfigPath: Path to ReID config file (default: "botsort.yaml")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Extract tracking configuration
|
||||||
|
tracking_config = node.get("tracking", {})
|
||||||
|
tracking_enabled = tracking_config.get("enabled", True)
|
||||||
|
reid_config_path = tracking_config.get("reidConfigPath", "botsort.yaml")
|
||||||
|
|
||||||
|
# Check if we need to reset tracker after cooldown
|
||||||
|
camera_id = context.get("camera_id", "unknown") if context else "unknown"
|
||||||
|
model_id = node.get("modelId", "unknown")
|
||||||
|
stability_data = get_camera_stability_data(camera_id, model_id)
|
||||||
|
session_state = stability_data["session_state"]
|
||||||
|
|
||||||
|
if session_state.get("reset_tracker_on_resume", False):
|
||||||
|
# Reset YOLO tracker to get fresh track IDs
|
||||||
|
if hasattr(node["model"], 'trackers') and node["model"].trackers:
|
||||||
|
node["model"].trackers.clear() # Clear tracker state
|
||||||
|
logger.info(f"Camera {camera_id}: 🔄 Reset YOLO tracker - new cars will get fresh track IDs")
|
||||||
|
session_state["reset_tracker_on_resume"] = False # Clear the flag
|
||||||
|
|
||||||
|
# Get tracking zone from runtime context (camera-specific)
|
||||||
|
tracking_zone = context.get("trackingZone", []) if context else []
|
||||||
|
|
||||||
|
# Prepare class filtering
|
||||||
|
trigger_class_indices = node.get("triggerClassIndices")
|
||||||
|
class_filter = {"classes": trigger_class_indices} if trigger_class_indices else {}
|
||||||
|
|
||||||
|
logger.debug(f"Running detection for {node['modelId']} - tracking: {tracking_enabled}, classes: {node.get('triggerClasses', 'all')}")
|
||||||
|
|
||||||
|
if tracking_enabled and tracking_zone:
|
||||||
|
# Use tracking with zone validation
|
||||||
|
logger.debug(f"Using tracking with ReID config: {reid_config_path}")
|
||||||
|
res = node["model"].track(
|
||||||
|
frame,
|
||||||
|
stream=False,
|
||||||
|
persist=True,
|
||||||
|
tracker=reid_config_path,
|
||||||
|
**class_filter
|
||||||
|
)[0]
|
||||||
|
elif tracking_enabled:
|
||||||
|
# Use tracking without zone restriction
|
||||||
|
logger.debug("Using tracking without zone restriction")
|
||||||
|
res = node["model"].track(
|
||||||
|
frame,
|
||||||
|
stream=False,
|
||||||
|
persist=True,
|
||||||
|
**class_filter
|
||||||
|
)[0]
|
||||||
|
else:
|
||||||
|
# Use detection only (no tracking)
|
||||||
|
logger.debug("Using detection only (tracking disabled)")
|
||||||
|
res = node["model"].predict(
|
||||||
|
frame,
|
||||||
|
stream=False,
|
||||||
|
**class_filter
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
# Process detection results
|
||||||
|
all_detections = []
|
||||||
|
regions_dict = {}
|
||||||
|
min_confidence = node.get("minConfidence", 0.0)
|
||||||
|
|
||||||
|
if res.boxes is None or len(res.boxes) == 0:
|
||||||
|
logger.debug("No detections found")
|
||||||
|
return [], {}
|
||||||
|
|
||||||
|
logger.debug(f"Processing {len(res.boxes)} raw detections")
|
||||||
|
|
||||||
|
for i, box in enumerate(res.boxes):
|
||||||
|
# Extract detection data
|
||||||
|
conf = float(box.cpu().conf[0])
|
||||||
|
cls_id = int(box.cpu().cls[0])
|
||||||
|
class_name = node["model"].names[cls_id]
|
||||||
|
|
||||||
|
# Apply confidence filtering
|
||||||
|
if conf < min_confidence:
|
||||||
|
logger.debug(f"Detection {i} '{class_name}' rejected: {conf:.3f} < {min_confidence}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract bounding box
|
||||||
|
xy = box.cpu().xyxy[0]
|
||||||
|
x1, y1, x2, y2 = map(int, xy)
|
||||||
|
bbox = (x1, y1, x2, y2)
|
||||||
|
|
||||||
|
# Extract tracking ID if available
|
||||||
|
track_id = None
|
||||||
|
if hasattr(box, "id") and box.id is not None:
|
||||||
|
track_id = int(box.id.item())
|
||||||
|
|
||||||
|
# Apply tracking zone validation if enabled
|
||||||
|
if tracking_enabled and tracking_zone:
|
||||||
|
bbox_center_x = (x1 + x2) // 2
|
||||||
|
bbox_center_y = (y1 + y2) // 2
|
||||||
|
|
||||||
|
# Check if detection center is within tracking zone
|
||||||
|
if not _point_in_polygon((bbox_center_x, bbox_center_y), tracking_zone):
|
||||||
|
logger.debug(f"Detection {i} '{class_name}' outside tracking zone")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Create detection object
|
||||||
|
detection = {
|
||||||
|
"class": class_name,
|
||||||
|
"confidence": conf,
|
||||||
|
"id": track_id,
|
||||||
|
"bbox": bbox,
|
||||||
|
"class_id": cls_id
|
||||||
|
}
|
||||||
|
|
||||||
|
all_detections.append(detection)
|
||||||
|
logger.debug(f"Detection {i} accepted: {class_name} (conf={conf:.3f}, id={track_id}, bbox={bbox})")
|
||||||
|
|
||||||
|
# Update regions_dict with highest confidence detection per class
|
||||||
|
if class_name not in regions_dict or conf > regions_dict[class_name]["confidence"]:
|
||||||
|
regions_dict[class_name] = {
|
||||||
|
"bbox": bbox,
|
||||||
|
"confidence": conf,
|
||||||
|
"detection": detection,
|
||||||
|
"track_id": track_id
|
||||||
|
}
|
||||||
|
|
||||||
|
# Multi-class validation
|
||||||
|
if node.get("multiClass", False) and node.get("expectedClasses"):
|
||||||
|
expected_classes = node["expectedClasses"]
|
||||||
|
detected_classes = list(regions_dict.keys())
|
||||||
|
|
||||||
|
logger.debug(f"Multi-class validation: expected={expected_classes}, detected={detected_classes}")
|
||||||
|
|
||||||
|
# Check for required classes (flexible - at least one must match)
|
||||||
|
matching_classes = [cls for cls in expected_classes if cls in detected_classes]
|
||||||
|
if not matching_classes:
|
||||||
|
logger.warning(f"Multi-class validation failed: no expected classes detected")
|
||||||
|
return [], {}
|
||||||
|
|
||||||
|
logger.info(f"Multi-class validation passed: {matching_classes} detected")
|
||||||
|
|
||||||
|
logger.info(f"Detection completed: {len(all_detections)} detections, {len(regions_dict)} unique classes")
|
||||||
|
|
||||||
|
# Update stability tracking for detections with track IDs (requires camera_id from context)
|
||||||
|
camera_id = context.get("camera_id", "unknown") if context else "unknown"
|
||||||
|
update_track_stability(node, all_detections, camera_id)
|
||||||
|
|
||||||
|
return all_detections, regions_dict
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in detection_with_tracking for {node.get('modelId', 'unknown')}: {e}")
|
||||||
|
logger.debug(f"Detection error traceback: {traceback.format_exc()}")
|
||||||
|
return [], {}
|
||||||
|
|
||||||
|
def _point_in_polygon(point, polygon):
|
||||||
|
"""Check if a point is inside a polygon using ray casting algorithm."""
|
||||||
|
if not polygon or len(polygon) < 3:
|
||||||
|
return True # No zone restriction if invalid polygon
|
||||||
|
|
||||||
|
x, y = point
|
||||||
|
n = len(polygon)
|
||||||
|
inside = False
|
||||||
|
|
||||||
|
p1x, p1y = polygon[0]
|
||||||
|
for i in range(1, n + 1):
|
||||||
|
p2x, p2y = polygon[i % n]
|
||||||
|
if y > min(p1y, p2y):
|
||||||
|
if y <= max(p1y, p2y):
|
||||||
|
if x <= max(p1x, p2x):
|
||||||
|
if p1y != p2y:
|
||||||
|
xinters = (y - p1y) * (p2x - p1x) / (p2y - p1y) + p1x
|
||||||
|
if p1x == p2x or x <= xinters:
|
||||||
|
inside = not inside
|
||||||
|
p1x, p1y = p2x, p2y
|
||||||
|
|
||||||
|
return inside
|
||||||
|
|
||||||
|
def get_camera_stability_data(camera_id, model_id):
|
||||||
|
"""Get or create stability tracking data for a specific camera and model."""
|
||||||
|
global _camera_stability_tracking
|
||||||
|
|
||||||
|
if camera_id not in _camera_stability_tracking:
|
||||||
|
_camera_stability_tracking[camera_id] = {}
|
||||||
|
|
||||||
|
if model_id not in _camera_stability_tracking[camera_id]:
|
||||||
|
logger.warning(f"🔄 Camera {camera_id}: Creating NEW stability data for {model_id} - this will reset any cooldown!")
|
||||||
|
_camera_stability_tracking[camera_id][model_id] = {
|
||||||
|
"track_stability_counters": {},
|
||||||
|
"stable_tracks": set(),
|
||||||
|
"session_state": {
|
||||||
|
"active": True,
|
||||||
|
"cooldown_until": 0.0,
|
||||||
|
"reset_tracker_on_resume": False
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _camera_stability_tracking[camera_id][model_id]
|
||||||
|
|
||||||
|
def update_track_stability(node, detections, camera_id):
|
||||||
|
"""Update stability counters for tracked objects per camera."""
|
||||||
|
stability_threshold = node.get("stabilityThreshold", 1)
|
||||||
|
model_id = node.get("modelId", "unknown")
|
||||||
|
|
||||||
|
# Get camera-specific stability data
|
||||||
|
stability_data = get_camera_stability_data(camera_id, model_id)
|
||||||
|
track_counters = stability_data["track_stability_counters"]
|
||||||
|
stable_tracks = stability_data["stable_tracks"]
|
||||||
|
|
||||||
|
# Get current track IDs from detections
|
||||||
|
current_track_ids = set()
|
||||||
|
for detection in detections:
|
||||||
|
track_id = detection.get("id")
|
||||||
|
if track_id is not None:
|
||||||
|
current_track_ids.add(track_id)
|
||||||
|
|
||||||
|
# Increment counter for this track
|
||||||
|
track_counters[track_id] = track_counters.get(track_id, 0) + 1
|
||||||
|
|
||||||
|
# Check if track becomes stable
|
||||||
|
if track_counters[track_id] >= stability_threshold and track_id not in stable_tracks:
|
||||||
|
stable_tracks.add(track_id)
|
||||||
|
logger.info(f"Camera {camera_id}: Track ID {track_id} became stable after {track_counters[track_id]} detections (threshold: {stability_threshold})")
|
||||||
|
|
||||||
|
# Clean up counters for tracks that disappeared
|
||||||
|
disappeared_tracks = set(track_counters.keys()) - current_track_ids
|
||||||
|
for track_id in disappeared_tracks:
|
||||||
|
logger.debug(f"Camera {camera_id}: Track ID {track_id} disappeared, removing from counters")
|
||||||
|
track_counters.pop(track_id, None)
|
||||||
|
stable_tracks.discard(track_id)
|
||||||
|
|
||||||
|
logger.debug(f"Camera {camera_id}: Track stability: active={list(current_track_ids)}, stable={list(stable_tracks)}, counters={track_counters}")
|
||||||
|
|
||||||
|
def check_stable_tracks(camera_id, model_id, regions_dict):
|
||||||
|
"""Check if any stable tracks match the detected classes for a specific camera."""
|
||||||
|
# Get camera-specific stability data
|
||||||
|
stability_data = get_camera_stability_data(camera_id, model_id)
|
||||||
|
stable_tracks = stability_data["stable_tracks"]
|
||||||
|
|
||||||
|
if not stable_tracks:
|
||||||
|
return False, []
|
||||||
|
|
||||||
|
# Check if any detection in regions_dict has a stable track ID
|
||||||
|
stable_detections = []
|
||||||
|
for class_name, region_data in regions_dict.items():
|
||||||
|
detection = region_data.get("detection", {})
|
||||||
|
track_id = detection.get("id")
|
||||||
|
|
||||||
|
if track_id is not None and track_id in stable_tracks:
|
||||||
|
stable_detections.append((class_name, track_id))
|
||||||
|
logger.debug(f"Camera {camera_id}: Found stable detection: {class_name} with stable track ID {track_id}")
|
||||||
|
|
||||||
|
has_stable_tracks = len(stable_detections) > 0
|
||||||
|
return has_stable_tracks, stable_detections
|
||||||
|
|
||||||
|
def start_cooldown_timer(camera_id, model_id):
|
||||||
|
"""Start 30-second cooldown timer after successful pipeline completion."""
|
||||||
|
stability_data = get_camera_stability_data(camera_id, model_id)
|
||||||
|
session_state = stability_data["session_state"]
|
||||||
|
|
||||||
|
# Start timer-based cooldown
|
||||||
|
cooldown_until = time.time() + _cooldown_duration_seconds
|
||||||
|
session_state["cooldown_until"] = cooldown_until
|
||||||
|
session_state["active"] = False
|
||||||
|
session_state["reset_tracker_on_resume"] = True # Flag to reset YOLO tracker
|
||||||
|
|
||||||
|
logger.info(f"Camera {camera_id}: 🛑 Starting {_cooldown_duration_seconds}s cooldown timer (until: {cooldown_until:.2f})")
|
||||||
|
|
||||||
|
# DO NOT clear tracking state here - preserve it during cooldown
|
||||||
|
# Tracking state will be cleared when cooldown expires and new session starts
|
||||||
|
|
||||||
|
def is_camera_active(camera_id, model_id):
|
||||||
|
"""Check if camera should be processing detections (timer-based cooldown)."""
|
||||||
|
stability_data = get_camera_stability_data(camera_id, model_id)
|
||||||
|
session_state = stability_data["session_state"]
|
||||||
|
|
||||||
|
# Check if cooldown timer has expired
|
||||||
|
if not session_state["active"]:
|
||||||
|
current_time = time.time()
|
||||||
|
cooldown_until = session_state["cooldown_until"]
|
||||||
|
remaining_time = cooldown_until - current_time
|
||||||
|
|
||||||
|
if current_time >= cooldown_until:
|
||||||
|
session_state["active"] = True
|
||||||
|
session_state["reset_tracker_on_resume"] = True # Ensure tracker reset flag is set
|
||||||
|
|
||||||
|
# Clear tracking state NOW - before new detection session starts
|
||||||
|
stability_data["track_stability_counters"].clear()
|
||||||
|
stability_data["stable_tracks"].clear()
|
||||||
|
|
||||||
|
logger.info(f"Camera {camera_id}: 📢 Cooldown timer ended, resuming detection with fresh track IDs")
|
||||||
|
logger.info(f"Camera {camera_id}: 🧹 Cleared stability counters and stable tracks for fresh session")
|
||||||
|
else:
|
||||||
|
logger.debug(f"Camera {camera_id}: Still in cooldown - {remaining_time:.1f}s remaining")
|
||||||
|
|
||||||
|
return session_state["active"]
|
||||||
|
|
||||||
|
def cleanup_camera_stability(camera_id):
|
||||||
|
"""Clean up stability tracking data when a camera is disconnected, preserving cooldown timers."""
|
||||||
|
global _camera_stability_tracking
|
||||||
|
if camera_id in _camera_stability_tracking:
|
||||||
|
# Check if any models are still in cooldown before cleanup
|
||||||
|
models_in_cooldown = []
|
||||||
|
for model_id, model_data in _camera_stability_tracking[camera_id].items():
|
||||||
|
session_state = model_data.get("session_state", {})
|
||||||
|
if not session_state.get("active", True) and time.time() < session_state.get("cooldown_until", 0):
|
||||||
|
cooldown_remaining = session_state["cooldown_until"] - time.time()
|
||||||
|
models_in_cooldown.append((model_id, cooldown_remaining))
|
||||||
|
logger.warning(f"⚠️ Camera {camera_id}: Model {model_id} is in cooldown ({cooldown_remaining:.1f}s remaining) - preserving timer!")
|
||||||
|
|
||||||
|
if models_in_cooldown:
|
||||||
|
# DO NOT clear any tracking data during cooldown - preserve everything
|
||||||
|
logger.warning(f"⚠️ Camera {camera_id}: PRESERVING ALL data during cooldown - no cleanup performed!")
|
||||||
|
logger.warning(f" - Track IDs will reset only AFTER cooldown expires")
|
||||||
|
logger.warning(f" - Stability counters preserved until cooldown ends")
|
||||||
|
else:
|
||||||
|
# Safe to delete everything - no active cooldowns
|
||||||
|
del _camera_stability_tracking[camera_id]
|
||||||
|
logger.info(f"Cleaned up stability tracking data for camera {camera_id} (no active cooldowns)")
|
||||||
|
|
||||||
def validate_pipeline_execution(node, regions_dict):
|
def validate_pipeline_execution(node, regions_dict):
|
||||||
"""
|
"""
|
||||||
Pre-validate that all required branches will execute successfully before
|
Pre-validate that all required branches will execute successfully before
|
||||||
|
@ -618,92 +967,42 @@ def run_pipeline(frame, node: dict, return_bbox: bool=False, context=None):
|
||||||
execute_actions(node, frame, det)
|
execute_actions(node, frame, det)
|
||||||
return (det, None) if return_bbox else det
|
return (det, None) if return_bbox else det
|
||||||
|
|
||||||
# ─── Detection stage - Multi-class support ──────────────────
|
# ─── Session management check ───────────────────────────────────────
|
||||||
tk = node["triggerClassIndices"]
|
camera_id = context.get("camera_id", "unknown") if context else "unknown"
|
||||||
logger.debug(f"Running detection for node {node['modelId']} with trigger classes: {node.get('triggerClasses', [])} (indices: {tk})")
|
model_id = node.get("modelId", "unknown")
|
||||||
logger.debug(f"Node configuration: minConfidence={node['minConfidence']}, multiClass={node.get('multiClass', False)}")
|
|
||||||
|
|
||||||
res = node["model"].track(
|
if not is_camera_active(camera_id, model_id):
|
||||||
frame,
|
logger.info(f"⏰ Camera {camera_id}: Tracker stopped - in cooldown period, skipping all detection")
|
||||||
stream=False,
|
return (None, None) if return_bbox else None
|
||||||
persist=True,
|
|
||||||
**({"classes": tk} if tk else {})
|
|
||||||
)[0]
|
|
||||||
|
|
||||||
# Collect all detections above confidence threshold
|
# ─── Detection stage - Using structured detection function ──────────────────
|
||||||
all_detections = []
|
all_detections, regions_dict = run_detection_with_tracking(frame, node, context)
|
||||||
all_boxes = []
|
|
||||||
regions_dict = {}
|
|
||||||
|
|
||||||
logger.debug(f"Raw detection results from model: {len(res.boxes) if res.boxes is not None else 0} detections")
|
|
||||||
|
|
||||||
for i, box in enumerate(res.boxes):
|
|
||||||
conf = float(box.cpu().conf[0])
|
|
||||||
cid = int(box.cpu().cls[0])
|
|
||||||
name = node["model"].names[cid]
|
|
||||||
|
|
||||||
logger.debug(f"Detection {i}: class='{name}' (id={cid}), confidence={conf:.3f}, threshold={node['minConfidence']}")
|
|
||||||
|
|
||||||
if conf < node["minConfidence"]:
|
|
||||||
logger.debug(f" -> REJECTED: confidence {conf:.3f} < threshold {node['minConfidence']}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
xy = box.cpu().xyxy[0]
|
|
||||||
x1, y1, x2, y2 = map(int, xy)
|
|
||||||
bbox = (x1, y1, x2, y2)
|
|
||||||
|
|
||||||
detection = {
|
|
||||||
"class": name,
|
|
||||||
"confidence": conf,
|
|
||||||
"id": box.id.item() if hasattr(box, "id") else None,
|
|
||||||
"bbox": bbox
|
|
||||||
}
|
|
||||||
|
|
||||||
all_detections.append(detection)
|
|
||||||
all_boxes.append(bbox)
|
|
||||||
|
|
||||||
logger.debug(f" -> ACCEPTED: {name} with confidence {conf:.3f}, bbox={bbox}")
|
|
||||||
|
|
||||||
# Store highest confidence detection for each class
|
|
||||||
if name not in regions_dict or conf > regions_dict[name]["confidence"]:
|
|
||||||
regions_dict[name] = {
|
|
||||||
"bbox": bbox,
|
|
||||||
"confidence": conf,
|
|
||||||
"detection": detection
|
|
||||||
}
|
|
||||||
logger.debug(f" -> Updated regions_dict['{name}'] with confidence {conf:.3f}")
|
|
||||||
|
|
||||||
logger.info(f"Detection summary: {len(all_detections)} accepted detections from {len(res.boxes) if res.boxes is not None else 0} total")
|
|
||||||
logger.info(f"Detected classes: {list(regions_dict.keys())}")
|
|
||||||
|
|
||||||
if not all_detections:
|
if not all_detections:
|
||||||
logger.warning("No detections above confidence threshold - returning null")
|
logger.warning("No detections from structured detection function - returning null")
|
||||||
return (None, None) if return_bbox else None
|
return (None, None) if return_bbox else None
|
||||||
|
|
||||||
# ─── Multi-class validation ─────────────────────────────────
|
# Extract bounding boxes for compatibility
|
||||||
if node.get("multiClass", False) and node.get("expectedClasses"):
|
all_boxes = [det["bbox"] for det in all_detections]
|
||||||
expected_classes = node["expectedClasses"]
|
|
||||||
detected_classes = list(regions_dict.keys())
|
|
||||||
|
|
||||||
logger.info(f"Multi-class validation: expected={expected_classes}, detected={detected_classes}")
|
# ─── Stability validation (only for root pipeline node) ────────────────────────
|
||||||
|
stability_threshold = node.get("stabilityThreshold", 1)
|
||||||
|
if stability_threshold > 1:
|
||||||
|
# Extract camera_id for stability check
|
||||||
|
camera_id = context.get("camera_id", "unknown") if context else "unknown"
|
||||||
|
model_id = node.get("modelId", "unknown")
|
||||||
|
|
||||||
# Check if at least one expected class is detected (flexible mode)
|
# Check if we have stable tracks for this specific camera
|
||||||
matching_classes = [cls for cls in expected_classes if cls in detected_classes]
|
has_stable_tracks, stable_detections = check_stable_tracks(camera_id, model_id, regions_dict)
|
||||||
missing_classes = [cls for cls in expected_classes if cls not in detected_classes]
|
|
||||||
|
|
||||||
logger.debug(f"Matching classes: {matching_classes}, Missing classes: {missing_classes}")
|
if not has_stable_tracks:
|
||||||
|
logger.info(f"Camera {camera_id}: Track not stable yet (threshold: {stability_threshold}) - validation only, skipping branches")
|
||||||
if not matching_classes:
|
# Return early with just the detection result, no branch processing
|
||||||
# No expected classes found at all
|
primary_detection = max(all_detections, key=lambda x: x["confidence"]) if all_detections else {"class": "none", "confidence": 0.0, "bbox": [0, 0, 0, 0]}
|
||||||
logger.warning(f"PIPELINE REJECTED: No expected classes detected. Expected: {expected_classes}, Detected: {detected_classes}")
|
primary_bbox = primary_detection.get("bbox", [0, 0, 0, 0])
|
||||||
return (None, None) if return_bbox else None
|
return (primary_detection, primary_bbox) if return_bbox else primary_detection
|
||||||
|
|
||||||
if missing_classes:
|
|
||||||
logger.info(f"Partial multi-class detection: {matching_classes} found, {missing_classes} missing")
|
|
||||||
else:
|
else:
|
||||||
logger.info(f"Complete multi-class detection success: {detected_classes}")
|
logger.info(f"Camera {camera_id}: Stable tracks {[det[1] for det in stable_detections]} detected - proceeding with full pipeline")
|
||||||
else:
|
|
||||||
logger.debug("No multi-class validation - proceeding with all detections")
|
|
||||||
|
|
||||||
# ─── Pre-validate pipeline execution ────────────────────────
|
# ─── Pre-validate pipeline execution ────────────────────────
|
||||||
pipeline_valid, missing_branches = validate_pipeline_execution(node, regions_dict)
|
pipeline_valid, missing_branches = validate_pipeline_execution(node, regions_dict)
|
||||||
|
@ -752,10 +1051,14 @@ def run_pipeline(frame, node: dict, return_bbox: bool=False, context=None):
|
||||||
|
|
||||||
execute_actions(node, frame, detection_result, regions_dict)
|
execute_actions(node, frame, detection_result, regions_dict)
|
||||||
|
|
||||||
# ─── Parallel branch processing ─────────────────────────────
|
# ─── Branch processing (no stability check here) ─────────────────────────────
|
||||||
if node["branches"]:
|
if node["branches"]:
|
||||||
branch_results = {}
|
branch_results = {}
|
||||||
|
|
||||||
|
# Extract camera_id for logging
|
||||||
|
camera_id = detection_result.get("camera_id", context.get("camera_id", "unknown") if context else "unknown")
|
||||||
|
|
||||||
|
|
||||||
# Filter branches that should be triggered
|
# Filter branches that should be triggered
|
||||||
active_branches = []
|
active_branches = []
|
||||||
for br in node["branches"]:
|
for br in node["branches"]:
|
||||||
|
@ -849,6 +1152,10 @@ def run_pipeline(frame, node: dict, return_bbox: bool=False, context=None):
|
||||||
if node.get("parallelActions") and "branch_results" in detection_result:
|
if node.get("parallelActions") and "branch_results" in detection_result:
|
||||||
execute_parallel_actions(node, frame, detection_result, regions_dict)
|
execute_parallel_actions(node, frame, detection_result, regions_dict)
|
||||||
|
|
||||||
|
# ─── Start 30s cooldown timer after successful pipeline completion ─────────────────
|
||||||
|
start_cooldown_timer(camera_id, model_id)
|
||||||
|
logger.info(f"Camera {camera_id}: Pipeline completed successfully, starting 30s cooldown")
|
||||||
|
|
||||||
# ─── Return detection result ────────────────────────────────
|
# ─── Return detection result ────────────────────────────────
|
||||||
primary_detection = max(all_detections, key=lambda x: x["confidence"])
|
primary_detection = max(all_detections, key=lambda x: x["confidence"])
|
||||||
primary_bbox = primary_detection["bbox"]
|
primary_bbox = primary_detection["bbox"]
|
||||||
|
|
352
test_botsort_zone_track.py
Normal file
352
test_botsort_zone_track.py
Normal file
|
@ -0,0 +1,352 @@
|
||||||
|
import cv2
|
||||||
|
import torch
|
||||||
|
import numpy as np
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
from ultralytics import YOLO
|
||||||
|
|
||||||
|
def point_in_polygon(point, polygon):
|
||||||
|
"""Check if a point is inside a polygon using ray casting algorithm"""
|
||||||
|
x, y = point
|
||||||
|
n = len(polygon)
|
||||||
|
inside = False
|
||||||
|
|
||||||
|
p1x, p1y = polygon[0]
|
||||||
|
for i in range(1, n + 1):
|
||||||
|
p2x, p2y = polygon[i % n]
|
||||||
|
if y > min(p1y, p2y):
|
||||||
|
if y <= max(p1y, p2y):
|
||||||
|
if x <= max(p1x, p2x):
|
||||||
|
if p1y != p2y:
|
||||||
|
xinters = (y - p1y) * (p2x - p1x) / (p2y - p1y) + p1x
|
||||||
|
if p1x == p2x or x <= xinters:
|
||||||
|
inside = not inside
|
||||||
|
p1x, p1y = p2x, p2y
|
||||||
|
|
||||||
|
return inside
|
||||||
|
|
||||||
|
def draw_zone(frame, zone_polygon, color=(255, 0, 0), thickness=3):
|
||||||
|
"""Draw tracking zone on frame"""
|
||||||
|
pts = np.array(zone_polygon, np.int32)
|
||||||
|
pts = pts.reshape((-1, 1, 2))
|
||||||
|
cv2.polylines(frame, [pts], True, color, thickness)
|
||||||
|
|
||||||
|
# Add semi-transparent fill
|
||||||
|
overlay = frame.copy()
|
||||||
|
cv2.fillPoly(overlay, [pts], color)
|
||||||
|
cv2.addWeighted(overlay, 0.2, frame, 0.8, 0, frame)
|
||||||
|
|
||||||
|
def setup_video_writer(output_path, fps, width, height):
|
||||||
|
"""Setup video writer for output"""
|
||||||
|
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
|
||||||
|
return cv2.VideoWriter(output_path, fourcc, fps, (width, height))
|
||||||
|
|
||||||
|
def write_frame_to_video(video_writer, frame, repeat_count):
|
||||||
|
"""Write frame to video with specified repeat count"""
|
||||||
|
for _ in range(repeat_count):
|
||||||
|
video_writer.write(frame)
|
||||||
|
|
||||||
|
def finalize_video(video_writer):
|
||||||
|
"""Release video writer"""
|
||||||
|
video_writer.release()
|
||||||
|
|
||||||
|
def main():
|
||||||
|
video_path = "sample2.mp4"
|
||||||
|
yolo_model = "bangchakv2/yolov8n.pt"
|
||||||
|
|
||||||
|
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
||||||
|
print(f"Using device: {device}")
|
||||||
|
|
||||||
|
print("Loading YOLO model...")
|
||||||
|
model = YOLO(yolo_model)
|
||||||
|
|
||||||
|
print("Opening video...")
|
||||||
|
cap = cv2.VideoCapture(video_path)
|
||||||
|
fps = int(cap.get(cv2.CAP_PROP_FPS))
|
||||||
|
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||||
|
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||||
|
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||||
|
|
||||||
|
print(f"Video info: {width}x{height}, {fps} FPS, {total_frames} frames")
|
||||||
|
|
||||||
|
# Define tracking zone - Gas station floor area (trapezoidal shape)
|
||||||
|
# Based on the perspective of the gas station floor from your image
|
||||||
|
# width 2560, height 1440
|
||||||
|
|
||||||
|
tracking_zone = [
|
||||||
|
(423, 974), # Point 1
|
||||||
|
(1540, 1407), # Point 2
|
||||||
|
(1976, 806), # Point 3
|
||||||
|
(1364, 749) # Point 4
|
||||||
|
]
|
||||||
|
|
||||||
|
print(f"🎯 Tracking zone defined: {tracking_zone}")
|
||||||
|
|
||||||
|
# CONTINUOUS TRACKING: Process every 118 frames (~2.0s intervals)
|
||||||
|
frame_skip = 118
|
||||||
|
|
||||||
|
print(f"🎯 CONTINUOUS MODE: Processing every {frame_skip} frames ({frame_skip/fps:.2f}s intervals)")
|
||||||
|
print(f"🎬 Output video will have same duration as input (each processed frame shown for 2 seconds)")
|
||||||
|
print("🔥 ZONE-FIRST TRACKING: Only cars entering the zone will be tracked!")
|
||||||
|
print("Requires 5 consecutive detections IN ZONE for verification")
|
||||||
|
print("🕐 24/7 MODE: Memory reset every hour to prevent overflow")
|
||||||
|
print("Press 'q' to quit")
|
||||||
|
|
||||||
|
# Setup video writer for output (same fps as input for normal playback speed)
|
||||||
|
output_path = "tracking_output_botsort_zone_track.mp4"
|
||||||
|
output_fps = fps # Use same fps as input video
|
||||||
|
out = setup_video_writer(output_path, output_fps, width, height)
|
||||||
|
|
||||||
|
# Track car IDs and their consecutive detections
|
||||||
|
car_id_counts = defaultdict(int)
|
||||||
|
successful_cars = set()
|
||||||
|
last_positions = {}
|
||||||
|
processed_count = 0
|
||||||
|
|
||||||
|
# ID remapping for clean sequential zone IDs
|
||||||
|
tracker_to_zone_id = {} # Maps tracker IDs to clean zone IDs
|
||||||
|
next_zone_id = 1 # Next clean zone ID to assign
|
||||||
|
|
||||||
|
# Store previous frame detections to filter tracking inputs
|
||||||
|
previous_zone_cars = set()
|
||||||
|
|
||||||
|
# 24/7 operation: Reset every hour (1800 snapshots at 2-sec intervals = 1 hour)
|
||||||
|
RESET_INTERVAL = 1800 # Reset every 1800 processed frames (1 hour)
|
||||||
|
|
||||||
|
frame_idx = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# Skip frames to maintain interval
|
||||||
|
for _ in range(frame_skip):
|
||||||
|
ret, frame = cap.read()
|
||||||
|
if not ret:
|
||||||
|
print("\nNo more frames to read")
|
||||||
|
cap.release()
|
||||||
|
cv2.destroyAllWindows()
|
||||||
|
return
|
||||||
|
frame_idx += 1
|
||||||
|
|
||||||
|
processed_count += 1
|
||||||
|
current_time = frame_idx / fps
|
||||||
|
|
||||||
|
print(f"\n🎬 Frame {frame_idx} at {current_time:.2f}s (processed #{processed_count})")
|
||||||
|
|
||||||
|
# 24/7 Memory Management: Reset every hour
|
||||||
|
if processed_count % RESET_INTERVAL == 0:
|
||||||
|
print(f"🕐 HOURLY RESET: Clearing all tracking data (processed {processed_count} frames)")
|
||||||
|
print(f" 📊 Before reset: {len(tracker_to_zone_id)} tracked cars, next Zone ID was {next_zone_id}")
|
||||||
|
|
||||||
|
# Clear all tracking data
|
||||||
|
tracker_to_zone_id.clear()
|
||||||
|
car_id_counts.clear()
|
||||||
|
successful_cars.clear()
|
||||||
|
last_positions.clear()
|
||||||
|
next_zone_id = 1 # Reset to 1
|
||||||
|
|
||||||
|
# Reset BoT-SORT tracker state
|
||||||
|
try:
|
||||||
|
model.reset()
|
||||||
|
print(f" ✅ BoT-SORT tracker reset successfully")
|
||||||
|
except:
|
||||||
|
print(f" ⚠️ BoT-SORT reset not available (continuing without reset)")
|
||||||
|
|
||||||
|
print(f" 🆕 Zone IDs will start from 1 again")
|
||||||
|
|
||||||
|
# Draw tracking zone on frame
|
||||||
|
draw_zone(frame, tracking_zone, color=(0, 255, 255), thickness=3) # Yellow zone
|
||||||
|
|
||||||
|
# First run YOLO detection (without tracking) to find cars in zone
|
||||||
|
detection_results = model(frame, verbose=False, conf=0.7, classes=[2])
|
||||||
|
|
||||||
|
# Find cars currently in the tracking zone
|
||||||
|
current_zone_cars = []
|
||||||
|
total_detections = 0
|
||||||
|
|
||||||
|
if detection_results[0].boxes is not None:
|
||||||
|
boxes = detection_results[0].boxes.xyxy.cpu()
|
||||||
|
scores = detection_results[0].boxes.conf.cpu()
|
||||||
|
|
||||||
|
total_detections = len(boxes)
|
||||||
|
print(f" 🔍 Total car detections: {total_detections}")
|
||||||
|
|
||||||
|
for i in range(len(boxes)):
|
||||||
|
x1, y1, x2, y2 = boxes[i]
|
||||||
|
conf = float(scores[i])
|
||||||
|
|
||||||
|
# Check if detection is in zone (using bottom center)
|
||||||
|
box_bottom = ((x1 + x2) / 2, y2)
|
||||||
|
if point_in_polygon(box_bottom, tracking_zone):
|
||||||
|
current_zone_cars.append({
|
||||||
|
'bbox': [float(x1), float(y1), float(x2), float(y2)],
|
||||||
|
'conf': conf,
|
||||||
|
'center': ((x1 + x2) / 2, (y1 + y2) / 2),
|
||||||
|
'bottom': box_bottom
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f" 🎯 Cars in zone: {len(current_zone_cars)}")
|
||||||
|
|
||||||
|
# Only run tracking if there are cars in the zone
|
||||||
|
detected_car_ids = set()
|
||||||
|
|
||||||
|
if current_zone_cars:
|
||||||
|
# Run tracking on the full frame (let tracker handle associations)
|
||||||
|
# But we'll filter results to only zone cars afterward
|
||||||
|
results = model.track(
|
||||||
|
frame,
|
||||||
|
persist=True,
|
||||||
|
verbose=False,
|
||||||
|
conf=0.7,
|
||||||
|
classes=[2],
|
||||||
|
tracker="botsort_reid.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
if results[0].boxes is not None and results[0].boxes.id is not None:
|
||||||
|
boxes = results[0].boxes.xyxy.cpu()
|
||||||
|
scores = results[0].boxes.conf.cpu()
|
||||||
|
track_ids = results[0].boxes.id.cpu().int()
|
||||||
|
|
||||||
|
print(f" 📊 Total tracked objects: {len(track_ids)}")
|
||||||
|
|
||||||
|
# Filter tracked objects to only those in zone
|
||||||
|
zone_tracks = []
|
||||||
|
for i, track_id in enumerate(track_ids):
|
||||||
|
x1, y1, x2, y2 = boxes[i]
|
||||||
|
conf = float(scores[i])
|
||||||
|
|
||||||
|
# Check if this tracked object is in our zone
|
||||||
|
box_bottom = ((x1 + x2) / 2, y2)
|
||||||
|
if point_in_polygon(box_bottom, tracking_zone):
|
||||||
|
zone_tracks.append({
|
||||||
|
'id': int(track_id),
|
||||||
|
'bbox': [int(x1), int(y1), int(x2), int(y2)],
|
||||||
|
'conf': conf,
|
||||||
|
'center': ((x1 + x2) / 2, (y1 + y2) / 2),
|
||||||
|
'bottom': box_bottom
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f" ✅ Zone tracks: {len(zone_tracks)}")
|
||||||
|
|
||||||
|
# Process each zone track
|
||||||
|
for track in zone_tracks:
|
||||||
|
tracker_id = track['id'] # Original tracker ID
|
||||||
|
x1, y1, x2, y2 = track['bbox']
|
||||||
|
conf = track['conf']
|
||||||
|
box_center = track['center']
|
||||||
|
|
||||||
|
# Map tracker ID to clean zone ID
|
||||||
|
if tracker_id not in tracker_to_zone_id:
|
||||||
|
tracker_to_zone_id[tracker_id] = next_zone_id
|
||||||
|
print(f" 🆕 New car: Tracker ID {tracker_id} → Zone ID {next_zone_id}")
|
||||||
|
next_zone_id += 1
|
||||||
|
|
||||||
|
zone_id = tracker_to_zone_id[tracker_id] # Clean sequential ID
|
||||||
|
|
||||||
|
# Validate track continuity (use tracker_id for internal logic)
|
||||||
|
is_valid = True
|
||||||
|
|
||||||
|
# Check for suspicious jumps
|
||||||
|
if tracker_id in last_positions:
|
||||||
|
last_center = last_positions[tracker_id]
|
||||||
|
distance = np.sqrt((box_center[0] - last_center[0])**2 +
|
||||||
|
(box_center[1] - last_center[1])**2)
|
||||||
|
|
||||||
|
if distance > 400: # pixels in ~2.0s
|
||||||
|
is_valid = False
|
||||||
|
print(f" ⚠️ Zone ID {zone_id} (Tracker {tracker_id}): suspicious jump {distance:.0f}px")
|
||||||
|
|
||||||
|
# Skip already successful cars (use zone_id for user logic)
|
||||||
|
if zone_id in successful_cars:
|
||||||
|
is_valid = False
|
||||||
|
print(f" ✅ Zone ID {zone_id}: already successful, skipping")
|
||||||
|
|
||||||
|
# Only process valid, high-confidence zone tracks
|
||||||
|
if is_valid and conf > 0.7:
|
||||||
|
detected_car_ids.add(zone_id) # Use zone_id for display
|
||||||
|
car_id_counts[zone_id] += 1
|
||||||
|
last_positions[tracker_id] = box_center # Track by tracker_id internally
|
||||||
|
|
||||||
|
# Draw tracking results with clean zone ID
|
||||||
|
zone_color = (0, 255, 0) # Green for zone cars
|
||||||
|
cv2.rectangle(frame, (x1, y1), (x2, y2), zone_color, 2)
|
||||||
|
cv2.putText(frame, f'ZONE ID:{zone_id}',
|
||||||
|
(x1, y1-30), cv2.FONT_HERSHEY_SIMPLEX, 0.6, zone_color, 2)
|
||||||
|
cv2.putText(frame, f'#{car_id_counts[zone_id]} {conf:.2f}',
|
||||||
|
(x1, y1-10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, zone_color, 2)
|
||||||
|
|
||||||
|
# Draw center point
|
||||||
|
cv2.circle(frame, (int(track['bottom'][0]), int(track['bottom'][1])), 5, zone_color, -1)
|
||||||
|
|
||||||
|
print(f" ✅ Zone ID {zone_id} (Tracker {tracker_id}): ZONE detection #{car_id_counts[zone_id]} (conf: {conf:.2f})")
|
||||||
|
|
||||||
|
# Check for success (5 consecutive detections IN ZONE)
|
||||||
|
if car_id_counts[zone_id] == 5:
|
||||||
|
print(f"🏆 SUCCESS: Zone ID {zone_id} achieved 5 continuous ZONE detections - TRIGGER NEXT MODEL!")
|
||||||
|
successful_cars.add(zone_id)
|
||||||
|
|
||||||
|
# Add success indicator to frame
|
||||||
|
cv2.putText(frame, f"SUCCESS: Zone Car {zone_id}!",
|
||||||
|
(50, height-50), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 255, 0), 3)
|
||||||
|
else:
|
||||||
|
print(" 📋 No cars in zone - no tracking performed")
|
||||||
|
|
||||||
|
# Draw any cars outside the zone in red (for reference)
|
||||||
|
if detection_results[0].boxes is not None:
|
||||||
|
boxes = detection_results[0].boxes.xyxy.cpu()
|
||||||
|
scores = detection_results[0].boxes.conf.cpu()
|
||||||
|
|
||||||
|
for i in range(len(boxes)):
|
||||||
|
x1, y1, x2, y2 = boxes[i]
|
||||||
|
conf = float(scores[i])
|
||||||
|
|
||||||
|
box_bottom = ((x1 + x2) / 2, y2)
|
||||||
|
if not point_in_polygon(box_bottom, tracking_zone):
|
||||||
|
# Draw cars outside zone in red (not tracked)
|
||||||
|
x1, y1, x2, y2 = int(x1), int(y1), int(x2), int(y2)
|
||||||
|
cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 0, 255), 1)
|
||||||
|
cv2.putText(frame, f'OUT {conf:.2f}',
|
||||||
|
(x1, y1-10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1)
|
||||||
|
|
||||||
|
# Display results
|
||||||
|
if detected_car_ids:
|
||||||
|
print(f" 📋 Active Zone IDs: {sorted(detected_car_ids)} (Clean sequential IDs)")
|
||||||
|
|
||||||
|
# Show ID mapping for debugging
|
||||||
|
if tracker_to_zone_id:
|
||||||
|
mapping_str = ", ".join([f"Tracker{k}→Zone{v}" for k, v in tracker_to_zone_id.items()])
|
||||||
|
print(f" 🔄 ID Mapping: {mapping_str}")
|
||||||
|
|
||||||
|
# Add annotations to frame
|
||||||
|
cv2.putText(frame, f"BoT-SORT Zone-First Tracking | Frame: {frame_idx} | {current_time:.2f}s",
|
||||||
|
(10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 255, 255), 2)
|
||||||
|
cv2.putText(frame, f"Zone Cars: {len(current_zone_cars)} | Active Tracks: {len(detected_car_ids)}",
|
||||||
|
(10, 65), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 2)
|
||||||
|
cv2.putText(frame, f"Successful Cars: {len(successful_cars)}",
|
||||||
|
(10, 100), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)
|
||||||
|
cv2.putText(frame, "TRACKING ZONE",
|
||||||
|
(tracking_zone[0][0], tracking_zone[0][1]-10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 255), 2)
|
||||||
|
|
||||||
|
# Write annotated frame to output video (repeat for 2 seconds duration)
|
||||||
|
write_frame_to_video(out, frame, frame_skip)
|
||||||
|
|
||||||
|
# Show video with zone tracking info
|
||||||
|
display_frame = cv2.resize(frame, (960, 540))
|
||||||
|
cv2.imshow('BoT-SORT Zone-First Tracking', display_frame)
|
||||||
|
|
||||||
|
# Quick check for quit
|
||||||
|
key = cv2.waitKey(1) & 0xFF
|
||||||
|
if key == ord('q'):
|
||||||
|
break
|
||||||
|
|
||||||
|
# Small delay to see results
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
cap.release()
|
||||||
|
finalize_video(out)
|
||||||
|
cv2.destroyAllWindows()
|
||||||
|
print(f"\n🎯 BoT-SORT zone-first tracking completed!")
|
||||||
|
print(f"📊 Processed {processed_count} frames with {frame_skip/fps:.2f}s intervals")
|
||||||
|
print(f"🏆 Successfully tracked {len(successful_cars)} unique cars IN ZONE")
|
||||||
|
print(f"💾 Annotated video saved to: {output_path}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
190
test_detection_tracking.py
Normal file
190
test_detection_tracking.py
Normal file
|
@ -0,0 +1,190 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test script for the refactored detection and tracking functionality.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add the project root to Python path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
from siwatsystem.pympta import run_detection_with_tracking, load_pipeline_from_zip
|
||||||
|
|
||||||
|
# Set up logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def create_test_frame():
|
||||||
|
"""Create a simple test frame for detection testing."""
|
||||||
|
frame = np.zeros((480, 640, 3), dtype=np.uint8)
|
||||||
|
# Add some simple shapes to simulate objects
|
||||||
|
cv2.rectangle(frame, (50, 50), (200, 150), (255, 0, 0), -1) # Blue rectangle
|
||||||
|
cv2.rectangle(frame, (300, 200), (450, 350), (0, 255, 0), -1) # Green rectangle
|
||||||
|
cv2.putText(frame, "Test Frame", (250, 400), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2)
|
||||||
|
return frame
|
||||||
|
|
||||||
|
def test_detection_function():
|
||||||
|
"""Test the structured detection function with mock data."""
|
||||||
|
logger.info("Testing run_detection_with_tracking function...")
|
||||||
|
|
||||||
|
# Create test frame
|
||||||
|
test_frame = create_test_frame()
|
||||||
|
|
||||||
|
# Mock node configuration (simulating what would come from an MPTA file)
|
||||||
|
mock_node = {
|
||||||
|
"modelId": "test_detection_v1",
|
||||||
|
"triggerClasses": ["car", "person"],
|
||||||
|
"triggerClassIndices": [0, 1],
|
||||||
|
"minConfidence": 0.5,
|
||||||
|
"multiClass": False,
|
||||||
|
"expectedClasses": [],
|
||||||
|
"tracking": {
|
||||||
|
"enabled": True,
|
||||||
|
"reidConfigPath": "botsort.yaml"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mock context
|
||||||
|
test_context = {
|
||||||
|
"display_id": "test-display-001",
|
||||||
|
"camera_id": "test-cam-001"
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Mock node configuration:")
|
||||||
|
for key, value in mock_node.items():
|
||||||
|
logger.info(f" {key}: {value}")
|
||||||
|
|
||||||
|
# Note: This test will fail without a real YOLO model, but demonstrates the structure
|
||||||
|
try:
|
||||||
|
detections, regions = run_detection_with_tracking(test_frame, mock_node, test_context)
|
||||||
|
logger.info(f"Function executed successfully!")
|
||||||
|
logger.info(f"Returned detections: {len(detections)}")
|
||||||
|
logger.info(f"Returned regions: {list(regions.keys())}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Function failed (expected without real model): {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def test_mpta_loading():
|
||||||
|
"""Test loading an MPTA file with tracking configuration."""
|
||||||
|
logger.info("Testing MPTA loading with tracking configuration...")
|
||||||
|
|
||||||
|
# Check if models directory exists
|
||||||
|
models_dir = Path("models")
|
||||||
|
if not models_dir.exists():
|
||||||
|
logger.warning("No models directory found - skipping MPTA test")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Look for any .mpta files
|
||||||
|
mpta_files = list(models_dir.glob("**/*.mpta"))
|
||||||
|
if not mpta_files:
|
||||||
|
logger.warning("No .mpta files found in models directory - skipping MPTA test")
|
||||||
|
return False
|
||||||
|
|
||||||
|
mpta_file = mpta_files[0]
|
||||||
|
logger.info(f"Testing with MPTA file: {mpta_file}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Attempt to load pipeline
|
||||||
|
target_dir = f"temp_test_{os.getpid()}"
|
||||||
|
pipeline = load_pipeline_from_zip(str(mpta_file), target_dir)
|
||||||
|
|
||||||
|
if pipeline:
|
||||||
|
logger.info("MPTA loaded successfully!")
|
||||||
|
logger.info(f"Pipeline model ID: {pipeline.get('modelId')}")
|
||||||
|
logger.info(f"Tracking config: {pipeline.get('tracking')}")
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
import shutil
|
||||||
|
if os.path.exists(target_dir):
|
||||||
|
shutil.rmtree(target_dir)
|
||||||
|
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error("Failed to load MPTA pipeline")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"MPTA loading failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def print_usage_example():
|
||||||
|
"""Print example usage of the new structured functions."""
|
||||||
|
logger.info("\n" + "="*60)
|
||||||
|
logger.info("USAGE EXAMPLE - Structured Detection & Tracking")
|
||||||
|
logger.info("="*60)
|
||||||
|
|
||||||
|
example_config = '''
|
||||||
|
Example pipeline.json configuration:
|
||||||
|
|
||||||
|
{
|
||||||
|
"pipeline": {
|
||||||
|
"modelId": "car_frontal_detection_v1",
|
||||||
|
"modelFile": "yolo11n.pt",
|
||||||
|
"triggerClasses": ["Car", "Frontal"],
|
||||||
|
"minConfidence": 0.7,
|
||||||
|
"multiClass": true,
|
||||||
|
"expectedClasses": ["Car", "Frontal"],
|
||||||
|
"tracking": {
|
||||||
|
"enabled": true,
|
||||||
|
"reidConfigPath": "botsort_reid.yaml"
|
||||||
|
},
|
||||||
|
"actions": [...],
|
||||||
|
"branches": [...]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
|
||||||
|
logger.info(example_config)
|
||||||
|
|
||||||
|
code_example = '''
|
||||||
|
Usage in code:
|
||||||
|
|
||||||
|
# Load pipeline from MPTA file
|
||||||
|
pipeline = load_pipeline_from_zip("model.mpta", "temp_dir")
|
||||||
|
|
||||||
|
# Run detection with tracking
|
||||||
|
detections, regions = run_detection_with_tracking(frame, pipeline, context)
|
||||||
|
|
||||||
|
# Process results
|
||||||
|
for detection in detections:
|
||||||
|
class_name = detection["class"]
|
||||||
|
confidence = detection["confidence"]
|
||||||
|
track_id = detection["id"] # Available when tracking enabled
|
||||||
|
bbox = detection["bbox"]
|
||||||
|
print(f"Detected: {class_name} (ID: {track_id}, conf: {confidence:.2f})")
|
||||||
|
'''
|
||||||
|
|
||||||
|
logger.info(code_example)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main test function."""
|
||||||
|
logger.info("Starting detection & tracking refactoring tests...")
|
||||||
|
|
||||||
|
# Test 1: Function structure
|
||||||
|
test1_passed = test_detection_function()
|
||||||
|
|
||||||
|
# Test 2: MPTA loading
|
||||||
|
test2_passed = test_mpta_loading()
|
||||||
|
|
||||||
|
# Print usage examples
|
||||||
|
print_usage_example()
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
logger.info("\n" + "="*60)
|
||||||
|
logger.info("TEST SUMMARY")
|
||||||
|
logger.info("="*60)
|
||||||
|
logger.info(f"Function structure test: {'PASS' if test1_passed else 'EXPECTED FAIL (no model)'}")
|
||||||
|
logger.info(f"MPTA loading test: {'PASS' if test2_passed else 'SKIP (no files)'}")
|
||||||
|
logger.info("\nRefactoring completed successfully!")
|
||||||
|
logger.info("The detection and tracking code is now structured and easy to configure.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
325
webcam_rtsp_server.py
Normal file
325
webcam_rtsp_server.py
Normal file
|
@ -0,0 +1,325 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Enhanced webcam server that provides both RTSP streaming and HTTP snapshot endpoints
|
||||||
|
Compatible with CMS UI requirements for camera configuration
|
||||||
|
"""
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import socket
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("webcam_rtsp_server")
|
||||||
|
|
||||||
|
# Global webcam capture object
|
||||||
|
webcam_cap = None
|
||||||
|
rtsp_process = None
|
||||||
|
|
||||||
|
class WebcamHTTPHandler(BaseHTTPRequestHandler):
|
||||||
|
"""HTTP handler for snapshot requests"""
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path == '/snapshot' or self.path == '/snapshot.jpg':
|
||||||
|
try:
|
||||||
|
# Capture fresh frame from webcam for each request
|
||||||
|
ret, frame = webcam_cap.read()
|
||||||
|
if ret and frame is not None:
|
||||||
|
# Encode as JPEG
|
||||||
|
success, buffer = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
||||||
|
if success:
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header('Content-Type', 'image/jpeg')
|
||||||
|
self.send_header('Content-Length', str(len(buffer)))
|
||||||
|
self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
|
||||||
|
self.send_header('Pragma', 'no-cache')
|
||||||
|
self.send_header('Expires', '0')
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(buffer.tobytes())
|
||||||
|
logger.debug(f"Served webcam snapshot, size: {len(buffer)} bytes")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
logger.error("Failed to encode frame as JPEG")
|
||||||
|
else:
|
||||||
|
logger.error("Failed to capture frame from webcam")
|
||||||
|
|
||||||
|
# Send error response
|
||||||
|
self.send_response(500)
|
||||||
|
self.send_header('Content-Type', 'text/plain')
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'Failed to capture webcam frame')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error serving snapshot: {e}")
|
||||||
|
self.send_response(500)
|
||||||
|
self.send_header('Content-Type', 'text/plain')
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(f'Error: {str(e)}'.encode())
|
||||||
|
|
||||||
|
elif self.path == '/status':
|
||||||
|
# Status endpoint for health checking
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header('Content-Type', 'application/json')
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
width = int(webcam_cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||||
|
height = int(webcam_cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||||
|
fps = webcam_cap.get(cv2.CAP_PROP_FPS)
|
||||||
|
|
||||||
|
status = f'{{"status": "online", "width": {width}, "height": {height}, "fps": {fps}}}'
|
||||||
|
self.wfile.write(status.encode())
|
||||||
|
|
||||||
|
else:
|
||||||
|
# 404 for other paths
|
||||||
|
self.send_response(404)
|
||||||
|
self.send_header('Content-Type', 'text/plain')
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'Not Found - Available endpoints: /snapshot, /snapshot.jpg, /status')
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
# Suppress default HTTP server logging to avoid spam
|
||||||
|
pass
|
||||||
|
|
||||||
|
def check_ffmpeg():
|
||||||
|
"""Check if FFmpeg is available for RTSP streaming"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(['ffmpeg', '-version'],
|
||||||
|
capture_output=True, text=True, timeout=5)
|
||||||
|
if result.returncode == 0:
|
||||||
|
logger.info("FFmpeg found and working")
|
||||||
|
return True
|
||||||
|
except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.warning("FFmpeg not found. RTSP streaming will not be available.")
|
||||||
|
logger.info("To enable RTSP streaming, install FFmpeg:")
|
||||||
|
logger.info(" Windows: Download from https://ffmpeg.org/download.html")
|
||||||
|
logger.info(" Linux: sudo apt install ffmpeg")
|
||||||
|
logger.info(" macOS: brew install ffmpeg")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_windows_camera_name():
|
||||||
|
"""Get the actual camera device name on Windows"""
|
||||||
|
try:
|
||||||
|
# List video devices using FFmpeg with proper encoding handling
|
||||||
|
result = subprocess.run(['ffmpeg', '-f', 'dshow', '-list_devices', 'true', '-i', 'dummy'],
|
||||||
|
capture_output=True, text=True, timeout=10, encoding='utf-8', errors='ignore')
|
||||||
|
output = result.stderr # FFmpeg outputs device list to stderr
|
||||||
|
|
||||||
|
# Look for video devices in the output
|
||||||
|
lines = output.split('\n')
|
||||||
|
video_devices = []
|
||||||
|
|
||||||
|
# Parse the output - look for lines with (video) that contain device names in quotes
|
||||||
|
for line in lines:
|
||||||
|
if '[dshow @' in line and '(video)' in line and '"' in line:
|
||||||
|
# Extract device name between first pair of quotes
|
||||||
|
start = line.find('"') + 1
|
||||||
|
end = line.find('"', start)
|
||||||
|
if start > 0 and end > start:
|
||||||
|
device_name = line[start:end]
|
||||||
|
video_devices.append(device_name)
|
||||||
|
|
||||||
|
logger.info(f"Found Windows video devices: {video_devices}")
|
||||||
|
if video_devices:
|
||||||
|
# Force use the first device (index 0) which is the Logitech HD webcam
|
||||||
|
return video_devices[0] # This will be "罗技高清网络摄像机 C930c"
|
||||||
|
else:
|
||||||
|
logger.info("No devices found via FFmpeg detection, using fallback")
|
||||||
|
# Fall through to fallback names
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Failed to get Windows camera name: {e}")
|
||||||
|
|
||||||
|
# Try common camera device names as fallback
|
||||||
|
# Prioritize Integrated Camera since that's what's working now
|
||||||
|
common_names = [
|
||||||
|
"Integrated Camera", # This is working for the current setup
|
||||||
|
"USB Video Device", # Common name for USB cameras
|
||||||
|
"USB2.0 Camera",
|
||||||
|
"C930c", # Direct model name
|
||||||
|
"HD Pro Webcam C930c", # Full Logitech name
|
||||||
|
"Logitech", # Brand name
|
||||||
|
"USB Camera",
|
||||||
|
"Webcam"
|
||||||
|
]
|
||||||
|
logger.info(f"Using fallback camera names: {common_names}")
|
||||||
|
return common_names[0] # Return "Integrated Camera" first
|
||||||
|
|
||||||
|
def start_rtsp_stream(webcam_index=0, rtsp_port=8554):
|
||||||
|
"""Start RTSP streaming using FFmpeg"""
|
||||||
|
global rtsp_process
|
||||||
|
|
||||||
|
if not check_ffmpeg():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get the actual camera device name for Windows
|
||||||
|
if sys.platform.startswith('win'):
|
||||||
|
camera_name = get_windows_camera_name()
|
||||||
|
logger.info(f"Using Windows camera device: {camera_name}")
|
||||||
|
|
||||||
|
# FFmpeg command to stream webcam via RTSP
|
||||||
|
if sys.platform.startswith('win'):
|
||||||
|
cmd = [
|
||||||
|
'ffmpeg',
|
||||||
|
'-f', 'dshow',
|
||||||
|
'-i', f'video={camera_name}', # Use detected camera name
|
||||||
|
'-c:v', 'libx264',
|
||||||
|
'-preset', 'veryfast',
|
||||||
|
'-tune', 'zerolatency',
|
||||||
|
'-r', '30',
|
||||||
|
'-s', '1280x720',
|
||||||
|
'-f', 'rtsp',
|
||||||
|
f'rtsp://localhost:{rtsp_port}/stream'
|
||||||
|
]
|
||||||
|
elif sys.platform.startswith('linux'):
|
||||||
|
cmd = [
|
||||||
|
'ffmpeg',
|
||||||
|
'-f', 'v4l2',
|
||||||
|
'-i', f'/dev/video{webcam_index}',
|
||||||
|
'-c:v', 'libx264',
|
||||||
|
'-preset', 'veryfast',
|
||||||
|
'-tune', 'zerolatency',
|
||||||
|
'-r', '30',
|
||||||
|
'-s', '1280x720',
|
||||||
|
'-f', 'rtsp',
|
||||||
|
f'rtsp://localhost:{rtsp_port}/stream'
|
||||||
|
]
|
||||||
|
else: # macOS
|
||||||
|
cmd = [
|
||||||
|
'ffmpeg',
|
||||||
|
'-f', 'avfoundation',
|
||||||
|
'-i', f'{webcam_index}:',
|
||||||
|
'-c:v', 'libx264',
|
||||||
|
'-preset', 'veryfast',
|
||||||
|
'-tune', 'zerolatency',
|
||||||
|
'-r', '30',
|
||||||
|
'-s', '1280x720',
|
||||||
|
'-f', 'rtsp',
|
||||||
|
f'rtsp://localhost:{rtsp_port}/stream'
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info(f"Starting RTSP stream on rtsp://localhost:{rtsp_port}/stream")
|
||||||
|
logger.info(f"FFmpeg command: {' '.join(cmd)}")
|
||||||
|
|
||||||
|
rtsp_process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Give FFmpeg a moment to start
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# Check if process is still running
|
||||||
|
if rtsp_process.poll() is None:
|
||||||
|
logger.info("RTSP streaming started successfully")
|
||||||
|
return rtsp_process
|
||||||
|
else:
|
||||||
|
# Get error output if process failed
|
||||||
|
stdout, stderr = rtsp_process.communicate(timeout=2)
|
||||||
|
logger.error("RTSP streaming failed to start")
|
||||||
|
logger.error(f"FFmpeg stdout: {stdout}")
|
||||||
|
logger.error(f"FFmpeg stderr: {stderr}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start RTSP stream: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_local_ip():
|
||||||
|
"""Get the Wireguard IP address for external access"""
|
||||||
|
# Use Wireguard IP for external access
|
||||||
|
return "10.101.1.4"
|
||||||
|
|
||||||
|
def main():
|
||||||
|
global webcam_cap, rtsp_process
|
||||||
|
|
||||||
|
# Configuration - Force use index 0 for Logitech HD webcam
|
||||||
|
webcam_index = 0 # Logitech HD webcam C930c (1920x1080@30fps)
|
||||||
|
http_port = 8080
|
||||||
|
rtsp_port = 8554
|
||||||
|
|
||||||
|
logger.info("=== Webcam RTSP & HTTP Server ===")
|
||||||
|
|
||||||
|
# Initialize webcam
|
||||||
|
logger.info("Initializing webcam...")
|
||||||
|
webcam_cap = cv2.VideoCapture(webcam_index)
|
||||||
|
|
||||||
|
if not webcam_cap.isOpened():
|
||||||
|
logger.error(f"Failed to open webcam at index {webcam_index}")
|
||||||
|
logger.info("Try different webcam indices (0, 1, 2, etc.)")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Set webcam properties - Use high resolution for Logitech HD webcam
|
||||||
|
webcam_cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1920)
|
||||||
|
webcam_cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 1080)
|
||||||
|
webcam_cap.set(cv2.CAP_PROP_FPS, 30)
|
||||||
|
|
||||||
|
width = int(webcam_cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||||
|
height = int(webcam_cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||||
|
fps = webcam_cap.get(cv2.CAP_PROP_FPS)
|
||||||
|
|
||||||
|
logger.info(f"Webcam initialized: {width}x{height} @ {fps}fps")
|
||||||
|
|
||||||
|
# Get local IP for CMS configuration
|
||||||
|
local_ip = get_local_ip()
|
||||||
|
|
||||||
|
# Start RTSP streaming (optional, requires FFmpeg)
|
||||||
|
rtsp_process = start_rtsp_stream(webcam_index, rtsp_port)
|
||||||
|
|
||||||
|
# Start HTTP server for snapshots
|
||||||
|
server_address = ('0.0.0.0', http_port) # Bind to all interfaces
|
||||||
|
http_server = HTTPServer(server_address, WebcamHTTPHandler)
|
||||||
|
|
||||||
|
logger.info("\n=== Server URLs for CMS Configuration ===")
|
||||||
|
logger.info(f"HTTP Snapshot URL: http://{local_ip}:{http_port}/snapshot")
|
||||||
|
|
||||||
|
if rtsp_process:
|
||||||
|
logger.info(f"RTSP Stream URL: rtsp://{local_ip}:{rtsp_port}/stream")
|
||||||
|
else:
|
||||||
|
logger.info("RTSP Stream: Not available (FFmpeg not found)")
|
||||||
|
logger.info("HTTP-only mode: Use Snapshot URL for camera input")
|
||||||
|
|
||||||
|
logger.info(f"Status URL: http://{local_ip}:{http_port}/status")
|
||||||
|
logger.info("\n=== CMS Configuration Suggestions ===")
|
||||||
|
logger.info(f"Camera Identifier: webcam-local-01")
|
||||||
|
logger.info(f"RTSP Stream URL: rtsp://{local_ip}:{rtsp_port}/stream")
|
||||||
|
logger.info(f"Snapshot URL: http://{local_ip}:{http_port}/snapshot")
|
||||||
|
logger.info(f"Snapshot Interval: 2000 (ms)")
|
||||||
|
logger.info("\nPress Ctrl+C to stop all servers")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Start HTTP server
|
||||||
|
http_server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Shutting down servers...")
|
||||||
|
finally:
|
||||||
|
# Clean up
|
||||||
|
if webcam_cap:
|
||||||
|
webcam_cap.release()
|
||||||
|
|
||||||
|
if rtsp_process:
|
||||||
|
logger.info("Stopping RTSP stream...")
|
||||||
|
rtsp_process.terminate()
|
||||||
|
try:
|
||||||
|
rtsp_process.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
rtsp_process.kill()
|
||||||
|
|
||||||
|
http_server.server_close()
|
||||||
|
logger.info("All servers stopped")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
Loading…
Add table
Add a link
Reference in a new issue