update tracker

This commit is contained in:
Pongsatorn 2025-08-20 21:26:54 +07:00
parent a54da904f7
commit 3a4a27ca68
7 changed files with 1405 additions and 84 deletions

2
.gitignore vendored
View file

@ -13,3 +13,5 @@ no_frame_debug.log
feeder/
.venv/
.vscode/
dist/

7
app.py
View file

@ -27,7 +27,7 @@ from websockets.exceptions import ConnectionClosedError
from ultralytics import YOLO
# 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()
@ -325,6 +325,7 @@ async def detect(websocket: WebSocket):
"type": "imageDetection",
"subscriptionIdentifier": stream["subscriptionIdentifier"],
"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": {
"detection": detection_dict,
"modelId": stream["modelId"],
@ -704,6 +705,7 @@ async def detect(websocket: WebSocket):
del camera_streams[camera_url]
latest_frames.pop(subscription_id, None)
cleanup_camera_stability(subscription_id)
logger.info(f"Unsubscribed from camera {subscription_id}")
async def process_streams():
@ -1018,8 +1020,9 @@ async def detect(websocket: WebSocket):
else:
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)
cleanup_camera_stability(camera_id)
logger.info(f"Unsubscribed from camera {camera_id}")
# Note: Keep models in memory for potential reuse
elif msg_type == "requestState":

View 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()

View file

@ -17,6 +17,13 @@ from .database import DatabaseManager
# Create a logger specifically for this module
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:
"""Validate Redis configuration parameters."""
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}")
model = YOLO(model_path)
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")
else:
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]
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 = {
"modelId": node_config["modelId"],
"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),
"actions": node_config.get("actions", []),
"parallelActions": node_config.get("parallelActions", []),
"tracking": tracking_config,
"stabilityThreshold": stability_threshold,
"model": model,
"branches": [],
"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}")
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):
"""
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)
return (det, None) if return_bbox else det
# ─── Detection stage - Multi-class support ──────────────────
tk = node["triggerClassIndices"]
logger.debug(f"Running detection for node {node['modelId']} with trigger classes: {node.get('triggerClasses', [])} (indices: {tk})")
logger.debug(f"Node configuration: minConfidence={node['minConfidence']}, multiClass={node.get('multiClass', False)}")
# ─── Session management check ───────────────────────────────────────
camera_id = context.get("camera_id", "unknown") if context else "unknown"
model_id = node.get("modelId", "unknown")
res = node["model"].track(
frame,
stream=False,
persist=True,
**({"classes": tk} if tk else {})
)[0]
# Collect all detections above confidence threshold
all_detections = []
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:
logger.warning("No detections above confidence threshold - returning null")
if not is_camera_active(camera_id, model_id):
logger.info(f"⏰ Camera {camera_id}: Tracker stopped - in cooldown period, skipping all detection")
return (None, None) if return_bbox else None
# ─── Multi-class validation ─────────────────────────────────
if node.get("multiClass", False) and node.get("expectedClasses"):
expected_classes = node["expectedClasses"]
detected_classes = list(regions_dict.keys())
# ─── Detection stage - Using structured detection function ──────────────────
all_detections, regions_dict = run_detection_with_tracking(frame, node, context)
logger.info(f"Multi-class validation: expected={expected_classes}, detected={detected_classes}")
if not all_detections:
logger.warning("No detections from structured detection function - returning null")
return (None, None) if return_bbox else None
# Check if at least one expected class is detected (flexible mode)
matching_classes = [cls for cls in expected_classes if cls in detected_classes]
missing_classes = [cls for cls in expected_classes if cls not in detected_classes]
# Extract bounding boxes for compatibility
all_boxes = [det["bbox"] for det in all_detections]
logger.debug(f"Matching classes: {matching_classes}, Missing classes: {missing_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")
if not matching_classes:
# No expected classes found at all
logger.warning(f"PIPELINE REJECTED: No expected classes detected. Expected: {expected_classes}, Detected: {detected_classes}")
return (None, None) if return_bbox else None
# Check if we have stable tracks for this specific camera
has_stable_tracks, stable_detections = check_stable_tracks(camera_id, model_id, regions_dict)
if missing_classes:
logger.info(f"Partial multi-class detection: {matching_classes} found, {missing_classes} missing")
if not has_stable_tracks:
logger.info(f"Camera {camera_id}: Track not stable yet (threshold: {stability_threshold}) - validation only, skipping branches")
# Return early with just the detection result, no branch processing
primary_detection = max(all_detections, key=lambda x: x["confidence"]) if all_detections else {"class": "none", "confidence": 0.0, "bbox": [0, 0, 0, 0]}
primary_bbox = primary_detection.get("bbox", [0, 0, 0, 0])
return (primary_detection, primary_bbox) if return_bbox else primary_detection
else:
logger.info(f"Complete multi-class detection success: {detected_classes}")
else:
logger.debug("No multi-class validation - proceeding with all detections")
logger.info(f"Camera {camera_id}: Stable tracks {[det[1] for det in stable_detections]} detected - proceeding with full pipeline")
# ─── Pre-validate pipeline execution ────────────────────────
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)
# ─── Parallel branch processing ─────────────────────────────
# ─── Branch processing (no stability check here) ─────────────────────────────
if node["branches"]:
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
active_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:
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 ────────────────────────────────
primary_detection = max(all_detections, key=lambda x: x["confidence"])
primary_bbox = primary_detection["bbox"]

352
test_botsort_zone_track.py Normal file
View 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
View 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
View 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()