diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml deleted file mode 100644 index 585009f..0000000 --- a/.gitea/workflows/build.yml +++ /dev/null @@ -1,112 +0,0 @@ -name: Build Worker Base and Application Images - -on: - push: - branches: - - main - - dev - workflow_dispatch: - inputs: - force_base_build: - description: 'Force base image build regardless of changes' - required: false - default: 'false' - type: boolean - -jobs: - check-base-changes: - runs-on: ubuntu-latest - outputs: - base-changed: ${{ steps.changes.outputs.base-changed }} - steps: - - name: Checkout code - uses: actions/checkout@v3 - with: - fetch-depth: 2 - - name: Check for base changes - id: changes - run: | - if git diff HEAD^ HEAD --name-only | grep -E "(Dockerfile\.base|requirements\.base\.txt)" > /dev/null; then - echo "base-changed=true" >> $GITHUB_OUTPUT - else - echo "base-changed=false" >> $GITHUB_OUTPUT - fi - - build-base: - needs: check-base-changes - if: needs.check-base-changes.outputs.base-changed == 'true' || (github.event_name == 'workflow_dispatch' && github.event.inputs.force_base_build == 'true') - runs-on: ubuntu-latest - permissions: - packages: write - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - name: Login to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: git.siwatsystem.com - username: ${{ github.actor }} - password: ${{ secrets.RUNNER_TOKEN }} - - - name: Build and push base Docker image - uses: docker/build-push-action@v4 - with: - context: . - file: ./Dockerfile.base - push: true - tags: git.siwatsystem.com/adsist-cms/worker-base:latest - - build-docker: - needs: [check-base-changes, build-base] - if: always() && (needs.build-base.result == 'success' || needs.build-base.result == 'skipped') - runs-on: ubuntu-latest - permissions: - packages: write - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - name: Login to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: git.siwatsystem.com - username: ${{ github.actor }} - password: ${{ secrets.RUNNER_TOKEN }} - - - name: Build and push Docker image - uses: docker/build-push-action@v4 - with: - context: . - file: ./Dockerfile - push: true - tags: git.siwatsystem.com/adsist-cms/worker:${{ github.ref_name == 'main' && 'latest' || 'dev' }} - - deploy-stack: - needs: build-docker - runs-on: adsist - steps: - - name: Checkout code - uses: actions/checkout@v3 - - name: Set up SSH connection - run: | - mkdir -p ~/.ssh - echo "${{ secrets.DEPLOY_KEY_CMS }}" > ~/.ssh/id_rsa - chmod 600 ~/.ssh/id_rsa - ssh-keyscan -H ${{ vars.DEPLOY_HOST_CMS }} >> ~/.ssh/known_hosts - - name: Deploy stack - run: | - echo "Pulling and starting containers on server..." - if [ "${{ github.ref_name }}" = "main" ]; then - echo "Deploying production stack..." - ssh -i ~/.ssh/id_rsa ${{ vars.DEPLOY_USER_CMS }}@${{ vars.DEPLOY_HOST_CMS }} "cd ~/cms-system-k8s && docker compose -f docker-compose.production.yml pull && docker compose -f docker-compose.production.yml up -d" - else - echo "Deploying staging stack..." - ssh -i ~/.ssh/id_rsa ${{ vars.DEPLOY_USER_CMS }}@${{ vars.DEPLOY_HOST_CMS }} "cd ~/cms-system-k8s && docker compose -f docker-compose.staging.yml pull && docker compose -f docker-compose.staging.yml up -d" - fi \ No newline at end of file diff --git a/.gitignore b/.gitignore index cf51c7b..2c881e8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,19 +1,3 @@ -/models -app.log -*.pt -# All pycache directories -__pycache__/ -.mptacache - -mptas -detector_worker.log -.gitignore -no_frame_debug.log - -feeder/ -.venv/ -.vscode/ -dist/ -websocket_comm.log -temp_debug/ \ No newline at end of file +/__pycache__ +models \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 06f7b97..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,277 +0,0 @@ -# Python Detector Worker - CLAUDE.md - -## Project Overview -This is a FastAPI-based computer vision detection worker that processes video streams from RTSP/HTTP sources and runs advanced YOLO-based machine learning pipelines for multi-class object detection and parallel classification. The system features comprehensive database integration, Redis support, and hierarchical pipeline execution designed to work within a larger CMS (Content Management System) architecture. - -### Key Features -- **Multi-Class Detection**: Simultaneous detection of multiple object classes (e.g., Car + Frontal) -- **Parallel Processing**: Concurrent execution of classification branches using ThreadPoolExecutor -- **Database Integration**: Automatic PostgreSQL schema management and record updates -- **Redis Actions**: Image storage with region cropping and pub/sub messaging -- **Pipeline Synchronization**: Branch coordination with `waitForBranches` functionality -- **Dynamic Field Mapping**: Template-based field resolution for database operations - -## Architecture & Technology Stack -- **Framework**: FastAPI with WebSocket support -- **ML/CV**: PyTorch, Ultralytics YOLO, OpenCV -- **Containerization**: Docker (Python 3.13-bookworm base) -- **Data Storage**: Redis integration for action handling + PostgreSQL for persistent storage -- **Database**: Automatic schema management with gas_station_1 database -- **Parallel Processing**: ThreadPoolExecutor for concurrent classification -- **Communication**: WebSocket-based real-time protocol - -## Core Components - -### Main Application (`app.py`) -- **FastAPI WebSocket server** for real-time communication -- **Multi-camera stream management** with shared stream optimization -- **HTTP REST endpoint** for image retrieval (`/camera/{camera_id}/image`) -- **Threading-based frame readers** for RTSP streams and HTTP snapshots -- **Model loading and inference** using MPTA (Machine Learning Pipeline Archive) format -- **Session management** with display identifier mapping -- **Resource monitoring** (CPU, memory, GPU usage via psutil) - -### Pipeline System (`siwatsystem/pympta.py`) -- **MPTA file handling** - ZIP archives containing model configurations -- **Hierarchical pipeline execution** with detection → classification branching -- **Multi-class detection** - Simultaneous detection of multiple classes (Car + Frontal) -- **Parallel processing** - Concurrent classification branches with ThreadPoolExecutor -- **Redis action system** - Image saving with region cropping and message publishing -- **PostgreSQL integration** - Automatic table creation and combined updates -- **Dynamic model loading** with GPU optimization -- **Configurable trigger classes and confidence thresholds** -- **Branch synchronization** - waitForBranches coordination for database updates - -### Database System (`siwatsystem/database.py`) -- **DatabaseManager class** for PostgreSQL operations -- **Automatic table creation** with gas_station_1.car_frontal_info schema -- **Combined update operations** with field mapping from branch results -- **Session management** with UUID generation -- **Error handling** and connection management - -### Testing & Debugging -- **Protocol test script** (`test_protocol.py`) for WebSocket communication validation -- **Pipeline webcam utility** (`pipeline_webcam.py`) for local testing with visual output -- **RTSP streaming debug tool** (`debug/rtsp_webcam.py`) using GStreamer - -## Code Conventions & Patterns - -### Logging -- **Structured logging** using Python's logging module -- **File + console output** to `detector_worker.log` -- **Debug level separation** for detailed troubleshooting -- **Context-aware messages** with camera IDs and model information - -### Error Handling -- **Graceful failure handling** with retry mechanisms (configurable max_retries) -- **Thread-safe operations** using locks for streams and models -- **WebSocket disconnect handling** with proper cleanup -- **Model loading validation** with detailed error reporting - -### Configuration -- **JSON configuration** (`config.json`) for runtime parameters: - - `poll_interval_ms`: Frame processing interval - - `max_streams`: Concurrent stream limit - - `target_fps`: Target frame rate - - `reconnect_interval_sec`: Stream reconnection delay - - `max_retries`: Maximum retry attempts (-1 for unlimited) - -### Threading Model -- **Frame reader threads** for each camera stream (RTSP/HTTP) -- **Shared stream optimization** - multiple subscriptions can reuse the same camera stream -- **Async WebSocket handling** with concurrent task management -- **Thread-safe data structures** with proper locking mechanisms - -## WebSocket Protocol - -### Message Types -- **subscribe**: Start camera stream with model pipeline -- **unsubscribe**: Stop camera stream processing -- **requestState**: Request current worker status -- **setSessionId**: Associate display with session identifier -- **patchSession**: Update session data -- **stateReport**: Periodic heartbeat with system metrics -- **imageDetection**: Detection results with timestamp and model info - -### Subscription Format -```json -{ - "type": "subscribe", - "payload": { - "subscriptionIdentifier": "display-001;cam-001", - "rtspUrl": "rtsp://...", // OR snapshotUrl - "snapshotUrl": "http://...", - "snapshotInterval": 5000, - "modelUrl": "http://...model.mpta", - "modelId": 101, - "modelName": "Vehicle Detection", - "cropX1": 100, "cropY1": 200, - "cropX2": 300, "cropY2": 400 - } -} -``` - -## Model Pipeline (MPTA) Format - -### Enhanced Structure -- **ZIP archive** containing models and configuration -- **pipeline.json** - Main configuration file with Redis + PostgreSQL settings -- **Model files** - YOLO .pt files for detection/classification -- **Multi-model support** - Detection + multiple classification models - -### Advanced Pipeline Flow -1. **Multi-class detection stage** - YOLO detection of Car + Frontal simultaneously -2. **Validation stage** - Check for expected classes (flexible matching) -3. **Database initialization** - Create initial record with session_id -4. **Redis actions** - Save cropped frontal images with expiration -5. **Parallel classification** - Concurrent brand and body type classification -6. **Branch synchronization** - Wait for all classification branches to complete -7. **Database update** - Combined update with all classification results - -### Enhanced Branch Configuration -```json -{ - "modelId": "car_frontal_detection_v1", - "modelFile": "car_frontal_detection_v1.pt", - "multiClass": true, - "expectedClasses": ["Car", "Frontal"], - "triggerClasses": ["Car", "Frontal"], - "minConfidence": 0.8, - "actions": [ - { - "type": "redis_save_image", - "region": "Frontal", - "key": "inference:{display_id}:{timestamp}:{session_id}:{filename}", - "expire_seconds": 600 - } - ], - "branches": [ - { - "modelId": "car_brand_cls_v1", - "modelFile": "car_brand_cls_v1.pt", - "parallel": true, - "crop": true, - "cropClass": "Frontal", - "triggerClasses": ["Frontal"], - "minConfidence": 0.85 - } - ], - "parallelActions": [ - { - "type": "postgresql_update_combined", - "table": "car_frontal_info", - "key_field": "session_id", - "waitForBranches": ["car_brand_cls_v1", "car_bodytype_cls_v1"], - "fields": { - "car_brand": "{car_brand_cls_v1.brand}", - "car_body_type": "{car_bodytype_cls_v1.body_type}" - } - } - ] -} -``` - -## Stream Management - -### Shared Streams -- Multiple subscriptions can share the same camera URL -- Reference counting prevents premature stream termination -- Automatic cleanup when last subscription ends - -### Frame Processing -- **Queue-based buffering** with single frame capacity (latest frame only) -- **Configurable polling interval** based on target FPS -- **Automatic reconnection** with exponential backoff - -## Development & Testing - -### Local Development -```bash -# Install dependencies -pip install -r requirements.txt - -# Run the worker -python app.py - -# Test protocol compliance -python test_protocol.py - -# Test pipeline with webcam -python pipeline_webcam.py --mpta-file path/to/model.mpta --video 0 -``` - -### Docker Deployment -```bash -# Build container -docker build -t detector-worker . - -# Run with volume mounts for models -docker run -p 8000:8000 -v ./models:/app/models detector-worker -``` - -### Testing Commands -- **Protocol testing**: `python test_protocol.py` -- **Pipeline validation**: `python pipeline_webcam.py --mpta-file --video 0` -- **RTSP debugging**: `python debug/rtsp_webcam.py` - -## Dependencies -- **fastapi[standard]**: Web framework with WebSocket support -- **uvicorn**: ASGI server -- **torch, torchvision**: PyTorch for ML inference -- **ultralytics**: YOLO implementation -- **opencv-python**: Computer vision operations -- **websockets**: WebSocket client/server -- **redis**: Redis client for action execution -- **psycopg2-binary**: PostgreSQL database adapter -- **scipy**: Scientific computing for advanced algorithms -- **filterpy**: Kalman filtering and state estimation - -## Security Considerations -- Model files are loaded from trusted sources only -- Redis connections use authentication when configured -- WebSocket connections handle disconnects gracefully -- Resource usage is monitored to prevent DoS - -## Database Integration - -### Schema Management -The system automatically creates and manages PostgreSQL tables: - -```sql -CREATE TABLE IF NOT EXISTS gas_station_1.car_frontal_info ( - display_id VARCHAR(255), - captured_timestamp VARCHAR(255), - session_id VARCHAR(255) PRIMARY KEY, - license_character VARCHAR(255) DEFAULT NULL, - license_type VARCHAR(255) DEFAULT 'No model available', - car_brand VARCHAR(255) DEFAULT NULL, - car_model VARCHAR(255) DEFAULT NULL, - car_body_type VARCHAR(255) DEFAULT NULL, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW() -); -``` - -### Workflow -1. **Detection**: When both "Car" and "Frontal" are detected, create initial database record with UUID session_id -2. **Redis Storage**: Save cropped frontal image to Redis with session_id in key -3. **Parallel Processing**: Run brand and body type classification concurrently -4. **Synchronization**: Wait for all branches to complete using `waitForBranches` -5. **Database Update**: Update record with combined classification results using field mapping - -### Field Mapping -Templates like `{car_brand_cls_v1.brand}` are resolved to actual classification results: -- `car_brand_cls_v1.brand` → "Honda" -- `car_bodytype_cls_v1.body_type` → "Sedan" - -## Performance Optimizations -- GPU acceleration when CUDA is available -- Shared camera streams reduce resource usage -- Frame queue optimization (single latest frame) -- Model caching across subscriptions -- Trigger class filtering for faster inference -- Parallel processing with ThreadPoolExecutor for classification branches -- Multi-class detection reduces inference passes -- Region-based cropping minimizes processing overhead -- Database connection pooling and prepared statements -- Redis image storage with automatic expiration \ No newline at end of file diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 2b3fcc6..0000000 --- a/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -# Use our pre-built base image with ML dependencies -FROM git.siwatsystem.com/adsist-cms/worker-base:latest - -# Copy and install application requirements (frequently changing dependencies) -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -# Copy the application code -COPY . . - -# Run the application -CMD ["python3", "-m", "fastapi", "run", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/Dockerfile.base b/Dockerfile.base deleted file mode 100644 index 60999b1..0000000 --- a/Dockerfile.base +++ /dev/null @@ -1,24 +0,0 @@ -# Base image with all ML dependencies -FROM pytorch/pytorch:2.8.0-cuda12.6-cudnn9-runtime - -# Install system dependencies -RUN apt update && apt install -y \ - libgl1 \ - libglib2.0-0 \ - libgstreamer1.0-0 \ - libgtk-3-0 \ - libavcodec58 \ - libavformat58 \ - libswscale5 \ - libgomp1 \ - && rm -rf /var/lib/apt/lists/* - -# Copy and install base requirements (ML dependencies that rarely change) -COPY requirements.base.txt . -RUN pip install --no-cache-dir -r requirements.base.txt - -# Set working directory -WORKDIR /app - -# This base image will be reused for all worker builds -CMD ["python3", "-m", "fastapi", "run", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/app.log b/app.log new file mode 100644 index 0000000..0866815 --- /dev/null +++ b/app.log @@ -0,0 +1,601 @@ +2025-01-09 00:43:08,967 [INFO] Will watch for changes in these directories: ['/Users/siwatsirichai/Documents/GitHub/python-detector-worker'] +2025-01-09 00:43:08,967 [INFO] Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) +2025-01-09 00:43:08,967 [INFO] Started reloader process [36467] using WatchFiles +2025-01-09 00:43:09,356 [INFO] 1 change detected +2025-01-09 00:43:10,532 [INFO] Started server process [36471] +2025-01-09 00:43:10,534 [INFO] Waiting for application startup. +2025-01-09 00:43:10,534 [INFO] Application startup complete. +2025-01-09 00:43:17,203 [INFO] WebSocket connection accepted +2025-01-09 00:43:17,205 [INFO] ('127.0.0.1', 59148) - "WebSocket /" [accepted] +2025-01-09 00:43:17,207 [INFO] connection open +2025-01-09 00:43:17,207 [INFO] Started processing streams +2025-01-09 00:43:23,325 [INFO] Subscribed to camera camera1 with URL rtsp://192.168.0.66:8554/common_room +2025-01-09 00:44:48,212 [INFO] 1 change detected +2025-01-09 00:44:48,217 [WARNING] WatchFiles detected changes in 'app.py'. Reloading... +2025-01-09 00:44:48,227 [INFO] Shutting down +2025-01-09 00:44:48,239 [ERROR] Error in WebSocket connection: (1012, None) +2025-01-09 00:44:48,255 [INFO] Released camera camera1 +2025-01-09 00:44:48,255 [INFO] WebSocket connection closed +2025-01-09 00:44:48,256 [ERROR] Exception in ASGI application +Traceback (most recent call last): + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/uvicorn/protocols/websockets/websockets_impl.py", line 243, in run_asgi + result = await self.app(self.scope, self.asgi_receive, self.asgi_send) # type: ignore[func-returns-value] + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/uvicorn/middleware/proxy_headers.py", line 60, in __call__ + return await self.app(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/fastapi/applications.py", line 1054, in __call__ + await super().__call__(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/applications.py", line 113, in __call__ + await self.middleware_stack(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/middleware/errors.py", line 152, in __call__ + await self.app(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/middleware/exceptions.py", line 62, in __call__ + await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app + raise exc + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app + await app(scope, receive, sender) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 715, in __call__ + await self.middleware_stack(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 735, in app + await route.handle(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 362, in handle + await self.app(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 95, in app + await wrap_app_handling_exceptions(app, session)(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app + raise exc + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app + await app(scope, receive, sender) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 93, in app + await func(session) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/fastapi/routing.py", line 383, in app + await dependant.call(**solved_result.values) + File "/Users/siwatsirichai/Documents/GitHub/python-detector-worker/app.py", line 102, in detect + streams.clear() + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/websockets.py", line 180, in close + await self.send({"type": "websocket.close", "code": code, "reason": reason or ""}) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/websockets.py", line 85, in send + await self._send(message) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 39, in sender + await send(message) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/uvicorn/protocols/websockets/websockets_impl.py", line 359, in asgi_send + raise RuntimeError(msg % message_type) +RuntimeError: Unexpected ASGI message 'websocket.close', after sending 'websocket.close' or response already completed. +2025-01-09 00:44:48,323 [INFO] connection closed +2025-01-09 00:44:48,333 [INFO] Waiting for application shutdown. +2025-01-09 00:44:48,334 [INFO] Application shutdown complete. +2025-01-09 00:44:48,335 [INFO] Finished server process [36471] +2025-01-09 00:44:48,728 [INFO] 1 change detected +2025-01-09 00:44:51,790 [INFO] Started server process [36622] +2025-01-09 00:44:51,793 [INFO] Waiting for application startup. +2025-01-09 00:44:51,794 [INFO] Application startup complete. +2025-01-09 00:44:52,764 [INFO] WebSocket connection accepted +2025-01-09 00:44:52,764 [INFO] ('127.0.0.1', 59328) - "WebSocket /" [accepted] +2025-01-09 00:44:52,765 [INFO] connection open +2025-01-09 00:44:52,766 [INFO] Started processing streams +2025-01-09 00:44:59,314 [INFO] Subscribed to camera camera1 with URL rtsp://192.168.0.66:8554/common_room +2025-01-09 00:45:23,328 [ERROR] Error in WebSocket connection: (, '') +2025-01-09 00:45:23,354 [INFO] Released camera camera1 +2025-01-09 00:45:23,354 [INFO] WebSocket connection closed +2025-01-09 00:45:23,356 [ERROR] Exception in ASGI application +Traceback (most recent call last): + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/uvicorn/protocols/websockets/websockets_impl.py", line 243, in run_asgi + result = await self.app(self.scope, self.asgi_receive, self.asgi_send) # type: ignore[func-returns-value] + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/uvicorn/middleware/proxy_headers.py", line 60, in __call__ + return await self.app(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/fastapi/applications.py", line 1054, in __call__ + await super().__call__(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/applications.py", line 113, in __call__ + await self.middleware_stack(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/middleware/errors.py", line 152, in __call__ + await self.app(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/middleware/exceptions.py", line 62, in __call__ + await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app + raise exc + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app + await app(scope, receive, sender) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 715, in __call__ + await self.middleware_stack(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 735, in app + await route.handle(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 362, in handle + await self.app(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 95, in app + await wrap_app_handling_exceptions(app, session)(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app + raise exc + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app + await app(scope, receive, sender) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 93, in app + await func(session) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/fastapi/routing.py", line 383, in app + await dependant.call(**solved_result.values) + File "/Users/siwatsirichai/Documents/GitHub/python-detector-worker/app.py", line 104, in detect + await websocket.close() + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/websockets.py", line 180, in close + await self.send({"type": "websocket.close", "code": code, "reason": reason or ""}) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/websockets.py", line 85, in send + await self._send(message) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 39, in sender + await send(message) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/uvicorn/protocols/websockets/websockets_impl.py", line 359, in asgi_send + raise RuntimeError(msg % message_type) +RuntimeError: Unexpected ASGI message 'websocket.close', after sending 'websocket.close' or response already completed. +2025-01-09 00:45:23,433 [INFO] connection closed +2025-01-09 00:45:25,088 [INFO] WebSocket connection accepted +2025-01-09 00:45:25,088 [INFO] ('127.0.0.1', 59396) - "WebSocket /" [accepted] +2025-01-09 00:45:25,091 [INFO] connection open +2025-01-09 00:45:25,092 [INFO] Started processing streams +2025-01-09 00:45:31,313 [INFO] Subscribed to camera camera1 with URL rtsp://192.168.0.66:8554/common_room +2025-01-09 00:45:37,901 [INFO] Shutting down +2025-01-09 00:45:37,906 [ERROR] Error in WebSocket connection: (1012, None) +2025-01-09 00:45:37,919 [INFO] Released camera camera1 +2025-01-09 00:45:37,919 [INFO] WebSocket connection closed +2025-01-09 00:45:37,919 [ERROR] Exception in ASGI application +Traceback (most recent call last): + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/uvicorn/protocols/websockets/websockets_impl.py", line 243, in run_asgi + result = await self.app(self.scope, self.asgi_receive, self.asgi_send) # type: ignore[func-returns-value] + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/uvicorn/middleware/proxy_headers.py", line 60, in __call__ + return await self.app(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/fastapi/applications.py", line 1054, in __call__ + await super().__call__(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/applications.py", line 113, in __call__ + await self.middleware_stack(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/middleware/errors.py", line 152, in __call__ + await self.app(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/middleware/exceptions.py", line 62, in __call__ + await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app + raise exc + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app + await app(scope, receive, sender) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 715, in __call__ + await self.middleware_stack(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 735, in app + await route.handle(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 362, in handle + await self.app(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 95, in app + await wrap_app_handling_exceptions(app, session)(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app + raise exc + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app + await app(scope, receive, sender) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 93, in app + await func(session) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/fastapi/routing.py", line 383, in app + await dependant.call(**solved_result.values) + File "/Users/siwatsirichai/Documents/GitHub/python-detector-worker/app.py", line 104, in detect + await websocket.close() + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/websockets.py", line 180, in close + await self.send({"type": "websocket.close", "code": code, "reason": reason or ""}) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/websockets.py", line 85, in send + await self._send(message) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 39, in sender + await send(message) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/uvicorn/protocols/websockets/websockets_impl.py", line 359, in asgi_send + raise RuntimeError(msg % message_type) +RuntimeError: Unexpected ASGI message 'websocket.close', after sending 'websocket.close' or response already completed. +2025-01-09 00:45:37,921 [INFO] connection closed +2025-01-09 00:45:38,006 [INFO] Waiting for application shutdown. +2025-01-09 00:45:38,007 [INFO] Application shutdown complete. +2025-01-09 00:45:38,008 [INFO] Finished server process [36622] +2025-01-09 00:45:38,031 [INFO] Stopping reloader process [36467] +2025-01-09 00:46:40,345 [INFO] Will watch for changes in these directories: ['/Users/siwatsirichai/Documents/GitHub/python-detector-worker'] +2025-01-09 00:46:40,346 [INFO] Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) +2025-01-09 00:46:40,347 [INFO] Started reloader process [36868] using WatchFiles +2025-01-09 00:46:42,402 [INFO] Started server process [36902] +2025-01-09 00:46:42,404 [INFO] Waiting for application startup. +2025-01-09 00:46:42,405 [INFO] Application startup complete. +2025-01-09 00:46:42,439 [INFO] WebSocket connection accepted +2025-01-09 00:46:42,439 [INFO] ('127.0.0.1', 59523) - "WebSocket /" [accepted] +2025-01-09 00:46:42,440 [INFO] connection open +2025-01-09 00:46:42,440 [INFO] Started processing streams +2025-01-09 00:46:47,311 [INFO] Subscribed to camera camera1 with URL rtsp://192.168.0.66:8554/common_room +2025-01-09 00:46:51,990 [ERROR] Error in WebSocket connection: (, '') +2025-01-09 00:46:52,001 [INFO] Released camera camera1 +2025-01-09 00:46:52,002 [INFO] WebSocket connection closed +2025-01-09 00:46:52,002 [ERROR] Exception in ASGI application +Traceback (most recent call last): + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/uvicorn/protocols/websockets/websockets_impl.py", line 243, in run_asgi + result = await self.app(self.scope, self.asgi_receive, self.asgi_send) # type: ignore[func-returns-value] + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/uvicorn/middleware/proxy_headers.py", line 60, in __call__ + return await self.app(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/fastapi/applications.py", line 1054, in __call__ + await super().__call__(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/applications.py", line 113, in __call__ + await self.middleware_stack(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/middleware/errors.py", line 152, in __call__ + await self.app(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/middleware/exceptions.py", line 62, in __call__ + await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app + raise exc + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app + await app(scope, receive, sender) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 715, in __call__ + await self.middleware_stack(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 735, in app + await route.handle(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 362, in handle + await self.app(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 95, in app + await wrap_app_handling_exceptions(app, session)(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app + raise exc + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app + await app(scope, receive, sender) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 93, in app + await func(session) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/fastapi/routing.py", line 383, in app + await dependant.call(**solved_result.values) + File "/Users/siwatsirichai/Documents/GitHub/python-detector-worker/app.py", line 104, in detect + await websocket.close() + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/websockets.py", line 180, in close + await self.send({"type": "websocket.close", "code": code, "reason": reason or ""}) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/websockets.py", line 85, in send + await self._send(message) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 39, in sender + await send(message) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/uvicorn/protocols/websockets/websockets_impl.py", line 359, in asgi_send + raise RuntimeError(msg % message_type) +RuntimeError: Unexpected ASGI message 'websocket.close', after sending 'websocket.close' or response already completed. +2025-01-09 00:46:52,030 [INFO] connection closed +2025-01-09 00:47:56,615 [INFO] WebSocket connection accepted +2025-01-09 00:47:56,616 [INFO] ('127.0.0.1', 59664) - "WebSocket /" [accepted] +2025-01-09 00:47:56,628 [INFO] connection open +2025-01-09 00:47:56,631 [INFO] Started processing streams +2025-01-09 00:48:03,306 [INFO] Subscribed to camera camera1 with URL rtsp://192.168.0.66:8554/common_room +2025-01-09 00:48:06,345 [ERROR] Error in WebSocket connection: (, '') +2025-01-09 00:48:06,352 [INFO] Released camera camera1 +2025-01-09 00:48:06,352 [INFO] WebSocket connection closed +2025-01-09 00:48:06,353 [ERROR] Exception in ASGI application +Traceback (most recent call last): + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/uvicorn/protocols/websockets/websockets_impl.py", line 243, in run_asgi + result = await self.app(self.scope, self.asgi_receive, self.asgi_send) # type: ignore[func-returns-value] + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/uvicorn/middleware/proxy_headers.py", line 60, in __call__ + return await self.app(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/fastapi/applications.py", line 1054, in __call__ + await super().__call__(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/applications.py", line 113, in __call__ + await self.middleware_stack(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/middleware/errors.py", line 152, in __call__ + await self.app(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/middleware/exceptions.py", line 62, in __call__ + await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app + raise exc + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app + await app(scope, receive, sender) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 715, in __call__ + await self.middleware_stack(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 735, in app + await route.handle(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 362, in handle + await self.app(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 95, in app + await wrap_app_handling_exceptions(app, session)(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app + raise exc + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app + await app(scope, receive, sender) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 93, in app + await func(session) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/fastapi/routing.py", line 383, in app + await dependant.call(**solved_result.values) + File "/Users/siwatsirichai/Documents/GitHub/python-detector-worker/app.py", line 104, in detect + await websocket.close() + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/websockets.py", line 180, in close + await self.send({"type": "websocket.close", "code": code, "reason": reason or ""}) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/websockets.py", line 85, in send + await self._send(message) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 39, in sender + await send(message) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/uvicorn/protocols/websockets/websockets_impl.py", line 359, in asgi_send + raise RuntimeError(msg % message_type) +RuntimeError: Unexpected ASGI message 'websocket.close', after sending 'websocket.close' or response already completed. +2025-01-09 00:48:06,361 [INFO] connection closed +2025-01-09 00:48:38,544 [INFO] WebSocket connection accepted +2025-01-09 00:48:38,545 [INFO] ('127.0.0.1', 59735) - "WebSocket /" [accepted] +2025-01-09 00:48:38,546 [INFO] connection open +2025-01-09 00:48:38,550 [INFO] Started processing streams +2025-01-09 00:48:43,303 [INFO] Subscribed to camera camera1 with URL rtsp://192.168.0.66:8554/common_room +2025-01-09 00:49:28,103 [ERROR] Error in WebSocket connection: (, '') +2025-01-09 00:49:28,115 [INFO] Released camera camera1 +2025-01-09 00:49:28,116 [INFO] WebSocket connection closed +2025-01-09 00:49:28,116 [ERROR] Exception in ASGI application +Traceback (most recent call last): + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/uvicorn/protocols/websockets/websockets_impl.py", line 243, in run_asgi + result = await self.app(self.scope, self.asgi_receive, self.asgi_send) # type: ignore[func-returns-value] + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/uvicorn/middleware/proxy_headers.py", line 60, in __call__ + return await self.app(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/fastapi/applications.py", line 1054, in __call__ + await super().__call__(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/applications.py", line 113, in __call__ + await self.middleware_stack(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/middleware/errors.py", line 152, in __call__ + await self.app(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/middleware/exceptions.py", line 62, in __call__ + await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app + raise exc + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app + await app(scope, receive, sender) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 715, in __call__ + await self.middleware_stack(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 735, in app + await route.handle(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 362, in handle + await self.app(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 95, in app + await wrap_app_handling_exceptions(app, session)(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app + raise exc + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app + await app(scope, receive, sender) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 93, in app + await func(session) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/fastapi/routing.py", line 383, in app + await dependant.call(**solved_result.values) + File "/Users/siwatsirichai/Documents/GitHub/python-detector-worker/app.py", line 104, in detect + await websocket.close() + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/websockets.py", line 180, in close + await self.send({"type": "websocket.close", "code": code, "reason": reason or ""}) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/websockets.py", line 85, in send + await self._send(message) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 39, in sender + await send(message) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/uvicorn/protocols/websockets/websockets_impl.py", line 359, in asgi_send + raise RuntimeError(msg % message_type) +RuntimeError: Unexpected ASGI message 'websocket.close', after sending 'websocket.close' or response already completed. +2025-01-09 00:49:28,125 [INFO] connection closed +2025-01-09 00:50:30,615 [INFO] WebSocket connection accepted +2025-01-09 00:50:30,616 [INFO] ('127.0.0.1', 59919) - "WebSocket /" [accepted] +2025-01-09 00:50:30,618 [INFO] connection open +2025-01-09 00:50:30,619 [INFO] Started processing streams +2025-01-09 00:50:35,299 [INFO] Subscribed to camera camera1 with URL rtsp://192.168.0.66:8554/common_room +2025-01-09 00:51:20,717 [ERROR] Error in WebSocket connection: (, '') +2025-01-09 00:51:20,727 [INFO] Released camera camera1 +2025-01-09 00:51:20,727 [INFO] WebSocket connection closed +2025-01-09 00:51:20,727 [ERROR] Exception in ASGI application +Traceback (most recent call last): + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/uvicorn/protocols/websockets/websockets_impl.py", line 243, in run_asgi + result = await self.app(self.scope, self.asgi_receive, self.asgi_send) # type: ignore[func-returns-value] + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/uvicorn/middleware/proxy_headers.py", line 60, in __call__ + return await self.app(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/fastapi/applications.py", line 1054, in __call__ + await super().__call__(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/applications.py", line 113, in __call__ + await self.middleware_stack(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/middleware/errors.py", line 152, in __call__ + await self.app(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/middleware/exceptions.py", line 62, in __call__ + await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app + raise exc + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app + await app(scope, receive, sender) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 715, in __call__ + await self.middleware_stack(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 735, in app + await route.handle(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 362, in handle + await self.app(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 95, in app + await wrap_app_handling_exceptions(app, session)(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app + raise exc + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app + await app(scope, receive, sender) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 93, in app + await func(session) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/fastapi/routing.py", line 383, in app + await dependant.call(**solved_result.values) + File "/Users/siwatsirichai/Documents/GitHub/python-detector-worker/app.py", line 104, in detect + await websocket.close() + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/websockets.py", line 180, in close + await self.send({"type": "websocket.close", "code": code, "reason": reason or ""}) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/websockets.py", line 85, in send + await self._send(message) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 39, in sender + await send(message) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/uvicorn/protocols/websockets/websockets_impl.py", line 359, in asgi_send + raise RuntimeError(msg % message_type) +RuntimeError: Unexpected ASGI message 'websocket.close', after sending 'websocket.close' or response already completed. +2025-01-09 00:51:20,732 [INFO] connection closed +2025-01-09 00:52:20,552 [INFO] 1 change detected +2025-01-09 00:52:20,571 [WARNING] WatchFiles detected changes in 'app.py'. Reloading... +2025-01-09 00:52:20,681 [INFO] Shutting down +2025-01-09 00:52:20,787 [INFO] Waiting for application shutdown. +2025-01-09 00:52:20,790 [INFO] Application shutdown complete. +2025-01-09 00:52:20,791 [INFO] Finished server process [36902] +2025-01-09 00:52:21,170 [INFO] 1 change detected +2025-01-09 00:52:23,436 [INFO] Started server process [37369] +2025-01-09 00:52:23,438 [INFO] Waiting for application startup. +2025-01-09 00:52:23,438 [INFO] Application startup complete. +2025-01-09 00:52:54,852 [INFO] 1 change detected +2025-01-09 00:52:54,860 [WARNING] WatchFiles detected changes in 'app.py'. Reloading... +2025-01-09 00:52:54,949 [INFO] Shutting down +2025-01-09 00:52:55,052 [INFO] Waiting for application shutdown. +2025-01-09 00:52:55,053 [INFO] Application shutdown complete. +2025-01-09 00:52:55,053 [INFO] Finished server process [37369] +2025-01-09 00:52:55,426 [INFO] 1 change detected +2025-01-09 00:52:57,074 [INFO] Started server process [37436] +2025-01-09 00:52:57,076 [INFO] Waiting for application startup. +2025-01-09 00:52:57,078 [INFO] Application startup complete. +2025-01-09 00:53:06,378 [INFO] 1 change detected +2025-01-09 00:53:08,915 [INFO] Shutting down +2025-01-09 00:53:09,018 [INFO] Waiting for application shutdown. +2025-01-09 00:53:09,020 [INFO] Application shutdown complete. +2025-01-09 00:53:09,021 [INFO] Finished server process [37436] +2025-01-09 00:53:09,044 [INFO] Stopping reloader process [36868] +2025-01-09 00:53:11,752 [INFO] Will watch for changes in these directories: ['/Users/siwatsirichai/Documents/GitHub/python-detector-worker'] +2025-01-09 00:53:11,753 [INFO] Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) +2025-01-09 00:53:11,753 [INFO] Started reloader process [37483] using WatchFiles +2025-01-09 00:53:13,520 [INFO] Started server process [37487] +2025-01-09 00:53:13,522 [INFO] Waiting for application startup. +2025-01-09 00:53:13,523 [INFO] Application startup complete. +2025-01-09 00:53:14,050 [INFO] WebSocket connection accepted +2025-01-09 00:53:14,050 [INFO] ('127.0.0.1', 60224) - "WebSocket /" [accepted] +2025-01-09 00:53:14,052 [INFO] connection open +2025-01-09 00:53:14,052 [INFO] Started processing streams +2025-01-09 00:53:19,283 [INFO] Subscribed to camera camera1 with URL rtsp://192.168.0.66:8554/common_room +2025-01-09 00:53:36,514 [INFO] 1 change detected +2025-01-09 00:53:38,902 [ERROR] Error in WebSocket connection: (, '') +2025-01-09 00:53:38,910 [INFO] Released camera camera1 +2025-01-09 00:53:38,911 [INFO] WebSocket connection closed +2025-01-09 00:53:38,911 [ERROR] Exception in ASGI application +Traceback (most recent call last): + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/uvicorn/protocols/websockets/websockets_impl.py", line 243, in run_asgi + result = await self.app(self.scope, self.asgi_receive, self.asgi_send) # type: ignore[func-returns-value] + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/uvicorn/middleware/proxy_headers.py", line 60, in __call__ + return await self.app(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/fastapi/applications.py", line 1054, in __call__ + await super().__call__(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/applications.py", line 113, in __call__ + await self.middleware_stack(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/middleware/errors.py", line 152, in __call__ + await self.app(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/middleware/exceptions.py", line 62, in __call__ + await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app + raise exc + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app + await app(scope, receive, sender) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 715, in __call__ + await self.middleware_stack(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 735, in app + await route.handle(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 362, in handle + await self.app(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 95, in app + await wrap_app_handling_exceptions(app, session)(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app + raise exc + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app + await app(scope, receive, sender) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 93, in app + await func(session) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/fastapi/routing.py", line 383, in app + await dependant.call(**solved_result.values) + File "/Users/siwatsirichai/Documents/GitHub/python-detector-worker/app.py", line 111, in detect + await websocket.close() + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/websockets.py", line 180, in close + await self.send({"type": "websocket.close", "code": code, "reason": reason or ""}) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/websockets.py", line 85, in send + await self._send(message) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 39, in sender + await send(message) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/uvicorn/protocols/websockets/websockets_impl.py", line 359, in asgi_send + raise RuntimeError(msg % message_type) +RuntimeError: Unexpected ASGI message 'websocket.close', after sending 'websocket.close' or response already completed. +2025-01-09 00:53:38,944 [INFO] connection closed +2025-01-09 00:53:40,757 [INFO] Shutting down +2025-01-09 00:53:40,880 [INFO] Finished server process [37487] +2025-01-09 00:53:40,980 [ERROR] Traceback (most recent call last): + File "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run + return loop.run_until_complete(main) + File "uvloop/loop.pyx", line 1512, in uvloop.loop.Loop.run_until_complete + File "uvloop/loop.pyx", line 1505, in uvloop.loop.Loop.run_until_complete + File "uvloop/loop.pyx", line 1379, in uvloop.loop.Loop.run_forever + File "uvloop/loop.pyx", line 557, in uvloop.loop.Loop._run + File "uvloop/loop.pyx", line 476, in uvloop.loop.Loop._on_idle + File "uvloop/cbhandles.pyx", line 83, in uvloop.loop.Handle._run + File "uvloop/cbhandles.pyx", line 63, in uvloop.loop.Handle._run + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/uvicorn/server.py", line 70, in serve + await self._serve(sockets) + File "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/contextlib.py", line 124, in __exit__ + next(self.gen) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/uvicorn/server.py", line 330, in capture_signals + signal.raise_signal(captured_signal) +KeyboardInterrupt + +During handling of the above exception, another exception occurred: + +Traceback (most recent call last): + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 700, in lifespan + await receive() + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/uvicorn/lifespan/on.py", line 137, in receive + return await self.receive_queue.get() + File "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/queues.py", line 166, in get + await getter +asyncio.exceptions.CancelledError + +2025-01-09 00:53:41,696 [INFO] Stopping reloader process [37483] +2025-01-09 00:53:46,103 [INFO] Will watch for changes in these directories: ['/Users/siwatsirichai/Documents/GitHub/python-detector-worker'] +2025-01-09 00:53:46,103 [INFO] Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) +2025-01-09 00:53:46,104 [INFO] Started reloader process [37591] using WatchFiles +2025-01-09 00:53:47,860 [INFO] Started server process [37599] +2025-01-09 00:53:47,862 [INFO] Waiting for application startup. +2025-01-09 00:53:47,862 [INFO] Application startup complete. +2025-01-09 00:54:51,976 [INFO] Shutting down +2025-01-09 00:54:52,080 [INFO] Waiting for application shutdown. +2025-01-09 00:54:52,083 [INFO] Application shutdown complete. +2025-01-09 00:54:52,083 [INFO] Finished server process [37599] +2025-01-09 00:54:52,102 [INFO] Stopping reloader process [37591] +2025-01-09 00:54:54,952 [INFO] Will watch for changes in these directories: ['/Users/siwatsirichai/Documents/GitHub/python-detector-worker'] +2025-01-09 00:54:54,953 [INFO] Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) +2025-01-09 00:54:54,953 [INFO] Started reloader process [37680] using WatchFiles +2025-01-09 00:54:56,634 [INFO] Started server process [37693] +2025-01-09 00:54:56,636 [INFO] Waiting for application startup. +2025-01-09 00:54:56,636 [INFO] Application startup complete. +2025-01-09 00:54:56,882 [INFO] WebSocket connection accepted +2025-01-09 00:54:56,882 [INFO] ('127.0.0.1', 60381) - "WebSocket /" [accepted] +2025-01-09 00:54:56,884 [INFO] connection open +2025-01-09 00:54:56,885 [INFO] Started processing streams +2025-01-09 00:55:03,279 [INFO] Subscribed to camera camera1 with URL rtsp://192.168.0.66:8554/common_room +2025-01-09 00:55:13,896 [ERROR] Error in WebSocket connection: (, '') +2025-01-09 00:55:13,907 [INFO] Released camera camera1 +2025-01-09 00:55:13,908 [INFO] WebSocket connection closed +2025-01-09 00:55:13,908 [ERROR] Exception in ASGI application +Traceback (most recent call last): + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/uvicorn/protocols/websockets/websockets_impl.py", line 243, in run_asgi + result = await self.app(self.scope, self.asgi_receive, self.asgi_send) # type: ignore[func-returns-value] + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/uvicorn/middleware/proxy_headers.py", line 60, in __call__ + return await self.app(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/fastapi/applications.py", line 1054, in __call__ + await super().__call__(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/applications.py", line 113, in __call__ + await self.middleware_stack(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/middleware/errors.py", line 152, in __call__ + await self.app(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/middleware/exceptions.py", line 62, in __call__ + await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app + raise exc + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app + await app(scope, receive, sender) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 715, in __call__ + await self.middleware_stack(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 735, in app + await route.handle(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 362, in handle + await self.app(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 95, in app + await wrap_app_handling_exceptions(app, session)(scope, receive, send) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app + raise exc + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app + await app(scope, receive, sender) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/routing.py", line 93, in app + await func(session) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/fastapi/routing.py", line 383, in app + await dependant.call(**solved_result.values) + File "/Users/siwatsirichai/Documents/GitHub/python-detector-worker/app.py", line 111, in detect + await websocket.close() + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/websockets.py", line 180, in close + await self.send({"type": "websocket.close", "code": code, "reason": reason or ""}) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/websockets.py", line 85, in send + await self._send(message) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/starlette/_exception_handler.py", line 39, in sender + await send(message) + File "/Users/siwatsirichai/Library/Python/3.9/lib/python/site-packages/uvicorn/protocols/websockets/websockets_impl.py", line 359, in asgi_send + raise RuntimeError(msg % message_type) +RuntimeError: Unexpected ASGI message 'websocket.close', after sending 'websocket.close' or response already completed. +2025-01-09 00:55:13,943 [INFO] connection closed +2025-01-09 00:55:14,603 [INFO] Shutting down +2025-01-09 00:55:14,704 [INFO] Waiting for application shutdown. +2025-01-09 00:55:14,705 [INFO] Application shutdown complete. +2025-01-09 00:55:14,705 [INFO] Finished server process [37693] +2025-01-09 00:55:14,721 [INFO] Stopping reloader process [37680] diff --git a/app.py b/app.py index 3a1c84a..666730f 100644 --- a/app.py +++ b/app.py @@ -1,1915 +1,152 @@ -from typing import Any, Dict -import os -import json -import time -import queue -import torch -import cv2 -import numpy as np -import base64 -import logging -import threading -import requests -import asyncio -import psutil -import zipfile -import ssl -import urllib3 -import subprocess -import tempfile -import redis -from urllib.parse import urlparse -from requests.adapters import HTTPAdapter -from urllib3.util.ssl_ import create_urllib3_context -from fastapi import FastAPI, WebSocket, HTTPException +from fastapi import FastAPI, WebSocket from fastapi.websockets import WebSocketDisconnect -from fastapi.responses import Response from websockets.exceptions import ConnectionClosedError from ultralytics import YOLO - -# Import shared pipeline functions -from siwatsystem.pympta import load_pipeline_from_zip, run_pipeline, cleanup_camera_stability, cleanup_pipeline_node -from siwatsystem.model_registry import get_registry_status, cleanup_registry -from siwatsystem.mpta_manager import get_or_download_mpta, release_mpta, get_mpta_manager_status, cleanup_mpta_manager +import torch +import cv2 +import base64 +import numpy as np +import json +import logging +import threading +import queue +import os +import requests +from urllib.parse import urlparse # Added import +import asyncio # Ensure asyncio is imported +import psutil # Added import app = FastAPI() -# Global dictionaries to keep track of models and streams -# "models" now holds a nested dict: { camera_id: { modelId: model_tree } } -models: Dict[str, Dict[str, Any]] = {} -streams: Dict[str, Dict[str, Any]] = {} -# Store session IDs per display -session_ids: Dict[str, int] = {} -# Track shared camera streams by camera URL -camera_streams: Dict[str, Dict[str, Any]] = {} -# Map subscriptions to their camera URL -subscription_to_camera: Dict[str, str] = {} -# Store latest frames for REST API access (separate from processing buffer) -latest_frames: Dict[str, Any] = {} -# Store cached detection dict after successful pipeline completion -cached_detections: Dict[str, Dict[str, Any]] = {} -# Enhanced caching system for LPR integration -session_detections: Dict[str, Dict[str, Any]] = {} # session_id -> detection data -session_to_camera: Dict[str, str] = {} # session_id -> camera_id -detection_timestamps: Dict[str, float] = {} # session_id -> timestamp (for cleanup) -# Track frame skipping for pipeline buffer after detection -frame_skip_flags: Dict[str, bool] = {} -# Track camera connection states for immediate error handling -camera_states: Dict[str, Dict[str, Any]] = {} -# Track session ID states and pipeline modes per camera -session_pipeline_states: Dict[str, Dict[str, Any]] = {} -# Store full pipeline results for caching -cached_full_pipeline_results: Dict[str, Dict[str, Any]] = {} +model = YOLO("yolov8n.pt") +if torch.cuda.is_available(): + model.to('cuda') + +# Retrieve class names from the model +class_names = model.names with open("config.json", "r") as f: config = json.load(f) poll_interval = config.get("poll_interval_ms", 100) -reconnect_interval = config.get("reconnect_interval_sec", 5) -TARGET_FPS = config.get("target_fps", 10) -poll_interval = 1000 / TARGET_FPS +reconnect_interval = config.get("reconnect_interval_sec", 5) # New setting +TARGET_FPS = config.get("target_fps", 10) # Add TARGET_FPS +poll_interval = 1000 / TARGET_FPS # Adjust poll_interval based on TARGET_FPS logging.info(f"Poll interval: {poll_interval}ms") max_streams = config.get("max_streams", 5) max_retries = config.get("max_retries", 3) # Configure logging logging.basicConfig( - level=logging.INFO, # Set to INFO level for less verbose output - format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", handlers=[ - logging.FileHandler("detector_worker.log"), # Write logs to a file - logging.StreamHandler() # Also output to console + logging.FileHandler("app.log"), + logging.StreamHandler() ] ) -# Create a logger specifically for this application -logger = logging.getLogger("detector_worker") -logger.setLevel(logging.DEBUG) # Set app-specific logger to DEBUG level - -# Create WebSocket communication logger -ws_logger = logging.getLogger("websocket_comm") -ws_logger.setLevel(logging.INFO) -ws_handler = logging.FileHandler("websocket_comm.log", encoding='utf-8') -ws_formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s") -ws_handler.setFormatter(ws_formatter) -ws_logger.addHandler(ws_handler) -ws_logger.propagate = False # Don't propagate to root logger - -# Ensure all other libraries (including root) use at least INFO level -logging.getLogger().setLevel(logging.INFO) - -logger.info("Starting detector worker application") -logger.info(f"Configuration: Target FPS: {TARGET_FPS}, Max streams: {max_streams}, Max retries: {max_retries}") -ws_logger.info("WebSocket communication logging started - TX/RX format") -logger.info("WebSocket communication will be logged to websocket_comm.log") - # Ensure the models directory exists os.makedirs("models", exist_ok=True) -logger.info("Ensured models directory exists") -# Constants for heartbeat and timeouts +# Add constants for heartbeat HEARTBEAT_INTERVAL = 2 # seconds - -# Global Redis connection for LPR integration -redis_client_global = None -lpr_listener_thread = None -cleanup_timer_thread = None -lpr_integration_started = False - -# Redis connection helper functions -def get_redis_config_from_model(camera_id: str) -> Dict[str, Any]: - """Extract Redis configuration from loaded model pipeline""" - try: - for model_id, model_tree in models.get(camera_id, {}).items(): - if hasattr(model_tree, 'get') and 'redis_client' in model_tree: - # Extract config from existing Redis client - client = model_tree['redis_client'] - if client: - return { - 'host': client.connection_pool.connection_kwargs['host'], - 'port': client.connection_pool.connection_kwargs['port'], - 'password': client.connection_pool.connection_kwargs.get('password'), - 'db': client.connection_pool.connection_kwargs.get('db', 0) - } - except Exception as e: - logger.debug(f"Could not extract Redis config from model: {e}") - - # Fallback - try to read from pipeline.json directly - try: - pipeline_dirs = [] - models_dir = "models" - if os.path.exists(models_dir): - for root, dirs, files in os.walk(models_dir): - if "pipeline.json" in files: - with open(os.path.join(root, "pipeline.json"), 'r') as f: - config = json.load(f) - if 'redis' in config: - return config['redis'] - except Exception as e: - logger.debug(f"Could not read Redis config from pipeline.json: {e}") - - return None - -def create_redis_connection() -> redis.Redis: - """Create Redis connection using config from pipeline""" - global redis_client_global - - if redis_client_global is not None: - try: - redis_client_global.ping() - return redis_client_global - except: - redis_client_global = None - - # Find any camera with a loaded model to get Redis config - redis_config = None - for camera_id in models.keys(): - redis_config = get_redis_config_from_model(camera_id) - if redis_config: - break - - if not redis_config: - logger.error("No Redis configuration found in any loaded models") - return None - - try: - redis_client_global = redis.Redis( - host=redis_config['host'], - port=redis_config['port'], - password=redis_config.get('password'), - db=redis_config.get('db', 0), - decode_responses=True, - socket_connect_timeout=5, - socket_timeout=5 - ) - redis_client_global.ping() - logger.info(f"✅ Connected to Redis for LPR at {redis_config['host']}:{redis_config['port']}") - return redis_client_global - except Exception as e: - logger.error(f"❌ Failed to connect to Redis for LPR: {e}") - redis_client_global = None - return None - -# LPR Integration Functions -def process_license_result(lpr_data: Dict[str, Any]): - """Process incoming LPR result and update backend""" - try: - # Enhanced debugging for LPR data reception - logger.info("=" * 60) - logger.info("🚗 LPR SERVICE DATA RECEIVED") - logger.info("=" * 60) - logger.info(f"📥 Raw LPR data: {json.dumps(lpr_data, indent=2)}") - - session_id = str(lpr_data.get('session_id', '')) - license_text = lpr_data.get('license_character', '') - - logger.info(f"🔍 Extracted session_id: '{session_id}'") - logger.info(f"🔍 Extracted license_character: '{license_text}'") - logger.info(f"📊 Current cached sessions count: {len(session_detections)}") - logger.info(f"📊 Available session IDs: {list(session_detections.keys())}") - - # Find cached detection by session_id - if session_id not in session_detections: - logger.warning("❌ LPR SESSION ID NOT FOUND!") - logger.warning(f" Looking for session_id: '{session_id}'") - logger.warning(f" Available sessions: {list(session_detections.keys())}") - logger.warning(f" Session count: {len(session_detections)}") - - # Additional debugging - show session timestamps - if session_detections: - logger.warning("📅 Available session details:") - for sid, timestamp in detection_timestamps.items(): - age = time.time() - timestamp - camera = session_to_camera.get(sid, 'unknown') - logger.warning(f" Session {sid}: camera={camera}, age={age:.1f}s") - else: - logger.warning(" No cached sessions available - worker may not have processed any detections yet") - - logger.warning("💡 Possible causes:") - logger.warning(" 1. Session expired (TTL: 10 minutes)") - logger.warning(" 2. Session ID mismatch between detection and LPR service") - logger.warning(" 3. Detection was not cached (no sessionId from backend)") - logger.warning(" 4. Worker restarted after detection but before LPR result") - return - - # Get the original detection data - detection_data = session_detections[session_id].copy() - camera_id = session_to_camera.get(session_id, 'unknown') - - logger.info("✅ LPR SESSION FOUND!") - logger.info(f" 📹 Camera ID: {camera_id}") - logger.info(f" ⏰ Session age: {time.time() - detection_timestamps.get(session_id, 0):.1f} seconds") - - # Show original detection structure before update - original_license = detection_data.get('data', {}).get('detection', {}).get('licensePlateText') - logger.info(f" 🔍 Original licensePlateText: {original_license}") - logger.info(f" 🆕 New licensePlateText: '{license_text}'") - - # Update licensePlateText in detection - if 'data' in detection_data and 'detection' in detection_data['data']: - detection_data['data']['detection']['licensePlateText'] = license_text - - logger.info("🎯 LICENSE PLATE UPDATE SUCCESS!") - logger.info(f" ✅ Updated detection for session {session_id}") - logger.info(f" ✅ Set licensePlateText = '{license_text}'") - - # Show full detection structure after update - detection_dict = detection_data['data']['detection'] - logger.info("📋 Updated detection dictionary:") - logger.info(f" carModel: {detection_dict.get('carModel')}") - logger.info(f" carBrand: {detection_dict.get('carBrand')}") - logger.info(f" bodyType: {detection_dict.get('bodyType')}") - logger.info(f" licensePlateText: {detection_dict.get('licensePlateText')} ← UPDATED") - logger.info(f" licensePlateConfidence: {detection_dict.get('licensePlateConfidence')}") - else: - logger.error("❌ INVALID DETECTION DATA STRUCTURE!") - logger.error(f" Session {session_id} has malformed detection data") - logger.error(f" Detection data keys: {list(detection_data.keys())}") - if 'data' in detection_data: - logger.error(f" Data keys: {list(detection_data['data'].keys())}") - return - - # Update timestamp to indicate this is an LPR update - detection_data['timestamp'] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - - # Update all caches with new data - session_detections[session_id] = detection_data.copy() - cached_detections[camera_id] = detection_data.copy() - - # CRITICAL: Also update the pipeline state cached detection dict (used by lightweight mode) - if camera_id in session_pipeline_states: - pipeline_state = session_pipeline_states[camera_id] - current_cached_dict = pipeline_state.get("cached_detection_dict", {}) - - # Update the pipeline cached detection dict with new license plate - updated_dict = current_cached_dict.copy() if current_cached_dict else {} - updated_dict['licensePlateText'] = license_text - - pipeline_state["cached_detection_dict"] = updated_dict - logger.info(f"✅ LPR: Updated pipeline state cached_detection_dict for camera {camera_id}") - logger.debug(f"🔍 Pipeline cached dict now: {updated_dict}") - else: - logger.warning(f"⚠️ Camera {camera_id} not found in session_pipeline_states - pipeline cache not updated") - - logger.info("📡 SENDING UPDATED DETECTION TO BACKEND") - logger.info(f" 📹 Camera ID: {camera_id}") - logger.info(f" 📨 Updated licensePlateText: '{license_text}'") - logger.info(" 🔄 Updated both cache systems:") - logger.info(f" 1️⃣ cached_detections[{camera_id}] ✅") - logger.info(f" 2️⃣ session_pipeline_states[{camera_id}].cached_detection_dict ✅") - - # Log the full message being sent - logger.info("📋 Updated detection data in cache:") - logger.info(json.dumps(detection_data, indent=2)) - - logger.info("✅ ALL CACHES UPDATED!") - logger.info(f" 🎯 Lightweight mode will now use updated licensePlateText") - logger.info(f" 📤 Backend will receive: licensePlateText = '{license_text}'") - logger.info(" 🔄 Both cache systems synchronized with LPR data") - - logger.info("=" * 60) - logger.info("🏁 LPR PROCESSING COMPLETE") - logger.info(f" Session: {session_id}") - logger.info(f" License: '{license_text}'") - logger.info(f" Status: ✅ SUCCESS - DETECTION CACHE UPDATED") - logger.info("=" * 60) - - except Exception as e: - logger.error("=" * 60) - logger.error("❌ LPR PROCESSING FAILED") - logger.error("=" * 60) - logger.error(f"Error: {e}") - import traceback - logger.error(f"Traceback: {traceback.format_exc()}") - logger.error("=" * 60) - -# LPR integration now uses cached detection mechanism instead of direct WebSocket sending - -def license_results_listener(): - """Background thread to listen for LPR results from Redis""" - logger.info("🎧 Starting LPR listener thread...") - - while True: - try: - redis_client = create_redis_connection() - if not redis_client: - logger.error("❌ No Redis connection available for LPR listener") - time.sleep(10) - continue - - pubsub = redis_client.pubsub() - pubsub.subscribe("license_results") - logger.info("✅ LPR listener subscribed to 'license_results' channel") - - for message in pubsub.listen(): - try: - if message['type'] == 'message': - logger.info("🔔 REDIS MESSAGE RECEIVED!") - logger.info(f" 📡 Channel: {message['channel']}") - logger.info(f" 📥 Raw data: {message['data']}") - logger.info(f" 📏 Data size: {len(str(message['data']))} bytes") - - try: - lpr_data = json.loads(message['data']) - logger.info("✅ JSON parsing successful") - logger.info("🏁 Starting LPR processing...") - process_license_result(lpr_data) - logger.info("✅ LPR processing completed") - except json.JSONDecodeError as e: - logger.error("❌ JSON PARSING FAILED!") - logger.error(f" Error: {e}") - logger.error(f" Raw data: {message['data']}") - logger.error(f" Data type: {type(message['data'])}") - except Exception as e: - logger.error("❌ LPR PROCESSING ERROR!") - logger.error(f" Error: {e}") - import traceback - logger.error(f" Traceback: {traceback.format_exc()}") - elif message['type'] == 'subscribe': - logger.info(f"📡 LPR listener subscribed to channel: {message['channel']}") - logger.info("🎧 Ready to receive license plate results...") - elif message['type'] == 'unsubscribe': - logger.warning(f"📡 LPR listener unsubscribed from channel: {message['channel']}") - else: - logger.debug(f"📡 Redis message type: {message['type']}") - - except Exception as e: - logger.error(f"❌ Error in LPR message processing loop: {e}") - break - - except redis.exceptions.ConnectionError as e: - logger.error(f"❌ Redis connection lost in LPR listener: {e}") - time.sleep(5) # Wait before reconnecting - except Exception as e: - logger.error(f"❌ Unexpected error in LPR listener: {e}") - time.sleep(10) - - logger.warning("🛑 LPR listener thread stopped") - -def cleanup_expired_sessions(): - """Remove sessions older than TTL (10 minutes)""" - try: - current_time = time.time() - ttl_seconds = 600 # 10 minutes - - expired_sessions = [ - session_id for session_id, timestamp in detection_timestamps.items() - if current_time - timestamp > ttl_seconds - ] - - if expired_sessions: - logger.info(f"🧹 Cleaning up {len(expired_sessions)} expired sessions") - - for session_id in expired_sessions: - session_detections.pop(session_id, None) - camera_id = session_to_camera.pop(session_id, None) - detection_timestamps.pop(session_id, None) - logger.debug(f"Cleaned up expired session: {session_id} (camera: {camera_id})") - - else: - logger.debug(f"🧹 No expired sessions to clean up ({len(detection_timestamps)} active)") - - except Exception as e: - logger.error(f"❌ Error in session cleanup: {e}") - -def cleanup_timer(): - """Background thread for periodic session cleanup""" - logger.info("⏰ Starting session cleanup timer thread...") - - while True: - try: - time.sleep(120) # Run cleanup every 2 minutes - cleanup_expired_sessions() - except Exception as e: - logger.error(f"❌ Error in cleanup timer: {e}") - time.sleep(120) - -def start_lpr_integration(): - """Start LPR integration threads""" - global lpr_listener_thread, cleanup_timer_thread - - # Start LPR listener thread - lpr_listener_thread = threading.Thread(target=license_results_listener, daemon=True, name="LPR-Listener") - lpr_listener_thread.start() - logger.info("✅ LPR listener thread started") - - # Start cleanup timer thread - cleanup_timer_thread = threading.Thread(target=cleanup_timer, daemon=True, name="Session-Cleanup") - cleanup_timer_thread.start() - logger.info("✅ Session cleanup timer thread started") - WORKER_TIMEOUT_MS = 10000 -logger.debug(f"Heartbeat interval set to {HEARTBEAT_INTERVAL} seconds") -# Locks for thread-safe operations -streams_lock = threading.Lock() -models_lock = threading.Lock() -logger.debug("Initialized thread locks") - - -# Add helper to fetch snapshot image from HTTP/HTTPS URL -def fetch_snapshot(url: str): - try: - from requests.auth import HTTPBasicAuth, HTTPDigestAuth - import requests.adapters - import urllib3 - - # Parse URL to extract credentials - parsed = urlparse(url) - - # Prepare headers - some cameras require User-Agent and specific headers - headers = { - 'User-Agent': 'Mozilla/5.0 (compatible; DetectorWorker/1.0)', - 'Accept': 'image/jpeg,image/*,*/*', - 'Connection': 'close', - 'Cache-Control': 'no-cache' - } - - # Create a session with custom adapter for better connection handling - session = requests.Session() - adapter = requests.adapters.HTTPAdapter( - pool_connections=1, - pool_maxsize=1, - max_retries=urllib3.util.retry.Retry( - total=2, - backoff_factor=0.1, - status_forcelist=[500, 502, 503, 504] - ) - ) - session.mount('http://', adapter) - session.mount('https://', adapter) - - # Reconstruct URL without credentials - clean_url = f"{parsed.scheme}://{parsed.hostname}" - if parsed.port: - clean_url += f":{parsed.port}" - clean_url += parsed.path - if parsed.query: - clean_url += f"?{parsed.query}" - - auth = None - response = None - - if parsed.username and parsed.password: - # Try HTTP Digest authentication first (common for IP cameras) - try: - auth = HTTPDigestAuth(parsed.username, parsed.password) - response = session.get(clean_url, auth=auth, headers=headers, timeout=(5, 15), stream=True) - if response.status_code == 200: - logger.debug(f"Successfully authenticated using HTTP Digest for {clean_url}") - elif response.status_code == 401: - # If Digest fails, try Basic auth - logger.debug(f"HTTP Digest failed, trying Basic auth for {clean_url}") - auth = HTTPBasicAuth(parsed.username, parsed.password) - response = session.get(clean_url, auth=auth, headers=headers, timeout=(5, 15), stream=True) - if response.status_code == 200: - logger.debug(f"Successfully authenticated using HTTP Basic for {clean_url}") - except Exception as auth_error: - logger.debug(f"Authentication setup error: {auth_error}") - # Fallback to original URL with embedded credentials - response = session.get(url, headers=headers, timeout=(5, 15), stream=True) - else: - # No credentials in URL, make request as-is - response = session.get(url, headers=headers, timeout=(5, 15), stream=True) - - if response and response.status_code == 200: - # Read content with size limit to prevent memory issues - content = b'' - max_size = 10 * 1024 * 1024 # 10MB limit - for chunk in response.iter_content(chunk_size=8192): - content += chunk - if len(content) > max_size: - logger.error(f"Snapshot too large (>{max_size} bytes) from {clean_url}") - return None - - # Convert response content to numpy array - nparr = np.frombuffer(content, np.uint8) - # Decode image - frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR) - if frame is not None: - logger.debug(f"Successfully fetched snapshot from {clean_url}, shape: {frame.shape}, size: {len(content)} bytes") - return frame - else: - logger.error(f"Failed to decode image from snapshot URL: {clean_url} (content size: {len(content)} bytes)") - return None - elif response: - logger.error(f"Failed to fetch snapshot (status code {response.status_code}): {clean_url}") - # Log response headers and first part of content for debugging - logger.debug(f"Response headers: {dict(response.headers)}") - if len(response.content) < 1000: - logger.debug(f"Response content: {response.content[:500]}") - return None - else: - logger.error(f"No response received from snapshot URL: {clean_url}") - return None - except requests.exceptions.Timeout as e: - logger.error(f"Timeout fetching snapshot from {url}: {str(e)}") - return None - except requests.exceptions.ConnectionError as e: - logger.error(f"Connection error fetching snapshot from {url}: {str(e)}") - return None - except Exception as e: - logger.error(f"Exception fetching snapshot from {url}: {str(e)}", exc_info=True) - return None - -# Helper to get crop coordinates from stream -def get_crop_coords(stream): - return { - "cropX1": stream.get("cropX1"), - "cropY1": stream.get("cropY1"), - "cropX2": stream.get("cropX2"), - "cropY2": stream.get("cropY2") - } - -# Camera state management functions -def set_camera_connected(camera_id, connected=True, error_msg=None): - """Set camera connection state and track error information""" - current_time = time.time() - - if camera_id not in camera_states: - camera_states[camera_id] = { - "connected": True, - "last_error": None, - "last_error_time": None, - "consecutive_failures": 0, - "disconnection_notified": False - } - - state = camera_states[camera_id] - was_connected = state["connected"] - - if connected: - state["connected"] = True - state["consecutive_failures"] = 0 - state["disconnection_notified"] = False - if not was_connected: - logger.info(f"📶 CAMERA RECONNECTED: {camera_id}") - else: - state["connected"] = False - state["last_error"] = error_msg - state["last_error_time"] = current_time - state["consecutive_failures"] += 1 - - # Distinguish between temporary and permanent disconnection - is_permanent = state["consecutive_failures"] >= 3 - - if was_connected and is_permanent: - logger.error(f"📵 CAMERA DISCONNECTED: {camera_id} - {error_msg} (consecutive failures: {state['consecutive_failures']})") - logger.info(f"🚨 CAMERA ERROR DETECTED - Will send detection: null to reset backend session for {camera_id}") - -def is_camera_connected(camera_id): - """Check if camera is currently connected""" - return camera_states.get(camera_id, {}).get("connected", True) - -def should_notify_disconnection(camera_id): - """Check if we should notify backend about disconnection""" - state = camera_states.get(camera_id, {}) - is_disconnected = not state.get("connected", True) - not_yet_notified = not state.get("disconnection_notified", False) - has_enough_failures = state.get("consecutive_failures", 0) >= 3 - - return is_disconnected and not_yet_notified and has_enough_failures - -def mark_disconnection_notified(camera_id): - """Mark that we've notified backend about this disconnection""" - if camera_id in camera_states: - camera_states[camera_id]["disconnection_notified"] = True - logger.debug(f"Marked disconnection notification sent for camera {camera_id}") - -def get_or_init_session_pipeline_state(camera_id): - """Get or initialize session pipeline state for a camera""" - if camera_id not in session_pipeline_states: - session_pipeline_states[camera_id] = { - "mode": "validation_detecting", # "validation_detecting", "send_detections", "waiting_for_session_id", "full_pipeline", "lightweight", "car_gone_waiting" - "session_id_received": False, - "full_pipeline_completed": False, - "absence_counter": 0, - "validation_counter": 0, # Counter for validation phase - "validation_threshold": 4, # Default validation threshold - "max_absence_frames": 3, - "yolo_inference_enabled": True, # Controls whether to run YOLO inference - "cached_detection_dict": None, # Cached detection dict for lightweight mode - "stable_track_id": None, # The stable track ID we're monitoring - "validated_detection": None, # Stored detection result from validation phase for full_pipeline reuse - "progression_stage": None # Tracks current progression stage (welcome, car_wait_staff, car_fueling, car_waitpayment) - } - return session_pipeline_states[camera_id] - -def update_session_pipeline_mode(camera_id, new_mode, session_id=None): - """Update session pipeline mode and related state""" - state = get_or_init_session_pipeline_state(camera_id) - old_mode = state["mode"] - state["mode"] = new_mode - - # Reset counters based on mode transition - if new_mode == "validation_detecting": - # Transitioning to validation mode - reset both counters for fresh start - old_validation_counter = state.get("validation_counter", 0) - old_absence_counter = state.get("absence_counter", 0) - state["validation_counter"] = 0 - state["absence_counter"] = 0 - if old_validation_counter > 0 or old_absence_counter > 0: - logger.info(f"🧹 Camera {camera_id}: VALIDATION MODE RESET - validation_counter: {old_validation_counter}→0, absence_counter: {old_absence_counter}→0") - - if session_id: - state["session_id_received"] = True - state["absence_counter"] = 0 # Reset absence counter when session starts - - logger.info(f"📊 Camera {camera_id}: Pipeline mode changed from '{old_mode}' to '{new_mode}'") - return state - -#################################################### -# REST API endpoint for image retrieval -#################################################### -@app.get("/lpr/debug") -async def get_lpr_debug_info(): - """Debug endpoint to inspect LPR integration state""" - try: - return { - "status": "success", - "lpr_integration_started": lpr_integration_started, - "redis_connected": redis_client_global is not None and redis_client_global.ping() if redis_client_global else False, - "active_sessions": len(session_detections), - "session_details": { - session_id: { - "camera_id": session_to_camera.get(session_id, "unknown"), - "timestamp": detection_timestamps.get(session_id, 0), - "age_seconds": time.time() - detection_timestamps.get(session_id, time.time()), - "has_license": session_detections[session_id].get('data', {}).get('detection', {}).get('licensePlateText') is not None - } - for session_id in session_detections.keys() - }, - "thread_status": { - "lpr_listener_alive": lpr_listener_thread.is_alive() if lpr_listener_thread else False, - "cleanup_timer_alive": cleanup_timer_thread.is_alive() if cleanup_timer_thread else False, - "model_registry": get_registry_status(), - "mpta_manager": get_mpta_manager_status() - }, - "cached_detections_by_camera": list(cached_detections.keys()) - } - except Exception as e: - return { - "status": "error", - "error": str(e), - "lpr_integration_started": lpr_integration_started - } - -@app.get("/camera/{camera_id}/image") -async def get_camera_image(camera_id: str): - """ - Get the current frame from a camera as JPEG image - """ - try: - # URL decode the camera_id to handle encoded characters like %3B for semicolon - from urllib.parse import unquote - original_camera_id = camera_id - camera_id = unquote(camera_id) - logger.debug(f"REST API request: original='{original_camera_id}', decoded='{camera_id}'") - - with streams_lock: - if camera_id not in streams: - logger.warning(f"Camera ID '{camera_id}' not found in streams. Current streams: {list(streams.keys())}") - raise HTTPException(status_code=404, detail=f"Camera {camera_id} not found or not active") - - # Check if we have a cached frame for this camera - if camera_id not in latest_frames: - logger.warning(f"No cached frame available for camera '{camera_id}'.") - raise HTTPException(status_code=404, detail=f"No frame available for camera {camera_id}") - - frame = latest_frames[camera_id] - logger.debug(f"Retrieved cached frame for camera '{camera_id}', frame shape: {frame.shape}") - # Encode frame as JPEG - success, buffer_img = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 85]) - if not success: - raise HTTPException(status_code=500, detail="Failed to encode image as JPEG") - - # Return image as binary response - return Response(content=buffer_img.tobytes(), media_type="image/jpeg") - - except HTTPException: - raise - except Exception as e: - logger.error(f"Error retrieving image for camera {camera_id}: {str(e)}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") - -#################################################### -# Detection and frame processing functions -#################################################### @app.websocket("/") async def detect(websocket: WebSocket): - logger.info("WebSocket connection accepted") - persistent_data_dict = {} + import asyncio + import time - async def handle_detection(camera_id, stream, frame, websocket, model_tree, persistent_data): - try: - # Check camera connection state first - handle disconnection immediately - if should_notify_disconnection(camera_id): - logger.error(f"🚨 CAMERA DISCONNECTION DETECTED: {camera_id} - sending immediate detection: null") - - # Clear cached detections and occupancy state - cached_detections.pop(camera_id, None) - frame_skip_flags.pop(camera_id, None) - cached_full_pipeline_results.pop(camera_id, None) # Clear cached pipeline results - session_pipeline_states.pop(camera_id, None) # Reset session pipeline state - - # Reset pipeline state immediately - from siwatsystem.pympta import reset_tracking_state - model_id = stream.get("modelId", "unknown") - reset_tracking_state(camera_id, model_id, "camera disconnected") - - # Send immediate detection: null to backend - detection_data = { - "type": "imageDetection", - "subscriptionIdentifier": stream["subscriptionIdentifier"], - "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), - "data": { - "detection": None, # null detection for disconnection - "modelId": stream["modelId"], - "modelName": stream["modelName"] - } - } - - try: - ws_logger.info(f"TX -> {json.dumps(detection_data, separators=(',', ':'))}") - await websocket.send_json(detection_data) - except RuntimeError as e: - if "websocket.close" in str(e): - logger.warning(f"WebSocket connection closed - cannot send disconnection signal for camera {camera_id}") - return persistent_data - else: - raise - mark_disconnection_notified(camera_id) - logger.info(f"📡 SENT DISCONNECTION SIGNAL - detection: null for camera {camera_id}, backend should clear session") - - return persistent_data - - # Apply crop if specified - cropped_frame = frame - if all(coord is not None for coord in [stream.get("cropX1"), stream.get("cropY1"), stream.get("cropX2"), stream.get("cropY2")]): - cropX1, cropY1, cropX2, cropY2 = stream["cropX1"], stream["cropY1"], stream["cropX2"], stream["cropY2"] - cropped_frame = frame[cropY1:cropY2, cropX1:cropX2] - logger.debug(f"Applied crop coordinates ({cropX1}, {cropY1}, {cropX2}, {cropY2}) to frame for camera {camera_id}") - - logger.debug(f"Processing frame for camera {camera_id} with model {stream['modelId']}") - start_time = time.time() - - # Extract display identifier for pipeline context - subscription_parts = stream["subscriptionIdentifier"].split(';') - display_identifier = subscription_parts[0] if subscription_parts else None - - # Get backend session ID if available - backend_session_id = session_ids.get(display_identifier) - - # Get or initialize session pipeline state - pipeline_state = get_or_init_session_pipeline_state(camera_id) - current_mode = pipeline_state["mode"] - - logger.debug(f"🔍 SESSIONID LOOKUP: display='{display_identifier}', session_id={repr(backend_session_id)}, mode='{current_mode}'") - logger.debug(f"🔍 Available session_ids: {session_ids}") - logger.debug(f"🔍 VALIDATED_DETECTION TRACE: {pipeline_state.get('validated_detection')}") - - # ═══ SESSION ID-BASED PROCESSING MODE ═══ - if not backend_session_id: - # No session ID - handle different modes appropriately - if current_mode == "lightweight": - # Check if we're in car_waitpayment stage - if so, don't reset immediately - current_progression = pipeline_state.get("progression_stage") - if current_progression == "car_waitpayment": - # Stay in lightweight mode - let absence counter + sessionId null logic handle reset - logger.debug(f"🔍 Camera {camera_id}: No session ID but in car_waitpayment - staying in lightweight mode for dual reset condition") - else: - # Not in car_waitpayment - reset immediately (situation 1) - update_session_pipeline_mode(camera_id, "validation_detecting") - current_mode = "validation_detecting" - logger.debug(f"🔍 Camera {camera_id}: No session ID - reset to validation_detecting (not in car_waitpayment)") - elif current_mode not in ["validation_detecting", "send_detections", "waiting_for_session_id"]: - # Other modes - reset to validation_detecting - update_session_pipeline_mode(camera_id, "validation_detecting") - current_mode = "validation_detecting" - logger.debug(f"🔍 Camera {camera_id}: No session ID - reset to validation_detecting from {current_mode}") - else: - logger.debug(f"🔍 Camera {camera_id}: No session ID - staying in {current_mode} mode") - else: - # Session ID available - switch to full pipeline mode - if current_mode in ["send_detections", "waiting_for_session_id"]: - # Session ID just arrived - switch to full pipeline mode - update_session_pipeline_mode(camera_id, "full_pipeline", backend_session_id) - current_mode = "full_pipeline" - logger.info(f"🔥 Camera {camera_id}: Session ID received ({backend_session_id}) - switching to FULL PIPELINE mode") - - # Create context for pipeline execution - pipeline_context = { - "camera_id": camera_id, - "display_id": display_identifier, - "backend_session_id": backend_session_id, - "current_mode": current_mode # Pass current mode to pipeline - } - - start_time = time.time() - detection_result = None - - if current_mode == "validation_detecting": - # ═══ TRACK VALIDATION MODE ═══ - # Run tracking-based validation with track ID stability - logger.debug(f"🔍 Camera {camera_id}: In validation_detecting mode - running track-based validation") - - # Get tracking configuration from model_tree - tracking_config = model_tree.get("tracking", {}) - tracking_enabled = tracking_config.get("enabled", True) - stability_threshold = tracking_config.get("stabilityThreshold", 4) - - # Default to "none" - only proceed after track validation - detection_result = {"class": "none", "confidence": 1.0, "bbox": [0, 0, 0, 0]} - - if tracking_enabled: - # Run full tracking detection to get track IDs - from siwatsystem.pympta import run_detection_with_tracking - all_detections, regions_dict, track_validation_result = run_detection_with_tracking(cropped_frame, model_tree, pipeline_context) - - if track_validation_result.get("validation_complete", False): - # Track validation completed - we have stable track IDs - stable_tracks = track_validation_result.get("stable_tracks", []) - logger.info(f"🎯 Camera {camera_id}: TRACK VALIDATION COMPLETED - stable tracks: {stable_tracks}") - - # Switch to send_detections mode - update_session_pipeline_mode(camera_id, "send_detections") - - # Send the best detection with stable track - if all_detections: - # Find detection with stable track ID - stable_detection = None - for detection in all_detections: - if detection.get("id") in stable_tracks: - stable_detection = detection - break - - if stable_detection: - detection_result = { - "class": stable_detection.get("class", "car"), - "confidence": stable_detection.get("confidence", 0.0), - "bbox": stable_detection.get("bbox", [0, 0, 0, 0]), - "track_id": stable_detection.get("id") - } - - # Store validated detection for full_pipeline mode to reuse - pipeline_state["validated_detection"] = detection_result.copy() - logger.debug(f"🔍 Camera {camera_id}: VALIDATION DEBUG - storing detection_result = {detection_result}") - logger.debug(f"🔍 Camera {camera_id}: VALIDATION DEBUG - pipeline_state after storing = {pipeline_state.get('validated_detection')}") - logger.info(f"🚗 Camera {camera_id}: SENDING STABLE DETECTION - track ID {detection_result['track_id']}") - logger.info(f"💾 Camera {camera_id}: STORED VALIDATED DETECTION for full_pipeline reuse") - else: - logger.warning(f"⚠️ Camera {camera_id}: Stable tracks found but no matching detection") - else: - # Track validation still in progress - stable_tracks = track_validation_result.get("stable_tracks", []) - current_tracks = track_validation_result.get("current_tracks", []) - - if current_tracks: - track_id = current_tracks[0] if current_tracks else "None" - stable_status = "STABLE" if stable_tracks else "validating" - logger.info(f"🔍 Camera {camera_id}: TRACK VALIDATION - car track_id {track_id} ({stable_status}, need {stability_threshold} consecutive frames)") - else: - logger.debug(f"👻 Camera {camera_id}: No car detected") - - logger.debug(f"📤 Camera {camera_id}: Sending 'none' (track validation in progress)") - else: - # Tracking disabled - fall back to basic detection validation - logger.debug(f"🔍 Camera {camera_id}: Tracking disabled - using basic detection validation") - from siwatsystem.pympta import run_lightweight_detection - basic_detection = run_lightweight_detection(cropped_frame, model_tree) - - if basic_detection and basic_detection.get("car_detected"): - best_detection = basic_detection.get("best_detection") - - # Increment validation counter for basic detection - pipeline_state["validation_counter"] += 1 - current_count = pipeline_state["validation_counter"] - threshold = pipeline_state["validation_threshold"] - - if current_count >= threshold: - update_session_pipeline_mode(camera_id, "send_detections") - detection_result = { - "class": best_detection.get("class", "car"), - "confidence": best_detection.get("confidence", 0.0), - "bbox": best_detection.get("bbox", [0, 0, 0, 0]) - } - - # Store validated detection for full_pipeline mode to reuse - pipeline_state["validated_detection"] = detection_result.copy() - logger.debug(f"🔍 Camera {camera_id}: BASIC VALIDATION DEBUG - storing detection_result = {detection_result}") - logger.info(f"💾 Camera {camera_id}: STORED BASIC VALIDATED DETECTION for full_pipeline reuse") - logger.info(f"🎯 Camera {camera_id}: BASIC VALIDATION COMPLETED after {current_count} frames") - else: - logger.info(f"📊 Camera {camera_id}: Basic validation progress {current_count}/{threshold}") - else: - # Reset validation counter - if pipeline_state["validation_counter"] > 0: - pipeline_state["validation_counter"] = 0 - logger.info(f"🔄 Camera {camera_id}: Reset validation counter (no detection)") - - elif current_mode == "send_detections": - # ═══ SEND DETECTIONS MODE ═══ - # Validation completed - now send detection_dict for car detections, detection: null for no car - logger.debug(f"📤 Camera {camera_id}: In send_detections mode - sending detection_dict for cars") - from siwatsystem.pympta import run_lightweight_detection - basic_detection = run_lightweight_detection(cropped_frame, model_tree) - - if basic_detection and basic_detection.get("car_detected"): - # Car detected - send detection_dict - best_detection = basic_detection.get("best_detection") - detection_result = { - "class": best_detection.get("class", "car"), - "confidence": best_detection.get("confidence", 0.0), - "bbox": best_detection.get("bbox", [0, 0, 0, 0]) - } - logger.info(f"🚗 Camera {camera_id}: SENDING DETECTION_DICT - {detection_result['class']} (conf={detection_result['confidence']:.3f}) - backend should generate session ID") - else: - # No car detected - send "none" - detection_result = {"class": "none", "confidence": 1.0, "bbox": [0, 0, 0, 0]} - logger.debug(f"👻 Camera {camera_id}: No car detected - sending 'none'") - - elif current_mode == "waiting_for_session_id": - # ═══ WAITING FOR SESSION ID MODE ═══ - # Stop processing snapshots, wait for session ID - logger.debug(f"⏳ Camera {camera_id}: In waiting_for_session_id mode - not processing snapshots") - return persistent_data # Don't process or send anything - - elif current_mode == "full_pipeline": - # ═══ FULL PIPELINE MODE ═══ - logger.info(f"🔥 Camera {camera_id}: Running FULL PIPELINE (classification branches + Redis + PostgreSQL)") - - # Use validated detection from validation phase instead of detecting again - validated_detection = pipeline_state.get("validated_detection") - logger.debug(f"🔍 Camera {camera_id}: FULL_PIPELINE DEBUG - validated_detection = {validated_detection}") - logger.debug(f"🔍 Camera {camera_id}: FULL_PIPELINE DEBUG - pipeline_state keys = {list(pipeline_state.keys())}") - if validated_detection: - logger.info(f"🔄 Camera {camera_id}: Using validated detection for full pipeline: track_id={validated_detection.get('track_id')}") - detection_result = run_pipeline(cropped_frame, model_tree, context=pipeline_context, validated_detection=validated_detection) - # Clear the validated detection after using it - pipeline_state["validated_detection"] = None - else: - logger.warning(f"⚠️ Camera {camera_id}: No validated detection found for full pipeline - this shouldn't happen") - detection_result = run_pipeline(cropped_frame, model_tree, context=pipeline_context) - - if detection_result and isinstance(detection_result, dict): - # Cache the full pipeline result - cached_full_pipeline_results[camera_id] = { - "result": detection_result.copy(), - "timestamp": time.time() - } - - # Note: Will cache detection_dict after branch processing completes - - # Store the stable track ID for lightweight monitoring - track_id = detection_result.get("track_id") or detection_result.get("id") - if track_id is not None: - pipeline_state["stable_track_id"] = track_id - logger.info(f"💾 Camera {camera_id}: Cached stable track_id={track_id}") - else: - logger.warning(f"⚠️ Camera {camera_id}: No track_id found in detection_result: {detection_result.keys()}") - - # Ensure we have a cached detection dict for lightweight mode - if not pipeline_state.get("cached_detection_dict"): - # Create fallback cached detection dict if branch processing didn't populate it - fallback_detection = { - "carModel": None, - "carBrand": None, - "carYear": None, - "bodyType": None, - "licensePlateText": None, - "licensePlateConfidence": None - } - pipeline_state["cached_detection_dict"] = fallback_detection - logger.warning(f"⚠️ Camera {camera_id}: Created fallback cached detection dict (branch processing may have failed)") - - # Switch to lightweight mode - update_session_pipeline_mode(camera_id, "lightweight") - logger.info(f"✅ Camera {camera_id}: Full pipeline completed - switching to LIGHTWEIGHT mode") - - elif current_mode == "lightweight": - # ═══ SIMPLIFIED LIGHTWEIGHT MODE ═══ - # Send cached detection dict + check for 2 consecutive empty frames to reset - - stable_track_id = pipeline_state.get("stable_track_id") - cached_detection_dict = pipeline_state.get("cached_detection_dict") - - logger.debug(f"🪶 Camera {camera_id}: LIGHTWEIGHT MODE - stable_track_id={stable_track_id}") - - if not pipeline_state.get("yolo_inference_enabled", True): - # YOLO inference disabled during car_fueling - continue sending cached detection dict - logger.debug(f"🛑 Camera {camera_id}: YOLO inference disabled during car_fueling - continue sending cached detection dict") - if cached_detection_dict: - detection_result = cached_detection_dict # Continue sending cached data - logger.info(f"⛽ Camera {camera_id}: YOLO disabled during car_fueling but sending cached detection dict") - else: - logger.warning(f"⚠️ Camera {camera_id}: YOLO disabled but no cached detection dict available") - detection_result = None - else: - # Run lightweight YOLO inference to check car presence for reset logic (no tracking validation needed) - from siwatsystem.pympta import run_lightweight_detection - basic_detection = run_lightweight_detection(cropped_frame, model_tree) - - any_car_detected = basic_detection and basic_detection.get("car_detected", False) - logger.debug(f"🔍 Camera {camera_id}: LIGHTWEIGHT - simple car presence check: {any_car_detected}") - - if any_car_detected: - # Car detected - reset absence counter, continue sending cached detection dict - pipeline_state["absence_counter"] = 0 # Reset absence since cars are present - - if cached_detection_dict: - detection_result = cached_detection_dict # Always send cached data - logger.info(f"💾 Camera {camera_id}: LIGHTWEIGHT - car detected, sending cached detection dict") - else: - logger.warning(f"⚠️ Camera {camera_id}: LIGHTWEIGHT - car detected but no cached detection dict available") - detection_result = None - else: - # No car detected - increment absence counter - pipeline_state["absence_counter"] += 1 - absence_count = pipeline_state["absence_counter"] - max_absence = 3 # Need 3 consecutive empty frames - - logger.info(f"👻 Camera {camera_id}: LIGHTWEIGHT - no car detected (absence {absence_count}/{max_absence})") - - # Check if we should reset: Need BOTH 3 consecutive absence frames AND sessionId: null - current_progression = pipeline_state.get("progression_stage") - should_check_session_null = current_progression == "car_waitpayment" - - if absence_count >= max_absence: - if should_check_session_null: - # In car_waitpayment stage - require BOTH conditions - if backend_session_id is None: - # Both conditions met: 3 absence frames + sessionId: null - logger.info(f"🔄 Camera {camera_id}: DUAL RESET CONDITIONS MET - {max_absence} consecutive absence frames + sessionId: null") - - # Clear all state and prepare for next car - cached_full_pipeline_results.pop(camera_id, None) - pipeline_state["cached_detection_dict"] = None - pipeline_state["stable_track_id"] = None - pipeline_state["validated_detection"] = None - pipeline_state["progression_stage"] = None - old_absence_counter = pipeline_state["absence_counter"] - old_validation_counter = pipeline_state.get("validation_counter", 0) - pipeline_state["absence_counter"] = 0 - pipeline_state["validation_counter"] = 0 - pipeline_state["yolo_inference_enabled"] = True - - logger.info(f"🧹 Camera {camera_id}: DUAL RESET - absence_counter: {old_absence_counter}→0, validation_counter: {old_validation_counter}→0, progression_stage: {current_progression}→None") - - # Clear stability tracking data for this camera - from siwatsystem.pympta import reset_camera_stability_tracking - reset_camera_stability_tracking(camera_id, model_tree.get("modelId", "unknown")) - - # Switch back to validation phase - update_session_pipeline_mode(camera_id, "validation_detecting") - logger.info(f"✅ Camera {camera_id}: DUAL RESET TO VALIDATION COMPLETE - ready for new car") - - # Now in validation mode - send what YOLO detection finds (will be null since no car) - detection_result = {"class": "none", "confidence": 1.0, "bbox": [0, 0, 0, 0]} - else: - # Only absence frames met, but sessionId is not null - continue sending cached detection - logger.info(f"⏳ Camera {camera_id}: {max_absence} absence frames reached but sessionId={backend_session_id} (not null) - continuing with cached detection") - if cached_detection_dict: - detection_result = cached_detection_dict - else: - logger.warning(f"⚠️ Camera {camera_id}: No cached detection dict available") - detection_result = None - else: - # Not in car_waitpayment - use original simple reset condition (situation 1) - logger.info(f"🔄 Camera {camera_id}: SIMPLE RESET CONDITION MET - {max_absence} consecutive empty frames (not in car_waitpayment)") - - # Clear all state and prepare for next car - cached_full_pipeline_results.pop(camera_id, None) - pipeline_state["cached_detection_dict"] = None - pipeline_state["stable_track_id"] = None - pipeline_state["validated_detection"] = None - pipeline_state["progression_stage"] = None - old_absence_counter = pipeline_state["absence_counter"] - old_validation_counter = pipeline_state.get("validation_counter", 0) - pipeline_state["absence_counter"] = 0 - pipeline_state["validation_counter"] = 0 - pipeline_state["yolo_inference_enabled"] = True - - logger.info(f"🧹 Camera {camera_id}: SIMPLE RESET - absence_counter: {old_absence_counter}→0, validation_counter: {old_validation_counter}→0") - - # Clear stability tracking data for this camera - from siwatsystem.pympta import reset_camera_stability_tracking - reset_camera_stability_tracking(camera_id, model_tree.get("modelId", "unknown")) - - # Switch back to validation phase - update_session_pipeline_mode(camera_id, "validation_detecting") - logger.info(f"✅ Camera {camera_id}: SIMPLE RESET TO VALIDATION COMPLETE - ready for new car") - - # Now in validation mode - send what YOLO detection finds (will be null since no car) - detection_result = {"class": "none", "confidence": 1.0, "bbox": [0, 0, 0, 0]} - else: - # Still within absence threshold - continue sending cached detection dict - if cached_detection_dict: - detection_result = cached_detection_dict # Send cached data - logger.info(f"⏳ Camera {camera_id}: LIGHTWEIGHT - no car but absence<{max_absence}, still sending cached detection dict") - else: - logger.warning(f"⚠️ Camera {camera_id}: LIGHTWEIGHT - no cached detection dict available") - detection_result = None - - elif current_mode == "car_gone_waiting": - # ═══ CAR GONE WAITING STATE ═══ - # Car is gone (both conditions met), YOLO inference disabled, waiting for new session - - logger.debug(f"🛑 Camera {camera_id}: CAR GONE WAITING - YOLO inference stopped") - - # Check if backend has started a new session (indicates new car scenario) - if backend_session_id is not None: - # Backend started new session - re-enable YOLO and reset to validation - pipeline_state["yolo_inference_enabled"] = True - pipeline_state["absence_counter"] = 0 - pipeline_state["stable_track_id"] = None - pipeline_state["cached_detection_dict"] = None - pipeline_state["validated_detection"] = None - - # Clear stability tracking data for this camera - from siwatsystem.pympta import reset_camera_stability_tracking - reset_camera_stability_tracking(camera_id, model_tree.get("modelId", "unknown")) - - update_session_pipeline_mode(camera_id, "validation_detecting") - logger.info(f"🔄 Camera {camera_id}: New session detected (id={backend_session_id}) - re-enabling YOLO inference") - logger.info(f"✅ Camera {camera_id}: Reset to validation mode - cleared all tracking, ready for new car detection") - - # Don't run detection this frame - let next frame start fresh - detection_result = {"class": "none", "confidence": 1.0, "bbox": [0, 0, 0, 0]} - else: - # Still waiting - no sessionId, no detection to send - logger.debug(f"🛑 Camera {camera_id}: Car gone waiting - no YOLO inference, no data sent") - detection_result = None - - process_time = (time.time() - start_time) * 1000 - logger.debug(f"Detection for camera {camera_id} completed in {process_time:.2f}ms (mode: {current_mode})") - - # Skip processing if no detection result (blocked by session gating) - if detection_result is None: - logger.debug(f"No detection result to process for camera {camera_id}") - return persistent_data - - # Log the raw detection result for debugging - logger.debug(f"Raw detection result for camera {camera_id}:\n{json.dumps(detection_result, indent=2, default=str)}") - - # Extract session_id from pipeline result (always use backend sessionId) - session_id = backend_session_id - logger.debug(f"Using backend session_id: {session_id}") - - - # Process detection result based on current mode - if current_mode == "validation_detecting": - # ═══ VALIDATION DETECTING MODE ═══ - # Always send detection: null during validation phase - detection_dict = None - logger.debug(f"🔍 SENDING 'NONE' - validation_detecting mode for camera {camera_id}") - - elif current_mode == "send_detections": - # ═══ SEND DETECTIONS MODE ═══ - if detection_result.get("class") == "none": - # No car detected - send detection: null - detection_dict = None - logger.debug(f"📤 SENDING 'NONE' - send_detections mode (no car) for camera {camera_id}") - else: - # Car detected in send_detections mode - ALWAYS send empty dict to trigger backend sessionId - # Purpose: Tell backend "car is here, please create sessionId" - detection_dict = {} - logger.info(f"📤 SENDING EMPTY DETECTION_DICT - send_detections mode, requesting backend to create sessionId (conf={detection_result.get('confidence', 0):.3f}) for camera {camera_id}") - - if backend_session_id: - logger.debug(f"🔄 Camera {camera_id}: Note - sessionId {backend_session_id} exists but still in send_detections mode (transition pending)") - - elif current_mode == "lightweight": - # ═══ SIMPLIFIED LIGHTWEIGHT MODE DETECTION PROCESSING ═══ - if detection_result.get("class") == "none": - # No car detected - this happens when resetting to validation - detection_dict = None # Send detection: null - logger.info(f"🚫 LIGHTWEIGHT - no car detected, sending detection=null") - elif isinstance(detection_result, dict) and ("carBrand" in detection_result or "carModel" in detection_result): - # Check if we're waiting for dual reset condition - current_progression = pipeline_state.get("progression_stage") - if current_progression == "car_waitpayment" and backend_session_id is None: - # In car_waitpayment + sessionId: null - STOP sending cached detection to prevent new session creation - detection_dict = None - logger.info(f"🛑 LIGHTWEIGHT - in car_waitpayment with sessionId: null, NOT sending cached detection (waiting for dual reset)") - else: - # Normal lightweight mode - send cached detection dict - detection_dict = detection_result - logger.info(f"💾 LIGHTWEIGHT - sending cached detection dict") - else: - logger.warning(f"⚠️ LIGHTWEIGHT - unexpected detection_result type: {type(detection_result)}") - detection_dict = None - - elif detection_result.get("class") == "none": - # Other modes - send null to clear session - detection_dict = None - logger.info(f"📤 SENDING 'NONE' (detection: null) - Car absent, expecting backend to clear session for camera {camera_id}") - elif detection_result and "carBrand" in detection_result: - # Handle cached detection dict format (fallback for compatibility) - detection_dict = detection_result - logger.info(f"💾 Camera {camera_id}: LIGHTWEIGHT MODE - using detection_result as detection_dict:") - logger.info(f"💾 Camera {camera_id}: - detection_dict: {detection_dict}") - else: - # Valid detection - convert to backend format (will be populated by branch processing) - detection_dict = { - "carModel": None, - "carBrand": None, - "carYear": None, - "bodyType": None, - "licensePlateText": None, - "licensePlateConfidence": None - } - - # Extract and process branch results from parallel classification (only for valid detections, skip cached mode) - if detection_result.get("class") != "none" and "branch_results" in detection_result and not detection_result.get("cached_mode", False): - def process_branch_results(branch_results, depth=0): - """Recursively process branch results including nested branches.""" - if not isinstance(branch_results, dict): - return - - indent = " " * depth - for branch_id, branch_data in branch_results.items(): - if isinstance(branch_data, dict): - logger.debug(f"{indent}Processing branch {branch_id}: {branch_data}") - - # Map common classification fields to backend-expected names - if "brand" in branch_data: - detection_dict["carBrand"] = branch_data["brand"] - logger.debug(f"{indent}Mapped carBrand: {branch_data['brand']}") - if "body_type" in branch_data: - detection_dict["bodyType"] = branch_data["body_type"] - logger.debug(f"{indent}Mapped bodyType: {branch_data['body_type']}") - if "class" in branch_data: - class_name = branch_data["class"] - - # Map based on branch/model type - if "brand" in branch_id.lower(): - detection_dict["carBrand"] = class_name - logger.debug(f"{indent}Mapped carBrand from class: {class_name}") - elif "bodytype" in branch_id.lower() or "body" in branch_id.lower(): - detection_dict["bodyType"] = class_name - logger.debug(f"{indent}Mapped bodyType from class: {class_name}") - - # Process nested branch results recursively - if "branch_results" in branch_data: - logger.debug(f"{indent}Processing nested branches in {branch_id}") - process_branch_results(branch_data["branch_results"], depth + 1) + logging.info("WebSocket connection accepted") - branch_results = detection_result.get("branch_results", {}) - if branch_results: - logger.debug(f"Processing branch results: {branch_results}") - process_branch_results(branch_results) - logger.info(f"Detection payload after branch processing: {detection_dict}") - - # Cache the detection_dict for lightweight mode (after branch processing completes) - if current_mode == "full_pipeline": - pipeline_state = get_or_init_session_pipeline_state(camera_id) - pipeline_state["cached_detection_dict"] = detection_dict.copy() - logger.info(f"💾 Camera {camera_id}: CACHED DETECTION DICT after branch processing: {detection_dict}") - - else: - logger.debug("No branch results found in detection result") - - detection_data = { - "type": "imageDetection", - "subscriptionIdentifier": stream["subscriptionIdentifier"], - "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", 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"], - "modelName": stream["modelName"] - } - } - - # SessionId should NEVER be sent from worker to backend - it's uni-directional (backend -> worker only) - # Backend manages sessionIds independently based on detection content - logger.debug(f"TX message prepared (no sessionId) - detection_dict type: {type(detection_dict)}") - - # Log detection details for different modes - if current_mode == "lightweight": - if detection_result and detection_result.get("class") == "none": - logger.info(f"🚫 Camera {camera_id}: LIGHTWEIGHT - No car detected (resetting to validation)") - elif isinstance(detection_result, dict) and ("carBrand" in detection_result or "carModel" in detection_result): - logger.info(f"💾 Camera {camera_id}: LIGHTWEIGHT - Sending cached detection data") - else: - logger.info(f"🪶 Camera {camera_id}: LIGHTWEIGHT - Processing detection") - elif detection_result and "class" in detection_result and detection_result.get("class") != "none": - confidence = detection_result.get("confidence", 0.0) - logger.info(f"🚗 Camera {camera_id}: Detected {detection_result['class']} with confidence {confidence:.2f} using model {stream['modelName']}") - - # Send detection data to backend (session gating handled above in processing logic) - logger.debug(f"📤 SENDING TO BACKEND for camera {camera_id}: {json.dumps(detection_data, indent=2)}") - try: - ws_logger.info(f"TX -> {json.dumps(detection_data, separators=(',', ':'))}") - await websocket.send_json(detection_data) - logger.debug(f"Sent detection data to client for camera {camera_id}") - - # Cache the detection data for potential resubscriptions (only if not null detection) - if detection_dict is not None and detection_result.get("class") != "none": - cached_detections[camera_id] = detection_data.copy() - logger.debug(f"Cached detection for camera {camera_id}: {detection_dict}") - - # Enhanced caching: Store by session_id for LPR integration - session_id = detection_data.get('sessionId') - if session_id: - session_id_str = str(session_id) - session_detections[session_id_str] = detection_data.copy() - session_to_camera[session_id_str] = camera_id - detection_timestamps[session_id_str] = time.time() - logger.debug(f"🔑 Cached detection for LPR by session_id {session_id_str}: {camera_id}") - else: - # Don't cache null/none detections - let them reset properly - cached_detections.pop(camera_id, None) - logger.debug(f"Not caching null/none detection for camera {camera_id}") - - except RuntimeError as e: - if "websocket.close" in str(e): - logger.warning(f"WebSocket connection closed - cannot send detection data for camera {camera_id}") - return persistent_data - else: - raise - - # Log status after sending (no sessionId sent to backend) - if detection_dict is None: - logger.info(f"📡 SENT 'none' detection - backend should clear session for camera {camera_id}") - elif detection_dict == {}: - logger.info(f"📡 SENT empty detection - backend should create sessionId for camera {camera_id}") - else: - logger.info(f"📡 SENT detection data - backend manages sessionId independently for camera {camera_id}") - return persistent_data - except Exception as e: - logger.error(f"Error in handle_detection for camera {camera_id}: {str(e)}", exc_info=True) - return persistent_data + streams = {} def frame_reader(camera_id, cap, buffer, stop_event): + import time retries = 0 - logger.info(f"Starting frame reader thread for camera {camera_id}") - frame_count = 0 - last_log_time = time.time() - - try: - # Log initial camera status and properties - if cap.isOpened(): - 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) - logger.info(f"Camera {camera_id} opened successfully with resolution {width}x{height}, FPS: {fps}") - set_camera_connected(camera_id, True) - else: - logger.error(f"Camera {camera_id} failed to open initially") - set_camera_connected(camera_id, False, "Failed to open camera initially") - - while not stop_event.is_set(): - try: - if not cap.isOpened(): - logger.error(f"Camera {camera_id} is not open before trying to read") - # Attempt to reopen - cap = cv2.VideoCapture(streams[camera_id]["rtsp_url"]) - time.sleep(reconnect_interval) - continue - - logger.debug(f"Attempting to read frame from camera {camera_id}") - ret, frame = cap.read() - - if not ret: - error_msg = f"Connection lost for camera: {camera_id}, retry {retries+1}/{max_retries}" - logger.warning(error_msg) - set_camera_connected(camera_id, False, error_msg) - cap.release() - time.sleep(reconnect_interval) - retries += 1 - if retries > max_retries and max_retries != -1: - logger.error(f"Max retries reached for camera: {camera_id}, stopping frame reader") - set_camera_connected(camera_id, False, "Max retries reached") - break - # Re-open - logger.info(f"Attempting to reopen RTSP stream for camera: {camera_id}") - cap = cv2.VideoCapture(streams[camera_id]["rtsp_url"]) - if not cap.isOpened(): - logger.error(f"Failed to reopen RTSP stream for camera: {camera_id}") - set_camera_connected(camera_id, False, "Failed to reopen RTSP stream") - continue - logger.info(f"Successfully reopened RTSP stream for camera: {camera_id}") - set_camera_connected(camera_id, True) - continue - - # Successfully read a frame - frame_count += 1 - current_time = time.time() - # Log frame stats every 5 seconds - if current_time - last_log_time > 5: - logger.info(f"Camera {camera_id}: Read {frame_count} frames in the last {current_time - last_log_time:.1f} seconds") - frame_count = 0 - last_log_time = current_time - - logger.debug(f"Successfully read frame from camera {camera_id}, shape: {frame.shape}") - retries = 0 - set_camera_connected(camera_id, True) # Mark as connected on successful frame read - - # Overwrite old frame if buffer is full - if not buffer.empty(): - try: - buffer.get_nowait() - logger.debug(f"[frame_reader] Removed old frame from buffer for camera {camera_id}") - except queue.Empty: - pass - buffer.put(frame) - logger.debug(f"[frame_reader] Added new frame to buffer for camera {camera_id}. Buffer size: {buffer.qsize()}") - - # Short sleep to avoid CPU overuse - time.sleep(0.01) - - except cv2.error as e: - error_msg = f"OpenCV error for camera {camera_id}: {e}" - logger.error(error_msg, exc_info=True) - set_camera_connected(camera_id, False, error_msg) + while not stop_event.is_set(): + try: + ret, frame = cap.read() + if not ret: + logging.warning(f"Connection lost for camera: {camera_id}, retry {retries+1}/{max_retries}") cap.release() time.sleep(reconnect_interval) retries += 1 - if retries > max_retries and max_retries != -1: - logger.error(f"Max retries reached after OpenCV error for camera {camera_id}") - set_camera_connected(camera_id, False, "Max retries reached after OpenCV error") + if retries > max_retries: + logging.error(f"Max retries reached for camera: {camera_id}") break - logger.info(f"Attempting to reopen RTSP stream after OpenCV error for camera: {camera_id}") - cap = cv2.VideoCapture(streams[camera_id]["rtsp_url"]) + # Re-open the VideoCapture + cap = cv2.VideoCapture(streams[camera_id]['rtsp_url']) if not cap.isOpened(): - logger.error(f"Failed to reopen RTSP stream for camera {camera_id} after OpenCV error") - set_camera_connected(camera_id, False, "Failed to reopen after OpenCV error") + logging.error(f"Failed to reopen RTSP stream for camera: {camera_id}") continue - logger.info(f"Successfully reopened RTSP stream after OpenCV error for camera: {camera_id}") - set_camera_connected(camera_id, True) - except Exception as e: - error_msg = f"Unexpected error for camera {camera_id}: {str(e)}" - logger.error(error_msg, exc_info=True) - set_camera_connected(camera_id, False, error_msg) - cap.release() - break - except Exception as e: - logger.error(f"Error in frame_reader thread for camera {camera_id}: {str(e)}", exc_info=True) - finally: - logger.info(f"Frame reader thread for camera {camera_id} is exiting") - if cap and cap.isOpened(): + continue + retries = 0 # Reset on success + if not buffer.empty(): + try: + buffer.get_nowait() # Discard the old frame + except queue.Empty: + pass + buffer.put(frame) + except cv2.error as e: + logging.error(f"OpenCV error for camera {camera_id}: {e}") cap.release() - - def snapshot_reader(camera_id, snapshot_url, snapshot_interval, buffer, stop_event): - """Frame reader that fetches snapshots from HTTP/HTTPS URL at specified intervals""" - retries = 0 - consecutive_failures = 0 # Track consecutive failures for backoff - logger.info(f"Starting snapshot reader thread for camera {camera_id} from {snapshot_url}") - frame_count = 0 - last_log_time = time.time() - - # Initialize camera state - set_camera_connected(camera_id, True) - - try: - interval_seconds = snapshot_interval / 1000.0 # Convert milliseconds to seconds - logger.info(f"Snapshot interval for camera {camera_id}: {interval_seconds}s") - - while not stop_event.is_set(): - try: - start_time = time.time() - frame = fetch_snapshot(snapshot_url) - - if frame is None: - consecutive_failures += 1 - error_msg = f"Failed to fetch snapshot for camera: {camera_id}, consecutive failures: {consecutive_failures}" - logger.warning(error_msg) - set_camera_connected(camera_id, False, error_msg) - retries += 1 - - # Check network connectivity with a simple ping-like test - if consecutive_failures % 5 == 1: # Every 5th failure, test connectivity - try: - test_response = requests.get(snapshot_url, timeout=(2, 5), stream=False) - logger.info(f"Camera {camera_id}: Connectivity test result: {test_response.status_code}") - except Exception as test_error: - logger.warning(f"Camera {camera_id}: Connectivity test failed: {test_error}") - - if retries > max_retries and max_retries != -1: - logger.error(f"Max retries reached for snapshot camera: {camera_id}, stopping reader") - set_camera_connected(camera_id, False, "Max retries reached for snapshot camera") - break - - # Exponential backoff based on consecutive failures - backoff_delay = min(30, max(1, min(2 ** min(consecutive_failures - 1, 6), interval_seconds * 2))) # Start with 1s, max 30s - logger.debug(f"Camera {camera_id}: Backing off for {backoff_delay:.1f}s (consecutive failures: {consecutive_failures})") - if stop_event.wait(backoff_delay): # Use wait with timeout instead of sleep - break # Exit if stop_event is set during backoff - continue - - # Successfully fetched a frame - reset consecutive failures - consecutive_failures = 0 # Reset backoff on success - frame_count += 1 - current_time = time.time() - # Log frame stats every 5 seconds - if current_time - last_log_time > 5: - logger.info(f"Camera {camera_id}: Fetched {frame_count} snapshots in the last {current_time - last_log_time:.1f} seconds") - frame_count = 0 - last_log_time = current_time - - logger.debug(f"Successfully fetched snapshot from camera {camera_id}, shape: {frame.shape}") - retries = 0 - set_camera_connected(camera_id, True) # Mark as connected on successful snapshot - - # Overwrite old frame if buffer is full - if not buffer.empty(): - try: - buffer.get_nowait() - logger.debug(f"[snapshot_reader] Removed old snapshot from buffer for camera {camera_id}") - except queue.Empty: - pass - buffer.put(frame) - logger.debug(f"[snapshot_reader] Added new snapshot to buffer for camera {camera_id}. Buffer size: {buffer.qsize()}") - - # Wait for the specified interval - elapsed = time.time() - start_time - sleep_time = max(interval_seconds - elapsed, 0) - if sleep_time > 0: - time.sleep(sleep_time) - - except Exception as e: - consecutive_failures += 1 - error_msg = f"Unexpected error fetching snapshot for camera {camera_id}: {str(e)}" - logger.error(error_msg, exc_info=True) - set_camera_connected(camera_id, False, error_msg) - retries += 1 - if retries > max_retries and max_retries != -1: - logger.error(f"Max retries reached after error for snapshot camera {camera_id}") - set_camera_connected(camera_id, False, "Max retries reached after error") - break - - # Exponential backoff for exceptions too - backoff_delay = min(30, max(1, min(2 ** min(consecutive_failures - 1, 6), interval_seconds * 2))) # Start with 1s, max 30s - logger.debug(f"Camera {camera_id}: Exception backoff for {backoff_delay:.1f}s (consecutive failures: {consecutive_failures})") - if stop_event.wait(backoff_delay): # Use wait with timeout instead of sleep - break # Exit if stop_event is set during backoff - except Exception as e: - logger.error(f"Error in snapshot_reader thread for camera {camera_id}: {str(e)}", exc_info=True) - finally: - logger.info(f"Snapshot reader thread for camera {camera_id} is exiting") - - async def reconcile_subscriptions(desired_subscriptions, websocket): - """ - Declarative reconciliation: Compare desired vs current subscriptions and make changes - """ - logger.info(f"Reconciling subscriptions: {len(desired_subscriptions)} desired") - - with streams_lock: - # Get current subscriptions - current_subscription_ids = set(streams.keys()) - desired_subscription_ids = set(sub["subscriptionIdentifier"] for sub in desired_subscriptions) - - # Find what to add and remove - to_add = desired_subscription_ids - current_subscription_ids - to_remove = current_subscription_ids - desired_subscription_ids - to_check_for_changes = current_subscription_ids & desired_subscription_ids - - logger.info(f"Reconciliation: {len(to_add)} to add, {len(to_remove)} to remove, {len(to_check_for_changes)} to check for changes") - - # Remove subscriptions that are no longer wanted - for subscription_id in to_remove: - await unsubscribe_internal(subscription_id) - - # Check existing subscriptions for parameter changes - for subscription_id in to_check_for_changes: - desired_sub = next(sub for sub in desired_subscriptions if sub["subscriptionIdentifier"] == subscription_id) - current_stream = streams[subscription_id] - - # Check if parameters changed - if has_subscription_changed(desired_sub, current_stream): - logger.info(f"Parameters changed for {subscription_id}, resubscribing") - logger.debug(f"Parameter comparison for {subscription_id}:") - logger.debug(f" rtspUrl: '{desired_sub.get('rtspUrl')}' vs '{current_stream.get('rtsp_url')}'") - logger.debug(f" snapshotUrl: '{desired_sub.get('snapshotUrl')}' vs '{current_stream.get('snapshot_url')}'") - logger.debug(f" modelUrl: '{extract_model_file_identifier(desired_sub.get('modelUrl'))}' vs '{extract_model_file_identifier(current_stream.get('modelUrl'))}'") - logger.debug(f" modelId: {desired_sub.get('modelId')} vs {current_stream.get('modelId')}") - - # Preserve detection state for resubscription - cached_detection = cached_detections.get(subscription_id) - logger.debug(f"Preserving detection state for resubscription: {cached_detection is not None}") - - await unsubscribe_internal(subscription_id, preserve_detection=True) - await subscribe_internal(desired_sub, websocket, cached_detection=cached_detection) - - # Add new subscriptions - for subscription_id in to_add: - desired_sub = next(sub for sub in desired_subscriptions if sub["subscriptionIdentifier"] == subscription_id) - await subscribe_internal(desired_sub, websocket) - - def extract_model_file_identifier(model_url): - """Extract the core model file identifier from S3 URLs, ignoring timestamp parameters""" - if not model_url: - return None - - # For S3 URLs, extract just the path portion before query parameters - try: - from urllib.parse import urlparse - parsed = urlparse(model_url) - # Return the path which contains the actual model file identifier - # e.g. "/adsist-cms-staging/models/bangchak_poc-1756312318569.mpta" - return parsed.path - except Exception as e: - logger.warning(f"Failed to parse model URL {model_url}: {e}") - return model_url - - def has_subscription_changed(desired_sub, current_stream): - """Check if subscription parameters have changed""" - # Smart model URL comparison - ignore timestamp changes in signed URLs - desired_model_id = extract_model_file_identifier(desired_sub.get("modelUrl")) - current_model_id = extract_model_file_identifier(current_stream.get("modelUrl")) - - return ( - desired_sub.get("rtspUrl") != current_stream.get("rtsp_url") or - desired_sub.get("snapshotUrl") != current_stream.get("snapshot_url") or - desired_sub.get("snapshotInterval") != current_stream.get("snapshot_interval") or - desired_sub.get("cropX1") != current_stream.get("cropX1") or - desired_sub.get("cropY1") != current_stream.get("cropY1") or - desired_sub.get("cropX2") != current_stream.get("cropX2") or - desired_sub.get("cropY2") != current_stream.get("cropY2") or - desired_sub.get("modelId") != current_stream.get("modelId") or - desired_sub.get("modelName") != current_stream.get("modelName") or - desired_model_id != current_model_id - ) - - async def subscribe_internal(subscription, websocket, cached_detection=None): - """Internal subscription logic extracted from original subscribe handler""" - subscriptionIdentifier = subscription.get("subscriptionIdentifier") - rtsp_url = subscription.get("rtspUrl") - snapshot_url = subscription.get("snapshotUrl") - snapshot_interval = subscription.get("snapshotInterval") - model_url = subscription.get("modelUrl") - modelId = subscription.get("modelId") - modelName = subscription.get("modelName") - cropX1 = subscription.get("cropX1") - cropY1 = subscription.get("cropY1") - cropX2 = subscription.get("cropX2") - cropY2 = subscription.get("cropY2") - - # Extract camera_id from subscriptionIdentifier - parts = subscriptionIdentifier.split(';') - if len(parts) != 2: - logger.error(f"Invalid subscriptionIdentifier format: {subscriptionIdentifier}") - return - - display_identifier, camera_identifier = parts - camera_id = subscriptionIdentifier - - # Load model if needed using shared MPTA manager - if model_url: - with models_lock: - if (camera_id not in models) or (modelId not in models[camera_id]): - logger.info(f"Getting shared MPTA for camera {camera_id}, modelId {modelId}") - - # Use shared MPTA manager for optimized downloads - mpta_result = get_or_download_mpta(modelId, model_url, camera_id) - if not mpta_result: - logger.error(f"Failed to get/download MPTA for modelId {modelId}") - return - - shared_extraction_path, local_mpta_file = mpta_result - - # Load pipeline from local MPTA file - model_tree = load_pipeline_from_zip(local_mpta_file, shared_extraction_path) - if model_tree is None: - logger.error(f"Failed to load model {modelId} from shared MPTA") - return - - if camera_id not in models: - models[camera_id] = {} - models[camera_id][modelId] = model_tree - - # Start LPR integration threads after first model is loaded (only once) - global lpr_integration_started - if not lpr_integration_started and hasattr(model_tree, 'get') and model_tree.get('redis_client'): - try: - start_lpr_integration() - lpr_integration_started = True - logger.info("🚀 LPR integration started after first model load") - except Exception as e: - logger.error(f"❌ Failed to start LPR integration: {e}") - - # Create stream (same logic as original) - if camera_id and (rtsp_url or snapshot_url) and len(streams) < max_streams: - camera_url = snapshot_url if snapshot_url else rtsp_url - - # Check if we already have a stream for this camera URL - shared_stream = camera_streams.get(camera_url) - - if shared_stream: - # Reuse existing stream - buffer = shared_stream["buffer"] - stop_event = shared_stream["stop_event"] - thread = shared_stream["thread"] - mode = shared_stream["mode"] - shared_stream["ref_count"] = shared_stream.get("ref_count", 0) + 1 - else: - # Create new stream - buffer = queue.Queue(maxsize=1) - stop_event = threading.Event() - - if snapshot_url and snapshot_interval: - thread = threading.Thread(target=snapshot_reader, args=(camera_id, snapshot_url, snapshot_interval, buffer, stop_event)) - thread.daemon = True - thread.start() - mode = "snapshot" - shared_stream = { - "buffer": buffer, "thread": thread, "stop_event": stop_event, - "mode": mode, "url": snapshot_url, "snapshot_interval": snapshot_interval, "ref_count": 1 - } - camera_streams[camera_url] = shared_stream - elif rtsp_url: - cap = cv2.VideoCapture(rtsp_url) - if not cap.isOpened(): - logger.error(f"Failed to open RTSP stream for camera {camera_id}") - return - thread = threading.Thread(target=frame_reader, args=(camera_id, cap, buffer, stop_event)) - thread.daemon = True - thread.start() - mode = "rtsp" - shared_stream = { - "buffer": buffer, "thread": thread, "stop_event": stop_event, - "mode": mode, "url": rtsp_url, "cap": cap, "ref_count": 1 - } - camera_streams[camera_url] = shared_stream - else: - logger.error(f"No valid URL provided for camera {camera_id}") - return - - # Create stream info - stream_info = { - "buffer": buffer, "thread": thread, "stop_event": stop_event, - "modelId": modelId, "modelName": modelName, "subscriptionIdentifier": subscriptionIdentifier, - "cropX1": cropX1, "cropY1": cropY1, "cropX2": cropX2, "cropY2": cropY2, - "mode": mode, "camera_url": camera_url, "modelUrl": model_url, - # Always store both URLs for comparison consistency - "rtsp_url": rtsp_url, - "snapshot_url": snapshot_url, - "snapshot_interval": snapshot_interval - } - - if mode == "rtsp": - stream_info["cap"] = shared_stream["cap"] - - streams[camera_id] = stream_info - subscription_to_camera[camera_id] = camera_url - logger.info(f"Subscribed to camera {camera_id}") - - # Send initial detection to backend - use cached if available, otherwise "none" - if cached_detection: - # Restore cached detection with updated timestamp (RESUBSCRIPTION STATUS UPDATE) - initial_detection_data = cached_detection.copy() - initial_detection_data["timestamp"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - logger.info(f"📡 RESUBSCRIPTION: Restoring cached detection for camera {camera_id}") - logger.debug(f"📡 RESUBSCRIPTION: Cached detection has sessionId: {initial_detection_data.get('sessionId', 'None')}") - else: - # Send "none" detection for new subscriptions - initial_detection_data = { - "type": "imageDetection", - "subscriptionIdentifier": subscriptionIdentifier, - "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), - "data": { - "detection": None, - "modelId": modelId, - "modelName": modelName - } - } - logger.info(f"📡 NEW SUBSCRIPTION: Sending initial 'none' detection for camera {camera_id}") - - ws_logger.info(f"TX -> {json.dumps(initial_detection_data, separators=(',', ':'))}") - await websocket.send_json(initial_detection_data) - logger.debug(f"Initial detection data sent (resubscription={cached_detection is not None}): {initial_detection_data}") - - # This cached detection was just a one-time status update for resubscription - # Normal frame processing will continue independently - - async def unsubscribe_internal(subscription_id, preserve_detection=False): - """Internal unsubscription logic""" - if subscription_id in streams: - stream = streams.pop(subscription_id) - camera_url = subscription_to_camera.pop(subscription_id, None) - - # Clean up model references for this camera - with models_lock: - if subscription_id in models: - camera_models = models[subscription_id] - for model_id, model_tree in camera_models.items(): - logger.info(f"🧹 Cleaning up model references for camera {subscription_id}, modelId {model_id}") - # Release model registry references - cleanup_pipeline_node(model_tree) - # Release MPTA manager reference - release_mpta(model_id, subscription_id) - del models[subscription_id] - - if camera_url and camera_url in camera_streams: - shared_stream = camera_streams[camera_url] - shared_stream["ref_count"] -= 1 - - if shared_stream["ref_count"] <= 0: - shared_stream["stop_event"].set() - shared_stream["thread"].join() - if "cap" in shared_stream: - shared_stream["cap"].release() - del camera_streams[camera_url] - - latest_frames.pop(subscription_id, None) - if not preserve_detection: - cached_detections.pop(subscription_id, None) # Clear cached detection only if not preserving - frame_skip_flags.pop(subscription_id, None) # Clear frame skip flag - camera_states.pop(subscription_id, None) # Clear camera state - cached_full_pipeline_results.pop(subscription_id, None) # Clear cached pipeline results - session_pipeline_states.pop(subscription_id, None) # Clear session pipeline state - cleanup_camera_stability(subscription_id) - logger.info(f"Unsubscribed from camera {subscription_id} (preserve_detection={preserve_detection})") + time.sleep(reconnect_interval) + retries += 1 + if retries > max_retries: + logging.error(f"Max retries reached after OpenCV error for camera: {camera_id}") + break + # Re-open the VideoCapture + cap = cv2.VideoCapture(streams[camera_id]['rtsp_url']) + if not cap.isOpened(): + logging.error(f"Failed to reopen RTSP stream for camera {camera_id} after OpenCV error") + continue + except Exception as e: + logging.error(f"Unexpected error for camera {camera_id}: {e}") + cap.release() + break async def process_streams(): - logger.info("Started processing streams") + global model, class_names # Added line + logging.info("Started processing streams") try: while True: start_time = time.time() - with streams_lock: - current_streams = list(streams.items()) - if current_streams: - logger.debug(f"Processing {len(current_streams)} active streams") - else: - logger.debug("No active streams to process") - - for camera_id, stream in current_streams: - buffer = stream["buffer"] - if buffer.empty(): - logger.debug(f"Frame buffer is empty for camera {camera_id}") - continue - - logger.debug(f"Got frame from buffer for camera {camera_id}") - frame = buffer.get() - - # Cache the frame for REST API access - latest_frames[camera_id] = frame.copy() - logger.debug(f"Cached frame for REST API access for camera {camera_id}") - - with models_lock: - model_tree = models.get(camera_id, {}).get(stream["modelId"]) - if not model_tree: - logger.warning(f"Model not found for camera {camera_id}, modelId {stream['modelId']}") - continue - logger.debug(f"Found model tree for camera {camera_id}, modelId {stream['modelId']}") - - key = (camera_id, stream["modelId"]) - persistent_data = persistent_data_dict.get(key, {}) - logger.debug(f"Starting detection for camera {camera_id} with modelId {stream['modelId']}") - updated_persistent_data = await handle_detection( - camera_id, stream, frame, websocket, model_tree, persistent_data - ) - persistent_data_dict[key] = updated_persistent_data - - elapsed_time = (time.time() - start_time) * 1000 # ms + # Round-robin processing + for camera_id, stream in list(streams.items()): + buffer = stream['buffer'] + if not buffer.empty(): + frame = buffer.get() + results = model(frame, stream=False) + boxes = [] + for r in results: + for box in r.boxes: + boxes.append({ + "class": class_names[int(box.cls[0])], + "confidence": float(box.conf[0]), + }) + # Broadcast to all subscribers of this URL + detection_data = { + "type": "imageDetection", + "cameraIdentifier": camera_id, + "timestamp": time.time(), + "data": { + "detections": boxes, + "modelId": stream['modelId'], + "modelName": stream['modelName'] + } + } + logging.debug(f"Sending detection data for camera {camera_id}: {detection_data}") + await websocket.send_json(detection_data) + elapsed_time = (time.time() - start_time) * 1000 # in ms sleep_time = max(poll_interval - elapsed_time, 0) - logger.debug(f"Frame processing cycle: {elapsed_time:.2f}ms, sleeping for: {sleep_time:.2f}ms") + logging.debug(f"Elapsed time: {elapsed_time}ms, sleeping for: {sleep_time}ms") await asyncio.sleep(sleep_time / 1000.0) except asyncio.CancelledError: - logger.info("Stream processing task cancelled") + logging.info("Stream processing task cancelled") except Exception as e: - logger.error(f"Error in process_streams: {str(e)}", exc_info=True) + logging.error(f"Error in process_streams: {e}") async def send_heartbeat(): while True: @@ -1917,27 +154,22 @@ async def detect(websocket: WebSocket): cpu_usage = psutil.cpu_percent() memory_usage = psutil.virtual_memory().percent if torch.cuda.is_available(): - gpu_usage = torch.cuda.utilization() if hasattr(torch.cuda, 'utilization') else None - gpu_memory_usage = torch.cuda.memory_reserved() / (1024 ** 2) + gpu_usage = torch.cuda.memory_allocated() / (1024 ** 2) # Convert to MB + gpu_memory_usage = torch.cuda.memory_reserved() / (1024 ** 2) # Convert to MB else: gpu_usage = None gpu_memory_usage = None - + camera_connections = [ { - "subscriptionIdentifier": stream["subscriptionIdentifier"], - "modelId": stream["modelId"], - "modelName": stream["modelName"], - "online": True, - # Include all subscription parameters for proper change detection - "rtspUrl": stream.get("rtsp_url"), - "snapshotUrl": stream.get("snapshot_url"), - "snapshotInterval": stream.get("snapshot_interval"), - **{k: v for k, v in get_crop_coords(stream).items() if v is not None} + "cameraIdentifier": camera_id, + "modelId": stream['modelId'], + "modelName": stream['modelName'], + "online": True } for camera_id, stream in streams.items() ] - + state_report = { "type": "stateReport", "cpuUsage": cpu_usage, @@ -1947,378 +179,202 @@ async def detect(websocket: WebSocket): "cameraConnections": camera_connections } await websocket.send_text(json.dumps(state_report)) - logger.debug(f"Sent stateReport as heartbeat: CPU {cpu_usage:.1f}%, Memory {memory_usage:.1f}%, {len(camera_connections)} active cameras") + logging.debug("Sent stateReport as heartbeat") await asyncio.sleep(HEARTBEAT_INTERVAL) except Exception as e: - logger.error(f"Error sending stateReport heartbeat: {e}") + logging.error(f"Error sending stateReport heartbeat: {e}") break async def on_message(): + global model, class_names # Changed from nonlocal to global + while True: + msg = await websocket.receive_text() + logging.debug(f"Received message: {msg}") + data = json.loads(msg) + msg_type = data.get("type") + + if msg_type == "subscribe": + payload = data.get("payload", {}) + camera_id = payload.get("cameraIdentifier") + rtsp_url = payload.get("rtspUrl") + model_url = payload.get("modelUrl") + modelId = payload.get("modelId") + modelName = payload.get("modelName") + + if model_url: + print(f"Downloading model from {model_url}") + parsed_url = urlparse(model_url) + filename = os.path.basename(parsed_url.path) + model_filename = os.path.join("models", filename) + # Download the model + response = requests.get(model_url, stream=True) + if response.status_code == 200: + with open(model_filename, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + logging.info(f"Downloaded model from {model_url} to {model_filename}") + model = YOLO(model_filename) + if torch.cuda.is_available(): + model.to('cuda') + class_names = model.names + else: + logging.error(f"Failed to download model from {model_url}") + continue + if camera_id and rtsp_url: + if camera_id not in streams and len(streams) < max_streams: + cap = cv2.VideoCapture(rtsp_url) + if not cap.isOpened(): + logging.error(f"Failed to open RTSP stream for camera {camera_id}") + continue + buffer = queue.Queue(maxsize=1) + stop_event = threading.Event() + thread = threading.Thread(target=frame_reader, args=(camera_id, cap, buffer, stop_event)) + thread.daemon = True + thread.start() + streams[camera_id] = { + 'cap': cap, + 'buffer': buffer, + 'thread': thread, + 'rtsp_url': rtsp_url, + 'stop_event': stop_event, + 'modelId': modelId, + 'modelName': modelName + } + logging.info(f"Subscribed to camera {camera_id} with modelId {modelId}, modelName {modelName} and URL {rtsp_url}") + elif camera_id and camera_id in streams: + stream = streams.pop(camera_id) + stream['cap'].release() + logging.info(f"Unsubscribed from camera {camera_id}") + elif msg_type == "unsubscribe": + payload = data.get("payload", {}) + camera_id = payload.get("cameraIdentifier") + if camera_id and camera_id in streams: + stream = streams.pop(camera_id) + stream['cap'].release() + logging.info(f"Unsubscribed from camera {camera_id}") + elif msg_type == "requestState": + # Handle state request + cpu_usage = psutil.cpu_percent() + memory_usage = psutil.virtual_memory().percent + if torch.cuda.is_available(): + gpu_usage = torch.cuda.memory_allocated() / (1024 ** 2) # Convert to MB + gpu_memory_usage = torch.cuda.memory_reserved() / (1024 ** 2) # Convert to MB + else: + gpu_usage = None + gpu_memory_usage = None + + camera_connections = [ + { + "cameraIdentifier": camera_id, + "modelId": stream['modelId'], + "modelName": stream['modelName'], + "online": True + } + for camera_id, stream in streams.items() + ] + + state_report = { + "type": "stateReport", + "cpuUsage": cpu_usage, + "memoryUsage": memory_usage, + "gpuUsage": gpu_usage, + "gpuMemoryUsage": gpu_memory_usage, + "cameraConnections": camera_connections + } + await websocket.send_text(json.dumps(state_report)) + else: + logging.error(f"Unknown message type: {msg_type}") + + await websocket.accept() + task = asyncio.create_task(process_streams()) + heartbeat_task = asyncio.create_task(send_heartbeat()) + message_task = asyncio.create_task(on_message()) + + await asyncio.gather(heartbeat_task, message_task) + + model = None + model_path = None + + try: while True: try: msg = await websocket.receive_text() - ws_logger.info(f"RX <- {msg}") - logger.debug(f"Received message: {msg}") + logging.debug(f"Received message: {msg}") data = json.loads(msg) - msg_type = data.get("type") - - if msg_type == "setSubscriptionList": - # Declarative approach: Backend sends list of subscriptions this worker should have - desired_subscriptions = data.get("subscriptions", []) - logger.info(f"Received subscription list with {len(desired_subscriptions)} subscriptions") - - await reconcile_subscriptions(desired_subscriptions, websocket) - - elif msg_type == "subscribe": - # Legacy support - convert single subscription to list - payload = data.get("payload", {}) - await reconcile_subscriptions([payload], websocket) - - elif msg_type == "unsubscribe": - # Legacy support - remove subscription - payload = data.get("payload", {}) - subscriptionIdentifier = payload.get("subscriptionIdentifier") - # Remove from current subscriptions and reconcile - current_subs = [] - with streams_lock: - for camera_id, stream in streams.items(): - if stream["subscriptionIdentifier"] != subscriptionIdentifier: - # Convert stream back to subscription format - current_subs.append({ - "subscriptionIdentifier": stream["subscriptionIdentifier"], - "rtspUrl": stream.get("rtsp_url"), - "snapshotUrl": stream.get("snapshot_url"), - "snapshotInterval": stream.get("snapshot_interval"), - "modelId": stream["modelId"], - "modelName": stream["modelName"], - "modelUrl": stream.get("modelUrl", ""), - "cropX1": stream.get("cropX1"), - "cropY1": stream.get("cropY1"), - "cropX2": stream.get("cropX2"), - "cropY2": stream.get("cropY2") - }) - await reconcile_subscriptions(current_subs, websocket) - - elif msg_type == "unsubscribe": - payload = data.get("payload", {}) - subscriptionIdentifier = payload.get("subscriptionIdentifier") - camera_id = subscriptionIdentifier - with streams_lock: - if camera_id and camera_id in streams: - stream = streams.pop(camera_id) - camera_url = subscription_to_camera.pop(camera_id, None) - - if camera_url and camera_url in camera_streams: - shared_stream = camera_streams[camera_url] - shared_stream["ref_count"] -= 1 - - # If no more references, stop the shared stream - if shared_stream["ref_count"] <= 0: - logger.info(f"Stopping shared stream for camera URL: {camera_url}") - shared_stream["stop_event"].set() - shared_stream["thread"].join() - if "cap" in shared_stream: - shared_stream["cap"].release() - del camera_streams[camera_url] - else: - logger.info(f"Shared stream for {camera_url} still has {shared_stream['ref_count']} references") - - # Clean up cached frame and stability tracking - latest_frames.pop(camera_id, None) - cached_detections.pop(camera_id, None) # Clear cached detection - frame_skip_flags.pop(camera_id, None) # Clear frame skip flag - camera_states.pop(camera_id, None) # Clear camera state - 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": - cpu_usage = psutil.cpu_percent() - memory_usage = psutil.virtual_memory().percent - if torch.cuda.is_available(): - gpu_usage = torch.cuda.utilization() if hasattr(torch.cuda, 'utilization') else None - gpu_memory_usage = torch.cuda.memory_reserved() / (1024 ** 2) + camera_id = data.get("cameraIdentifier") + rtsp_url = data.get("rtspUrl") + model_url = data.get("modelUrl") + modelId = data.get("modelId") + modelName = data.get("modelName") + + if model_url: + print(f"Downloading model from {model_url}") + parsed_url = urlparse(model_url) + filename = os.path.basename(parsed_url.path) + model_filename = os.path.join("models", filename) + # Download the model + response = requests.get(model_url, stream=True) + if response.status_code == 200: + with open(model_filename, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + logging.info(f"Downloaded model from {model_url} to {model_filename}") + model = YOLO(model_filename) + if torch.cuda.is_available(): + model.to('cuda') + class_names = model.names else: - gpu_usage = None - gpu_memory_usage = None - - camera_connections = [ - { - "subscriptionIdentifier": stream["subscriptionIdentifier"], - "modelId": stream["modelId"], - "modelName": stream["modelName"], - "online": True, - # Include all subscription parameters for proper change detection - "rtspUrl": stream.get("rtsp_url"), - "snapshotUrl": stream.get("snapshot_url"), - "snapshotInterval": stream.get("snapshot_interval"), - **{k: v for k, v in get_crop_coords(stream).items() if v is not None} + logging.error(f"Failed to download model from {model_url}") + continue + if camera_id and rtsp_url: + if camera_id not in streams and len(streams) < max_streams: + cap = cv2.VideoCapture(rtsp_url) + if not cap.isOpened(): + logging.error(f"Failed to open RTSP stream for camera {camera_id}") + continue + buffer = queue.Queue(maxsize=1) + stop_event = threading.Event() + thread = threading.Thread(target=frame_reader, args=(camera_id, cap, buffer, stop_event)) + thread.daemon = True + thread.start() + streams[camera_id] = { + 'cap': cap, + 'buffer': buffer, + 'thread': thread, + 'rtsp_url': rtsp_url, + 'stop_event': stop_event, + 'modelId': modelId, + 'modelName': modelName } - for camera_id, stream in streams.items() - ] - - state_report = { - "type": "stateReport", - "cpuUsage": cpu_usage, - "memoryUsage": memory_usage, - "gpuUsage": gpu_usage, - "gpuMemoryUsage": gpu_memory_usage, - "cameraConnections": camera_connections - } - await websocket.send_text(json.dumps(state_report)) - - elif msg_type == "setSessionId": - payload = data.get("payload", {}) - display_identifier = payload.get("displayIdentifier") - session_id = payload.get("sessionId") - - # Debug sessionId value types and contents - session_id_type = type(session_id).__name__ - if session_id is None: - logger.info(f"🆔 BACKEND SESSIONID RECEIVED: displayId={display_identifier}, sessionId=None (type: {session_id_type})") - logger.info(f"🔄 BACKEND WANTS TO CLEAR SESSION for display {display_identifier}") - elif session_id == "null": - logger.info(f"🆔 BACKEND SESSIONID RECEIVED: displayId={display_identifier}, sessionId='null' (type: {session_id_type})") - logger.info(f"🔄 BACKEND SENT STRING 'null' for display {display_identifier}") - elif session_id == "": - logger.info(f"🆔 BACKEND SESSIONID RECEIVED: displayId={display_identifier}, sessionId='' (empty string, type: {session_id_type})") - logger.info(f"🔄 BACKEND SENT EMPTY STRING for display {display_identifier}") - else: - logger.info(f"🆔 BACKEND SESSIONID RECEIVED: displayId={display_identifier}, sessionId='{session_id}' (type: {session_id_type}, length: {len(str(session_id))})") - logger.info(f"🔄 BACKEND CREATED/UPDATED SESSION: {session_id} for display {display_identifier}") - - logger.debug(f"Full setSessionId payload: {payload}") - logger.debug(f"WebSocket message raw data: {json.dumps(data, indent=2)}") - logger.debug(f"Current active cameras: {list(streams.keys())}") - - if display_identifier: - # Store session ID for this display - if session_id is None or session_id == "null" or session_id == "": - old_session_id = session_ids.get(display_identifier) - session_ids.pop(display_identifier, None) - - if session_id is None: - logger.info(f"🚫 BACKEND ENDED SESSION: Cleared session ID for display {display_identifier} (was: {old_session_id}) - received None") - elif session_id == "null": - logger.info(f"🚫 BACKEND ENDED SESSION: Cleared session ID for display {display_identifier} (was: {old_session_id}) - received string 'null'") - elif session_id == "": - logger.info(f"🚫 BACKEND ENDED SESSION: Cleared session ID for display {display_identifier} (was: {old_session_id}) - received empty string") - - logger.debug(f"Session IDs after clearing: {session_ids}") - - # Reset tracking state for all cameras associated with this display - with streams_lock: - affected_cameras = [] - for camera_id, stream in streams.items(): - if stream["subscriptionIdentifier"].startswith(display_identifier + ";"): - affected_cameras.append(camera_id) - # Import here to avoid circular import - from siwatsystem.pympta import reset_tracking_state - model_id = stream.get("modelId", "unknown") - reset_tracking_state(camera_id, model_id, "backend session ended") - - - logger.info(f"Reset tracking for camera {camera_id} (display: {display_identifier})") - logger.debug(f"Reset tracking for {len(affected_cameras)} cameras: {affected_cameras}") - else: - old_session_id = session_ids.get(display_identifier) - session_ids[display_identifier] = session_id - logger.info(f"✅ BACKEND SESSION STARTED: Set session ID {session_id} for display {display_identifier} (previous: {old_session_id})") - logger.debug(f"Session IDs after update: {session_ids}") - logger.debug(f"🎯 CMS Backend created sessionId {session_id} after receiving detection data") - - # 🔑 LPR Integration: Retroactively cache the last detection by this new session_id - session_id_str = str(session_id) - logger.info(f"🔑 LPR: Attempting to retroactively cache detection for session_id {session_id_str}") - - # Find cameras associated with this display - display_cameras = [] - with streams_lock: - for camera_id, stream in streams.items(): - if stream["subscriptionIdentifier"].startswith(display_identifier + ";"): - display_cameras.append(camera_id) - - logger.debug(f"🔍 Found {len(display_cameras)} cameras for display {display_identifier}: {display_cameras}") - - # Cache the most recent detection for each camera by the new session_id - cached_count = 0 - for camera_id in display_cameras: - if camera_id in cached_detections: - detection_data = cached_detections[camera_id].copy() - - # Add sessionId to the detection data - detection_data['sessionId'] = session_id - - # Cache by session_id for LPR lookup - session_detections[session_id_str] = detection_data - session_to_camera[session_id_str] = camera_id - detection_timestamps[session_id_str] = time.time() - cached_count += 1 - - logger.info(f"✅ LPR: Cached detection for session_id {session_id_str} -> camera {camera_id}") - logger.debug(f"🔍 Detection data: {detection_data.get('data', {}).get('detection', {})}") - else: - logger.debug(f"⚠️ No cached detection available for camera {camera_id}") - - if cached_count > 0: - logger.info(f"🎉 LPR: Successfully cached {cached_count} detection(s) for session_id {session_id_str}") - logger.info(f"📊 Total LPR sessions now cached: {len(session_detections)}") - else: - logger.warning(f"⚠️ LPR: No detections could be cached for session_id {session_id_str}") - logger.warning(f" Display cameras: {display_cameras}") - logger.warning(f" Available cached detections: {list(cached_detections.keys())}") - - # Clear waiting state for cameras associated with this display - with streams_lock: - affected_cameras = [] - for camera_id, stream in streams.items(): - if stream["subscriptionIdentifier"].startswith(display_identifier + ";"): - affected_cameras.append(camera_id) - from siwatsystem.pympta import get_camera_stability_data - model_id = stream.get("modelId", "unknown") - stability_data = get_camera_stability_data(camera_id, model_id) - session_state = stability_data["session_state"] - if session_state.get("waiting_for_backend_session", False): - session_state["waiting_for_backend_session"] = False - session_state["wait_start_time"] = 0.0 - logger.info(f"🚀 PIPELINE UNBLOCKED: Backend sessionId {session_id} received - camera {camera_id} can proceed with database operations") - logger.debug(f"📋 Camera {camera_id}: SessionId {session_id} now available for future database operations") - logger.debug(f"Updated session state for {len(affected_cameras)} cameras: {affected_cameras}") - else: - logger.warning(f"🚨 Invalid setSessionId message: missing displayIdentifier in payload") - - elif msg_type == "patchSession": - session_id = data.get("sessionId") - patch_data = data.get("data", {}) - - # For now, just acknowledge the patch - actual implementation depends on backend requirements - response = { - "type": "patchSessionResult", - "payload": { - "sessionId": session_id, - "success": True, - "message": "Session patch acknowledged" - } - } - ws_logger.info(f"TX -> {json.dumps(response, separators=(',', ':'))}") - await websocket.send_json(response) - logger.info(f"Acknowledged patch for session {session_id}") - - elif msg_type == "setProgressionStage": - payload = data.get("payload", {}) - display_identifier = payload.get("displayIdentifier") - progression_stage = payload.get("progressionStage") - - logger.info(f"🏁 PROGRESSION STAGE RECEIVED: displayId={display_identifier}, stage={progression_stage}") - - if display_identifier: - # Find all cameras associated with this display - with streams_lock: - affected_cameras = [] - for camera_id, stream in streams.items(): - if stream["subscriptionIdentifier"].startswith(display_identifier + ";"): - affected_cameras.append(camera_id) - - logger.debug(f"🎯 Found {len(affected_cameras)} cameras for display {display_identifier}: {affected_cameras}") - - # Handle different progression stages - for camera_id in affected_cameras: - pipeline_state = get_or_init_session_pipeline_state(camera_id) - current_mode = pipeline_state.get("mode", "validation_detecting") - - if progression_stage == "car_fueling": - # Situation 2: Stop YOLO inference, continue sending cached detection dict - if current_mode == "lightweight": - pipeline_state["yolo_inference_enabled"] = False - pipeline_state["progression_stage"] = "car_fueling" - logger.info(f"⏸️ Camera {camera_id}: YOLO inference DISABLED for car_fueling stage (still sending cached detection dict)") - else: - logger.debug(f"📊 Camera {camera_id}: car_fueling received but not in lightweight mode (mode: {current_mode})") - - elif progression_stage == "car_waitpayment": - # Resume YOLO inference for absence counter - pipeline_state["yolo_inference_enabled"] = True - pipeline_state["progression_stage"] = "car_waitpayment" - logger.info(f"▶️ Camera {camera_id}: YOLO inference RE-ENABLED for car_waitpayment stage") - - elif progression_stage == "welcome": - # Ignore welcome messages during car_waitpayment as per requirement - current_progression = pipeline_state.get("progression_stage") - if current_progression == "car_waitpayment": - logger.info(f"🚫 Camera {camera_id}: IGNORING welcome stage (currently in car_waitpayment)") - else: - pipeline_state["progression_stage"] = "welcome" - logger.info(f"🎉 Camera {camera_id}: Progression stage set to welcome") - - elif progression_stage in ["car_wait_staff"]: - pipeline_state["progression_stage"] = progression_stage - logger.info(f"📋 Camera {camera_id}: Progression stage set to {progression_stage}") - else: - logger.warning(f"🚨 Invalid setProgressionStage message: missing displayIdentifier in payload") - - else: - logger.error(f"Unknown message type: {msg_type}") + logging.info(f"Subscribed to camera {camera_id} with modelId {modelId}, modelName {modelName} and URL {rtsp_url}") + elif camera_id and camera_id in streams: + stream = streams.pop(camera_id) + stream['cap'].release() + logging.info(f"Unsubscribed from camera {camera_id}") + elif data.get("command") == "stop": + logging.info("Received stop command") + break except json.JSONDecodeError: - logger.error("Received invalid JSON message") + logging.error("Received invalid JSON message") except (WebSocketDisconnect, ConnectionClosedError) as e: - logger.warning(f"WebSocket disconnected: {e}") - break + logging.warning(f"WebSocket disconnected: {e}") + break except Exception as e: - logger.error(f"Error handling message: {e}") + logging.error(f"Error handling message: {e}") break - try: - await websocket.accept() - stream_task = asyncio.create_task(process_streams()) - heartbeat_task = asyncio.create_task(send_heartbeat()) - message_task = asyncio.create_task(on_message()) - await asyncio.gather(heartbeat_task, message_task) except Exception as e: - logger.error(f"Error in detect websocket: {e}") + logging.error(f"Unexpected error in WebSocket connection: {e}") finally: - stream_task.cancel() - await stream_task - with streams_lock: - # Clean up shared camera streams - for camera_url, shared_stream in camera_streams.items(): - shared_stream["stop_event"].set() - shared_stream["thread"].join() - if "cap" in shared_stream: - shared_stream["cap"].release() - while not shared_stream["buffer"].empty(): - try: - shared_stream["buffer"].get_nowait() - except queue.Empty: - pass - logger.info(f"Released shared camera stream for {camera_url}") - - streams.clear() - camera_streams.clear() - subscription_to_camera.clear() - with models_lock: - # Clean up all model references before clearing models dict - for camera_id, camera_models in models.items(): - for model_id, model_tree in camera_models.items(): - logger.info(f"🧹 Shutdown cleanup: Releasing model {model_id} for camera {camera_id}") - # Release model registry references - cleanup_pipeline_node(model_tree) - # Release MPTA manager reference - release_mpta(model_id, camera_id) - models.clear() - - # Clean up the entire model registry and MPTA manager - # logger.info("🏭 Performing final model registry cleanup...") - # cleanup_registry() - # logger.info("🏭 Performing final MPTA manager cleanup...") - # cleanup_mpta_manager() - - latest_frames.clear() - cached_detections.clear() - frame_skip_flags.clear() - camera_states.clear() - cached_full_pipeline_results.clear() - session_pipeline_states.clear() - session_ids.clear() - # Clean up LPR integration caches - session_detections.clear() - session_to_camera.clear() - detection_timestamps.clear() - logger.info("WebSocket connection closed") + task.cancel() + await task + for camera_id, stream in streams.items(): + stream['stop_event'].set() + stream['thread'].join() + stream['cap'].release() + stream['buffer'].queue.clear() + logging.info(f"Released camera {camera_id} and cleaned up resources") + streams.clear() + if model_path and os.path.exists(model_path): + os.remove(model_path) + logging.info(f"Deleted model file {model_path}") + logging.info("WebSocket connection closed") diff --git a/config.json b/config.json index 5b23cdf..b9ffa8f 100644 --- a/config.json +++ b/config.json @@ -1,7 +1,7 @@ { "poll_interval_ms": 100, - "max_streams": 999, + "max_streams": 5, "target_fps": 2, "reconnect_interval_sec": 5, - "max_retries": -1 + "max_retries": 3 } diff --git a/debug/rtsp_webcam.py b/debug/rtsp_webcam.py deleted file mode 100644 index 4d9f3ae..0000000 --- a/debug/rtsp_webcam.py +++ /dev/null @@ -1,51 +0,0 @@ -import cv2 -import gi -import time - -gi.require_version('Gst', '1.0') -from gi.repository import Gst - -# Initialize GStreamer -Gst.init(None) - -# Open the default webcam -cap = cv2.VideoCapture(0) - -# Define the RTSP pipeline using GStreamer -rtsp_pipeline = ( - "appsrc ! videoconvert ! video/x-raw,format=I420 ! x264enc tune=zerolatency bitrate=2048 speed-preset=ultrafast " - "! rtph264pay config-interval=1 pt=96 ! udpsink host=127.0.0.1 port=8554" -) - -# Create GStreamer pipeline -pipeline = Gst.parse_launch(rtsp_pipeline) -appsrc = pipeline.get_by_name("appsrc") - -# Start streaming -pipeline.set_state(Gst.State.PLAYING) -time.sleep(1) - -while cap.isOpened(): - ret, frame = cap.read() - if not ret: - break - - # Convert frame to I420 format (YUV420) - frame = cv2.cvtColor(frame, cv2.COLOR_BGR2YUV_I420) - data = frame.tobytes() - - # Push frame to GStreamer pipeline - buf = Gst.Buffer.new_allocate(None, len(data), None) - buf.fill(0, data) - appsrc.emit("push-buffer", buf) - - # Display frame locally (optional) - cv2.imshow("RTSP Streaming", frame) - - if cv2.waitKey(1) & 0xFF == ord('q'): - break - -# Cleanup -cap.release() -cv2.destroyAllWindows() -pipeline.set_state(Gst.State.NULL) diff --git a/debug/test_camera_indices.py b/debug/test_camera_indices.py deleted file mode 100644 index f88bc87..0000000 --- a/debug/test_camera_indices.py +++ /dev/null @@ -1,142 +0,0 @@ -#!/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() \ No newline at end of file diff --git a/docs/MasterElection.md b/docs/MasterElection.md deleted file mode 100644 index c5980b8..0000000 --- a/docs/MasterElection.md +++ /dev/null @@ -1,1449 +0,0 @@ -# Master Election Service Specification - Distributed Process Coordination - -## Overview - -The MasterElection service implements a Redis-based distributed leadership election and process coordination system for the CMS backend cluster. This service provides robust master-slave coordination with automatic failover, process registration, and TTL-based cleanup for multi-process backend deployments. - -**Key Architectural Principle**: Redis-based coordination with atomic Lua scripts ensures consistency and prevents split-brain scenarios while providing automatic cleanup through per-entry TTL expiration. - -## Architecture Components - -### Two-Tier Process Coordination - -The system manages two distinct coordination layers: - -1. **Master Election Layer**: Single leader election across all backend processes -2. **Process Registry Layer**: Individual process registration and heartbeat management - -### Leadership Election Pattern - -- **Single Master**: Only one backend process holds master lock at any time -- **Automatic Failover**: Master election triggers immediately when current master fails -- **Heartbeat-Based**: Master must renew lock every 10 seconds or lose leadership -- **Lua Script Atomicity**: All Redis operations use atomic Lua scripts to prevent race conditions -- **Event-Driven Transitions**: Role changes emit events for dependent services integration - -## Core Components - -### MasterElection Class -`cms-backend/services/MasterElection.ts` - -Primary coordination service that handles distributed leadership election and process lifecycle management. - -**Key Responsibilities:** -- Manages master lock acquisition and renewal using atomic Redis operations -- Provides process registration with automatic TTL-based expiration (45 seconds) -- Emits role transition events for dependent service coordination -- Handles slave registration and heartbeat management -- Maintains process-to-channel mapping for message routing - -### Process Management System - -**Process Registration:** -- Each backend process registers with unique UUID-based identifier -- Process metadata includes role, channel name, and capabilities -- TTL-based expiration (45 seconds) with heartbeat renewal -- Automatic cleanup of stale process entries without manual intervention - -**Channel Assignment:** -- Each process gets assigned a unique Redis pub/sub channel -- Channel mapping stored persistently for message routing -- Master process maintains channel-to-process mapping - -## Data Structures - -### MasterElectionEvents -```typescript -interface MasterElectionEvents { - 'master-acquired': () => void; // This process became master - 'master-lost': () => void; // This process lost master status - 'election-started': () => void; // Election process initiated - 'election-completed': (isMaster: boolean) => void; // Election finished - 'slave-registered': (slave: SlaveNode) => void; // New slave joined - 'slave-removed': (nodeId: string) => void; // Slave left/expired - 'error': (error: Error) => void; // Election/coordination errors -} -``` - -### ProcessInfo -```typescript -interface ProcessInfo { - processId: string; // Unique process identifier (UUID) - nodeId: string; // Node identifier (same as processId) - role: 'master' | 'slave'; // Current process role - lastSeen: string; // Last heartbeat timestamp (ISO string) - capabilities: ProcessCapabilities; // Process feature capabilities -} - -// Channel name derived as: `worker:slave:${processInfo.processId}` -``` - -### ProcessCapabilities -```typescript -interface ProcessCapabilities { - canProcessDetections: boolean; // Can handle AI detection processing - maxSubscriptions: number; // Maximum camera subscriptions supported - preferredWorkload: number; // Preferred subscription load (0-100) -} -``` - -### SlaveNode -```typescript -interface SlaveNode { - nodeId: string; // Unique slave node identifier - identifier: string; // Human-readable process identifier - registeredAt: string; // Initial registration timestamp - lastSeen: string; // Last heartbeat timestamp - metadata?: Record; // Optional process metadata -} -``` - -## Redis Data Architecture - -### Master Election Keys -- `master-election:master` - Current master process identifier with TTL lock -- `master-election:heartbeat` - Master heartbeat timestamp for liveness detection -- `master-election:master_process` - Detailed master process information (JSON) - -### Process Registry Keys (TTL-Enabled) -- `master-election:processes` - Hash map of all active processes with per-entry TTL (45s) -- Channel names derived directly from process ID: `worker:slave:{processId}` - no separate mapping needed - -### TTL Configuration -```typescript -// Per-entry TTL using hSetEx for automatic cleanup -PROCESS_TTL = 45; // Process registration expires after 45 seconds -HEARTBEAT_RENEWAL_INTERVAL = 10; // Process heartbeats renew TTL every 10 seconds -MASTER_LOCK_TTL = 30; // Master lock expires after 30 seconds -``` - -### Data Persistence Strategy -Uses **per-entry TTL with hSetEx** for automatic cleanup: -- Process entries automatically expire if heartbeats stop -- No manual cleanup processes required -- Prevents memory leaks from crashed processes -- Self-healing system that maintains only active processes -- Slave information derived from processes with role='slave' - no separate storage needed -- Channel names derived directly from process ID - no mapping table required - -## Master Election Algorithm - -### Election Flow Diagram - -```mermaid -graph TB - subgraph "Election Process" - START[Process Starts] --> ATTEMPT[attemptElection] - ATTEMPT --> ACQUIRE{acquireMasterLock} - - ACQUIRE -->|Success| MASTER[becomeMaster] - ACQUIRE -->|Failed| SLAVE[becomeSlave] - - MASTER --> HEARTBEAT[startHeartbeat] - SLAVE --> REGISTER[registerAsSlave] - - HEARTBEAT --> RENEW{renewMasterLock} - RENEW -->|Success| CONTINUE[Continue as Master] - RENEW -->|Failed| STEPDOWN[Step Down → SLAVE] - - REGISTER --> MONITOR[Monitor Master] - MONITOR --> CHECK{Master Exists?} - CHECK -->|Yes| WAIT[Wait and Monitor] - CHECK -->|No| ATTEMPT - - STEPDOWN --> SLAVE - WAIT --> MONITOR - CONTINUE --> RENEW - end - - subgraph "Atomic Operations" - ACQUIRE --> LUA1[Lua Script: SET master NX + SET heartbeat] - RENEW --> LUA2[Lua Script: Check owner + PEXPIRE + SET heartbeat] - STEPDOWN --> LUA3[Lua Script: Check owner + DEL master + DEL heartbeat] - end -``` - -### Atomic Lock Operations - -#### Master Lock Acquisition -```lua --- Atomic master lock acquisition with heartbeat -if redis.call("SET", KEYS[1], ARGV[1], "NX", "PX", ARGV[2]) then - redis.call("SET", KEYS[2], ARGV[3], "PX", ARGV[2]) - return 1 -else - return 0 -end -``` - -#### Master Lock Renewal -```lua --- Atomic master lock renewal with heartbeat update -if redis.call("GET", KEYS[1]) == ARGV[1] then - redis.call("PEXPIRE", KEYS[1], ARGV[2]) - redis.call("SET", KEYS[2], ARGV[3], "PX", ARGV[2]) - return 1 -else - return 0 -end -``` - -#### Master Lock Release -```lua --- Atomic master lock release -if redis.call("GET", KEYS[1]) == ARGV[1] then - redis.call("DEL", KEYS[1], KEYS[2]) - return 1 -else - return 0 -end -``` - -## Process Lifecycle Management - -### Process Registration Flow - -```mermaid -sequenceDiagram - participant P as Process - participant R as Redis - participant M as Master Process - - Note over P,M: Process Registration with TTL - - P->>+P: Generate UUID processId - P->>+P: Determine role (master/slave) - P->>+P: Assign channel name - - P->>+R: hSetEx(processes, processId, processInfo, {EX: 45}) - R-->>-P: Registration confirmed - - P->>+R: hSet(channels, processId, channelName) - R-->>-P: Channel mapping stored - - alt Process becomes master - P->>+R: set(master_process, processInfo) - R-->>-P: Master process registered - P->>+M: emit('master-acquired') - else Process becomes slave - P->>+R: hSet(slaves, nodeId, slaveInfo) - R-->>-P: Slave registered - P->>+M: emit('slave-registered', slaveInfo) - end - - Note over P,M: Heartbeat Loop (Every 10s) - - loop Every 10 seconds - P->>+P: updateProcessHeartbeat(processId) - P->>+R: hSetEx(processes, processId, updatedInfo, {EX: 45}) - Note over R: TTL renewed for 45 seconds - R-->>-P: Heartbeat recorded - end - - Note over P,M: Automatic Expiration (No heartbeat) - - R->>R: 45 seconds pass without heartbeat - R->>R: Process entry automatically expires - Note over R: No manual cleanup needed -``` - -### Master Election Scenarios - -#### Scenario 1: Initial Startup -```mermaid -sequenceDiagram - participant P1 as Process 1 - participant P2 as Process 2 - participant R as Redis - - Note over P1,R: First Process Startup - - P1->>+P1: attemptElection() - P1->>+R: Lua Script: SET master NX - R-->>-P1: Success (no existing master) - - P1->>+P1: becomeMaster() - P1->>+P1: emit('master-acquired') - P1->>+P1: startHeartbeat() every 10s - - Note over P1,R: Second Process Startup - - P2->>+P2: attemptElection() - P2->>+R: Lua Script: SET master NX - R-->>-P2: Failed (master exists) - - P2->>+P2: becomeSlave() - P2->>+R: hSet(slaves, nodeId, slaveInfo) - P2->>+P2: emit('election-completed', false) -``` - -#### Scenario 2: Master Failure and Failover -```mermaid -sequenceDiagram - participant P1 as Master Process - participant P2 as Slave Process 1 - participant P3 as Slave Process 2 - participant R as Redis - - Note over P1,R: Normal Operation - - P1->>+R: Heartbeat renewal every 10s - P2->>+P2: Monitor master existence every 5s - P3->>+P3: Monitor master existence every 5s - - Note over P1,R: Master Failure - - P1--XP1: Process crashes/network failure - - Note over R: Master lock expires after 30s - - R->>R: Master lock TTL expires - - Note over P2,R: Slave Detects Missing Master - - P2->>+R: checkMasterExists() Lua Script - R-->>-P2: Master not found or stale - - P2->>+P2: Random delay (0-2s) to reduce collisions - P2->>+R: attemptElection() - Lua Script: SET master NX - R-->>-P2: Success - became new master - - P2->>+P2: becomeMaster() - P2->>+P2: emit('master-acquired') - - Note over P3,R: Other Slave Detects New Master - - P3->>+R: checkMasterExists() - R-->>-P3: New master found - P3->>+P3: Continue as slave - no election needed -``` - -## TTL-Based Cleanup System - -### Per-Entry TTL Implementation - -```typescript -// Process registration with automatic TTL expiration -public async registerProcess(processInfo: ProcessInfo): Promise { - // Set process registration with 45 second TTL per entry - await redisClient.hSetEx( - this.processesKey, - { - [processInfo.processId]: JSON.stringify(processInfo) - }, - { - expiration: { - type: 'EX', - value: 45 // 45 second TTL per process entry - } - } - ); - - // Map process to channel (no TTL - cleaned up manually) - await redisClient.hSet( - this.processChannelsKey, - processInfo.processId, - processInfo.channelName - ); -} - -// Heartbeat renewal extends TTL automatically -public async updateProcessHeartbeat(processId: string): Promise { - const processData = await redisClient.hGet(this.processesKey, processId); - if (processData) { - const processInfo: ProcessInfo = JSON.parse(processData); - processInfo.lastSeen = new Date().toISOString(); - - // Update process and renew TTL on heartbeat (per-entry TTL) - await redisClient.hSetEx( - this.processesKey, - { - [processId]: JSON.stringify(processInfo) - }, - { - expiration: { - type: 'EX', - value: 45 // Renew 45 second TTL for this specific process entry - } - } - ); - } -} -``` - -### Cleanup Behavior - -```mermaid -graph TB - subgraph "TTL Cleanup Process" - REG[Process Registration] --> TTL[45s TTL Set] - TTL --> HB{Heartbeat Within 45s?} - - HB -->|Yes| RENEW[TTL Renewed to 45s] - HB -->|No| EXPIRE[Entry Automatically Expires] - - RENEW --> HB - EXPIRE --> GONE[Process Removed from Redis] - - GONE --> DETECT[Other Processes Detect Absence] - DETECT --> REBALANCE[Automatic Rebalancing] - end - - subgraph "Manual vs TTL Cleanup" - MANUAL[Manual Cleanup Process] - AUTOMATIC[TTL-Based Cleanup] - - MANUAL -.->|"❌ Complex"| ISSUES[Race Conditions
Memory Leaks
Stale Data] - AUTOMATIC -.->|"✅ Simple"| BENEFITS[Self-Healing
No Race Conditions
Guaranteed Cleanup] - end -``` - -## Event System Architecture - -### Event Emission Flow - -```mermaid -graph TD - subgraph "Election Events" - START[Election Started] --> ATTEMPT[Attempt Lock Acquisition] - ATTEMPT --> SUCCESS{Lock Acquired?} - - SUCCESS -->|Yes| MASTER[Become Master] - SUCCESS -->|No| SLAVE[Become Slave] - - MASTER --> MASTER_EVENT[emit('master-acquired')] - SLAVE --> SLAVE_EVENT[emit('election-completed', false)] - - MASTER_EVENT --> HEARTBEAT[Start Heartbeat Loop] - SLAVE_EVENT --> MONITOR[Start Master Monitoring] - end - - subgraph "Heartbeat Events" - HEARTBEAT --> RENEW{Renew Lock?} - RENEW -->|Success| CONTINUE[Continue as Master] - RENEW -->|Failed| LOST[emit('master-lost')] - - LOST --> STEPDOWN[Step Down to Slave] - STEPDOWN --> TRIGGER[Trigger New Election] - CONTINUE --> HEARTBEAT - end - - subgraph "Slave Management Events" - SLAVE_JOIN[New Slave Joins] --> SLAVE_REG[emit('slave-registered')] - SLAVE_TIMEOUT[Slave Heartbeat Timeout] --> SLAVE_REM[emit('slave-removed')] - - SLAVE_REG --> NOTIFY[Notify Dependent Services] - SLAVE_REM --> CLEANUP[Cleanup Assignments] - end -``` - -### Event Handler Integration - -```typescript -// Example: Camera module integration with MasterElection events -const masterElection = getMasterElection(); - -masterElection.on('master-acquired', () => { - // This process became master - start managing workers - masterSlaveWorkerCluster.becomeMaster(); - logger.info('Camera cluster: Became master, connecting to workers'); -}); - -masterElection.on('master-lost', () => { - // This process lost master status - become slave - masterSlaveWorkerCluster.becomeSlave(); - logger.info('Camera cluster: Became slave, disconnecting workers'); -}); - -masterElection.on('slave-registered', (slave: SlaveNode) => { - // New backend process joined - rebalance workload - masterSlaveWorkerCluster.handleSlaveJoined(slave); - logger.info(`Camera cluster: Slave ${slave.nodeId} joined`); -}); - -masterElection.on('slave-removed', (nodeId: string) => { - // Backend process left - reassign its workload - masterSlaveWorkerCluster.handleSlaveLeft(nodeId); - logger.info(`Camera cluster: Slave ${nodeId} removed`); -}); -``` - -## Process Coordination Patterns - -### Master Role Responsibilities - -```mermaid -graph TB - subgraph "Master Process Duties" - LOCK[Maintain Master Lock] --> HEARTBEAT[Send Heartbeats Every 10s] - HEARTBEAT --> MONITOR[Monitor All Slave Processes] - - MONITOR --> CLEANUP[Cleanup Stale Slave Entries] - CLEANUP --> BALANCE[Coordinate Resource Balancing] - - BALANCE --> WORKERS[Manage Worker Connections] - WORKERS --> ROUTE[Route Messages to Slaves] - - ROUTE --> STATUS[Provide Cluster Status] - STATUS --> LOCK - end - - subgraph "Master Failure Scenarios" - NETWORK[Network Partition] --> TIMEOUT[Lock Renewal Timeout] - CRASH[Process Crash] --> TIMEOUT - OVERLOAD[Resource Overload] --> TIMEOUT - - TIMEOUT --> EXPIRE[Master Lock Expires] - EXPIRE --> ELECTION[New Election Triggered] - ELECTION --> RECOVER[New Master Elected] - end -``` - -### Slave Role Responsibilities - -```mermaid -graph TB - subgraph "Slave Process Duties" - REGISTER[Register with Master Election] --> HEARTBEAT[Send Heartbeats Every 5s] - HEARTBEAT --> MONITOR[Monitor Master Existence] - - MONITOR --> PROCESS[Process Assigned Messages] - PROCESS --> REPORT[Report Status to Master] - - REPORT --> DETECT{Master Missing?} - DETECT -->|No| MONITOR - DETECT -->|Yes| ELECTION[Trigger Election] - - ELECTION --> ATTEMPT{Win Election?} - ATTEMPT -->|Yes| PROMOTE[Become Master] - ATTEMPT -->|No| CONTINUE[Continue as Slave] - - PROMOTE --> MASTER[Master Role Duties] - CONTINUE --> REGISTER - end -``` - -## Class Responsibilities Overview - -### Core Class Functions - -| Class | Primary Responsibility | Key Methods | Process Type | -|-------|----------------------|-------------|--------------| -| **MasterElection** | Distributed coordination and leadership election | • `start()` - Initialize election process
• `attemptElection()` - Try to acquire master lock
• `becomeMaster()` - Transition to master role
• `becomeSlave()` - Transition to slave role
• `waitForElectionComplete()` - Synchronous election waiting | Both Master & Slave | -| **Process Registry** | Process lifecycle management | • `registerProcess()` - Register with TTL
• `updateProcessHeartbeat()` - Renew TTL
• `getAllProcesses()` - Get active processes
• `getProcessesByRole()` - Filter by master/slave
• `unregisterProcess()` - Manual cleanup | Both Master & Slave | -| **Master Lock Manager** | Atomic lock operations | • `acquireMasterLock()` - Lua script lock acquisition
• `renewMasterLock()` - Lua script lock renewal
• `releaseMasterLock()` - Lua script lock release
• `checkMasterExists()` - Lua script master validation | Both Master & Slave | -| **Slave Management** | Slave registration and monitoring | • `registerAsSlave()` - Register as slave node
• `updateSlaveHeartbeat()` - Update slave status
• `cleanupStaleSlaves()` - Remove expired slaves
• `getSlaves()` - Get all registered slaves | Both Master & Slave | - -## Object Relationship Diagrams - -### Core Class Structure and Dependencies - -```mermaid -classDiagram - class MasterElection { - -nodeId: string - -identifier: string - -isMaster: boolean - -lockTtl: number - -heartbeatInterval: number - +start() - +stop() - +getIsMaster(): boolean - +getNodeId(): string - +waitForElectionComplete(): Promise~boolean~ - -attemptElection() - -acquireMasterLock(): Promise~boolean~ - -renewMasterLock(): Promise~boolean~ - -releaseMasterLock() - -becomeMaster() - -becomeSlave() - -checkMasterExists(): Promise~boolean~ - } - - class ProcessRegistry { - +registerProcess(processInfo) - +updateProcessHeartbeat(processId) - +getAllProcesses(): Promise~ProcessInfo[]~ - +getMasterProcess(): Promise~ProcessInfo~ - +getProcessesByRole(role): Promise~ProcessInfo[]~ - +unregisterProcess(processId) - +getProcessChannel(processId): Promise~string~ - } - - class SlaveManagement { - +registerAsSlave() - +unregisterFromSlaves() - +updateSlaveHeartbeat() - +getSlaves(): Promise~SlaveNode[]~ - +getSlave(nodeId): Promise~SlaveNode~ - +getSlaveCount(): Promise~number~ - -cleanupStaleSlaves() - -startSlaveManagement() - -stopSlaveManagement() - } - - class EventEmitter { - +on(event, listener) - +emit(event, ...args) - +once(event, listener) - +off(event, listener) - } - - MasterElection --|> EventEmitter : extends - MasterElection --* ProcessRegistry : contains - MasterElection --* SlaveManagement : contains - - MasterElection --> Redis : uses for coordination - ProcessRegistry --> Redis : uses hSetEx for TTL - SlaveManagement --> Redis : uses for slave state -``` - -### Redis Operations and Key Management - -```mermaid -graph TB - subgraph "Redis Key Structure" - MASTER[master-election:master
String - Current master ID with TTL] - HEARTBEAT[master-election:heartbeat
String - Master heartbeat timestamp] - MASTER_PROC[master-election:master_process
String - Master ProcessInfo JSON] - - PROCESSES[master-election:processes
Hash - ProcessInfo with per-entry TTL] - CHANNELS[master-election:channels
Hash - ProcessID → Channel mapping] - SLAVES[master-election:slaves
Hash - SlaveNode data] - end - - subgraph "Atomic Operations" - LUA1[Master Acquisition
SET master NX + SET heartbeat] - LUA2[Master Renewal
Check owner + PEXPIRE + SET heartbeat] - LUA3[Master Release
Check owner + DEL master + heartbeat] - LUA4[Master Check
GET master + GET heartbeat + validate TTL] - end - - subgraph "TTL Operations" - HSETEX1[Process Registration
hSetEx with 45s TTL per entry] - HSETEX2[Heartbeat Renewal
hSetEx renews TTL to 45s] - AUTO[Automatic Expiration
Redis removes expired entries] - end - - MASTER --> LUA1 - MASTER --> LUA2 - MASTER --> LUA3 - HEARTBEAT --> LUA1 - HEARTBEAT --> LUA2 - HEARTBEAT --> LUA4 - - PROCESSES --> HSETEX1 - PROCESSES --> HSETEX2 - PROCESSES --> AUTO -``` - -## Method Call Flow Analysis - -### Election and Role Transition Flow - -```mermaid -sequenceDiagram - participant App as Application - participant ME as MasterElection - participant R as Redis - participant Dep as Dependent Services - - Note over App,Dep: Election Initialization - - App->>+ME: start() - ME->>+ME: attemptElection() - ME->>+ME: emit('election-started') - - ME->>+R: Lua Script: acquireMasterLock() - - alt Lock acquired successfully - R-->>-ME: Success (1) - ME->>+ME: becomeMaster() - ME->>+ME: startHeartbeat() - every 10s - ME->>+ME: startSlaveManagement() - ME->>+Dep: emit('master-acquired') - ME->>+ME: emit('election-completed', true) - else Lock acquisition failed - R-->>-ME: Failed (0) - ME->>+ME: becomeSlave() - ME->>+R: hSet(slaves, nodeId, slaveInfo) - ME->>+ME: startPeriodicCheck() - every 5s - ME->>+Dep: emit('election-completed', false) - end - - Note over App,Dep: Heartbeat and Monitoring Loop - - loop Every 10 seconds (Master) / 5 seconds (Slave) - alt Process is Master - ME->>+R: Lua Script: renewMasterLock() - alt Renewal successful - R-->>-ME: Success (1) - ME->>+ME: Continue as master - else Renewal failed - R-->>-ME: Failed (0) - ME->>+ME: becomeSlave() - ME->>+Dep: emit('master-lost') - ME->>+ME: attemptElection() after delay - end - else Process is Slave - ME->>+R: Lua Script: checkMasterExists() - alt Master exists and healthy - R-->>-ME: Master found (1) - ME->>+ME: Continue monitoring - else No master or stale - R-->>-ME: No master (0) - ME->>+ME: attemptElection() with random delay - end - end - end -``` - -### Process Registration and TTL Management Flow - -```mermaid -sequenceDiagram - participant P as Process - participant ME as MasterElection - participant R as Redis - participant Auto as Redis TTL - - Note over P,Auto: Process Registration with TTL - - P->>+ME: registerProcess(processInfo) - - ME->>+R: hSetEx(processes, processId, processInfo, {EX: 45}) - Note over R: Entry set with 45 second TTL - R-->>-ME: Registration confirmed - - ME->>+R: hSet(channels, processId, channelName) - R-->>-ME: Channel mapping stored - - alt Process is master - ME->>+R: set(master_process, processInfo) - R-->>-ME: Master process info stored - end - - ME-->>-P: Registration complete - - Note over P,Auto: Heartbeat Loop (Every 10s) - - loop Every 10 seconds - P->>+ME: updateProcessHeartbeat(processId) - - ME->>+R: hGet(processes, processId) - R-->>-ME: Current process data - - ME->>+ME: Update lastSeen timestamp - - ME->>+R: hSetEx(processes, processId, updatedInfo, {EX: 45}) - Note over R: TTL renewed to 45 seconds - R-->>-ME: Heartbeat recorded - - ME-->>-P: Heartbeat updated - end - - Note over P,Auto: Automatic TTL Expiration (No heartbeat) - - Note over Auto: 45 seconds pass without heartbeat - Auto->>Auto: Process entry automatically expires - Auto->>R: Remove expired entry from hash - - Note over P,Auto: Other processes detect absence - - P->>+ME: getAllProcesses() - ME->>+R: hGetAll(processes) - R-->>-ME: Only active processes returned - Note over ME: Expired process not included - ME-->>-P: Updated process list -``` - -## System Architecture Diagrams - -### Master Election Cluster Architecture - -```mermaid -graph TB - subgraph "Backend Process Cluster" - M[Master Process
Elected Leader
🏆] - S1[Slave Process 1
Follower] - S2[Slave Process 2
Follower] - S3[Slave Process N
Follower] - end - - subgraph "Redis Coordination Layer" - R[(Redis Server)] - subgraph "Election Keys" - MK[master-election:master
Lock with TTL] - HK[master-election:heartbeat
Timestamp] - end - subgraph "Process Registry (TTL)" - PK[master-election:processes
Hash with per-entry TTL] - CK[master-election:channels
Process→Channel mapping] - end - subgraph "Slave Management" - SK[master-election:slaves
Slave registration data] - end - end - - subgraph "Dependent Services" - CAM[Camera Module
MasterSlaveWorkerCluster] - DS[Display Service
WebSocket Cluster] - OTHER[Other Services
...] - end - - M ===|Master Lock
Heartbeat Every 10s| MK - M ===|Timestamp Update| HK - M ===|TTL Registration
Heartbeat Renewal| PK - - S1 <-->|Monitor Master
Every 5s| R - S2 <-->|Monitor Master
Every 5s| R - S3 <-->|Monitor Master
Every 5s| R - - S1 ===|Slave Registration
Heartbeat Every 5s| SK - S2 ===|Slave Registration
Heartbeat Every 5s| SK - S3 ===|Slave Registration
Heartbeat Every 5s| SK - - M -.->|master-acquired
slave-registered
slave-removed| CAM - M -.->|Role transition events| DS - M -.->|Coordination events| OTHER - - S1 -.->|election-completed
master-lost| CAM - S2 -.->|Election events| DS - S3 -.->|Status events| OTHER -``` - -### TTL-Based Cleanup Architecture - -```mermaid -graph TB - subgraph "Process Lifecycle with TTL" - START[Process Starts] --> REG[Register with 45s TTL] - REG --> ACTIVE[Process Active] - - ACTIVE --> HB{Heartbeat?} - HB -->|Every 10s| RENEW[Renew TTL to 45s] - HB -->|Missed| COUNT[Count down TTL] - - RENEW --> ACTIVE - COUNT --> EXPIRE{TTL = 0?} - EXPIRE -->|No| COUNT - EXPIRE -->|Yes| CLEANUP[Redis Auto-Remove] - - CLEANUP --> DETECT[Other Processes Detect] - DETECT --> REBALANCE[Trigger Rebalancing] - end - - subgraph "Traditional Manual Cleanup vs TTL" - subgraph "❌ Manual Cleanup Problems" - RACE[Race Conditions] - LEAK[Memory Leaks] - STALE[Stale Data] - COMPLEX[Complex Logic] - end - - subgraph "✅ TTL-Based Benefits" - AUTO[Automatic Cleanup] - RELIABLE[Reliable Expiration] - SIMPLE[Simple Implementation] - SELF[Self-Healing] - end - end - - subgraph "TTL Management Operations" - HSETEX[hSetEx(key, field, value, {EX: 45})] - RENEWAL[Heartbeat renews TTL automatically] - EXPIRY[Redis removes expired entries] - - HSETEX --> RENEWAL - RENEWAL --> EXPIRY - EXPIRY --> HSETEX - end -``` - -### Election Timing and Coordination - -```mermaid -gantt - title Master Election Timeline - dateFormat X - axisFormat %s - - section Master Lock - Master Lock TTL (30s) :milestone, m1, 0, 0s - Lock Renewal (10s) :10, 20s - Lock Renewal (10s) :20, 30s - Lock Expires :milestone, m2, 30, 30s - - section Process TTL - Process Registration (45s) :milestone, p1, 0, 0s - Heartbeat Renewal (10s) :10, 20s - Heartbeat Renewal (10s) :20, 30s - Heartbeat Renewal (10s) :30, 40s - Process Expires :milestone, p2, 45, 45s - - section Election Events - Initial Election :milestone, e1, 0, 0s - Slave Monitoring (5s) :5, 10s - Slave Monitoring (5s) :10, 15s - Master Failure Detected :milestone, e2, 30, 30s - New Election Started :32, 35s - New Master Elected :milestone, e3, 35, 35s -``` - -## Event System Architecture - -### Event Flow and Dependencies - -```mermaid -graph TD - subgraph "MasterElection Events" - ES[election-started] --> EA{Election Attempt} - EA -->|Success| MA[master-acquired] - EA -->|Failed| EC[election-completed(false)] - - MA --> HB[Start Heartbeat Loop] - EC --> MON[Start Master Monitoring] - - HB --> RENEW{Heartbeat Success?} - RENEW -->|Success| CONT[Continue as Master] - RENEW -->|Failed| ML[master-lost] - - ML --> STEP[Step Down to Slave] - STEP --> MON - - CONT --> HB - MON --> CHECK{Master Missing?} - CHECK -->|Yes| ES - CHECK -->|No| MON - end - - subgraph "Slave Management Events" - SR[slave-registered] --> UP[Update Assignments] - SREM[slave-removed] --> CLEAN[Cleanup Assignments] - - UP --> NOTIFY[Notify Services] - CLEAN --> REBAL[Rebalance Load] - end - - subgraph "Error Handling Events" - ERR[error] --> LOG[Log Error Details] - LOG --> RECOVER[Attempt Recovery] - RECOVER --> ES - end - - subgraph "External Service Integration" - MA -.->|becomeMaster()| CAMERA[Camera Module] - ML -.->|becomeSlave()| CAMERA - SR -.->|slaveJoined()| CAMERA - SREM -.->|slaveLeft()| CAMERA - - MA -.->|Master role| DISPLAY[Display Service] - ML -.->|Slave role| DISPLAY - - MA -.->|Coordinate| OTHER[Other Services] - ML -.->|Follow| OTHER - end -``` - -### Event Sequence Patterns - -#### Master Failure and Recovery Pattern - -```mermaid -sequenceDiagram - participant M as Master Process - participant S1 as Slave 1 - participant S2 as Slave 2 - participant R as Redis - participant Svc as Dependent Services - - Note over M,Svc: Normal Operation - M->>R: Heartbeat renewal every 10s - S1->>R: Monitor master every 5s - S2->>R: Monitor master every 5s - - Note over M,Svc: Master Failure - M--XM: Process crashes - - Note over R: Master lock expires (30s) - R->>R: Lock TTL expires - - Note over S1,S2: Slaves detect master failure - S1->>R: checkMasterExists() → false - S2->>R: checkMasterExists() → false - - Note over S1,S2: Election race with random delay - S1->>S1: Random delay 1.2s - S2->>S2: Random delay 0.8s - - S2->>R: attemptElection() first - R->>S2: Success - became master - S2->>S2: emit('master-acquired') - S2->>Svc: becomeMaster() event - - S1->>R: attemptElection() second - R->>S1: Failed - master exists - S1->>S1: Continue as slave - - Note over S2,Svc: New master operational - S2->>R: Start heartbeat renewal - Svc->>S2: Acknowledge new master -``` - -## Configuration and Tuning - -### Timing Configuration - -```typescript -// MasterElection constructor parameters -interface MasterElectionConfig { - lockName: string = 'master-election'; // Redis key prefix - lockTtl: number = 30000; // Master lock TTL (30 seconds) - heartbeatInterval: number = 10000; // Master heartbeat interval (10 seconds) - checkInterval: number = 5000; // Slave monitoring interval (5 seconds) - identifier: string = 'cms-backend'; // Human-readable process identifier -} - -// TTL Configuration -const PROCESS_TTL_SECONDS = 45; // Process registration TTL -const SLAVE_TIMEOUT_MS = 15000; // Slave cleanup threshold (3x heartbeat) -const ELECTION_RANDOM_DELAY_MAX = 2000; // Max random delay to prevent collisions -``` - -### Redis Key Structure - -```typescript -// Election and coordination keys -const REDIS_KEYS = { - // Master election coordination - master: `${lockName}:master`, // Current master ID with TTL - heartbeat: `${lockName}:heartbeat`, // Master heartbeat timestamp - masterProcess: `${lockName}:master_process`, // Master ProcessInfo JSON - - // Process registry with TTL - processes: `${lockName}:processes`, // Hash: processId → ProcessInfo (TTL per entry) - channels: `${lockName}:channels`, // Hash: processId → channelName - - // Slave management - slaves: `${lockName}:slaves`, // Hash: nodeId → SlaveNode -}; - -// TTL settings -const TTL_CONFIG = { - masterLock: 30, // seconds - Master lock expiration - processEntry: 45, // seconds - Process registration TTL - heartbeatRenewal: 10, // seconds - How often to renew heartbeats - slaveMonitoring: 5, // seconds - How often slaves check master -}; -``` - -### Performance Characteristics - -#### Scalability Metrics -- **Election Speed**: < 100ms for uncontested election -- **Failover Time**: < 5 seconds from master failure to new election -- **Process Registration**: < 10ms per process registration -- **TTL Cleanup**: Automatic, no performance impact on application - -#### Resource Usage -- **Memory**: O(n) where n = number of backend processes -- **Redis Operations**: Atomic Lua scripts prevent race conditions -- **Network**: Minimal - only heartbeats and election attempts -- **CPU**: Negligible overhead for coordination operations - -#### Reliability Guarantees -- **Split-Brain Prevention**: Atomic Lua scripts ensure single master -- **Automatic Recovery**: TTL-based cleanup handles all failure scenarios -- **Event Consistency**: All role transitions emit events for service coordination -- **State Persistence**: Process registry survives Redis restarts - -## Public Interface Specification - -The MasterElection service provides a clean, event-driven interface for distributed coordination across backend processes. - -### Primary Interface: MasterElection Class - -#### Core Lifecycle Methods - -```typescript -/** - * Initialize and start the master election process - * @returns Promise - Resolves when election completes - */ -public async start(): Promise - -/** - * Stop master election and cleanup resources - * @returns Promise - Resolves when cleanup completes - */ -public async stop(): Promise - -/** - * Wait for election to complete with timeout - * @param timeoutMs - Maximum time to wait (default: 30000) - * @returns Promise - true if became master, false if slave - */ -public async waitForElectionComplete(timeoutMs: number = 30000): Promise -``` - -#### Status and Information Methods - -```typescript -/** - * Check if this process is currently the master - * @returns boolean - true if master, false if slave - */ -public getIsMaster(): boolean - -/** - * Get this process's unique node identifier - * @returns string - UUID-based node identifier - */ -public getNodeId(): string - -/** - * Get this process's human-readable identifier - * @returns string - Process identifier (e.g., 'cms-backend') - */ -public getIdentifier(): string - -/** - * Get or set process metadata for coordination - * @param metadata - Optional metadata to set - * @returns Record - Current metadata - */ -public setMetadata(metadata: Record): void -public getMetadata(): Record -``` - -#### Process Registry Methods - -```typescript -/** - * Register a process in the distributed registry with TTL - * @param processInfo - Process information including role and capabilities - * @returns Promise - */ -public async registerProcess(processInfo: ProcessInfo): Promise - -/** - * Update process heartbeat to renew TTL (45 seconds) - * @param processId - Process identifier to update - * @returns Promise - */ -public async updateProcessHeartbeat(processId: string): Promise - -/** - * Get all currently registered processes (auto-filtered by TTL) - * @returns Promise - Array of active processes - */ -public async getAllProcesses(): Promise - -/** - * Get current master process information - * @returns Promise - Master process or null if none - */ -public async getMasterProcess(): Promise - -/** - * Get processes filtered by role - * @param role - 'master' or 'slave' - * @returns Promise - Processes with specified role - */ -public async getProcessesByRole(role: 'master' | 'slave'): Promise -``` - -#### Slave Management Methods - -```typescript -/** - * Get all registered slave nodes - * @returns Promise - Array of active slaves - */ -public async getSlaves(): Promise - -/** - * Get specific slave node information - * @param nodeId - Slave node identifier - * @returns Promise - Slave info or null if not found - */ -public async getSlave(nodeId: string): Promise - -/** - * Get count of registered slave nodes - * @returns Promise - Number of active slaves - */ -public async getSlaveCount(): Promise -``` - -### Event System Interface - -#### Event Registration - -```typescript -// Type-safe event registration -masterElection.on('master-acquired', () => { - // This process became the master - console.log('Became master - start coordinating resources'); -}); - -masterElection.on('master-lost', () => { - // This process lost master status - console.log('Lost master status - step down to slave role'); -}); - -masterElection.on('election-completed', (isMaster: boolean) => { - // Election finished - role determined - console.log(`Election completed - role: ${isMaster ? 'MASTER' : 'SLAVE'}`); -}); - -masterElection.on('slave-registered', (slave: SlaveNode) => { - // New backend process joined cluster - console.log(`New slave joined: ${slave.nodeId}`); -}); - -masterElection.on('slave-removed', (nodeId: string) => { - // Backend process left cluster (TTL expired) - console.log(`Slave removed: ${nodeId}`); -}); - -masterElection.on('error', (error: Error) => { - // Election or coordination error occurred - console.error('Master election error:', error); -}); -``` - -#### Event Timing Guarantees - -- **master-acquired**: Emitted immediately after successful lock acquisition -- **master-lost**: Emitted immediately after failed lock renewal -- **election-completed**: Emitted after initial election resolves (master or slave) -- **slave-registered**: Emitted when new slave joins (master only) -- **slave-removed**: Emitted when slave TTL expires (master only) -- **error**: Emitted on Redis connection issues or election failures - -### Usage Patterns - -#### Basic Initialization and Coordination - -```typescript -import { initialize, getMasterElection } from '~/services/MasterElection'; - -// Initialize master election with custom settings -await initialize( - 'cms-cluster', // lockName - Redis key prefix - 30000, // lockTtl - Master lock TTL (30s) - 10000, // heartbeatInterval - Master heartbeat (10s) - 5000, // checkInterval - Slave monitoring (5s) - 'cms-backend-prod' // identifier - Human-readable name -); - -// Get election instance for event handling -const masterElection = getMasterElection(); - -// Wait for initial election to complete -const isMaster = await masterElection.waitForElectionComplete(); -console.log(`Process started as: ${isMaster ? 'MASTER' : 'SLAVE'}`); -``` - -#### Service Integration Pattern - -```typescript -// Camera module integration example -class CameraClusterService { - private masterElection: MasterElection; - - constructor() { - this.masterElection = getMasterElection(); - this.setupElectionHandlers(); - } - - private setupElectionHandlers() { - // Handle master role transitions - this.masterElection.on('master-acquired', () => { - this.becomeMaster(); - }); - - this.masterElection.on('master-lost', () => { - this.becomeSlave(); - }); - - // Handle cluster membership changes - this.masterElection.on('slave-registered', (slave) => { - this.handleSlaveJoined(slave); - }); - - this.masterElection.on('slave-removed', (nodeId) => { - this.handleSlaveLeft(nodeId); - }); - } - - private async becomeMaster() { - console.log('Camera service: Becoming master'); - - // Connect to all Python ML workers - await this.connectToAllWorkers(); - - // Start managing cluster assignments - this.startClusterManagement(); - - // Begin rebalancing subscriptions - this.startRebalancing(); - } - - private async becomeSlave() { - console.log('Camera service: Becoming slave'); - - // Disconnect from Python workers (master-only) - await this.disconnectFromWorkers(); - - // Stop cluster management - this.stopClusterManagement(); - - // Start listening for routed messages - this.startSlaveMessageHandling(); - } -} -``` - -#### Process Registration with Custom Capabilities - -```typescript -// Register this process with specific capabilities -await masterElection.registerProcess({ - processId: masterElection.getNodeId(), - nodeId: masterElection.getNodeId(), - role: masterElection.getIsMaster() ? 'master' : 'slave', - channelName: `worker:slave:${masterElection.getNodeId()}`, - lastSeen: new Date().toISOString(), - capabilities: { - canProcessDetections: true, // Can handle AI detection callbacks - maxSubscriptions: 100, // Maximum camera subscriptions - preferredWorkload: 80 // Preferred load percentage (0-100) - } -}); - -// Start heartbeat loop to maintain registration -setInterval(async () => { - await masterElection.updateProcessHeartbeat(masterElection.getNodeId()); -}, 10000); // Every 10 seconds -``` - -#### Cluster Monitoring and Status - -```typescript -// Monitor cluster status and health -async function monitorClusterHealth() { - // Get all active processes (TTL-filtered automatically) - const allProcesses = await masterElection.getAllProcesses(); - console.log(`Active processes: ${allProcesses.length}`); - - // Get current master - const masterProcess = await masterElection.getMasterProcess(); - if (masterProcess) { - console.log(`Master: ${masterProcess.processId} (${masterProcess.capabilities.maxSubscriptions} max subscriptions)`); - } - - // Get all slaves - const slaves = await masterElection.getSlaves(); - console.log(`Slaves: ${slaves.length}`); - slaves.forEach(slave => { - console.log(` Slave ${slave.nodeId}: last seen ${slave.lastSeen}`); - }); - - // Check if this process is master - if (masterElection.getIsMaster()) { - console.log('This process is the master - coordinating cluster'); - } else { - console.log('This process is a slave - following master'); - } -} - -// Run monitoring every 30 seconds -setInterval(monitorClusterHealth, 30000); -``` - -#### Graceful Shutdown Pattern - -```typescript -// Graceful shutdown with proper cleanup -process.on('SIGTERM', async () => { - console.log('Shutting down master election...'); - - try { - // Stop election and cleanup resources - await masterElection.stop(); - - // Master automatically releases lock - // Process TTL will expire naturally - // Slaves will detect and trigger new election - - console.log('Master election shutdown complete'); - } catch (error) { - console.error('Error during election shutdown:', error); - } - - process.exit(0); -}); -``` - -### Error Handling and Recovery - -#### Election Failure Scenarios - -```typescript -// Handle various failure modes -masterElection.on('error', (error) => { - console.error('Master election error:', error.message); - - // Common error types: - if (error.message.includes('Redis')) { - // Redis connection issues - console.log('Redis connectivity problem - will retry automatically'); - - } else if (error.message.includes('timeout')) { - // Election timeout - console.log('Election timeout - may indicate network issues'); - - } else if (error.message.includes('lock')) { - // Lock acquisition issues - console.log('Lock contention - normal during elections'); - } - - // Service continues running - election will retry automatically -}); - -// Handle network partitions -masterElection.on('master-lost', () => { - console.log('Lost master status - likely network partition or overload'); - - // Dependent services should gracefully step down - // New election will start automatically after random delay -}); -``` - -#### Recovery Guarantees - -- **Split-Brain Prevention**: Atomic Lua scripts ensure only one master exists -- **Automatic Failover**: New elections triggered immediately when master fails -- **TTL-Based Cleanup**: Processes automatically removed when heartbeats stop -- **State Recovery**: Process registry rebuilds automatically from active heartbeats -- **Event Consistency**: All role changes emit events for service coordination - -### Integration with Dependent Services - -The MasterElection service is designed to coordinate multiple backend services that need distributed leadership: - -#### Camera Module Integration -- Master: Connects to Python ML workers, manages subscriptions -- Slaves: Process routed detection messages, forward commands - -#### Display WebSocket Cluster -- Master: Manages WebSocket connection assignments across processes -- Slaves: Handle assigned display connections, route messages - -#### Database Migration Coordination -- Master: Executes database migrations and schema changes -- Slaves: Wait for master to complete before proceeding - -This specification provides a comprehensive understanding of the MasterElection service's distributed coordination capabilities and integration patterns for multi-process backend systems. \ No newline at end of file diff --git a/docs/WorkerConnection.md b/docs/WorkerConnection.md deleted file mode 100644 index 822c700..0000000 --- a/docs/WorkerConnection.md +++ /dev/null @@ -1,1498 +0,0 @@ -# Worker Connection Architecture Specification - Pure Declarative State Management - -## Overview - -The Camera Module implements a pure declarative architecture for managing connections to Python ML workers. This system uses the database as the single source of truth for desired subscription state, with automatic regeneration and reconciliation providing intelligent camera management, real-time object detection, and AI-powered content selection with automatic load balancing capabilities. - -**Key Architectural Principle**: Database mutations trigger complete state regeneration rather than incremental updates, ensuring consistency and eliminating complex state synchronization issues. - -## Architecture Components - -### Two-Cluster System - -The system consists of two distinct but coordinated clusters: - -1. **Backend Process Cluster**: Multiple CMS backend processes with leader election -2. **Worker Cluster**: Python ML workers for object detection processing - -### Master-Slave WebSocket Architecture - -- **Master Process**: Single elected backend process that maintains WebSocket connections to Python workers -- **Slave Processes**: All other backend processes that handle message routing and processing -- **Message Routing**: Master forwards worker messages to assigned slaves via Redis pub/sub channels -- **MasterElection Integration**: Automated master/slave role management with event-driven transitions -- **Seamless Scaling**: Backend processes can be added/removed without affecting WebSocket connections - -## Core Components - -### DetectorCluster -`cms-backend/modules/camera/services/DetectorCluster.ts` - -Primary interface for camera operations that abstracts the underlying distributed architecture. - -**Key Responsibilities:** -- Routes camera subscription requests through the cluster -- Manages detection callback registration and event emission -- Bridges CameraService with underlying MasterSlaveWorkerCluster -- Provides unified API regardless of master/slave status - -### MasterSlaveWorkerCluster -`cms-backend/modules/camera/services/MasterSlaveWorkerCluster.ts` - -Core distributed cluster implementation that handles declarative state management and worker assignment reconciliation. - -**Master Mode Responsibilities:** -- Maintains WebSocket connections to all Python workers -- Manages desired vs actual subscription state separation -- Implements intelligent global rebalancing algorithm -- Processes automatic reconciliation every 30 seconds -- Responds to slave join/leave events from MasterElection -- Generates fresh pre-signed model URLs for worker assignments - -**Slave Mode Responsibilities:** -- Submits desired subscription state changes to master -- Processes detection results routed from master -- Event-driven role transitions managed by MasterElection -- No direct worker management (delegated to master) - -### DetectorConnection -`cms-backend/modules/camera/services/DetectorConnection.ts` - -Individual WebSocket connection handler for Python workers. - -**Key Features:** -- Connection lifecycle management (connect, disconnect, reconnect) -- Exponential backoff reconnection with 10-second intervals -- Subscription state management and restoration after reconnection -- Real-time heartbeat monitoring with 10-second timeout -- Resource usage tracking (CPU, memory, GPU) - -## Data Structures - -### WorkerConnectionState -```typescript -interface WorkerConnectionState { - url: string; // Worker WebSocket URL - processId: string; // Backend process managing this worker - online: boolean; // Connection status - cpuUsage: number | null; // Worker CPU utilization - memoryUsage: number | null; // Worker memory usage - gpuUsage: number | null; // Worker GPU utilization - gpuMemoryUsage: number | null; // Worker GPU memory usage - subscriptionCount: number; // Active camera subscriptions - subscriptions: string[]; // List of subscription identifiers - lastHeartbeat: string; // Last heartbeat timestamp - connectedAt: string; // Connection established timestamp -} -``` - -### DesiredCameraSubscription -```typescript -interface DesiredCameraSubscription { - subscriptionIdentifier: string; // Format: ${displayId};${cameraId} - rtspUrl: string; // Camera RTSP stream URL - modelId: number; // AI model database ID - modelName: string; // AI model identifier - createdAt: string; // Subscription creation timestamp - - // Snapshot configuration - snapshotUrl?: string; // Optional snapshot endpoint URL - snapshotInterval?: number; // Snapshot interval in milliseconds - - // Image cropping parameters - cropX1?: number; // Crop region top-left X - cropY1?: number; // Crop region top-left Y - cropX2?: number; // Crop region bottom-right X - cropY2?: number; // Crop region bottom-right Y -} -``` - -### ActualCameraSubscription -```typescript -interface ActualCameraSubscription { - subscriptionIdentifier: string; // Format: ${displayId};${cameraId} - assignedWorkerUrl: string; // Worker handling this subscription - modelUrl: string; // AI model presigned URL (1hr TTL) - status: 'active' | 'pending' | 'failed' | 'recovering'; - assignedAt: string; // Worker assignment timestamp - lastSeen: string; // Last activity timestamp -} -``` - -### SlaveState -```typescript -interface SlaveState { - slaveId: string; // Unique slave identifier (process ID) - processId: string; // Backend process ID (same as slaveId) - online: boolean; // Always true (maintained by MasterElection) - workload: number; // Number of assigned workers (calculated) - lastSeen: string; // Last heartbeat from MasterElection - capabilities?: Record; // Metadata from MasterElection -} -``` - -### DetectorWorkerCommand -```typescript -interface DetectorWorkerCommand { - type: DetectorWorkerCommandType; - payload?: { - subscriptionIdentifier: string; - rtspUrl: string; - snapshotUrl?: string; - snapshotInterval?: number; - modelUrl: string; - modelName: string; - modelId: number; - cropX1?: number; - cropY1?: number; - cropX2?: number; - cropY2?: number; - }; -} - -enum DetectorWorkerCommandType { - SUBSCRIBE = "subscribe", - UNSUBSCRIBE = "unsubscribe", - REQUEST_STATE = "requestState", - PATCH_SESSION_RESULT = "patchSessionResult", - SET_SESSION_ID = "setSessionId" -} -``` - -### ImageDetectionResponse -```typescript -interface ImageDetectionResponse { - subscriptionIdentifier: string; - timestamp: Date; - data: { - detection: { - carModel?: string; - carBrand?: string; - carYear?: number; - bodyType?: string; - licensePlateText?: string; - licensePlateType?: string; - }; - modelId: number; - modelName: string; - }; -} -``` - -## Redis Data Architecture - -### Persistent Storage Keys -- `worker:connections` - Worker connection states and health metrics -- `worker:assignments` - Worker-to-slave assignment mappings -- `worker:desired_subscriptions` - Desired camera subscription state (user intent) -- `worker:actual_subscriptions` - Actual worker subscription assignments (system state) -- `master-election:slaves` - Slave registration and heartbeat (managed by MasterElection) - -### Communication Channels -- `worker:slave:{slaveId}` - Individual slave message routing channels -- `worker:messages:upstream` - Worker-to-master communication channel (currently unused) -- `worker:assignments:changed` - Assignment change broadcast notifications -- `worker:master:commands` - Database change notification channel (slaves → master) - -### Data Persistence Strategy -All Redis data uses **manual cleanup only** (no TTL) to ensure: -- Reliable state recovery after process restarts -- Consistent subscription persistence across failovers -- Predictable cleanup during planned maintenance -- Debug visibility into system state history - -## Pure Declarative Architecture - -### Concept Overview -The system implements a pure declarative approach where: -- **Database**: Single source of truth for desired state (Display+Camera+Playlist combinations) -- **Actual State**: What subscriptions are currently running on workers (stored in `worker:actual_subscriptions`) -- **Regeneration**: Master regenerates complete desired state from database on every change notification -- **Reconciliation**: Master continuously reconciles desired vs actual state via global rebalancing - -### Pure Declarative Benefits -- **Database as Truth**: Desired state always derived fresh from database, eliminating state synchronization issues -- **Zero Incremental Updates**: No complex state management, just "regenerate everything on change" -- **Automatic Recovery**: System heals itself by comparing database state vs actual worker state -- **Load Balancing**: Global optimization across all workers and subscriptions -- **Fault Tolerance**: Desired state survives all failures since it's always derived from database -- **Simplicity**: Database mutations just trigger regeneration - no complex command protocols - -### Pure Declarative Flow -```typescript -// Triggered by any database change -async handleDatabaseChange(changeType: string, entityId: string) { - // 1. Any process detects database change - await triggerSubscriptionUpdate(changeType, entityId); - - // 2. Master receives regeneration request - async handleMasterCommand(message) { - if (data.type === 'regenerate_subscriptions') { - await regenerateDesiredStateFromDatabase(); - } - } - - // 3. Master regenerates complete desired state from database - async regenerateDesiredStateFromDatabase() { - const activeDisplays = await db.display.findMany({ - where: { - AND: [ - { cameraIdentifier: { not: null } }, - { playlistId: { not: null } } - ] - }, - include: { camera: true, playlist: { include: { model: true } } } - }); - - // Generate fresh desired subscriptions from database - await storeDesiredSubscriptions(generateFromDisplays(activeDisplays)); - - // Trigger reconciliation - await rebalanceCameraSubscriptions(); - } - - // 4. Reconciliation (same VMware DRS algorithm) - async rebalanceCameraSubscriptions() { - const desired = await getDesiredSubscriptions(); // Fresh from database - const actual = await getActualSubscriptions(); // Current worker state - - // Find and fix differences using load balancing - await reconcileDifferences(desired, actual); - } -} - -// Intelligent worker selection (unchanged) -function findBestWorkerForLoad(workers, currentLoads) { - return workers - .map(worker => ({ - worker, - score: (currentLoads.get(worker.url) * 0.4) + // 40% load balance - (worker.cpuUsage * 0.35) + // 35% CPU usage - (worker.memoryUsage * 0.25) // 25% memory usage - })) - .sort((a, b) => a.score - b.score)[0].worker; // Lower score = better -} -``` - -### Simplified Reconciliation Flow -1. **Database Change**: Any process modifies database (Display, Camera, Playlist, Model) -2. **Trigger Notification**: Process sends `regenerate_subscriptions` to `worker:master:commands` -3. **Complete Regeneration**: Master queries database for all active Display+Camera+Playlist combinations -4. **Desired State Creation**: Master generates fresh desired subscriptions from database query results -5. **Diff Analysis**: Master compares fresh desired state vs current actual state on workers -6. **Global Reconciliation**: Master applies intelligent load balancing algorithm to reconcile differences -7. **Worker Commands**: Master sends subscription/unsubscription commands to workers -8. **State Update**: Master updates actual subscription state in Redis - -### Key Simplifications vs Previous Architecture -- **No Incremental State Management**: No complex tracking of individual subscription changes -- **No State Synchronization Issues**: Desired state always freshly derived from database -- **No Complex Command Protocols**: Only one command type: `regenerate_subscriptions` -- **No Partial Update Bugs**: Complete regeneration eliminates edge cases and race conditions -- **Zero Database-Redis Divergence**: Database is always the authoritative source -- **Simpler Service Layer**: Services just update database + trigger, no subscription logic - -## Class Responsibilities Overview - -### Core Class Functions - -| Class | Primary Responsibility | Key Functions | Process Type | -|-------|----------------------|---------------|--------------| -| **DetectorCluster** | Public API facade and event management | • `subscribeToCamera()` - Legacy interface (triggers regeneration)
• `addDetectionListener()` - Callback registration
• `getState()` - Cluster monitoring
• Event emission to external services | Both Master & Slave | -| **MasterSlaveWorkerCluster** | Pure declarative cluster coordination | **Master**: `regenerateDesiredStateFromDatabase()`, `rebalanceCameraSubscriptions()`, `connectToAllWorkers()`
**Slave**: Minimal role - just routes detection messages
**Both**: `handleDetectionMessage()` for callbacks | Both (different roles) | -| **DetectorConnection** | Individual worker WebSocket management | • `initialize()` - WebSocket connection setup
• `subscribeToCamera()` - Send subscription to worker
• `handleImageDetectionResponse()` - Process AI results
• `resubscribeAll()` - Restore subscriptions after reconnect | Master Only | -| **CameraService** | Database operations + trigger notifications | • `addCamera()` - Database create + trigger regeneration
• `updateCamera()` - Database update + trigger regeneration
• `removeCamera()` - Database delete + trigger regeneration | Both Master & Slave | -| **DisplayService** | Database operations + trigger notifications | • `registerDisplay()` - Database create + trigger regeneration
• `updateDisplay()` - Database update + trigger regeneration
• `deleteDisplay()` - Database delete + trigger regeneration | Both Master & Slave | -| **SubscriptionTrigger** | Simple notification system | • `triggerSubscriptionUpdate()` - Send regeneration request to master | Both Master & Slave | - -## Object Relationship Diagrams - -### Core Class Structure and Methods - -```mermaid -classDiagram - class CameraService { - +addCamera(identifier, rtspUrl) - +removeCamera(identifier) - +resubscribeCamera(identifier) - +getCameras() - +updateCamera(...) - -processDetection(data) - } - - class DetectorCluster { - +initialize() - +subscribeToCamera(...) - +unsubscribeFromCamera(subscriptionId) - +unsubscribeFromAllWithCameraID(cameraId) - +getState() - +addDetectionListener(subscriptionId, callback) - +addGlobalDetectionListener(callback) - -handleWorkerDetection(data) - } - - class MasterSlaveWorkerCluster { - +initialize() - +subscribeToCamera(...) - +storeCameraSubscription(subscription) - +getClusterState() - +shutdown() - -connectToAllWorkers() [MASTER] - -rebalanceCameraSubscriptions() [MASTER] - -triggerRebalancing() [MASTER] - -becomeMaster() - -becomeSlave() - -setupMasterElectionListeners() - } - - class DetectorConnection { - +initialize() - +subscribeToCamera(...) - +unsubscribeFromCamera(subscriptionId) - +getCameraImage(cameraId) - +setSessionId(displayId, sessionId) - +getState() - -connect() - -resubscribeAll() - -handleImageDetectionResponse(data) - -scheduleReconnect() - } - - CameraService --> DetectorCluster : "subscribeToCamera()\ngetState()" - DetectorCluster --> MasterSlaveWorkerCluster : "initialize()\nstoreCameraSubscription()" - MasterSlaveWorkerCluster --> DetectorConnection : "[MASTER] creates connections" -``` - -### Direct Function Call Relationships - -```mermaid -graph TD - API[API Routes] --> CS[CameraService] - CS --> |subscribeToCamera
getState
unsubscribeFromAllWithCameraID| DC[DetectorCluster] - DC --> |initialize
storeCameraSubscription
getClusterState
subscribeToCamera| MSC[MasterSlaveWorkerCluster] - - subgraph "Master Process Only" - MSC --> |connectToAllWorkers
creates connections| CONN[DetectorConnection] - CONN --> |WebSocket calls| PW[Python ML Worker] - end - - ME[MasterElection] --> |getIsMaster
getNodeId
getSlaves| MSC - WL[WorkerLogger] --> |attachToDetectorCluster| DC - - classDef masterOnly fill:#ffcccc - classDef external fill:#ffffcc - - class CONN masterOnly - class PW external - class API external -``` - -### Event-Driven Communication - -```mermaid -graph LR - subgraph "Internal Events" - MSC[MasterSlaveWorkerCluster] -.-> |emit detection| DC[DetectorCluster] - MSC -.-> |emit worker:online
emit worker:offline| DC - DC -.-> |emit worker:detection_result
emit worker:online
emit worker:offline| CS[CameraService] - DC -.-> |emit events| WL[WorkerLogger] - ME[MasterElection] -.-> |master-acquired
master-lost
slave-registered
slave-removed| MSC - end - - subgraph "Callback System" - CS -.-> |callback registration| DC - DC -.-> |detection callbacks| CS - end - - subgraph "WebSocket Events (Master Only)" - CONN[DetectorConnection] -.-> |handleWorkerMessage
handleWorkerOnline
handleWorkerOffline| MSC - PW[Python ML Worker] -.-> |IMAGE_DETECTION
STATE_REPORT| CONN - end - - classDef events fill:#e6f3ff - classDef callbacks fill:#fff2e6 - classDef websocket fill:#ffe6e6 - - class MSC,DC,CS,WL events - class CONN,PW websocket -``` - -### Redis Communication Patterns - -```mermaid -graph TB - subgraph "Master Process" - M[Master MasterSlaveWorkerCluster] - end - - subgraph "Slave Processes" - S1[Slave Process 1] - S2[Slave Process 2] - end - - subgraph "Redis Channels" - SC1[worker:slave:slave1] - SC2[worker:slave:slave2] - MC[worker:master:commands] - AC[worker:assignments:changed] - end - - subgraph "Redis Storage" - WC[worker:connections] - WA[worker:assignments] - WS[worker:slaves] - CS[worker:camera_subscriptions] - end - - M --> |publish detection routing| SC1 - M --> |publish detection routing| SC2 - M --> |publish assignments| AC - M --> |hSet/hGet state| WC - M --> |hSet/hGet assignments| WA - M --> |hSet/hGet subscriptions| CS - - S1 --> |publish commands| MC - S2 --> |publish commands| MC - S1 --> |hSet registration| WS - S2 --> |hSet registration| WS - - SC1 --> |subscribe| S1 - SC2 --> |subscribe| S2 - MC --> |subscribe| M - AC --> |subscribe all| S1 - AC --> |subscribe all| S2 -``` - -## Method Call Flow Analysis - -### Camera Subscription Flow (External Request → Worker) - -```mermaid -sequenceDiagram - participant API as API Routes - participant CS as CameraService - participant DB as Database - participant ST as SubscriptionTrigger - participant R as Redis - participant MSC as MasterSlaveCluster - participant CONN as DetectorConnection - participant W as Python Worker - - Note over API,W: Pure Declarative Flow - API->>+CS: POST /api/camera - CS->>+DB: db.cameraEntity.create({...}) - DB-->>-CS: Camera created - CS->>+ST: triggerSubscriptionUpdate('camera.created', id) - ST->>+R: publish(worker:master:commands, {type: 'regenerate_subscriptions', ...}) - - Note over R,MSC: Only Master Processes Commands - R->>+MSC: Master receives regeneration request - MSC->>+MSC: regenerateDesiredStateFromDatabase() - MSC->>+DB: Query all Display+Camera+Playlist combinations - DB-->>-MSC: Active display configurations - MSC->>+MSC: Generate fresh desired subscriptions - MSC->>+R: Store desired state in Redis - MSC->>+MSC: rebalanceCameraSubscriptions() - MSC->>+MSC: findBestWorkerForSubscription() - MSC->>+CONN: subscribeToCamera(subscriptionId, rtspUrl, ...) - CONN->>+W: WebSocket: {type: "subscribe", payload: {...}} - W-->>-CONN: WebSocket: {type: "stateReport", ...} - CONN->>-MSC: handleWorkerOnline(workerUrl) - MSC->>-R: Update actual subscription state - - Note over W,CS: Detection Processing (unchanged) - W->>CONN: Detection results - CONN->>MSC: Route to assigned slave - MSC->>CS: Detection callback - CS-->>-API: Camera added successfully -``` - -### Detection Processing Flow (Worker → External Callback) - -```mermaid -sequenceDiagram - participant W as Python Worker - participant CONN as DetectorConnection - participant MSC as MasterSlaveCluster - participant R as Redis - participant DC as DetectorCluster - participant CS as CameraService - - Note over W,CS: AI Detection Result Processing - W->>+CONN: WebSocket: {type: "imageDetection", subscriptionIdentifier, data} - CONN->>+MSC: handleWorkerMessage(ImageDetectionResponse) - - Note over MSC: Master finds assigned slave - MSC->>+MSC: findWorkerForSubscription(subscriptionId) - MSC->>+R: hGet(worker:assignments, workerUrl) - MSC->>+R: publish(worker:slave:{slaveId}, {type: 'detection', ...}) - - Note over R: Redis routes to assigned slave - R-->>+MSC: Slave receives detection message - MSC->>+MSC: handleDetectionMessage(message) - MSC->>+DC: emit('detection', detectionData) - - Note over DC: Process detection and trigger callbacks - DC->>+DC: handleWorkerDetection(data) - DC->>+DC: detectionListeners.get(subscriptionId).forEach(callback) - DC->>+CS: callback(detectionData) - DC->>+DC: emit('worker:detection_result', {url, cameraId, detections}) - - Note over CS: External service processes detection - CS->>+CS: processDetection(data) - CS-->>CS: updateAnalytics(), triggerDecisionTrees() -``` - -### Master Election and Failover Flow - -```mermaid -sequenceDiagram - participant ME as MasterElection - participant MSC1 as MasterSlaveCluster (Process 1) - participant MSC2 as MasterSlaveCluster (Process 2) - participant R as Redis - participant W1 as Python Worker 1 - participant W2 as Python Worker 2 - - Note over ME,W2: Master Failover Scenario - - %% Initial state - ME->>+MSC1: emit('master-acquired') - MSC1->>+MSC1: becomeMaster() - ME->>+MSC2: emit('master-lost') - MSC2->>+MSC2: becomeSlave() - - ME->>+R: Automatic slave registration - MSC1->>+W1: WebSocket connection (Master) - MSC1->>+W2: WebSocket connection (Master) - - Note over MSC1: Original master fails - MSC1--xMSC1: Process crash/network failure - - %% MasterElection detects failure and triggers new election - ME->>+ME: Detect failed master, trigger election - ME->>+MSC2: emit('master-acquired') - MSC2->>+MSC2: becomeMaster() - - %% Master recovery process - MSC2->>+MSC2: connectToAllWorkers() - MSC2->>+W1: WebSocket reconnection - MSC2->>+W2: WebSocket reconnection - - MSC2->>+MSC2: healClusterAssignments() - MSC2->>+R: hGetAll(worker:camera_subscriptions) - MSC2->>+MSC2: rebalanceCameraSubscriptions() - - %% Restore subscriptions - MSC2->>+W1: WebSocket: SUBSCRIBE commands - MSC2->>+W2: WebSocket: SUBSCRIBE commands - - Note over MSC2,W2: New master operational - slave registration handled by MasterElection -``` - -## System Architecture Diagrams - -### Master-Slave Cluster Architecture - -```mermaid -graph TB - subgraph "Backend Process Cluster" - M[Master Process
NodeJS Backend] - S1[Slave Process 1
NodeJS Backend] - S2[Slave Process 2
NodeJS Backend] - S3[Slave Process N
NodeJS Backend] - end - - subgraph "Python Worker Cluster" - W1[Python ML Worker 1
WebSocket Server] - W2[Python ML Worker 2
WebSocket Server] - W3[Python ML Worker N
WebSocket Server] - end - - subgraph "Redis Coordination Layer" - R[(Redis)] - R --- C1[worker:slave:* channels] - R --- C2[worker:connections state] - R --- C3[worker:assignments mapping] - R --- C4[worker:camera_subscriptions] - end - - M ===|WebSocket Connections
Only Master| W1 - M ===|WebSocket Connections
Only Master| W2 - M ===|WebSocket Connections
Only Master| W3 - - M <-->|Pub/Sub Messages| R - S1 <-->|Pub/Sub Messages| R - S2 <-->|Pub/Sub Messages| R - S3 <-->|Pub/Sub Messages| R - - M -.->|Route Messages| S1 - M -.->|Route Messages| S2 - M -.->|Route Messages| S3 -``` - -### Data Flow Architecture - -```mermaid -sequenceDiagram - participant CS as CameraService - participant DC as DetectorCluster - participant MS as MasterSlaveCluster - participant R as Redis - participant W as Python Worker - participant S as Slave Process - - Note over CS,S: Camera Subscription Flow - - CS->>DC: subscribeToCamera(cameraId, rtspUrl, modelUrl, ...) - DC->>MS: storeCameraSubscription({...}) - - alt Master Process - MS->>MS: findBestWorkerForSubscription() - MS->>R: hSet(camera_subscriptions, subscriptionId, {...}) - MS->>W: WebSocket: SUBSCRIBE command - W->>MS: STATE_REPORT (subscription confirmed) - MS->>R: publish(worker:slave:{slaveId}, detection_message) - else Slave Process - MS->>R: publish(worker:master:commands, subscribe_command) - Note over MS: Routes to master for execution - end - - Note over CS,S: Detection Processing Flow - - W->>MS: WebSocket: IMAGE_DETECTION response - MS->>MS: findSlaveForWorker(workerUrl) - MS->>R: publish(worker:slave:{slaveId}, detection_data) - R->>S: Redis pub/sub delivery - S->>DC: emit('detection', detectionData) - DC->>CS: callback(detectionData) -``` - -### Subscription Lifecycle Management - -```mermaid -stateDiagram-v2 - [*] --> Pending: Camera Subscription Request - - Pending --> Active: Worker accepts subscription - Pending --> Failed: Worker rejects/unavailable - Pending --> Recovering: Assignment change needed - - Active --> Recovering: Worker goes offline - Active --> [*]: Unsubscribe request - - Recovering --> Active: Reassigned to online worker - Recovering --> Failed: No workers available - Recovering --> [*]: Subscription expired - - Failed --> Recovering: Worker becomes available - Failed --> [*]: Max retries exceeded - - note right of Recovering - Automatic rebalancing every 30s - Master detects offline workers - Reassigns to healthy workers - end note -``` - -### Worker Connection State Machine - -```mermaid -stateDiagram-v2 - [*] --> Connecting: initialize() - - Connecting --> Online: WebSocket connected + STATE_REPORT received - Connecting --> Reconnecting: Connection failed - - Online --> Offline: Heartbeat timeout (10s) - Online --> Reconnecting: WebSocket error/close - Online --> [*]: close() called - - Offline --> Reconnecting: Scheduled reconnect (10s) - Offline --> [*]: close() called - - Reconnecting --> Online: Reconnection successful - Reconnecting --> Reconnecting: Reconnection failed (retry) - Reconnecting --> [*]: close() called - - note right of Online - - Sends heartbeat every 2s - - Processes subscriptions - - Reports resource usage - - Handles detection results - end note -``` - -### Redis Channel Communication Flow - -```mermaid -graph LR - subgraph "Master Process" - M[Master] - WS1[WebSocket to Worker 1] - WS2[WebSocket to Worker 2] - end - - subgraph "Slave Processes" - S1[Slave 1] - S2[Slave 2] - end - - subgraph "Redis Channels" - CH1[worker:slave:slave1] - CH2[worker:slave:slave2] - CH3[worker:messages:upstream] - CH4[worker:assignments:changed] - end - - WS1 -->|Detection Data| M - WS2 -->|Detection Data| M - - M -->|Route by Assignment| CH1 - M -->|Route by Assignment| CH2 - - CH1 -->|Subscribed| S1 - CH2 -->|Subscribed| S2 - - S1 -->|Commands/Responses| CH3 - S2 -->|Commands/Responses| CH3 - CH3 -->|Subscribed| M - - M -->|Assignment Updates| CH4 - CH4 -->|Subscribed| S1 - CH4 -->|Subscribed| S2 -``` - -### Detailed Message Flow by Channel - -```mermaid -graph TB - subgraph "Python ML Workers" - W1[Worker 1
ws://worker1:8000] - W2[Worker 2
ws://worker2:8000] - W3[Worker N
ws://workerN:8000] - end - - subgraph "Master Process (Only One)" - M[Master Backend Process] - subgraph "Master Managed Data" - WC1[WebSocket Connection Pool] - AS[Assignment State] - SUB[Subscription Manager] - end - end - - subgraph "Redis Channels & Storage" - subgraph "Individual Slave Channels" - SC1["worker:slave:slave-uuid-1"] - SC2["worker:slave:slave-uuid-2"] - SC3["worker:slave:slave-uuid-N"] - end - - subgraph "Master Coordination Channels" - MC["worker:master:commands"] - ACH["worker:assignments:changed"] - UPC["worker:messages:upstream"] - SEC["worker:subscription:events"] - end - - subgraph "Persistent Storage" - WCS["worker:connections
(Worker Health States)"] - WAS["worker:assignments
(Worker→Slave Mapping)"] - WSS["worker:slaves
(Slave Registration)"] - CSS["worker:camera_subscriptions
(Subscription Persistence)"] - end - end - - subgraph "Slave Processes" - S1[Slave Process 1
slave-uuid-1] - S2[Slave Process 2
slave-uuid-2] - S3[Slave Process N
slave-uuid-N] - end - - %% WebSocket Communications (Master Only) - W1 -.->|"WebSocket Messages:
• IMAGE_DETECTION
• STATE_REPORT
• PATCH_SESSION"| WC1 - W2 -.->|"WebSocket Messages:
• IMAGE_DETECTION
• STATE_REPORT
• PATCH_SESSION"| WC1 - W3 -.->|"WebSocket Messages:
• IMAGE_DETECTION
• STATE_REPORT
• PATCH_SESSION"| WC1 - - WC1 -.->|"WebSocket Commands:
• SUBSCRIBE
• UNSUBSCRIBE
• REQUEST_STATE
• SET_SESSION_ID"| W1 - WC1 -.->|"WebSocket Commands:
• SUBSCRIBE
• UNSUBSCRIBE
• REQUEST_STATE
• SET_SESSION_ID"| W2 - WC1 -.->|"WebSocket Commands:
• SUBSCRIBE
• UNSUBSCRIBE
• REQUEST_STATE
• SET_SESSION_ID"| W3 - - %% Master Redis Operations - M -->|"hSet() operations:
• Worker states
• Assignments
• Subscriptions"| WCS - M -->|"hSet() operations:
• Worker→Slave mapping
• Load balancing data"| WAS - M -->|"hSet() operations:
• Subscription details
• Assignment tracking"| CSS - - %% Master to Slave Routing - M -->|"Detection Routing:
{type: 'detection',
workerUrl: string,
data: ImageDetectionResponse,
timestamp: string}"| SC1 - M -->|"Detection Routing:
{type: 'detection',
workerUrl: string,
data: ImageDetectionResponse,
timestamp: string}"| SC2 - M -->|"Detection Routing:
{type: 'detection',
workerUrl: string,
data: ImageDetectionResponse,
timestamp: string}"| SC3 - - M -->|"Assignment Updates:
{type: 'assignments_updated',
assignments: Record,
timestamp: string}"| ACH - - %% Slave to Master Communication - S1 -->|"Slave Commands:
{type: 'subscribe_camera',
subscriptionIdentifier: string,
rtspUrl: string,
modelUrl: string,
modelId: number,
snapshotUrl?: string,
cropX1?: number, ...}"| MC - S2 -->|"Slave Commands:
{type: 'subscribe_camera',
subscriptionIdentifier: string,
rtspUrl: string,
modelUrl: string,
modelId: number,
snapshotUrl?: string,
cropX1?: number, ...}"| MC - S3 -->|"Slave Commands:
{type: 'subscribe_camera',
subscriptionIdentifier: string,
rtspUrl: string,
modelUrl: string,
modelId: number,
snapshotUrl?: string,
cropX1?: number, ...}"| MC - - %% Slave Registration and Heartbeats - S1 -->|"hSet() Slave Registration:
{slaveId: string,
processId: string,
online: boolean,
workload: number,
lastSeen: string,
capabilities: {...}}"| WSS - S2 -->|"hSet() Slave Registration:
{slaveId: string,
processId: string,
online: boolean,
workload: number,
lastSeen: string,
capabilities: {...}}"| WSS - S3 -->|"hSet() Slave Registration:
{slaveId: string,
processId: string,
online: boolean,
workload: number,
lastSeen: string,
capabilities: {...}}"| WSS - - %% Channel Subscriptions - SC1 -->|"Subscribed"| S1 - SC2 -->|"Subscribed"| S2 - SC3 -->|"Subscribed"| S3 - - MC -->|"Subscribed"| M - ACH -->|"Subscribed (All Slaves)"| S1 - ACH -->|"Subscribed (All Slaves)"| S2 - ACH -->|"Subscribed (All Slaves)"| S3 - - style M fill:#ff9999 - style WC1 fill:#ffcc99 - style AS fill:#ffcc99 - style SUB fill:#ffcc99 - style S1 fill:#99ccff - style S2 fill:#99ccff - style S3 fill:#99ccff -``` - -### Channel Message Specification - -| Channel Name | Direction | Message Type | Sender | Receiver | Payload Structure | Purpose | -|--------------|-----------|--------------|---------|-----------|-------------------|---------| -| `worker:slave:{slaveId}` | Master→Slave | `detection` | Master Process | Assigned Slave | `{type: 'detection', workerUrl: string, data: ImageDetectionResponse, timestamp: string}` | Route AI detection results from workers to processing slaves | -| `worker:master:commands` | Slave→Master | `regenerate_subscriptions` | Any Process | Master Process | `{type: 'regenerate_subscriptions', reason: string, triggeredBy: string, timestamp: string}` | Notify master that database changed and subscriptions need regeneration | -| `worker:assignments:changed` | Master→All Slaves | `assignments_updated` | Master Process | All Slave Processes | `{type: 'assignments_updated', assignments: Record, timestamp: string}` | Broadcast worker-to-slave assignment changes for rebalancing | -| `worker:messages:upstream` | Slave→Master | Various | Any Slave Process | Master Process | `{type: string, slaveId: string, data: any, timestamp: string}` | General slave-to-master communication (currently unused) | - -### Redis Hash Storage Specification - -| Redis Key | Data Type | Content | Update Pattern | Cleanup Strategy | -|-----------|-----------|---------|----------------|-------------------| -| `worker:connections` | Hash Map | `{[workerUrl]: JSON.stringify(WorkerConnectionState)}` | Master updates every 2s | Manual cleanup only | -| `worker:assignments` | Hash Map | `{[workerUrl]: slaveId}` | Master updates on rebalancing | Manual cleanup only | -| `worker:camera_subscriptions` | Hash Map | `{[subscriptionId]: JSON.stringify(CameraSubscription)}` | Master on subscription changes | Manual cleanup only | -| `master-election:slaves` | Hash Map | `{[nodeId]: JSON.stringify(SlaveNode)}` | MasterElection service manages | TTL-based cleanup | - -### WebSocket Message Protocol - -| Direction | Message Type | JSON Structure | Trigger | Response Expected | -|-----------|--------------|----------------|---------|-------------------| -| Backend→Worker | `SUBSCRIBE` | `{type: "subscribe", payload: {subscriptionIdentifier, rtspUrl, snapshotUrl?, snapshotInterval?, modelUrl, modelName, modelId, cropX1?, cropY1?, cropX2?, cropY2?}}` | Camera subscription request | STATE_REPORT confirmation | -| Backend→Worker | `UNSUBSCRIBE` | `{type: "unsubscribe", payload: {subscriptionIdentifier}}` | Camera unsubscription | STATE_REPORT confirmation | -| Backend→Worker | `REQUEST_STATE` | `{type: "requestState"}` | Health check or monitoring | STATE_REPORT response | -| Backend→Worker | `SET_SESSION_ID` | `{type: "setSessionId", payload: {displayIdentifier, sessionId}}` | Associate session with display | None | -| Backend→Worker | `PATCH_SESSION_RESULT` | `{type: "patchSessionResult", payload: {sessionId, success, message?}}` | Session update response | None | -| Worker→Backend | `IMAGE_DETECTION` | `{type: "imageDetection", subscriptionIdentifier, timestamp, data: {detection: {carModel?, carBrand?, carYear?, bodyType?, licensePlateText?, licensePlateType?}, modelId, modelName}}` | AI detection result | None | -| Worker→Backend | `STATE_REPORT` | `{type: "stateReport", cpuUsage, memoryUsage, gpuUsage?, gpuMemoryUsage?, cameraConnections: [{subscriptionIdentifier, modelId, modelName, online, cropX?, cropY?}]}` | Periodic health report (every 2s) | None | -| Worker→Backend | `PATCH_SESSION` | `{type: "patchSession", sessionId, data: any}` | Session data update from ML processing | PATCH_SESSION_RESULT | - -## Event System Architecture - -### Event Flow Hierarchy - -```mermaid -graph TD - subgraph "Service Layer" - CS[CameraService] - end - - subgraph "Cluster Layer" - DC[DetectorCluster] - DC --> DCE[Detection Events] - DC --> WOE[Worker Online Events] - DC --> WOFE[Worker Offline Events] - end - - subgraph "Worker Management Layer" - MS[MasterSlaveWorkerCluster] - MS --> DE[detection] - MS --> WC[worker:connected] - MS --> WD[worker:disconnected] - MS --> WSE[worker:websocket_error] - MS --> WON[worker:online] - MS --> WOFF[worker:offline] - MS --> WSR[worker:state_report] - end - - subgraph "Connection Layer" - DConn[DetectorConnection] - DConn --> IMG[IMAGE_DETECTION] - DConn --> STATE[STATE_REPORT] - DConn --> PATCH[PATCH_SESSION] - end - - DConn --> MS - MS --> DC - DC --> CS - - IMG -.-> DE - STATE -.-> WSR - WC -.-> WOE - WD -.-> WOFE -``` - -### Message Types and Routing - -#### WebSocket Message Types (Python Worker → Backend) -- `IMAGE_DETECTION`: AI detection results from camera streams -- `STATE_REPORT`: Worker health, resource usage, and subscription status -- `PATCH_SESSION`: Session data updates from worker processing - -#### Redis Channel Message Types -- `detection`: Detection results routed from master to assigned slave -- `command_response`: Command acknowledgment and status updates -- `heartbeat`: Worker and slave health monitoring messages -- `assignments_updated`: Worker-to-slave assignment change notifications - -#### Internal Event Types -- `worker:online`: Worker connection established and ready -- `worker:offline`: Worker connection lost or health check failed -- `worker:connected`: WebSocket connection opened (not necessarily ready) -- `worker:disconnected`: WebSocket connection closed -- `worker:websocket_error`: WebSocket communication errors -- `worker:detection_result`: Processed detection with metadata -- `worker:state_report`: Worker resource and subscription status - -## Subscription Management - -### Camera Subscription Flow - -1. **Registration Phase** - - `CameraService.subscribeToCamera()` → `DetectorCluster.subscribeToCamera()` - - Master process finds optimal worker using load balancing algorithm - - Subscription stored in Redis with full configuration including crop parameters - - Master sends WebSocket SUBSCRIBE command to assigned worker - -2. **Processing Phase** - - Python worker establishes RTSP connection to camera - - Worker performs AI inference on video stream frames - - Detection results sent back via WebSocket with subscription identifier - - Master routes results to appropriate slave based on worker assignments - -3. **Rebalancing Phase** - - Master monitors worker health every 30 seconds - - Orphaned subscriptions (offline workers) automatically detected - - Load balancing algorithm reassigns cameras to healthy workers - - Fresh model URLs generated to handle S3 presigned URL expiration - -### Load Balancing Algorithm - -```typescript -// Simplified load balancing logic -function findBestWorkerForSubscription(onlineWorkers, allSubscriptions) { - return onlineWorkers - .sort((a, b) => { - const loadA = getSubscriptionCount(a.url); - const loadB = getSubscriptionCount(b.url); - if (loadA !== loadB) { - return loadA - loadB; // Prefer lower load - } - return (a.cpuUsage || 0) - (b.cpuUsage || 0); // Then prefer lower CPU - })[0]; -} -``` - -### Automatic Failover Process - -1. **Detection**: Master detects worker offline via missed heartbeats (10s timeout) -2. **Identification**: System identifies all camera subscriptions assigned to offline worker -3. **Reassignment**: Load balancer selects optimal replacement worker -4. **Migration**: Subscription updated in Redis with new worker assignment -5. **Resubscription**: Master sends SUBSCRIBE command to new worker with fresh model URL -6. **Verification**: New worker confirms subscription and begins processing - -## Resource Management - -### Connection Pooling -- Master maintains persistent WebSocket connections to all configured workers -- Connection sharing across all backend processes reduces resource overhead -- Automatic reconnection with exponential backoff prevents connection storms - -### Memory Management -- Redis data uses manual cleanup to prevent accidental state loss -- Subscription callbacks stored in local memory with automatic cleanup on unsubscribe -- Worker resource usage tracked in real-time to prevent overload - -### CPU and GPU Monitoring -- Workers report resource usage every 2 seconds via STATE_REPORT messages -- Load balancing algorithm considers CPU usage when assigning new subscriptions -- GPU utilization tracked for ML model optimization and capacity planning - -## Error Handling - -### Connection Error Recovery -- **Exponential Backoff**: 10-second fixed interval reconnection attempts -- **Circuit Breaker**: Automatic failover prevents overwhelming failed workers -- **Graceful Degradation**: System continues operating with available workers - -### Master Election Failover -- **Leadership Transfer**: New master elected via Redis-based coordination -- **State Recovery**: Worker connections and subscriptions restored from Redis persistence -- **Seamless Transition**: No subscription loss during master failover process - -### Monitoring and Observability - -#### Structured Logging Topics -- `detector-cluster`: High-level cluster operations and state changes -- `master-slave-worker-cluster`: Worker assignment and rebalancing operations -- `DetectorConnection`: WebSocket connection events and message processing - -#### Monitoring Information -- Subscription identifier format: `${displayId};${cameraId}` for traceability -- Worker assignment tracking with process ID and timestamp correlation -- Redis pub/sub message routing with structured logging -- Heartbeat and health check timing with millisecond precision - -## Configuration Parameters - -### Timing Configuration -```typescript -const WORKER_TIMEOUT_MS = 10000; // Worker heartbeat timeout -const SLAVE_HEARTBEAT_INTERVAL = 5000; // Slave heartbeat frequency -const SLAVE_TIMEOUT = 15000; // Slave registration timeout -const REBALANCE_INTERVAL = 30000; // Automatic rebalancing frequency -const STATE_UPDATE_INTERVAL = 2000; // Worker state update frequency -const RECONNECT_DELAY = 10000; // WebSocket reconnection delay -``` - -### Environment Variables -```bash -DETECTOR_WORKERS=ws://worker1:8000,ws://worker2:8000 # Python worker URLs -REDIS_HOST=localhost # Redis coordination server -REDIS_PORT=6379 # Redis server port -REDIS_PASSWORD=secure_password # Redis authentication -DETECT_DEBUG=true # Enable detailed structured logging -``` - -## Performance Characteristics - -### Scalability Metrics -- **Horizontal Scaling**: Add backend processes without WebSocket connection changes -- **Worker Scaling**: Python ML workers scale independently of backend processes -- **Redis Optimization**: Efficient pub/sub routing with minimal memory overhead - -### Throughput Capabilities -- **Camera Subscriptions**: Support for 100+ simultaneous camera streams per worker -- **Detection Processing**: Sub-second AI inference with real-time result delivery -- **Message Routing**: Sub-millisecond Redis pub/sub message delivery - -### Resource Efficiency -- **Connection Multiplexing**: Single WebSocket per worker shared across all processes -- **Memory Usage**: Lightweight subscription state with callback cleanup -- **Network Optimization**: Binary WebSocket frames with JSON payload compression - -## Public Interface Specification - -The distributed worker cluster exposes a clean, simplified interface to external services like CameraService, hiding the complexity of the underlying master-slave architecture. All interactions go through the `DetectorCluster` class, which serves as the primary facade. - -### Primary Interface: DetectorCluster - -The `DetectorCluster` class in `/services/DetectorCluster.ts` provides the main public interface that external services interact with. It abstracts away the distributed architecture complexity and provides consistent behavior regardless of whether the current process is a master or slave. - -#### Core Interface Methods - -##### Camera Subscription Management - -```typescript -/** - * Subscribe to a camera stream for AI detection processing - * @param subscriptionIdentifier - Unique identifier format: "${displayId};${cameraId}" - * @param rtspUrl - RTSP stream URL for the camera - * @param modelUrl - Pre-signed S3 URL for AI model (1hr TTL) - * @param modelId - Database ID of the AI model - * @param modelName - Human-readable model identifier - * @param callback - Function called when detection results are received - * @param snapshotUrl - Optional HTTP endpoint for camera snapshots - * @param snapshotInterval - Optional snapshot capture interval in milliseconds - * @param cropX1, cropY1, cropX2, cropY2 - Optional image crop coordinates - * @returns Promise - Always returns true (errors thrown as exceptions) - */ -public async subscribeToCamera( - subscriptionIdentifier: string, - rtspUrl: string, - modelUrl: string, - modelId: number, - modelName: string, - callback: Function, - snapshotUrl?: string, - snapshotInterval?: number, - cropX1?: number, - cropY1?: number, - cropX2?: number, - cropY2?: number -): Promise -``` - -**Behavior:** -- **Master Process**: Stores subscription in Redis, assigns to optimal worker, sends WebSocket command -- **Slave Process**: Routes subscription request to master via Redis pub/sub -- **Callback Registration**: Stores callback locally for detection result processing -- **Persistence**: All subscription details stored in Redis for failover recovery -- **Load Balancing**: Automatically selects best available worker based on CPU and subscription load - -```typescript -/** - * Unsubscribe from a specific camera stream - * @param subscriptionIdentifier - The subscription to remove - * @returns Promise - Success status - */ -public async unsubscribeFromCamera(subscriptionIdentifier: string): Promise -``` - -**Behavior:** -- Removes local callback listeners immediately -- Subscription cleanup handled automatically by cluster rebalancing -- Safe to call multiple times (idempotent operation) - -```typescript -/** - * Remove all subscriptions for a specific camera across all displays - * @param cameraIdentifier - The camera ID to unsubscribe from all displays - * @returns Promise - */ -public async unsubscribeFromAllWithCameraID(cameraIdentifier: string): Promise -``` - -**Behavior:** -- Finds all subscription identifiers matching pattern `*;${cameraIdentifier}` -- Removes all local callbacks for matched subscriptions -- Cluster automatically handles worker-side cleanup - -##### Event Registration and Callbacks - -```typescript -/** - * Register a callback for detection results from a specific subscription - * @param subscriptionIdentifier - Target subscription - * @param callback - Function to call with detection data - */ -public addDetectionListener(subscriptionIdentifier: string, callback: Function): void - -/** - * Register a global callback for all detection results - * @param callback - Function to call with any detection data - */ -public addGlobalDetectionListener(callback: Function): void -``` - -**Detection Callback Signature:** -```typescript -type DetectionCallback = (data: { - subscriptionIdentifier: string; - timestamp: Date; - data: { - detection: { - carModel?: string; - carBrand?: string; - carYear?: number; - bodyType?: string; - licensePlateText?: string; - licensePlateType?: string; - }; - modelId: number; - modelName: string; - }; -}) => void; -``` - -##### Cluster State Management - -```typescript -/** - * Get comprehensive cluster state for monitoring and status reporting - * @returns Promise - */ -public async getState(): Promise - -/** - * Legacy method - rebalancing now happens automatically - * @returns Promise - Always returns true - */ -public async rebalanceWorkers(): Promise -``` - -**DetectorClusterState Interface:** -```typescript -interface DetectorClusterState { - processId: string; // Current process identifier - isMaster: boolean; // Whether this process is the master - slaveId: string; // This process's slave identifier - totalWorkers: number; // Number of Python ML workers - totalSlaves: number; // Number of backend slave processes - workers: WorkerState[]; // Detailed worker health and status - slaves: SlaveInfo[]; // Slave process information - assignments: Record; // workerUrl -> slaveId mapping -} -``` - -##### Session Management (Future Implementation) - -```typescript -/** - * Associate a session ID with a camera subscription for tracking - * @param subscriptionIdentifier - Target subscription - * @param sessionId - Session ID to associate (null to clear) - * @returns Promise - Success status - */ -public async setSessionId(subscriptionIdentifier: string, sessionId: number | null): Promise - -/** - * Get current camera image via worker REST API - * @param cameraIdentifier - Camera to capture from - * @returns Promise - JPEG image data - */ -public async getCameraImage(cameraIdentifier: string): Promise -``` - -**Note:** These methods are currently not fully implemented in master-slave mode. - -### Event System Interface - -The cluster emits events that external services can listen to for system monitoring and integration: - -#### Emitted Events - -```typescript -// Detection result processed -detectorCluster.on('worker:detection_result', (event: { - url: string; // Worker URL (always 'cluster-managed') - cameraId: string; // Subscription identifier - detections: number; // Number of objects detected (0 or 1) -}) => void); - -// Worker status changes -detectorCluster.on('worker:online', (event: { url: string }) => void); -detectorCluster.on('worker:offline', (event: { url: string }) => void); - -// Connection events -detectorCluster.on('worker:connecting', (event: { url: string }) => void); -detectorCluster.on('worker:disconnected', (event: { url: string, reason: string }) => void); -detectorCluster.on('worker:websocket_error', (event: { url: string, error: string }) => void); -``` - -### Usage Examples - -#### Basic Camera Subscription (CameraService Integration) - -```typescript -import { detectorCluster } from '~/modules/camera/services/CameraService'; - -// Subscribe to camera with AI detection -const success = await detectorCluster.subscribeToCamera( - `display-123;camera-456`, // subscriptionIdentifier - 'rtsp://192.168.1.100:554/stream1', // rtspUrl - 'https://s3.bucket.com/model.onnx', // modelUrl (pre-signed) - 42, // modelId - 'vehicle-detection-v2', // modelName - (detectionData) => { // callback - console.log('Detection:', detectionData.data.detection); - // Process car model, license plate, etc. - }, - 'http://192.168.1.100/snapshot.jpg', // snapshotUrl (optional) - 5000, // snapshotInterval (optional) - 100, 50, 800, 600 // crop coordinates (optional) -); -``` - -#### Event Monitoring Integration - -```typescript -// Monitor worker health -detectorCluster.on('worker:online', (event) => { - console.log(`Worker ${event.url} came online`); - // Update dashboard, send notifications, etc. -}); - -detectorCluster.on('worker:offline', (event) => { - console.log(`Worker ${event.url} went offline`); - // Alert administrators, trigger failover procedures -}); - -// Monitor detection activity -detectorCluster.on('worker:detection_result', (event) => { - if (event.detections > 0) { - console.log(`Camera ${event.cameraId} detected objects`); - // Trigger content changes, log analytics, etc. - } -}); -``` - -#### Cluster State Monitoring - -```typescript -// Get comprehensive cluster status -const state = await detectorCluster.getState(); - -console.log(`Process ${state.processId} is ${state.isMaster ? 'MASTER' : 'SLAVE'}`); -console.log(`Cluster: ${state.totalWorkers} workers, ${state.totalSlaves} slaves`); - -// Monitor worker health -state.workers.forEach(worker => { - console.log(`Worker ${worker.url}: ${worker.online ? 'ONLINE' : 'OFFLINE'}`); - console.log(` CPU: ${worker.cpuUsage}%, Memory: ${worker.memoryUsage}%`); - console.log(` Subscriptions: ${worker.subscriptionCount}`); -}); - -// Check assignments -Object.entries(state.assignments).forEach(([workerUrl, slaveId]) => { - console.log(`Worker ${workerUrl} assigned to slave ${slaveId}`); -}); -``` - -#### Bulk Camera Management - -```typescript -// Remove all subscriptions for a camera being deleted -await detectorCluster.unsubscribeFromAllWithCameraID('camera-456'); - -// Re-subscribe camera to all displays after configuration change -const displays = await getDisplaysForCamera('camera-456'); -for (const display of displays) { - await detectorCluster.subscribeToCamera( - `${display.id};camera-456`, - camera.rtspUrl, - freshModelUrl, - modelId, - modelName, - createDetectionHandler(display.id, camera.id), - camera.snapshotUrl, - camera.snapshotInterval, - display.cropX1, display.cropY1, - display.cropX2, display.cropY2 - ); -} -``` - -### Error Handling Interface - -The cluster interface follows consistent error handling patterns: - -#### Exception Types - -```typescript -// Subscription errors -try { - await detectorCluster.subscribeToCamera(...); -} catch (error) { - // Possible errors: - // - "No workers available for assignment" - // - "Invalid subscription identifier format" - // - "Model URL expired or inaccessible" - // - Redis connection errors -} - -// State retrieval errors -try { - const state = await detectorCluster.getState(); -} catch (error) { - // Returns safe default state on errors - // Logs detailed error information -} -``` - -#### Graceful Degradation - -- **No Workers Available**: Subscriptions stored in Redis, will activate when workers come online -- **Master Process Failure**: New master elected, all subscriptions restored from Redis -- **Redis Connection Issues**: Local callbacks continue working, subscriptions restored when connection recovers -- **Invalid Parameters**: Clear error messages with parameter validation - -### Integration Patterns - -#### Service Layer Integration - -```typescript -// CameraService.ts example -export class CameraService { - constructor() { - // Initialize cluster connection - detectorCluster.initialize(); - - // Set up global detection processing - detectorCluster.addGlobalDetectionListener(this.processDetection.bind(this)); - } - - async subscribeCamera(displayId: string, camera: CameraEntity) { - const subscriptionId = `${displayId};${camera.cameraIdentifier}`; - - return await detectorCluster.subscribeToCamera( - subscriptionId, - camera.rtspUrl, - await this.getModelUrl(camera.modelId), - camera.modelId, - camera.modelName, - (data) => this.handleDetection(displayId, camera.id, data), - camera.snapshotUrl, - camera.snapshotInterval, - camera.cropX1, camera.cropY1, - camera.cropX2, camera.cropY2 - ); - } - - private processDetection(data: ImageDetectionResponse) { - // Global detection processing logic - this.updateAnalytics(data); - this.triggerDecisionTrees(data); - } -} -``` - -### Interface Guarantees and Contracts - -#### Reliability Guarantees - -- **At-Least-Once Detection Delivery**: Detection callbacks will be called at least once per detection -- **Subscription Persistence**: Subscriptions survive process restarts and master failovers -- **Automatic Reconnection**: Workers automatically reconnect with exponential backoff -- **Load Balancing**: New subscriptions automatically assigned to least loaded workers - -#### Performance Characteristics - -- **Subscription Latency**: < 100ms for new camera subscriptions -- **Detection Latency**: < 50ms from worker to callback (excluding AI processing time) -- **State Query Performance**: < 10ms for cluster state retrieval -- **Memory Usage**: O(n) where n = number of active subscriptions - -#### Thread Safety - -- **Callback Execution**: All callbacks executed on main event loop (Node.js single-threaded) -- **Concurrent Subscriptions**: Multiple simultaneous subscriptions handled safely -- **State Consistency**: Redis operations use atomic transactions where needed - -This interface specification provides external services with a clear understanding of how to integrate with the distributed worker cluster while maintaining abstraction from the underlying complexity. - -## Architecture Evolution: From Complex to Pure Declarative - -### Previous Architecture Limitations (Addressed) -- **Complex State Synchronization**: Incremental updates between database, Redis desired state, and worker actual state created synchronization complexity -- **Command Protocol Complexity**: Multiple command types (`subscribe_camera`, `unsubscribe_camera`) with complex payloads and error handling -- **State Divergence**: Database and Redis desired state could diverge, causing inconsistent behavior -- **Partial Update Complexity**: Complex logic for handling individual subscription changes led to edge cases and race conditions -- **Service Layer Complexity**: Camera/Display services contained complex subscription management logic - -### Current Pure Declarative Architecture Benefits -- **Single Source of Truth**: Database is the only source for desired state - no secondary state stores to synchronize -- **Zero State Divergence**: Desired state is always freshly derived from database queries, eliminating synchronization complexity -- **Simplified Protocol**: Only one command type (`regenerate_subscriptions`) with minimal payload -- **Consistent State Management**: Complete regeneration eliminates all edge cases and partial update complexity -- **Service Layer Simplicity**: Services just update database + trigger regeneration - no subscription logic -- **Operational Resilience**: System is self-healing and predictable - any database change triggers complete reconciliation - -### Declarative Architecture Benefits -- **Global Optimization**: Every regeneration considers all subscriptions globally for optimal load balancing -- **Automatic Recovery**: System automatically heals from any inconsistent state by regenerating from database -- **Resource Efficiency**: Workers assigned based on real-time CPU/memory metrics with load balancing -- **Fault Tolerance**: Complete state recovery from database after any failure (process crashes, network interruptions, etc.) - -### Performance Characteristics -- **Regeneration Speed**: Database queries are fast (~10ms) even with hundreds of displays -- **Reconciliation Efficiency**: Only changed subscriptions are actually modified on workers -- **Memory Efficiency**: No persistent state storage outside of database and current worker assignments -- **Network Efficiency**: Minimal command protocol reduces Redis pub/sub overhead - -This pure declarative architecture provides the reliability and simplicity of container orchestration-style declarative resource management while maintaining the performance and scalability needed for real-time camera processing systems. \ No newline at end of file diff --git a/pipeline_webcam.py b/pipeline_webcam.py deleted file mode 100755 index 9da3a1b..0000000 --- a/pipeline_webcam.py +++ /dev/null @@ -1,137 +0,0 @@ -import argparse -import os -import cv2 -import time -import logging -import shutil -import threading # added threading -import yaml # for silencing YOLO - -from siwatsystem.pympta import load_pipeline_from_zip, run_pipeline - -# Configure logging -logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") - -# Silence YOLO logging -os.environ["YOLO_VERBOSE"] = "False" -for logger_name in ["ultralytics", "ultralytics.hub", "ultralytics.yolo.utils"]: - logging.getLogger(logger_name).setLevel(logging.WARNING) - -# Global variables for frame sharing -global_frame = None -global_ret = False -capture_running = False - -def video_capture_loop(cap): - global global_frame, global_ret, capture_running - while capture_running: - global_ret, global_frame = cap.read() - time.sleep(0.01) # slight delay to reduce CPU usage - -def clear_cache(cache_dir: str): - if os.path.exists(cache_dir): - shutil.rmtree(cache_dir) - -def log_pipeline_flow(frame, model_tree, level=0): - """ - Wrapper around run_pipeline that logs the model flow and detection results. - Returns the same output as the original run_pipeline function. - """ - indent = " " * level - model_id = model_tree.get("modelId", "unknown") - logging.info(f"{indent}→ Running model: {model_id}") - - detection, bbox = run_pipeline(frame, model_tree, return_bbox=True) - - if detection: - confidence = detection.get("confidence", 0) * 100 - class_name = detection.get("class", "unknown") - object_id = detection.get("id", "N/A") - - logging.info(f"{indent}✓ Detected: {class_name} (ID: {object_id}, confidence: {confidence:.1f}%)") - - # Check if any branches were triggered - triggered = False - for branch in model_tree.get("branches", []): - trigger_classes = branch.get("triggerClasses", []) - min_conf = branch.get("minConfidence", 0) - - if class_name in trigger_classes and detection.get("confidence", 0) >= min_conf: - triggered = True - if branch.get("crop", False) and bbox: - x1, y1, x2, y2 = bbox - cropped_frame = frame[y1:y2, x1:x2] - logging.info(f"{indent} ⌊ Triggering branch with cropped region {x1},{y1} to {x2},{y2}") - branch_result = log_pipeline_flow(cropped_frame, branch, level + 1) - else: - logging.info(f"{indent} ⌊ Triggering branch with full frame") - branch_result = log_pipeline_flow(frame, branch, level + 1) - - if branch_result[0]: # If branch detection successful, return it - return branch_result - - if not triggered and model_tree.get("branches"): - logging.info(f"{indent} ⌊ No branches triggered") - else: - logging.info(f"{indent}✗ No detection for {model_id}") - - return detection, bbox - -def main(mpta_file: str, video_source: str): - global capture_running - CACHE_DIR = os.path.join(".", ".mptacache") - clear_cache(CACHE_DIR) - logging.info(f"Loading pipeline from local file: {mpta_file}") - model_tree = load_pipeline_from_zip(mpta_file, CACHE_DIR) - if model_tree is None: - logging.error("Failed to load pipeline.") - return - - cap = cv2.VideoCapture(video_source) - if not cap.isOpened(): - logging.error(f"Cannot open video source {video_source}") - return - - # Start video capture in a separate thread - capture_running = True - capture_thread = threading.Thread(target=video_capture_loop, args=(cap,)) - capture_thread.start() - - logging.info("Press 'q' to exit.") - try: - while True: - # Use the global frame and ret updated by the thread - if not global_ret or global_frame is None: - continue # wait until a frame is available - - frame = global_frame.copy() # local copy to work with - - # Replace run_pipeline with our logging version - detection, bbox = log_pipeline_flow(frame, model_tree) - - if bbox: - x1, y1, x2, y2 = bbox - cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) - label = detection["class"] if detection else "Detection" - cv2.putText(frame, label, (x1, y1 - 10), - cv2.FONT_HERSHEY_SIMPLEX, 0.9, (36, 255, 12), 2) - - cv2.imshow("Pipeline Webcam", frame) - if cv2.waitKey(1) & 0xFF == ord('q'): - break - finally: - # Stop capture thread and cleanup - capture_running = False - capture_thread.join() - cap.release() - cv2.destroyAllWindows() - clear_cache(CACHE_DIR) - logging.info("Cleaned up .mptacache directory on shutdown.") - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Run pipeline webcam utility.") - parser.add_argument("--mpta-file", type=str, required=True, help="Path to the local pipeline mpta (ZIP) file.") - parser.add_argument("--video", type=str, default="0", help="Video source (default webcam index 0).") - args = parser.parse_args() - video_source = int(args.video) if args.video.isdigit() else args.video - main(args.mpta_file, video_source) diff --git a/pympta.md b/pympta.md deleted file mode 100644 index e35fec2..0000000 --- a/pympta.md +++ /dev/null @@ -1,327 +0,0 @@ -# pympta: Modular Pipeline Task Executor - -`pympta` is a Python module designed to load and execute modular, multi-stage AI pipelines defined in a special package format (`.mpta`). It is primarily used within the detector worker to run complex computer vision tasks where the output of one model can trigger a subsequent model on a specific region of interest. - -## Core Concepts - -### 1. MPTA Package (`.mpta`) - -An `.mpta` file is a standard `.zip` archive with a different extension. It bundles all the necessary components for a pipeline to run. - -A typical `.mpta` file has the following structure: - -``` -my_pipeline.mpta/ -├── pipeline.json -├── model1.pt -├── model2.pt -└── ... -``` - -- **`pipeline.json`**: (Required) The manifest file that defines the structure of the pipeline, the models to use, and the logic connecting them. -- **Model Files (`.pt`, etc.)**: The actual pre-trained model files (e.g., PyTorch, ONNX). The pipeline currently uses `ultralytics.YOLO` models. - -### 2. Pipeline Structure - -A pipeline is a tree-like structure of "nodes," defined in `pipeline.json`. - -- **Root Node**: The entry point of the pipeline. It processes the initial, full-frame image. -- **Branch Nodes**: Child nodes that are triggered by specific detection results from their parent. For example, a root node might detect a "vehicle," which then triggers a branch node to detect a "license plate" within the vehicle's bounding box. - -This modular structure allows for creating complex and efficient inference logic, avoiding the need to run every model on every frame. - -## `pipeline.json` Specification - -This file defines the entire pipeline logic. The root object contains a `pipeline` key for the pipeline definition, optional `redis` key for Redis configuration, and optional `postgresql` key for database integration. - -### Top-Level Object Structure - -| Key | Type | Required | Description | -| ------------ | ------ | -------- | ------------------------------------------------------- | -| `pipeline` | Object | Yes | The root node object of the pipeline. | -| `redis` | Object | No | Configuration for connecting to a Redis server. | -| `postgresql` | Object | No | Configuration for connecting to a PostgreSQL database. | - -### Redis Configuration (`redis`) - -| Key | Type | Required | Description | -| ---------- | ------ | -------- | ------------------------------------------------------- | -| `host` | String | Yes | The hostname or IP address of the Redis server. | -| `port` | Number | Yes | The port number of the Redis server. | -| `password` | String | No | The password for Redis authentication. | -| `db` | Number | No | The Redis database number to use. Defaults to `0`. | - -### PostgreSQL Configuration (`postgresql`) - -| Key | Type | Required | Description | -| ---------- | ------ | -------- | ------------------------------------------------------- | -| `host` | String | Yes | The hostname or IP address of the PostgreSQL server. | -| `port` | Number | Yes | The port number of the PostgreSQL server. | -| `database` | String | Yes | The database name to connect to. | -| `username` | String | Yes | The username for database authentication. | -| `password` | String | Yes | The password for database authentication. | - -### Node Object Structure - -| Key | Type | Required | Description | -| ------------------- | ------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| `modelId` | String | Yes | A unique identifier for this model node (e.g., "vehicle-detector"). | -| `modelFile` | String | Yes | The path to the model file within the `.mpta` archive (e.g., "yolov8n.pt"). | -| `minConfidence` | Float | Yes | The minimum confidence score (0.0 to 1.0) required for a detection to be considered valid and potentially trigger a branch. | -| `triggerClasses` | Array | Yes | A list of class names that, when detected by the parent, can trigger this node. For the root node, this lists all classes of interest. | -| `crop` | Boolean | No | If `true`, the image is cropped to the parent's detection bounding box before being passed to this node's model. Defaults to `false`. | -| `cropClass` | String | No | The specific class to use for cropping (e.g., "Frontal" for frontal view cropping). | -| `multiClass` | Boolean | No | If `true`, enables multi-class detection mode where multiple classes can be detected simultaneously. | -| `expectedClasses` | Array | No | When `multiClass` is true, defines which classes are expected. At least one must be detected for processing to continue. | -| `parallel` | Boolean | No | If `true`, this branch will be processed in parallel with other parallel branches. | -| `branches` | Array | No | A list of child node objects that can be triggered by this node's detections. | -| `actions` | Array | No | A list of actions to execute upon a successful detection in this node. | -| `parallelActions` | Array | No | A list of actions to execute after all specified branches have completed. | - -### Action Object Structure - -Actions allow the pipeline to interact with Redis and PostgreSQL databases. They are executed sequentially for a given detection. - -#### Action Context & Dynamic Keys - -All actions have access to a dynamic context for formatting keys and messages. The context is created for each detection event and includes: - -- All key-value pairs from the detection result (e.g., `class`, `confidence`, `id`). -- `{timestamp_ms}`: The current Unix timestamp in milliseconds. -- `{timestamp}`: Formatted timestamp string (YYYY-MM-DDTHH-MM-SS). -- `{uuid}`: A unique identifier (UUID4) for the detection event. -- `{filename}`: Generated filename with UUID. -- `{camera_id}`: Full camera subscription identifier. -- `{display_id}`: Display identifier extracted from subscription. -- `{session_id}`: Session ID for database operations. -- `{image_key}`: If a `redis_save_image` action has already been executed for this event, this placeholder will be replaced with the key where the image was stored. - -#### `redis_save_image` - -Saves the current image frame (or cropped sub-image) to a Redis key. - -| Key | Type | Required | Description | -| ---------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------- | -| `type` | String | Yes | Must be `"redis_save_image"`. | -| `key` | String | Yes | The Redis key to save the image to. Can contain any of the dynamic placeholders. | -| `region` | String | No | Specific detected region to crop and save (e.g., "Frontal"). | -| `format` | String | No | Image format: "jpeg" or "png". Defaults to "jpeg". | -| `quality` | Number | No | JPEG quality (1-100). Defaults to 90. | -| `expire_seconds` | Number | No | If provided, sets an expiration time (in seconds) for the Redis key. | - -#### `redis_publish` - -Publishes a message to a Redis channel. - -| Key | Type | Required | Description | -| --------- | ------ | -------- | ------------------------------------------------------------------------------------------------------- | -| `type` | String | Yes | Must be `"redis_publish"`. | -| `channel` | String | Yes | The Redis channel to publish the message to. | -| `message` | String | Yes | The message to publish. Can contain any of the dynamic placeholders, including `{image_key}`. | - -#### `postgresql_update_combined` - -Updates PostgreSQL database with results from multiple branches after they complete. - -| Key | Type | Required | Description | -| ------------------ | ------------- | -------- | ------------------------------------------------------------------------------------------------------- | -| `type` | String | Yes | Must be `"postgresql_update_combined"`. | -| `table` | String | Yes | The database table name (will be prefixed with `gas_station_1.` schema). | -| `key_field` | String | Yes | The field to use as the update key (typically "session_id"). | -| `key_value` | String | Yes | Template for the key value (e.g., "{session_id}"). | -| `waitForBranches` | Array | Yes | List of branch model IDs to wait for completion before executing update. | -| `fields` | Object | Yes | Field mapping object where keys are database columns and values are templates (e.g., "{branch.field}").| - -### Complete Example `pipeline.json` - -This example demonstrates a comprehensive pipeline for vehicle detection with parallel classification and database integration: - -```json -{ - "redis": { - "host": "10.100.1.3", - "port": 6379, - "password": "your-redis-password", - "db": 0 - }, - "postgresql": { - "host": "10.100.1.3", - "port": 5432, - "database": "inference", - "username": "root", - "password": "your-db-password" - }, - "pipeline": { - "modelId": "car_frontal_detection_v1", - "modelFile": "car_frontal_detection_v1.pt", - "crop": false, - "triggerClasses": ["Car", "Frontal"], - "minConfidence": 0.8, - "multiClass": true, - "expectedClasses": ["Car", "Frontal"], - "actions": [ - { - "type": "redis_save_image", - "region": "Frontal", - "key": "inference:{display_id}:{timestamp}:{session_id}:{filename}", - "expire_seconds": 600, - "format": "jpeg", - "quality": 90 - }, - { - "type": "redis_publish", - "channel": "car_detections", - "message": "{\"event\":\"frontal_detected\"}" - } - ], - "branches": [ - { - "modelId": "car_brand_cls_v1", - "modelFile": "car_brand_cls_v1.pt", - "crop": true, - "cropClass": "Frontal", - "resizeTarget": [224, 224], - "triggerClasses": ["Frontal"], - "minConfidence": 0.85, - "parallel": true, - "branches": [] - }, - { - "modelId": "car_bodytype_cls_v1", - "modelFile": "car_bodytype_cls_v1.pt", - "crop": true, - "cropClass": "Car", - "resizeTarget": [224, 224], - "triggerClasses": ["Car"], - "minConfidence": 0.85, - "parallel": true, - "branches": [] - } - ], - "parallelActions": [ - { - "type": "postgresql_update_combined", - "table": "car_frontal_info", - "key_field": "session_id", - "key_value": "{session_id}", - "waitForBranches": ["car_brand_cls_v1", "car_bodytype_cls_v1"], - "fields": { - "car_brand": "{car_brand_cls_v1.brand}", - "car_body_type": "{car_bodytype_cls_v1.body_type}" - } - } - ] - } -} -``` - -## API Reference - -The `pympta` module exposes two main functions. - -### `load_pipeline_from_zip(zip_source: str, target_dir: str) -> dict` - -Loads, extracts, and parses an `.mpta` file to build a pipeline tree in memory. It also establishes Redis and PostgreSQL connections if configured in `pipeline.json`. - -- **Parameters:** - - `zip_source` (str): The file path to the local `.mpta` zip archive. - - `target_dir` (str): A directory path where the archive's contents will be extracted. -- **Returns:** - - A dictionary representing the root node of the pipeline, ready to be used with `run_pipeline`. Returns `None` if loading fails. - -### `run_pipeline(frame, node: dict, return_bbox: bool = False, context: dict = None)` - -Executes the inference pipeline on a single image frame. - -- **Parameters:** - - `frame`: The input image frame (e.g., a NumPy array from OpenCV). - - `node` (dict): The pipeline node to execute (typically the root node returned by `load_pipeline_from_zip`). - - `return_bbox` (bool): If `True`, the function returns a tuple `(detection, bounding_box)`. Otherwise, it returns only the `detection`. - - `context` (dict): Optional context dictionary containing camera_id, display_id, session_id for action formatting. -- **Returns:** - - The final detection result from the last executed node in the chain. A detection is a dictionary like `{'class': 'car', 'confidence': 0.95, 'id': 1}`. If no detection meets the criteria, it returns `None` (or `(None, None)` if `return_bbox` is `True`). - -## Database Integration - -The pipeline system includes automatic PostgreSQL database management: - -### Table Schema (`gas_station_1.car_frontal_info`) - -The system automatically creates and manages the following table structure: - -```sql -CREATE TABLE IF NOT EXISTS gas_station_1.car_frontal_info ( - display_id VARCHAR(255), - captured_timestamp VARCHAR(255), - session_id VARCHAR(255) PRIMARY KEY, - license_character VARCHAR(255) DEFAULT NULL, - license_type VARCHAR(255) DEFAULT 'No model available', - car_brand VARCHAR(255) DEFAULT NULL, - car_model VARCHAR(255) DEFAULT NULL, - car_body_type VARCHAR(255) DEFAULT NULL, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW() -); -``` - -### Workflow - -1. **Initial Record Creation**: When both "Car" and "Frontal" are detected, an initial database record is created with a UUID session_id. -2. **Redis Storage**: Vehicle images are stored in Redis with keys containing the session_id. -3. **Parallel Classification**: Brand and body type classification run concurrently. -4. **Database Update**: After all branches complete, the database record is updated with classification results. - -## Usage Example - -This snippet shows how to use `pympta` with the enhanced features: - -```python -import cv2 -from siwatsystem.pympta import load_pipeline_from_zip, run_pipeline - -# 1. Define paths -MPTA_FILE = "path/to/your/pipeline.mpta" -CACHE_DIR = ".mptacache" - -# 2. Load the pipeline from the .mpta file -# This reads pipeline.json and loads the YOLO models into memory. -model_tree = load_pipeline_from_zip(MPTA_FILE, CACHE_DIR) - -if not model_tree: - print("Failed to load pipeline.") - exit() - -# 3. Open a video source -cap = cv2.VideoCapture(0) - -while True: - ret, frame = cap.read() - if not ret: - break - - # 4. Run the pipeline on the current frame with context - context = { - "camera_id": "display-001;cam-001", - "display_id": "display-001", - "session_id": None # Will be generated automatically - } - - detection_result, bounding_box = run_pipeline(frame, model_tree, return_bbox=True, context=context) - - # 5. Display the results - if detection_result: - print(f"Detected: {detection_result['class']} with confidence {detection_result['confidence']:.2f}") - if bounding_box: - x1, y1, x2, y2 = bounding_box - cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) - cv2.putText(frame, detection_result['class'], (x1, y1 - 10), - cv2.FONT_HERSHEY_SIMPLEX, 0.9, (36, 255, 12), 2) - - cv2.imshow("Pipeline Output", frame) - - if cv2.waitKey(1) & 0xFF == ord('q'): - break - -cap.release() -cv2.destroyAllWindows() -``` \ No newline at end of file diff --git a/requirements.base.txt b/requirements.base.txt deleted file mode 100644 index e7a302f..0000000 --- a/requirements.base.txt +++ /dev/null @@ -1,12 +0,0 @@ -ultralytics>=8.3.0 -opencv-python>=4.6.0 -scipy>=1.9.0 -filterpy>=1.4.0 -psycopg2-binary>=2.9.0 -easydict -loguru -pyzmq -gitpython -gdown -lap -pynvml \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index baddeb5..46a2624 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ -fastapi[standard] +fastapi uvicorn -websockets -redis -urllib3<2.0.0 \ No newline at end of file +torch +torchvision +ultralytics +opencv-python \ No newline at end of file diff --git a/siwatsystem/database.py b/siwatsystem/database.py deleted file mode 100644 index 5bcbf1d..0000000 --- a/siwatsystem/database.py +++ /dev/null @@ -1,224 +0,0 @@ -import psycopg2 -import psycopg2.extras -from typing import Optional, Dict, Any -import logging -import uuid - -logger = logging.getLogger(__name__) - -class DatabaseManager: - def __init__(self, config: Dict[str, Any]): - self.config = config - self.connection: Optional[psycopg2.extensions.connection] = None - - def connect(self) -> bool: - try: - self.connection = psycopg2.connect( - host=self.config['host'], - port=self.config['port'], - database=self.config['database'], - user=self.config['username'], - password=self.config['password'] - ) - logger.info("PostgreSQL connection established successfully") - return True - except Exception as e: - logger.error(f"Failed to connect to PostgreSQL: {e}") - return False - - def disconnect(self): - if self.connection: - self.connection.close() - self.connection = None - logger.info("PostgreSQL connection closed") - - def is_connected(self) -> bool: - try: - if self.connection and not self.connection.closed: - cur = self.connection.cursor() - cur.execute("SELECT 1") - cur.fetchone() - cur.close() - return True - except: - pass - return False - - def update_car_info(self, session_id: str, brand: str, model: str, body_type: str) -> bool: - if not self.is_connected(): - if not self.connect(): - return False - - try: - cur = self.connection.cursor() - query = """ - INSERT INTO car_frontal_info (session_id, car_brand, car_model, car_body_type, updated_at) - VALUES (%s, %s, %s, %s, NOW()) - ON CONFLICT (session_id) - DO UPDATE SET - car_brand = EXCLUDED.car_brand, - car_model = EXCLUDED.car_model, - car_body_type = EXCLUDED.car_body_type, - updated_at = NOW() - """ - cur.execute(query, (session_id, brand, model, body_type)) - self.connection.commit() - cur.close() - logger.info(f"Updated car info for session {session_id}: {brand} {model} ({body_type})") - return True - except Exception as e: - logger.error(f"Failed to update car info: {e}") - if self.connection: - self.connection.rollback() - return False - - def execute_update(self, table: str, key_field: str, key_value: str, fields: Dict[str, str]) -> bool: - if not self.is_connected(): - if not self.connect(): - return False - - try: - cur = self.connection.cursor() - - # Build the INSERT and UPDATE query dynamically - insert_placeholders = [] - insert_values = [key_value] # Start with key_value - - set_clauses = [] - update_values = [] - - for field, value in fields.items(): - if value == "NOW()": - # Special handling for NOW() - insert_placeholders.append("NOW()") - set_clauses.append(f"{field} = NOW()") - else: - insert_placeholders.append("%s") - insert_values.append(value) - set_clauses.append(f"{field} = %s") - update_values.append(value) - - # Add schema prefix if table doesn't already have it - full_table_name = table if '.' in table else f"gas_station_1.{table}" - - # Build the complete query - query = f""" - INSERT INTO {full_table_name} ({key_field}, {', '.join(fields.keys())}) - VALUES (%s, {', '.join(insert_placeholders)}) - ON CONFLICT ({key_field}) - DO UPDATE SET {', '.join(set_clauses)} - """ - - # Combine values for the query: insert_values + update_values - all_values = insert_values + update_values - - logger.debug(f"SQL Query: {query}") - logger.debug(f"Values: {all_values}") - - cur.execute(query, all_values) - self.connection.commit() - cur.close() - logger.info(f"✅ Updated {table} for {key_field}={key_value} with fields: {fields}") - return True - except Exception as e: - logger.error(f"❌ Failed to execute update on {table}: {e}") - logger.debug(f"Query: {query if 'query' in locals() else 'Query not built'}") - logger.debug(f"Values: {all_values if 'all_values' in locals() else 'Values not prepared'}") - if self.connection: - self.connection.rollback() - return False - - def create_car_frontal_info_table(self) -> bool: - """Create the car_frontal_info table in gas_station_1 schema if it doesn't exist.""" - if not self.is_connected(): - if not self.connect(): - return False - - try: - cur = self.connection.cursor() - - # Create schema if it doesn't exist - cur.execute("CREATE SCHEMA IF NOT EXISTS gas_station_1") - - # Create table if it doesn't exist - create_table_query = """ - CREATE TABLE IF NOT EXISTS gas_station_1.car_frontal_info ( - display_id VARCHAR(255), - captured_timestamp VARCHAR(255), - session_id VARCHAR(255) PRIMARY KEY, - license_character VARCHAR(255) DEFAULT NULL, - license_type VARCHAR(255) DEFAULT 'No model available', - car_brand VARCHAR(255) DEFAULT NULL, - car_model VARCHAR(255) DEFAULT NULL, - car_body_type VARCHAR(255) DEFAULT NULL, - updated_at TIMESTAMP DEFAULT NOW() - ) - """ - - cur.execute(create_table_query) - - # Add columns if they don't exist (for existing tables) - alter_queries = [ - "ALTER TABLE gas_station_1.car_frontal_info ADD COLUMN IF NOT EXISTS car_brand VARCHAR(255) DEFAULT NULL", - "ALTER TABLE gas_station_1.car_frontal_info ADD COLUMN IF NOT EXISTS car_model VARCHAR(255) DEFAULT NULL", - "ALTER TABLE gas_station_1.car_frontal_info ADD COLUMN IF NOT EXISTS car_body_type VARCHAR(255) DEFAULT NULL", - "ALTER TABLE gas_station_1.car_frontal_info ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT NOW()" - ] - - for alter_query in alter_queries: - try: - cur.execute(alter_query) - logger.debug(f"Executed: {alter_query}") - except Exception as e: - # Ignore errors if column already exists (for older PostgreSQL versions) - if "already exists" in str(e).lower(): - logger.debug(f"Column already exists, skipping: {alter_query}") - else: - logger.warning(f"Error in ALTER TABLE: {e}") - - self.connection.commit() - cur.close() - logger.info("Successfully created/verified car_frontal_info table with all required columns") - return True - - except Exception as e: - logger.error(f"Failed to create car_frontal_info table: {e}") - if self.connection: - self.connection.rollback() - return False - - def insert_initial_detection(self, display_id: str, captured_timestamp: str, session_id: str = None) -> str: - """Insert initial detection record and return the session_id.""" - if not self.is_connected(): - if not self.connect(): - return None - - # Generate session_id if not provided - if not session_id: - session_id = str(uuid.uuid4()) - - try: - # Ensure table exists - if not self.create_car_frontal_info_table(): - logger.error("Failed to create/verify table before insertion") - return None - - cur = self.connection.cursor() - insert_query = """ - INSERT INTO gas_station_1.car_frontal_info - (display_id, captured_timestamp, session_id, license_character, license_type, car_brand, car_model, car_body_type) - VALUES (%s, %s, %s, NULL, 'No model available', NULL, NULL, NULL) - ON CONFLICT (session_id) DO NOTHING - """ - - cur.execute(insert_query, (display_id, captured_timestamp, session_id)) - self.connection.commit() - cur.close() - logger.info(f"Inserted initial detection record with session_id: {session_id}") - return session_id - - except Exception as e: - logger.error(f"Failed to insert initial detection record: {e}") - if self.connection: - self.connection.rollback() - return None \ No newline at end of file diff --git a/siwatsystem/model_registry.py b/siwatsystem/model_registry.py deleted file mode 100644 index 95daf3b..0000000 --- a/siwatsystem/model_registry.py +++ /dev/null @@ -1,242 +0,0 @@ -""" -Shared Model Registry for Memory Optimization - -This module implements a global shared model registry to prevent duplicate model loading -in memory when multiple cameras use the same model. This significantly reduces RAM and -GPU VRAM usage by ensuring only one instance of each unique model is loaded. - -Key Features: -- Thread-safe model loading and access -- Reference counting for proper cleanup -- Automatic model lifecycle management -- Maintains compatibility with existing pipeline system -""" - -import os -import threading -import logging -from typing import Dict, Any, Optional, Set -import torch -from ultralytics import YOLO - -# Create a logger for this module -logger = logging.getLogger("detector_worker.model_registry") - -class ModelRegistry: - """ - Singleton class for managing shared YOLO models across multiple cameras. - - This registry ensures that each unique model is loaded only once in memory, - dramatically reducing RAM and GPU VRAM usage when multiple cameras use the - same model. - """ - - _instance = None - _lock = threading.Lock() - - def __new__(cls): - if cls._instance is None: - with cls._lock: - if cls._instance is None: - cls._instance = super(ModelRegistry, cls).__new__(cls) - cls._instance._initialized = False - return cls._instance - - def __init__(self): - if self._initialized: - return - - self._initialized = True - - # Thread-safe storage for loaded models - self._models: Dict[str, YOLO] = {} # modelId -> YOLO model instance - self._model_files: Dict[str, str] = {} # modelId -> file path - self._reference_counts: Dict[str, int] = {} # modelId -> reference count - self._model_lock = threading.RLock() # Reentrant lock for nested calls - - logger.info("🏭 Shared Model Registry initialized - ready for memory-optimized model loading") - - def get_model(self, model_id: str, model_file_path: str) -> YOLO: - """ - Get or load a YOLO model. Returns shared instance if already loaded. - - Args: - model_id: Unique identifier for the model - model_file_path: Path to the model file - - Returns: - YOLO model instance (shared across all callers) - """ - with self._model_lock: - if model_id in self._models: - # Model already loaded - increment reference count and return - self._reference_counts[model_id] += 1 - logger.info(f"📖 Model '{model_id}' reused (ref_count: {self._reference_counts[model_id]}) - SAVED MEMORY!") - return self._models[model_id] - - # Model not loaded yet - load it - logger.info(f"🔄 Loading NEW model '{model_id}' from {model_file_path}") - - if not os.path.exists(model_file_path): - raise FileNotFoundError(f"Model file {model_file_path} not found") - - try: - # Load the YOLO model - model = YOLO(model_file_path) - - # Move to GPU if available - if torch.cuda.is_available(): - logger.info(f"🚀 CUDA available. Moving model '{model_id}' to GPU VRAM") - model.to("cuda") - else: - logger.info(f"💻 CUDA not available. Using CPU for model '{model_id}'") - - # Store in registry - self._models[model_id] = model - self._model_files[model_id] = model_file_path - self._reference_counts[model_id] = 1 - - logger.info(f"✅ Model '{model_id}' loaded and registered (ref_count: 1)") - self._log_registry_status() - - return model - - except Exception as e: - logger.error(f"❌ Failed to load model '{model_id}' from {model_file_path}: {e}") - raise - - def release_model(self, model_id: str) -> None: - """ - Release a reference to a model. If reference count reaches zero, - the model may be unloaded to free memory. - - Args: - model_id: Unique identifier for the model to release - """ - with self._model_lock: - if model_id not in self._reference_counts: - logger.warning(f"⚠️ Attempted to release unknown model '{model_id}'") - return - - self._reference_counts[model_id] -= 1 - logger.info(f"📉 Model '{model_id}' reference count decreased to {self._reference_counts[model_id]}") - - # For now, keep models in memory even when ref count reaches 0 - # This prevents reload overhead if the same model is needed again soon - # In the future, we could implement LRU eviction policy - # if self._reference_counts[model_id] <= 0: - # logger.info(f"💤 Model '{model_id}' has 0 references but keeping in memory for reuse") - # Optionally: self._unload_model(model_id) - - def _unload_model(self, model_id: str) -> None: - """ - Internal method to unload a model from memory. - Currently not used to prevent reload overhead. - """ - with self._model_lock: - if model_id in self._models: - logger.info(f"🗑️ Unloading model '{model_id}' from memory") - - # Clear GPU memory if model was on GPU - model = self._models[model_id] - if hasattr(model, 'model') and hasattr(model.model, 'cuda'): - try: - # Move model to CPU before deletion to free GPU memory - model.to('cpu') - except Exception as e: - logger.warning(f"⚠️ Failed to move model '{model_id}' to CPU: {e}") - - # Remove from registry - del self._models[model_id] - del self._model_files[model_id] - del self._reference_counts[model_id] - - # Force garbage collection - import gc - gc.collect() - if torch.cuda.is_available(): - torch.cuda.empty_cache() - - logger.info(f"✅ Model '{model_id}' unloaded and memory freed") - self._log_registry_status() - - def get_registry_status(self) -> Dict[str, Any]: - """ - Get current status of the model registry. - - Returns: - Dictionary with registry statistics - """ - with self._model_lock: - return { - "total_models": len(self._models), - "models": { - model_id: { - "file_path": self._model_files[model_id], - "reference_count": self._reference_counts[model_id] - } - for model_id in self._models - }, - "total_references": sum(self._reference_counts.values()) - } - - def _log_registry_status(self) -> None: - """Log current registry status for debugging.""" - status = self.get_registry_status() - logger.info(f"📊 Model Registry Status: {status['total_models']} unique models, {status['total_references']} total references") - for model_id, info in status['models'].items(): - logger.debug(f" 📋 '{model_id}': refs={info['reference_count']}, file={os.path.basename(info['file_path'])}") - - def cleanup_all(self) -> None: - """ - Clean up all models from the registry. Used during shutdown. - """ - with self._model_lock: - model_ids = list(self._models.keys()) - logger.info(f"🧹 Cleaning up {len(model_ids)} models from registry") - - for model_id in model_ids: - self._unload_model(model_id) - - logger.info("✅ Model registry cleanup complete") - - -# Global singleton instance -_registry = ModelRegistry() - -def get_shared_model(model_id: str, model_file_path: str) -> YOLO: - """ - Convenience function to get a shared model instance. - - Args: - model_id: Unique identifier for the model - model_file_path: Path to the model file - - Returns: - YOLO model instance (shared across all callers) - """ - return _registry.get_model(model_id, model_file_path) - -def release_shared_model(model_id: str) -> None: - """ - Convenience function to release a shared model reference. - - Args: - model_id: Unique identifier for the model to release - """ - _registry.release_model(model_id) - -def get_registry_status() -> Dict[str, Any]: - """ - Convenience function to get registry status. - - Returns: - Dictionary with registry statistics - """ - return _registry.get_registry_status() - -def cleanup_registry() -> None: - """ - Convenience function to cleanup the entire registry. - """ - _registry.cleanup_all() \ No newline at end of file diff --git a/siwatsystem/mpta_manager.py b/siwatsystem/mpta_manager.py deleted file mode 100644 index 1abda3f..0000000 --- a/siwatsystem/mpta_manager.py +++ /dev/null @@ -1,375 +0,0 @@ -""" -Shared MPTA Manager for Disk Space Optimization - -This module implements shared MPTA file management to prevent duplicate downloads -and extractions when multiple cameras use the same model. MPTA files are stored -in modelId-based directories and shared across all cameras using that model. - -Key Features: -- Thread-safe MPTA downloading and extraction -- ModelId-based directory structure: models/{modelId}/ -- Reference counting for proper cleanup -- Eliminates duplicate MPTA downloads -- Maintains compatibility with existing pipeline system -""" - -import os -import threading -import logging -import shutil -import requests -from typing import Dict, Set, Optional -from urllib.parse import urlparse -from .pympta import load_pipeline_from_zip - -# Create a logger for this module -logger = logging.getLogger("detector_worker.mpta_manager") - -class MPTAManager: - """ - Singleton class for managing shared MPTA files across multiple cameras. - - This manager ensures that each unique modelId is downloaded and extracted - only once, dramatically reducing disk usage and download time when multiple - cameras use the same model. - """ - - _instance = None - _lock = threading.Lock() - - def __new__(cls): - if cls._instance is None: - with cls._lock: - if cls._instance is None: - cls._instance = super(MPTAManager, cls).__new__(cls) - cls._instance._initialized = False - return cls._instance - - def __init__(self): - if self._initialized: - return - - self._initialized = True - - # Thread-safe storage for MPTA management - self._model_paths: Dict[int, str] = {} # modelId -> shared_extraction_path - self._mpta_file_paths: Dict[int, str] = {} # modelId -> local_mpta_file_path - self._reference_counts: Dict[int, int] = {} # modelId -> reference count - self._download_locks: Dict[int, threading.Lock] = {} # modelId -> download lock - self._cameras_using_model: Dict[int, Set[str]] = {} # modelId -> set of camera_ids - self._manager_lock = threading.RLock() # Reentrant lock for nested calls - - logger.info("🏭 Shared MPTA Manager initialized - ready for disk-optimized MPTA management") - - def get_or_download_mpta(self, model_id: int, model_url: str, camera_id: str) -> Optional[tuple[str, str]]: - """ - Get or download an MPTA file. Returns (extraction_path, mpta_file_path) if successful. - - Args: - model_id: Unique identifier for the model - model_url: URL to download the MPTA file from - camera_id: Identifier for the requesting camera - - Returns: - Tuple of (extraction_path, mpta_file_path), or None if failed - """ - with self._manager_lock: - # Track camera usage - if model_id not in self._cameras_using_model: - self._cameras_using_model[model_id] = set() - self._cameras_using_model[model_id].add(camera_id) - - # Check if model directory already exists on disk (from previous sessions) - if model_id not in self._model_paths: - potential_path = f"models/{model_id}" - if os.path.exists(potential_path) and os.path.isdir(potential_path): - # Directory exists from previous session, find the MPTA file - mpta_files = [f for f in os.listdir(potential_path) if f.endswith('.mpta')] - if mpta_files: - # Use the first .mpta file found - mpta_file_path = os.path.join(potential_path, mpta_files[0]) - self._model_paths[model_id] = potential_path - self._mpta_file_paths[model_id] = mpta_file_path - self._reference_counts[model_id] = 0 # Will be incremented below - logger.info(f"📂 Found existing MPTA modelId {model_id} from previous session") - - # Check if already available - if model_id in self._model_paths: - shared_path = self._model_paths[model_id] - mpta_file_path = self._mpta_file_paths.get(model_id) - if os.path.exists(shared_path) and mpta_file_path and os.path.exists(mpta_file_path): - self._reference_counts[model_id] += 1 - logger.info(f"📂 MPTA modelId {model_id} reused for camera {camera_id} (ref_count: {self._reference_counts[model_id]}) - SAVED DOWNLOAD!") - return (shared_path, mpta_file_path) - else: - # Path was deleted externally, clean up our records - logger.warning(f"⚠️ MPTA path for modelId {model_id} was deleted externally, will re-download") - del self._model_paths[model_id] - self._mpta_file_paths.pop(model_id, None) - self._reference_counts.pop(model_id, 0) - - # Need to download - get or create download lock for this modelId - if model_id not in self._download_locks: - self._download_locks[model_id] = threading.Lock() - - # Download with model-specific lock (released _manager_lock to allow other models) - download_lock = self._download_locks[model_id] - with download_lock: - # Double-check after acquiring download lock - with self._manager_lock: - if model_id in self._model_paths and os.path.exists(self._model_paths[model_id]): - mpta_file_path = self._mpta_file_paths.get(model_id) - if mpta_file_path and os.path.exists(mpta_file_path): - self._reference_counts[model_id] += 1 - logger.info(f"📂 MPTA modelId {model_id} became available during wait (ref_count: {self._reference_counts[model_id]})") - return (self._model_paths[model_id], mpta_file_path) - - # Actually download and extract - shared_path = f"models/{model_id}" - logger.info(f"🔄 Downloading NEW MPTA for modelId {model_id} from {model_url}") - - try: - # Ensure directory exists - os.makedirs(shared_path, exist_ok=True) - - # Download MPTA file - mpta_filename = self._extract_filename_from_url(model_url) or f"model_{model_id}.mpta" - local_mpta_path = os.path.join(shared_path, mpta_filename) - - if not self._download_file(model_url, local_mpta_path): - logger.error(f"❌ Failed to download MPTA for modelId {model_id}") - return None - - # Extract MPTA - pipeline_tree = load_pipeline_from_zip(local_mpta_path, shared_path) - if pipeline_tree is None: - logger.error(f"❌ Failed to extract MPTA for modelId {model_id}") - return None - - # Success - register in manager - with self._manager_lock: - self._model_paths[model_id] = shared_path - self._mpta_file_paths[model_id] = local_mpta_path - self._reference_counts[model_id] = 1 - - logger.info(f"✅ MPTA modelId {model_id} downloaded and registered (ref_count: 1)") - self._log_manager_status() - - return (shared_path, local_mpta_path) - - except Exception as e: - logger.error(f"❌ Error downloading/extracting MPTA for modelId {model_id}: {e}") - # Clean up partial download - if os.path.exists(shared_path): - shutil.rmtree(shared_path, ignore_errors=True) - return None - - def release_mpta(self, model_id: int, camera_id: str) -> None: - """ - Release a reference to an MPTA. If reference count reaches zero, - the MPTA directory may be cleaned up to free disk space. - - Args: - model_id: Unique identifier for the model to release - camera_id: Identifier for the camera releasing the reference - """ - with self._manager_lock: - if model_id not in self._reference_counts: - logger.warning(f"⚠️ Attempted to release unknown MPTA modelId {model_id} for camera {camera_id}") - return - - # Remove camera from usage tracking - if model_id in self._cameras_using_model: - self._cameras_using_model[model_id].discard(camera_id) - - self._reference_counts[model_id] -= 1 - logger.info(f"📉 MPTA modelId {model_id} reference count decreased to {self._reference_counts[model_id]} (released by {camera_id})") - - # Clean up if no more references - # if self._reference_counts[model_id] <= 0: - # self._cleanup_mpta(model_id) - - def _cleanup_mpta(self, model_id: int) -> None: - """ - Internal method to clean up an MPTA directory and free disk space. - """ - if model_id in self._model_paths: - shared_path = self._model_paths[model_id] - - try: - if os.path.exists(shared_path): - shutil.rmtree(shared_path) - logger.info(f"🗑️ Cleaned up MPTA directory: {shared_path}") - - # Remove from tracking - del self._model_paths[model_id] - self._mpta_file_paths.pop(model_id, None) - del self._reference_counts[model_id] - self._cameras_using_model.pop(model_id, None) - - # Clean up download lock (optional, could keep for future use) - self._download_locks.pop(model_id, None) - - logger.info(f"✅ MPTA modelId {model_id} fully cleaned up and disk space freed") - self._log_manager_status() - - except Exception as e: - logger.error(f"❌ Error cleaning up MPTA modelId {model_id}: {e}") - - def get_shared_path(self, model_id: int) -> Optional[str]: - """ - Get the shared extraction path for a modelId without downloading. - - Args: - model_id: Model identifier to look up - - Returns: - Shared path if available, None otherwise - """ - with self._manager_lock: - return self._model_paths.get(model_id) - - def get_manager_status(self) -> Dict: - """ - Get current status of the MPTA manager. - - Returns: - Dictionary with manager statistics - """ - with self._manager_lock: - return { - "total_mpta_models": len(self._model_paths), - "models": { - str(model_id): { - "shared_path": path, - "reference_count": self._reference_counts.get(model_id, 0), - "cameras_using": list(self._cameras_using_model.get(model_id, set())) - } - for model_id, path in self._model_paths.items() - }, - "total_references": sum(self._reference_counts.values()), - "active_downloads": len(self._download_locks) - } - - def _log_manager_status(self) -> None: - """Log current manager status for debugging.""" - status = self.get_manager_status() - logger.info(f"📊 MPTA Manager Status: {status['total_mpta_models']} unique models, {status['total_references']} total references") - for model_id, info in status['models'].items(): - cameras_str = ','.join(info['cameras_using'][:3]) # Show first 3 cameras - if len(info['cameras_using']) > 3: - cameras_str += f"+{len(info['cameras_using'])-3} more" - logger.debug(f" 📋 ModelId {model_id}: refs={info['reference_count']}, cameras=[{cameras_str}]") - - def cleanup_all(self) -> None: - """ - Clean up all MPTA directories. Used during shutdown. - """ - with self._manager_lock: - model_ids = list(self._model_paths.keys()) - logger.info(f"🧹 Cleaning up {len(model_ids)} MPTA directories") - - for model_id in model_ids: - self._cleanup_mpta(model_id) - - # Clear all tracking data - self._download_locks.clear() - logger.info("✅ MPTA manager cleanup complete") - - def _download_file(self, url: str, local_path: str) -> bool: - """ - Download a file from URL to local path with progress logging. - - Args: - url: URL to download from - local_path: Local path to save to - - Returns: - True if successful, False otherwise - """ - try: - logger.info(f"⬇️ Starting download from {url}") - - response = requests.get(url, stream=True) - response.raise_for_status() - - total_size = int(response.headers.get('content-length', 0)) - if total_size > 0: - logger.info(f"📦 File size: {total_size / 1024 / 1024:.2f} MB") - - downloaded = 0 - last_logged_progress = 0 - with open(local_path, 'wb') as f: - for chunk in response.iter_content(chunk_size=8192): - if chunk: - f.write(chunk) - downloaded += len(chunk) - - if total_size > 0: - progress = int((downloaded / total_size) * 100) - # Log at 10% intervals (10%, 20%, 30%, etc.) - if progress >= last_logged_progress + 10 and progress <= 100: - logger.debug(f"Download progress: {progress}%") - last_logged_progress = progress - - logger.info(f"✅ Successfully downloaded to {local_path}") - return True - - except Exception as e: - logger.error(f"❌ Download failed: {e}") - # Clean up partial file - if os.path.exists(local_path): - os.remove(local_path) - return False - - def _extract_filename_from_url(self, url: str) -> Optional[str]: - """Extract filename from URL.""" - try: - parsed = urlparse(url) - filename = os.path.basename(parsed.path) - return filename if filename else None - except Exception: - return None - - -# Global singleton instance -_mpta_manager = MPTAManager() - -def get_or_download_mpta(model_id: int, model_url: str, camera_id: str) -> Optional[tuple[str, str]]: - """ - Convenience function to get or download a shared MPTA. - - Args: - model_id: Unique identifier for the model - model_url: URL to download the MPTA file from - camera_id: Identifier for the requesting camera - - Returns: - Tuple of (extraction_path, mpta_file_path), or None if failed - """ - return _mpta_manager.get_or_download_mpta(model_id, model_url, camera_id) - -def release_mpta(model_id: int, camera_id: str) -> None: - """ - Convenience function to release a shared MPTA reference. - - Args: - model_id: Unique identifier for the model to release - camera_id: Identifier for the camera releasing the reference - """ - _mpta_manager.release_mpta(model_id, camera_id) - -def get_mpta_manager_status() -> Dict: - """ - Convenience function to get MPTA manager status. - - Returns: - Dictionary with manager statistics - """ - return _mpta_manager.get_manager_status() - -def cleanup_mpta_manager() -> None: - """ - Convenience function to cleanup the entire MPTA manager. - """ - _mpta_manager.cleanup_all() \ No newline at end of file diff --git a/siwatsystem/pympta.py b/siwatsystem/pympta.py deleted file mode 100644 index ac34d88..0000000 --- a/siwatsystem/pympta.py +++ /dev/null @@ -1,1790 +0,0 @@ -import os -import json -import logging -import torch -import cv2 -import zipfile -import shutil -import traceback -import redis -import time -import uuid -import concurrent.futures -from ultralytics import YOLO -from urllib.parse import urlparse -from .database import DatabaseManager -from .model_registry import get_shared_model, release_shared_model -from datetime import datetime - -# 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 = {} - -# Session timeout configuration (waiting for backend sessionId) -_session_timeout_seconds = 15 - -def validate_redis_config(redis_config: dict) -> bool: - """Validate Redis configuration parameters.""" - required_fields = ["host", "port"] - for field in required_fields: - if field not in redis_config: - logger.error(f"Missing required Redis config field: {field}") - return False - - if not isinstance(redis_config["port"], int) or redis_config["port"] <= 0: - logger.error(f"Invalid Redis port: {redis_config['port']}") - return False - - return True - -def validate_postgresql_config(pg_config: dict) -> bool: - """Validate PostgreSQL configuration parameters.""" - required_fields = ["host", "port", "database", "username", "password"] - for field in required_fields: - if field not in pg_config: - logger.error(f"Missing required PostgreSQL config field: {field}") - return False - - if not isinstance(pg_config["port"], int) or pg_config["port"] <= 0: - logger.error(f"Invalid PostgreSQL port: {pg_config['port']}") - return False - - return True - -def crop_region_by_class(frame, regions_dict, class_name): - """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 - - # Diagnostic logging for crop issues - frame_h, frame_w = frame.shape[:2] - logger.debug(f"CROP DEBUG: Frame dimensions: {frame_w}x{frame_h}") - logger.debug(f"CROP DEBUG: Original bbox: {bbox}") - logger.debug(f"CROP DEBUG: Bbox dimensions: {x2-x1}x{y2-y1}") - - # Check if bbox is within frame bounds - if x1 < 0 or y1 < 0 or x2 > frame_w or y2 > frame_h: - logger.warning(f"CROP DEBUG: Bbox extends beyond frame! Clipping...") - x1, y1 = max(0, x1), max(0, y1) - x2, y2 = min(frame_w, x2), min(frame_h, y2) - logger.debug(f"CROP DEBUG: Clipped bbox: ({x1}, {y1}, {x2}, {y2})") - - cropped = frame[y1:y2, x1:x2] - - if cropped.size == 0: - logger.warning(f"Empty crop for class '{class_name}' with bbox {bbox}") - return None - - logger.debug(f"CROP DEBUG: Successful crop shape: {cropped.shape}") - return cropped - -def format_action_context(base_context, additional_context=None): - """Format action context with dynamic values.""" - context = {**base_context} - if additional_context: - context.update(additional_context) - return context - -def load_pipeline_node(node_config: dict, mpta_dir: str, redis_client, db_manager=None) -> dict: - # Recursively load a model node from configuration. - model_path = os.path.join(mpta_dir, node_config["modelFile"]) - if not os.path.exists(model_path): - logger.error(f"Model file {model_path} not found. Current directory: {os.getcwd()}") - logger.error(f"Directory content: {os.listdir(os.path.dirname(model_path))}") - raise FileNotFoundError(f"Model file {model_path} not found.") - - # Use shared model registry to prevent duplicate loading - model_id = node_config['modelId'] - logger.info(f"Getting shared model for node {model_id} from {model_path}") - model = get_shared_model(model_id, model_path) - - # Prepare trigger class indices for optimization - trigger_classes = node_config.get("triggerClasses", []) - trigger_class_indices = None - if trigger_classes and hasattr(model, "names"): - # Convert class names to indices for the model - trigger_class_indices = [i for i, name in model.names.items() - if name in trigger_classes] - logger.debug(f"Converted trigger classes to indices: {trigger_class_indices}") - - # Extract stability threshold from main pipeline config (not tracking config) - tracking_config = node_config.get("tracking", {"enabled": True, "reidConfigPath": "botsort.yaml"}) - stability_threshold = node_config.get("stabilityThreshold", 4) # Read from main config, default to 4 - - node = { - "modelId": node_config["modelId"], - "modelFile": node_config["modelFile"], - "triggerClasses": trigger_classes, - "triggerClassIndices": trigger_class_indices, - "classMapping": node_config.get("classMapping", {}), - "crop": node_config.get("crop", False), - "cropClass": node_config.get("cropClass"), - "minConfidence": node_config.get("minConfidence", None), - "frontalMinConfidence": node_config.get("frontalMinConfidence", None), - "minBboxAreaRatio": node_config.get("minBboxAreaRatio", 0.0), - "multiClass": node_config.get("multiClass", False), - "expectedClasses": node_config.get("expectedClasses", []), - "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, - "db_manager": db_manager - } - logger.debug(f"Configured node {node_config['modelId']} with trigger classes: {node['triggerClasses']}") - for child in node_config.get("branches", []): - logger.debug(f"Loading branch for parent node {node_config['modelId']}") - node["branches"].append(load_pipeline_node(child, mpta_dir, redis_client, db_manager)) - return node - -def load_pipeline_from_zip(zip_source: str, target_dir: str) -> dict: - logger.info(f"Attempting to load pipeline from {zip_source} to {target_dir}") - os.makedirs(target_dir, exist_ok=True) - zip_path = os.path.join(target_dir, "pipeline.mpta") - - # Parse the source; only local files are supported here. - parsed = urlparse(zip_source) - if parsed.scheme in ("", "file"): - local_path = parsed.path if parsed.scheme == "file" else zip_source - logger.debug(f"Checking if local file exists: {local_path}") - if os.path.exists(local_path): - try: - shutil.copy(local_path, zip_path) - logger.info(f"Copied local .mpta file from {local_path} to {zip_path}") - except Exception as e: - logger.error(f"Failed to copy local .mpta file from {local_path}: {str(e)}", exc_info=True) - return None - else: - logger.error(f"Local file {local_path} does not exist. Current directory: {os.getcwd()}") - # List all subdirectories of models directory to help debugging - if os.path.exists("models"): - logger.error(f"Content of models directory: {os.listdir('models')}") - for root, dirs, files in os.walk("models"): - logger.error(f"Directory {root} contains subdirs: {dirs} and files: {files}") - else: - logger.error("The models directory doesn't exist") - return None - else: - logger.error(f"HTTP download functionality has been moved. Use a local file path here. Received: {zip_source}") - return None - - try: - if not os.path.exists(zip_path): - logger.error(f"Zip file not found at expected location: {zip_path}") - return None - - logger.debug(f"Extracting .mpta file from {zip_path} to {target_dir}") - # Extract contents and track the directories created - extracted_dirs = [] - with zipfile.ZipFile(zip_path, "r") as zip_ref: - file_list = zip_ref.namelist() - logger.debug(f"Files in .mpta archive: {file_list}") - - # Extract and track the top-level directories - for file_path in file_list: - parts = file_path.split('/') - if len(parts) > 1: - top_dir = parts[0] - if top_dir and top_dir not in extracted_dirs: - extracted_dirs.append(top_dir) - - # Now extract the files - zip_ref.extractall(target_dir) - - logger.info(f"Successfully extracted .mpta file to {target_dir}") - logger.debug(f"Extracted directories: {extracted_dirs}") - - # Check what was actually created after extraction - actual_dirs = [d for d in os.listdir(target_dir) if os.path.isdir(os.path.join(target_dir, d))] - logger.debug(f"Actual directories created: {actual_dirs}") - except zipfile.BadZipFile as e: - logger.error(f"Bad zip file {zip_path}: {str(e)}", exc_info=True) - return None - except Exception as e: - logger.error(f"Failed to extract .mpta file {zip_path}: {str(e)}", exc_info=True) - return None - finally: - if os.path.exists(zip_path): - os.remove(zip_path) - logger.debug(f"Removed temporary zip file: {zip_path}") - - # Use the first extracted directory if it exists, otherwise use the expected name - pipeline_name = os.path.basename(zip_source) - pipeline_name = os.path.splitext(pipeline_name)[0] - - # Find the directory with pipeline.json - mpta_dir = None - # First try the expected directory name - expected_dir = os.path.join(target_dir, pipeline_name) - if os.path.exists(expected_dir) and os.path.exists(os.path.join(expected_dir, "pipeline.json")): - mpta_dir = expected_dir - logger.debug(f"Found pipeline.json in the expected directory: {mpta_dir}") - else: - # Look through all subdirectories for pipeline.json - for subdir in actual_dirs: - potential_dir = os.path.join(target_dir, subdir) - if os.path.exists(os.path.join(potential_dir, "pipeline.json")): - mpta_dir = potential_dir - logger.info(f"Found pipeline.json in directory: {mpta_dir} (different from expected: {expected_dir})") - break - - if not mpta_dir: - logger.error(f"Could not find pipeline.json in any extracted directory. Directory content: {os.listdir(target_dir)}") - return None - - pipeline_json_path = os.path.join(mpta_dir, "pipeline.json") - if not os.path.exists(pipeline_json_path): - logger.error(f"pipeline.json not found in the .mpta file. Files in directory: {os.listdir(mpta_dir)}") - return None - - try: - with open(pipeline_json_path, "r") as f: - pipeline_config = json.load(f) - logger.info(f"Successfully loaded pipeline configuration from {pipeline_json_path}") - logger.debug(f"Pipeline config: {json.dumps(pipeline_config, indent=2)}") - - # Establish Redis connection if configured - redis_client = None - if "redis" in pipeline_config: - redis_config = pipeline_config["redis"] - if not validate_redis_config(redis_config): - logger.error("Invalid Redis configuration, skipping Redis connection") - else: - try: - redis_client = redis.Redis( - host=redis_config["host"], - port=redis_config["port"], - password=redis_config.get("password"), - db=redis_config.get("db", 0), - decode_responses=True - ) - redis_client.ping() - logger.info(f"Successfully connected to Redis at {redis_config['host']}:{redis_config['port']}") - except redis.exceptions.ConnectionError as e: - logger.error(f"Failed to connect to Redis: {e}") - redis_client = None - - # Establish PostgreSQL connection if configured - db_manager = None - if "postgresql" in pipeline_config: - pg_config = pipeline_config["postgresql"] - if not validate_postgresql_config(pg_config): - logger.error("Invalid PostgreSQL configuration, skipping database connection") - else: - try: - db_manager = DatabaseManager(pg_config) - if db_manager.connect(): - logger.info(f"Successfully connected to PostgreSQL at {pg_config['host']}:{pg_config['port']}") - else: - logger.error("Failed to connect to PostgreSQL") - db_manager = None - except Exception as e: - logger.error(f"Error initializing PostgreSQL connection: {e}") - db_manager = None - - return load_pipeline_node(pipeline_config["pipeline"], mpta_dir, redis_client, db_manager) - except json.JSONDecodeError as e: - logger.error(f"Error parsing pipeline.json: {str(e)}", exc_info=True) - return None - except KeyError as e: - logger.error(f"Missing key in pipeline.json: {str(e)}", exc_info=True) - return None - except Exception as e: - logger.error(f"Error loading pipeline.json: {str(e)}", exc_info=True) - return None - -def execute_actions(node, frame, detection_result, regions_dict=None): - if not node["redis_client"] or not node["actions"]: - return - - # Create a dynamic context for this detection event - from datetime import datetime - action_context = { - **detection_result, - "timestamp_ms": int(time.time() * 1000), - "uuid": str(uuid.uuid4()), - "timestamp": datetime.now().strftime("%Y-%m-%dT%H-%M-%S"), - "filename": f"{uuid.uuid4()}.jpg" - } - - for action in node["actions"]: - try: - if action["type"] == "redis_save_image": - key = action["key"].format(**action_context) - - # Check if we need to crop a specific region - region_name = action.get("region") - image_to_save = frame - - if region_name and regions_dict: - cropped_image = crop_region_by_class(frame, regions_dict, region_name) - if cropped_image is not None: - image_to_save = cropped_image - logger.debug(f"Cropped region '{region_name}' for redis_save_image") - else: - logger.warning(f"Could not crop region '{region_name}', saving full frame instead") - - # Encode image with specified format and quality (default to JPEG) - img_format = action.get("format", "jpeg").lower() - quality = action.get("quality", 90) - - if img_format == "jpeg": - encode_params = [cv2.IMWRITE_JPEG_QUALITY, quality] - success, buffer = cv2.imencode('.jpg', image_to_save, encode_params) - elif img_format == "png": - success, buffer = cv2.imencode('.png', image_to_save) - else: - success, buffer = cv2.imencode('.jpg', image_to_save, [cv2.IMWRITE_JPEG_QUALITY, quality]) - - if not success: - logger.error(f"Failed to encode image for redis_save_image") - continue - - expire_seconds = action.get("expire_seconds") - if expire_seconds: - node["redis_client"].setex(key, expire_seconds, buffer.tobytes()) - logger.info(f"Saved image to Redis with key: {key} (expires in {expire_seconds}s)") - else: - node["redis_client"].set(key, buffer.tobytes()) - logger.info(f"Saved image to Redis with key: {key}") - action_context["image_key"] = key - elif action["type"] == "redis_publish": - channel = action["channel"] - try: - # Handle JSON message format by creating it programmatically - message_template = action["message"] - - # Check if the message is JSON-like (starts and ends with braces) - if message_template.strip().startswith('{') and message_template.strip().endswith('}'): - # Create JSON data programmatically to avoid formatting issues - json_data = {} - - # Add common fields - json_data["event"] = "frontal_detected" - json_data["display_id"] = action_context.get("display_id", "unknown") - json_data["session_id"] = action_context.get("session_id") - json_data["timestamp"] = action_context.get("timestamp", "") - json_data["image_key"] = action_context.get("image_key", "") - - # Convert to JSON string - message = json.dumps(json_data) - else: - # Use regular string formatting for non-JSON messages - message = message_template.format(**action_context) - - # Publish to Redis - if not node["redis_client"]: - logger.error("Redis client is None, cannot publish message") - continue - - # Test Redis connection - try: - node["redis_client"].ping() - logger.debug("Redis connection is active") - except Exception as ping_error: - logger.error(f"Redis connection test failed: {ping_error}") - continue - - result = node["redis_client"].publish(channel, message) - logger.info(f"Published message to Redis channel '{channel}': {message}") - logger.info(f"Redis publish result (subscribers count): {result}") - - # Additional debug info - if result == 0: - logger.warning(f"No subscribers listening to channel '{channel}'") - else: - logger.info(f"Message delivered to {result} subscriber(s)") - - except KeyError as e: - logger.error(f"Missing key in redis_publish message template: {e}") - logger.debug(f"Available context keys: {list(action_context.keys())}") - except Exception as e: - logger.error(f"Error in redis_publish action: {e}") - logger.debug(f"Message template: {action['message']}") - logger.debug(f"Available context keys: {list(action_context.keys())}") - import traceback - logger.debug(f"Full traceback: {traceback.format_exc()}") - except Exception as e: - logger.error(f"Error executing action {action['type']}: {e}") - -def execute_parallel_actions(node, frame, detection_result, regions_dict): - """Execute parallel actions after all required branches have completed.""" - if not node.get("parallelActions"): - return - - logger.debug("Executing parallel actions...") - branch_results = detection_result.get("branch_results", {}) - - for action in node["parallelActions"]: - try: - action_type = action.get("type") - logger.debug(f"Processing parallel action: {action_type}") - - if action_type == "postgresql_update_combined": - # Check if all required branches have completed - wait_for_branches = action.get("waitForBranches", []) - missing_branches = [branch for branch in wait_for_branches if branch not in branch_results] - - if missing_branches: - logger.warning(f"Cannot execute postgresql_update_combined: missing branch results for {missing_branches}") - continue - - logger.info(f"All required branches completed: {wait_for_branches}") - - # Execute the database update - execute_postgresql_update_combined(node, action, detection_result, branch_results) - else: - logger.warning(f"Unknown parallel action type: {action_type}") - - except Exception as e: - logger.error(f"Error executing parallel action {action.get('type', 'unknown')}: {e}") - import traceback - logger.debug(f"Full traceback: {traceback.format_exc()}") - -def execute_postgresql_update_combined(node, action, detection_result, branch_results): - """Execute a PostgreSQL update with combined branch results.""" - if not node.get("db_manager"): - logger.error("No database manager available for postgresql_update_combined action") - return - - try: - table = action["table"] - key_field = action["key_field"] - key_value_template = action["key_value"] - fields = action["fields"] - - # Create context for key value formatting - action_context = {**detection_result} - key_value = key_value_template.format(**action_context) - - logger.info(f"Executing database update: table={table}, {key_field}={key_value}") - logger.debug(f"Available branch results: {list(branch_results.keys())}") - - # Process field mappings - mapped_fields = {} - for db_field, value_template in fields.items(): - try: - mapped_value = resolve_field_mapping(value_template, branch_results, action_context) - if mapped_value is not None: - mapped_fields[db_field] = mapped_value - logger.info(f"Mapped field: {db_field} = {mapped_value}") - else: - logger.warning(f"Could not resolve field mapping for {db_field}: {value_template}") - logger.debug(f"Available branch results: {branch_results}") - except Exception as e: - logger.error(f"Error mapping field {db_field} with template '{value_template}': {e}") - import traceback - logger.debug(f"Field mapping error traceback: {traceback.format_exc()}") - - if not mapped_fields: - logger.warning("No fields mapped successfully, skipping database update") - logger.debug(f"Branch results available: {branch_results}") - logger.debug(f"Field templates: {fields}") - return - - # Add updated_at field automatically - mapped_fields["updated_at"] = "NOW()" - - # Execute the database update - logger.info(f"Attempting database update with fields: {mapped_fields}") - success = node["db_manager"].execute_update(table, key_field, key_value, mapped_fields) - - if success: - logger.info(f"✅ Successfully updated database: {table} with {len(mapped_fields)} fields") - logger.info(f"Updated fields: {mapped_fields}") - else: - logger.error(f"❌ Failed to update database: {table}") - logger.error(f"Attempted update with: {key_field}={key_value}, fields={mapped_fields}") - - except KeyError as e: - logger.error(f"Missing required field in postgresql_update_combined action: {e}") - logger.debug(f"Action config: {action}") - except Exception as e: - logger.error(f"Error in postgresql_update_combined action: {e}") - import traceback - logger.debug(f"Full traceback: {traceback.format_exc()}") - -def resolve_field_mapping(value_template, branch_results, action_context): - """Resolve field mapping templates like {car_brand_cls_v1.brand}.""" - try: - logger.debug(f"Resolving field mapping: '{value_template}'") - logger.debug(f"Available branch results: {list(branch_results.keys())}") - - # Handle simple context variables first (non-branch references) - if not '.' in value_template: - result = value_template.format(**action_context) - logger.debug(f"Simple template resolved: '{value_template}' -> '{result}'") - return result - - # Handle branch result references like {model_id.field} - import re - branch_refs = re.findall(r'\{([^}]+\.[^}]+)\}', value_template) - logger.debug(f"Found branch references: {branch_refs}") - - resolved_template = value_template - for ref in branch_refs: - try: - model_id, field_name = ref.split('.', 1) - logger.debug(f"Processing branch reference: model_id='{model_id}', field_name='{field_name}'") - - if model_id in branch_results: - branch_data = branch_results[model_id] - logger.debug(f"Branch '{model_id}' data: {branch_data}") - - if field_name in branch_data: - field_value = branch_data[field_name] - resolved_template = resolved_template.replace(f'{{{ref}}}', str(field_value)) - logger.info(f"✅ Resolved {ref} to '{field_value}'") - else: - logger.warning(f"Field '{field_name}' not found in branch '{model_id}' results.") - logger.debug(f"Available fields in '{model_id}': {list(branch_data.keys())}") - - # Try alternative field names based on the class result and model type - if isinstance(branch_data, dict): - fallback_value = None - - # First, try the exact field name - if field_name in branch_data: - fallback_value = branch_data[field_name] - # Then try 'class' field as fallback - elif 'class' in branch_data: - fallback_value = branch_data['class'] - logger.info(f"Using 'class' field as fallback for '{field_name}': '{fallback_value}'") - # For brand models, also check if the class name exists as a key - elif field_name == 'brand' and branch_data.get('class') in branch_data: - fallback_value = branch_data[branch_data['class']] - logger.info(f"Found brand value using class name as key: '{fallback_value}'") - # For body_type models, also check if the class name exists as a key - elif field_name == 'body_type' and branch_data.get('class') in branch_data: - fallback_value = branch_data[branch_data['class']] - logger.info(f"Found body_type value using class name as key: '{fallback_value}'") - - if fallback_value is not None: - resolved_template = resolved_template.replace(f'{{{ref}}}', str(fallback_value)) - logger.info(f"✅ Resolved {ref} to '{fallback_value}' (using fallback)") - else: - logger.error(f"No suitable field found for '{field_name}' in branch '{model_id}'") - logger.debug(f"Branch data structure: {branch_data}") - return None - else: - logger.error(f"Branch data for '{model_id}' is not a dictionary: {type(branch_data)}") - return None - else: - logger.warning(f"Branch '{model_id}' not found in results. Available branches: {list(branch_results.keys())}") - return None - except ValueError as e: - logger.error(f"Invalid branch reference format: {ref}") - return None - - # Format any remaining simple variables - try: - final_value = resolved_template.format(**action_context) - logger.debug(f"Final resolved value: '{final_value}'") - return final_value - except KeyError as e: - logger.warning(f"Could not resolve context variable in template: {e}") - return resolved_template - - except Exception as e: - logger.error(f"Error resolving field mapping '{value_template}': {e}") - import traceback - logger.debug(f"Field mapping error traceback: {traceback.format_exc()}") - return None - -def run_detection_with_tracking(frame, node, context=None): - """ - Structured function for running YOLO detection with BoT-SORT tracking. - Now includes track ID-based validation requiring N consecutive frames of the same track ID. - - 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, track_validation_result) where: - - all_detections: List of all detection objects - - regions_dict: Dict mapping class names to highest confidence detections - - track_validation_result: Dict with validation status and stable tracks - - 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 - - method: Tracking method ("botsort") - - reidConfig: Path to ReID config file - - stabilityThreshold: Number of consecutive frames required for validation - """ - try: - # Extract tracking configuration - tracking_config = node.get("tracking", {}) - tracking_enabled = tracking_config.get("enabled", True) - reid_config_path = tracking_config.get("reidConfig", tracking_config.get("reidConfigPath", "botsort.yaml")) - stability_threshold = tracking_config.get("stabilityThreshold", node.get("stabilityThreshold", 4)) - - # 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 - - # Tracking zones removed - process all detections - - # 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}, stability_threshold: {stability_threshold}, classes: {node.get('triggerClasses', 'all')}") - - # Use predict for detection-only models (frontal detection), track for main detection models - model_id = node.get("modelId", "") - use_tracking = tracking_enabled and not ("frontal" in model_id.lower() or "detection" in model_id.lower()) - - if use_tracking: - # Use tracking for main detection models (yolo11m, etc.) - logger.debug(f"Using tracking for {model_id}") - res = node["model"].track( - frame, - stream=False, - persist=True, - **class_filter - )[0] - else: - # Use detection only for frontal detection and other detection-only models - logger.debug(f"Using prediction only for {model_id}") - res = node["model"].predict( - frame, - stream=False, - **class_filter - )[0] - - # Process detection results - candidate_detections = [] - # Use frontalMinConfidence for frontal detection models, otherwise use minConfidence - model_id = node.get("modelId", "") - if "frontal" in model_id.lower() and "frontalMinConfidence" in node: - min_confidence = node.get("frontalMinConfidence", 0.0) - logger.debug(f"Using frontalMinConfidence={min_confidence} for {model_id}") - else: - min_confidence = node.get("minConfidence", 0.0) - - if res.boxes is None or len(res.boxes) == 0: - logger.debug(f"🚫 Camera {camera_id}: YOLO returned no detections") - - # Update stability tracking even when no detection (to reset counters) - camera_id = context.get("camera_id", "unknown") if context else "unknown" - model_id = node.get("modelId", "unknown") - track_validation_result = update_single_track_stability(node, None, camera_id, frame.shape, stability_threshold, context) - - # Store validation state in context for pipeline decisions - if context is not None: - context["track_validation_result"] = track_validation_result - - return [], {}, track_validation_result - - logger.debug(f"🔍 Camera {camera_id}: YOLO detected {len(res.boxes)} raw objects - processing with tracking...") - - # First pass: collect all valid detections - logger.debug(f"🔍 Camera {camera_id}: === DETECTION ANALYSIS ===") - 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] - - # 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()) - - logger.debug(f"🔍 Camera {camera_id}: Detection {i+1}: class='{class_name}' conf={conf:.3f} track_id={track_id} bbox={bbox}") - - # Apply confidence filtering - if conf < min_confidence: - logger.debug(f"❌ Camera {camera_id}: Detection {i+1} REJECTED - confidence {conf:.3f} < {min_confidence}") - continue - - # Tracking zone validation removed - process all detections - - # Create detection object - detection = { - "class": class_name, - "confidence": conf, - "id": track_id, - "bbox": bbox, - "class_id": cls_id - } - - candidate_detections.append(detection) - logger.debug(f"✅ Camera {camera_id}: Detection {i+1} ACCEPTED as candidate: {class_name} (conf={conf:.3f}, track_id={track_id})") - - # Second pass: select only the highest confidence detection overall - if not candidate_detections: - logger.debug(f"🚫 Camera {camera_id}: No valid candidates after filtering - no car will be tracked") - - # Update stability tracking even when no detection (to reset counters) - camera_id = context.get("camera_id", "unknown") if context else "unknown" - model_id = node.get("modelId", "unknown") - track_validation_result = update_single_track_stability(node, None, camera_id, frame.shape, stability_threshold, context) - - # Store validation state in context for pipeline decisions - if context is not None: - context["track_validation_result"] = track_validation_result - - return [], {}, track_validation_result - - logger.debug(f"🏆 Camera {camera_id}: === SELECTING HIGHEST CONFIDENCE CAR ===") - for i, detection in enumerate(candidate_detections): - logger.debug(f"🏆 Camera {camera_id}: Candidate {i+1}: {detection['class']} conf={detection['confidence']:.3f} track_id={detection['id']}") - - # Show all candidate detections before selection - logger.debug(f"Found {len(candidate_detections)} candidate detections:") - for i, det in enumerate(candidate_detections): - logger.debug(f"Candidate {i+1}: {det['class']} conf={det['confidence']:.3f} bbox={det['bbox']}") - - # Find the single highest confidence detection across all detected classes - best_detection = max(candidate_detections, key=lambda x: x["confidence"]) - original_class = best_detection["class"] - track_id = best_detection["id"] - - logger.info(f"🎯 Camera {camera_id}: SELECTED WINNER: {original_class} (conf={best_detection['confidence']:.3f}, track_id={track_id}, bbox={best_detection['bbox']})") - - # Show which cars were NOT selected - for detection in candidate_detections: - if detection != best_detection: - logger.debug(f"🚫 Camera {camera_id}: NOT SELECTED: {detection['class']} (conf={detection['confidence']:.3f}, track_id={detection['id']}) - lower confidence") - - # Apply class mapping if configured - mapped_class = original_class - class_mapping = node.get("classMapping", {}) - if original_class in class_mapping: - mapped_class = class_mapping[original_class] - logger.info(f"Class mapping applied: {original_class} → {mapped_class}") - # Update the detection object with mapped class - best_detection["class"] = mapped_class - best_detection["original_class"] = original_class # Keep original for reference - - # Keep only the single best detection with mapped class - all_detections = [best_detection] - regions_dict = { - mapped_class: { - "bbox": best_detection["bbox"], - "confidence": best_detection["confidence"], - "detection": best_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"✅ Camera {camera_id}: DETECTION COMPLETE - tracking single car: track_id={track_id}, conf={best_detection['confidence']:.3f}") - logger.debug(f"📊 Camera {camera_id}: Detection summary: {len(res.boxes)} raw → {len(candidate_detections)} candidates → 1 selected") - - # Debug: Save vehicle crop for debugging (disabled for production) - # if node.get("modelId") in ["yolo11n", "yolo11m"] and regions_dict: - # try: - # import datetime - # os.makedirs("temp_debug", exist_ok=True) - # timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3] - # - # for class_name, region_data in regions_dict.items(): - # bbox = region_data['bbox'] - # x1, y1, x2, y2 = bbox - # cropped = frame[y1:y2, x1:x2] - # if cropped.size > 0: - # model_name = node.get("modelId", "yolo") - # debug_path = f"temp_debug/{model_name}_{class_name}_crop_{timestamp}.jpg" - # cv2.imwrite(debug_path, cropped) - # logger.debug(f"Saved {model_name} {class_name} crop to {debug_path}") - # except Exception as e: - # logger.error(f"Failed to save {node.get('modelId', 'yolo')} crop: {e}") - - # Update track-based stability tracking for the single selected car - camera_id = context.get("camera_id", "unknown") if context else "unknown" - model_id = node.get("modelId", "unknown") - - # Update stability tracking for the single best detection - track_validation_result = update_single_track_stability(node, best_detection, camera_id, frame.shape, stability_threshold, context) - - # Store validation state in context for pipeline decisions - if context is not None: - context["track_validation_result"] = track_validation_result - - return all_detections, regions_dict, track_validation_result - - 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 [], {}, {"validation_complete": False, "stable_tracks": [], "current_tracks": []} - - -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": {}, # Track ID -> consecutive frame count - "stable_tracks": set(), # Set of track IDs that have reached stability threshold - "session_state": { - "active": True, - "waiting_for_backend_session": False, - "wait_start_time": 0.0, - "reset_tracker_on_resume": False - } - # Removed obsolete occupancy_state - app.py handles all mode transitions now - } - - return _camera_stability_tracking[camera_id][model_id] - -def reset_camera_stability_tracking(camera_id, model_id): - """Reset all stability tracking data for a specific camera and model.""" - if camera_id in _camera_stability_tracking and model_id in _camera_stability_tracking[camera_id]: - stability_data = _camera_stability_tracking[camera_id][model_id] - - # Clear all tracking data - track_counters = stability_data["track_stability_counters"] - stable_tracks = stability_data["stable_tracks"] - session_state = stability_data["session_state"] - - old_counters = dict(track_counters) - old_stable = list(stable_tracks) - - track_counters.clear() - stable_tracks.clear() - - # IMPORTANT: Set flag to reset YOLO tracker on next detection run - # This will ensure track IDs start fresh (1, 2, 3...) instead of continuing from old IDs - session_state["reset_tracker_on_resume"] = True - - logger.info(f"🧹 Camera {camera_id}: CLEARED stability tracking - old_counters={old_counters}, old_stable={old_stable}") - logger.info(f"🔄 Camera {camera_id}: YOLO tracker will be reset on next detection - fresh track IDs will start from 1") - else: - logger.debug(f"🧹 Camera {camera_id}: No stability tracking data to clear for model {model_id}") - -def update_single_track_stability(node, detection, camera_id, frame_shape=None, stability_threshold=4, context=None): - """Update track stability validation for a single highest confidence car.""" - model_id = node.get("modelId", "unknown") - - # Branch nodes should not do validation - only main pipeline should - is_branch_node = node.get("cropClass") is not None or node.get("parallel") is True - if is_branch_node: - logger.debug(f"⏭️ Camera {camera_id}: Skipping validation for branch node {model_id} - validation only done at main pipeline level") - return {"validation_complete": False, "branch_node": True, "stable_tracks": [], "current_tracks": []} - - # Check current mode - VALIDATION COUNTERS should increment in both validation_detecting and full_pipeline modes - current_mode = context.get("current_mode", "unknown") if context else "unknown" - is_validation_mode = (current_mode in ["validation_detecting", "full_pipeline"]) - - # 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"] - - current_track_id = detection.get("id") if detection else None - - # ═══ MODE-AWARE TRACK VALIDATION ═══ - logger.debug(f"📋 Camera {camera_id}: === TRACK VALIDATION ANALYSIS ===") - logger.debug(f"📋 Camera {camera_id}: Current mode: {current_mode} (validation_mode={is_validation_mode})") - logger.debug(f"📋 Camera {camera_id}: Current track_id: {current_track_id} (assigned by YOLO tracking - not sequential)") - logger.debug(f"📋 Camera {camera_id}: Existing counters: {dict(track_counters)}") - logger.debug(f"📋 Camera {camera_id}: Stable tracks: {list(stable_tracks)}") - - # IMPORTANT: Only modify validation counters during validation_detecting mode - if not is_validation_mode: - logger.debug(f"🚫 Camera {camera_id}: NOT in validation mode - skipping counter modifications") - return { - "validation_complete": False, - "stable_tracks": list(stable_tracks), - "current_tracks": [current_track_id] if current_track_id is not None else [] - } - - if current_track_id is not None: - # Check if this is a different track than we were tracking - previous_track_ids = list(track_counters.keys()) - - # VALIDATION MODE: Reset counter if different track OR if track was previously stable - should_reset = ( - len(previous_track_ids) == 0 or # No previous tracking - current_track_id not in previous_track_ids or # Different track ID - current_track_id in stable_tracks # Track was stable - start fresh validation - ) - - logger.debug(f"📋 Camera {camera_id}: Previous track_ids: {previous_track_ids}") - logger.debug(f"📋 Camera {camera_id}: Track {current_track_id} was stable: {current_track_id in stable_tracks}") - logger.debug(f"📋 Camera {camera_id}: Should reset counters: {should_reset}") - - if should_reset: - # Clear all previous tracking - fresh validation needed - if previous_track_ids: - for old_track_id in previous_track_ids: - old_count = track_counters.pop(old_track_id, 0) - stable_tracks.discard(old_track_id) - logger.info(f"🔄 Camera {camera_id}: VALIDATION RESET - track {old_track_id} counter from {old_count} to 0 (reason: {'stable_track_restart' if current_track_id == old_track_id else 'different_track'})") - - # Start fresh validation for this track - old_count = track_counters.get(current_track_id, 0) # Store old count for logging - track_counters[current_track_id] = 1 - current_count = 1 - logger.info(f"🆕 Camera {camera_id}: FRESH VALIDATION - Track {current_track_id} starting at 1/{stability_threshold}") - else: - # Continue validation for same track - old_count = track_counters.get(current_track_id, 0) - track_counters[current_track_id] = old_count + 1 - current_count = track_counters[current_track_id] - - logger.debug(f"🔢 Camera {camera_id}: Track {current_track_id} counter: {old_count} → {current_count}") - logger.info(f"🔍 Camera {camera_id}: Track ID {current_track_id} validation {current_count}/{stability_threshold}") - - # Check if track has reached stability threshold - logger.debug(f"📊 Camera {camera_id}: Checking stability: {current_count} >= {stability_threshold}? {current_count >= stability_threshold}") - logger.debug(f"📊 Camera {camera_id}: Already stable: {current_track_id in stable_tracks}") - - if current_count >= stability_threshold and current_track_id not in stable_tracks: - stable_tracks.add(current_track_id) - logger.info(f"✅ Camera {camera_id}: Track ID {current_track_id} STABLE after {current_count} consecutive frames") - logger.info(f"🎯 Camera {camera_id}: TRACK VALIDATION COMPLETE") - logger.debug(f"🎯 Camera {camera_id}: Stable tracks now: {list(stable_tracks)}") - return { - "validation_complete": True, - "send_none_detection": True, - "stable_tracks": [current_track_id], - "newly_stable_tracks": [current_track_id], - "current_tracks": [current_track_id] - } - elif current_count >= stability_threshold: - logger.debug(f"📊 Camera {camera_id}: Track {current_track_id} already stable - not re-adding") - else: - # No car detected - ALWAYS clear all tracking and reset counters - logger.debug(f"🚫 Camera {camera_id}: NO CAR DETECTED - clearing all tracking") - if track_counters or stable_tracks: - logger.debug(f"🚫 Camera {camera_id}: Existing state before reset: counters={dict(track_counters)}, stable={list(stable_tracks)}") - for track_id in list(track_counters.keys()): - old_count = track_counters.pop(track_id, 0) - logger.info(f"🔄 Camera {camera_id}: No car detected - RESET track {track_id} counter from {old_count} to 0") - track_counters.clear() # Ensure complete reset - stable_tracks.clear() # Clear all stable tracks - logger.info(f"✅ Camera {camera_id}: RESET TO VALIDATION PHASE - All counters and stable tracks cleared") - else: - logger.debug(f"🚫 Camera {camera_id}: No existing counters to clear") - logger.debug(f"Camera {camera_id}: VALIDATION - no car detected (all counters reset)") - - # Final return - validation not complete - result = { - "validation_complete": False, - "stable_tracks": list(stable_tracks), - "current_tracks": [current_track_id] if current_track_id is not None else [] - } - - logger.debug(f"📋 Camera {camera_id}: Track stability result: {result}") - logger.debug(f"📋 Camera {camera_id}: Final counters: {dict(track_counters)}") - logger.debug(f"📋 Camera {camera_id}: Final stable tracks: {list(stable_tracks)}") - - return result - -# Keep the old function for backward compatibility but mark as deprecated -def update_track_stability_validation(node, detections, camera_id, frame_shape=None, stability_threshold=4): - """DEPRECATED: Use update_single_track_stability instead.""" - logger.warning(f"update_track_stability_validation called for camera {camera_id} - this function is deprecated, use update_single_track_stability instead") - if detections: - best_detection = max(detections, key=lambda x: x.get("confidence", 0)) - return update_single_track_stability(node, best_detection, camera_id, frame_shape, stability_threshold, None) - else: - return update_single_track_stability(node, None, camera_id, frame_shape, stability_threshold, None) - -def update_detection_stability(node, detections, camera_id, frame_shape=None): - """Legacy detection-based stability counter - DEPRECATED.""" - # This function is deprecated in favor of track-based validation only - logger.warning(f"update_detection_stability called for camera {camera_id} - this function is deprecated, use track-based validation instead") - return {"validation_complete": False, "valid_detections": 0, "deprecated": True} - -def update_track_stability(node, detections, camera_id, frame_shape=None): - """DEPRECATED: This function is obsolete and should not be used.""" - logger.warning(f"update_track_stability called for camera {camera_id} - this function is deprecated and obsolete") - return {"phase": "validation", "absence_counter": 0, "deprecated": True} - -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 for track-based stability - 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 reset_tracking_state(camera_id, model_id, reason="session ended"): - """Reset tracking state after session completion or timeout.""" - stability_data = get_camera_stability_data(camera_id, model_id) - session_state = stability_data["session_state"] - - # Clear all tracking data for fresh start - stability_data["track_stability_counters"].clear() - stability_data["stable_tracks"].clear() - session_state["active"] = True - session_state["waiting_for_backend_session"] = False - session_state["wait_start_time"] = 0.0 - session_state["reset_tracker_on_resume"] = True - - logger.info(f"Camera {camera_id}: 🔄 Reset tracking state - {reason}") - logger.info(f"Camera {camera_id}: 🧹 Cleared stability counters and stable tracks for fresh session") - -def is_camera_active(camera_id, model_id): - """Check if camera should be processing detections.""" - stability_data = get_camera_stability_data(camera_id, model_id) - session_state = stability_data["session_state"] - - # Check if waiting for backend sessionId has timed out - if session_state.get("waiting_for_backend_session", False): - current_time = time.time() - wait_start_time = session_state.get("wait_start_time", 0) - elapsed_time = current_time - wait_start_time - - if elapsed_time >= _session_timeout_seconds: - logger.warning(f"Camera {camera_id}: Backend sessionId timeout ({_session_timeout_seconds}s) - resetting tracking") - reset_tracking_state(camera_id, model_id, "backend sessionId timeout") - return True - else: - remaining_time = _session_timeout_seconds - elapsed_time - logger.debug(f"Camera {camera_id}: Still waiting for backend sessionId - {remaining_time:.1f}s remaining") - return False - - return session_state.get("active", True) - -def cleanup_pipeline_node(node: dict): - """Clean up a pipeline node and release its model reference.""" - if node and "modelId" in node: - model_id = node["modelId"] - logger.info(f"🧹 Cleaning up pipeline node: {model_id}") - release_shared_model(model_id) - - # Recursively clean up branches - for branch in node.get("branches", []): - cleanup_pipeline_node(branch) - -def cleanup_camera_stability(camera_id): - """Clean up stability tracking data when a camera is disconnected.""" - global _camera_stability_tracking - if camera_id in _camera_stability_tracking: - del _camera_stability_tracking[camera_id] - logger.info(f"Cleaned up stability tracking data for camera {camera_id}") - -def occupancy_detector(camera_id, model_id, enable=True): - """ - Temporary function to stop model inference after pipeline completion. - - Args: - camera_id (str): Camera identifier - model_id (str): Model identifier - enable (bool): True to enable occupancy mode (stop model after pipeline), False to disable - - When enabled: - - Model stops inference after completing full pipeline - - Backend sessionId handling continues in background - - Note: This is a temporary function that will be changed in the future. - """ - stability_data = get_camera_stability_data(camera_id, model_id) - session_state = stability_data["session_state"] - - if enable: - session_state["occupancy_mode"] = True - session_state["occupancy_enabled_at"] = time.time() - # Occupancy mode logging removed - not used in enhanced lightweight mode - else: - session_state["occupancy_mode"] = False - session_state.pop("occupancy_enabled_at", None) - # Occupancy mode logging removed - not used in enhanced lightweight mode - - return session_state.get("occupancy_mode", False) - -def validate_pipeline_execution(node, regions_dict): - """ - Pre-validate that all required branches will execute successfully before - committing to Redis actions and database records. - - Returns: - - (True, []) if pipeline can execute completely - - (False, missing_branches) if some required branches won't execute - """ - # Get all branches that parallel actions are waiting for - required_branches = set() - - for action in node.get("parallelActions", []): - if action.get("type") == "postgresql_update_combined": - wait_for_branches = action.get("waitForBranches", []) - required_branches.update(wait_for_branches) - - if not required_branches: - # No parallel actions requiring specific branches - logger.debug("No parallel actions with waitForBranches - validation passes") - return True, [] - - logger.debug(f"Pre-validation: checking if required branches {list(required_branches)} will execute") - - # Check each required branch - missing_branches = [] - - for branch in node.get("branches", []): - branch_id = branch["modelId"] - - if branch_id not in required_branches: - continue # This branch is not required by parallel actions - - # Check if this branch would be triggered - trigger_classes = branch.get("triggerClasses", []) - min_conf = branch.get("minConfidence", 0) - - branch_triggered = False - for det_class in regions_dict: - det_confidence = regions_dict[det_class]["confidence"] - - if (det_class in trigger_classes and det_confidence >= min_conf): - branch_triggered = True - logger.debug(f"Pre-validation: branch {branch_id} WILL be triggered by {det_class} (conf={det_confidence:.3f} >= {min_conf})") - break - - if not branch_triggered: - missing_branches.append(branch_id) - logger.warning(f"Pre-validation: branch {branch_id} will NOT be triggered - no matching classes or insufficient confidence") - logger.debug(f" Required: {trigger_classes} with min_conf={min_conf}") - logger.debug(f" Available: {[(cls, regions_dict[cls]['confidence']) for cls in regions_dict]}") - - if missing_branches: - logger.error(f"Pipeline pre-validation FAILED: required branches {missing_branches} will not execute") - return False, missing_branches - else: - logger.info(f"Pipeline pre-validation PASSED: all required branches {list(required_branches)} will execute") - return True, [] - -def run_lightweight_detection_with_validation(frame, node: dict, min_confidence=0.7, min_bbox_area_ratio=0.3): - """ - Run lightweight detection with validation rules for session ID triggering. - Returns detection info only if it passes validation thresholds. - """ - logger.debug(f"Running lightweight detection with validation: {node['modelId']} (conf>={min_confidence}, bbox_area>={min_bbox_area_ratio})") - - try: - # Run basic detection only - no branches, no actions - model = node["model"] - trigger_classes = node.get("triggerClasses", []) - trigger_class_indices = node.get("triggerClassIndices") - - # Run YOLO inference - res = model(frame, verbose=False) - - best_detection = None - frame_height, frame_width = frame.shape[:2] - frame_area = frame_height * frame_width - - for r in res: - boxes = r.boxes - if boxes is None or len(boxes) == 0: - continue - - for box in boxes: - # Extract detection info - xyxy = box.xyxy[0].cpu().numpy() - conf = box.conf[0].cpu().numpy() - cls_id = int(box.cls[0].cpu().numpy()) - class_name = model.names[cls_id] - - # Apply confidence threshold - if conf < min_confidence: - continue - - # Apply trigger class filtering if specified - if trigger_class_indices and cls_id not in trigger_class_indices: - continue - if trigger_classes and class_name not in trigger_classes: - continue - - # Calculate bbox area ratio - x1, y1, x2, y2 = xyxy - bbox_area = (x2 - x1) * (y2 - y1) - bbox_area_ratio = bbox_area / frame_area if frame_area > 0 else 0 - - # Apply bbox area threshold - if bbox_area_ratio < min_bbox_area_ratio: - logger.debug(f"Detection filtered out: bbox_area_ratio={bbox_area_ratio:.3f} < {min_bbox_area_ratio}") - continue - - # Validation passed - if not best_detection or conf > best_detection["confidence"]: - best_detection = { - "class": class_name, - "confidence": float(conf), - "bbox": [int(x) for x in xyxy], - "bbox_area_ratio": float(bbox_area_ratio), - "validation_passed": True - } - - if best_detection: - logger.debug(f"Validation PASSED: {best_detection['class']} (conf: {best_detection['confidence']:.3f}, area: {best_detection['bbox_area_ratio']:.3f})") - return best_detection - else: - logger.debug(f"Validation FAILED: No detection meets criteria (conf>={min_confidence}, area>={min_bbox_area_ratio})") - return {"validation_passed": False} - - except Exception as e: - logger.error(f"Error in lightweight detection with validation: {str(e)}", exc_info=True) - return {"validation_passed": False} - -def run_lightweight_detection(frame, node: dict): - """ - Run lightweight detection for car presence validation only. - Returns basic detection info without running branches or external actions. - """ - logger.debug(f"Running lightweight detection: {node['modelId']}") - - try: - # Run basic detection only - no branches, no actions - model = node["model"] - min_confidence = node.get("minConfidence", 0.5) - trigger_classes = node.get("triggerClasses", []) - trigger_class_indices = node.get("triggerClassIndices") - - # Run YOLO inference - res = model(frame, verbose=False) - - car_detected = False - best_detection = None - - for r in res: - boxes = r.boxes - if boxes is None or len(boxes) == 0: - continue - - for box in boxes: - # Extract detection info - xyxy = box.xyxy[0].cpu().numpy() - conf = box.conf[0].cpu().numpy() - cls_id = int(box.cls[0].cpu().numpy()) - class_name = model.names[cls_id] - - # Apply confidence threshold - if conf < min_confidence: - continue - - # Apply trigger class filtering if specified - if trigger_class_indices and cls_id not in trigger_class_indices: - continue - if trigger_classes and class_name not in trigger_classes: - continue - - # Car detected - car_detected = True - if not best_detection or conf > best_detection["confidence"]: - best_detection = { - "class": class_name, - "confidence": float(conf), - "bbox": [int(x) for x in xyxy] - } - - logger.debug(f"Lightweight detection result: car_detected={car_detected}") - if best_detection: - logger.debug(f"Best detection: {best_detection['class']} (conf: {best_detection['confidence']:.3f})") - - return { - "car_detected": car_detected, - "best_detection": best_detection - } - - except Exception as e: - logger.error(f"Error in lightweight detection: {str(e)}", exc_info=True) - return {"car_detected": False, "best_detection": None} - -def run_pipeline(frame, node: dict, return_bbox: bool=False, context=None, validated_detection=None): - """ - 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 - """ - try: - # Extract backend sessionId from context at the start of function - backend_session_id = context.get("backend_session_id") if context else None - camera_id = context.get("camera_id", "unknown") if context else "unknown" - model_id = node.get("modelId", "unknown") - - if backend_session_id: - logger.info(f"🔑 PIPELINE USING BACKEND SESSION_ID: {backend_session_id} for camera {camera_id}") - - task = getattr(node["model"], "task", None) - - # ─── Classification stage ─────────────────────────────────── - if task == "classify": - 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(node, frame, det, context.get("regions_dict") if context else None) - return (det, None) if return_bbox else det - - # ─── Occupancy mode check (stop future frames after pipeline completion) ─────────────────────────────────────── - # Old occupancy mode logic removed - now using two-phase detection system - - # ─── Session management check ─────────────────────────────────────── - 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 - - # ─── Detection stage - Use validated detection if provided (full_pipeline mode) ─── - 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, context) - - # Debug: Save crops for debugging (disabled for production) - # if regions_dict: - # try: - # import datetime - # os.makedirs("temp_debug", exist_ok=True) - # timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") - # model_id = node.get("modelId", "unknown") - # - # # Save vehicle crop from yolo model (any vehicle: car, truck, bus, motorcycle) - # if model_id in ["yolo11n", "yolo11m"]: - # # Look for any vehicle class in regions_dict - # vehicle_classes = ["car", "truck", "bus", "motorcycle"] - # found_vehicle = None - # for vehicle_class in vehicle_classes: - # if vehicle_class in regions_dict: - # found_vehicle = vehicle_class - # break - # - # if found_vehicle: - # bbox = regions_dict[found_vehicle]['bbox'] - # x1, y1, x2, y2 = bbox - # cropped = frame[y1:y2, x1:x2] - # if cropped.size > 0: - # debug_path = f"temp_debug/{found_vehicle}_crop_{timestamp}.jpg" - # cv2.imwrite(debug_path, cropped) - # logger.debug(f"Saved {found_vehicle} crop to {debug_path}") - # - # # Save frontal crop from frontal_detection_v1 - # elif model_id == "frontal_detection_v1" and "frontal" in regions_dict: - # bbox = regions_dict["frontal"]['bbox'] - # x1, y1, x2, y2 = bbox - # cropped = frame[y1:y2, x1:x2] - # if cropped.size > 0: - # debug_path = f"temp_debug/frontal_crop_{timestamp}.jpg" - # cv2.imwrite(debug_path, cropped) - # logger.debug(f"Saved frontal crop to {debug_path}") - # - # except Exception as e: - # logger.error(f"Failed to save crops: {e}") - - 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 - - # Extract bounding boxes for compatibility - all_boxes = [det["bbox"] for det in all_detections] - - # ─── Track-Based Validation System: Using Track ID Stability ──────────────────────── - tracking_config = node.get("tracking", {}) - stability_threshold = tracking_config.get("stabilityThreshold", node.get("stabilityThreshold", 1)) - - camera_id = context.get("camera_id", "unknown") if context else "unknown" - - if stability_threshold > 1 and tracking_config.get("enabled", True): - # Note: Old occupancy state system removed - app.py handles all mode transitions now - # Track validation is handled by update_single_track_stability function - model_id = node.get("modelId", "unknown") - - # Simplified: just check if we have stable tracks from track validation - current_phase = "validation" # Always validation phase in simplified system - absence_counter = 0 - max_absence_frames = 3 - - if current_phase == "validation": - # ═══ TRACK VALIDATION PHASE ═══ - # 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: - # This is a branch node - allow normal execution regardless of main pipeline validation - logger.debug(f"🔍 Camera {camera_id}: Branch node {model_id} executing during track validation phase") - else: - # Main pipeline node during track validation - check for stable tracks - stable_tracks = track_validation_result.get("stable_tracks", []) - - if not stable_tracks: - # No stable tracks yet - return detection without branches until track validation completes - if all_detections: - # Return the best detection but skip branches during validation - primary_detection = max(all_detections, key=lambda x: x["confidence"]) - logger.debug(f"🔍 Camera {camera_id}: TRACK VALIDATION PHASE - returning detection without branches (stable_tracks: {len(stable_tracks)}, sessionId: {backend_session_id or 'none'})") - else: - # No detection - return none - primary_detection = {"class": "none", "confidence": 0.0, "bbox": [0, 0, 0, 0]} - logger.debug(f"🔍 Camera {camera_id}: TRACK VALIDATION PHASE - no detection found (sessionId: {backend_session_id or 'none'})") - - primary_bbox = primary_detection.get("bbox", [0, 0, 0, 0]) - return (primary_detection, primary_bbox) if return_bbox else primary_detection - else: - # We have stable tracks - validation is complete, proceed with pipeline - logger.info(f"🎯 Camera {camera_id}: STABLE TRACKS DETECTED - proceeding with full pipeline (tracks: {stable_tracks})") - - # Note: Old waiting_for_session and occupancy phases removed - # app.py lightweight mode handles all state transitions now - - # ─── Pre-validate pipeline execution (only proceed if we have stable tracks for main pipeline) ──────────────────────── - is_branch_node = node.get("cropClass") is not None or node.get("parallel") is True - - if not is_branch_node and stability_threshold > 1 and tracking_config.get("enabled", True): - # Main pipeline node with tracking - check for stable tracks before proceeding - stable_tracks = track_validation_result.get("stable_tracks", []) - if not stable_tracks: - logger.debug(f"🔒 Camera {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 - - 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, - **(context or {}) - } - - # ─── Database operations will be handled when backend sessionId is received ──── - - if node.get("db_manager") and regions_dict: - detected_classes = list(regions_dict.keys()) - logger.debug(f"Valid detections found: {detected_classes}") - - if backend_session_id: - # Backend sessionId is available, proceed with database operations - from datetime import datetime - display_id = detection_result.get("display_id", "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=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: {backend_session_id}") - else: - logger.info(f"📡 Camera {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 - from datetime import datetime - detection_result["timestamp"] = datetime.now().strftime("%Y-%m-%dT%H-%M-%S") - - # 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": - execute_actions(node, frame, detection_result, regions_dict) - - # ─── 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"]: - 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") - - if active_branches: - if node.get("parallel", False) or any(br.get("parallel", False) for br in active_branches): - # Run branches in parallel - with concurrent.futures.ThreadPoolExecutor(max_workers=len(active_branches)) as executor: - futures = {} - - for br in active_branches: - sub_frame = frame - crop_class = br.get("cropClass") - - logger.info(f"Starting parallel branch: {br['modelId']}, cropClass: {crop_class}") - - if br.get("crop", False) and crop_class: - if crop_class in regions_dict: - cropped = 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 {br['modelId']} - model will handle resizing") - else: - logger.warning(f"Failed to crop {crop_class} region for {br['modelId']}, skipping branch") - continue - else: - logger.warning(f"Crop class {crop_class} not found in detected regions for {br['modelId']}, skipping branch") - continue - - # Add regions_dict and session_id to context for child branches - branch_context = dict(context) if context else {} - 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 backend_session_id: - branch_context["session_id"] = backend_session_id - logger.debug(f"Added backend_session_id to branch context: {backend_session_id}") - - future = executor.submit(run_pipeline, sub_frame, br, True, branch_context) - futures[future] = br - - # Collect results - for future in concurrent.futures.as_completed(futures): - br = futures[future] - try: - result, _ = future.result() - if result: - branch_results[br["modelId"]] = result - logger.info(f"Branch {br['modelId']} completed: {result}") - - # Collect nested branch results if they exist - if "branch_results" in result: - for nested_id, nested_result in result["branch_results"].items(): - branch_results[nested_id] = nested_result - logger.info(f"Collected nested branch result: {nested_id} = {nested_result}") - except Exception as e: - logger.error(f"Branch {br['modelId']} failed: {e}") - else: - # Run branches sequentially - for br in active_branches: - sub_frame = frame - crop_class = br.get("cropClass") - - logger.info(f"Starting sequential branch: {br['modelId']}, cropClass: {crop_class}") - - if br.get("crop", False) and crop_class: - if crop_class in regions_dict: - cropped = 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 {br['modelId']} - model will handle resizing") - else: - logger.warning(f"Failed to crop {crop_class} region for {br['modelId']}, skipping branch") - continue - else: - logger.warning(f"Crop class {crop_class} not found in detected regions for {br['modelId']}, skipping branch") - continue - - try: - # Add regions_dict and session_id to context for child branches - branch_context = dict(context) if context else {} - 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 sequential branch context: {detection_result['session_id']}") - elif backend_session_id: - branch_context["session_id"] = backend_session_id - logger.debug(f"Added backend_session_id to sequential branch context: {backend_session_id}") - - result, _ = run_pipeline(sub_frame, br, True, branch_context) - if result: - branch_results[br["modelId"]] = result - logger.info(f"Branch {br['modelId']} completed: {result}") - - # Collect nested branch results if they exist - if "branch_results" in result: - for nested_id, nested_result in result["branch_results"].items(): - branch_results[nested_id] = nested_result - logger.info(f"Collected nested branch result: {nested_id} = {nested_result}") - else: - logger.warning(f"Branch {br['modelId']} returned no result") - except Exception as e: - logger.error(f"Error in sequential branch {br['modelId']}: {e}") - import traceback - logger.debug(f"Branch error traceback: {traceback.format_exc()}") - - # Store branch results in detection_result for parallel actions - detection_result["branch_results"] = branch_results - - # ─── Execute Parallel Actions ─────────────────────────────── - if node.get("parallelActions") and "branch_results" in detection_result: - execute_parallel_actions(node, frame, detection_result, regions_dict) - - # ─── Auto-enable occupancy mode after successful pipeline completion ───────────────── - camera_id = context.get("camera_id", "unknown") if context else "unknown" - model_id = node.get("modelId", "unknown") - - # Enable occupancy detector automatically after first successful pipeline - # Auto-enabling occupancy logging removed - not used in enhanced lightweight mode - occupancy_detector(camera_id, model_id, enable=True) - - logger.info(f"✅ Camera {camera_id}: Pipeline completed, detection data will be sent to backend") - logger.info(f"🛑 Camera {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')}") - execute_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: - logger.error(f"Error in node {node.get('modelId')}: {e}") - import traceback - traceback.print_exc() - return (None, None) if return_bbox else None diff --git a/test/sample.png b/test/sample.png deleted file mode 100644 index 568e38f..0000000 Binary files a/test/sample.png and /dev/null differ diff --git a/test/sample2.png b/test/sample2.png deleted file mode 100644 index c1e8485..0000000 Binary files a/test/sample2.png and /dev/null differ diff --git a/test/test.py b/test/test.py deleted file mode 100644 index ff073c4..0000000 --- a/test/test.py +++ /dev/null @@ -1,60 +0,0 @@ -from ultralytics import YOLO -import cv2 -import os - -# Load the model -# model = YOLO('../models/webcam-local-01/4/bangchak_poc/yolo11n.pt') -model = YOLO('yolo11m.pt') - -def test_image(image_path): - """Test a single image with YOLO model""" - if not os.path.exists(image_path): - print(f"Image not found: {image_path}") - return - - # Run inference - filter for car class only (class 2 in COCO) - results = model(image_path, classes=[2, 5, 7]) # 2, 5, 7 = car, bus, truck in COCO dataset - - # Display results - for r in results: - im_array = r.plot() # plot a BGR numpy array of predictions - - # Resize image for display (max width/height 800px) - height, width = im_array.shape[:2] - max_dimension = 800 - if width > max_dimension or height > max_dimension: - if width > height: - new_width = max_dimension - new_height = int(height * (max_dimension / width)) - else: - new_height = max_dimension - new_width = int(width * (max_dimension / height)) - im_array = cv2.resize(im_array, (new_width, new_height)) - - # Show image with predictions - cv2.imshow('YOLO Test - Car Detection Only', im_array) - cv2.waitKey(0) - cv2.destroyAllWindows() - - # Print detection info - print(f"\nDetections for {image_path}:") - if r.boxes is not None and len(r.boxes) > 0: - for i, box in enumerate(r.boxes): - cls = int(box.cls[0]) - conf = float(box.conf[0]) - original_class = model.names[cls] # Original class name (car/bus/truck) - # Get bounding box coordinates - x1, y1, x2, y2 = box.xyxy[0].tolist() - # Rename all vehicle types to "car" - print(f"Detection {i+1}: car (was: {original_class}) - Confidence: {conf:.3f} - BBox: ({x1:.0f}, {y1:.0f}, {x2:.0f}, {y2:.0f})") - print(f"Total cars detected: {len(r.boxes)}") - else: - print("No cars detected in the image") - -if __name__ == "__main__": - # Test with an image file - image_path = input("Enter image path (or press Enter for default test): ") - if not image_path: - image_path = "sample.png" # Default test image - - test_image(image_path) \ No newline at end of file diff --git a/test/test_botsort_zone_track.py b/test/test_botsort_zone_track.py deleted file mode 100644 index bbbd188..0000000 --- a/test/test_botsort_zone_track.py +++ /dev/null @@ -1,352 +0,0 @@ -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() \ No newline at end of file diff --git a/test_protocol.py b/test_protocol.py deleted file mode 100644 index 74af7d8..0000000 --- a/test_protocol.py +++ /dev/null @@ -1,125 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script to verify the worker implementation follows the protocol -""" -import json -import asyncio -import websockets -import time - -async def test_protocol(): - """Test the worker protocol implementation""" - uri = "ws://localhost:8000" - - try: - async with websockets.connect(uri) as websocket: - print("✓ Connected to worker") - - # Test 1: Check if we receive heartbeat (stateReport) - print("\n1. Testing heartbeat...") - try: - message = await asyncio.wait_for(websocket.recv(), timeout=5) - data = json.loads(message) - if data.get("type") == "stateReport": - print("✓ Received stateReport heartbeat") - print(f" - CPU Usage: {data.get('cpuUsage', 'N/A')}%") - print(f" - Memory Usage: {data.get('memoryUsage', 'N/A')}%") - print(f" - Camera Connections: {len(data.get('cameraConnections', []))}") - else: - print(f"✗ Expected stateReport, got {data.get('type')}") - except asyncio.TimeoutError: - print("✗ No heartbeat received within 5 seconds") - - # Test 2: Request state - print("\n2. Testing requestState...") - await websocket.send(json.dumps({"type": "requestState"})) - try: - message = await asyncio.wait_for(websocket.recv(), timeout=5) - data = json.loads(message) - if data.get("type") == "stateReport": - print("✓ Received stateReport response") - else: - print(f"✗ Expected stateReport, got {data.get('type')}") - except asyncio.TimeoutError: - print("✗ No response to requestState within 5 seconds") - - # Test 3: Set session ID - print("\n3. Testing setSessionId...") - session_message = { - "type": "setSessionId", - "payload": { - "displayIdentifier": "display-001", - "sessionId": 12345 - } - } - await websocket.send(json.dumps(session_message)) - print("✓ Sent setSessionId message") - - # Test 4: Test patchSession - print("\n4. Testing patchSession...") - patch_message = { - "type": "patchSession", - "sessionId": 12345, - "data": { - "currentCar": { - "carModel": "Civic", - "carBrand": "Honda" - } - } - } - await websocket.send(json.dumps(patch_message)) - - # Wait for patchSessionResult - try: - message = await asyncio.wait_for(websocket.recv(), timeout=5) - data = json.loads(message) - if data.get("type") == "patchSessionResult": - print("✓ Received patchSessionResult") - print(f" - Success: {data.get('payload', {}).get('success')}") - print(f" - Message: {data.get('payload', {}).get('message')}") - else: - print(f"✗ Expected patchSessionResult, got {data.get('type')}") - except asyncio.TimeoutError: - print("✗ No patchSessionResult received within 5 seconds") - - # Test 5: Test subscribe message format (without actual camera) - print("\n5. Testing subscribe message format...") - subscribe_message = { - "type": "subscribe", - "payload": { - "subscriptionIdentifier": "display-001;cam-001", - "snapshotUrl": "http://example.com/snapshot.jpg", - "snapshotInterval": 5000, - "modelUrl": "http://example.com/model.mpta", - "modelName": "Test Model", - "modelId": 101, - "cropX1": 100, - "cropY1": 200, - "cropX2": 300, - "cropY2": 400 - } - } - await websocket.send(json.dumps(subscribe_message)) - print("✓ Sent subscribe message (will fail without actual camera/model)") - - # Listen for a few more messages to catch any errors - print("\n6. Listening for additional messages...") - for i in range(3): - try: - message = await asyncio.wait_for(websocket.recv(), timeout=2) - data = json.loads(message) - msg_type = data.get("type") - print(f" - Received {msg_type}") - if msg_type == "error": - print(f" Error: {data.get('error')}") - except asyncio.TimeoutError: - break - - print("\n✓ Protocol test completed successfully!") - - except Exception as e: - print(f"✗ Connection failed: {e}") - print("Make sure the worker is running on localhost:8000") - -if __name__ == "__main__": - asyncio.run(test_protocol()) \ No newline at end of file diff --git a/view_redis_images.py b/view_redis_images.py deleted file mode 100644 index b1b3c63..0000000 --- a/view_redis_images.py +++ /dev/null @@ -1,162 +0,0 @@ -#!/usr/bin/env python3 -""" -Script to view frontal images saved in Redis -""" -import redis -import cv2 -import numpy as np -import sys -from datetime import datetime - -# Redis connection config (from pipeline.json) -REDIS_CONFIG = { - "host": "10.100.1.3", - "port": 6379, - "password": "FBQgi0i5RevAAMO5Hh66", - "db": 0 -} - -def connect_redis(): - """Connect to Redis server.""" - try: - client = redis.Redis( - host=REDIS_CONFIG["host"], - port=REDIS_CONFIG["port"], - password=REDIS_CONFIG["password"], - db=REDIS_CONFIG["db"], - decode_responses=False # Keep bytes for images - ) - client.ping() - print(f"✅ Connected to Redis at {REDIS_CONFIG['host']}:{REDIS_CONFIG['port']}") - return client - except redis.exceptions.ConnectionError as e: - print(f"❌ Failed to connect to Redis: {e}") - return None - -def list_image_keys(client): - """List all image keys in Redis.""" - try: - # Look for keys matching the inference pattern - keys = client.keys("inference:*") - print(f"\n📋 Found {len(keys)} image keys:") - for i, key in enumerate(keys): - key_str = key.decode() if isinstance(key, bytes) else key - print(f"{i+1}. {key_str}") - return keys - except Exception as e: - print(f"❌ Error listing keys: {e}") - return [] - -def view_image(client, key): - """View a specific image from Redis.""" - try: - # Get image data from Redis - image_data = client.get(key) - if image_data is None: - print(f"❌ No data found for key: {key}") - return - - print(f"📸 Image size: {len(image_data)} bytes") - - # Convert bytes to numpy array - nparr = np.frombuffer(image_data, np.uint8) - - # Decode image - img = cv2.imdecode(nparr, cv2.IMREAD_COLOR) - if img is None: - print("❌ Failed to decode image data") - return - - print(f"🖼️ Image dimensions: {img.shape[1]}x{img.shape[0]} pixels") - - # Display image - key_str = key.decode() if isinstance(key, bytes) else key - cv2.imshow(f'Redis Image: {key_str}', img) - print("👁️ Image displayed. Press any key to close...") - cv2.waitKey(0) - cv2.destroyAllWindows() - - # Ask if user wants to save the image - save = input("💾 Save image to file? (y/n): ").lower().strip() - if save == 'y': - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - filename = f"redis_image_{timestamp}.jpg" - cv2.imwrite(filename, img) - print(f"💾 Image saved as: {filename}") - - except Exception as e: - print(f"❌ Error viewing image: {e}") - -def monitor_new_images(client): - """Monitor for new images being added to Redis.""" - print("👀 Monitoring for new images... (Press Ctrl+C to stop)") - try: - # Subscribe to Redis pub/sub for car detections - pubsub = client.pubsub() - pubsub.subscribe('car_detections') - - for message in pubsub.listen(): - if message['type'] == 'message': - data = message['data'].decode() - print(f"🚨 New detection: {data}") - - # Try to extract image key from message - import json - try: - detection_data = json.loads(data) - image_key = detection_data.get('image_key') - if image_key: - print(f"🖼️ New image available: {image_key}") - view_choice = input("View this image now? (y/n): ").lower().strip() - if view_choice == 'y': - view_image(client, image_key) - except json.JSONDecodeError: - pass - - except KeyboardInterrupt: - print("\n👋 Stopping monitor...") - except Exception as e: - print(f"❌ Monitor error: {e}") - -def main(): - """Main function.""" - print("🔍 Redis Image Viewer") - print("=" * 50) - - # Connect to Redis - client = connect_redis() - if not client: - return - - while True: - print("\n📋 Options:") - print("1. List all image keys") - print("2. View specific image") - print("3. Monitor for new images") - print("4. Exit") - - choice = input("\nEnter choice (1-4): ").strip() - - if choice == '1': - keys = list_image_keys(client) - elif choice == '2': - keys = list_image_keys(client) - if keys: - try: - idx = int(input(f"\nEnter image number (1-{len(keys)}): ")) - 1 - if 0 <= idx < len(keys): - view_image(client, keys[idx]) - else: - print("❌ Invalid selection") - except ValueError: - print("❌ Please enter a valid number") - elif choice == '3': - monitor_new_images(client) - elif choice == '4': - print("👋 Goodbye!") - break - else: - print("❌ Invalid choice") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/webcam_rtsp_server.py b/webcam_rtsp_server.py deleted file mode 100644 index 65698ac..0000000 --- a/webcam_rtsp_server.py +++ /dev/null @@ -1,325 +0,0 @@ -#!/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() \ No newline at end of file diff --git a/worker.md b/worker.md deleted file mode 100644 index c485db5..0000000 --- a/worker.md +++ /dev/null @@ -1,495 +0,0 @@ -# Worker Communication Protocol - -This document outlines the WebSocket-based communication protocol between the CMS backend and a detector worker. As a worker developer, your primary responsibility is to implement a WebSocket server that adheres to this protocol. - -## 1. Connection - -The worker must run a WebSocket server, preferably on port `8000`. The backend system, which is managed by a container orchestration service, will automatically discover and establish a WebSocket connection to your worker. - -Upon a successful connection from the backend, you should begin sending `stateReport` messages as heartbeats. - -## 2. Communication Overview - -Communication is bidirectional and asynchronous. All messages are JSON objects with a `type` field that indicates the message's purpose, and an optional `payload` field containing the data. - -- **Worker -> Backend:** You will send messages to the backend to report status, forward detection events, or request changes to session data. -- **Backend -> Worker:** The backend will send commands to you to manage camera subscriptions. - -## 3. Dynamic Configuration via MPTA File - -To enable modularity and dynamic configuration, the backend will send you a URL to a `.mpta` file when it issues a `subscribe` command. This file is a renamed `.zip` archive that contains everything your worker needs to perform its task. - -**Your worker is responsible for:** - -1. Fetching this file from the provided URL. -2. Extracting its contents. -3. Interpreting the contents to configure its internal pipeline. - -**The contents of the `.mpta` file are entirely up to the user who configures the model in the CMS.** This allows for maximum flexibility. For example, the archive could contain: - -- AI/ML Models: Pre-trained models for libraries like TensorFlow, PyTorch, or ONNX. -- Configuration Files: A `config.json` or `pipeline.yaml` that defines a sequence of operations, specifies model paths, or sets detection thresholds. -- Scripts: Custom Python scripts for pre-processing or post-processing. -- API Integration Details: A JSON file with endpoint information and credentials for interacting with third-party detection services. - -Essentially, the `.mpta` file is a self-contained package that tells your worker _how_ to process the video stream for a given subscription. - -## 4. Messages from Worker to Backend - -These are the messages your worker is expected to send to the backend. - -### 4.1. State Report (Heartbeat) - -This message is crucial for the backend to monitor your worker's health and status, including GPU usage. - -- **Type:** `stateReport` -- **When to Send:** Periodically (e.g., every 2 seconds) after a connection is established. - -**Payload:** - -```json -{ - "type": "stateReport", - "cpuUsage": 75.5, - "memoryUsage": 40.2, - "gpuUsage": 60.0, - "gpuMemoryUsage": 25.1, - "cameraConnections": [ - { - "subscriptionIdentifier": "display-001;cam-001", - "modelId": 101, - "modelName": "General Object Detection", - "online": true, - "cropX1": 100, - "cropY1": 200, - "cropX2": 300, - "cropY2": 400 - } - ] -} -``` - -> **Note:** -> -> - `cropX1`, `cropY1`, `cropX2`, `cropY2` (optional, integer) should be included in each camera connection to indicate the crop coordinates for that subscription. - -### 4.2. Image Detection - -Sent when the worker detects a relevant object. The `detection` object should be flat and contain key-value pairs corresponding to the detected attributes. - -- **Type:** `imageDetection` - -**Payload Example:** - -```json -{ - "type": "imageDetection", - "subscriptionIdentifier": "display-001;cam-001", - "timestamp": "2025-07-14T12:34:56.789Z", - "data": { - "detection": { - "carModel": "Civic", - "carBrand": "Honda", - "carYear": 2023, - "bodyType": "Sedan", - "licensePlateText": "ABCD1234", - "licensePlateConfidence": 0.95 - }, - "modelId": 101, - "modelName": "US-LPR-and-Vehicle-ID" - } -} -``` - -### 4.3. Patch Session - -> **Note:** Patch messages are only used when the worker can't keep up and needs to retroactively send detections. Normally, detections should be sent in real-time using `imageDetection` messages. Use `patchSession` only to update session data after the fact. - -Allows the worker to request a modification to an active session's data. The `data` payload must be a partial object of the `DisplayPersistentData` structure. - -- **Type:** `patchSession` - -**Payload Example:** - -```json -{ - "type": "patchSession", - "sessionId": 12345, - "data": { - "currentCar": { - "carModel": "Civic", - "carBrand": "Honda", - "licensePlateText": "ABCD1234" - } - } -} -``` - -The backend will respond with a `patchSessionResult` command. - -#### `DisplayPersistentData` Structure - -The `data` object in the `patchSession` message is merged with the existing `DisplayPersistentData` on the backend. Here is its structure: - -```typescript -interface DisplayPersistentData { - progressionStage: - | 'welcome' - | 'car_fueling' - | 'car_waitpayment' - | 'car_postpayment' - | null; - qrCode: string | null; - adsPlayback: { - playlistSlotOrder: number; // The 'order' of the current slot - adsId: number | null; - adsUrl: string | null; - } | null; - currentCar: { - carModel?: string; - carBrand?: string; - carYear?: number; - bodyType?: string; - licensePlateText?: string; - licensePlateType?: string; - } | null; - fuelPump: { - /* FuelPumpData structure */ - } | null; - weatherData: { - /* WeatherResponse structure */ - } | null; - sessionId: number | null; -} -``` - -#### Patching Behavior - -- The patch is a **deep merge**. -- **`undefined`** values are ignored. -- **`null`** values will set the corresponding field to `null`. -- Nested objects are merged recursively. - -## 5. Commands from Backend to Worker - -These are the commands your worker will receive from the backend. - -### 5.1. Subscribe to Camera - -Instructs the worker to process a camera's RTSP stream using the configuration from the specified `.mpta` file. - -- **Type:** `subscribe` - -**Payload:** - -```json -{ - "type": "subscribe", - "payload": { - "subscriptionIdentifier": "display-001;cam-002", - "rtspUrl": "rtsp://user:pass@host:port/stream", - "snapshotUrl": "http://go2rtc/snapshot/1", - "snapshotInterval": 5000, - "modelUrl": "http://storage/models/us-lpr.mpta", - "modelName": "US-LPR-and-Vehicle-ID", - "modelId": 102, - "cropX1": 100, - "cropY1": 200, - "cropX2": 300, - "cropY2": 400 - } -} -``` - -> **Note:** -> -> - `cropX1`, `cropY1`, `cropX2`, `cropY2` (optional, integer) specify the crop coordinates for the camera stream. These values are configured per display and passed in the subscription payload. If not provided, the worker should process the full frame. -> -> **Important:** -> If multiple displays are bound to the same camera, your worker must ensure that only **one stream** is opened per camera. When you receive multiple subscriptions for the same camera (with different `subscriptionIdentifier` values), you should: -> -> - Open the RTSP stream **once** for that camera if using RTSP. -> - Capture each snapshot only once per cycle, and reuse it for all display subscriptions sharing that camera. -> - Capture each frame/image only once per cycle. -> - Reuse the same captured image and snapshot for all display subscriptions that share the camera, processing and routing detection results separately for each display as needed. -> This avoids unnecessary load and bandwidth usage, and ensures consistent detection results and snapshots across all displays sharing the same camera. - -### 5.2. Unsubscribe from Camera - -Instructs the worker to stop processing a camera's stream. - -- **Type:** `unsubscribe` - -**Payload:** - -```json -{ - "type": "unsubscribe", - "payload": { - "subscriptionIdentifier": "display-001;cam-002" - } -} -``` - -### 5.3. Request State - -Direct request for the worker's current state. Respond with a `stateReport` message. - -- **Type:** `requestState` - -**Payload:** - -```json -{ - "type": "requestState" -} -``` - -### 5.4. Patch Session Result - -Backend's response to a `patchSession` message. - -- **Type:** `patchSessionResult` - -**Payload:** - -```json -{ - "type": "patchSessionResult", - "payload": { - "sessionId": 12345, - "success": true, - "message": "Session updated successfully." - } -} -``` - -### 5.5. Set Session ID - -Allows the backend to instruct the worker to associate a session ID with a subscription. This is useful for linking detection events to a specific session. The session ID can be `null` to indicate no active session. - -- **Type:** `setSessionId` - -**Payload:** - -```json -{ - "type": "setSessionId", - "payload": { - "displayIdentifier": "display-001", - "sessionId": 12345 - } -} -``` - -Or to clear the session: - -```json -{ - "type": "setSessionId", - "payload": { - "displayIdentifier": "display-001", - "sessionId": null - } -} -``` - -> **Note:** -> -> - The worker should store the session ID for the given subscription and use it in subsequent detection or patch messages as appropriate. If `sessionId` is `null`, the worker should treat the subscription as having no active session. - -## Subscription Identifier Format - -The `subscriptionIdentifier` used in all messages is constructed as: - -``` -displayIdentifier;cameraIdentifier -``` - -This uniquely identifies a camera subscription for a specific display. - -### Session ID Association - -When the backend sends a `setSessionId` command, it will only provide the `displayIdentifier` (not the full `subscriptionIdentifier`). - -**Worker Responsibility:** - -- The worker must match the `displayIdentifier` to all active subscriptions for that display (i.e., all `subscriptionIdentifier` values that start with `displayIdentifier;`). -- The worker should set or clear the session ID for all matching subscriptions. - -## 6. Example Communication Log - -This section shows a typical sequence of messages between the backend and the worker. Patch messages are not included, as they are only used when the worker cannot keep up. - -> **Note:** Unsubscribe is triggered when a user removes a camera or when the node is too heavily loaded and needs rebalancing. - -1. **Connection Established** & **Heartbeat** - - **Worker -> Backend** - ```json - { - "type": "stateReport", - "cpuUsage": 70.2, - "memoryUsage": 38.1, - "gpuUsage": 55.0, - "gpuMemoryUsage": 20.0, - "cameraConnections": [] - } - ``` -2. **Backend Subscribes Camera** - - **Backend -> Worker** - ```json - { - "type": "subscribe", - "payload": { - "subscriptionIdentifier": "display-001;entry-cam-01", - "rtspUrl": "rtsp://192.168.1.100/stream1", - "modelUrl": "http://storage/models/vehicle-id.mpta", - "modelName": "Vehicle Identification", - "modelId": 201 - } - } - ``` -3. **Worker Acknowledges in Heartbeat** - - **Worker -> Backend** - ```json - { - "type": "stateReport", - "cpuUsage": 72.5, - "memoryUsage": 39.0, - "gpuUsage": 57.0, - "gpuMemoryUsage": 21.0, - "cameraConnections": [ - { - "subscriptionIdentifier": "display-001;entry-cam-01", - "modelId": 201, - "modelName": "Vehicle Identification", - "online": true - } - ] - } - ``` -4. **Worker Detects a Car** - - **Worker -> Backend** - ```json - { - "type": "imageDetection", - "subscriptionIdentifier": "display-001;entry-cam-01", - "timestamp": "2025-07-15T10:00:00.000Z", - "data": { - "detection": { - "carBrand": "Honda", - "carModel": "CR-V", - "bodyType": "SUV", - "licensePlateText": "GEMINI-AI", - "licensePlateConfidence": 0.98 - }, - "modelId": 201, - "modelName": "Vehicle Identification" - } - } - ``` - - **Worker -> Backend** - ```json - { - "type": "imageDetection", - "subscriptionIdentifier": "display-001;entry-cam-01", - "timestamp": "2025-07-15T10:00:01.000Z", - "data": { - "detection": { - "carBrand": "Toyota", - "carModel": "Corolla", - "bodyType": "Sedan", - "licensePlateText": "CMS-1234", - "licensePlateConfidence": 0.97 - }, - "modelId": 201, - "modelName": "Vehicle Identification" - } - } - ``` - - **Worker -> Backend** - ```json - { - "type": "imageDetection", - "subscriptionIdentifier": "display-001;entry-cam-01", - "timestamp": "2025-07-15T10:00:02.000Z", - "data": { - "detection": { - "carBrand": "Ford", - "carModel": "Focus", - "bodyType": "Hatchback", - "licensePlateText": "CMS-5678", - "licensePlateConfidence": 0.96 - }, - "modelId": 201, - "modelName": "Vehicle Identification" - } - } - ``` -5. **Backend Unsubscribes Camera** - - **Backend -> Worker** - ```json - { - "type": "unsubscribe", - "payload": { - "subscriptionIdentifier": "display-001;entry-cam-01" - } - } - ``` -6. **Worker Acknowledges Unsubscription** - - **Worker -> Backend** - ```json - { - "type": "stateReport", - "cpuUsage": 68.0, - "memoryUsage": 37.0, - "gpuUsage": 50.0, - "gpuMemoryUsage": 18.0, - "cameraConnections": [] - } - ``` - -## 7. HTTP API: Image Retrieval - -In addition to the WebSocket protocol, the worker exposes an HTTP endpoint for retrieving the latest image frame from a camera. - -### Endpoint - -``` -GET /camera/{camera_id}/image -``` - -- **`camera_id`**: The full `subscriptionIdentifier` (e.g., `display-001;cam-001`). - -### Response - -- **Success (200):** Returns the latest JPEG image from the camera stream. - - - `Content-Type: image/jpeg` - - Binary JPEG data. - -- **Error (404):** If the camera is not found or no frame is available. - - - JSON error response. - -- **Error (500):** Internal server error. - -### Example Request - -``` -GET /camera/display-001;cam-001/image -``` - -### Example Response - -- **Headers:** - ``` - Content-Type: image/jpeg - ``` -- **Body:** Binary JPEG image. - -### Notes - -- The endpoint returns the most recent frame available for the specified camera subscription. -- If multiple displays share the same camera, each subscription has its own buffer; the endpoint uses the buffer for the given `camera_id`. -- This API is useful for debugging, monitoring, or integrating with external systems that require direct image access. diff --git a/yolov8n.pt b/yolov8n.pt new file mode 100644 index 0000000..0db4ca4 Binary files /dev/null and b/yolov8n.pt differ