Refactor: PHASE 2: Core Module Extraction

This commit is contained in:
ziesorx 2025-09-12 14:45:11 +07:00
parent 96bedae80a
commit 4e9ae6bcc4
7 changed files with 3684 additions and 0 deletions

View file

@ -0,0 +1,694 @@
"""
Pipeline execution engine for computer vision detection workflows.
This module provides the main pipeline execution functionality including:
- Multi-class detection coordination
- Branch processing (parallel and sequential)
- Action execution and database operations
- Session state management
"""
import logging
import concurrent.futures
from typing import Dict, List, Any, Optional, Tuple, Union
from dataclasses import dataclass, field
from datetime import datetime
import numpy as np
from ..core.constants import (
DEFAULT_THREAD_POOL_SIZE,
CLASSIFICATION_TIMEOUT_SECONDS,
PIPELINE_EXECUTION_TIMEOUT
)
from ..core.exceptions import PipelineError, create_pipeline_error
from ..detection.detection_result import DetectionResult, DetectionSession
from ..detection.yolo_detector import run_detection_with_tracking
from ..detection.stability_validator import validate_pipeline_execution
from ..detection.tracking_manager import is_camera_active, occupancy_detector
logger = logging.getLogger(__name__)
@dataclass
class PipelineContext:
"""Context information passed through pipeline execution."""
camera_id: str = "unknown"
backend_session_id: Optional[str] = None
display_id: Optional[str] = None
current_mode: str = "unknown"
regions_dict: Optional[Dict[str, Any]] = None
session_id: Optional[str] = None
timestamp: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary format."""
return {
"camera_id": self.camera_id,
"backend_session_id": self.backend_session_id,
"display_id": self.display_id,
"current_mode": self.current_mode,
"regions_dict": self.regions_dict,
"session_id": self.session_id,
"timestamp": self.timestamp
}
@dataclass
class BranchResult:
"""Result from branch execution."""
model_id: str
success: bool
result: Optional[Dict[str, Any]] = None
error: Optional[str] = None
nested_results: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary format."""
data = {
"model_id": self.model_id,
"success": self.success
}
if self.result:
data["result"] = self.result
if self.error:
data["error"] = self.error
if self.nested_results:
data["nested_results"] = self.nested_results
return data
@dataclass
class PipelineResult:
"""Result from pipeline execution."""
success: bool
primary_detection: Optional[Dict[str, Any]] = None
primary_bbox: Optional[List[int]] = None
branch_results: Dict[str, Any] = field(default_factory=dict)
session_id: Optional[str] = None
awaiting_session_id: bool = False
awaiting_stable_tracks: bool = False
def to_tuple(self, return_bbox: bool = False) -> Union[Dict[str, Any], Tuple[Dict[str, Any], List[int]], Tuple[None, None]]:
"""Convert to return format expected by original function."""
if not self.success or not self.primary_detection:
return (None, None) if return_bbox else None
if return_bbox:
return (self.primary_detection, self.primary_bbox or [0, 0, 0, 0])
else:
return self.primary_detection
class PipelineExecutor:
"""
Main pipeline execution engine for computer vision detection workflows.
This class handles the complete pipeline including detection, tracking,
branch processing, action execution, and database operations.
"""
def __init__(self, thread_pool_size: int = DEFAULT_THREAD_POOL_SIZE):
"""
Initialize pipeline executor.
Args:
thread_pool_size: Maximum number of threads for parallel processing
"""
self.thread_pool_size = thread_pool_size
def _extract_context(self, context: Optional[Dict[str, Any]]) -> PipelineContext:
"""Extract pipeline context from input dictionary."""
if not context:
return PipelineContext()
return PipelineContext(
camera_id=context.get("camera_id", "unknown"),
backend_session_id=context.get("backend_session_id"),
display_id=context.get("display_id"),
current_mode=context.get("current_mode", "unknown"),
regions_dict=context.get("regions_dict"),
session_id=context.get("session_id"),
timestamp=context.get("timestamp")
)
def _handle_classification_task(self,
frame: np.ndarray,
node: Dict[str, Any],
pipeline_context: PipelineContext,
return_bbox: bool) -> Union[Dict[str, Any], Tuple[Dict[str, Any], List[int]], Tuple[None, None]]:
"""Handle classification-only pipeline nodes."""
try:
results = node["model"].predict(frame, stream=False)
if not results:
return (None, None) if return_bbox else None
r = results[0]
probs = r.probs
if probs is None:
return (None, None) if return_bbox else None
top1_idx = int(probs.top1)
top1_conf = float(probs.top1conf)
class_name = node["model"].names[top1_idx]
det = {
"class": class_name,
"confidence": top1_conf,
"id": None,
class_name: class_name # Add class name as key for backward compatibility
}
# Add specific field mappings for database operations based on model type
model_id = node.get("modelId", "").lower()
if "brand" in model_id or "brand_cls" in model_id:
det["brand"] = class_name
elif "bodytype" in model_id or "body" in model_id:
det["body_type"] = class_name
elif "color" in model_id:
det["color"] = class_name
# Execute actions for classification nodes
self._execute_node_actions(node, frame, det, pipeline_context.regions_dict)
return (det, None) if return_bbox else det
except Exception as e:
logger.error(f"Error in classification task for {node.get('modelId')}: {e}")
return (None, None) if return_bbox else None
def _check_camera_active(self, camera_id: str, model_id: str, return_bbox: bool) -> Optional[Union[Dict[str, Any], Tuple[Dict[str, Any], List[int]]]]:
"""Check if camera is active for processing."""
if not is_camera_active(camera_id, model_id):
logger.debug(f"⏰ Camera {camera_id}: Waiting for backend sessionId, sending 'none' detection")
none_detection = {
"class": "none",
"confidence": 1.0,
"bbox": [0, 0, 0, 0],
"branch_results": {}
}
return (none_detection, [0, 0, 0, 0]) if return_bbox else none_detection
return None
def _run_detection_stage(self,
frame: np.ndarray,
node: Dict[str, Any],
pipeline_context: PipelineContext,
validated_detection: Optional[Dict[str, Any]] = None) -> Tuple[List[Dict[str, Any]], Dict[str, Any], Dict[str, Any]]:
"""Run the detection stage of the pipeline."""
if validated_detection:
track_id = validated_detection.get('track_id')
logger.info(f"🔄 PIPELINE: Using validated detection from validation phase - track_id={track_id}")
# Convert validated detection back to all_detections format for branch processing
all_detections = [validated_detection]
# Create regions_dict based on validated detection class with proper structure
class_name = validated_detection.get("class", "car")
regions_dict = {
class_name: {
"confidence": validated_detection.get("confidence"),
"bbox": validated_detection.get("bbox", [0, 0, 0, 0]),
"detection": validated_detection
}
}
# Bypass track validation completely - force pipeline execution
track_validation_result = {
"validation_complete": True,
"stable_tracks": ["cached"], # Use dummy stable track to force pipeline execution
"current_tracks": ["cached"],
"bypass_validation": True
}
else:
# Normal detection stage - Using structured detection function
all_detections, regions_dict, track_validation_result = run_detection_with_tracking(
frame, node, pipeline_context.to_dict()
)
return all_detections, regions_dict, track_validation_result
def _validate_tracking_requirements(self,
node: Dict[str, Any],
track_validation_result: Dict[str, Any],
pipeline_context: PipelineContext,
return_bbox: bool) -> Optional[Union[Dict[str, Any], Tuple[Dict[str, Any], List[int]]]]:
"""Validate tracking requirements for pipeline execution."""
tracking_config = node.get("tracking", {})
stability_threshold = tracking_config.get("stabilityThreshold", node.get("stabilityThreshold", 1))
if stability_threshold <= 1 or not tracking_config.get("enabled", True):
return None # No tracking requirements
# Check if this is a branch node - branches should execute regardless of main validation state
is_branch_node = node.get("cropClass") is not None or node.get("parallel") is True
if is_branch_node:
logger.debug(f"🔍 Camera {pipeline_context.camera_id}: Branch node {node.get('modelId')} executing during track validation phase")
return None
# Main pipeline node during track validation - check for stable tracks
stable_tracks = track_validation_result.get("stable_tracks", [])
if not stable_tracks:
logger.debug(f"🔒 Camera {pipeline_context.camera_id}: Main pipeline requires stable tracks - none found, skipping pipeline execution")
none_detection = {
"class": "none",
"confidence": 1.0,
"bbox": [0, 0, 0, 0],
"awaiting_stable_tracks": True
}
return (none_detection, [0, 0, 0, 0]) if return_bbox else none_detection
logger.info(f"🎯 Camera {pipeline_context.camera_id}: STABLE TRACKS DETECTED - proceeding with full pipeline (tracks: {stable_tracks})")
return None
def _handle_database_operations(self,
node: Dict[str, Any],
detection_result: Dict[str, Any],
regions_dict: Dict[str, Any],
pipeline_context: PipelineContext) -> None:
"""Handle database operations if database manager is available."""
if not (node.get("db_manager") and regions_dict):
return
detected_classes = list(regions_dict.keys())
logger.debug(f"Valid detections found: {detected_classes}")
if pipeline_context.backend_session_id:
# Backend sessionId is available, proceed with database operations
display_id = pipeline_context.display_id or "unknown"
timestamp = datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
inserted_session_id = node["db_manager"].insert_initial_detection(
display_id=display_id,
captured_timestamp=timestamp,
session_id=pipeline_context.backend_session_id
)
if inserted_session_id:
detection_result["session_id"] = inserted_session_id
detection_result["timestamp"] = timestamp
logger.info(f"💾 DATABASE RECORD CREATED with backend session_id: {inserted_session_id}")
logger.debug(f"Database record: display_id={display_id}, timestamp={timestamp}")
else:
logger.error(f"Failed to create database record with backend session_id: {pipeline_context.backend_session_id}")
else:
logger.info(f"📡 Camera {pipeline_context.camera_id}: Full pipeline completed, detection data will be sent to backend. Database operations will occur when sessionId is received.")
# Store detection info for later database operations when sessionId arrives
detection_result["awaiting_session_id"] = True
detection_result["timestamp"] = datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
def _execute_node_actions(self,
node: Dict[str, Any],
frame: np.ndarray,
detection_result: Dict[str, Any],
regions_dict: Optional[Dict[str, Any]]) -> None:
"""Execute actions for a node."""
# This is a placeholder for action execution
# In the actual implementation, this would import and call execute_actions
pass
def _execute_parallel_actions(self,
node: Dict[str, Any],
frame: np.ndarray,
detection_result: Dict[str, Any],
regions_dict: Dict[str, Any]) -> None:
"""Execute parallel actions after branch completion."""
# This is a placeholder for parallel action execution
# In the actual implementation, this would import and call execute_parallel_actions
pass
def _crop_region_by_class(self,
frame: np.ndarray,
regions_dict: Dict[str, Any],
class_name: str) -> Optional[np.ndarray]:
"""Crop a specific region from frame based on detected class."""
if class_name not in regions_dict:
logger.warning(f"Class '{class_name}' not found in detected regions")
return None
bbox = regions_dict[class_name]["bbox"]
x1, y1, x2, y2 = bbox
# Validate bbox coordinates
if x2 <= x1 or y2 <= y1:
logger.warning(f"Invalid bbox for class {class_name}: {bbox}")
return None
try:
cropped = frame[y1:y2, x1:x2]
if cropped.size == 0:
logger.warning(f"Empty crop for class {class_name}")
return None
return cropped
except Exception as e:
logger.error(f"Error cropping region for class {class_name}: {e}")
return None
def _prepare_branch_context(self,
base_context: PipelineContext,
regions_dict: Dict[str, Any],
detection_result: Dict[str, Any]) -> Dict[str, Any]:
"""Prepare context for branch execution."""
branch_context = base_context.to_dict()
branch_context["regions_dict"] = regions_dict
# Pass session_id from detection_result to branch context for Redis actions
if "session_id" in detection_result:
branch_context["session_id"] = detection_result["session_id"]
logger.debug(f"Added session_id to branch context: {detection_result['session_id']}")
elif base_context.backend_session_id:
branch_context["session_id"] = base_context.backend_session_id
logger.debug(f"Added backend_session_id to branch context: {base_context.backend_session_id}")
return branch_context
def _execute_single_branch(self,
frame: np.ndarray,
branch: Dict[str, Any],
branch_context: Dict[str, Any],
regions_dict: Dict[str, Any]) -> BranchResult:
"""Execute a single branch."""
model_id = branch["modelId"]
try:
sub_frame = frame
crop_class = branch.get("cropClass")
logger.info(f"Starting branch: {model_id}, cropClass: {crop_class}")
# Handle cropping if required
if branch.get("crop", False) and crop_class:
if crop_class in regions_dict:
cropped = self._crop_region_by_class(frame, regions_dict, crop_class)
if cropped is not None:
sub_frame = cropped # Use cropped image without manual resizing
logger.debug(f"Successfully cropped {crop_class} region for {model_id} - model will handle resizing")
else:
return BranchResult(
model_id=model_id,
success=False,
error=f"Failed to crop {crop_class} region"
)
else:
return BranchResult(
model_id=model_id,
success=False,
error=f"Crop class {crop_class} not found in detected regions"
)
# Execute branch pipeline
result, _ = self.run_pipeline(sub_frame, branch, True, branch_context)
if result:
branch_result = BranchResult(
model_id=model_id,
success=True,
result=result
)
# Collect nested branch results if they exist
if "branch_results" in result:
branch_result.nested_results = result["branch_results"]
logger.info(f"Branch {model_id} completed: {result}")
return branch_result
else:
return BranchResult(
model_id=model_id,
success=False,
error="Branch returned no result"
)
except Exception as e:
logger.error(f"Error in branch {model_id}: {e}")
return BranchResult(
model_id=model_id,
success=False,
error=str(e)
)
def _process_branches_parallel(self,
frame: np.ndarray,
active_branches: List[Dict[str, Any]],
branch_context: Dict[str, Any],
regions_dict: Dict[str, Any]) -> Dict[str, Any]:
"""Process branches in parallel."""
branch_results = {}
with concurrent.futures.ThreadPoolExecutor(max_workers=len(active_branches)) as executor:
futures = {}
for branch in active_branches:
future = executor.submit(
self._execute_single_branch,
frame, branch, branch_context, regions_dict
)
futures[future] = branch
# Collect results
for future in concurrent.futures.as_completed(futures):
branch = futures[future]
try:
result = future.result()
if result.success and result.result:
branch_results[result.model_id] = result.result
# Collect nested branch results
for nested_id, nested_result in result.nested_results.items():
branch_results[nested_id] = nested_result
logger.info(f"Collected nested branch result: {nested_id} = {nested_result}")
else:
logger.error(f"Branch {result.model_id} failed: {result.error}")
except Exception as e:
logger.error(f"Branch {branch['modelId']} failed: {e}")
return branch_results
def _process_branches_sequential(self,
frame: np.ndarray,
active_branches: List[Dict[str, Any]],
branch_context: Dict[str, Any],
regions_dict: Dict[str, Any]) -> Dict[str, Any]:
"""Process branches sequentially."""
branch_results = {}
for branch in active_branches:
result = self._execute_single_branch(frame, branch, branch_context, regions_dict)
if result.success and result.result:
branch_results[result.model_id] = result.result
# Collect nested branch results
for nested_id, nested_result in result.nested_results.items():
branch_results[nested_id] = nested_result
logger.info(f"Collected nested branch result: {nested_id} = {nested_result}")
else:
logger.error(f"Branch {result.model_id} failed: {result.error}")
return branch_results
def _filter_active_branches(self,
node: Dict[str, Any],
regions_dict: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Filter branches that should be triggered based on detected regions."""
active_branches = []
for br in node["branches"]:
trigger_classes = br.get("triggerClasses", [])
min_conf = br.get("minConfidence", 0)
logger.debug(f"Evaluating branch {br['modelId']}: trigger_classes={trigger_classes}, min_conf={min_conf}")
# Check if any detected class matches branch trigger
branch_triggered = False
for det_class in regions_dict:
det_confidence = regions_dict[det_class]["confidence"]
logger.debug(f" Checking detected class '{det_class}' (confidence={det_confidence:.3f}) against triggers {trigger_classes}")
if (det_class in trigger_classes and det_confidence >= min_conf):
active_branches.append(br)
branch_triggered = True
logger.info(f"Branch {br['modelId']} activated by class '{det_class}' (conf={det_confidence:.3f} >= {min_conf})")
break
if not branch_triggered:
logger.debug(f"Branch {br['modelId']} not triggered - no matching classes or insufficient confidence")
return active_branches
def _process_branches(self,
frame: np.ndarray,
node: Dict[str, Any],
detection_result: Dict[str, Any],
regions_dict: Dict[str, Any],
pipeline_context: PipelineContext) -> Dict[str, Any]:
"""Process all branches for a node."""
if not node.get("branches"):
return {}
# Filter branches that should be triggered
active_branches = self._filter_active_branches(node, regions_dict)
if not active_branches:
return {}
# Prepare branch context
branch_context = self._prepare_branch_context(pipeline_context, regions_dict, detection_result)
# Execute branches
if node.get("parallel", False) or any(br.get("parallel", False) for br in active_branches):
# Run branches in parallel
branch_results = self._process_branches_parallel(frame, active_branches, branch_context, regions_dict)
else:
# Run branches sequentially
branch_results = self._process_branches_sequential(frame, active_branches, branch_context, regions_dict)
return branch_results
def run_pipeline(self,
frame: np.ndarray,
node: Dict[str, Any],
return_bbox: bool = False,
context: Optional[Dict[str, Any]] = None,
validated_detection: Optional[Dict[str, Any]] = None) -> Union[Dict[str, Any], Tuple[Dict[str, Any], List[int]], Tuple[None, None]]:
"""
Run enhanced pipeline that supports:
- Multi-class detection (detecting multiple classes simultaneously)
- Parallel branch processing
- Region-based actions and cropping
- Context passing for session/camera information
Args:
frame: Input frame for processing
node: Pipeline node configuration
return_bbox: Whether to return bounding box with result
context: Optional context information
validated_detection: Optional pre-validated detection to use
Returns:
Detection result, optionally with bounding box
"""
try:
# Extract context information
pipeline_context = self._extract_context(context)
model_id = node.get("modelId", "unknown")
if pipeline_context.backend_session_id:
logger.info(f"🔑 PIPELINE USING BACKEND SESSION_ID: {pipeline_context.backend_session_id} for camera {pipeline_context.camera_id}")
task = getattr(node["model"], "task", None)
# ─── Classification stage ───────────────────────────────────
if task == "classify":
return self._handle_classification_task(frame, node, pipeline_context, return_bbox)
# ─── Session management check ───────────────────────────────────────
camera_inactive_result = self._check_camera_active(pipeline_context.camera_id, model_id, return_bbox)
if camera_inactive_result is not None:
return camera_inactive_result
# ─── Detection stage ───
all_detections, regions_dict, track_validation_result = self._run_detection_stage(
frame, node, pipeline_context, validated_detection
)
if not all_detections:
logger.debug("No detections from structured detection function - sending 'none' detection")
none_detection = {
"class": "none",
"confidence": 1.0,
"bbox": [0, 0, 0, 0],
"branch_results": {}
}
return (none_detection, [0, 0, 0, 0]) if return_bbox else none_detection
# ─── Track-Based Validation System ────────────────────────
tracking_validation_result = self._validate_tracking_requirements(
node, track_validation_result, pipeline_context, return_bbox
)
if tracking_validation_result is not None:
return tracking_validation_result
# ─── Pre-validate pipeline execution ────────────────────────
pipeline_valid, missing_branches = validate_pipeline_execution(node, regions_dict)
if not pipeline_valid:
logger.error(f"Pipeline execution validation FAILED - required branches {missing_branches} cannot execute")
logger.error("Aborting pipeline: no Redis actions or database records will be created")
return (None, None) if return_bbox else None
# ─── Execute actions with region information ────────────────
detection_result = {
"detections": all_detections,
"regions": regions_dict,
**pipeline_context.to_dict()
}
# ─── Database operations ────
self._handle_database_operations(node, detection_result, regions_dict, pipeline_context)
# Execute actions for root node only if it doesn't have branches
# Branch nodes with actions will execute them after branch processing
if not node.get("branches") or node.get("modelId") == "yolo11n":
self._execute_node_actions(node, frame, detection_result, regions_dict)
# ─── Branch processing ─────────────────────────────
branch_results = self._process_branches(frame, node, detection_result, regions_dict, pipeline_context)
detection_result["branch_results"] = branch_results
# ─── Execute Parallel Actions ───────────────────────────────
if node.get("parallelActions") and "branch_results" in detection_result:
self._execute_parallel_actions(node, frame, detection_result, regions_dict)
# ─── Auto-enable occupancy mode after successful pipeline completion ─────────────────
occupancy_detector(pipeline_context.camera_id, model_id, enable=True)
logger.info(f"✅ Camera {pipeline_context.camera_id}: Pipeline completed, detection data will be sent to backend")
logger.info(f"🛑 Camera {pipeline_context.camera_id}: Model will stop inference for future frames")
logger.info(f"📡 Backend sessionId will be handled when received via WebSocket")
# ─── Execute actions after successful detection AND branch processing ──────────
# This ensures detection nodes (like frontal_detection_v1) execute their actions
# after completing both detection and branch processing
if node.get("actions") and regions_dict and node.get("modelId") != "yolo11n":
# Execute actions for branch detection nodes, skip root to avoid duplication
logger.debug(f"Executing post-detection actions for branch node {node.get('modelId')}")
self._execute_node_actions(node, frame, detection_result, regions_dict)
# ─── Return detection result ────────────────────────────────
primary_detection = max(all_detections, key=lambda x: x["confidence"])
primary_bbox = primary_detection["bbox"]
# Add branch results and session_id to primary detection for compatibility
if "branch_results" in detection_result:
primary_detection["branch_results"] = detection_result["branch_results"]
if "session_id" in detection_result:
primary_detection["session_id"] = detection_result["session_id"]
return (primary_detection, primary_bbox) if return_bbox else primary_detection
except Exception as e:
pipeline_id = node.get("modelId", "unknown")
raise create_pipeline_error(pipeline_id, "pipeline_execution", e)
# Global pipeline executor instance
pipeline_executor = PipelineExecutor()
# ===== CONVENIENCE FUNCTIONS =====
# These provide the same interface as the original functions in pympta.py
def run_pipeline(frame: np.ndarray,
node: Dict[str, Any],
return_bbox: bool = False,
context: Optional[Dict[str, Any]] = None,
validated_detection: Optional[Dict[str, Any]] = None) -> Union[Dict[str, Any], Tuple[Dict[str, Any], List[int]], Tuple[None, None]]:
"""Run enhanced pipeline using global executor instance."""
return pipeline_executor.run_pipeline(frame, node, return_bbox, context, validated_detection)
def crop_region_by_class(frame: np.ndarray, regions_dict: Dict[str, Any], class_name: str) -> Optional[np.ndarray]:
"""Crop a specific region from frame based on detected class."""
return pipeline_executor._crop_region_by_class(frame, regions_dict, class_name)