Compare commits

...
Sign in to create a new pull request.

96 commits
dev-old ... dev

Author SHA1 Message Date
b2e7bc499d feat: add session image retrieval endpoint
Add HTTP endpoint to retrieve saved session images by session ID.
Images are saved during car_fueling progression stage.

- Add GET /session-image/{session_id} endpoint
- Search images directory for files matching session ID pattern
- Return most recent image if multiple exist
- Proper error handling (404 for not found, 500 for errors)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 01:27:12 +07:00
ziesorx
402f7732a8 fix: change min bbox size for frontal
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 7s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 2m53s
Build Worker Base and Application Images / deploy-stack (push) Successful in 15s
2025-09-30 17:24:33 +07:00
ziesorx
9e5b5a32ad fix: bring back gpu usage
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 18s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 2m42s
Build Worker Base and Application Images / deploy-stack (push) Successful in 11s
2025-09-30 16:23:07 +07:00
ziesorx
3ed7a2cd53 fix: abandonment works
Some checks failed
Build Worker Base and Application Images / deploy-stack (push) Blocked by required conditions
Build Worker Base and Application Images / check-base-changes (push) Successful in 9s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Has been cancelled
2025-09-30 16:20:39 +07:00
ziesorx
793beb1571 fix: tracking works but absent not work
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 7s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 2m26s
Build Worker Base and Application Images / deploy-stack (push) Successful in 13s
2025-09-30 16:04:24 +07:00
ziesorx
354ed9ce3c fix: fallback when there is sessionId
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 7s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 2m28s
Build Worker Base and Application Images / deploy-stack (push) Successful in 14s
2025-09-30 15:46:32 +07:00
ziesorx
e92efdbe11 fix: custom subscriptionIdentifier
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 7s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 2m51s
Build Worker Base and Application Images / deploy-stack (push) Successful in 15s
2025-09-30 15:14:28 +07:00
ziesorx
8d2a71fcd7 fix: inference in reader thread
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 7s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 2m45s
Build Worker Base and Application Images / deploy-stack (push) Successful in 17s
2025-09-30 14:21:29 +07:00
ziesorx
fed71046a9 fix: update ffmpeg flags to improve frame handling
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 7s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 2m46s
Build Worker Base and Application Images / deploy-stack (push) Successful in 14s
2025-09-30 12:20:52 +07:00
ziesorx
31bc91d57b fix: add ffmpeg flags fix frame delay
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 7s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 2m58s
Build Worker Base and Application Images / deploy-stack (push) Successful in 14s
2025-09-30 12:06:03 +07:00
ziesorx
fa0f865319 feat: add fallback when cant initially detect but backend start session
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 7s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 2m45s
Build Worker Base and Application Images / deploy-stack (push) Successful in 16s
2025-09-30 00:53:27 +07:00
ziesorx
ee484b4655 feat: add min bbox for frontal tracking
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 9s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 3m22s
Build Worker Base and Application Images / deploy-stack (push) Successful in 15s
2025-09-29 23:45:20 +07:00
ziesorx
52ba1ff316 fix: sessionId type mismatch
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 7s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 3m31s
Build Worker Base and Application Images / deploy-stack (push) Successful in 15s
2025-09-29 17:43:30 +07:00
eb57de02c3 fix: update import paths for monitoring modules in FFmpegRTSPReader and HTTPSnapshotReader
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 7s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 2m48s
Build Worker Base and Application Images / deploy-stack (push) Successful in 9s
2025-09-27 14:57:20 +07:00
b08ce27de2 Implement comprehensive health monitoring for streams and threads
Some checks failed
Build Worker Base and Application Images / check-base-changes (push) Successful in 8s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 3m7s
Build Worker Base and Application Images / deploy-stack (push) Has been cancelled
- Added RecoveryManager for automatic handling of health issues, including circuit breaker patterns, automatic restarts, and graceful degradation.
- Introduced StreamHealthTracker to monitor video stream metrics, including frame production, connection health, and error rates.
- Developed ThreadHealthMonitor for detecting unresponsive and deadlocked threads, providing liveness detection and responsiveness testing.
- Integrated health checks for streams and threads, reporting metrics and recovery actions to the health monitor.
- Enhanced logging for recovery attempts, errors, and health checks to improve observability and debugging.
2025-09-27 12:27:38 +07:00
8c08c815ce Merge pull request 'dev' (#18) from dev into main
Some checks failed
Build Worker Base and Application Images / check-base-changes (push) Successful in 8s
Build Worker Base and Application Images / build-base (push) Successful in 11m28s
Build Worker Base and Application Images / build-docker (push) Successful in 4m6s
Build Worker Base and Application Images / deploy-stack (push) Has been cancelled
Reviewed-on: #18
2025-09-26 13:50:19 +00:00
2b382210eb Refactor streaming readers: Split into modular files and implement base class
Some checks failed
Build Worker Base and Application Images / check-base-changes (push) Successful in 7s
Build Worker Base and Application Images / build-base (push) Successful in 10m17s
Build Worker Base and Application Images / build-docker (push) Successful in 3m54s
Build Worker Base and Application Images / deploy-stack (push) Has been cancelled
- Removed the existing `readers.py` file and created separate modules for `FFmpegRTSPReader`, `HTTPSnapshotReader`, and utility functions.
- Introduced an abstract base class `VideoReader` to standardize the interface for video stream readers.
- Updated `FFmpegRTSPReader` and `HTTPSnapshotReader` to inherit from `VideoReader` and implement required methods.
- Enhanced logging utilities for better readability and maintainability.
- Removed `pycuda` from requirements as it is no longer needed.
2025-09-26 20:03:09 +07:00
d8d1b33cd8 feat: add GPU accelerated libraries
Some checks failed
Build Worker Base and Application Images / check-base-changes (push) Successful in 7s
Build Worker Base and Application Images / build-base (push) Failing after 13m12s
Build Worker Base and Application Images / build-docker (push) Has been skipped
Build Worker Base and Application Images / deploy-stack (push) Has been skipped
2025-09-26 19:47:13 +07:00
33d738b31b fix: remove unused watchdog logging configuration and FrameFileHandler
Some checks failed
Build Worker Base and Application Images / check-base-changes (push) Successful in 7s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 2m55s
Build Worker Base and Application Images / deploy-stack (push) Has been cancelled
2025-09-26 19:42:57 +07:00
2808316e94 fix: remove unused RTSPReader import and related code
Some checks failed
Build Worker Base and Application Images / deploy-stack (push) Blocked by required conditions
Build Worker Base and Application Images / check-base-changes (push) Successful in 7s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Has been cancelled
2025-09-26 19:42:41 +07:00
ziesorx
cd1359f5d2 fix: enable hardward acceleration
Some checks failed
Build Worker Base and Application Images / check-base-changes (push) Successful in 8s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 3m14s
Build Worker Base and Application Images / deploy-stack (push) Has been cancelled
2025-09-26 15:06:12 +07:00
ziesorx
cc604841d0 Merge branch 'dev'
Some checks are pending
Build Worker Base and Application Images / deploy-stack (push) Blocked by required conditions
Build Worker Base and Application Images / check-base-changes (push) Successful in 8s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 3m12s
2025-09-26 15:00:48 +07:00
ziesorx
9f8372d844 fix: change save image logic
Some checks failed
Build Worker Base and Application Images / check-base-changes (push) Successful in 8s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 3m15s
Build Worker Base and Application Images / deploy-stack (push) Has been cancelled
2025-09-26 15:00:24 +07:00
ziesorx
61ac39b4f3 fix: validator 2025-09-26 14:50:45 +07:00
ziesorx
791f611f7d feat: custom bot-sort based tracker 2025-09-26 14:22:38 +07:00
ziesorx
bd201acac1 fix: cameras buffer 2025-09-26 13:16:37 +07:00
ziesorx
519e073f7f fix: camera api endpoint 2025-09-26 13:05:58 +07:00
ziesorx
d43a971d5b Merge branch 'dev'
Some checks failed
Build Worker Base and Application Images / check-base-changes (push) Successful in 9s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 3m8s
Build Worker Base and Application Images / deploy-stack (push) Failing after 38s
2025-09-26 11:26:03 +07:00
ziesorx
83aaf95f59 fix: can read, track, and detect frame
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 8s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 3m5s
Build Worker Base and Application Images / deploy-stack (push) Successful in 11s
2025-09-26 11:24:48 +07:00
ziesorx
f5c6da8014 change: temp_file path
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 8s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 3m1s
Build Worker Base and Application Images / deploy-stack (push) Successful in 20s
2025-09-26 10:18:44 +07:00
a12e3efa12 refactor: enhance FFmpegRTSPReader to implement persistent file locking for PPM frame reading
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 9s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 3m8s
Build Worker Base and Application Images / deploy-stack (push) Successful in 14s
2025-09-26 03:04:53 +07:00
d663aaa446 Merge pull request 'dev' (#17) from dev into main
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 8s
Build Worker Base and Application Images / build-base (push) Successful in 9m7s
Build Worker Base and Application Images / build-docker (push) Successful in 4m3s
Build Worker Base and Application Images / deploy-stack (push) Successful in 29s
Reviewed-on: #17
2025-09-25 19:59:49 +00:00
fe0da18d0f refactor: change temporary file format from JPG to PPM for improved frame reading
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 8s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 2m52s
Build Worker Base and Application Images / deploy-stack (push) Successful in 13s
2025-09-26 02:55:26 +07:00
73c3367681 refactor: update FFmpegRTSPReader to use JPG format for single frame updates and improve image quality
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 7s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 2m48s
Build Worker Base and Application Images / deploy-stack (push) Successful in 10s
2025-09-26 02:51:30 +07:00
95c39a008f refactor: suppress noisy watchdog debug logs for cleaner output
Some checks failed
Build Worker Base and Application Images / check-base-changes (push) Successful in 7s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 2m53s
Build Worker Base and Application Images / deploy-stack (push) Failing after 12s
2025-09-26 02:35:27 +07:00
2742b86961 refactor: enhance FFmpegRTSPReader to improve frame reading reliability with retry logic
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 8s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 2m51s
Build Worker Base and Application Images / deploy-stack (push) Successful in 8s
2025-09-26 02:26:44 +07:00
84144a2955 refactor: update FFmpegRTSPReader to read and update a single frame in place for improved efficiency
Some checks failed
Build Worker Base and Application Images / check-base-changes (push) Successful in 10s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 3m0s
Build Worker Base and Application Images / deploy-stack (push) Has been cancelled
2025-09-26 02:20:14 +07:00
cb31633cc1 refactor: enhance FFmpegRTSPReader with file watching and reactive frame reading
Some checks failed
Build Worker Base and Application Images / deploy-stack (push) Blocked by required conditions
Build Worker Base and Application Images / check-base-changes (push) Successful in 8s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Has been cancelled
2025-09-26 02:18:20 +07:00
79a1189675 refactor: update FFmpegRTSPReader to use a temporary file for frame reading and improve error handling 2025-09-26 02:15:06 +07:00
c38b58e34c refactor: add FFmpegRTSPReader for enhanced RTSP stream handling with CUDA acceleration
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 8s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 2m57s
Build Worker Base and Application Images / deploy-stack (push) Successful in 20s
2025-09-26 02:07:17 +07:00
08cb4eafc4 refactor: enhance error handling and logging in RTSPReader for improved frame retrieval diagnostics
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 8s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 2m57s
Build Worker Base and Application Images / deploy-stack (push) Successful in 20s
2025-09-26 01:58:50 +07:00
65b7573fed refactor: remove unnecessary buffer size setting for RTSP stream to improve latency
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 8s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 2m52s
Build Worker Base and Application Images / deploy-stack (push) Successful in 10s
2025-09-26 01:52:50 +07:00
a1e7c42fb3 refactor: improve error handling and logging in RTSPReader for frame capture failures
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 8s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 2m55s
Build Worker Base and Application Images / deploy-stack (push) Successful in 23s
2025-09-26 01:44:46 +07:00
c6a4258055 refactor: enhance error logging in RTSPReader for better debugging of frame capture issues
Some checks failed
Build Worker Base and Application Images / deploy-stack (push) Blocked by required conditions
Build Worker Base and Application Images / check-base-changes (push) Successful in 7s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Has been cancelled
2025-09-26 01:42:30 +07:00
cb9ff7bc86 refactor: update FFmpeg hardware acceleration to use NVDEC instead of CUVID for improved performance
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 8s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 3m2s
Build Worker Base and Application Images / deploy-stack (push) Successful in 10s
2025-09-26 01:33:41 +07:00
bdbf688946 refactor: streamline CUDA development tools installation and simplify FFmpeg configuration for NVIDIA support
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 8s
Build Worker Base and Application Images / build-base (push) Successful in 10m9s
Build Worker Base and Application Images / build-docker (push) Successful in 4m4s
Build Worker Base and Application Images / deploy-stack (push) Successful in 43s
2025-09-26 01:11:32 +07:00
fa3ab5c6d2 refactor: update base image to runtime version and install minimal CUDA development tools for FFmpeg
Some checks failed
Build Worker Base and Application Images / check-base-changes (push) Successful in 7s
Build Worker Base and Application Images / build-base (push) Failing after 3m52s
Build Worker Base and Application Images / build-docker (push) Has been skipped
Build Worker Base and Application Images / deploy-stack (push) Has been skipped
2025-09-26 00:48:39 +07:00
6fe4b6ebf0 refactor: update Dockerfile to use development image and enhance FFmpeg build process with NVIDIA support
Some checks failed
Build Worker Base and Application Images / check-base-changes (push) Successful in 7s
Build Worker Base and Application Images / build-docker (push) Has been cancelled
Build Worker Base and Application Images / deploy-stack (push) Has been cancelled
Build Worker Base and Application Images / build-base (push) Has been cancelled
2025-09-26 00:48:06 +07:00
e2e5356047 refactor: build FFmpeg from source with NVIDIA CUDA support and remove unnecessary development libraries
Some checks failed
Build Worker Base and Application Images / check-base-changes (push) Successful in 8s
Build Worker Base and Application Images / build-base (push) Failing after 4m23s
Build Worker Base and Application Images / build-docker (push) Has been skipped
Build Worker Base and Application Images / deploy-stack (push) Has been skipped
2025-09-26 00:41:49 +07:00
59e8448f0d fix: add missing FFmpeg development libraries for OpenCV integration
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 8s
Build Worker Base and Application Images / build-base (push) Successful in 7m22s
Build Worker Base and Application Images / build-docker (push) Successful in 4m45s
Build Worker Base and Application Images / deploy-stack (push) Successful in 44s
2025-09-26 00:27:08 +07:00
360a4ab890 feat: enhance logging for detected hardware codecs and improve CUDA acceleration handling
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 8s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 2m53s
Build Worker Base and Application Images / deploy-stack (push) Successful in 12s
2025-09-26 00:16:49 +07:00
719d16ae4d refactor: simplify frame handling by removing stream type management and enhancing validation
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 7s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 2m55s
Build Worker Base and Application Images / deploy-stack (push) Successful in 12s
2025-09-26 00:07:48 +07:00
dc1db635d0 fix: remove unnecessary copying of FFmpeg library and include files
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 7s
Build Worker Base and Application Images / build-base (push) Successful in 5m37s
Build Worker Base and Application Images / build-docker (push) Successful in 3m42s
Build Worker Base and Application Images / deploy-stack (push) Successful in 50s
2025-09-25 23:56:29 +07:00
47d4fa6b8f refactor: streamline FFmpeg installation process and remove unnecessary CUDA development tools
Some checks failed
Build Worker Base and Application Images / check-base-changes (push) Successful in 8s
Build Worker Base and Application Images / build-base (push) Failing after 4m18s
Build Worker Base and Application Images / build-docker (push) Has been skipped
Build Worker Base and Application Images / deploy-stack (push) Has been skipped
2025-09-25 23:48:35 +07:00
ziesorx
ff56c1b666 fix: dockerfile base
Some checks failed
Build Worker Base and Application Images / check-base-changes (push) Successful in 8s
Build Worker Base and Application Images / build-base (push) Failing after 5m52s
Build Worker Base and Application Images / build-docker (push) Has been skipped
Build Worker Base and Application Images / deploy-stack (push) Has been skipped
2025-09-25 23:36:07 +07:00
ziesorx
a45f76884f fix: make ffmpeg support
Some checks failed
Build Worker Base and Application Images / check-base-changes (push) Successful in 7s
Build Worker Base and Application Images / build-base (push) Failing after 6m4s
Build Worker Base and Application Images / build-docker (push) Has been skipped
Build Worker Base and Application Images / deploy-stack (push) Has been skipped
2025-09-25 23:23:56 +07:00
ziesorx
0fc86fb72b Merge branch 'dev-check-cpu' into dev
Some checks failed
Build Worker Base and Application Images / check-base-changes (push) Successful in 8s
Build Worker Base and Application Images / build-base (push) Failing after 3m15s
Build Worker Base and Application Images / build-docker (push) Has been skipped
Build Worker Base and Application Images / deploy-stack (push) Has been skipped
2025-09-25 23:01:04 +07:00
ziesorx
6bb679f4d8 fix: use gpu 2025-09-25 22:59:55 +07:00
ziesorx
5f29392c2f chore: update Dockerfile.base 2025-09-25 22:25:27 +07:00
ziesorx
b919a1ebe2 fix: use nvdec 2025-09-25 22:16:19 +07:00
ziesorx
9f29755e0f feat: update filename and timestamp to gmt+7
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 9s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 2m54s
Build Worker Base and Application Images / deploy-stack (push) Successful in 9s
2025-09-25 11:24:45 +07:00
f9a67935d6 Merge pull request 'fix: wrong function name' (#14) from dev into main
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 7s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 2m53s
Build Worker Base and Application Images / deploy-stack (push) Successful in 7s
Reviewed-on: #14
2025-09-24 20:27:43 +00:00
ziesorx
f4b898ccd1 fix: wrong function name
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 8s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 2m50s
Build Worker Base and Application Images / deploy-stack (push) Successful in 9s
2025-09-25 03:27:33 +07:00
b6d5aabf22 Merge pull request 'fix: initialization None error' (#13) from dev into main
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 8s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 2m48s
Build Worker Base and Application Images / deploy-stack (push) Successful in 10s
Reviewed-on: #13
2025-09-24 20:21:40 +00:00
ziesorx
c2c80222f1 fix: initialization None error
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 8s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 2m53s
Build Worker Base and Application Images / deploy-stack (push) Successful in 9s
2025-09-25 03:21:22 +07:00
1cc46e0663 Merge pull request 'fix: minor' (#12) from dev into main
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 8s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 2m44s
Build Worker Base and Application Images / deploy-stack (push) Successful in 22s
Reviewed-on: #12
2025-09-24 20:15:29 +00:00
ziesorx
2f3c2b08cb fix: RTSP Connection Issues
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 11s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 2m56s
Build Worker Base and Application Images / deploy-stack (push) Successful in 10s
2025-09-25 03:12:27 +07:00
ziesorx
f467cb005d feat: update max_streams
Some checks failed
Build Worker Base and Application Images / deploy-stack (push) Blocked by required conditions
Build Worker Base and Application Images / check-base-changes (push) Successful in 7s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Has been cancelled
2025-09-25 03:11:22 +07:00
ziesorx
b8de5e191e fix: asyncio: Task was destroyed but it is pending
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 8s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 2m48s
Build Worker Base and Application Images / deploy-stack (push) Successful in 18s
2025-09-25 01:50:04 +07:00
ziesorx
5065e43837 feat: update pynvml in linux
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 12s
Build Worker Base and Application Images / build-base (push) Successful in 4m44s
Build Worker Base and Application Images / build-docker (push) Successful in 3m3s
Build Worker Base and Application Images / deploy-stack (push) Successful in 24s
2025-09-25 01:26:19 +07:00
ziesorx
67096d4141 fix: minor bug fix
Some checks failed
Build Worker Base and Application Images / deploy-stack (push) Blocked by required conditions
Build Worker Base and Application Images / check-base-changes (push) Successful in 7s
Build Worker Base and Application Images / build-base (push) Successful in 4m44s
Build Worker Base and Application Images / build-docker (push) Has been cancelled
2025-09-25 01:20:07 +07:00
ziesorx
2eba1f94ea feat: add save frame if there is sessionId
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 8s
Build Worker Base and Application Images / build-base (push) Successful in 5m23s
Build Worker Base and Application Images / build-docker (push) Successful in 3m11s
Build Worker Base and Application Images / deploy-stack (push) Successful in 23s
2025-09-25 00:45:09 +07:00
ziesorx
965a0d0a72 refactor: remove hardcoded
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 7s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 2m53s
Build Worker Base and Application Images / deploy-stack (push) Successful in 10s
2025-09-25 00:26:55 +07:00
ziesorx
dc47eb8580 refactor: remove hardcoded modelid
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 7s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Successful in 2m46s
Build Worker Base and Application Images / deploy-stack (push) Successful in 12s
2025-09-25 00:18:02 +07:00
ziesorx
c94dfa10e7 chore: update CICD
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 8s
Build Worker Base and Application Images / build-base (push) Successful in 9m19s
Build Worker Base and Application Images / build-docker (push) Successful in 3m10s
Build Worker Base and Application Images / deploy-stack (push) Successful in 26s
2025-09-24 22:35:24 +07:00
ziesorx
eda5368771 chore: update refactor plan
Some checks failed
Build Worker Base and Application Images / check-base-changes (push) Successful in 8s
Build Worker Base and Application Images / build-base (push) Has been skipped
Build Worker Base and Application Images / build-docker (push) Failing after 6m17s
Build Worker Base and Application Images / deploy-stack (push) Has been skipped
2025-09-24 22:19:45 +07:00
ziesorx
476f19cabe Refactor: done phase 5 2025-09-24 22:01:26 +07:00
ziesorx
5176f99ba7 Refactor: Logging Cleanup 2025-09-24 20:39:32 +07:00
ziesorx
7a9a149955 Refactor: nearly done phase 5 2025-09-24 20:29:31 +07:00
ziesorx
227e696ed6 chore: update refactor plan 2025-09-23 23:10:28 +07:00
ziesorx
dd401f14d7 feat: tracking works 100% 2025-09-23 23:06:03 +07:00
ziesorx
4002febed2 feat: add validator for post fueling and car abandon 2025-09-23 22:11:39 +07:00
ziesorx
7a38933bb0 feat: correct mock 2025-09-23 21:41:03 +07:00
ziesorx
d3d9c426f8 feat: validation with tracking works 2025-09-23 21:21:27 +07:00
ziesorx
4619c122f1 feat: make tracking works 2025-09-23 20:32:08 +07:00
ziesorx
255be78d43 feat: update tracking working 2025-09-23 18:08:21 +07:00
ziesorx
9e4c23c75c Refactor: done phase 4 2025-09-23 17:56:40 +07:00
ziesorx
7e8034c6e5 Refactor: done phase 3 2025-09-23 17:20:46 +07:00
ziesorx
6ec10682c0 refactor: heartbeat spamming log 2025-09-23 16:26:53 +07:00
ziesorx
aa10d5a55c Refactor: done phase 2 2025-09-23 16:13:11 +07:00
ziesorx
8222e82dd7 feat: update rxtx log 2025-09-23 15:44:09 +07:00
ziesorx
cbbed3d933 refactor: done phase 1 2025-09-22 17:18:07 +07:00
ziesorx
f7c464be21 refactor: remove old code 2025-09-22 17:16:51 +07:00
ziesorx
b2d5726b7a refactor: move old code to archive 2025-09-22 17:16:32 +07:00
ziesorx
dced713ac5 feat: add refactor plan 2025-09-22 16:16:50 +07:00
ziesorx
c70ca311c7 feat: update worker.md 2025-09-19 18:14:03 +07:00
51 changed files with 14164 additions and 982 deletions

View file

@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"Bash(dir:*)",
"WebSearch",
"Bash(mkdir:*)"
],
"deny": [],
"ask": []
}
}

View file

@ -51,7 +51,7 @@ jobs:
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:
@ -79,7 +79,7 @@ jobs:
registry: git.siwatsystem.com
username: ${{ github.actor }}
password: ${{ secrets.RUNNER_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
@ -109,4 +109,4 @@ jobs:
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
fi

2
.gitignore vendored
View file

@ -2,6 +2,8 @@
app.log
*.pt
images
# All pycache directories
__pycache__/
.mptacache

View file

@ -1,15 +1,130 @@
# Base image with all ML dependencies
FROM python:3.13-bookworm
# Base image with complete ML and hardware acceleration stack
FROM pytorch/pytorch:2.8.0-cuda12.6-cudnn9-runtime
# Install system dependencies
RUN apt update && apt install -y libgl1 && rm -rf /var/lib/apt/lists/*
# Install build dependencies and system libraries
RUN apt-get update && apt-get install -y \
# Build tools
build-essential \
cmake \
git \
pkg-config \
wget \
unzip \
yasm \
nasm \
# Additional dependencies for FFmpeg/NVIDIA build
libtool \
libc6 \
libc6-dev \
libnuma1 \
libnuma-dev \
# Essential compilation libraries
gcc \
g++ \
libc6-dev \
linux-libc-dev \
# System libraries
libgl1-mesa-glx \
libglib2.0-0 \
libgomp1 \
# Core media libraries (essential ones only)
libjpeg-dev \
libpng-dev \
libx264-dev \
libx265-dev \
libvpx-dev \
libmp3lame-dev \
libv4l-dev \
# TurboJPEG for fast JPEG encoding
libturbojpeg0-dev \
# Python development
python3-dev \
python3-numpy \
&& rm -rf /var/lib/apt/lists/*
# Copy and install base requirements (ML dependencies that rarely change)
# Add NVIDIA CUDA repository and install minimal development tools
RUN apt-get update && apt-get install -y wget gnupg && \
wget -O - https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2204/x86_64/3bf863cc.pub | apt-key add - && \
echo "deb https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2204/x86_64 /" > /etc/apt/sources.list.d/cuda.list && \
apt-get update && \
apt-get install -y \
cuda-nvcc-12-6 \
cuda-cudart-dev-12-6 \
libnpp-dev-12-6 \
&& apt-get remove -y wget gnupg && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/*
# Ensure CUDA paths are available
ENV PATH="/usr/local/cuda/bin:${PATH}"
ENV LD_LIBRARY_PATH="/usr/local/cuda/lib64:${LD_LIBRARY_PATH}"
# Install NVIDIA Video Codec SDK headers (official method)
RUN cd /tmp && \
git clone https://git.videolan.org/git/ffmpeg/nv-codec-headers.git && \
cd nv-codec-headers && \
make install && \
cd / && rm -rf /tmp/*
# Build FFmpeg from source with NVIDIA CUDA support
RUN cd /tmp && \
echo "Building FFmpeg with NVIDIA CUDA support..." && \
# Download FFmpeg source (official method)
git clone https://git.ffmpeg.org/ffmpeg.git ffmpeg/ && \
cd ffmpeg && \
# Configure with NVIDIA support (simplified to avoid configure issues)
./configure \
--prefix=/usr/local \
--enable-shared \
--disable-static \
--enable-nonfree \
--enable-gpl \
--enable-cuda-nvcc \
--enable-cuvid \
--enable-nvdec \
--enable-nvenc \
--enable-libnpp \
--extra-cflags=-I/usr/local/cuda/include \
--extra-ldflags=-L/usr/local/cuda/lib64 \
--enable-libx264 \
--enable-libx265 \
--enable-libvpx \
--enable-libmp3lame && \
# Build and install
make -j$(nproc) && \
make install && \
ldconfig && \
# Verify CUVID decoders are available
echo "=== Verifying FFmpeg CUVID Support ===" && \
(ffmpeg -hide_banner -decoders 2>/dev/null | grep cuvid || echo "No CUVID decoders found") && \
echo "=== Verifying FFmpeg NVENC Support ===" && \
(ffmpeg -hide_banner -encoders 2>/dev/null | grep nvenc || echo "No NVENC encoders found") && \
cd / && rm -rf /tmp/*
# Set environment variables for maximum hardware acceleration
ENV LD_LIBRARY_PATH="/usr/local/cuda/lib64:/usr/local/lib:${LD_LIBRARY_PATH}"
ENV PKG_CONFIG_PATH="/usr/local/lib/pkgconfig:${PKG_CONFIG_PATH}"
ENV PYTHONPATH="/usr/local/lib/python3.10/dist-packages:${PYTHONPATH}"
# Optimized environment variables for hardware acceleration
ENV OPENCV_FFMPEG_CAPTURE_OPTIONS="rtsp_transport;tcp|hwaccel;cuda|hwaccel_device;0|video_codec;h264_cuvid|hwaccel_output_format;cuda"
ENV OPENCV_FFMPEG_WRITER_OPTIONS="video_codec;h264_nvenc|preset;fast|tune;zerolatency|gpu;0"
ENV CUDA_VISIBLE_DEVICES=0
ENV NVIDIA_VISIBLE_DEVICES=all
ENV NVIDIA_DRIVER_CAPABILITIES=compute,video,utility
# Copy and install base requirements (exclude opencv-python since we built from source)
COPY requirements.base.txt .
RUN pip install --no-cache-dir -r requirements.base.txt
RUN grep -v opencv-python requirements.base.txt > requirements.tmp && \
mv requirements.tmp requirements.base.txt && \
pip install --no-cache-dir -r requirements.base.txt
# Set working directory
WORKDIR /app
# Create images directory for bind mount
RUN mkdir -p /app/images && \
chmod 755 /app/images
# This base image will be reused for all worker builds
CMD ["python3", "-m", "fastapi", "run", "--host", "0.0.0.0", "--port", "8000"]

545
REFACTOR_PLAN.md Normal file
View file

@ -0,0 +1,545 @@
# Detector Worker Refactoring Plan
## Project Overview
Transform the current monolithic structure (~4000 lines across `app.py` and `siwatsystem/pympta.py`) into a modular, maintainable system with clear separation of concerns. The goal is to make the sophisticated computer vision pipeline easily understandable for other engineers while maintaining all existing functionality.
## Current System Flow Understanding
### Validated System Flow
1. **WebSocket Connection** → Backend connects and sends `setSubscriptionList`
2. **Model Management** → Download unique `.mpta` files to `models/` and extract
3. **Tracking Phase** → Continuous tracking with `front_rear_detection_v1.pt`
4. **Validation Phase** → Validate stable car (not just passing by)
5. **Pipeline Execution**
- Detect car with `yolo11m.pt`
- **Branch 1**: Front/rear detection → crop frontal → save to Redis + brand classification
- **Branch 2**: Body type classification from car crop
6. **Communication** → Send `imageDetection` → Backend generates `sessionId` → Fueling starts
7. **Post-Fueling** → Backend clears `sessionId` → Continue tracking same car to avoid re-pipeline
### Core Responsibilities Identified
1. **WebSocket Communication** - Message handling and protocol compliance
2. **Stream Management** - RTSP/HTTP frame processing and buffering
3. **Model Management** - MPTA download, extraction, and loading
4. **Pipeline Configuration** - Parse `pipeline.json` and setup execution flow
5. **Vehicle Tracking** - Continuous tracking and car identification
6. **Validation Logic** - Stable car detection vs. passing-by cars
7. **Detection Pipeline** - Main ML pipeline with parallel branches
8. **Data Persistence** - Redis/PostgreSQL operations
9. **Session Management** - Handle session IDs and lifecycle
## Proposed Directory Structure
```
core/
├── communication/
│ ├── __init__.py
│ ├── websocket.py # WebSocket message handling & protocol
│ ├── messages.py # Message types and validation
│ ├── models.py # Message data structures
│ └── state.py # Worker state management
├── streaming/
│ ├── __init__.py
│ ├── manager.py # Stream coordination and lifecycle
│ ├── readers.py # RTSP/HTTP frame readers
│ └── buffers.py # Frame buffering and caching
├── models/
│ ├── __init__.py
│ ├── manager.py # MPTA download and model loading
│ ├── pipeline.py # Pipeline.json parser and config
│ └── inference.py # YOLO model wrapper and optimization
├── tracking/
│ ├── __init__.py
│ ├── tracker.py # Vehicle tracking with front_rear_detection_v1
│ ├── validator.py # Stable car validation logic
│ └── integration.py # Tracking-pipeline integration
├── detection/
│ ├── __init__.py
│ ├── pipeline.py # Main detection pipeline orchestration
│ └── branches.py # Parallel branch processing (brand/bodytype)
└── storage/
├── __init__.py
├── redis.py # Redis operations and image storage
└── database.py # PostgreSQL operations (existing - will be moved)
```
## Implementation Strategy (Feature-by-Feature Testing)
### Phase 1: Communication Layer
- WebSocket message handling (setSubscriptionList, sessionId management)
- HTTP API endpoints (camera image retrieval)
- Worker state reporting
### Phase 2: Pipeline Configuration Reader
- Parse `pipeline.json`
- Model dependency resolution
- Branch configuration setup
### Phase 3: Tracking System
- Continuous vehicle tracking
- Car identification and persistence
### Phase 4: Tracking Validator
- Stable car detection logic
- Passing-by vs. fueling car differentiation
### Phase 5: Model Pipeline Execution
- Main detection pipeline
- Parallel branch processing
- Redis/DB integration
### Phase 6: Post-Session Tracking Validation
- Same car validation after sessionId cleared
- Prevent duplicate pipeline execution
## Key Preservation Requirements
- **HTTP Endpoint**: `/camera/{camera_id}/image` must remain unchanged
- **WebSocket Protocol**: Full compliance with `worker.md` specification
- **MPTA Format**: Maintain compatibility with existing model archives
- **Database Schema**: Keep existing PostgreSQL structure
- **Redis Integration**: Preserve image storage and pub/sub functionality
- **Configuration**: Maintain `config.json` compatibility
- **Logging**: Preserve structured logging format
## Expected Benefits
- **Maintainability**: Single responsibility modules (~200-400 lines each)
- **Testability**: Independent testing of each component
- **Readability**: Clear separation of concerns
- **Scalability**: Easy to extend and modify individual components
- **Documentation**: Self-documenting code structure
---
# Comprehensive TODO List
## ✅ Phase 1: Project Setup & Communication Layer - COMPLETED
### 1.1 Project Structure Setup
- ✅ Create `core/` directory structure
- ✅ Create all module directories and `__init__.py` files
- ✅ Set up logging configuration for new modules
- ✅ Update imports in existing files to prepare for migration
### 1.2 Communication Module (`core/communication/`)
- ✅ **Create `models.py`** - Message data structures
- ✅ Define WebSocket message models (SubscriptionList, StateReport, etc.)
- ✅ Add validation schemas for incoming messages
- ✅ Create response models for outgoing messages
- ✅ **Create `messages.py`** - Message types and validation
- ✅ Implement message type constants
- ✅ Add message validation functions
- ✅ Create message builders for common responses
- ✅ **Create `websocket.py`** - WebSocket message handling
- ✅ Extract WebSocket connection management from `app.py`
- ✅ Implement message routing and dispatching
- ✅ Add connection lifecycle management (connect, disconnect, reconnect)
- ✅ Handle `setSubscriptionList` message processing
- ✅ Handle `setSessionId` and `setProgressionStage` messages
- ✅ Handle `requestState` and `patchSessionResult` messages
- ✅ **Create `state.py`** - Worker state management
- ✅ Extract state reporting logic from `app.py`
- ✅ Implement system metrics collection (CPU, memory, GPU)
- ✅ Manage active subscriptions state
- ✅ Handle session ID mapping and storage
### 1.3 HTTP API Preservation
- ✅ **Preserve `/camera/{camera_id}/image` endpoint**
- ✅ Extract REST API logic from `app.py`
- ✅ Ensure frame caching mechanism works with new structure
- ✅ Maintain exact same response format and error handling
### 1.4 Testing Phase 1
- ✅ Test WebSocket connection and message handling
- ✅ Test HTTP API endpoint functionality
- ✅ Verify state reporting works correctly
- ✅ Test session management functionality
### 1.5 Phase 1 Results
- ✅ **Modular Architecture**: Transformed ~900 lines into 4 focused modules (~200 lines each)
- ✅ **WebSocket Protocol**: Full compliance with worker.md specification
- ✅ **System Metrics**: Real-time CPU, memory, GPU monitoring
- ✅ **State Management**: Thread-safe subscription and session tracking
- ✅ **Backward Compatibility**: All existing endpoints preserved
- ✅ **Modern FastAPI**: Lifespan events, Pydantic v2 compatibility
## ✅ Phase 2: Pipeline Configuration & Model Management - COMPLETED
### 2.1 Models Module (`core/models/`)
- ✅ **Create `pipeline.py`** - Pipeline.json parser
- ✅ Extract pipeline configuration parsing from `pympta.py`
- ✅ Implement pipeline validation
- ✅ Add configuration schema validation
- ✅ Handle Redis and PostgreSQL configuration parsing
- ✅ **Create `manager.py`** - MPTA download and model loading
- ✅ Extract MPTA download logic from `pympta.py`
- ✅ Implement ZIP extraction and validation
- ✅ Add model file management and caching
- ✅ Handle model loading with GPU optimization
- ✅ Implement model dependency resolution
- ✅ **Create `inference.py`** - YOLO model wrapper
- ✅ Create unified YOLO model interface
- ✅ Add inference optimization and caching
- ✅ Implement batch processing capabilities
- ✅ Handle model switching and memory management
### 2.2 Testing Phase 2
- ✅ Test MPTA file download and extraction
- ✅ Test pipeline.json parsing and validation
- ✅ Test model loading with different configurations
- ✅ Verify GPU optimization works correctly
### 2.3 Phase 2 Results
- ✅ **ModelManager**: Downloads, extracts, and manages MPTA files with model ID-based directory structure
- ✅ **PipelineParser**: Parses and validates pipeline.json with full support for Redis, PostgreSQL, tracking, and branches
- ✅ **YOLOWrapper**: Unified interface for YOLO models with caching, tracking, and classification support
- ✅ **Model Caching**: Shared model cache across instances to optimize memory usage
- ✅ **Dependency Resolution**: Automatically identifies and tracks all model file dependencies
## ✅ Phase 3: Streaming System - COMPLETED
### 3.1 Streaming Module (`core/streaming/`)
- ✅ **Create `readers.py`** - RTSP/HTTP frame readers
- ✅ Extract `frame_reader` function from `app.py`
- ✅ Extract `snapshot_reader` function from `app.py`
- ✅ Add connection management and retry logic
- ✅ Implement frame rate control and optimization
- ✅ **Create `buffers.py`** - Frame buffering and caching
- ✅ Extract frame buffer management from `app.py`
- ✅ Implement efficient frame caching for REST API
- ✅ Add buffer size management and memory optimization
- ✅ **Create `manager.py`** - Stream coordination
- ✅ Extract stream lifecycle management from `app.py`
- ✅ Implement shared stream optimization
- ✅ Add subscription reconciliation logic
- ✅ Handle stream sharing across multiple subscriptions
### 3.2 Testing Phase 3
- ✅ Test RTSP stream reading and buffering
- ✅ Test HTTP snapshot capture functionality
- ✅ Test shared stream optimization
- ✅ Verify frame caching for REST API access
### 3.3 Phase 3 Results
- ✅ **RTSPReader**: OpenCV-based RTSP stream reader with automatic reconnection and frame callbacks
- ✅ **HTTPSnapshotReader**: Periodic HTTP snapshot capture with HTTPBasicAuth and HTTPDigestAuth support
- ✅ **FrameBuffer**: Thread-safe frame storage with automatic aging and cleanup
- ✅ **CacheBuffer**: Enhanced frame cache with cropping support and highest quality JPEG encoding (default quality=100)
- ✅ **StreamManager**: Complete stream lifecycle management with shared optimization and subscription reconciliation
- ✅ **Authentication Support**: Proper handling of credentials in URLs with automatic auth type detection
- ✅ **Real Camera Testing**: Verified with authenticated RTSP (1280x720) and HTTP snapshot (2688x1520) cameras
- ✅ **Production Ready**: Stable concurrent streaming from multiple camera sources
- ✅ **Dependencies**: Added opencv-python, numpy, and requests to requirements.txt
### 3.4 Recent Streaming Enhancements (Post-Phase 3)
- ✅ **Format-Specific Optimization**: Tailored for 1280x720@6fps RTSP streams and 2560x1440 HTTP snapshots
- ✅ **H.264 Error Recovery**: Enhanced error handling for corrupted frames with automatic stream recovery
- ✅ **Frame Validation**: Implemented corruption detection using edge density analysis
- ✅ **Buffer Size Optimization**: Adjusted buffer limits to 3MB for RTSP frames (1280x720x3 bytes)
- ✅ **FFMPEG Integration**: Added environment variables to suppress verbose H.264 decoder errors
- ✅ **URL Preservation**: Maintained clean RTSP URLs without parameter injection
- ✅ **Type Detection**: Automatic stream type detection based on frame dimensions
- ✅ **Quality Settings**: Format-specific JPEG quality (90% for RTSP, 95% for HTTP)
## ✅ Phase 4: Vehicle Tracking System - COMPLETED
### 4.1 Tracking Module (`core/tracking/`)
- ✅ **Create `tracker.py`** - Vehicle tracking implementation (305 lines)
- ✅ Implement continuous tracking with configurable model (front_rear_detection_v1.pt)
- ✅ Add vehicle identification and persistence with TrackedVehicle dataclass
- ✅ Implement tracking state management with thread-safe operations
- ✅ Add bounding box tracking and motion analysis with position history
- ✅ Multi-class tracking support for complex detection scenarios
- ✅ **Create `validator.py`** - Stable car validation (417 lines)
- ✅ Implement stable car detection algorithm with multiple validation criteria
- ✅ Add passing-by vs. fueling car differentiation using velocity and position analysis
- ✅ Implement validation thresholds and timing with configurable parameters
- ✅ Add confidence scoring for validation decisions with state history
- ✅ Advanced motion analysis with velocity smoothing and position variance
- ✅ **Create `integration.py`** - Tracking-pipeline integration (547 lines)
- ✅ Connect tracking system with main pipeline through TrackingPipelineIntegration
- ✅ Handle tracking state transitions and session management
- ✅ Implement post-session tracking validation with cooldown periods
- ✅ Add same-car validation after sessionId cleared with 30-second cooldown
- ✅ Car abandonment detection with automatic timeout monitoring
- ✅ Mock detection system for backend communication
- ✅ Async pipeline execution with proper error handling
### 4.2 Testing Phase 4
- ✅ Test continuous vehicle tracking functionality
- ✅ Test stable car validation logic
- ✅ Test integration with existing pipeline
- ✅ Verify tracking performance and accuracy
- ✅ Test car abandonment detection with null detection messages
- ✅ Verify session management and progression stage handling
### 4.3 Phase 4 Results
- ✅ **VehicleTracker**: Complete tracking implementation with YOLO tracking integration, position history, and stability calculations
- ✅ **StableCarValidator**: Sophisticated validation logic using velocity, position variance, and state consistency
- ✅ **TrackingPipelineIntegration**: Full integration with pipeline system including session management and async processing
- ✅ **StreamManager Integration**: Updated streaming manager to process tracking on every frame with proper threading
- ✅ **Thread-Safe Operations**: All tracking operations are thread-safe with proper locking mechanisms
- ✅ **Configurable Parameters**: All tracking parameters are configurable through pipeline.json
- ✅ **Session Management**: Complete session lifecycle management with post-fueling validation
- ✅ **Statistics and Monitoring**: Comprehensive statistics collection for tracking performance
- ✅ **Car Abandonment Detection**: Automatic detection when cars leave without fueling, sends `detection: null` to backend
- ✅ **Message Protocol**: Fixed JSON serialization to include `detection: null` for abandonment notifications
- ✅ **Streaming Optimization**: Enhanced RTSP/HTTP readers for 1280x720@6fps RTSP and 2560x1440 HTTP snapshots
- ✅ **Error Recovery**: Improved H.264 error handling and corrupted frame detection
## ✅ Phase 5: Detection Pipeline System - COMPLETED
### 5.1 Detection Module (`core/detection/`) ✅
- ✅ **Create `pipeline.py`** - Main detection orchestration (574 lines)
- ✅ Extracted main pipeline execution from `pympta.py` with full orchestration
- ✅ Implemented detection flow coordination with async execution
- ✅ Added pipeline state management with comprehensive statistics
- ✅ Handled pipeline result aggregation with branch synchronization
- ✅ Redis and database integration with error handling
- ✅ Immediate and parallel action execution with template resolution
- ✅ **Create `branches.py`** - Parallel branch processing (442 lines)
- ✅ Extracted parallel branch execution from `pympta.py`
- ✅ Implemented ThreadPoolExecutor-based parallel processing
- ✅ Added branch synchronization and result collection
- ✅ Handled branch failure and retry logic with graceful degradation
- ✅ Support for nested branches and model caching
- ✅ Both detection and classification model support
### 5.2 Storage Module (`core/storage/`) ✅
- ✅ **Create `redis.py`** - Redis operations (410 lines)
- ✅ Extracted Redis action execution from `pympta.py`
- ✅ Implemented async image storage with region cropping
- ✅ Added pub/sub messaging functionality with JSON support
- ✅ Handled Redis connection management and retry logic
- ✅ Added statistics tracking and health monitoring
- ✅ Support for various image formats (JPEG, PNG) with quality control
- ✅ **Move `database.py`** - PostgreSQL operations (339 lines)
- ✅ Moved existing `archive/siwatsystem/database.py` to `core/storage/`
- ✅ Updated imports and integration points
- ✅ Ensured compatibility with new module structure
- ✅ Added session management and statistics methods
- ✅ Enhanced error handling and connection management
### 5.3 Integration Updates ✅
- ✅ **Updated `core/tracking/integration.py`**
- ✅ Added DetectionPipeline integration
- ✅ Replaced placeholder `_execute_pipeline` with real implementation
- ✅ Added detection pipeline initialization and cleanup
- ✅ Integrated with existing tracking system flow
- ✅ Maintained backward compatibility with test mode
### 5.4 Testing Phase 5 ✅
- ✅ Verified module imports work correctly
- ✅ All new modules follow established coding patterns
- ✅ Integration points properly connected
- ✅ Error handling and cleanup methods implemented
- ✅ Statistics and monitoring capabilities added
### 5.5 Phase 5 Results ✅
- ✅ **DetectionPipeline**: Complete detection orchestration with Redis/PostgreSQL integration, async execution, and comprehensive error handling
- ✅ **BranchProcessor**: Parallel branch execution with ThreadPoolExecutor, model caching, and nested branch support
- ✅ **RedisManager**: Async Redis operations with image storage, pub/sub messaging, and connection management
- ✅ **DatabaseManager**: Enhanced PostgreSQL operations with session management and statistics
- ✅ **Module Integration**: Seamless integration with existing tracking system while maintaining compatibility
- ✅ **Error Handling**: Comprehensive error handling and graceful degradation throughout all components
- ✅ **Performance**: Optimized parallel processing and caching for high-performance pipeline execution
## ✅ Additional Implemented Features (Not in Original Plan)
### License Plate Recognition Integration (`core/storage/license_plate.py`) ✅
- ✅ **LicensePlateManager**: Subscribes to Redis channel `license_results` for external LPR service
- ✅ **Multi-format Support**: Handles various message formats from LPR service
- ✅ **Result Caching**: 5-minute TTL for license plate results
- ✅ **WebSocket Integration**: Sends combined `imageDetection` messages with license data
- ✅ **Asynchronous Processing**: Non-blocking Redis pub/sub listener
### Advanced Session State Management (`core/communication/state.py`) ✅
- ✅ **Session ID Mapping**: Per-display session identifier tracking
- ✅ **Progression Stage Tracking**: Workflow state per display (welcome, car_wait_staff, finished, cleared)
- ✅ **Thread-Safe Operations**: RLock-based synchronization for concurrent access
- ✅ **Comprehensive State Reporting**: Full system state for debugging
### Car Abandonment Detection (`core/tracking/integration.py`) ✅
- ✅ **Abandonment Monitoring**: Detects cars leaving without completing fueling
- ✅ **Timeout Configuration**: 3-second abandonment timeout
- ✅ **Null Detection Messages**: Sends `detection: null` to backend for abandoned cars
- ✅ **Automatic Cleanup**: Removes abandoned sessions from tracking
### Enhanced Message Protocol (`core/communication/models.py`) ✅
- ✅ **PatchSessionResult**: Session data patching support
- ✅ **SetProgressionStage**: Workflow stage management messages
- ✅ **Null Detection Handling**: Support for abandonment notifications
- ✅ **Complex Detection Structure**: Supports both classification and null states
### Comprehensive Timeout and Cooldown Systems ✅
- ✅ **Post-Session Cooldown**: 30-second cooldown after session clearing
- ✅ **Processing Cooldown**: 10-second cooldown for repeated processing
- ✅ **Abandonment Timeout**: 3-second timeout for car abandonment detection
- ✅ **Vehicle Expiration**: 2-second timeout for tracking cleanup
- ✅ **Stream Timeouts**: 30-second connection timeout management
## 📋 Phase 6: Integration & Final Testing
### 6.1 Main Application Refactoring
- [ ] **Refactor `app.py`**
- [ ] Remove extracted functionality
- [ ] Update to use new modular structure
- [ ] Maintain FastAPI application structure
- [ ] Update imports and dependencies
- [ ] **Clean up `siwatsystem/pympta.py`**
- [ ] Remove extracted functionality
- [ ] Keep only necessary legacy compatibility code
- [ ] Update imports to use new modules
### 6.2 Post-Session Tracking Validation
- [ ] Implement same-car validation after sessionId cleared
- [ ] Add logic to prevent duplicate pipeline execution
- [ ] Test tracking persistence through session lifecycle
- [ ] Verify correct behavior during edge cases
### 6.3 Configuration & Documentation
- [ ] Update configuration handling for new structure
- [ ] Ensure `config.json` compatibility maintained
- [ ] Update logging configuration for all modules
- [ ] Add module-level documentation
### 6.4 Comprehensive Testing
- [ ] **Integration Testing**
- [ ] Test complete system flow end-to-end
- [ ] Test all WebSocket message types
- [ ] Test HTTP API endpoints
- [ ] Test error handling and recovery
- [ ] **Performance Testing**
- [ ] Verify system performance is maintained
- [ ] Test memory usage optimization
- [ ] Test GPU utilization efficiency
- [ ] Benchmark against original implementation
- [ ] **Edge Case Testing**
- [ ] Test connection failures and reconnection
- [ ] Test model loading failures
- [ ] Test stream interruption handling
- [ ] Test concurrent subscription management
### 6.5 Logging Optimization & Cleanup ✅
- ✅ **Removed Debug Frame Saving**
- ✅ Removed hard-coded debug frame saving in `core/detection/pipeline.py`
- ✅ Removed hard-coded debug frame saving in `core/detection/branches.py`
- ✅ Eliminated absolute debug paths for production use
- ✅ **Eliminated Test/Mock Functionality**
- ✅ Removed `save_frame_for_testing` function from `core/streaming/buffers.py`
- ✅ Removed `save_test_frames` configuration from `StreamConfig`
- ✅ Cleaned up test frame saving calls in stream manager
- ✅ Updated module exports to remove test functions
- ✅ **Reduced Verbose Logging**
- ✅ Commented out verbose frame storage logging (every frame)
- ✅ Converted debug-level info logs to proper debug level
- ✅ Reduced repetitive frame dimension logging
- ✅ Maintained important model results and detection confidence logging
- ✅ Kept critical pipeline execution and error messages
- ✅ **Production-Ready Logging**
- ✅ Clean startup and initialization messages
- ✅ Clear model loading and pipeline status
- ✅ Preserved detection results with confidence scores
- ✅ Maintained session management and tracking messages
- ✅ Kept important error and warning messages
### 6.6 Final Cleanup
- [ ] Remove any remaining duplicate code
- [ ] Optimize imports across all modules
- [ ] Clean up temporary files and debugging code
- [ ] Update project documentation
## 📋 Post-Refactoring Tasks
### Documentation Updates
- [ ] Update `CLAUDE.md` with new architecture
- [ ] Create module-specific documentation
- [ ] Update installation and deployment guides
- [ ] Add troubleshooting guide for new structure
### Code Quality
- [ ] Add type hints to all new modules
- [ ] Implement proper error handling patterns
- [ ] Add logging consistency across modules
- [ ] Ensure proper resource cleanup
### Future Enhancements (Optional)
- [ ] Add unit tests for each module
- [ ] Implement monitoring and metrics collection
- [ ] Add configuration validation
- [ ] Consider adding dependency injection container
---
## Success Criteria
**Modularity**: Each module has a single, clear responsibility
**Testability**: Each phase can be tested independently
**Maintainability**: Code is easy to understand and modify
**Compatibility**: All existing functionality preserved
**Performance**: System performance is maintained or improved
**Documentation**: Clear documentation for new architecture
## Risk Mitigation
- **Feature-by-feature testing** ensures functionality is preserved at each step
- **Gradual migration** minimizes risk of breaking existing functionality
- **Preserve critical interfaces** (WebSocket protocol, HTTP endpoints)
- **Maintain backward compatibility** with existing configurations
- **Comprehensive testing** at each phase before proceeding
---
## 🎯 Current Status Summary
### ✅ Completed Phases (95% Complete)
- **Phase 1**: Communication Layer - ✅ COMPLETED
- **Phase 2**: Pipeline Configuration & Model Management - ✅ COMPLETED
- **Phase 3**: Streaming System - ✅ COMPLETED
- **Phase 4**: Vehicle Tracking System - ✅ COMPLETED
- **Phase 5**: Detection Pipeline System - ✅ COMPLETED
- **Additional Features**: License Plate Recognition, Car Abandonment, Session Management - ✅ COMPLETED
### 📋 Remaining Work (5%)
- **Phase 6**: Final Integration & Testing
- Main application cleanup (`app.py` and `pympta.py`)
- Comprehensive integration testing
- Performance benchmarking
- Documentation updates
### 🚀 Production Ready Features
- ✅ **Modular Architecture**: ~4000 lines refactored into 20+ focused modules
- ✅ **WebSocket Protocol**: Full compliance with all message types
- ✅ **License Plate Recognition**: External LPR service integration via Redis
- ✅ **Car Abandonment Detection**: Automatic detection and notification
- ✅ **Session Management**: Complete lifecycle with progression stages
- ✅ **Parallel Processing**: ThreadPoolExecutor for branch execution
- ✅ **Redis Integration**: Pub/sub, image storage, LPR subscription
- ✅ **PostgreSQL Integration**: Automatic schema management, combined updates
- ✅ **Stream Optimization**: Shared streams, format-specific handling
- ✅ **Error Recovery**: H.264 corruption detection, automatic reconnection
- ✅ **Production Logging**: Clean, informative logging without debug clutter
### 📊 Metrics
- **Modules Created**: 20+ specialized modules
- **Lines Per Module**: ~200-500 (highly maintainable)
- **Test Coverage**: Feature-by-feature validation completed
- **Performance**: Maintained or improved from original implementation
- **Backward Compatibility**: 100% preserved

1373
app.py

File diff suppressed because it is too large Load diff

903
archive/app.py Normal file
View file

@ -0,0 +1,903 @@
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
from urllib.parse import urlparse
from fastapi import FastAPI, WebSocket, HTTPException
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
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] = {}
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
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",
handlers=[
logging.FileHandler("detector_worker.log"), # Write logs to a file
logging.StreamHandler() # Also output to console
]
)
# Create a logger specifically for this application
logger = logging.getLogger("detector_worker")
logger.setLevel(logging.DEBUG) # Set app-specific logger to DEBUG level
# 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}")
# Ensure the models directory exists
os.makedirs("models", exist_ok=True)
logger.info("Ensured models directory exists")
# Constants for heartbeat and timeouts
HEARTBEAT_INTERVAL = 2 # seconds
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 download mpta ZIP file from a remote URL
def download_mpta(url: str, dest_path: str) -> str:
try:
logger.info(f"Starting download of model from {url} to {dest_path}")
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
response = requests.get(url, stream=True)
if response.status_code == 200:
file_size = int(response.headers.get('content-length', 0))
logger.info(f"Model file size: {file_size/1024/1024:.2f} MB")
downloaded = 0
with open(dest_path, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
downloaded += len(chunk)
if file_size > 0 and downloaded % (file_size // 10) < 8192: # Log approximately every 10%
logger.debug(f"Download progress: {downloaded/file_size*100:.1f}%")
logger.info(f"Successfully downloaded mpta file from {url} to {dest_path}")
return dest_path
else:
logger.error(f"Failed to download mpta file (status code {response.status_code}): {response.text}")
return None
except Exception as e:
logger.error(f"Exception downloading mpta file from {url}: {str(e)}", exc_info=True)
return None
# Add helper to fetch snapshot image from HTTP/HTTPS URL
def fetch_snapshot(url: str):
try:
from requests.auth import HTTPBasicAuth, HTTPDigestAuth
# Parse URL to extract credentials
parsed = urlparse(url)
# Prepare headers - some cameras require User-Agent
headers = {
'User-Agent': 'Mozilla/5.0 (compatible; DetectorWorker/1.0)'
}
# 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
if parsed.username and parsed.password:
# Try HTTP Digest authentication first (common for IP cameras)
try:
auth = HTTPDigestAuth(parsed.username, parsed.password)
response = requests.get(clean_url, auth=auth, headers=headers, timeout=10)
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 = requests.get(clean_url, auth=auth, headers=headers, timeout=10)
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 = requests.get(url, headers=headers, timeout=10)
else:
# No credentials in URL, make request as-is
response = requests.get(url, headers=headers, timeout=10)
if response.status_code == 200:
# Convert response content to numpy array
nparr = np.frombuffer(response.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}")
return frame
else:
logger.error(f"Failed to decode image from snapshot URL: {clean_url}")
return None
else:
logger.error(f"Failed to fetch snapshot (status code {response.status_code}): {clean_url}")
return None
except Exception as e:
logger.error(f"Exception fetching snapshot from {url}: {str(e)}")
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")
}
####################################################
# REST API endpoint for image retrieval
####################################################
@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 = {}
async def handle_detection(camera_id, stream, frame, websocket, model_tree, persistent_data):
try:
# 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 session ID lookup
subscription_parts = stream["subscriptionIdentifier"].split(';')
display_identifier = subscription_parts[0] if subscription_parts else None
session_id = session_ids.get(display_identifier) if display_identifier else None
# Create context for pipeline execution
pipeline_context = {
"camera_id": camera_id,
"display_id": display_identifier,
"session_id": session_id
}
detection_result = run_pipeline(cropped_frame, model_tree, context=pipeline_context)
process_time = (time.time() - start_time) * 1000
logger.debug(f"Detection for camera {camera_id} completed in {process_time:.2f}ms")
# 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)}")
# Direct class result (no detections/classifications structure)
if detection_result and isinstance(detection_result, dict) and "class" in detection_result and "confidence" in detection_result:
highest_confidence_detection = {
"class": detection_result.get("class", "none"),
"confidence": detection_result.get("confidence", 1.0),
"box": [0, 0, 0, 0] # Empty bounding box for classifications
}
# Handle case when no detections found or result is empty
elif not detection_result or not detection_result.get("detections"):
# Check if we have classification results
if detection_result and detection_result.get("classifications"):
# Get the highest confidence classification
classifications = detection_result.get("classifications", [])
highest_confidence_class = max(classifications, key=lambda x: x.get("confidence", 0)) if classifications else None
if highest_confidence_class:
highest_confidence_detection = {
"class": highest_confidence_class.get("class", "none"),
"confidence": highest_confidence_class.get("confidence", 1.0),
"box": [0, 0, 0, 0] # Empty bounding box for classifications
}
else:
highest_confidence_detection = {
"class": "none",
"confidence": 1.0,
"box": [0, 0, 0, 0]
}
else:
highest_confidence_detection = {
"class": "none",
"confidence": 1.0,
"box": [0, 0, 0, 0]
}
else:
# Find detection with highest confidence
detections = detection_result.get("detections", [])
highest_confidence_detection = max(detections, key=lambda x: x.get("confidence", 0)) if detections else {
"class": "none",
"confidence": 1.0,
"box": [0, 0, 0, 0]
}
# Convert detection format to match protocol - flatten detection attributes
detection_dict = {}
# Handle different detection result formats
if isinstance(highest_confidence_detection, dict):
# Copy all fields from the detection result
for key, value in highest_confidence_detection.items():
if key not in ["box", "id"]: # Skip internal fields
detection_dict[key] = value
detection_data = {
"type": "imageDetection",
"subscriptionIdentifier": stream["subscriptionIdentifier"],
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S.%fZ", time.gmtime()),
"data": {
"detection": detection_dict,
"modelId": stream["modelId"],
"modelName": stream["modelName"]
}
}
# Add session ID if available
if session_id is not None:
detection_data["sessionId"] = session_id
if highest_confidence_detection["class"] != "none":
logger.info(f"Camera {camera_id}: Detected {highest_confidence_detection['class']} with confidence {highest_confidence_detection['confidence']:.2f} using model {stream['modelName']}")
# Log session ID if available
if session_id:
logger.debug(f"Detection associated with session ID: {session_id}")
await websocket.send_json(detection_data)
logger.debug(f"Sent detection data to client 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
def frame_reader(camera_id, cap, buffer, stop_event):
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}")
else:
logger.error(f"Camera {camera_id} failed to open 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:
logger.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 for camera: {camera_id}, stopping frame reader")
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}")
continue
logger.info(f"Successfully reopened RTSP stream for camera: {camera_id}")
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
# 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:
logger.error(f"OpenCV error for camera {camera_id}: {e}", exc_info=True)
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}")
break
logger.info(f"Attempting to reopen RTSP stream after OpenCV error 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} after OpenCV error")
continue
logger.info(f"Successfully reopened RTSP stream after OpenCV error for camera: {camera_id}")
except Exception as e:
logger.error(f"Unexpected error for camera {camera_id}: {str(e)}", exc_info=True)
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():
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
logger.info(f"Starting snapshot reader thread for camera {camera_id} from {snapshot_url}")
frame_count = 0
last_log_time = time.time()
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:
logger.warning(f"Failed to fetch snapshot for camera: {camera_id}, retry {retries+1}/{max_retries}")
retries += 1
if retries > max_retries and max_retries != -1:
logger.error(f"Max retries reached for snapshot camera: {camera_id}, stopping reader")
break
time.sleep(min(interval_seconds, reconnect_interval))
continue
# Successfully fetched 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}: 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
# 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:
logger.error(f"Unexpected error fetching snapshot for camera {camera_id}: {str(e)}", exc_info=True)
retries += 1
if retries > max_retries and max_retries != -1:
logger.error(f"Max retries reached after error for snapshot camera {camera_id}")
break
time.sleep(min(interval_seconds, reconnect_interval))
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 process_streams():
logger.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
sleep_time = max(poll_interval - elapsed_time, 0)
logger.debug(f"Frame processing cycle: {elapsed_time:.2f}ms, sleeping for: {sleep_time:.2f}ms")
await asyncio.sleep(sleep_time / 1000.0)
except asyncio.CancelledError:
logger.info("Stream processing task cancelled")
except Exception as e:
logger.error(f"Error in process_streams: {str(e)}", exc_info=True)
async def send_heartbeat():
while True:
try:
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)
else:
gpu_usage = None
gpu_memory_usage = None
camera_connections = [
{
"subscriptionIdentifier": stream["subscriptionIdentifier"],
"modelId": stream["modelId"],
"modelName": stream["modelName"],
"online": True,
**{k: v for k, v in get_crop_coords(stream).items() if v is not None}
}
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))
logger.debug(f"Sent stateReport as heartbeat: CPU {cpu_usage:.1f}%, Memory {memory_usage:.1f}%, {len(camera_connections)} active cameras")
await asyncio.sleep(HEARTBEAT_INTERVAL)
except Exception as e:
logger.error(f"Error sending stateReport heartbeat: {e}")
break
async def on_message():
while True:
try:
msg = await websocket.receive_text()
logger.debug(f"Received message: {msg}")
data = json.loads(msg)
msg_type = data.get("type")
if msg_type == "subscribe":
payload = data.get("payload", {})
subscriptionIdentifier = payload.get("subscriptionIdentifier")
rtsp_url = payload.get("rtspUrl")
snapshot_url = payload.get("snapshotUrl")
snapshot_interval = payload.get("snapshotInterval")
model_url = payload.get("modelUrl")
modelId = payload.get("modelId")
modelName = payload.get("modelName")
cropX1 = payload.get("cropX1")
cropY1 = payload.get("cropY1")
cropX2 = payload.get("cropX2")
cropY2 = payload.get("cropY2")
# Extract camera_id from subscriptionIdentifier (format: displayIdentifier;cameraIdentifier)
parts = subscriptionIdentifier.split(';')
if len(parts) != 2:
logger.error(f"Invalid subscriptionIdentifier format: {subscriptionIdentifier}")
continue
display_identifier, camera_identifier = parts
camera_id = subscriptionIdentifier # Use full subscriptionIdentifier as camera_id for mapping
if model_url:
with models_lock:
if (camera_id not in models) or (modelId not in models[camera_id]):
logger.info(f"Loading model from {model_url} for camera {camera_id}, modelId {modelId}")
extraction_dir = os.path.join("models", camera_identifier, str(modelId))
os.makedirs(extraction_dir, exist_ok=True)
# If model_url is remote, download it first.
parsed = urlparse(model_url)
if parsed.scheme in ("http", "https"):
logger.info(f"Downloading remote .mpta file from {model_url}")
filename = os.path.basename(parsed.path) or f"model_{modelId}.mpta"
local_mpta = os.path.join(extraction_dir, filename)
logger.debug(f"Download destination: {local_mpta}")
local_path = download_mpta(model_url, local_mpta)
if not local_path:
logger.error(f"Failed to download the remote .mpta file from {model_url}")
error_response = {
"type": "error",
"subscriptionIdentifier": subscriptionIdentifier,
"error": f"Failed to download model from {model_url}"
}
await websocket.send_json(error_response)
continue
model_tree = load_pipeline_from_zip(local_path, extraction_dir)
else:
logger.info(f"Loading local .mpta file from {model_url}")
# Check if file exists before attempting to load
if not os.path.exists(model_url):
logger.error(f"Local .mpta file not found: {model_url}")
logger.debug(f"Current working directory: {os.getcwd()}")
error_response = {
"type": "error",
"subscriptionIdentifier": subscriptionIdentifier,
"error": f"Model file not found: {model_url}"
}
await websocket.send_json(error_response)
continue
model_tree = load_pipeline_from_zip(model_url, extraction_dir)
if model_tree is None:
logger.error(f"Failed to load model {modelId} from .mpta file for camera {camera_id}")
error_response = {
"type": "error",
"subscriptionIdentifier": subscriptionIdentifier,
"error": f"Failed to load model {modelId}"
}
await websocket.send_json(error_response)
continue
if camera_id not in models:
models[camera_id] = {}
models[camera_id][modelId] = model_tree
logger.info(f"Successfully loaded model {modelId} for camera {camera_id}")
logger.debug(f"Model extraction directory: {extraction_dir}")
if camera_id and (rtsp_url or snapshot_url):
with streams_lock:
# Determine camera URL for shared stream management
camera_url = snapshot_url if snapshot_url else rtsp_url
if camera_id not in streams and len(streams) < max_streams:
# Check if we already have a stream for this camera URL
shared_stream = camera_streams.get(camera_url)
if shared_stream:
# Reuse existing stream
logger.info(f"Reusing existing stream for camera URL: {camera_url}")
buffer = shared_stream["buffer"]
stop_event = shared_stream["stop_event"]
thread = shared_stream["thread"]
mode = shared_stream["mode"]
# Increment reference count
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:
logger.info(f"Creating new snapshot stream for camera {camera_id}: {snapshot_url}")
thread = threading.Thread(target=snapshot_reader, args=(camera_id, snapshot_url, snapshot_interval, buffer, stop_event))
thread.daemon = True
thread.start()
mode = "snapshot"
# Store shared stream info
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:
logger.info(f"Creating new RTSP stream for camera {camera_id}: {rtsp_url}")
cap = cv2.VideoCapture(rtsp_url)
if not cap.isOpened():
logger.error(f"Failed to open RTSP stream for camera {camera_id}")
continue
thread = threading.Thread(target=frame_reader, args=(camera_id, cap, buffer, stop_event))
thread.daemon = True
thread.start()
mode = "rtsp"
# Store shared stream info
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}")
continue
# Create stream info for this subscription
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
}
if mode == "snapshot":
stream_info["snapshot_url"] = snapshot_url
stream_info["snapshot_interval"] = snapshot_interval
elif mode == "rtsp":
stream_info["rtsp_url"] = rtsp_url
stream_info["cap"] = shared_stream["cap"]
streams[camera_id] = stream_info
subscription_to_camera[camera_id] = camera_url
elif camera_id and camera_id in streams:
# If already subscribed, unsubscribe first
logger.info(f"Resubscribing to camera {camera_id}")
# Note: Keep models in memory for reuse across subscriptions
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
latest_frames.pop(camera_id, None)
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)
else:
gpu_usage = None
gpu_memory_usage = None
camera_connections = [
{
"subscriptionIdentifier": stream["subscriptionIdentifier"],
"modelId": stream["modelId"],
"modelName": stream["modelName"],
"online": True,
**{k: v for k, v in get_crop_coords(stream).items() if v is not None}
}
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")
if display_identifier:
# Store session ID for this display
if session_id is None:
session_ids.pop(display_identifier, None)
logger.info(f"Cleared session ID for display {display_identifier}")
else:
session_ids[display_identifier] = session_id
logger.info(f"Set session ID {session_id} for display {display_identifier}")
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"
}
}
await websocket.send_json(response)
logger.info(f"Acknowledged patch for session {session_id}")
else:
logger.error(f"Unknown message type: {msg_type}")
except json.JSONDecodeError:
logger.error("Received invalid JSON message")
except (WebSocketDisconnect, ConnectionClosedError) as e:
logger.warning(f"WebSocket disconnected: {e}")
break
except Exception as e:
logger.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}")
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:
models.clear()
latest_frames.clear()
session_ids.clear()
logger.info("WebSocket connection closed")

View file

@ -1,7 +1,9 @@
{
"poll_interval_ms": 100,
"max_streams": 5,
"max_streams": 20,
"target_fps": 2,
"reconnect_interval_sec": 5,
"max_retries": -1
"reconnect_interval_sec": 10,
"max_retries": -1,
"rtsp_buffer_size": 3,
"rtsp_tcp_transport": true
}

1
core/__init__.py Normal file
View file

@ -0,0 +1 @@
# Core package for detector worker

View file

@ -0,0 +1 @@
# Communication module for WebSocket and HTTP handling

View file

@ -0,0 +1,212 @@
"""
Message types, constants, and validation functions for WebSocket communication.
"""
import json
import logging
from typing import Dict, Any, Optional, Union
from .models import (
IncomingMessage, OutgoingMessage,
SetSubscriptionListMessage, SetSessionIdMessage, SetProgressionStageMessage,
RequestStateMessage, PatchSessionResultMessage,
StateReportMessage, ImageDetectionMessage, PatchSessionMessage
)
logger = logging.getLogger(__name__)
# Message type constants
class MessageTypes:
"""WebSocket message type constants."""
# Incoming from backend
SET_SUBSCRIPTION_LIST = "setSubscriptionList"
SET_SESSION_ID = "setSessionId"
SET_PROGRESSION_STAGE = "setProgressionStage"
REQUEST_STATE = "requestState"
PATCH_SESSION_RESULT = "patchSessionResult"
# Outgoing to backend
STATE_REPORT = "stateReport"
IMAGE_DETECTION = "imageDetection"
PATCH_SESSION = "patchSession"
def parse_incoming_message(raw_message: str) -> Optional[IncomingMessage]:
"""
Parse incoming WebSocket message and validate against known types.
Args:
raw_message: Raw JSON string from WebSocket
Returns:
Parsed message object or None if invalid
"""
try:
data = json.loads(raw_message)
message_type = data.get("type")
if not message_type:
logger.error("Message missing 'type' field")
return None
# Route to appropriate message class
if message_type == MessageTypes.SET_SUBSCRIPTION_LIST:
return SetSubscriptionListMessage(**data)
elif message_type == MessageTypes.SET_SESSION_ID:
return SetSessionIdMessage(**data)
elif message_type == MessageTypes.SET_PROGRESSION_STAGE:
return SetProgressionStageMessage(**data)
elif message_type == MessageTypes.REQUEST_STATE:
return RequestStateMessage(**data)
elif message_type == MessageTypes.PATCH_SESSION_RESULT:
return PatchSessionResultMessage(**data)
else:
logger.warning(f"Unknown message type: {message_type}")
return None
except json.JSONDecodeError as e:
logger.error(f"Failed to decode JSON message: {e}")
return None
except Exception as e:
logger.error(f"Failed to parse incoming message: {e}")
return None
def serialize_outgoing_message(message: OutgoingMessage) -> str:
"""
Serialize outgoing message to JSON string.
Args:
message: Message object to serialize
Returns:
JSON string representation
"""
try:
# For ImageDetectionMessage, we need to include None values for abandonment detection
from .models import ImageDetectionMessage
if isinstance(message, ImageDetectionMessage):
return message.model_dump_json(exclude_none=False)
else:
return message.model_dump_json(exclude_none=True)
except Exception as e:
logger.error(f"Failed to serialize outgoing message: {e}")
raise
def validate_subscription_identifier(identifier: str) -> bool:
"""
Validate subscription identifier format (displayId;cameraId).
Args:
identifier: Subscription identifier to validate
Returns:
True if valid format, False otherwise
"""
if not identifier or not isinstance(identifier, str):
return False
parts = identifier.split(';')
if len(parts) != 2:
logger.error(f"Invalid subscription identifier format: {identifier}")
return False
display_id, camera_id = parts
if not display_id or not camera_id:
logger.error(f"Empty display or camera ID in identifier: {identifier}")
return False
return True
def extract_display_identifier(subscription_identifier: str) -> Optional[str]:
"""
Extract display identifier from subscription identifier.
Args:
subscription_identifier: Full subscription identifier (displayId;cameraId)
Returns:
Display identifier or None if invalid format
"""
if not validate_subscription_identifier(subscription_identifier):
return None
return subscription_identifier.split(';')[0]
def create_state_report(cpu_usage: float, memory_usage: float,
gpu_usage: Optional[float] = None,
gpu_memory_usage: Optional[float] = None,
camera_connections: Optional[list] = None) -> StateReportMessage:
"""
Create a state report message with system metrics.
Args:
cpu_usage: CPU usage percentage
memory_usage: Memory usage percentage
gpu_usage: GPU usage percentage (optional)
gpu_memory_usage: GPU memory usage in MB (optional)
camera_connections: List of active camera connections
Returns:
StateReportMessage object
"""
return StateReportMessage(
cpuUsage=cpu_usage,
memoryUsage=memory_usage,
gpuUsage=gpu_usage,
gpuMemoryUsage=gpu_memory_usage,
cameraConnections=camera_connections or []
)
def create_image_detection(subscription_identifier: str, detection_data: Union[Dict[str, Any], None],
model_id: int, model_name: str) -> ImageDetectionMessage:
"""
Create an image detection message.
Args:
subscription_identifier: Camera subscription identifier
detection_data: Detection results - Dict for data, {} for empty, None for abandonment
model_id: Model identifier
model_name: Model name
Returns:
ImageDetectionMessage object
"""
from .models import DetectionData
from typing import Union
# Handle three cases:
# 1. None = car abandonment (detection: null)
# 2. {} = empty detection (triggers session creation)
# 3. {...} = full detection data (updates session)
data = DetectionData(
detection=detection_data,
modelId=model_id,
modelName=model_name
)
return ImageDetectionMessage(
subscriptionIdentifier=subscription_identifier,
data=data
)
def create_patch_session(session_id: int, patch_data: Dict[str, Any]) -> PatchSessionMessage:
"""
Create a patch session message.
Args:
session_id: Session ID to patch
patch_data: Partial session data to update
Returns:
PatchSessionMessage object
"""
return PatchSessionMessage(
sessionId=session_id,
data=patch_data
)

View file

@ -0,0 +1,150 @@
"""
Message data structures for WebSocket communication.
Based on worker.md protocol specification.
"""
from typing import Dict, Any, List, Optional, Union, Literal
from pydantic import BaseModel, Field
from datetime import datetime
class SubscriptionObject(BaseModel):
"""Individual camera subscription configuration."""
subscriptionIdentifier: str = Field(..., description="Format: displayId;cameraId")
rtspUrl: Optional[str] = Field(None, description="RTSP stream URL")
snapshotUrl: Optional[str] = Field(None, description="HTTP snapshot URL")
snapshotInterval: Optional[int] = Field(None, description="Snapshot interval in milliseconds")
modelUrl: str = Field(..., description="Pre-signed URL to .mpta file")
modelId: int = Field(..., description="Unique model identifier")
modelName: str = Field(..., description="Human-readable model name")
cropX1: Optional[int] = Field(None, description="Crop region X1 coordinate")
cropY1: Optional[int] = Field(None, description="Crop region Y1 coordinate")
cropX2: Optional[int] = Field(None, description="Crop region X2 coordinate")
cropY2: Optional[int] = Field(None, description="Crop region Y2 coordinate")
class CameraConnection(BaseModel):
"""Camera connection status for state reporting."""
subscriptionIdentifier: str
modelId: int
modelName: str
online: bool
cropX1: Optional[int] = None
cropY1: Optional[int] = None
cropX2: Optional[int] = None
cropY2: Optional[int] = None
class DetectionData(BaseModel):
"""
Detection result data structure.
Supports three cases:
1. Empty detection: detection = {} (triggers session creation)
2. Full detection: detection = {"carBrand": "Honda", ...} (updates session)
3. Null detection: detection = None (car abandonment)
"""
model_config = {
"json_encoders": {type(None): lambda v: None},
"arbitrary_types_allowed": True
}
detection: Union[Dict[str, Any], None] = Field(
default_factory=dict,
description="Detection results: {} for empty, {...} for data, None/null for abandonment"
)
modelId: int
modelName: str
# Incoming Messages from Backend to Worker
class SetSubscriptionListMessage(BaseModel):
"""Complete subscription list for declarative state management."""
type: Literal["setSubscriptionList"] = "setSubscriptionList"
subscriptions: List[SubscriptionObject]
class SetSessionIdPayload(BaseModel):
"""Session ID association payload."""
displayIdentifier: str
sessionId: Optional[int] = None
class SetSessionIdMessage(BaseModel):
"""Associate session ID with display."""
type: Literal["setSessionId"] = "setSessionId"
payload: SetSessionIdPayload
class SetProgressionStagePayload(BaseModel):
"""Progression stage payload."""
displayIdentifier: str
progressionStage: Optional[str] = None
class SetProgressionStageMessage(BaseModel):
"""Set progression stage for display."""
type: Literal["setProgressionStage"] = "setProgressionStage"
payload: SetProgressionStagePayload
class RequestStateMessage(BaseModel):
"""Request current worker state."""
type: Literal["requestState"] = "requestState"
class PatchSessionResultPayload(BaseModel):
"""Patch session result payload."""
sessionId: int
success: bool
message: str
class PatchSessionResultMessage(BaseModel):
"""Response to patch session request."""
type: Literal["patchSessionResult"] = "patchSessionResult"
payload: PatchSessionResultPayload
# Outgoing Messages from Worker to Backend
class StateReportMessage(BaseModel):
"""Periodic heartbeat with system metrics."""
type: Literal["stateReport"] = "stateReport"
cpuUsage: float
memoryUsage: float
gpuUsage: Optional[float] = None
gpuMemoryUsage: Optional[float] = None
cameraConnections: List[CameraConnection]
class ImageDetectionMessage(BaseModel):
"""Detection event message."""
type: Literal["imageDetection"] = "imageDetection"
subscriptionIdentifier: str
timestamp: str = Field(default_factory=lambda: datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.%fZ"))
data: DetectionData
class PatchSessionMessage(BaseModel):
"""Request to modify session data."""
type: Literal["patchSession"] = "patchSession"
sessionId: int
data: Dict[str, Any] = Field(..., description="Partial DisplayPersistentData structure")
# Union type for all incoming messages
IncomingMessage = Union[
SetSubscriptionListMessage,
SetSessionIdMessage,
SetProgressionStageMessage,
RequestStateMessage,
PatchSessionResultMessage
]
# Union type for all outgoing messages
OutgoingMessage = Union[
StateReportMessage,
ImageDetectionMessage,
PatchSessionMessage
]

234
core/communication/state.py Normal file
View file

@ -0,0 +1,234 @@
"""
Worker state management for system metrics and subscription tracking.
"""
import logging
import psutil
import threading
from typing import Dict, Set, Optional, List
from dataclasses import dataclass, field
from .models import CameraConnection, SubscriptionObject
logger = logging.getLogger(__name__)
# Try to import torch and pynvml for GPU monitoring
try:
import torch
TORCH_AVAILABLE = True
except ImportError:
TORCH_AVAILABLE = False
logger.warning("PyTorch not available, GPU metrics will not be collected")
try:
import pynvml
PYNVML_AVAILABLE = True
pynvml.nvmlInit()
logger.info("NVIDIA ML Python (pynvml) initialized successfully")
except ImportError:
PYNVML_AVAILABLE = False
logger.debug("pynvml not available, falling back to PyTorch GPU monitoring")
except Exception as e:
PYNVML_AVAILABLE = False
logger.warning(f"Failed to initialize pynvml: {e}")
@dataclass
class WorkerState:
"""Central state management for the detector worker."""
# Active subscriptions indexed by subscription identifier
subscriptions: Dict[str, SubscriptionObject] = field(default_factory=dict)
# Session ID mapping: display_identifier -> session_id
session_ids: Dict[str, int] = field(default_factory=dict)
# Progression stage mapping: display_identifier -> stage
progression_stages: Dict[str, str] = field(default_factory=dict)
# Active camera connections for state reporting
camera_connections: List[CameraConnection] = field(default_factory=list)
# Thread lock for state synchronization
_lock: threading.RLock = field(default_factory=threading.RLock)
def set_subscriptions(self, new_subscriptions: List[SubscriptionObject]) -> None:
"""
Update active subscriptions with declarative list from backend.
Args:
new_subscriptions: Complete list of desired subscriptions
"""
with self._lock:
# Convert to dict for easy lookup
new_sub_dict = {sub.subscriptionIdentifier: sub for sub in new_subscriptions}
# Log changes for debugging
current_ids = set(self.subscriptions.keys())
new_ids = set(new_sub_dict.keys())
added = new_ids - current_ids
removed = current_ids - new_ids
updated = current_ids & new_ids
if added:
logger.info(f"[State Update] Adding subscriptions: {added}")
if removed:
logger.info(f"[State Update] Removing subscriptions: {removed}")
if updated:
logger.info(f"[State Update] Updating subscriptions: {updated}")
# Replace entire subscription dict
self.subscriptions = new_sub_dict
# Update camera connections for state reporting
self._update_camera_connections()
def get_subscription(self, subscription_identifier: str) -> Optional[SubscriptionObject]:
"""Get subscription by identifier."""
with self._lock:
return self.subscriptions.get(subscription_identifier)
def get_all_subscriptions(self) -> List[SubscriptionObject]:
"""Get all active subscriptions."""
with self._lock:
return list(self.subscriptions.values())
def set_session_id(self, display_identifier: str, session_id: Optional[int]) -> None:
"""
Set or clear session ID for a display.
Args:
display_identifier: Display identifier
session_id: Session ID to set, or None to clear
"""
with self._lock:
if session_id is None:
self.session_ids.pop(display_identifier, None)
logger.info(f"[State Update] Cleared session ID for display {display_identifier}")
else:
self.session_ids[display_identifier] = session_id
logger.info(f"[State Update] Set session ID {session_id} for display {display_identifier}")
def get_session_id(self, display_identifier: str) -> Optional[int]:
"""Get session ID for display identifier."""
with self._lock:
return self.session_ids.get(display_identifier)
def get_session_id_for_subscription(self, subscription_identifier: str) -> Optional[int]:
"""Get session ID for subscription by extracting display identifier."""
from .messages import extract_display_identifier
display_id = extract_display_identifier(subscription_identifier)
if display_id:
return self.get_session_id(display_id)
return None
def set_progression_stage(self, display_identifier: str, stage: Optional[str]) -> None:
"""
Set or clear progression stage for a display.
Args:
display_identifier: Display identifier
stage: Progression stage to set, or None to clear
"""
with self._lock:
if stage is None:
self.progression_stages.pop(display_identifier, None)
logger.info(f"[State Update] Cleared progression stage for display {display_identifier}")
else:
self.progression_stages[display_identifier] = stage
logger.info(f"[State Update] Set progression stage '{stage}' for display {display_identifier}")
def get_progression_stage(self, display_identifier: str) -> Optional[str]:
"""Get progression stage for display identifier."""
with self._lock:
return self.progression_stages.get(display_identifier)
def _update_camera_connections(self) -> None:
"""Update camera connections list for state reporting."""
connections = []
for sub in self.subscriptions.values():
connection = CameraConnection(
subscriptionIdentifier=sub.subscriptionIdentifier,
modelId=sub.modelId,
modelName=sub.modelName,
online=True, # TODO: Add actual online status tracking
cropX1=sub.cropX1,
cropY1=sub.cropY1,
cropX2=sub.cropX2,
cropY2=sub.cropY2
)
connections.append(connection)
self.camera_connections = connections
def get_camera_connections(self) -> List[CameraConnection]:
"""Get current camera connections for state reporting."""
with self._lock:
return self.camera_connections.copy()
class SystemMetrics:
"""System metrics collection for state reporting."""
@staticmethod
def get_cpu_usage() -> float:
"""Get current CPU usage percentage."""
try:
return psutil.cpu_percent(interval=0.1)
except Exception as e:
logger.error(f"Failed to get CPU usage: {e}")
return 0.0
@staticmethod
def get_memory_usage() -> float:
"""Get current memory usage percentage."""
try:
return psutil.virtual_memory().percent
except Exception as e:
logger.error(f"Failed to get memory usage: {e}")
return 0.0
@staticmethod
def get_gpu_usage() -> Optional[float]:
"""Get current GPU usage percentage."""
try:
# Prefer pynvml for accurate GPU utilization
if PYNVML_AVAILABLE:
handle = pynvml.nvmlDeviceGetHandleByIndex(0) # First GPU
utilization = pynvml.nvmlDeviceGetUtilizationRates(handle)
return float(utilization.gpu)
# Fallback to PyTorch memory-based estimation
elif TORCH_AVAILABLE and torch.cuda.is_available():
if hasattr(torch.cuda, 'utilization'):
return torch.cuda.utilization()
else:
# Estimate based on memory usage
allocated = torch.cuda.memory_allocated()
reserved = torch.cuda.memory_reserved()
if reserved > 0:
return (allocated / reserved) * 100
return None
except Exception as e:
logger.error(f"Failed to get GPU usage: {e}")
return None
@staticmethod
def get_gpu_memory_usage() -> Optional[float]:
"""Get current GPU memory usage in MB."""
if not TORCH_AVAILABLE:
return None
try:
if torch.cuda.is_available():
return torch.cuda.memory_reserved() / (1024 ** 2) # Convert to MB
return None
except Exception as e:
logger.error(f"Failed to get GPU memory usage: {e}")
return None
# Global worker state instance
worker_state = WorkerState()

View file

@ -0,0 +1,677 @@
"""
WebSocket message handling and protocol implementation.
"""
import asyncio
import json
import logging
import os
import cv2
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Optional
from fastapi import WebSocket, WebSocketDisconnect
from websockets.exceptions import ConnectionClosedError
from .messages import (
parse_incoming_message, serialize_outgoing_message,
MessageTypes, create_state_report
)
from .models import (
SetSubscriptionListMessage, SetSessionIdMessage, SetProgressionStageMessage,
RequestStateMessage, PatchSessionResultMessage
)
from .state import worker_state, SystemMetrics
from ..models import ModelManager
from ..streaming.manager import shared_stream_manager
from ..tracking.integration import TrackingPipelineIntegration
logger = logging.getLogger(__name__)
# Constants
HEARTBEAT_INTERVAL = 2.0 # seconds
WORKER_TIMEOUT_MS = 10000
# Global model manager instance
model_manager = ModelManager()
class WebSocketHandler:
"""
Handles WebSocket connection lifecycle and message processing.
"""
def __init__(self, websocket: WebSocket):
self.websocket = websocket
self.connected = False
self._heartbeat_task: Optional[asyncio.Task] = None
self._message_task: Optional[asyncio.Task] = None
self._heartbeat_count = 0
self._last_processed_models: set = set() # Cache of last processed model IDs
async def handle_connection(self) -> None:
"""
Main connection handler that manages the WebSocket lifecycle.
Based on the original architecture from archive/app.py
"""
client_info = f"{self.websocket.client.host}:{self.websocket.client.port}" if self.websocket.client else "unknown"
logger.info(f"Starting WebSocket handler for {client_info}")
stream_task = None
try:
logger.info(f"Accepting WebSocket connection from {client_info}")
await self.websocket.accept()
self.connected = True
logger.info(f"WebSocket connection accepted and established for {client_info}")
# Send immediate heartbeat to show connection is alive
await self._send_immediate_heartbeat()
# Start background tasks (matching original architecture)
stream_task = asyncio.create_task(self._process_streams())
heartbeat_task = asyncio.create_task(self._send_heartbeat())
message_task = asyncio.create_task(self._handle_messages())
logger.info(f"WebSocket background tasks started for {client_info} (stream + heartbeat + message handler)")
# Wait for heartbeat and message tasks (stream runs independently)
await asyncio.gather(heartbeat_task, message_task)
except Exception as e:
logger.error(f"Error in WebSocket connection for {client_info}: {e}", exc_info=True)
finally:
logger.info(f"Cleaning up connection for {client_info}")
# Cancel stream task
if stream_task and not stream_task.done():
stream_task.cancel()
try:
await stream_task
except asyncio.CancelledError:
logger.debug(f"Stream task cancelled for {client_info}")
await self._cleanup()
async def _send_immediate_heartbeat(self) -> None:
"""Send immediate heartbeat on connection to show we're alive."""
try:
cpu_usage = SystemMetrics.get_cpu_usage()
memory_usage = SystemMetrics.get_memory_usage()
gpu_usage = SystemMetrics.get_gpu_usage()
gpu_memory_usage = SystemMetrics.get_gpu_memory_usage()
camera_connections = worker_state.get_camera_connections()
state_report = create_state_report(
cpu_usage=cpu_usage,
memory_usage=memory_usage,
gpu_usage=gpu_usage,
gpu_memory_usage=gpu_memory_usage,
camera_connections=camera_connections
)
await self._send_message(state_report)
logger.info(f"[TX → Backend] Initial stateReport: CPU {cpu_usage:.1f}%, Memory {memory_usage:.1f}%, "
f"GPU {gpu_usage or 'N/A'}, {len(camera_connections)} cameras")
except Exception as e:
logger.error(f"Error sending immediate heartbeat: {e}")
async def _send_heartbeat(self) -> None:
"""Send periodic state reports as heartbeat."""
while self.connected:
try:
# Collect system metrics
cpu_usage = SystemMetrics.get_cpu_usage()
memory_usage = SystemMetrics.get_memory_usage()
gpu_usage = SystemMetrics.get_gpu_usage()
gpu_memory_usage = SystemMetrics.get_gpu_memory_usage()
camera_connections = worker_state.get_camera_connections()
# Create and send state report
state_report = create_state_report(
cpu_usage=cpu_usage,
memory_usage=memory_usage,
gpu_usage=gpu_usage,
gpu_memory_usage=gpu_memory_usage,
camera_connections=camera_connections
)
await self._send_message(state_report)
# Only log full details every 10th heartbeat, otherwise just show a dot
self._heartbeat_count += 1
if self._heartbeat_count % 10 == 0:
logger.info(f"[TX → Backend] Heartbeat #{self._heartbeat_count}: CPU {cpu_usage:.1f}%, Memory {memory_usage:.1f}%, "
f"GPU {gpu_usage or 'N/A'}, {len(camera_connections)} cameras")
else:
print(".", end="", flush=True) # Just show a dot to indicate heartbeat activity
await asyncio.sleep(HEARTBEAT_INTERVAL)
except Exception as e:
logger.error(f"Error sending heartbeat: {e}")
break
async def _handle_messages(self) -> None:
"""Handle incoming WebSocket messages."""
while self.connected:
try:
raw_message = await self.websocket.receive_text()
logger.info(f"[RX ← Backend] {raw_message}")
# Parse incoming message
message = parse_incoming_message(raw_message)
if not message:
logger.warning("Failed to parse incoming message")
continue
# Route message to appropriate handler
await self._route_message(message)
except (WebSocketDisconnect, ConnectionClosedError) as e:
logger.warning(f"WebSocket disconnected: {e}")
break
except json.JSONDecodeError:
logger.error("Received invalid JSON message")
except Exception as e:
logger.error(f"Error handling message: {e}")
break
async def _route_message(self, message) -> None:
"""Route parsed message to appropriate handler."""
message_type = message.type
try:
if message_type == MessageTypes.SET_SUBSCRIPTION_LIST:
await self._handle_set_subscription_list(message)
elif message_type == MessageTypes.SET_SESSION_ID:
await self._handle_set_session_id(message)
elif message_type == MessageTypes.SET_PROGRESSION_STAGE:
await self._handle_set_progression_stage(message)
elif message_type == MessageTypes.REQUEST_STATE:
await self._handle_request_state(message)
elif message_type == MessageTypes.PATCH_SESSION_RESULT:
await self._handle_patch_session_result(message)
else:
logger.warning(f"Unknown message type: {message_type}")
except Exception as e:
logger.error(f"Error handling {message_type} message: {e}")
async def _handle_set_subscription_list(self, message: SetSubscriptionListMessage) -> None:
"""Handle setSubscriptionList message for declarative subscription management."""
logger.info(f"[RX Processing] setSubscriptionList with {len(message.subscriptions)} subscriptions")
# Update worker state with new subscriptions
worker_state.set_subscriptions(message.subscriptions)
# Phase 2: Download and manage models
await self._ensure_models(message.subscriptions)
# Phase 3 & 4: Integrate with streaming management and tracking
await self._update_stream_subscriptions(message.subscriptions)
logger.info("Subscription list updated successfully")
async def _ensure_models(self, subscriptions) -> None:
"""Ensure all required models are downloaded and available."""
# Extract unique model requirements
unique_models = {}
for subscription in subscriptions:
model_id = subscription.modelId
if model_id not in unique_models:
unique_models[model_id] = {
'model_url': subscription.modelUrl,
'model_name': subscription.modelName
}
# Check if model set has changed to avoid redundant processing
current_model_ids = set(unique_models.keys())
if current_model_ids == self._last_processed_models:
logger.debug(f"[Model Management] Model set unchanged {list(current_model_ids)}, skipping checks")
return
logger.info(f"[Model Management] Processing {len(unique_models)} unique models: {list(unique_models.keys())}")
self._last_processed_models = current_model_ids
# Check and download models concurrently
download_tasks = []
for model_id, model_info in unique_models.items():
task = asyncio.create_task(
self._ensure_single_model(model_id, model_info['model_url'], model_info['model_name'])
)
download_tasks.append(task)
# Wait for all downloads to complete
if download_tasks:
results = await asyncio.gather(*download_tasks, return_exceptions=True)
# Log results
success_count = 0
for i, result in enumerate(results):
model_id = list(unique_models.keys())[i]
if isinstance(result, Exception):
logger.error(f"[Model Management] Failed to ensure model {model_id}: {result}")
elif result:
success_count += 1
logger.info(f"[Model Management] Model {model_id} ready for use")
else:
logger.error(f"[Model Management] Failed to ensure model {model_id}")
logger.info(f"[Model Management] Successfully ensured {success_count}/{len(unique_models)} models")
async def _update_stream_subscriptions(self, subscriptions) -> None:
"""Update streaming subscriptions with tracking integration."""
try:
# Convert subscriptions to the format expected by StreamManager
subscription_payloads = []
for subscription in subscriptions:
payload = {
'subscriptionIdentifier': subscription.subscriptionIdentifier,
'rtspUrl': subscription.rtspUrl,
'snapshotUrl': subscription.snapshotUrl,
'snapshotInterval': subscription.snapshotInterval,
'modelId': subscription.modelId,
'modelUrl': subscription.modelUrl,
'modelName': subscription.modelName
}
# Add crop coordinates if present
if hasattr(subscription, 'cropX1'):
payload.update({
'cropX1': subscription.cropX1,
'cropY1': subscription.cropY1,
'cropX2': subscription.cropX2,
'cropY2': subscription.cropY2
})
subscription_payloads.append(payload)
# Reconcile subscriptions with StreamManager
logger.info("[Streaming] Reconciling stream subscriptions with tracking")
reconcile_result = await self._reconcile_subscriptions_with_tracking(subscription_payloads)
logger.info(f"[Streaming] Subscription reconciliation complete: "
f"added={reconcile_result.get('added', 0)}, "
f"removed={reconcile_result.get('removed', 0)}, "
f"failed={reconcile_result.get('failed', 0)}")
except Exception as e:
logger.error(f"Error updating stream subscriptions: {e}", exc_info=True)
async def _reconcile_subscriptions_with_tracking(self, target_subscriptions) -> dict:
"""Reconcile subscriptions with tracking integration."""
try:
# Create separate tracking integrations for each subscription (camera isolation)
tracking_integrations = {}
for subscription_payload in target_subscriptions:
subscription_id = subscription_payload['subscriptionIdentifier']
model_id = subscription_payload['modelId']
# Create separate tracking integration per subscription for camera isolation
# Get pipeline configuration for this model
pipeline_parser = model_manager.get_pipeline_config(model_id)
if pipeline_parser:
# Create tracking integration with message sender (separate instance per camera)
tracking_integration = TrackingPipelineIntegration(
pipeline_parser, model_manager, model_id, self._send_message
)
# Initialize tracking model
success = await tracking_integration.initialize_tracking_model()
if success:
tracking_integrations[subscription_id] = tracking_integration
logger.info(f"[Tracking] Created isolated tracking integration for subscription {subscription_id} (model {model_id})")
else:
logger.warning(f"[Tracking] Failed to initialize tracking for subscription {subscription_id} (model {model_id})")
else:
logger.warning(f"[Tracking] No pipeline config found for model {model_id} in subscription {subscription_id}")
# Now reconcile with StreamManager, adding tracking integrations
current_subscription_ids = set()
for subscription_info in shared_stream_manager.get_all_subscriptions():
current_subscription_ids.add(subscription_info.subscription_id)
target_subscription_ids = {sub['subscriptionIdentifier'] for sub in target_subscriptions}
# Find subscriptions to remove and add
to_remove = current_subscription_ids - target_subscription_ids
to_add = target_subscription_ids - current_subscription_ids
# Remove old subscriptions
removed_count = 0
for subscription_id in to_remove:
if shared_stream_manager.remove_subscription(subscription_id):
removed_count += 1
logger.info(f"[Streaming] Removed subscription {subscription_id}")
# Add new subscriptions with tracking
added_count = 0
failed_count = 0
for subscription_payload in target_subscriptions:
subscription_id = subscription_payload['subscriptionIdentifier']
if subscription_id in to_add:
success = await self._add_subscription_with_tracking(
subscription_payload, tracking_integrations
)
if success:
added_count += 1
logger.info(f"[Streaming] Added subscription {subscription_id} with tracking")
else:
failed_count += 1
logger.error(f"[Streaming] Failed to add subscription {subscription_id}")
return {
'removed': removed_count,
'added': added_count,
'failed': failed_count,
'total_active': len(shared_stream_manager.get_all_subscriptions())
}
except Exception as e:
logger.error(f"Error in subscription reconciliation with tracking: {e}", exc_info=True)
return {'removed': 0, 'added': 0, 'failed': 0, 'total_active': 0}
async def _add_subscription_with_tracking(self, payload, tracking_integrations) -> bool:
"""Add a subscription with tracking integration."""
try:
from ..streaming.manager import StreamConfig
subscription_id = payload['subscriptionIdentifier']
camera_id = subscription_id.split(';')[-1]
model_id = payload['modelId']
logger.info(f"[SUBSCRIPTION_MAPPING] subscription_id='{subscription_id}' → camera_id='{camera_id}'")
# Get tracking integration for this subscription (camera-isolated)
tracking_integration = tracking_integrations.get(subscription_id)
# Extract crop coordinates if present
crop_coords = None
if all(key in payload for key in ['cropX1', 'cropY1', 'cropX2', 'cropY2']):
crop_coords = (
payload['cropX1'],
payload['cropY1'],
payload['cropX2'],
payload['cropY2']
)
# Create stream configuration
stream_config = StreamConfig(
camera_id=camera_id,
rtsp_url=payload.get('rtspUrl'),
snapshot_url=payload.get('snapshotUrl'),
snapshot_interval=payload.get('snapshotInterval', 5000),
max_retries=3,
)
# Add subscription to StreamManager with tracking
success = shared_stream_manager.add_subscription(
subscription_id=subscription_id,
stream_config=stream_config,
crop_coords=crop_coords,
model_id=model_id,
model_url=payload.get('modelUrl'),
tracking_integration=tracking_integration
)
if success and tracking_integration:
logger.info(f"[Tracking] Subscription {subscription_id} configured with isolated tracking for model {model_id}")
return success
except Exception as e:
logger.error(f"Error adding subscription with tracking: {e}", exc_info=True)
return False
async def _ensure_single_model(self, model_id: int, model_url: str, model_name: str) -> bool:
"""Ensure a single model is downloaded and available."""
try:
# Check if model is already available
if model_manager.is_model_downloaded(model_id):
logger.info(f"[Model Management] Model {model_id} ({model_name}) already available")
return True
# Download and extract model in a thread pool to avoid blocking the event loop
logger.info(f"[Model Management] Downloading model {model_id} ({model_name}) from {model_url}")
# Use asyncio.to_thread for CPU-bound operations (Python 3.9+)
# For compatibility, we'll use run_in_executor
loop = asyncio.get_event_loop()
model_path = await loop.run_in_executor(
None,
model_manager.ensure_model,
model_id,
model_url,
model_name
)
if model_path:
logger.info(f"[Model Management] Successfully prepared model {model_id} at {model_path}")
return True
else:
logger.error(f"[Model Management] Failed to prepare model {model_id}")
return False
except Exception as e:
logger.error(f"[Model Management] Exception ensuring model {model_id}: {str(e)}", exc_info=True)
return False
async def _save_snapshot(self, display_identifier: str, session_id: int) -> None:
"""
Save snapshot image to images folder after receiving sessionId.
Args:
display_identifier: Display identifier to match with subscriptionIdentifier
session_id: Session ID to include in filename
"""
try:
# Find subscription that matches the displayIdentifier
matching_subscription = None
for subscription in worker_state.get_all_subscriptions():
# Extract display ID from subscriptionIdentifier (format: displayId;cameraId)
from .messages import extract_display_identifier
sub_display_id = extract_display_identifier(subscription.subscriptionIdentifier)
if sub_display_id == display_identifier:
matching_subscription = subscription
break
if not matching_subscription:
logger.error(f"[Snapshot Save] No subscription found for display {display_identifier}")
return
if not matching_subscription.snapshotUrl:
logger.error(f"[Snapshot Save] No snapshotUrl found for display {display_identifier}")
return
# Ensure images directory exists (relative path for Docker bind mount)
images_dir = Path("images")
images_dir.mkdir(exist_ok=True)
# Generate filename with timestamp and session ID
timestamp = datetime.now(tz=timezone(timedelta(hours=7))).strftime("%Y%m%d_%H%M%S")
filename = f"{session_id}_{display_identifier}_{timestamp}.jpg"
filepath = images_dir / filename
# Use existing HTTPSnapshotReader to fetch snapshot
logger.info(f"[Snapshot Save] Fetching snapshot from {matching_subscription.snapshotUrl}")
# Run snapshot fetch in thread pool to avoid blocking async loop
loop = asyncio.get_event_loop()
frame = await loop.run_in_executor(None, self._fetch_snapshot_sync, matching_subscription.snapshotUrl)
if frame is not None:
# Save the image using OpenCV
success = cv2.imwrite(str(filepath), frame)
if success:
logger.info(f"[Snapshot Save] Successfully saved snapshot to {filepath}")
else:
logger.error(f"[Snapshot Save] Failed to save image file {filepath}")
else:
logger.error(f"[Snapshot Save] Failed to fetch snapshot from {matching_subscription.snapshotUrl}")
except Exception as e:
logger.error(f"[Snapshot Save] Error saving snapshot for display {display_identifier}: {e}", exc_info=True)
def _fetch_snapshot_sync(self, snapshot_url: str):
"""
Synchronous snapshot fetching using existing HTTPSnapshotReader infrastructure.
Args:
snapshot_url: URL to fetch snapshot from
Returns:
np.ndarray or None: Fetched frame or None on error
"""
try:
from ..streaming.readers import HTTPSnapshotReader
# Create temporary snapshot reader for single fetch
snapshot_reader = HTTPSnapshotReader(
camera_id="temp_snapshot",
snapshot_url=snapshot_url,
interval_ms=5000 # Not used for single fetch
)
# Use existing fetch_single_snapshot method
return snapshot_reader.fetch_single_snapshot()
except Exception as e:
logger.error(f"Error in sync snapshot fetch: {e}")
return None
async def _handle_set_session_id(self, message: SetSessionIdMessage) -> None:
"""Handle setSessionId message."""
display_identifier = message.payload.displayIdentifier
session_id = str(message.payload.sessionId) if message.payload.sessionId is not None else None
logger.info(f"[RX Processing] setSessionId for display {display_identifier}: {session_id}")
# Update worker state
worker_state.set_session_id(display_identifier, session_id)
# Update tracking integrations with session ID
shared_stream_manager.set_session_id(display_identifier, session_id)
async def _handle_set_progression_stage(self, message: SetProgressionStageMessage) -> None:
"""Handle setProgressionStage message."""
display_identifier = message.payload.displayIdentifier
stage = message.payload.progressionStage
logger.info(f"[RX Processing] setProgressionStage for display {display_identifier}: {stage}")
# Update worker state
worker_state.set_progression_stage(display_identifier, stage)
# Update tracking integration for car abandonment detection
session_id = worker_state.get_session_id(display_identifier)
if session_id:
shared_stream_manager.set_progression_stage(session_id, stage)
# Save snapshot image when progression stage is car_fueling
if stage == 'car_fueling' and session_id:
await self._save_snapshot(display_identifier, session_id)
# If stage indicates session is cleared/finished, clear from tracking
if stage in ['finished', 'cleared', 'idle']:
# Get session ID for this display and clear it
if session_id:
shared_stream_manager.clear_session_id(session_id)
logger.info(f"[Tracking] Cleared session {session_id} due to progression stage: {stage}")
async def _handle_request_state(self, message: RequestStateMessage) -> None:
"""Handle requestState message by sending immediate state report."""
logger.debug("[RX Processing] requestState - sending immediate state report")
# Collect metrics and send state report
cpu_usage = SystemMetrics.get_cpu_usage()
memory_usage = SystemMetrics.get_memory_usage()
gpu_usage = SystemMetrics.get_gpu_usage()
gpu_memory_usage = SystemMetrics.get_gpu_memory_usage()
camera_connections = worker_state.get_camera_connections()
state_report = create_state_report(
cpu_usage=cpu_usage,
memory_usage=memory_usage,
gpu_usage=gpu_usage,
gpu_memory_usage=gpu_memory_usage,
camera_connections=camera_connections
)
await self._send_message(state_report)
async def _handle_patch_session_result(self, message: PatchSessionResultMessage) -> None:
"""Handle patchSessionResult message."""
payload = message.payload
logger.info(f"[RX Processing] patchSessionResult for session {payload.sessionId}: "
f"success={payload.success}, message='{payload.message}'")
# TODO: Handle patch session result if needed
# For now, just log the response
async def _send_message(self, message) -> None:
"""Send message to backend via WebSocket."""
if not self.connected:
logger.warning("Cannot send message: WebSocket not connected")
return
try:
json_message = serialize_outgoing_message(message)
await self.websocket.send_text(json_message)
# Log non-heartbeat messages only (heartbeats are logged in their respective functions)
if not (hasattr(message, 'type') and message.type == 'stateReport'):
logger.info(f"[TX → Backend] {json_message}")
except Exception as e:
logger.error(f"Failed to send WebSocket message: {e}")
raise
async def _process_streams(self) -> None:
"""
Stream processing task that handles frame processing and detection.
This is a placeholder for Phase 2 - currently just logs that it's running.
"""
logger.info("Stream processing task started")
try:
while self.connected:
# Get current subscriptions
subscriptions = worker_state.get_all_subscriptions()
# TODO: Phase 2 - Add actual frame processing logic here
# This will include:
# - Frame reading from RTSP/HTTP streams
# - Model inference using loaded pipelines
# - Detection result sending via WebSocket
# Sleep to prevent excessive CPU usage (similar to old poll_interval)
await asyncio.sleep(0.1) # 100ms polling interval
except asyncio.CancelledError:
logger.info("Stream processing task cancelled")
except Exception as e:
logger.error(f"Error in stream processing: {e}", exc_info=True)
async def _cleanup(self) -> None:
"""Clean up resources when connection closes."""
logger.info("Cleaning up WebSocket connection")
self.connected = False
# Cancel background tasks
if self._heartbeat_task and not self._heartbeat_task.done():
self._heartbeat_task.cancel()
if self._message_task and not self._message_task.done():
self._message_task.cancel()
# Clear worker state
worker_state.set_subscriptions([])
worker_state.session_ids.clear()
worker_state.progression_stages.clear()
logger.info("WebSocket connection cleanup completed")
# Factory function for FastAPI integration
async def websocket_endpoint(websocket: WebSocket) -> None:
"""
FastAPI WebSocket endpoint handler.
Args:
websocket: FastAPI WebSocket connection
"""
handler = WebSocketHandler(websocket)
await handler.handle_connection()

View file

@ -0,0 +1,10 @@
"""
Detection module for the Python Detector Worker.
This module provides the main detection pipeline orchestration and parallel branch processing
for advanced computer vision detection systems.
"""
from .pipeline import DetectionPipeline
from .branches import BranchProcessor
__all__ = ['DetectionPipeline', 'BranchProcessor']

791
core/detection/branches.py Normal file
View file

@ -0,0 +1,791 @@
"""
Parallel Branch Processing Module.
Handles concurrent execution of classification branches and result synchronization.
"""
import logging
import asyncio
import time
from typing import Dict, List, Optional, Any, Tuple
from concurrent.futures import ThreadPoolExecutor, as_completed
import numpy as np
import cv2
from ..models.inference import YOLOWrapper
logger = logging.getLogger(__name__)
class BranchProcessor:
"""
Handles parallel processing of classification branches.
Manages branch synchronization and result collection.
"""
def __init__(self, model_manager: Any, model_id: int):
"""
Initialize branch processor.
Args:
model_manager: Model manager for loading models
model_id: The model ID to use for loading models
"""
self.model_manager = model_manager
self.model_id = model_id
# Branch models cache
self.branch_models: Dict[str, YOLOWrapper] = {}
# Thread pool for parallel execution
self.executor = ThreadPoolExecutor(max_workers=4)
# Storage managers (set during initialization)
self.redis_manager = None
self.db_manager = None
# Statistics
self.stats = {
'branches_processed': 0,
'parallel_executions': 0,
'total_processing_time': 0.0,
'models_loaded': 0
}
logger.info("BranchProcessor initialized")
async def initialize(self, pipeline_config: Any, redis_manager: Any, db_manager: Any) -> bool:
"""
Initialize branch processor with pipeline configuration.
Args:
pipeline_config: Pipeline configuration object
redis_manager: Redis manager instance
db_manager: Database manager instance
Returns:
True if successful, False otherwise
"""
try:
self.redis_manager = redis_manager
self.db_manager = db_manager
# Pre-load branch models if they exist
branches = getattr(pipeline_config, 'branches', [])
if branches:
await self._preload_branch_models(branches)
logger.info(f"BranchProcessor initialized with {len(self.branch_models)} models")
return True
except Exception as e:
logger.error(f"Error initializing branch processor: {e}", exc_info=True)
return False
async def _preload_branch_models(self, branches: List[Any]) -> None:
"""
Pre-load all branch models for faster execution.
Args:
branches: List of branch configurations
"""
for branch in branches:
try:
await self._load_branch_model(branch)
# Recursively load nested branches
nested_branches = getattr(branch, 'branches', [])
if nested_branches:
await self._preload_branch_models(nested_branches)
except Exception as e:
logger.error(f"Error preloading branch model {getattr(branch, 'model_id', 'unknown')}: {e}")
async def _load_branch_model(self, branch_config: Any) -> Optional[YOLOWrapper]:
"""
Load a branch model if not already loaded.
Args:
branch_config: Branch configuration object
Returns:
Loaded YOLO model wrapper or None
"""
try:
model_id = getattr(branch_config, 'model_id', None)
model_file = getattr(branch_config, 'model_file', None)
if not model_id or not model_file:
logger.warning(f"Invalid branch config: model_id={model_id}, model_file={model_file}")
return None
# Check if model is already loaded
if model_id in self.branch_models:
logger.debug(f"Branch model {model_id} already loaded")
return self.branch_models[model_id]
# Load model
logger.info(f"Loading branch model: {model_id} ({model_file})")
# Load model using the proper model ID
model = self.model_manager.get_yolo_model(self.model_id, model_file)
if model:
self.branch_models[model_id] = model
self.stats['models_loaded'] += 1
logger.info(f"Branch model {model_id} loaded successfully")
return model
else:
logger.error(f"Failed to load branch model {model_id}")
return None
except Exception as e:
logger.error(f"Error loading branch model {getattr(branch_config, 'model_id', 'unknown')}: {e}")
return None
async def execute_branches(self,
frame: np.ndarray,
branches: List[Any],
detected_regions: Dict[str, Any],
detection_context: Dict[str, Any]) -> Dict[str, Any]:
"""
Execute all branches in parallel and collect results.
Args:
frame: Input frame
branches: List of branch configurations
detected_regions: Dictionary of detected regions from main detection
detection_context: Detection context data
Returns:
Dictionary with branch execution results
"""
start_time = time.time()
branch_results = {}
try:
# Separate parallel and sequential branches
parallel_branches = []
sequential_branches = []
for branch in branches:
if getattr(branch, 'parallel', False):
parallel_branches.append(branch)
else:
sequential_branches.append(branch)
# Execute parallel branches concurrently
if parallel_branches:
logger.info(f"Executing {len(parallel_branches)} branches in parallel")
parallel_results = await self._execute_parallel_branches(
frame, parallel_branches, detected_regions, detection_context
)
branch_results.update(parallel_results)
self.stats['parallel_executions'] += 1
# Execute sequential branches one by one
if sequential_branches:
logger.info(f"Executing {len(sequential_branches)} branches sequentially")
sequential_results = await self._execute_sequential_branches(
frame, sequential_branches, detected_regions, detection_context
)
branch_results.update(sequential_results)
# Update statistics
self.stats['branches_processed'] += len(branches)
processing_time = time.time() - start_time
self.stats['total_processing_time'] += processing_time
logger.info(f"Branch execution completed in {processing_time:.3f}s with {len(branch_results)} results")
except Exception as e:
logger.error(f"Error in branch execution: {e}", exc_info=True)
return branch_results
async def _execute_parallel_branches(self,
frame: np.ndarray,
branches: List[Any],
detected_regions: Dict[str, Any],
detection_context: Dict[str, Any]) -> Dict[str, Any]:
"""
Execute branches in parallel using ThreadPoolExecutor.
Args:
frame: Input frame
branches: List of parallel branch configurations
detected_regions: Dictionary of detected regions
detection_context: Detection context data
Returns:
Dictionary with parallel branch results
"""
results = {}
# Submit all branches for parallel execution
future_to_branch = {}
for branch in branches:
branch_id = getattr(branch, 'model_id', 'unknown')
logger.info(f"[PARALLEL SUBMIT] {branch_id}: Submitting branch to thread pool")
future = self.executor.submit(
self._execute_single_branch_sync,
frame, branch, detected_regions, detection_context
)
future_to_branch[future] = branch
# Collect results as they complete
for future in as_completed(future_to_branch):
branch = future_to_branch[future]
branch_id = getattr(branch, 'model_id', 'unknown')
try:
result = future.result()
results[branch_id] = result
logger.info(f"[PARALLEL COMPLETE] {branch_id}: Branch completed successfully")
except Exception as e:
logger.error(f"Error in parallel branch {branch_id}: {e}")
results[branch_id] = {
'status': 'error',
'message': str(e),
'processing_time': 0.0
}
# Flatten nested branch results to top level for database access
flattened_results = {}
for branch_id, branch_result in results.items():
# Add the branch result itself
flattened_results[branch_id] = branch_result
# If this branch has nested branches, add them to the top level too
if isinstance(branch_result, dict) and 'nested_branches' in branch_result:
nested_branches = branch_result['nested_branches']
for nested_branch_id, nested_result in nested_branches.items():
flattened_results[nested_branch_id] = nested_result
logger.info(f"[FLATTEN] Added nested branch {nested_branch_id} to top-level results")
return flattened_results
async def _execute_sequential_branches(self,
frame: np.ndarray,
branches: List[Any],
detected_regions: Dict[str, Any],
detection_context: Dict[str, Any]) -> Dict[str, Any]:
"""
Execute branches sequentially.
Args:
frame: Input frame
branches: List of sequential branch configurations
detected_regions: Dictionary of detected regions
detection_context: Detection context data
Returns:
Dictionary with sequential branch results
"""
results = {}
for branch in branches:
branch_id = getattr(branch, 'model_id', 'unknown')
try:
result = await asyncio.get_event_loop().run_in_executor(
self.executor,
self._execute_single_branch_sync,
frame, branch, detected_regions, detection_context
)
results[branch_id] = result
logger.debug(f"Sequential branch {branch_id} completed successfully")
except Exception as e:
logger.error(f"Error in sequential branch {branch_id}: {e}")
results[branch_id] = {
'status': 'error',
'message': str(e),
'processing_time': 0.0
}
# Flatten nested branch results to top level for database access
flattened_results = {}
for branch_id, branch_result in results.items():
# Add the branch result itself
flattened_results[branch_id] = branch_result
# If this branch has nested branches, add them to the top level too
if isinstance(branch_result, dict) and 'nested_branches' in branch_result:
nested_branches = branch_result['nested_branches']
for nested_branch_id, nested_result in nested_branches.items():
flattened_results[nested_branch_id] = nested_result
logger.info(f"[FLATTEN] Added nested branch {nested_branch_id} to top-level results")
return flattened_results
def _execute_single_branch_sync(self,
frame: np.ndarray,
branch_config: Any,
detected_regions: Dict[str, Any],
detection_context: Dict[str, Any]) -> Dict[str, Any]:
"""
Synchronous execution of a single branch (for ThreadPoolExecutor).
Args:
frame: Input frame
branch_config: Branch configuration object
detected_regions: Dictionary of detected regions
detection_context: Detection context data
Returns:
Dictionary with branch execution result
"""
start_time = time.time()
branch_id = getattr(branch_config, 'model_id', 'unknown')
logger.info(f"[BRANCH START] {branch_id}: Starting branch execution")
logger.debug(f"[BRANCH CONFIG] {branch_id}: crop={getattr(branch_config, 'crop', False)}, "
f"trigger_classes={getattr(branch_config, 'trigger_classes', [])}, "
f"min_confidence={getattr(branch_config, 'min_confidence', 0.6)}")
# Check if branch should execute based on triggerClasses (execution conditions)
trigger_classes = getattr(branch_config, 'trigger_classes', [])
logger.info(f"[DETECTED REGIONS] {branch_id}: Available parent detections: {list(detected_regions.keys())}")
for region_name, region_data in detected_regions.items():
logger.debug(f"[REGION DATA] {branch_id}: '{region_name}' -> bbox={region_data.get('bbox')}, conf={region_data.get('confidence')}")
if trigger_classes:
# Check if any parent detection matches our trigger classes
should_execute = False
for trigger_class in trigger_classes:
if trigger_class in detected_regions:
should_execute = True
logger.info(f"[TRIGGER CHECK] {branch_id}: Found '{trigger_class}' in parent detections - branch will execute")
break
if not should_execute:
logger.warning(f"[TRIGGER CHECK] {branch_id}: None of trigger classes {trigger_classes} found in parent detections {list(detected_regions.keys())} - skipping branch")
return {
'status': 'skipped',
'branch_id': branch_id,
'message': f'No trigger classes {trigger_classes} found in parent detections',
'processing_time': time.time() - start_time
}
result = {
'status': 'success',
'branch_id': branch_id,
'result': {},
'processing_time': 0.0,
'timestamp': time.time()
}
try:
# Get or load branch model
if branch_id not in self.branch_models:
logger.warning(f"Branch model {branch_id} not preloaded, loading now...")
# This should be rare since models are preloaded
return {
'status': 'error',
'message': f'Branch model {branch_id} not available',
'processing_time': time.time() - start_time
}
model = self.branch_models[branch_id]
# Get configuration values first
min_confidence = getattr(branch_config, 'min_confidence', 0.6)
# Prepare input frame for this branch
input_frame = frame
# Handle cropping if required - use biggest bbox that passes min_confidence
if getattr(branch_config, 'crop', False):
crop_classes = getattr(branch_config, 'crop_class', [])
if isinstance(crop_classes, str):
crop_classes = [crop_classes]
# Find the biggest bbox that passes min_confidence threshold
best_region = None
best_class = None
best_area = 0.0
for crop_class in crop_classes:
if crop_class in detected_regions:
region = detected_regions[crop_class]
confidence = region.get('confidence', 0.0)
# Only use detections above min_confidence
if confidence >= min_confidence:
bbox = region['bbox']
area = (bbox[2] - bbox[0]) * (bbox[3] - bbox[1]) # width * height
# Choose biggest bbox among valid detections
if area > best_area:
best_region = region
best_class = crop_class
best_area = area
if best_region:
bbox = best_region['bbox']
x1, y1, x2, y2 = [int(coord) for coord in bbox]
cropped = frame[y1:y2, x1:x2]
if cropped.size > 0:
input_frame = cropped
confidence = best_region.get('confidence', 0.0)
logger.info(f"[CROP SUCCESS] {branch_id}: cropped '{best_class}' region (conf={confidence:.3f}, area={int(best_area)}) -> shape={cropped.shape}")
else:
logger.warning(f"Branch {branch_id}: empty crop, using full frame")
else:
logger.warning(f"Branch {branch_id}: no valid crop regions found (min_conf={min_confidence})")
logger.info(f"[INFERENCE START] {branch_id}: Running inference on {'cropped' if input_frame is not frame else 'full'} frame "
f"({input_frame.shape[1]}x{input_frame.shape[0]}) with confidence={min_confidence}")
# Use .predict() method for both detection and classification models
inference_start = time.time()
detection_results = model.model.predict(input_frame, conf=min_confidence, verbose=False)
inference_time = time.time() - inference_start
logger.info(f"[INFERENCE DONE] {branch_id}: Predict completed in {inference_time:.3f}s using .predict() method")
# Initialize branch_detections outside the conditional
branch_detections = []
# Process results using clean, unified logic
if detection_results and len(detection_results) > 0:
result_obj = detection_results[0]
# Handle detection models (have .boxes attribute)
if hasattr(result_obj, 'boxes') and result_obj.boxes is not None:
logger.info(f"[RAW DETECTIONS] {branch_id}: Found {len(result_obj.boxes)} raw detections")
for i, box in enumerate(result_obj.boxes):
class_id = int(box.cls[0])
confidence = float(box.conf[0])
bbox = box.xyxy[0].cpu().numpy().tolist() # [x1, y1, x2, y2]
class_name = model.model.names[class_id]
logger.debug(f"[RAW DETECTION {i+1}] {branch_id}: '{class_name}', conf={confidence:.3f}")
# All detections are included - no filtering by trigger_classes here
branch_detections.append({
'class_name': class_name,
'confidence': confidence,
'bbox': bbox
})
# Handle classification models (have .probs attribute)
elif hasattr(result_obj, 'probs') and result_obj.probs is not None:
logger.info(f"[RAW CLASSIFICATION] {branch_id}: Processing classification results")
probs = result_obj.probs
top_indices = probs.top5 # Get top 5 predictions
top_conf = probs.top5conf.cpu().numpy()
for idx, conf in zip(top_indices, top_conf):
if conf >= min_confidence:
class_name = model.model.names[int(idx)]
logger.debug(f"[CLASSIFICATION RESULT {len(branch_detections)+1}] {branch_id}: '{class_name}', conf={conf:.3f}")
# For classification, use full input frame dimensions as bbox
branch_detections.append({
'class_name': class_name,
'confidence': float(conf),
'bbox': [0, 0, input_frame.shape[1], input_frame.shape[0]]
})
else:
logger.warning(f"[UNKNOWN MODEL] {branch_id}: Model results have no .boxes or .probs")
result['result'] = {
'detections': branch_detections,
'detection_count': len(branch_detections)
}
logger.info(f"[FINAL RESULTS] {branch_id}: {len(branch_detections)} detections processed")
# Extract best result for classification models
if branch_detections:
best_detection = max(branch_detections, key=lambda x: x['confidence'])
logger.info(f"[BEST DETECTION] {branch_id}: '{best_detection['class_name']}' with confidence {best_detection['confidence']:.3f}")
# Add classification-style results for database operations
if 'brand' in branch_id.lower():
result['result']['brand'] = best_detection['class_name']
elif 'body' in branch_id.lower() or 'bodytype' in branch_id.lower():
result['result']['body_type'] = best_detection['class_name']
elif 'front_rear' in branch_id.lower():
result['result']['front_rear'] = best_detection['confidence']
logger.info(f"[CLASSIFICATION RESULT] {branch_id}: Extracted classification fields")
else:
logger.warning(f"[NO RESULTS] {branch_id}: No detections found")
# Execute branch actions if this branch found valid detections
actions_executed = []
branch_actions = getattr(branch_config, 'actions', [])
if branch_actions and branch_detections:
logger.info(f"[BRANCH ACTIONS] {branch_id}: Executing {len(branch_actions)} actions")
# Create detected_regions from THIS branch's detections for actions
branch_detected_regions = {}
for detection in branch_detections:
branch_detected_regions[detection['class_name']] = {
'bbox': detection['bbox'],
'confidence': detection['confidence']
}
for action in branch_actions:
try:
action_type = action.type.value # Access the enum value
logger.info(f"[ACTION EXECUTE] {branch_id}: Executing action '{action_type}'")
if action_type == 'redis_save_image':
action_result = self._execute_redis_save_image_sync(
action, input_frame, branch_detected_regions, detection_context
)
elif action_type == 'redis_publish':
action_result = self._execute_redis_publish_sync(
action, detection_context
)
else:
logger.warning(f"[ACTION UNKNOWN] {branch_id}: Unknown action type '{action_type}'")
action_result = {'status': 'error', 'message': f'Unknown action type: {action_type}'}
actions_executed.append({
'action_type': action_type,
'result': action_result
})
logger.info(f"[ACTION COMPLETE] {branch_id}: Action '{action_type}' result: {action_result.get('status')}")
except Exception as e:
action_type = getattr(action, 'type', None)
if action_type:
action_type = action_type.value if hasattr(action_type, 'value') else str(action_type)
logger.error(f"[ACTION ERROR] {branch_id}: Error executing action '{action_type}': {e}", exc_info=True)
actions_executed.append({
'action_type': action_type,
'result': {'status': 'error', 'message': str(e)}
})
# Add actions executed to result
if actions_executed:
result['actions_executed'] = actions_executed
# Handle nested branches ONLY if parent found valid detections
nested_branches = getattr(branch_config, 'branches', [])
if nested_branches:
# Check if parent branch found any valid detections
if not branch_detections:
logger.warning(f"[BRANCH SKIP] {branch_id}: Skipping {len(nested_branches)} nested branches - parent found no valid detections")
else:
logger.debug(f"Branch {branch_id}: executing {len(nested_branches)} nested branches")
# Create detected_regions from THIS branch's detections for nested branches
# Nested branches should see their immediate parent's detections, not the root pipeline
nested_detected_regions = {}
for detection in branch_detections:
nested_detected_regions[detection['class_name']] = {
'bbox': detection['bbox'],
'confidence': detection['confidence']
}
logger.info(f"[NESTED REGIONS] {branch_id}: Passing {list(nested_detected_regions.keys())} to nested branches")
# Note: For simplicity, nested branches are executed sequentially in this sync method
# In a full async implementation, these could also be parallelized
nested_results = {}
for nested_branch in nested_branches:
nested_result = self._execute_single_branch_sync(
input_frame, nested_branch, nested_detected_regions, detection_context
)
nested_branch_id = getattr(nested_branch, 'model_id', 'unknown')
nested_results[nested_branch_id] = nested_result
result['nested_branches'] = nested_results
except Exception as e:
logger.error(f"[BRANCH ERROR] {branch_id}: Error in execution: {e}", exc_info=True)
result['status'] = 'error'
result['message'] = str(e)
result['processing_time'] = time.time() - start_time
# Summary log
logger.info(f"[BRANCH COMPLETE] {branch_id}: status={result['status']}, "
f"processing_time={result['processing_time']:.3f}s, "
f"result_keys={list(result['result'].keys()) if result['result'] else 'none'}")
return result
def _execute_redis_save_image_sync(self,
action: Dict,
frame: np.ndarray,
detected_regions: Dict[str, Any],
context: Dict[str, Any]) -> Dict[str, Any]:
"""Execute redis_save_image action synchronously."""
if not self.redis_manager:
return {'status': 'error', 'message': 'Redis not available'}
try:
# Get image to save (cropped or full frame)
image_to_save = frame
region_name = action.params.get('region')
bbox = None
if region_name and region_name in detected_regions:
# Crop the specified region
bbox = detected_regions[region_name]['bbox']
elif region_name and region_name.lower() == 'frontal' and 'front_rear' in detected_regions:
# Special case: "frontal" region maps to "front_rear" detection
bbox = detected_regions['front_rear']['bbox']
if bbox is not None:
x1, y1, x2, y2 = [int(coord) for coord in bbox]
cropped = frame[y1:y2, x1:x2]
if cropped.size > 0:
image_to_save = cropped
logger.debug(f"Cropped region '{region_name}' for redis_save_image")
else:
logger.warning(f"Empty crop for region '{region_name}', using full frame")
# Format key with context
key = action.params['key'].format(**context)
# Convert image to bytes
import cv2
image_format = action.params.get('format', 'jpeg')
quality = action.params.get('quality', 90)
if image_format.lower() == 'jpeg':
encode_param = [cv2.IMWRITE_JPEG_QUALITY, quality]
_, image_bytes = cv2.imencode('.jpg', image_to_save, encode_param)
else:
_, image_bytes = cv2.imencode('.png', image_to_save)
# Save to Redis synchronously using a sync Redis client
try:
import redis
import cv2
# Create a synchronous Redis client with same connection details
sync_redis = redis.Redis(
host=self.redis_manager.host,
port=self.redis_manager.port,
password=self.redis_manager.password,
db=self.redis_manager.db,
decode_responses=False, # We're storing binary data
socket_timeout=self.redis_manager.socket_timeout,
socket_connect_timeout=self.redis_manager.socket_connect_timeout
)
# Encode the image
if image_format.lower() == 'jpeg':
encode_param = [cv2.IMWRITE_JPEG_QUALITY, quality]
success, encoded_image = cv2.imencode('.jpg', image_to_save, encode_param)
else:
success, encoded_image = cv2.imencode('.png', image_to_save)
if not success:
return {'status': 'error', 'message': 'Failed to encode image'}
# Save to Redis with expiration
expire_seconds = action.params.get('expire_seconds', 600)
result = sync_redis.setex(key, expire_seconds, encoded_image.tobytes())
sync_redis.close() # Clean up connection
if result:
# Add image_key to context for subsequent actions
context['image_key'] = key
return {'status': 'success', 'key': key}
else:
return {'status': 'error', 'message': 'Failed to save image to Redis'}
except Exception as redis_error:
logger.error(f"Error calling Redis from sync context: {redis_error}")
return {'status': 'error', 'message': f'Redis operation failed: {redis_error}'}
except Exception as e:
logger.error(f"Error in redis_save_image action: {e}", exc_info=True)
return {'status': 'error', 'message': str(e)}
def _execute_redis_publish_sync(self, action: Dict, context: Dict[str, Any]) -> Dict[str, Any]:
"""Execute redis_publish action synchronously."""
if not self.redis_manager:
return {'status': 'error', 'message': 'Redis not available'}
try:
channel = action.params['channel']
message_template = action.params['message']
# Debug the message template
logger.debug(f"Message template: {repr(message_template)}")
logger.debug(f"Context keys: {list(context.keys())}")
# Format message with context - handle JSON string formatting carefully
# The message template contains JSON which causes issues with .format()
# Use string replacement instead of format to avoid JSON brace conflicts
try:
# Ensure image_key is available for message formatting
if 'image_key' not in context:
context['image_key'] = '' # Default empty value if redis_save_image failed
# Use string replacement to avoid JSON formatting issues
message = message_template
for key, value in context.items():
placeholder = '{' + key + '}'
message = message.replace(placeholder, str(value))
logger.debug(f"Formatted message using replacement: {message}")
except Exception as e:
logger.error(f"Message formatting failed: {e}")
logger.error(f"Template: {repr(message_template)}")
logger.error(f"Context: {context}")
return {'status': 'error', 'message': f'Message formatting failed: {e}'}
# Publish message synchronously using a sync Redis client
try:
import redis
# Create a synchronous Redis client with same connection details
sync_redis = redis.Redis(
host=self.redis_manager.host,
port=self.redis_manager.port,
password=self.redis_manager.password,
db=self.redis_manager.db,
decode_responses=True, # For publishing text messages
socket_timeout=self.redis_manager.socket_timeout,
socket_connect_timeout=self.redis_manager.socket_connect_timeout
)
# Publish message
result = sync_redis.publish(channel, message)
sync_redis.close() # Clean up connection
if result >= 0: # Redis publish returns number of subscribers
return {'status': 'success', 'subscribers': result, 'channel': channel}
else:
return {'status': 'error', 'message': 'Failed to publish message to Redis'}
except Exception as redis_error:
logger.error(f"Error calling Redis from sync context: {redis_error}")
return {'status': 'error', 'message': f'Redis operation failed: {redis_error}'}
except Exception as e:
logger.error(f"Error in redis_publish action: {e}", exc_info=True)
return {'status': 'error', 'message': str(e)}
def get_statistics(self) -> Dict[str, Any]:
"""Get branch processor statistics."""
return {
**self.stats,
'loaded_models': list(self.branch_models.keys()),
'model_count': len(self.branch_models)
}
def cleanup(self):
"""Cleanup resources."""
if self.executor:
self.executor.shutdown(wait=False)
# Clear model cache
self.branch_models.clear()
logger.info("BranchProcessor cleaned up")

1169
core/detection/pipeline.py Normal file

File diff suppressed because it is too large Load diff

42
core/models/__init__.py Normal file
View file

@ -0,0 +1,42 @@
"""
Models Module - MPTA management, pipeline configuration, and YOLO inference
"""
from .manager import ModelManager
from .pipeline import (
PipelineParser,
PipelineConfig,
TrackingConfig,
ModelBranch,
Action,
ActionType,
RedisConfig,
PostgreSQLConfig
)
from .inference import (
YOLOWrapper,
ModelInferenceManager,
Detection,
InferenceResult
)
__all__ = [
# Manager
'ModelManager',
# Pipeline
'PipelineParser',
'PipelineConfig',
'TrackingConfig',
'ModelBranch',
'Action',
'ActionType',
'RedisConfig',
'PostgreSQLConfig',
# Inference
'YOLOWrapper',
'ModelInferenceManager',
'Detection',
'InferenceResult',
]

447
core/models/inference.py Normal file
View file

@ -0,0 +1,447 @@
"""
YOLO Model Inference Wrapper - Handles model loading and inference optimization
"""
import logging
import torch
import numpy as np
from pathlib import Path
from typing import Dict, List, Optional, Any, Tuple, Union
from threading import Lock
from dataclasses import dataclass
import cv2
logger = logging.getLogger(__name__)
@dataclass
class Detection:
"""Represents a single detection result"""
bbox: List[float] # [x1, y1, x2, y2]
confidence: float
class_id: int
class_name: str
track_id: Optional[int] = None
@dataclass
class InferenceResult:
"""Result from model inference"""
detections: List[Detection]
image_shape: Tuple[int, int] # (height, width)
inference_time: float
model_id: str
class YOLOWrapper:
"""Wrapper for YOLO models with caching and optimization"""
# Class-level model cache shared across all instances
_model_cache: Dict[str, Any] = {}
_cache_lock = Lock()
def __init__(self, model_path: Path, model_id: str, device: Optional[str] = None):
"""
Initialize YOLO wrapper
Args:
model_path: Path to the .pt model file
model_id: Unique identifier for the model
device: Device to run inference on ('cuda', 'cpu', or None for auto)
"""
self.model_path = model_path
self.model_id = model_id
# Auto-detect device if not specified
if device is None:
self.device = 'cuda' if torch.cuda.is_available() else 'cpu'
else:
self.device = device
self.model = None
self._class_names = []
self._load_model()
logger.info(f"Initialized YOLO wrapper for {model_id} on {self.device}")
def _load_model(self) -> None:
"""Load the YOLO model with caching"""
cache_key = str(self.model_path)
with self._cache_lock:
# Check if model is already cached
if cache_key in self._model_cache:
logger.info(f"Loading model {self.model_id} from cache")
self.model = self._model_cache[cache_key]
self._extract_class_names()
return
# Load model
try:
from ultralytics import YOLO
logger.info(f"Loading YOLO model from {self.model_path}")
self.model = YOLO(str(self.model_path))
# Move model to device
if self.device == 'cuda' and torch.cuda.is_available():
self.model.to('cuda')
logger.info(f"Model {self.model_id} moved to GPU")
# Cache the model
self._model_cache[cache_key] = self.model
self._extract_class_names()
logger.info(f"Successfully loaded model {self.model_id}")
except ImportError:
logger.error("Ultralytics YOLO not installed. Install with: pip install ultralytics")
raise
except Exception as e:
logger.error(f"Failed to load YOLO model {self.model_id}: {str(e)}", exc_info=True)
raise
def _extract_class_names(self) -> None:
"""Extract class names from the model"""
try:
if hasattr(self.model, 'names'):
self._class_names = self.model.names
elif hasattr(self.model, 'model') and hasattr(self.model.model, 'names'):
self._class_names = self.model.model.names
else:
logger.warning(f"Could not extract class names from model {self.model_id}")
self._class_names = {}
except Exception as e:
logger.error(f"Failed to extract class names: {str(e)}")
self._class_names = {}
def infer(
self,
image: np.ndarray,
confidence_threshold: float = 0.5,
trigger_classes: Optional[List[str]] = None,
iou_threshold: float = 0.45
) -> InferenceResult:
"""
Run inference on an image
Args:
image: Input image as numpy array (BGR format)
confidence_threshold: Minimum confidence for detections
trigger_classes: List of class names to filter (None = all classes)
iou_threshold: IoU threshold for NMS
Returns:
InferenceResult containing detections
"""
if self.model is None:
raise RuntimeError(f"Model {self.model_id} not loaded")
try:
import time
start_time = time.time()
# Run inference
results = self.model(
image,
conf=confidence_threshold,
iou=iou_threshold,
verbose=False
)
inference_time = time.time() - start_time
# Parse results
detections = self._parse_results(results[0], trigger_classes)
return InferenceResult(
detections=detections,
image_shape=(image.shape[0], image.shape[1]),
inference_time=inference_time,
model_id=self.model_id
)
except Exception as e:
logger.error(f"Inference failed for model {self.model_id}: {str(e)}", exc_info=True)
raise
def _parse_results(
self,
result: Any,
trigger_classes: Optional[List[str]] = None
) -> List[Detection]:
"""
Parse YOLO results into Detection objects
Args:
result: YOLO result object
trigger_classes: Optional list of class names to filter
Returns:
List of Detection objects
"""
detections = []
try:
if result.boxes is None:
return detections
boxes = result.boxes
for i in range(len(boxes)):
# Get box coordinates
box = boxes.xyxy[i].cpu().numpy()
x1, y1, x2, y2 = box
# Get confidence and class
conf = float(boxes.conf[i])
cls_id = int(boxes.cls[i])
# Get class name
class_name = self._class_names.get(cls_id, f"class_{cls_id}")
# Filter by trigger classes if specified
if trigger_classes and class_name not in trigger_classes:
continue
# Get track ID if available
track_id = None
if hasattr(boxes, 'id') and boxes.id is not None:
track_id = int(boxes.id[i])
detection = Detection(
bbox=[float(x1), float(y1), float(x2), float(y2)],
confidence=conf,
class_id=cls_id,
class_name=class_name,
track_id=track_id
)
detections.append(detection)
except Exception as e:
logger.error(f"Failed to parse results: {str(e)}", exc_info=True)
return detections
def track(
self,
image: np.ndarray,
confidence_threshold: float = 0.5,
trigger_classes: Optional[List[str]] = None,
persist: bool = True,
camera_id: Optional[str] = None
) -> InferenceResult:
"""
Run detection (tracking will be handled by external tracker)
Args:
image: Input image as numpy array (BGR format)
confidence_threshold: Minimum confidence for detections
trigger_classes: List of class names to filter
persist: Ignored - tracking handled externally
camera_id: Ignored - tracking handled externally
Returns:
InferenceResult containing detections (no track IDs from YOLO)
"""
# Just do detection - no YOLO tracking
return self.infer(image, confidence_threshold, trigger_classes)
def predict_classification(
self,
image: np.ndarray,
top_k: int = 1
) -> Dict[str, float]:
"""
Run classification on an image
Args:
image: Input image as numpy array (BGR format)
top_k: Number of top predictions to return
Returns:
Dictionary of class_name -> confidence scores
"""
if self.model is None:
raise RuntimeError(f"Model {self.model_id} not loaded")
try:
# Run inference
results = self.model(image, verbose=False)
# For classification models, extract probabilities
if hasattr(results[0], 'probs'):
probs = results[0].probs
top_indices = probs.top5[:top_k]
top_conf = probs.top5conf[:top_k].cpu().numpy()
predictions = {}
for idx, conf in zip(top_indices, top_conf):
class_name = self._class_names.get(int(idx), f"class_{idx}")
predictions[class_name] = float(conf)
return predictions
else:
logger.warning(f"Model {self.model_id} does not support classification")
return {}
except Exception as e:
logger.error(f"Classification failed for model {self.model_id}: {str(e)}", exc_info=True)
raise
def crop_detection(
self,
image: np.ndarray,
detection: Detection,
padding: int = 0
) -> np.ndarray:
"""
Crop image to detection bounding box
Args:
image: Original image
detection: Detection to crop
padding: Additional padding around the box
Returns:
Cropped image region
"""
h, w = image.shape[:2]
x1, y1, x2, y2 = detection.bbox
# Add padding and clip to image boundaries
x1 = max(0, int(x1) - padding)
y1 = max(0, int(y1) - padding)
x2 = min(w, int(x2) + padding)
y2 = min(h, int(y2) + padding)
return image[y1:y2, x1:x2]
def get_class_names(self) -> Dict[int, str]:
"""Get the class names dictionary"""
return self._class_names.copy()
def get_num_classes(self) -> int:
"""Get the number of classes the model can detect"""
return len(self._class_names)
def clear_cache(self) -> None:
"""Clear the model cache"""
with self._cache_lock:
cache_key = str(self.model_path)
if cache_key in self._model_cache:
del self._model_cache[cache_key]
logger.info(f"Cleared cache for model {self.model_id}")
@classmethod
def clear_all_cache(cls) -> None:
"""Clear all cached models"""
with cls._cache_lock:
cls._model_cache.clear()
logger.info("Cleared all model cache")
def warmup(self, image_size: Tuple[int, int] = (640, 640)) -> None:
"""
Warmup the model with a dummy inference
Args:
image_size: Size of dummy image (height, width)
"""
try:
dummy_image = np.zeros((image_size[0], image_size[1], 3), dtype=np.uint8)
self.infer(dummy_image, confidence_threshold=0.5)
logger.info(f"Model {self.model_id} warmed up")
except Exception as e:
logger.warning(f"Failed to warmup model {self.model_id}: {str(e)}")
class ModelInferenceManager:
"""Manages multiple YOLO models for a pipeline"""
def __init__(self, model_dir: Path):
"""
Initialize the inference manager
Args:
model_dir: Directory containing model files
"""
self.model_dir = model_dir
self.models: Dict[str, YOLOWrapper] = {}
self._lock = Lock()
logger.info(f"Initialized ModelInferenceManager with model directory: {model_dir}")
def load_model(
self,
model_id: str,
model_file: str,
device: Optional[str] = None
) -> YOLOWrapper:
"""
Load a model for inference
Args:
model_id: Unique identifier for the model
model_file: Filename of the model
device: Device to run on
Returns:
YOLOWrapper instance
"""
with self._lock:
# Check if already loaded
if model_id in self.models:
logger.debug(f"Model {model_id} already loaded")
return self.models[model_id]
# Load the model
model_path = self.model_dir / model_file
if not model_path.exists():
raise FileNotFoundError(f"Model file not found: {model_path}")
wrapper = YOLOWrapper(model_path, model_id, device)
self.models[model_id] = wrapper
return wrapper
def get_model(self, model_id: str) -> Optional[YOLOWrapper]:
"""
Get a loaded model
Args:
model_id: Model identifier
Returns:
YOLOWrapper instance or None if not loaded
"""
return self.models.get(model_id)
def unload_model(self, model_id: str) -> bool:
"""
Unload a model to free memory
Args:
model_id: Model identifier
Returns:
True if unloaded, False if not found
"""
with self._lock:
if model_id in self.models:
self.models[model_id].clear_cache()
del self.models[model_id]
logger.info(f"Unloaded model {model_id}")
return True
return False
def unload_all(self) -> None:
"""Unload all models"""
with self._lock:
for model_id in list(self.models.keys()):
self.models[model_id].clear_cache()
self.models.clear()
logger.info("Unloaded all models")

439
core/models/manager.py Normal file
View file

@ -0,0 +1,439 @@
"""
Model Manager Module - Handles MPTA download, extraction, and model loading
"""
import os
import logging
import zipfile
import json
import hashlib
import requests
from pathlib import Path
from typing import Dict, Optional, Any, Set
from threading import Lock
from urllib.parse import urlparse, parse_qs
logger = logging.getLogger(__name__)
class ModelManager:
"""Manages MPTA model downloads, extraction, and caching"""
def __init__(self, models_dir: str = "models"):
"""
Initialize the Model Manager
Args:
models_dir: Base directory for storing models
"""
self.models_dir = Path(models_dir)
self.models_dir.mkdir(parents=True, exist_ok=True)
# Track downloaded models to avoid duplicates
self._downloaded_models: Set[int] = set()
self._model_paths: Dict[int, Path] = {}
self._download_lock = Lock()
# Scan existing models
self._scan_existing_models()
logger.info(f"ModelManager initialized with models directory: {self.models_dir}")
logger.info(f"Found existing models: {list(self._downloaded_models)}")
def _scan_existing_models(self) -> None:
"""Scan the models directory for existing downloaded models"""
if not self.models_dir.exists():
return
for model_dir in self.models_dir.iterdir():
if model_dir.is_dir() and model_dir.name.isdigit():
model_id = int(model_dir.name)
# Check if extraction was successful by looking for pipeline.json
extracted_dirs = list(model_dir.glob("*/pipeline.json"))
if extracted_dirs:
self._downloaded_models.add(model_id)
# Store path to the extracted model directory
self._model_paths[model_id] = extracted_dirs[0].parent
logger.debug(f"Found existing model {model_id} at {extracted_dirs[0].parent}")
def get_model_path(self, model_id: int) -> Optional[Path]:
"""
Get the path to an extracted model directory
Args:
model_id: The model ID
Returns:
Path to the extracted model directory or None if not found
"""
return self._model_paths.get(model_id)
def is_model_downloaded(self, model_id: int) -> bool:
"""
Check if a model has already been downloaded and extracted
Args:
model_id: The model ID to check
Returns:
True if the model is already available
"""
return model_id in self._downloaded_models
def ensure_model(self, model_id: int, model_url: str, model_name: str = None) -> Optional[Path]:
"""
Ensure a model is downloaded and extracted, downloading if necessary
Args:
model_id: The model ID
model_url: URL to download the MPTA file from
model_name: Optional model name for logging
Returns:
Path to the extracted model directory or None if failed
"""
# Check if already downloaded
if self.is_model_downloaded(model_id):
logger.info(f"Model {model_id} already available at {self._model_paths[model_id]}")
return self._model_paths[model_id]
# Download and extract with lock to prevent concurrent downloads of same model
with self._download_lock:
# Double-check after acquiring lock
if self.is_model_downloaded(model_id):
return self._model_paths[model_id]
logger.info(f"Model {model_id} not found locally, downloading from {model_url}")
# Create model directory
model_dir = self.models_dir / str(model_id)
model_dir.mkdir(parents=True, exist_ok=True)
# Extract filename from URL
mpta_filename = self._extract_filename_from_url(model_url, model_name, model_id)
mpta_path = model_dir / mpta_filename
# Download MPTA file
if not self._download_mpta(model_url, mpta_path):
logger.error(f"Failed to download model {model_id}")
return None
# Extract MPTA file
extracted_path = self._extract_mpta(mpta_path, model_dir)
if not extracted_path:
logger.error(f"Failed to extract model {model_id}")
return None
# Mark as downloaded and store path
self._downloaded_models.add(model_id)
self._model_paths[model_id] = extracted_path
logger.info(f"Successfully prepared model {model_id} at {extracted_path}")
return extracted_path
def _extract_filename_from_url(self, url: str, model_name: str = None, model_id: int = None) -> str:
"""
Extract a suitable filename from the URL
Args:
url: The URL to extract filename from
model_name: Optional model name
model_id: Optional model ID
Returns:
A suitable filename for the MPTA file
"""
parsed = urlparse(url)
path = parsed.path
# Try to get filename from path
if path:
filename = os.path.basename(path)
if filename and filename.endswith('.mpta'):
return filename
# Fallback to constructed name
if model_name:
return f"{model_name}-{model_id}.mpta"
else:
return f"model-{model_id}.mpta"
def _download_mpta(self, url: str, dest_path: Path) -> bool:
"""
Download an MPTA file from a URL
Args:
url: URL to download from
dest_path: Destination path for the file
Returns:
True if successful, False otherwise
"""
try:
logger.info(f"Starting download of model from {url}")
logger.debug(f"Download destination: {dest_path}")
response = requests.get(url, stream=True, timeout=300)
if response.status_code != 200:
logger.error(f"Failed to download MPTA file (status {response.status_code})")
return False
file_size = int(response.headers.get('content-length', 0))
logger.info(f"Model file size: {file_size/1024/1024:.2f} MB")
downloaded = 0
last_log_percent = 0
with open(dest_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
downloaded += len(chunk)
# Log progress every 10%
if file_size > 0:
percent = int(downloaded * 100 / file_size)
if percent >= last_log_percent + 10:
logger.debug(f"Download progress: {percent}%")
last_log_percent = percent
logger.info(f"Successfully downloaded MPTA file to {dest_path}")
return True
except requests.RequestException as e:
logger.error(f"Network error downloading MPTA: {str(e)}", exc_info=True)
# Clean up partial download
if dest_path.exists():
dest_path.unlink()
return False
except Exception as e:
logger.error(f"Unexpected error downloading MPTA: {str(e)}", exc_info=True)
# Clean up partial download
if dest_path.exists():
dest_path.unlink()
return False
def _extract_mpta(self, mpta_path: Path, target_dir: Path) -> Optional[Path]:
"""
Extract an MPTA (ZIP) file to the target directory
Args:
mpta_path: Path to the MPTA file
target_dir: Directory to extract to
Returns:
Path to the extracted model directory containing pipeline.json, or None if failed
"""
try:
if not mpta_path.exists():
logger.error(f"MPTA file not found: {mpta_path}")
return None
logger.info(f"Extracting MPTA file from {mpta_path} to {target_dir}")
with zipfile.ZipFile(mpta_path, 'r') as zip_ref:
# Get list of files
file_list = zip_ref.namelist()
logger.debug(f"Files in MPTA archive: {len(file_list)} files")
# Extract all files
zip_ref.extractall(target_dir)
logger.info(f"Successfully extracted MPTA file to {target_dir}")
# Find the directory containing pipeline.json
pipeline_files = list(target_dir.glob("*/pipeline.json"))
if not pipeline_files:
# Check if pipeline.json is in root
if (target_dir / "pipeline.json").exists():
logger.info(f"Found pipeline.json in root of {target_dir}")
return target_dir
logger.error(f"No pipeline.json found after extraction in {target_dir}")
return None
# Return the directory containing pipeline.json
extracted_dir = pipeline_files[0].parent
logger.info(f"Extracted model to {extracted_dir}")
# Keep the MPTA file for reference but could delete if space is a concern
# mpta_path.unlink()
# logger.debug(f"Removed MPTA file after extraction: {mpta_path}")
return extracted_dir
except zipfile.BadZipFile as e:
logger.error(f"Invalid ZIP/MPTA file {mpta_path}: {str(e)}", exc_info=True)
return None
except Exception as e:
logger.error(f"Failed to extract MPTA file {mpta_path}: {str(e)}", exc_info=True)
return None
def load_pipeline_config(self, model_id: int) -> Optional[Dict[str, Any]]:
"""
Load the pipeline.json configuration for a model
Args:
model_id: The model ID
Returns:
The pipeline configuration dictionary or None if not found
"""
model_path = self.get_model_path(model_id)
if not model_path:
logger.error(f"Model {model_id} not found")
return None
pipeline_path = model_path / "pipeline.json"
if not pipeline_path.exists():
logger.error(f"pipeline.json not found for model {model_id}")
return None
try:
with open(pipeline_path, 'r') as f:
config = json.load(f)
logger.debug(f"Loaded pipeline config for model {model_id}")
return config
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON in pipeline.json for model {model_id}: {str(e)}")
return None
except Exception as e:
logger.error(f"Failed to load pipeline.json for model {model_id}: {str(e)}")
return None
def get_model_file_path(self, model_id: int, filename: str) -> Optional[Path]:
"""
Get the full path to a model file (e.g., .pt file)
Args:
model_id: The model ID
filename: The filename within the model directory
Returns:
Full path to the model file or None if not found
"""
model_path = self.get_model_path(model_id)
if not model_path:
return None
file_path = model_path / filename
if not file_path.exists():
logger.error(f"Model file {filename} not found in model {model_id}")
return None
return file_path
def cleanup_model(self, model_id: int) -> bool:
"""
Remove a downloaded model to free up space
Args:
model_id: The model ID to remove
Returns:
True if successful, False otherwise
"""
if model_id not in self._downloaded_models:
logger.warning(f"Model {model_id} not in downloaded models")
return False
try:
model_dir = self.models_dir / str(model_id)
if model_dir.exists():
import shutil
shutil.rmtree(model_dir)
logger.info(f"Removed model directory: {model_dir}")
self._downloaded_models.discard(model_id)
self._model_paths.pop(model_id, None)
return True
except Exception as e:
logger.error(f"Failed to cleanup model {model_id}: {str(e)}")
return False
def get_all_downloaded_models(self) -> Set[int]:
"""
Get a set of all downloaded model IDs
Returns:
Set of model IDs that are currently downloaded
"""
return self._downloaded_models.copy()
def get_pipeline_config(self, model_id: int) -> Optional[Any]:
"""
Get the pipeline configuration for a model.
Args:
model_id: The model ID
Returns:
PipelineConfig object if found, None otherwise
"""
try:
if model_id not in self._downloaded_models:
logger.warning(f"Model {model_id} not downloaded")
return None
model_path = self._model_paths.get(model_id)
if not model_path:
logger.warning(f"Model path not found for model {model_id}")
return None
# Import here to avoid circular imports
from .pipeline import PipelineParser
# Load pipeline.json
pipeline_file = model_path / "pipeline.json"
if not pipeline_file.exists():
logger.warning(f"No pipeline.json found for model {model_id}")
return None
# Create PipelineParser object and parse the configuration
pipeline_parser = PipelineParser()
success = pipeline_parser.parse(pipeline_file)
if success:
return pipeline_parser
else:
logger.error(f"Failed to parse pipeline.json for model {model_id}")
return None
except Exception as e:
logger.error(f"Error getting pipeline config for model {model_id}: {e}", exc_info=True)
return None
def get_yolo_model(self, model_id: int, model_filename: str) -> Optional[Any]:
"""
Create a YOLOWrapper instance for a specific model file.
Args:
model_id: The model ID
model_filename: The .pt model filename
Returns:
YOLOWrapper instance if successful, None otherwise
"""
try:
# Get the model file path
model_file_path = self.get_model_file_path(model_id, model_filename)
if not model_file_path or not model_file_path.exists():
logger.error(f"Model file {model_filename} not found for model {model_id}")
return None
# Import here to avoid circular imports
from .inference import YOLOWrapper
# Create YOLOWrapper instance
yolo_model = YOLOWrapper(
model_path=model_file_path,
model_id=f"{model_id}_{model_filename}",
device=None # Auto-detect device
)
logger.info(f"Created YOLOWrapper for model {model_id}: {model_filename}")
return yolo_model
except Exception as e:
logger.error(f"Error creating YOLO model for {model_id}:{model_filename}: {e}", exc_info=True)
return None

357
core/models/pipeline.py Normal file
View file

@ -0,0 +1,357 @@
"""
Pipeline Configuration Parser - Handles pipeline.json parsing and validation
"""
import json
import logging
from pathlib import Path
from typing import Dict, List, Any, Optional, Set
from dataclasses import dataclass, field
from enum import Enum
logger = logging.getLogger(__name__)
class ActionType(Enum):
"""Supported action types in pipeline"""
REDIS_SAVE_IMAGE = "redis_save_image"
REDIS_PUBLISH = "redis_publish"
POSTGRESQL_UPDATE = "postgresql_update"
POSTGRESQL_UPDATE_COMBINED = "postgresql_update_combined"
POSTGRESQL_INSERT = "postgresql_insert"
@dataclass
class RedisConfig:
"""Redis connection configuration"""
host: str
port: int = 6379
password: Optional[str] = None
db: int = 0
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'RedisConfig':
return cls(
host=data['host'],
port=data.get('port', 6379),
password=data.get('password'),
db=data.get('db', 0)
)
@dataclass
class PostgreSQLConfig:
"""PostgreSQL connection configuration"""
host: str
port: int
database: str
username: str
password: str
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'PostgreSQLConfig':
return cls(
host=data['host'],
port=data.get('port', 5432),
database=data['database'],
username=data['username'],
password=data['password']
)
@dataclass
class Action:
"""Represents an action in the pipeline"""
type: ActionType
params: Dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Action':
action_type = ActionType(data['type'])
params = {k: v for k, v in data.items() if k != 'type'}
return cls(type=action_type, params=params)
@dataclass
class ModelBranch:
"""Represents a branch in the pipeline with its own model"""
model_id: str
model_file: str
trigger_classes: List[str]
min_confidence: float = 0.5
crop: bool = False
crop_class: Optional[Any] = None # Can be string or list
parallel: bool = False
actions: List[Action] = field(default_factory=list)
branches: List['ModelBranch'] = field(default_factory=list)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'ModelBranch':
actions = [Action.from_dict(a) for a in data.get('actions', [])]
branches = [cls.from_dict(b) for b in data.get('branches', [])]
return cls(
model_id=data['modelId'],
model_file=data['modelFile'],
trigger_classes=data.get('triggerClasses', []),
min_confidence=data.get('minConfidence', 0.5),
crop=data.get('crop', False),
crop_class=data.get('cropClass'),
parallel=data.get('parallel', False),
actions=actions,
branches=branches
)
@dataclass
class TrackingConfig:
"""Configuration for the tracking phase"""
model_id: str
model_file: str
trigger_classes: List[str]
min_confidence: float = 0.6
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'TrackingConfig':
return cls(
model_id=data['modelId'],
model_file=data['modelFile'],
trigger_classes=data.get('triggerClasses', []),
min_confidence=data.get('minConfidence', 0.6)
)
@dataclass
class PipelineConfig:
"""Main pipeline configuration"""
model_id: str
model_file: str
trigger_classes: List[str]
min_confidence: float = 0.5
crop: bool = False
branches: List[ModelBranch] = field(default_factory=list)
parallel_actions: List[Action] = field(default_factory=list)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'PipelineConfig':
branches = [ModelBranch.from_dict(b) for b in data.get('branches', [])]
parallel_actions = [Action.from_dict(a) for a in data.get('parallelActions', [])]
return cls(
model_id=data['modelId'],
model_file=data['modelFile'],
trigger_classes=data.get('triggerClasses', []),
min_confidence=data.get('minConfidence', 0.5),
crop=data.get('crop', False),
branches=branches,
parallel_actions=parallel_actions
)
class PipelineParser:
"""Parser for pipeline.json configuration files"""
def __init__(self):
self.redis_config: Optional[RedisConfig] = None
self.postgresql_config: Optional[PostgreSQLConfig] = None
self.tracking_config: Optional[TrackingConfig] = None
self.pipeline_config: Optional[PipelineConfig] = None
self._model_dependencies: Set[str] = set()
def parse(self, config_path: Path) -> bool:
"""
Parse a pipeline.json configuration file
Args:
config_path: Path to the pipeline.json file
Returns:
True if parsing was successful, False otherwise
"""
try:
if not config_path.exists():
logger.error(f"Pipeline config not found: {config_path}")
return False
with open(config_path, 'r') as f:
data = json.load(f)
return self.parse_dict(data)
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON in pipeline config: {str(e)}")
return False
except Exception as e:
logger.error(f"Failed to parse pipeline config: {str(e)}", exc_info=True)
return False
def parse_dict(self, data: Dict[str, Any]) -> bool:
"""
Parse a pipeline configuration from a dictionary
Args:
data: The configuration dictionary
Returns:
True if parsing was successful, False otherwise
"""
try:
# Parse Redis configuration
if 'redis' in data:
self.redis_config = RedisConfig.from_dict(data['redis'])
logger.debug(f"Parsed Redis config: {self.redis_config.host}:{self.redis_config.port}")
# Parse PostgreSQL configuration
if 'postgresql' in data:
self.postgresql_config = PostgreSQLConfig.from_dict(data['postgresql'])
logger.debug(f"Parsed PostgreSQL config: {self.postgresql_config.host}:{self.postgresql_config.port}/{self.postgresql_config.database}")
# Parse tracking configuration
if 'tracking' in data:
self.tracking_config = TrackingConfig.from_dict(data['tracking'])
self._model_dependencies.add(self.tracking_config.model_file)
logger.debug(f"Parsed tracking config: {self.tracking_config.model_id}")
# Parse main pipeline configuration
if 'pipeline' in data:
self.pipeline_config = PipelineConfig.from_dict(data['pipeline'])
self._collect_model_dependencies(self.pipeline_config)
logger.debug(f"Parsed pipeline config: {self.pipeline_config.model_id}")
logger.info(f"Successfully parsed pipeline configuration")
logger.debug(f"Model dependencies: {self._model_dependencies}")
return True
except KeyError as e:
logger.error(f"Missing required field in pipeline config: {str(e)}")
return False
except Exception as e:
logger.error(f"Failed to parse pipeline config: {str(e)}", exc_info=True)
return False
def _collect_model_dependencies(self, config: Any) -> None:
"""
Recursively collect all model file dependencies
Args:
config: Pipeline or branch configuration
"""
if hasattr(config, 'model_file'):
self._model_dependencies.add(config.model_file)
if hasattr(config, 'branches'):
for branch in config.branches:
self._collect_model_dependencies(branch)
def get_model_dependencies(self) -> Set[str]:
"""
Get all model file dependencies from the pipeline
Returns:
Set of model filenames required by the pipeline
"""
return self._model_dependencies.copy()
def validate(self) -> bool:
"""
Validate the parsed configuration
Returns:
True if configuration is valid, False otherwise
"""
if not self.pipeline_config:
logger.error("No pipeline configuration found")
return False
# Check that all required model files are specified
if not self.pipeline_config.model_file:
logger.error("Main pipeline model file not specified")
return False
# Validate action configurations
if not self._validate_actions(self.pipeline_config):
return False
# Validate parallel actions
for action in self.pipeline_config.parallel_actions:
if action.type == ActionType.POSTGRESQL_UPDATE_COMBINED:
wait_for = action.params.get('waitForBranches', [])
if wait_for:
# Check that referenced branches exist
branch_ids = self._get_all_branch_ids(self.pipeline_config)
for branch_id in wait_for:
if branch_id not in branch_ids:
logger.error(f"Referenced branch '{branch_id}' in waitForBranches not found")
return False
logger.info("Pipeline configuration validated successfully")
return True
def _validate_actions(self, config: Any) -> bool:
"""
Validate actions in a pipeline or branch configuration
Args:
config: Pipeline or branch configuration
Returns:
True if valid, False otherwise
"""
if hasattr(config, 'actions'):
for action in config.actions:
# Validate Redis actions need Redis config
if action.type in [ActionType.REDIS_SAVE_IMAGE, ActionType.REDIS_PUBLISH]:
if not self.redis_config:
logger.error(f"Action {action.type} requires Redis configuration")
return False
# Validate PostgreSQL actions need PostgreSQL config
if action.type in [ActionType.POSTGRESQL_UPDATE, ActionType.POSTGRESQL_UPDATE_COMBINED, ActionType.POSTGRESQL_INSERT]:
if not self.postgresql_config:
logger.error(f"Action {action.type} requires PostgreSQL configuration")
return False
# Recursively validate branches
if hasattr(config, 'branches'):
for branch in config.branches:
if not self._validate_actions(branch):
return False
return True
def _get_all_branch_ids(self, config: Any, branch_ids: Set[str] = None) -> Set[str]:
"""
Recursively collect all branch model IDs
Args:
config: Pipeline or branch configuration
branch_ids: Set to collect IDs into
Returns:
Set of all branch model IDs
"""
if branch_ids is None:
branch_ids = set()
if hasattr(config, 'branches'):
for branch in config.branches:
branch_ids.add(branch.model_id)
self._get_all_branch_ids(branch, branch_ids)
return branch_ids
def get_redis_config(self) -> Optional[RedisConfig]:
"""Get the Redis configuration"""
return self.redis_config
def get_postgresql_config(self) -> Optional[PostgreSQLConfig]:
"""Get the PostgreSQL configuration"""
return self.postgresql_config
def get_tracking_config(self) -> Optional[TrackingConfig]:
"""Get the tracking configuration"""
return self.tracking_config
def get_pipeline_config(self) -> Optional[PipelineConfig]:
"""Get the main pipeline configuration"""
return self.pipeline_config

View file

@ -0,0 +1,18 @@
"""
Comprehensive health monitoring system for detector worker.
Tracks stream health, thread responsiveness, and system performance.
"""
from .health import HealthMonitor, HealthStatus, HealthCheck
from .stream_health import StreamHealthTracker
from .thread_health import ThreadHealthMonitor
from .recovery import RecoveryManager
__all__ = [
'HealthMonitor',
'HealthStatus',
'HealthCheck',
'StreamHealthTracker',
'ThreadHealthMonitor',
'RecoveryManager'
]

456
core/monitoring/health.py Normal file
View file

@ -0,0 +1,456 @@
"""
Core health monitoring system for comprehensive stream and system health tracking.
Provides centralized health status, alerting, and recovery coordination.
"""
import time
import threading
import logging
import psutil
from typing import Dict, List, Optional, Any, Callable
from dataclasses import dataclass, field
from enum import Enum
from collections import defaultdict, deque
logger = logging.getLogger(__name__)
class HealthStatus(Enum):
"""Health status levels."""
HEALTHY = "healthy"
WARNING = "warning"
CRITICAL = "critical"
UNKNOWN = "unknown"
@dataclass
class HealthCheck:
"""Individual health check result."""
name: str
status: HealthStatus
message: str
timestamp: float = field(default_factory=time.time)
details: Dict[str, Any] = field(default_factory=dict)
recovery_action: Optional[str] = None
@dataclass
class HealthMetrics:
"""Health metrics for a component."""
component_id: str
last_update: float
frame_count: int = 0
error_count: int = 0
warning_count: int = 0
restart_count: int = 0
avg_frame_interval: float = 0.0
last_frame_time: Optional[float] = None
thread_alive: bool = True
connection_healthy: bool = True
memory_usage_mb: float = 0.0
cpu_usage_percent: float = 0.0
class HealthMonitor:
"""Comprehensive health monitoring system."""
def __init__(self, check_interval: float = 30.0):
"""
Initialize health monitor.
Args:
check_interval: Interval between health checks in seconds
"""
self.check_interval = check_interval
self.running = False
self.monitor_thread = None
self._lock = threading.RLock()
# Health data storage
self.health_checks: Dict[str, HealthCheck] = {}
self.metrics: Dict[str, HealthMetrics] = {}
self.alert_history: deque = deque(maxlen=1000)
self.recovery_actions: deque = deque(maxlen=500)
# Thresholds (configurable)
self.thresholds = {
'frame_stale_warning_seconds': 120, # 2 minutes
'frame_stale_critical_seconds': 300, # 5 minutes
'thread_unresponsive_seconds': 60, # 1 minute
'memory_warning_mb': 500, # 500MB per stream
'memory_critical_mb': 1000, # 1GB per stream
'cpu_warning_percent': 80, # 80% CPU
'cpu_critical_percent': 95, # 95% CPU
'error_rate_warning': 0.1, # 10% error rate
'error_rate_critical': 0.3, # 30% error rate
'restart_threshold': 3 # Max restarts per hour
}
# Health check functions
self.health_checkers: List[Callable[[], List[HealthCheck]]] = []
self.recovery_callbacks: Dict[str, Callable[[str, HealthCheck], bool]] = {}
# System monitoring
self.process = psutil.Process()
self.system_start_time = time.time()
def start(self):
"""Start health monitoring."""
if self.running:
logger.warning("Health monitor already running")
return
self.running = True
self.monitor_thread = threading.Thread(target=self._monitor_loop, daemon=True)
self.monitor_thread.start()
logger.info(f"Health monitor started (check interval: {self.check_interval}s)")
def stop(self):
"""Stop health monitoring."""
self.running = False
if self.monitor_thread:
self.monitor_thread.join(timeout=5.0)
logger.info("Health monitor stopped")
def register_health_checker(self, checker: Callable[[], List[HealthCheck]]):
"""Register a health check function."""
self.health_checkers.append(checker)
logger.debug(f"Registered health checker: {checker.__name__}")
def register_recovery_callback(self, component: str, callback: Callable[[str, HealthCheck], bool]):
"""Register a recovery callback for a component."""
self.recovery_callbacks[component] = callback
logger.debug(f"Registered recovery callback for {component}")
def update_metrics(self, component_id: str, **kwargs):
"""Update metrics for a component."""
with self._lock:
if component_id not in self.metrics:
self.metrics[component_id] = HealthMetrics(
component_id=component_id,
last_update=time.time()
)
metrics = self.metrics[component_id]
metrics.last_update = time.time()
# Update provided metrics
for key, value in kwargs.items():
if hasattr(metrics, key):
setattr(metrics, key, value)
def report_frame_received(self, component_id: str):
"""Report that a frame was received for a component."""
current_time = time.time()
with self._lock:
if component_id not in self.metrics:
self.metrics[component_id] = HealthMetrics(
component_id=component_id,
last_update=current_time
)
metrics = self.metrics[component_id]
# Update frame metrics
if metrics.last_frame_time:
interval = current_time - metrics.last_frame_time
# Moving average of frame intervals
if metrics.avg_frame_interval == 0:
metrics.avg_frame_interval = interval
else:
metrics.avg_frame_interval = (metrics.avg_frame_interval * 0.9) + (interval * 0.1)
metrics.last_frame_time = current_time
metrics.frame_count += 1
metrics.last_update = current_time
def report_error(self, component_id: str, error_type: str = "general"):
"""Report an error for a component."""
with self._lock:
if component_id not in self.metrics:
self.metrics[component_id] = HealthMetrics(
component_id=component_id,
last_update=time.time()
)
self.metrics[component_id].error_count += 1
self.metrics[component_id].last_update = time.time()
logger.debug(f"Error reported for {component_id}: {error_type}")
def report_warning(self, component_id: str, warning_type: str = "general"):
"""Report a warning for a component."""
with self._lock:
if component_id not in self.metrics:
self.metrics[component_id] = HealthMetrics(
component_id=component_id,
last_update=time.time()
)
self.metrics[component_id].warning_count += 1
self.metrics[component_id].last_update = time.time()
logger.debug(f"Warning reported for {component_id}: {warning_type}")
def report_restart(self, component_id: str):
"""Report that a component was restarted."""
with self._lock:
if component_id not in self.metrics:
self.metrics[component_id] = HealthMetrics(
component_id=component_id,
last_update=time.time()
)
self.metrics[component_id].restart_count += 1
self.metrics[component_id].last_update = time.time()
# Log recovery action
recovery_action = {
'timestamp': time.time(),
'component': component_id,
'action': 'restart',
'reason': 'manual_restart'
}
with self._lock:
self.recovery_actions.append(recovery_action)
logger.info(f"Restart reported for {component_id}")
def get_health_status(self, component_id: Optional[str] = None) -> Dict[str, Any]:
"""Get comprehensive health status."""
with self._lock:
if component_id:
# Get health for specific component
return self._get_component_health(component_id)
else:
# Get overall health status
return self._get_overall_health()
def _get_component_health(self, component_id: str) -> Dict[str, Any]:
"""Get health status for a specific component."""
if component_id not in self.metrics:
return {
'component_id': component_id,
'status': HealthStatus.UNKNOWN.value,
'message': 'No metrics available',
'metrics': {}
}
metrics = self.metrics[component_id]
current_time = time.time()
# Determine health status
status = HealthStatus.HEALTHY
issues = []
# Check frame freshness
if metrics.last_frame_time:
frame_age = current_time - metrics.last_frame_time
if frame_age > self.thresholds['frame_stale_critical_seconds']:
status = HealthStatus.CRITICAL
issues.append(f"Frames stale for {frame_age:.1f}s")
elif frame_age > self.thresholds['frame_stale_warning_seconds']:
if status == HealthStatus.HEALTHY:
status = HealthStatus.WARNING
issues.append(f"Frames aging ({frame_age:.1f}s)")
# Check error rates
if metrics.frame_count > 0:
error_rate = metrics.error_count / metrics.frame_count
if error_rate > self.thresholds['error_rate_critical']:
status = HealthStatus.CRITICAL
issues.append(f"High error rate ({error_rate:.1%})")
elif error_rate > self.thresholds['error_rate_warning']:
if status == HealthStatus.HEALTHY:
status = HealthStatus.WARNING
issues.append(f"Elevated error rate ({error_rate:.1%})")
# Check restart frequency
restart_rate = metrics.restart_count / max(1, (current_time - self.system_start_time) / 3600)
if restart_rate > self.thresholds['restart_threshold']:
status = HealthStatus.CRITICAL
issues.append(f"Frequent restarts ({restart_rate:.1f}/hour)")
# Check thread health
if not metrics.thread_alive:
status = HealthStatus.CRITICAL
issues.append("Thread not alive")
# Check connection health
if not metrics.connection_healthy:
if status == HealthStatus.HEALTHY:
status = HealthStatus.WARNING
issues.append("Connection unhealthy")
return {
'component_id': component_id,
'status': status.value,
'message': '; '.join(issues) if issues else 'All checks passing',
'metrics': {
'frame_count': metrics.frame_count,
'error_count': metrics.error_count,
'warning_count': metrics.warning_count,
'restart_count': metrics.restart_count,
'avg_frame_interval': metrics.avg_frame_interval,
'last_frame_age': current_time - metrics.last_frame_time if metrics.last_frame_time else None,
'thread_alive': metrics.thread_alive,
'connection_healthy': metrics.connection_healthy,
'memory_usage_mb': metrics.memory_usage_mb,
'cpu_usage_percent': metrics.cpu_usage_percent,
'uptime_seconds': current_time - self.system_start_time
},
'last_update': metrics.last_update
}
def _get_overall_health(self) -> Dict[str, Any]:
"""Get overall system health status."""
current_time = time.time()
components = {}
overall_status = HealthStatus.HEALTHY
# Get health for all components
for component_id in self.metrics.keys():
component_health = self._get_component_health(component_id)
components[component_id] = component_health
# Determine overall status
component_status = HealthStatus(component_health['status'])
if component_status == HealthStatus.CRITICAL:
overall_status = HealthStatus.CRITICAL
elif component_status == HealthStatus.WARNING and overall_status == HealthStatus.HEALTHY:
overall_status = HealthStatus.WARNING
# System metrics
try:
system_memory = self.process.memory_info()
system_cpu = self.process.cpu_percent()
except Exception:
system_memory = None
system_cpu = 0.0
return {
'overall_status': overall_status.value,
'timestamp': current_time,
'uptime_seconds': current_time - self.system_start_time,
'total_components': len(self.metrics),
'components': components,
'system_metrics': {
'memory_mb': system_memory.rss / (1024 * 1024) if system_memory else 0,
'cpu_percent': system_cpu,
'process_id': self.process.pid
},
'recent_alerts': list(self.alert_history)[-10:], # Last 10 alerts
'recent_recoveries': list(self.recovery_actions)[-10:] # Last 10 recovery actions
}
def _monitor_loop(self):
"""Main health monitoring loop."""
logger.info("Health monitor loop started")
while self.running:
try:
start_time = time.time()
# Run all registered health checks
all_checks = []
for checker in self.health_checkers:
try:
checks = checker()
all_checks.extend(checks)
except Exception as e:
logger.error(f"Error in health checker {checker.__name__}: {e}")
# Process health checks and trigger recovery if needed
for check in all_checks:
self._process_health_check(check)
# Update system metrics
self._update_system_metrics()
# Sleep until next check
elapsed = time.time() - start_time
sleep_time = max(0, self.check_interval - elapsed)
if sleep_time > 0:
time.sleep(sleep_time)
except Exception as e:
logger.error(f"Error in health monitor loop: {e}")
time.sleep(5.0) # Fallback sleep
logger.info("Health monitor loop ended")
def _process_health_check(self, check: HealthCheck):
"""Process a health check result and trigger recovery if needed."""
with self._lock:
# Store health check
self.health_checks[check.name] = check
# Log alerts for non-healthy status
if check.status != HealthStatus.HEALTHY:
alert = {
'timestamp': check.timestamp,
'component': check.name,
'status': check.status.value,
'message': check.message,
'details': check.details
}
self.alert_history.append(alert)
logger.warning(f"Health alert [{check.status.value.upper()}] {check.name}: {check.message}")
# Trigger recovery if critical and recovery action available
if check.status == HealthStatus.CRITICAL and check.recovery_action:
self._trigger_recovery(check.name, check)
def _trigger_recovery(self, component: str, check: HealthCheck):
"""Trigger recovery action for a component."""
if component in self.recovery_callbacks:
try:
logger.info(f"Triggering recovery for {component}: {check.recovery_action}")
success = self.recovery_callbacks[component](component, check)
recovery_action = {
'timestamp': time.time(),
'component': component,
'action': check.recovery_action,
'reason': check.message,
'success': success
}
with self._lock:
self.recovery_actions.append(recovery_action)
if success:
logger.info(f"Recovery successful for {component}")
else:
logger.error(f"Recovery failed for {component}")
except Exception as e:
logger.error(f"Error in recovery callback for {component}: {e}")
def _update_system_metrics(self):
"""Update system-level metrics."""
try:
# Update process metrics for all components
current_time = time.time()
with self._lock:
for component_id, metrics in self.metrics.items():
# Update CPU and memory if available
try:
# This is a simplified approach - in practice you'd want
# per-thread or per-component resource tracking
metrics.cpu_usage_percent = self.process.cpu_percent() / len(self.metrics)
memory_info = self.process.memory_info()
metrics.memory_usage_mb = memory_info.rss / (1024 * 1024) / len(self.metrics)
except Exception:
pass
except Exception as e:
logger.error(f"Error updating system metrics: {e}")
# Global health monitor instance
health_monitor = HealthMonitor()

385
core/monitoring/recovery.py Normal file
View file

@ -0,0 +1,385 @@
"""
Recovery manager for automatic handling of health issues.
Provides circuit breaker patterns, automatic restarts, and graceful degradation.
"""
import time
import logging
import threading
from typing import Dict, List, Optional, Any, Callable
from dataclasses import dataclass
from enum import Enum
from collections import defaultdict, deque
from .health import HealthCheck, HealthStatus, health_monitor
logger = logging.getLogger(__name__)
class RecoveryAction(Enum):
"""Types of recovery actions."""
RESTART_STREAM = "restart_stream"
RESTART_THREAD = "restart_thread"
CLEAR_BUFFER = "clear_buffer"
RECONNECT = "reconnect"
THROTTLE = "throttle"
DISABLE = "disable"
@dataclass
class RecoveryAttempt:
"""Record of a recovery attempt."""
timestamp: float
component: str
action: RecoveryAction
reason: str
success: bool
details: Dict[str, Any] = None
@dataclass
class RecoveryState:
"""Recovery state for a component - simplified without circuit breaker."""
failure_count: int = 0
success_count: int = 0
last_failure_time: Optional[float] = None
last_success_time: Optional[float] = None
class RecoveryManager:
"""Manages automatic recovery actions for health issues."""
def __init__(self):
self.recovery_handlers: Dict[str, Callable[[str, HealthCheck], bool]] = {}
self.recovery_states: Dict[str, RecoveryState] = {}
self.recovery_history: deque = deque(maxlen=1000)
self._lock = threading.RLock()
# Configuration - simplified without circuit breaker
self.recovery_cooldown = 30 # 30 seconds between recovery attempts
self.max_attempts_per_hour = 20 # Still limit to prevent spam, but much higher
# Track recovery attempts per component
self.recovery_attempts: Dict[str, deque] = defaultdict(lambda: deque(maxlen=50))
# Register with health monitor
health_monitor.register_recovery_callback("stream", self._handle_stream_recovery)
health_monitor.register_recovery_callback("thread", self._handle_thread_recovery)
health_monitor.register_recovery_callback("buffer", self._handle_buffer_recovery)
def register_recovery_handler(self, action: RecoveryAction, handler: Callable[[str, Dict[str, Any]], bool]):
"""
Register a recovery handler for a specific action.
Args:
action: Type of recovery action
handler: Function that performs the recovery
"""
self.recovery_handlers[action.value] = handler
logger.info(f"Registered recovery handler for {action.value}")
def can_attempt_recovery(self, component: str) -> bool:
"""
Check if recovery can be attempted for a component.
Args:
component: Component identifier
Returns:
True if recovery can be attempted (always allow with minimal throttling)
"""
with self._lock:
current_time = time.time()
# Check recovery attempt rate limiting (much more permissive)
recent_attempts = [
attempt for attempt in self.recovery_attempts[component]
if current_time - attempt <= 3600 # Last hour
]
# Only block if truly excessive attempts
if len(recent_attempts) >= self.max_attempts_per_hour:
logger.warning(f"Recovery rate limit exceeded for {component} "
f"({len(recent_attempts)} attempts in last hour)")
return False
# Check cooldown period (shorter cooldown)
if recent_attempts:
last_attempt = max(recent_attempts)
if current_time - last_attempt < self.recovery_cooldown:
logger.debug(f"Recovery cooldown active for {component} "
f"(last attempt {current_time - last_attempt:.1f}s ago)")
return False
return True
def attempt_recovery(self, component: str, action: RecoveryAction, reason: str,
details: Optional[Dict[str, Any]] = None) -> bool:
"""
Attempt recovery for a component.
Args:
component: Component identifier
action: Recovery action to perform
reason: Reason for recovery
details: Additional details
Returns:
True if recovery was successful
"""
if not self.can_attempt_recovery(component):
return False
current_time = time.time()
logger.info(f"Attempting recovery for {component}: {action.value} ({reason})")
try:
# Record recovery attempt
with self._lock:
self.recovery_attempts[component].append(current_time)
# Perform recovery action
success = self._execute_recovery_action(component, action, details or {})
# Record recovery result
attempt = RecoveryAttempt(
timestamp=current_time,
component=component,
action=action,
reason=reason,
success=success,
details=details
)
with self._lock:
self.recovery_history.append(attempt)
# Update recovery state
self._update_recovery_state(component, success)
if success:
logger.info(f"Recovery successful for {component}: {action.value}")
else:
logger.error(f"Recovery failed for {component}: {action.value}")
return success
except Exception as e:
logger.error(f"Error during recovery for {component}: {e}")
self._update_recovery_state(component, False)
return False
def _execute_recovery_action(self, component: str, action: RecoveryAction,
details: Dict[str, Any]) -> bool:
"""Execute a specific recovery action."""
handler_key = action.value
if handler_key not in self.recovery_handlers:
logger.error(f"No recovery handler registered for action: {handler_key}")
return False
try:
handler = self.recovery_handlers[handler_key]
return handler(component, details)
except Exception as e:
logger.error(f"Error executing recovery action {handler_key} for {component}: {e}")
return False
def _update_recovery_state(self, component: str, success: bool):
"""Update recovery state based on recovery result."""
current_time = time.time()
with self._lock:
if component not in self.recovery_states:
self.recovery_states[component] = RecoveryState()
state = self.recovery_states[component]
if success:
state.success_count += 1
state.last_success_time = current_time
# Reset failure count on success
state.failure_count = max(0, state.failure_count - 1)
logger.debug(f"Recovery success for {component} (total successes: {state.success_count})")
else:
state.failure_count += 1
state.last_failure_time = current_time
logger.debug(f"Recovery failure for {component} (total failures: {state.failure_count})")
def _handle_stream_recovery(self, component: str, health_check: HealthCheck) -> bool:
"""Handle recovery for stream-related issues."""
if "frames" in health_check.name:
# Frame-related issue - restart stream
return self.attempt_recovery(
component,
RecoveryAction.RESTART_STREAM,
health_check.message,
health_check.details
)
elif "connection" in health_check.name:
# Connection issue - reconnect
return self.attempt_recovery(
component,
RecoveryAction.RECONNECT,
health_check.message,
health_check.details
)
elif "errors" in health_check.name:
# High error rate - throttle or restart
return self.attempt_recovery(
component,
RecoveryAction.THROTTLE,
health_check.message,
health_check.details
)
else:
# Generic stream issue - restart
return self.attempt_recovery(
component,
RecoveryAction.RESTART_STREAM,
health_check.message,
health_check.details
)
def _handle_thread_recovery(self, component: str, health_check: HealthCheck) -> bool:
"""Handle recovery for thread-related issues."""
if "deadlock" in health_check.name:
# Deadlock detected - restart thread
return self.attempt_recovery(
component,
RecoveryAction.RESTART_THREAD,
health_check.message,
health_check.details
)
elif "responsive" in health_check.name:
# Thread unresponsive - restart
return self.attempt_recovery(
component,
RecoveryAction.RESTART_THREAD,
health_check.message,
health_check.details
)
else:
# Generic thread issue - restart
return self.attempt_recovery(
component,
RecoveryAction.RESTART_THREAD,
health_check.message,
health_check.details
)
def _handle_buffer_recovery(self, component: str, health_check: HealthCheck) -> bool:
"""Handle recovery for buffer-related issues."""
# Buffer issues - clear buffer
return self.attempt_recovery(
component,
RecoveryAction.CLEAR_BUFFER,
health_check.message,
health_check.details
)
def get_recovery_stats(self) -> Dict[str, Any]:
"""Get recovery statistics."""
current_time = time.time()
with self._lock:
# Calculate stats from history
recent_recoveries = [
attempt for attempt in self.recovery_history
if current_time - attempt.timestamp <= 3600 # Last hour
]
stats_by_component = defaultdict(lambda: {
'attempts': 0,
'successes': 0,
'failures': 0,
'last_attempt': None,
'last_success': None
})
for attempt in recent_recoveries:
stats = stats_by_component[attempt.component]
stats['attempts'] += 1
if attempt.success:
stats['successes'] += 1
if not stats['last_success'] or attempt.timestamp > stats['last_success']:
stats['last_success'] = attempt.timestamp
else:
stats['failures'] += 1
if not stats['last_attempt'] or attempt.timestamp > stats['last_attempt']:
stats['last_attempt'] = attempt.timestamp
return {
'total_recoveries_last_hour': len(recent_recoveries),
'recovery_by_component': dict(stats_by_component),
'recovery_states': {
component: {
'failure_count': state.failure_count,
'success_count': state.success_count,
'last_failure_time': state.last_failure_time,
'last_success_time': state.last_success_time
}
for component, state in self.recovery_states.items()
},
'recent_history': [
{
'timestamp': attempt.timestamp,
'component': attempt.component,
'action': attempt.action.value,
'reason': attempt.reason,
'success': attempt.success
}
for attempt in list(self.recovery_history)[-10:] # Last 10 attempts
]
}
def force_recovery(self, component: str, action: RecoveryAction, reason: str = "manual") -> bool:
"""
Force recovery for a component, bypassing rate limiting.
Args:
component: Component identifier
action: Recovery action to perform
reason: Reason for forced recovery
Returns:
True if recovery was successful
"""
logger.info(f"Forcing recovery for {component}: {action.value} ({reason})")
current_time = time.time()
try:
# Execute recovery action directly
success = self._execute_recovery_action(component, action, {})
# Record forced recovery
attempt = RecoveryAttempt(
timestamp=current_time,
component=component,
action=action,
reason=f"forced: {reason}",
success=success,
details={'forced': True}
)
with self._lock:
self.recovery_history.append(attempt)
self.recovery_attempts[component].append(current_time)
# Update recovery state
self._update_recovery_state(component, success)
return success
except Exception as e:
logger.error(f"Error during forced recovery for {component}: {e}")
return False
# Global recovery manager instance
recovery_manager = RecoveryManager()

View file

@ -0,0 +1,351 @@
"""
Stream-specific health monitoring for video streams.
Tracks frame production, connection health, and stream-specific metrics.
"""
import time
import logging
import threading
import requests
from typing import Dict, Optional, List, Any
from collections import deque
from dataclasses import dataclass
from .health import HealthCheck, HealthStatus, health_monitor
logger = logging.getLogger(__name__)
@dataclass
class StreamMetrics:
"""Metrics for an individual stream."""
camera_id: str
stream_type: str # 'rtsp', 'http_snapshot'
start_time: float
last_frame_time: Optional[float] = None
frame_count: int = 0
error_count: int = 0
reconnect_count: int = 0
bytes_received: int = 0
frames_per_second: float = 0.0
connection_attempts: int = 0
last_connection_test: Optional[float] = None
connection_healthy: bool = True
last_error: Optional[str] = None
last_error_time: Optional[float] = None
class StreamHealthTracker:
"""Tracks health for individual video streams."""
def __init__(self):
self.streams: Dict[str, StreamMetrics] = {}
self._lock = threading.RLock()
# Configuration
self.connection_test_interval = 300 # Test connection every 5 minutes
self.frame_timeout_warning = 120 # Warn if no frames for 2 minutes
self.frame_timeout_critical = 300 # Critical if no frames for 5 minutes
self.error_rate_threshold = 0.1 # 10% error rate threshold
# Register with health monitor
health_monitor.register_health_checker(self._perform_health_checks)
def register_stream(self, camera_id: str, stream_type: str, source_url: Optional[str] = None):
"""Register a new stream for monitoring."""
with self._lock:
if camera_id not in self.streams:
self.streams[camera_id] = StreamMetrics(
camera_id=camera_id,
stream_type=stream_type,
start_time=time.time()
)
logger.info(f"Registered stream for monitoring: {camera_id} ({stream_type})")
# Update health monitor metrics
health_monitor.update_metrics(
camera_id,
thread_alive=True,
connection_healthy=True
)
def unregister_stream(self, camera_id: str):
"""Unregister a stream from monitoring."""
with self._lock:
if camera_id in self.streams:
del self.streams[camera_id]
logger.info(f"Unregistered stream from monitoring: {camera_id}")
def report_frame_received(self, camera_id: str, frame_size_bytes: int = 0):
"""Report that a frame was received."""
current_time = time.time()
with self._lock:
if camera_id not in self.streams:
logger.warning(f"Frame received for unregistered stream: {camera_id}")
return
stream = self.streams[camera_id]
# Update frame metrics
if stream.last_frame_time:
interval = current_time - stream.last_frame_time
# Calculate FPS as moving average
if stream.frames_per_second == 0:
stream.frames_per_second = 1.0 / interval if interval > 0 else 0
else:
new_fps = 1.0 / interval if interval > 0 else 0
stream.frames_per_second = (stream.frames_per_second * 0.9) + (new_fps * 0.1)
stream.last_frame_time = current_time
stream.frame_count += 1
stream.bytes_received += frame_size_bytes
# Report to health monitor
health_monitor.report_frame_received(camera_id)
health_monitor.update_metrics(
camera_id,
frame_count=stream.frame_count,
avg_frame_interval=1.0 / stream.frames_per_second if stream.frames_per_second > 0 else 0,
last_frame_time=current_time
)
def report_error(self, camera_id: str, error_message: str):
"""Report an error for a stream."""
current_time = time.time()
with self._lock:
if camera_id not in self.streams:
logger.warning(f"Error reported for unregistered stream: {camera_id}")
return
stream = self.streams[camera_id]
stream.error_count += 1
stream.last_error = error_message
stream.last_error_time = current_time
# Report to health monitor
health_monitor.report_error(camera_id, "stream_error")
health_monitor.update_metrics(
camera_id,
error_count=stream.error_count
)
logger.debug(f"Error reported for stream {camera_id}: {error_message}")
def report_reconnect(self, camera_id: str, reason: str = "unknown"):
"""Report that a stream reconnected."""
current_time = time.time()
with self._lock:
if camera_id not in self.streams:
logger.warning(f"Reconnect reported for unregistered stream: {camera_id}")
return
stream = self.streams[camera_id]
stream.reconnect_count += 1
# Report to health monitor
health_monitor.report_restart(camera_id)
health_monitor.update_metrics(
camera_id,
restart_count=stream.reconnect_count
)
logger.info(f"Reconnect reported for stream {camera_id}: {reason}")
def report_connection_attempt(self, camera_id: str, success: bool):
"""Report a connection attempt."""
with self._lock:
if camera_id not in self.streams:
return
stream = self.streams[camera_id]
stream.connection_attempts += 1
stream.connection_healthy = success
# Report to health monitor
health_monitor.update_metrics(
camera_id,
connection_healthy=success
)
def test_http_connection(self, camera_id: str, url: str) -> bool:
"""Test HTTP connection health for snapshot streams."""
try:
# Quick HEAD request to test connectivity
response = requests.head(url, timeout=5, verify=False)
success = response.status_code in [200, 404] # 404 might be normal for some cameras
self.report_connection_attempt(camera_id, success)
if success:
logger.debug(f"Connection test passed for {camera_id}")
else:
logger.warning(f"Connection test failed for {camera_id}: HTTP {response.status_code}")
return success
except Exception as e:
logger.warning(f"Connection test failed for {camera_id}: {e}")
self.report_connection_attempt(camera_id, False)
return False
def get_stream_metrics(self, camera_id: str) -> Optional[Dict[str, Any]]:
"""Get metrics for a specific stream."""
with self._lock:
if camera_id not in self.streams:
return None
stream = self.streams[camera_id]
current_time = time.time()
# Calculate derived metrics
uptime = current_time - stream.start_time
frame_age = current_time - stream.last_frame_time if stream.last_frame_time else None
error_rate = stream.error_count / max(1, stream.frame_count)
return {
'camera_id': camera_id,
'stream_type': stream.stream_type,
'uptime_seconds': uptime,
'frame_count': stream.frame_count,
'frames_per_second': stream.frames_per_second,
'bytes_received': stream.bytes_received,
'error_count': stream.error_count,
'error_rate': error_rate,
'reconnect_count': stream.reconnect_count,
'connection_attempts': stream.connection_attempts,
'connection_healthy': stream.connection_healthy,
'last_frame_age_seconds': frame_age,
'last_error': stream.last_error,
'last_error_time': stream.last_error_time
}
def get_all_metrics(self) -> Dict[str, Dict[str, Any]]:
"""Get metrics for all streams."""
with self._lock:
return {
camera_id: self.get_stream_metrics(camera_id)
for camera_id in self.streams.keys()
}
def _perform_health_checks(self) -> List[HealthCheck]:
"""Perform health checks for all streams."""
checks = []
current_time = time.time()
with self._lock:
for camera_id, stream in self.streams.items():
checks.extend(self._check_stream_health(camera_id, stream, current_time))
return checks
def _check_stream_health(self, camera_id: str, stream: StreamMetrics, current_time: float) -> List[HealthCheck]:
"""Perform health checks for a single stream."""
checks = []
# Check frame freshness
if stream.last_frame_time:
frame_age = current_time - stream.last_frame_time
if frame_age > self.frame_timeout_critical:
checks.append(HealthCheck(
name=f"stream_{camera_id}_frames",
status=HealthStatus.CRITICAL,
message=f"No frames for {frame_age:.1f}s (critical threshold: {self.frame_timeout_critical}s)",
details={
'frame_age': frame_age,
'threshold': self.frame_timeout_critical,
'last_frame_time': stream.last_frame_time
},
recovery_action="restart_stream"
))
elif frame_age > self.frame_timeout_warning:
checks.append(HealthCheck(
name=f"stream_{camera_id}_frames",
status=HealthStatus.WARNING,
message=f"Frames aging: {frame_age:.1f}s (warning threshold: {self.frame_timeout_warning}s)",
details={
'frame_age': frame_age,
'threshold': self.frame_timeout_warning,
'last_frame_time': stream.last_frame_time
}
))
else:
# No frames received yet
startup_time = current_time - stream.start_time
if startup_time > 60: # Allow 1 minute for initial connection
checks.append(HealthCheck(
name=f"stream_{camera_id}_startup",
status=HealthStatus.CRITICAL,
message=f"No frames received since startup {startup_time:.1f}s ago",
details={
'startup_time': startup_time,
'start_time': stream.start_time
},
recovery_action="restart_stream"
))
# Check error rate
if stream.frame_count > 10: # Need sufficient samples
error_rate = stream.error_count / stream.frame_count
if error_rate > self.error_rate_threshold:
checks.append(HealthCheck(
name=f"stream_{camera_id}_errors",
status=HealthStatus.WARNING,
message=f"High error rate: {error_rate:.1%} ({stream.error_count}/{stream.frame_count})",
details={
'error_rate': error_rate,
'error_count': stream.error_count,
'frame_count': stream.frame_count,
'last_error': stream.last_error
}
))
# Check connection health
if not stream.connection_healthy:
checks.append(HealthCheck(
name=f"stream_{camera_id}_connection",
status=HealthStatus.WARNING,
message="Connection unhealthy (last test failed)",
details={
'connection_attempts': stream.connection_attempts,
'last_connection_test': stream.last_connection_test
}
))
# Check excessive reconnects
uptime_hours = (current_time - stream.start_time) / 3600
if uptime_hours > 1 and stream.reconnect_count > 5: # More than 5 reconnects per hour
reconnect_rate = stream.reconnect_count / uptime_hours
checks.append(HealthCheck(
name=f"stream_{camera_id}_stability",
status=HealthStatus.WARNING,
message=f"Frequent reconnects: {reconnect_rate:.1f}/hour ({stream.reconnect_count} total)",
details={
'reconnect_rate': reconnect_rate,
'reconnect_count': stream.reconnect_count,
'uptime_hours': uptime_hours
}
))
# Check frame rate health
if stream.last_frame_time and stream.frames_per_second > 0:
expected_fps = 6.0 # Expected FPS for streams
if stream.frames_per_second < expected_fps * 0.5: # Less than 50% of expected
checks.append(HealthCheck(
name=f"stream_{camera_id}_framerate",
status=HealthStatus.WARNING,
message=f"Low frame rate: {stream.frames_per_second:.1f} fps (expected: ~{expected_fps} fps)",
details={
'current_fps': stream.frames_per_second,
'expected_fps': expected_fps
}
))
return checks
# Global stream health tracker instance
stream_health_tracker = StreamHealthTracker()

View file

@ -0,0 +1,381 @@
"""
Thread health monitoring for detecting unresponsive and deadlocked threads.
Provides thread liveness detection and responsiveness testing.
"""
import time
import threading
import logging
import signal
import traceback
from typing import Dict, List, Optional, Any, Callable
from dataclasses import dataclass
from collections import defaultdict
from .health import HealthCheck, HealthStatus, health_monitor
logger = logging.getLogger(__name__)
@dataclass
class ThreadInfo:
"""Information about a monitored thread."""
thread_id: int
thread_name: str
start_time: float
last_heartbeat: float
heartbeat_count: int = 0
is_responsive: bool = True
last_activity: Optional[str] = None
stack_traces: List[str] = None
class ThreadHealthMonitor:
"""Monitors thread health and responsiveness."""
def __init__(self):
self.monitored_threads: Dict[int, ThreadInfo] = {}
self.heartbeat_callbacks: Dict[int, Callable[[], bool]] = {}
self._lock = threading.RLock()
# Configuration
self.heartbeat_timeout = 60.0 # 1 minute without heartbeat = unresponsive
self.responsiveness_test_interval = 30.0 # Test responsiveness every 30 seconds
self.stack_trace_count = 5 # Keep last 5 stack traces for analysis
# Register with health monitor
health_monitor.register_health_checker(self._perform_health_checks)
# Enable periodic responsiveness testing
self.test_thread = threading.Thread(target=self._responsiveness_test_loop, daemon=True)
self.test_thread.start()
def register_thread(self, thread: threading.Thread, heartbeat_callback: Optional[Callable[[], bool]] = None):
"""
Register a thread for monitoring.
Args:
thread: Thread to monitor
heartbeat_callback: Optional callback to test thread responsiveness
"""
with self._lock:
thread_info = ThreadInfo(
thread_id=thread.ident,
thread_name=thread.name,
start_time=time.time(),
last_heartbeat=time.time()
)
self.monitored_threads[thread.ident] = thread_info
if heartbeat_callback:
self.heartbeat_callbacks[thread.ident] = heartbeat_callback
logger.info(f"Registered thread for monitoring: {thread.name} (ID: {thread.ident})")
def unregister_thread(self, thread_id: int):
"""Unregister a thread from monitoring."""
with self._lock:
if thread_id in self.monitored_threads:
thread_name = self.monitored_threads[thread_id].thread_name
del self.monitored_threads[thread_id]
if thread_id in self.heartbeat_callbacks:
del self.heartbeat_callbacks[thread_id]
logger.info(f"Unregistered thread from monitoring: {thread_name} (ID: {thread_id})")
def heartbeat(self, thread_id: Optional[int] = None, activity: Optional[str] = None):
"""
Report thread heartbeat.
Args:
thread_id: Thread ID (uses current thread if None)
activity: Description of current activity
"""
if thread_id is None:
thread_id = threading.current_thread().ident
current_time = time.time()
with self._lock:
if thread_id in self.monitored_threads:
thread_info = self.monitored_threads[thread_id]
thread_info.last_heartbeat = current_time
thread_info.heartbeat_count += 1
thread_info.is_responsive = True
if activity:
thread_info.last_activity = activity
# Report to health monitor
health_monitor.update_metrics(
f"thread_{thread_info.thread_name}",
thread_alive=True,
last_frame_time=current_time
)
def get_thread_info(self, thread_id: int) -> Optional[Dict[str, Any]]:
"""Get information about a monitored thread."""
with self._lock:
if thread_id not in self.monitored_threads:
return None
thread_info = self.monitored_threads[thread_id]
current_time = time.time()
return {
'thread_id': thread_id,
'thread_name': thread_info.thread_name,
'uptime_seconds': current_time - thread_info.start_time,
'last_heartbeat_age': current_time - thread_info.last_heartbeat,
'heartbeat_count': thread_info.heartbeat_count,
'is_responsive': thread_info.is_responsive,
'last_activity': thread_info.last_activity,
'stack_traces': thread_info.stack_traces or []
}
def get_all_thread_info(self) -> Dict[int, Dict[str, Any]]:
"""Get information about all monitored threads."""
with self._lock:
return {
thread_id: self.get_thread_info(thread_id)
for thread_id in self.monitored_threads.keys()
}
def test_thread_responsiveness(self, thread_id: int) -> bool:
"""
Test if a thread is responsive by calling its heartbeat callback.
Args:
thread_id: ID of thread to test
Returns:
True if thread responds within timeout
"""
if thread_id not in self.heartbeat_callbacks:
return True # Can't test if no callback provided
try:
# Call the heartbeat callback with a timeout
callback = self.heartbeat_callbacks[thread_id]
# This is a simple approach - in practice you might want to use
# threading.Timer or asyncio for more sophisticated timeout handling
start_time = time.time()
result = callback()
response_time = time.time() - start_time
with self._lock:
if thread_id in self.monitored_threads:
self.monitored_threads[thread_id].is_responsive = result
if response_time > 5.0: # Slow response
logger.warning(f"Thread {thread_id} slow response: {response_time:.1f}s")
return result
except Exception as e:
logger.error(f"Error testing thread {thread_id} responsiveness: {e}")
with self._lock:
if thread_id in self.monitored_threads:
self.monitored_threads[thread_id].is_responsive = False
return False
def capture_stack_trace(self, thread_id: int) -> Optional[str]:
"""
Capture stack trace for a thread.
Args:
thread_id: ID of thread to capture
Returns:
Stack trace string or None if not available
"""
try:
# Get all frames for all threads
frames = dict(threading._current_frames())
if thread_id not in frames:
return None
# Format stack trace
frame = frames[thread_id]
stack_trace = ''.join(traceback.format_stack(frame))
# Store in thread info
with self._lock:
if thread_id in self.monitored_threads:
thread_info = self.monitored_threads[thread_id]
if thread_info.stack_traces is None:
thread_info.stack_traces = []
thread_info.stack_traces.append(f"{time.time()}: {stack_trace}")
# Keep only last N stack traces
if len(thread_info.stack_traces) > self.stack_trace_count:
thread_info.stack_traces = thread_info.stack_traces[-self.stack_trace_count:]
return stack_trace
except Exception as e:
logger.error(f"Error capturing stack trace for thread {thread_id}: {e}")
return None
def detect_deadlocks(self) -> List[Dict[str, Any]]:
"""
Attempt to detect potential deadlocks by analyzing thread states.
Returns:
List of potential deadlock scenarios
"""
deadlocks = []
current_time = time.time()
with self._lock:
# Look for threads that haven't had heartbeats for a long time
# and are supposedly alive
for thread_id, thread_info in self.monitored_threads.items():
heartbeat_age = current_time - thread_info.last_heartbeat
if heartbeat_age > self.heartbeat_timeout * 2: # Double the timeout
# Check if thread still exists
thread_exists = any(
t.ident == thread_id and t.is_alive()
for t in threading.enumerate()
)
if thread_exists:
# Thread exists but not responding - potential deadlock
stack_trace = self.capture_stack_trace(thread_id)
deadlock_info = {
'thread_id': thread_id,
'thread_name': thread_info.thread_name,
'heartbeat_age': heartbeat_age,
'last_activity': thread_info.last_activity,
'stack_trace': stack_trace,
'detection_time': current_time
}
deadlocks.append(deadlock_info)
logger.warning(f"Potential deadlock detected in thread {thread_info.thread_name}")
return deadlocks
def _responsiveness_test_loop(self):
"""Background loop to test thread responsiveness."""
logger.info("Thread responsiveness testing started")
while True:
try:
time.sleep(self.responsiveness_test_interval)
with self._lock:
thread_ids = list(self.monitored_threads.keys())
for thread_id in thread_ids:
try:
self.test_thread_responsiveness(thread_id)
except Exception as e:
logger.error(f"Error testing thread {thread_id}: {e}")
except Exception as e:
logger.error(f"Error in responsiveness test loop: {e}")
time.sleep(10.0) # Fallback sleep
def _perform_health_checks(self) -> List[HealthCheck]:
"""Perform health checks for all monitored threads."""
checks = []
current_time = time.time()
with self._lock:
for thread_id, thread_info in self.monitored_threads.items():
checks.extend(self._check_thread_health(thread_id, thread_info, current_time))
# Check for deadlocks
deadlocks = self.detect_deadlocks()
for deadlock in deadlocks:
checks.append(HealthCheck(
name=f"deadlock_detection_{deadlock['thread_id']}",
status=HealthStatus.CRITICAL,
message=f"Potential deadlock in thread {deadlock['thread_name']} "
f"(unresponsive for {deadlock['heartbeat_age']:.1f}s)",
details=deadlock,
recovery_action="restart_thread"
))
return checks
def _check_thread_health(self, thread_id: int, thread_info: ThreadInfo, current_time: float) -> List[HealthCheck]:
"""Perform health checks for a single thread."""
checks = []
# Check if thread still exists
thread_exists = any(
t.ident == thread_id and t.is_alive()
for t in threading.enumerate()
)
if not thread_exists:
checks.append(HealthCheck(
name=f"thread_{thread_info.thread_name}_alive",
status=HealthStatus.CRITICAL,
message=f"Thread {thread_info.thread_name} is no longer alive",
details={
'thread_id': thread_id,
'uptime': current_time - thread_info.start_time,
'last_heartbeat': thread_info.last_heartbeat
},
recovery_action="restart_thread"
))
return checks
# Check heartbeat freshness
heartbeat_age = current_time - thread_info.last_heartbeat
if heartbeat_age > self.heartbeat_timeout:
checks.append(HealthCheck(
name=f"thread_{thread_info.thread_name}_responsive",
status=HealthStatus.CRITICAL,
message=f"Thread {thread_info.thread_name} unresponsive for {heartbeat_age:.1f}s",
details={
'thread_id': thread_id,
'heartbeat_age': heartbeat_age,
'heartbeat_count': thread_info.heartbeat_count,
'last_activity': thread_info.last_activity,
'is_responsive': thread_info.is_responsive
},
recovery_action="restart_thread"
))
elif heartbeat_age > self.heartbeat_timeout * 0.5: # Warning at 50% of timeout
checks.append(HealthCheck(
name=f"thread_{thread_info.thread_name}_responsive",
status=HealthStatus.WARNING,
message=f"Thread {thread_info.thread_name} slow heartbeat: {heartbeat_age:.1f}s",
details={
'thread_id': thread_id,
'heartbeat_age': heartbeat_age,
'heartbeat_count': thread_info.heartbeat_count,
'last_activity': thread_info.last_activity,
'is_responsive': thread_info.is_responsive
}
))
# Check responsiveness test results
if not thread_info.is_responsive:
checks.append(HealthCheck(
name=f"thread_{thread_info.thread_name}_callback",
status=HealthStatus.WARNING,
message=f"Thread {thread_info.thread_name} failed responsiveness test",
details={
'thread_id': thread_id,
'last_activity': thread_info.last_activity
}
))
return checks
# Global thread health monitor instance
thread_health_monitor = ThreadHealthMonitor()

10
core/storage/__init__.py Normal file
View file

@ -0,0 +1,10 @@
"""
Storage module for the Python Detector Worker.
This module provides Redis and PostgreSQL operations for data persistence
and caching in the detection pipeline.
"""
from .redis import RedisManager
from .database import DatabaseManager
__all__ = ['RedisManager', 'DatabaseManager']

357
core/storage/database.py Normal file
View file

@ -0,0 +1,357 @@
"""
Database Operations Module.
Handles PostgreSQL operations for the detection pipeline.
"""
import psycopg2
import psycopg2.extras
from typing import Optional, Dict, Any
import logging
import uuid
logger = logging.getLogger(__name__)
class DatabaseManager:
"""
Manages PostgreSQL connections and operations for the detection pipeline.
Handles database operations and schema management.
"""
def __init__(self, config: Dict[str, Any]):
"""
Initialize database manager with configuration.
Args:
config: Database configuration dictionary
"""
self.config = config
self.connection: Optional[psycopg2.extensions.connection] = None
def connect(self) -> bool:
"""
Connect to PostgreSQL database.
Returns:
True if successful, False otherwise
"""
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):
"""Disconnect from PostgreSQL database."""
if self.connection:
self.connection.close()
self.connection = None
logger.info("PostgreSQL connection closed")
def is_connected(self) -> bool:
"""
Check if database connection is active.
Returns:
True if connected, False otherwise
"""
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:
"""
Update car information in the database.
Args:
session_id: Session identifier
brand: Car brand
model: Car model
body_type: Car body type
Returns:
True if successful, False otherwise
"""
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:
"""
Execute a dynamic update query on the database.
Args:
table: Table name
key_field: Primary key field name
key_value: Primary key value
fields: Dictionary of fields to update
Returns:
True if successful, False otherwise
"""
if not self.is_connected():
if not self.connect():
return False
try:
cur = self.connection.cursor()
# Build the UPDATE query dynamically
set_clauses = []
values = []
for field, value in fields.items():
if value == "NOW()":
set_clauses.append(f"{field} = NOW()")
else:
set_clauses.append(f"{field} = %s")
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}"
query = f"""
INSERT INTO {full_table_name} ({key_field}, {', '.join(fields.keys())})
VALUES (%s, {', '.join(['%s'] * len(fields))})
ON CONFLICT ({key_field})
DO UPDATE SET {', '.join(set_clauses)}
"""
# Add key_value to the beginning of values list
all_values = [key_value] + list(fields.values()) + values
cur.execute(query, all_values)
self.connection.commit()
cur.close()
logger.info(f"Updated {table} for {key_field}={key_value}")
return True
except Exception as e:
logger.error(f"Failed to execute update on {table}: {e}")
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.
Returns:
True if successful, False otherwise
"""
if not self.is_connected():
if not self.connect():
return False
try:
# Since the database already exists, just verify connection
cur = self.connection.cursor()
# Simple verification that the table exists
cur.execute("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'gas_station_1'
AND table_name = 'car_frontal_info'
)
""")
table_exists = cur.fetchone()[0]
cur.close()
if table_exists:
logger.info("Verified car_frontal_info table exists")
return True
else:
logger.error("car_frontal_info table does not exist in the database")
return False
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.
Args:
display_id: Display identifier
captured_timestamp: Timestamp of the detection
session_id: Optional session ID, generates one if not provided
Returns:
Session ID string or None on error
"""
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
def get_session_info(self, session_id: str) -> Optional[Dict[str, Any]]:
"""
Get session information from the database.
Args:
session_id: Session identifier
Returns:
Dictionary with session data or None if not found
"""
if not self.is_connected():
if not self.connect():
return None
try:
cur = self.connection.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
query = "SELECT * FROM gas_station_1.car_frontal_info WHERE session_id = %s"
cur.execute(query, (session_id,))
result = cur.fetchone()
cur.close()
if result:
return dict(result)
else:
logger.debug(f"No session info found for session_id: {session_id}")
return None
except Exception as e:
logger.error(f"Failed to get session info: {e}")
return None
def delete_session(self, session_id: str) -> bool:
"""
Delete session record from the database.
Args:
session_id: Session identifier
Returns:
True if successful, False otherwise
"""
if not self.is_connected():
if not self.connect():
return False
try:
cur = self.connection.cursor()
query = "DELETE FROM gas_station_1.car_frontal_info WHERE session_id = %s"
cur.execute(query, (session_id,))
rows_affected = cur.rowcount
self.connection.commit()
cur.close()
if rows_affected > 0:
logger.info(f"Deleted session record: {session_id}")
return True
else:
logger.warning(f"No session record found to delete: {session_id}")
return False
except Exception as e:
logger.error(f"Failed to delete session: {e}")
if self.connection:
self.connection.rollback()
return False
def get_statistics(self) -> Dict[str, Any]:
"""
Get database statistics.
Returns:
Dictionary with database statistics
"""
stats = {
'connected': self.is_connected(),
'host': self.config.get('host', 'unknown'),
'port': self.config.get('port', 'unknown'),
'database': self.config.get('database', 'unknown')
}
if self.is_connected():
try:
cur = self.connection.cursor()
# Get table record count
cur.execute("SELECT COUNT(*) FROM gas_station_1.car_frontal_info")
stats['total_records'] = cur.fetchone()[0]
# Get recent records count (last hour)
cur.execute("""
SELECT COUNT(*) FROM gas_station_1.car_frontal_info
WHERE created_at > NOW() - INTERVAL '1 hour'
""")
stats['recent_records'] = cur.fetchone()[0]
cur.close()
except Exception as e:
logger.warning(f"Failed to get database statistics: {e}")
stats['error'] = str(e)
return stats

View file

@ -0,0 +1,300 @@
"""
License Plate Manager Module.
Handles Redis subscription to license plate results from LPR service.
"""
import logging
import json
import asyncio
from typing import Dict, Optional, Any, Callable
import redis.asyncio as redis
logger = logging.getLogger(__name__)
class LicensePlateManager:
"""
Manages license plate result subscription from Redis channel.
Subscribes to 'license_results' channel for license plate data from LPR service.
"""
def __init__(self, redis_config: Dict[str, Any]):
"""
Initialize license plate manager with Redis configuration.
Args:
redis_config: Redis configuration dictionary
"""
self.config = redis_config
self.redis_client: Optional[redis.Redis] = None
self.pubsub = None
self.subscription_task = None
self.callback = None
# Connection parameters
self.host = redis_config.get('host', 'localhost')
self.port = redis_config.get('port', 6379)
self.password = redis_config.get('password')
self.db = redis_config.get('db', 0)
# License plate data cache - store recent results by session_id
self.license_plate_cache: Dict[str, Dict[str, Any]] = {}
self.cache_ttl = 300 # 5 minutes TTL for cached results
logger.info(f"LicensePlateManager initialized for {self.host}:{self.port}")
async def initialize(self, callback: Optional[Callable] = None) -> bool:
"""
Initialize Redis connection and start subscription to license_results channel.
Args:
callback: Optional callback function for processing license plate results
Returns:
True if successful, False otherwise
"""
try:
# Create Redis connection
self.redis_client = redis.Redis(
host=self.host,
port=self.port,
password=self.password,
db=self.db,
decode_responses=True
)
# Test connection
await self.redis_client.ping()
logger.info(f"Connected to Redis for license plate subscription")
# Set callback
self.callback = callback
# Start subscription
await self._start_subscription()
return True
except Exception as e:
logger.error(f"Failed to initialize license plate manager: {e}", exc_info=True)
return False
async def _start_subscription(self):
"""Start Redis subscription to license_results channel."""
try:
if not self.redis_client:
logger.error("Redis client not initialized")
return
# Create pubsub and subscribe
self.pubsub = self.redis_client.pubsub()
await self.pubsub.subscribe('license_results')
logger.info("Subscribed to Redis channel: license_results")
# Start listening task
self.subscription_task = asyncio.create_task(self._listen_for_messages())
except Exception as e:
logger.error(f"Error starting license plate subscription: {e}", exc_info=True)
async def _listen_for_messages(self):
"""Listen for messages on the license_results channel."""
listen_generator = None
try:
if not self.pubsub:
return
listen_generator = self.pubsub.listen()
async for message in listen_generator:
if message['type'] == 'message':
try:
# Log the raw message from Redis channel
logger.info(f"[LICENSE PLATE RAW] Received from 'license_results' channel: {message['data']}")
# Parse the license plate result message
data = json.loads(message['data'])
logger.info(f"[LICENSE PLATE PARSED] Parsed JSON data: {data}")
await self._process_license_plate_result(data)
except json.JSONDecodeError as e:
logger.error(f"[LICENSE PLATE ERROR] Invalid JSON in license plate message: {e}")
logger.error(f"[LICENSE PLATE ERROR] Raw message was: {message['data']}")
except Exception as e:
logger.error(f"Error processing license plate message: {e}", exc_info=True)
except asyncio.CancelledError:
logger.info("License plate subscription task cancelled")
# Don't try to close generator here - let it be handled by the context
# The async generator will be properly closed by the cancellation mechanism
raise # Re-raise to maintain proper cancellation semantics
except Exception as e:
logger.error(f"Error in license plate message listener: {e}", exc_info=True)
# Only attempt cleanup if it's not a cancellation
finally:
# Safe cleanup of async generator
if listen_generator is not None:
try:
# Check if we can safely close without conflicting with ongoing operations
if hasattr(listen_generator, 'aclose') and not asyncio.current_task().cancelled():
await listen_generator.aclose()
except (RuntimeError, AttributeError) as e:
# Generator is already closing or in invalid state - safe to ignore
logger.debug(f"Generator cleanup skipped (safe): {e}")
except Exception as e:
logger.debug(f"Generator cleanup error (non-critical): {e}")
async def _process_license_plate_result(self, data: Dict[str, Any]):
"""
Process incoming license plate result from LPR service.
Expected message format (from actual LPR service):
{
"session_id": "511",
"license_character": "ข3184"
}
or
{
"session_id": "508",
"display_id": "test3",
"license_plate_text": "ABC-123",
"confidence": 0.95,
"timestamp": "2025-09-24T21:10:00Z"
}
Args:
data: License plate result data
"""
try:
session_id = data.get('session_id')
if not session_id:
logger.warning("License plate result missing session_id")
return
# Handle different message formats
# Format 1: {"session_id": "511", "license_character": "ข3184"}
# Format 2: {"session_id": "508", "license_plate_text": "ABC-123", "confidence": 0.95, ...}
license_plate_text = data.get('license_plate_text') or data.get('license_character')
confidence = data.get('confidence', 1.0) # Default confidence for LPR service results
display_id = data.get('display_id', '')
timestamp = data.get('timestamp', '')
logger.info(f"[LICENSE PLATE] Received result for session {session_id}: "
f"text='{license_plate_text}', confidence={confidence:.3f}")
# Store in cache
self.license_plate_cache[session_id] = {
'license_plate_text': license_plate_text,
'confidence': confidence,
'display_id': display_id,
'timestamp': timestamp,
'received_at': asyncio.get_event_loop().time()
}
# Call callback if provided
if self.callback:
await self.callback(session_id, {
'license_plate_text': license_plate_text,
'confidence': confidence,
'display_id': display_id,
'timestamp': timestamp
})
except Exception as e:
logger.error(f"Error processing license plate result: {e}", exc_info=True)
def get_license_plate_result(self, session_id: str) -> Optional[Dict[str, Any]]:
"""
Get cached license plate result for a session.
Args:
session_id: Session identifier
Returns:
License plate result dictionary or None if not found
"""
if session_id not in self.license_plate_cache:
return None
result = self.license_plate_cache[session_id]
# Check TTL
current_time = asyncio.get_event_loop().time()
if current_time - result.get('received_at', 0) > self.cache_ttl:
# Expired, remove from cache
del self.license_plate_cache[session_id]
return None
return {
'license_plate_text': result.get('license_plate_text'),
'confidence': result.get('confidence'),
'display_id': result.get('display_id'),
'timestamp': result.get('timestamp')
}
def cleanup_expired_results(self):
"""Remove expired license plate results from cache."""
try:
current_time = asyncio.get_event_loop().time()
expired_sessions = []
for session_id, result in self.license_plate_cache.items():
if current_time - result.get('received_at', 0) > self.cache_ttl:
expired_sessions.append(session_id)
for session_id in expired_sessions:
del self.license_plate_cache[session_id]
logger.debug(f"Removed expired license plate result for session {session_id}")
except Exception as e:
logger.error(f"Error cleaning up expired license plate results: {e}", exc_info=True)
async def close(self):
"""Close Redis connection and cleanup resources."""
try:
# Cancel subscription task first
if self.subscription_task and not self.subscription_task.done():
self.subscription_task.cancel()
try:
await self.subscription_task
except asyncio.CancelledError:
logger.debug("License plate subscription task cancelled successfully")
except Exception as e:
logger.warning(f"Error waiting for subscription task cancellation: {e}")
# Close pubsub connection properly
if self.pubsub:
try:
# First unsubscribe from channels
await self.pubsub.unsubscribe('license_results')
# Then close the pubsub connection
await self.pubsub.aclose()
except Exception as e:
logger.warning(f"Error closing pubsub connection: {e}")
finally:
self.pubsub = None
# Close Redis connection
if self.redis_client:
try:
await self.redis_client.aclose()
except Exception as e:
logger.warning(f"Error closing Redis connection: {e}")
finally:
self.redis_client = None
# Clear cache
self.license_plate_cache.clear()
logger.info("License plate manager closed successfully")
except Exception as e:
logger.error(f"Error closing license plate manager: {e}", exc_info=True)
def get_statistics(self) -> Dict[str, Any]:
"""Get license plate manager statistics."""
return {
'cached_results': len(self.license_plate_cache),
'connected': self.redis_client is not None,
'subscribed': self.pubsub is not None,
'host': self.host,
'port': self.port
}

478
core/storage/redis.py Normal file
View file

@ -0,0 +1,478 @@
"""
Redis Operations Module.
Handles Redis connections, image storage, and pub/sub messaging.
"""
import logging
import json
import time
from typing import Optional, Dict, Any, Union
import asyncio
import cv2
import numpy as np
import redis.asyncio as redis
from redis.exceptions import ConnectionError, TimeoutError
logger = logging.getLogger(__name__)
class RedisManager:
"""
Manages Redis connections and operations for the detection pipeline.
Handles image storage with region cropping and pub/sub messaging.
"""
def __init__(self, redis_config: Dict[str, Any]):
"""
Initialize Redis manager with configuration.
Args:
redis_config: Redis configuration dictionary
"""
self.config = redis_config
self.redis_client: Optional[redis.Redis] = None
# Connection parameters
self.host = redis_config.get('host', 'localhost')
self.port = redis_config.get('port', 6379)
self.password = redis_config.get('password')
self.db = redis_config.get('db', 0)
self.decode_responses = redis_config.get('decode_responses', True)
# Connection pool settings
self.max_connections = redis_config.get('max_connections', 10)
self.socket_timeout = redis_config.get('socket_timeout', 5)
self.socket_connect_timeout = redis_config.get('socket_connect_timeout', 5)
self.health_check_interval = redis_config.get('health_check_interval', 30)
# Statistics
self.stats = {
'images_stored': 0,
'messages_published': 0,
'connection_errors': 0,
'operations_successful': 0,
'operations_failed': 0
}
logger.info(f"RedisManager initialized for {self.host}:{self.port}")
async def initialize(self) -> bool:
"""
Initialize Redis connection and test connectivity.
Returns:
True if successful, False otherwise
"""
try:
# Validate configuration
if not self._validate_config():
return False
# Create Redis connection
self.redis_client = redis.Redis(
host=self.host,
port=self.port,
password=self.password,
db=self.db,
decode_responses=self.decode_responses,
max_connections=self.max_connections,
socket_timeout=self.socket_timeout,
socket_connect_timeout=self.socket_connect_timeout,
health_check_interval=self.health_check_interval
)
# Test connection
await self.redis_client.ping()
logger.info(f"Successfully connected to Redis at {self.host}:{self.port}")
return True
except ConnectionError as e:
logger.error(f"Failed to connect to Redis: {e}")
self.stats['connection_errors'] += 1
return False
except Exception as e:
logger.error(f"Error initializing Redis connection: {e}", exc_info=True)
self.stats['connection_errors'] += 1
return False
def _validate_config(self) -> bool:
"""
Validate Redis configuration parameters.
Returns:
True if valid, False otherwise
"""
required_fields = ['host', 'port']
for field in required_fields:
if field not in self.config:
logger.error(f"Missing required Redis config field: {field}")
return False
if not isinstance(self.port, int) or self.port <= 0:
logger.error(f"Invalid Redis port: {self.port}")
return False
return True
async def is_connected(self) -> bool:
"""
Check if Redis connection is active.
Returns:
True if connected, False otherwise
"""
try:
if self.redis_client:
await self.redis_client.ping()
return True
except Exception:
pass
return False
async def save_image(self,
key: str,
image: np.ndarray,
expire_seconds: Optional[int] = None,
image_format: str = 'jpeg',
quality: int = 90) -> bool:
"""
Save image to Redis with optional expiration.
Args:
key: Redis key for the image
image: Image array to save
expire_seconds: Optional expiration time in seconds
image_format: Image format ('jpeg' or 'png')
quality: JPEG quality (1-100)
Returns:
True if successful, False otherwise
"""
try:
if not self.redis_client:
logger.error("Redis client not initialized")
self.stats['operations_failed'] += 1
return False
# Encode image
encoded_image = self._encode_image(image, image_format, quality)
if encoded_image is None:
logger.error("Failed to encode image")
self.stats['operations_failed'] += 1
return False
# Save to Redis
if expire_seconds:
await self.redis_client.setex(key, expire_seconds, encoded_image)
logger.debug(f"Saved image to Redis with key: {key} (expires in {expire_seconds}s)")
else:
await self.redis_client.set(key, encoded_image)
logger.debug(f"Saved image to Redis with key: {key}")
self.stats['images_stored'] += 1
self.stats['operations_successful'] += 1
return True
except Exception as e:
logger.error(f"Error saving image to Redis: {e}", exc_info=True)
self.stats['operations_failed'] += 1
return False
async def get_image(self, key: str) -> Optional[np.ndarray]:
"""
Retrieve image from Redis.
Args:
key: Redis key for the image
Returns:
Image array or None if not found
"""
try:
if not self.redis_client:
logger.error("Redis client not initialized")
self.stats['operations_failed'] += 1
return None
# Get image data from Redis
image_data = await self.redis_client.get(key)
if image_data is None:
logger.debug(f"Image not found for key: {key}")
return None
# Decode image
image_array = np.frombuffer(image_data, np.uint8)
image = cv2.imdecode(image_array, cv2.IMREAD_COLOR)
if image is not None:
logger.debug(f"Retrieved image from Redis with key: {key}")
self.stats['operations_successful'] += 1
return image
else:
logger.error(f"Failed to decode image for key: {key}")
self.stats['operations_failed'] += 1
return None
except Exception as e:
logger.error(f"Error retrieving image from Redis: {e}", exc_info=True)
self.stats['operations_failed'] += 1
return None
async def delete_image(self, key: str) -> bool:
"""
Delete image from Redis.
Args:
key: Redis key for the image
Returns:
True if successful, False otherwise
"""
try:
if not self.redis_client:
logger.error("Redis client not initialized")
self.stats['operations_failed'] += 1
return False
result = await self.redis_client.delete(key)
if result > 0:
logger.debug(f"Deleted image from Redis with key: {key}")
self.stats['operations_successful'] += 1
return True
else:
logger.debug(f"Image not found for deletion: {key}")
return False
except Exception as e:
logger.error(f"Error deleting image from Redis: {e}", exc_info=True)
self.stats['operations_failed'] += 1
return False
async def publish_message(self, channel: str, message: Union[str, Dict]) -> int:
"""
Publish message to Redis channel.
Args:
channel: Redis channel name
message: Message to publish (string or dict)
Returns:
Number of subscribers that received the message, -1 on error
"""
try:
if not self.redis_client:
logger.error("Redis client not initialized")
self.stats['operations_failed'] += 1
return -1
# Convert dict to JSON string if needed
if isinstance(message, dict):
message_str = json.dumps(message)
else:
message_str = str(message)
# Test connection before publishing
await self.redis_client.ping()
# Publish message
result = await self.redis_client.publish(channel, message_str)
logger.info(f"Published message to Redis channel '{channel}': {message_str}")
logger.info(f"Redis publish result (subscribers count): {result}")
if result == 0:
logger.warning(f"No subscribers listening to channel '{channel}'")
else:
logger.info(f"Message delivered to {result} subscriber(s)")
self.stats['messages_published'] += 1
self.stats['operations_successful'] += 1
return result
except Exception as e:
logger.error(f"Error publishing message to Redis: {e}", exc_info=True)
self.stats['operations_failed'] += 1
return -1
async def subscribe_to_channel(self, channel: str, callback=None):
"""
Subscribe to Redis channel (for future use).
Args:
channel: Redis channel name
callback: Optional callback function for messages
"""
try:
if not self.redis_client:
logger.error("Redis client not initialized")
return
pubsub = self.redis_client.pubsub()
await pubsub.subscribe(channel)
logger.info(f"Subscribed to Redis channel: {channel}")
if callback:
async for message in pubsub.listen():
if message['type'] == 'message':
try:
await callback(message['data'])
except Exception as e:
logger.error(f"Error in message callback: {e}")
except Exception as e:
logger.error(f"Error subscribing to Redis channel: {e}", exc_info=True)
async def set_key(self, key: str, value: Union[str, bytes], expire_seconds: Optional[int] = None) -> bool:
"""
Set a key-value pair in Redis.
Args:
key: Redis key
value: Value to store
expire_seconds: Optional expiration time in seconds
Returns:
True if successful, False otherwise
"""
try:
if not self.redis_client:
logger.error("Redis client not initialized")
self.stats['operations_failed'] += 1
return False
if expire_seconds:
await self.redis_client.setex(key, expire_seconds, value)
else:
await self.redis_client.set(key, value)
logger.debug(f"Set Redis key: {key}")
self.stats['operations_successful'] += 1
return True
except Exception as e:
logger.error(f"Error setting Redis key: {e}", exc_info=True)
self.stats['operations_failed'] += 1
return False
async def get_key(self, key: str) -> Optional[Union[str, bytes]]:
"""
Get value for a Redis key.
Args:
key: Redis key
Returns:
Value or None if not found
"""
try:
if not self.redis_client:
logger.error("Redis client not initialized")
self.stats['operations_failed'] += 1
return None
value = await self.redis_client.get(key)
if value is not None:
logger.debug(f"Retrieved Redis key: {key}")
self.stats['operations_successful'] += 1
return value
except Exception as e:
logger.error(f"Error getting Redis key: {e}", exc_info=True)
self.stats['operations_failed'] += 1
return None
async def delete_key(self, key: str) -> bool:
"""
Delete a Redis key.
Args:
key: Redis key
Returns:
True if successful, False otherwise
"""
try:
if not self.redis_client:
logger.error("Redis client not initialized")
self.stats['operations_failed'] += 1
return False
result = await self.redis_client.delete(key)
if result > 0:
logger.debug(f"Deleted Redis key: {key}")
self.stats['operations_successful'] += 1
return True
else:
logger.debug(f"Redis key not found: {key}")
return False
except Exception as e:
logger.error(f"Error deleting Redis key: {e}", exc_info=True)
self.stats['operations_failed'] += 1
return False
def _encode_image(self, image: np.ndarray, image_format: str, quality: int) -> Optional[bytes]:
"""
Encode image to bytes for Redis storage.
Args:
image: Image array
image_format: Image format ('jpeg' or 'png')
quality: JPEG quality (1-100)
Returns:
Encoded image bytes or None on error
"""
try:
format_lower = image_format.lower()
if format_lower == 'jpeg' or format_lower == 'jpg':
encode_params = [cv2.IMWRITE_JPEG_QUALITY, quality]
success, buffer = cv2.imencode('.jpg', image, encode_params)
elif format_lower == 'png':
success, buffer = cv2.imencode('.png', image)
else:
logger.warning(f"Unknown image format '{image_format}', using JPEG")
encode_params = [cv2.IMWRITE_JPEG_QUALITY, quality]
success, buffer = cv2.imencode('.jpg', image, encode_params)
if success:
return buffer.tobytes()
else:
logger.error(f"Failed to encode image as {image_format}")
return None
except Exception as e:
logger.error(f"Error encoding image: {e}", exc_info=True)
return None
def get_statistics(self) -> Dict[str, Any]:
"""
Get Redis manager statistics.
Returns:
Dictionary with statistics
"""
return {
**self.stats,
'connected': self.redis_client is not None,
'host': self.host,
'port': self.port,
'db': self.db
}
def cleanup(self):
"""Cleanup Redis connection."""
if self.redis_client:
# Note: redis.asyncio doesn't have a synchronous close method
# The connection will be closed when the event loop shuts down
self.redis_client = None
logger.info("Redis connection cleaned up")
async def aclose(self):
"""Async cleanup for Redis connection."""
if self.redis_client:
await self.redis_client.aclose()
self.redis_client = None
logger.info("Redis connection closed")

View file

@ -0,0 +1,26 @@
"""
Streaming system for RTSP and HTTP camera feeds.
Provides modular frame readers, buffers, and stream management.
"""
from .readers import HTTPSnapshotReader, FFmpegRTSPReader
from .buffers import FrameBuffer, CacheBuffer, shared_frame_buffer, shared_cache_buffer
from .manager import StreamManager, StreamConfig, SubscriptionInfo, shared_stream_manager, initialize_stream_manager
__all__ = [
# Readers
'HTTPSnapshotReader',
'FFmpegRTSPReader',
# Buffers
'FrameBuffer',
'CacheBuffer',
'shared_frame_buffer',
'shared_cache_buffer',
# Manager
'StreamManager',
'StreamConfig',
'SubscriptionInfo',
'shared_stream_manager',
'initialize_stream_manager'
]

295
core/streaming/buffers.py Normal file
View file

@ -0,0 +1,295 @@
"""
Frame buffering and caching system optimized for different stream formats.
Supports 1280x720 RTSP streams and 2560x1440 HTTP snapshots.
"""
import threading
import time
import cv2
import logging
import numpy as np
from typing import Optional, Dict, Any, Tuple
from collections import defaultdict
logger = logging.getLogger(__name__)
class FrameBuffer:
"""Thread-safe frame buffer for all camera streams."""
def __init__(self, max_age_seconds: int = 5):
self.max_age_seconds = max_age_seconds
self._frames: Dict[str, Dict[str, Any]] = {}
self._lock = threading.RLock()
def put_frame(self, camera_id: str, frame: np.ndarray):
"""Store a frame for the given camera ID."""
with self._lock:
# Validate frame
if not self._validate_frame(frame):
logger.warning(f"Frame validation failed for camera {camera_id}")
return
self._frames[camera_id] = {
'frame': frame.copy(),
'timestamp': time.time(),
'shape': frame.shape,
'dtype': str(frame.dtype),
'size_mb': frame.nbytes / (1024 * 1024)
}
def get_frame(self, camera_id: str) -> Optional[np.ndarray]:
"""Get the latest frame for the given camera ID."""
with self._lock:
if camera_id not in self._frames:
return None
frame_data = self._frames[camera_id]
# Return frame regardless of age - frames persist until replaced
return frame_data['frame'].copy()
def get_frame_info(self, camera_id: str) -> Optional[Dict[str, Any]]:
"""Get frame metadata without copying the frame data."""
with self._lock:
if camera_id not in self._frames:
return None
frame_data = self._frames[camera_id]
age = time.time() - frame_data['timestamp']
# Return frame info regardless of age - frames persist until replaced
return {
'timestamp': frame_data['timestamp'],
'age': age,
'shape': frame_data['shape'],
'dtype': frame_data['dtype'],
'size_mb': frame_data.get('size_mb', 0)
}
def has_frame(self, camera_id: str) -> bool:
"""Check if a valid frame exists for the camera."""
return self.get_frame_info(camera_id) is not None
def clear_camera(self, camera_id: str):
"""Remove all frames for a specific camera."""
with self._lock:
if camera_id in self._frames:
del self._frames[camera_id]
logger.debug(f"Cleared frames for camera {camera_id}")
def clear_all(self):
"""Clear all stored frames."""
with self._lock:
count = len(self._frames)
self._frames.clear()
logger.debug(f"Cleared all frames ({count} cameras)")
def get_camera_list(self) -> list:
"""Get list of cameras with frames - all frames persist until replaced."""
with self._lock:
# Return all cameras that have frames - no age-based filtering
return list(self._frames.keys())
def get_stats(self) -> Dict[str, Any]:
"""Get buffer statistics."""
with self._lock:
current_time = time.time()
stats = {
'total_cameras': len(self._frames),
'recent_cameras': 0,
'stale_cameras': 0,
'total_memory_mb': 0,
'cameras': {}
}
for camera_id, frame_data in self._frames.items():
age = current_time - frame_data['timestamp']
size_mb = frame_data.get('size_mb', 0)
# All frames are valid/available, but categorize by freshness for monitoring
if age <= self.max_age_seconds:
stats['recent_cameras'] += 1
else:
stats['stale_cameras'] += 1
stats['total_memory_mb'] += size_mb
stats['cameras'][camera_id] = {
'age': age,
'recent': age <= self.max_age_seconds, # Recent but all frames available
'shape': frame_data['shape'],
'dtype': frame_data['dtype'],
'size_mb': size_mb
}
return stats
def _validate_frame(self, frame: np.ndarray) -> bool:
"""Validate frame - basic validation for any stream type."""
if frame is None or frame.size == 0:
return False
h, w = frame.shape[:2]
size_mb = frame.nbytes / (1024 * 1024)
# Basic size validation - reject extremely large frames regardless of type
max_size_mb = 50 # Generous limit for any frame type
if size_mb > max_size_mb:
logger.warning(f"Frame too large: {size_mb:.2f}MB (max {max_size_mb}MB) for {w}x{h}")
return False
# Basic dimension validation
if w < 100 or h < 100:
logger.warning(f"Frame too small: {w}x{h}")
return False
return True
class CacheBuffer:
"""Enhanced frame cache with support for cropping."""
def __init__(self, max_age_seconds: int = 10):
self.frame_buffer = FrameBuffer(max_age_seconds)
self._crop_cache: Dict[str, Dict[str, Any]] = {}
self._cache_lock = threading.RLock()
self.jpeg_quality = 95 # High quality for all frames
def put_frame(self, camera_id: str, frame: np.ndarray):
"""Store a frame and clear any associated crop cache."""
self.frame_buffer.put_frame(camera_id, frame)
# Clear crop cache for this camera since we have a new frame
with self._cache_lock:
keys_to_remove = [key for key in self._crop_cache.keys() if key.startswith(f"{camera_id}_")]
for key in keys_to_remove:
del self._crop_cache[key]
def get_frame(self, camera_id: str, crop_coords: Optional[Tuple[int, int, int, int]] = None) -> Optional[np.ndarray]:
"""Get frame with optional cropping."""
if crop_coords is None:
return self.frame_buffer.get_frame(camera_id)
# Check crop cache first
crop_key = f"{camera_id}_{crop_coords}"
with self._cache_lock:
if crop_key in self._crop_cache:
cache_entry = self._crop_cache[crop_key]
age = time.time() - cache_entry['timestamp']
if age <= self.frame_buffer.max_age_seconds:
return cache_entry['cropped_frame'].copy()
else:
del self._crop_cache[crop_key]
# Get original frame and crop it
original_frame = self.frame_buffer.get_frame(camera_id)
if original_frame is None:
return None
try:
x1, y1, x2, y2 = crop_coords
# Ensure coordinates are within frame bounds
h, w = original_frame.shape[:2]
x1 = max(0, min(x1, w))
y1 = max(0, min(y1, h))
x2 = max(x1, min(x2, w))
y2 = max(y1, min(y2, h))
cropped_frame = original_frame[y1:y2, x1:x2]
# Cache the cropped frame
with self._cache_lock:
# Limit cache size to prevent memory issues
if len(self._crop_cache) > 100:
# Remove oldest entries
oldest_keys = sorted(self._crop_cache.keys(),
key=lambda k: self._crop_cache[k]['timestamp'])[:50]
for key in oldest_keys:
del self._crop_cache[key]
self._crop_cache[crop_key] = {
'cropped_frame': cropped_frame.copy(),
'timestamp': time.time(),
'crop_coords': (x1, y1, x2, y2)
}
return cropped_frame
except Exception as e:
logger.error(f"Error cropping frame for camera {camera_id}: {e}")
return original_frame
def get_frame_as_jpeg(self, camera_id: str, crop_coords: Optional[Tuple[int, int, int, int]] = None,
quality: Optional[int] = None) -> Optional[bytes]:
"""Get frame as JPEG bytes."""
frame = self.get_frame(camera_id, crop_coords)
if frame is None:
return None
try:
# Use specified quality or default
if quality is None:
quality = self.jpeg_quality
# Encode as JPEG with specified quality
encode_params = [cv2.IMWRITE_JPEG_QUALITY, quality]
success, encoded_img = cv2.imencode('.jpg', frame, encode_params)
if success:
jpeg_bytes = encoded_img.tobytes()
logger.debug(f"Encoded JPEG for camera {camera_id}: quality={quality}, size={len(jpeg_bytes)} bytes")
return jpeg_bytes
return None
except Exception as e:
logger.error(f"Error encoding frame as JPEG for camera {camera_id}: {e}")
return None
def has_frame(self, camera_id: str) -> bool:
"""Check if a valid frame exists for the camera."""
return self.frame_buffer.has_frame(camera_id)
def clear_camera(self, camera_id: str):
"""Remove all frames and cache for a specific camera."""
self.frame_buffer.clear_camera(camera_id)
with self._cache_lock:
# Clear crop cache entries for this camera
keys_to_remove = [key for key in self._crop_cache.keys() if key.startswith(f"{camera_id}_")]
for key in keys_to_remove:
del self._crop_cache[key]
def clear_all(self):
"""Clear all stored frames and cache."""
self.frame_buffer.clear_all()
with self._cache_lock:
self._crop_cache.clear()
def get_stats(self) -> Dict[str, Any]:
"""Get comprehensive buffer and cache statistics."""
buffer_stats = self.frame_buffer.get_stats()
with self._cache_lock:
cache_stats = {
'crop_cache_entries': len(self._crop_cache),
'crop_cache_cameras': len(set(key.split('_')[0] for key in self._crop_cache.keys() if '_' in key)),
'crop_cache_memory_mb': sum(
entry['cropped_frame'].nbytes / (1024 * 1024)
for entry in self._crop_cache.values()
)
}
return {
'buffer': buffer_stats,
'cache': cache_stats,
'total_memory_mb': buffer_stats.get('total_memory_mb', 0) + cache_stats.get('crop_cache_memory_mb', 0)
}
# Global shared instances for application use
shared_frame_buffer = FrameBuffer(max_age_seconds=5)
shared_cache_buffer = CacheBuffer(max_age_seconds=10)

722
core/streaming/manager.py Normal file
View file

@ -0,0 +1,722 @@
"""
Stream coordination and lifecycle management.
Optimized for 1280x720@6fps RTSP and 2560x1440 HTTP snapshots.
"""
import logging
import threading
import time
import queue
import asyncio
from typing import Dict, Set, Optional, List, Any
from dataclasses import dataclass
from collections import defaultdict
from .readers import HTTPSnapshotReader, FFmpegRTSPReader
from .buffers import shared_cache_buffer
from ..tracking.integration import TrackingPipelineIntegration
logger = logging.getLogger(__name__)
@dataclass
class StreamConfig:
"""Configuration for a stream."""
camera_id: str
rtsp_url: Optional[str] = None
snapshot_url: Optional[str] = None
snapshot_interval: int = 5000 # milliseconds
max_retries: int = 3
@dataclass
class SubscriptionInfo:
"""Information about a subscription."""
subscription_id: str
camera_id: str
stream_config: StreamConfig
created_at: float
crop_coords: Optional[tuple] = None
model_id: Optional[str] = None
model_url: Optional[str] = None
tracking_integration: Optional[TrackingPipelineIntegration] = None
class StreamManager:
"""Manages multiple camera streams with shared optimization."""
def __init__(self, max_streams: int = 10):
self.max_streams = max_streams
self._streams: Dict[str, Any] = {} # camera_id -> reader instance
self._subscriptions: Dict[str, SubscriptionInfo] = {} # subscription_id -> info
self._camera_subscribers: Dict[str, Set[str]] = defaultdict(set) # camera_id -> set of subscription_ids
self._lock = threading.RLock()
# Fair tracking queue system - per camera queues
self._tracking_queues: Dict[str, queue.Queue] = {} # camera_id -> queue
self._tracking_workers = []
self._stop_workers = threading.Event()
self._dropped_frame_counts: Dict[str, int] = {} # per-camera drop counts
# Round-robin scheduling state
self._camera_list = [] # Ordered list of active cameras
self._camera_round_robin_index = 0
self._round_robin_lock = threading.Lock()
# Start worker threads for tracking processing
num_workers = min(4, max_streams // 2 + 1) # Scale with streams
for i in range(num_workers):
worker = threading.Thread(
target=self._tracking_worker_loop,
name=f"TrackingWorker-{i}",
daemon=True
)
worker.start()
self._tracking_workers.append(worker)
logger.info(f"Started {num_workers} tracking worker threads")
def _ensure_camera_queue(self, camera_id: str):
"""Ensure a tracking queue exists for the camera."""
if camera_id not in self._tracking_queues:
self._tracking_queues[camera_id] = queue.Queue(maxsize=10) # 10 frames per camera
self._dropped_frame_counts[camera_id] = 0
with self._round_robin_lock:
if camera_id not in self._camera_list:
self._camera_list.append(camera_id)
logger.info(f"Created tracking queue for camera {camera_id}")
else:
logger.debug(f"Camera {camera_id} already has tracking queue")
def _remove_camera_queue(self, camera_id: str):
"""Remove tracking queue for a camera that's no longer active."""
if camera_id in self._tracking_queues:
# Clear any remaining items
while not self._tracking_queues[camera_id].empty():
try:
self._tracking_queues[camera_id].get_nowait()
except queue.Empty:
break
del self._tracking_queues[camera_id]
del self._dropped_frame_counts[camera_id]
with self._round_robin_lock:
if camera_id in self._camera_list:
self._camera_list.remove(camera_id)
# Reset index if needed
if self._camera_round_robin_index >= len(self._camera_list):
self._camera_round_robin_index = 0
logger.info(f"Removed tracking queue for camera {camera_id}")
def add_subscription(self, subscription_id: str, stream_config: StreamConfig,
crop_coords: Optional[tuple] = None,
model_id: Optional[str] = None,
model_url: Optional[str] = None,
tracking_integration: Optional[TrackingPipelineIntegration] = None) -> bool:
"""Add a new subscription. Returns True if successful."""
with self._lock:
if subscription_id in self._subscriptions:
logger.warning(f"Subscription {subscription_id} already exists")
return False
camera_id = stream_config.camera_id
# Create subscription info
subscription_info = SubscriptionInfo(
subscription_id=subscription_id,
camera_id=camera_id,
stream_config=stream_config,
created_at=time.time(),
crop_coords=crop_coords,
model_id=model_id,
model_url=model_url,
tracking_integration=tracking_integration
)
# Pass subscription info to tracking integration for snapshot access
if tracking_integration:
tracking_integration.set_subscription_info(subscription_info)
self._subscriptions[subscription_id] = subscription_info
self._camera_subscribers[camera_id].add(subscription_id)
# Start stream if not already running
if camera_id not in self._streams:
if len(self._streams) >= self.max_streams:
logger.error(f"Maximum streams ({self.max_streams}) reached, cannot add {camera_id}")
self._remove_subscription_internal(subscription_id)
return False
success = self._start_stream(camera_id, stream_config)
if not success:
self._remove_subscription_internal(subscription_id)
return False
else:
# Stream already exists, but ensure queue exists too
logger.info(f"Stream already exists for {camera_id}, ensuring queue exists")
self._ensure_camera_queue(camera_id)
logger.info(f"Added subscription {subscription_id} for camera {camera_id} "
f"({len(self._camera_subscribers[camera_id])} total subscribers)")
return True
def remove_subscription(self, subscription_id: str) -> bool:
"""Remove a subscription. Returns True if found and removed."""
with self._lock:
return self._remove_subscription_internal(subscription_id)
def _remove_subscription_internal(self, subscription_id: str) -> bool:
"""Internal method to remove subscription (assumes lock is held)."""
if subscription_id not in self._subscriptions:
logger.warning(f"Subscription {subscription_id} not found")
return False
subscription_info = self._subscriptions[subscription_id]
camera_id = subscription_info.camera_id
# Remove from tracking
del self._subscriptions[subscription_id]
self._camera_subscribers[camera_id].discard(subscription_id)
# Stop stream if no more subscribers
if not self._camera_subscribers[camera_id]:
self._stop_stream(camera_id)
del self._camera_subscribers[camera_id]
logger.info(f"Removed subscription {subscription_id} for camera {camera_id} "
f"({len(self._camera_subscribers[camera_id])} remaining subscribers)")
return True
def _start_stream(self, camera_id: str, stream_config: StreamConfig) -> bool:
"""Start a stream for the given camera."""
try:
if stream_config.rtsp_url:
# RTSP stream using FFmpeg subprocess with CUDA acceleration
logger.info(f"\033[94m[RTSP] Starting {camera_id}\033[0m")
reader = FFmpegRTSPReader(
camera_id=camera_id,
rtsp_url=stream_config.rtsp_url,
max_retries=stream_config.max_retries
)
reader.set_frame_callback(self._frame_callback)
reader.start()
self._streams[camera_id] = reader
self._ensure_camera_queue(camera_id) # Create tracking queue
logger.info(f"\033[92m[RTSP] {camera_id} connected\033[0m")
elif stream_config.snapshot_url:
# HTTP snapshot stream
logger.info(f"\033[95m[HTTP] Starting {camera_id}\033[0m")
reader = HTTPSnapshotReader(
camera_id=camera_id,
snapshot_url=stream_config.snapshot_url,
interval_ms=stream_config.snapshot_interval,
max_retries=stream_config.max_retries
)
reader.set_frame_callback(self._frame_callback)
reader.start()
self._streams[camera_id] = reader
self._ensure_camera_queue(camera_id) # Create tracking queue
logger.info(f"\033[92m[HTTP] {camera_id} connected\033[0m")
else:
logger.error(f"No valid URL provided for camera {camera_id}")
return False
return True
except Exception as e:
logger.error(f"Error starting stream for camera {camera_id}: {e}")
return False
def _stop_stream(self, camera_id: str):
"""Stop a stream for the given camera."""
if camera_id in self._streams:
try:
self._streams[camera_id].stop()
del self._streams[camera_id]
self._remove_camera_queue(camera_id) # Remove tracking queue
# DON'T clear frames - they should persist until replaced
# shared_cache_buffer.clear_camera(camera_id) # REMOVED - frames should persist
logger.info(f"Stopped stream for camera {camera_id} (frames preserved in buffer)")
except Exception as e:
logger.error(f"Error stopping stream for camera {camera_id}: {e}")
def _frame_callback(self, camera_id: str, frame):
"""Callback for when a new frame is available."""
try:
# Store frame in shared buffer
shared_cache_buffer.put_frame(camera_id, frame)
# Quieter frame callback logging - only log occasionally
if hasattr(self, '_frame_log_count'):
self._frame_log_count += 1
else:
self._frame_log_count = 1
# Log every 100 frames to avoid spam
if self._frame_log_count % 100 == 0:
available_cameras = shared_cache_buffer.frame_buffer.get_camera_list()
logger.info(f"\033[96m[BUFFER] {len(available_cameras)} active cameras: {', '.join(available_cameras)}\033[0m")
# Queue for tracking processing (non-blocking) - route to camera-specific queue
if camera_id in self._tracking_queues:
try:
self._tracking_queues[camera_id].put_nowait({
'frame': frame,
'timestamp': time.time()
})
except queue.Full:
# Drop frame if camera queue is full (maintain real-time)
self._dropped_frame_counts[camera_id] += 1
if self._dropped_frame_counts[camera_id] % 50 == 0:
logger.warning(f"Dropped {self._dropped_frame_counts[camera_id]} frames for camera {camera_id} due to full queue")
except Exception as e:
logger.error(f"Error in frame callback for camera {camera_id}: {e}")
def _process_tracking_for_camera(self, camera_id: str, frame):
"""Process tracking for all subscriptions of a camera."""
try:
with self._lock:
for subscription_id in self._camera_subscribers[camera_id]:
subscription_info = self._subscriptions[subscription_id]
# Skip if no tracking integration
if not subscription_info.tracking_integration:
continue
# Extract display_id from subscription_id
display_id = subscription_id.split(';')[0] if ';' in subscription_id else subscription_id
# Process frame through tracking asynchronously
# Note: This is synchronous for now, can be made async in future
try:
# Create a simple asyncio event loop for this frame
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
result = loop.run_until_complete(
subscription_info.tracking_integration.process_frame(
frame, display_id, subscription_id
)
)
# Log tracking results
if result:
tracked_count = len(result.get('tracked_vehicles', []))
validated_vehicle = result.get('validated_vehicle')
pipeline_result = result.get('pipeline_result')
if tracked_count > 0:
logger.info(f"[Tracking] {camera_id}: {tracked_count} vehicles tracked")
if validated_vehicle:
logger.info(f"[Tracking] {camera_id}: Vehicle {validated_vehicle['track_id']} "
f"validated as {validated_vehicle['state']} "
f"(confidence: {validated_vehicle['confidence']:.2f})")
if pipeline_result:
logger.info(f"[Pipeline] {camera_id}: {pipeline_result.get('status', 'unknown')} - "
f"{pipeline_result.get('message', 'no message')}")
finally:
loop.close()
except Exception as track_e:
logger.error(f"Error in tracking for {subscription_id}: {track_e}")
except Exception as e:
logger.error(f"Error processing tracking for camera {camera_id}: {e}")
def _tracking_worker_loop(self):
"""Worker thread loop for round-robin processing of camera queues."""
logger.info(f"Tracking worker {threading.current_thread().name} started")
consecutive_empty = 0
max_consecutive_empty = 10 # Sleep if all cameras empty this many times
while not self._stop_workers.is_set():
try:
# Get next camera in round-robin fashion
camera_id, item = self._get_next_camera_item()
if camera_id is None:
# No cameras have items, sleep briefly
consecutive_empty += 1
if consecutive_empty >= max_consecutive_empty:
time.sleep(0.1) # Sleep 100ms if nothing to process
consecutive_empty = 0
continue
consecutive_empty = 0 # Reset counter when we find work
frame = item['frame']
timestamp = item['timestamp']
# Check if frame is too old (drop if > 1 second old)
age = time.time() - timestamp
if age > 1.0:
logger.debug(f"Dropping old frame for {camera_id} (age: {age:.2f}s)")
continue
# Process tracking for this camera's frame
self._process_tracking_for_camera_sync(camera_id, frame)
except Exception as e:
logger.error(f"Error in tracking worker: {e}", exc_info=True)
logger.info(f"Tracking worker {threading.current_thread().name} stopped")
def _get_next_camera_item(self):
"""Get next item from camera queues using round-robin scheduling."""
with self._round_robin_lock:
# Get current list of cameras from actual tracking queues (central state)
camera_list = list(self._tracking_queues.keys())
if not camera_list:
return None, None
attempts = 0
max_attempts = len(camera_list)
while attempts < max_attempts:
# Get current camera using round-robin index
if self._camera_round_robin_index >= len(camera_list):
self._camera_round_robin_index = 0
camera_id = camera_list[self._camera_round_robin_index]
# Move to next camera for next call
self._camera_round_robin_index = (self._camera_round_robin_index + 1) % len(camera_list)
# Try to get item from this camera's queue
try:
item = self._tracking_queues[camera_id].get_nowait()
return camera_id, item
except queue.Empty:
pass # Try next camera
attempts += 1
return None, None # All cameras empty
def _process_tracking_for_camera_sync(self, camera_id: str, frame):
"""Synchronous version of tracking processing for worker threads."""
try:
with self._lock:
subscription_ids = list(self._camera_subscribers.get(camera_id, []))
for subscription_id in subscription_ids:
subscription_info = self._subscriptions.get(subscription_id)
if not subscription_info:
logger.warning(f"No subscription info found for {subscription_id}")
continue
if not subscription_info.tracking_integration:
logger.debug(f"No tracking integration for {subscription_id} (camera {camera_id}), skipping inference")
continue
display_id = subscription_id.split(';')[0] if ';' in subscription_id else subscription_id
try:
# Run async tracking in thread's event loop
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
result = loop.run_until_complete(
subscription_info.tracking_integration.process_frame(
frame, display_id, subscription_id
)
)
# Log tracking results
if result:
tracked_count = len(result.get('tracked_vehicles', []))
validated_vehicle = result.get('validated_vehicle')
pipeline_result = result.get('pipeline_result')
if tracked_count > 0:
logger.info(f"[Tracking] {camera_id}: {tracked_count} vehicles tracked")
if validated_vehicle:
logger.info(f"[Tracking] {camera_id}: Vehicle {validated_vehicle['track_id']} "
f"validated as {validated_vehicle['state']} "
f"(confidence: {validated_vehicle['confidence']:.2f})")
if pipeline_result:
logger.info(f"[Pipeline] {camera_id}: {pipeline_result.get('status', 'unknown')} - "
f"{pipeline_result.get('message', 'no message')}")
finally:
loop.close()
except Exception as track_e:
logger.error(f"Error in tracking for {subscription_id}: {track_e}")
except Exception as e:
logger.error(f"Error processing tracking for camera {camera_id}: {e}")
def get_frame(self, camera_id: str, crop_coords: Optional[tuple] = None):
"""Get the latest frame for a camera with optional cropping."""
return shared_cache_buffer.get_frame(camera_id, crop_coords)
def get_frame_as_jpeg(self, camera_id: str, crop_coords: Optional[tuple] = None,
quality: int = 100) -> Optional[bytes]:
"""Get frame as JPEG bytes for HTTP responses with highest quality by default."""
return shared_cache_buffer.get_frame_as_jpeg(camera_id, crop_coords, quality)
def has_frame(self, camera_id: str) -> bool:
"""Check if a frame is available for the camera."""
return shared_cache_buffer.has_frame(camera_id)
def get_subscription_info(self, subscription_id: str) -> Optional[SubscriptionInfo]:
"""Get information about a subscription."""
with self._lock:
return self._subscriptions.get(subscription_id)
def get_camera_subscribers(self, camera_id: str) -> Set[str]:
"""Get all subscription IDs for a camera."""
with self._lock:
return self._camera_subscribers[camera_id].copy()
def get_active_cameras(self) -> List[str]:
"""Get list of cameras with active streams."""
with self._lock:
return list(self._streams.keys())
def get_all_subscriptions(self) -> List[SubscriptionInfo]:
"""Get all active subscriptions."""
with self._lock:
return list(self._subscriptions.values())
def reconcile_subscriptions(self, target_subscriptions: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Reconcile current subscriptions with target list.
Returns summary of changes made.
"""
with self._lock:
current_subscription_ids = set(self._subscriptions.keys())
target_subscription_ids = {sub['subscriptionIdentifier'] for sub in target_subscriptions}
# Find subscriptions to remove and add
to_remove = current_subscription_ids - target_subscription_ids
to_add = target_subscription_ids - current_subscription_ids
# Remove old subscriptions
removed_count = 0
for subscription_id in to_remove:
if self._remove_subscription_internal(subscription_id):
removed_count += 1
# Add new subscriptions
added_count = 0
failed_count = 0
for target_sub in target_subscriptions:
subscription_id = target_sub['subscriptionIdentifier']
if subscription_id in to_add:
success = self._add_subscription_from_payload(subscription_id, target_sub)
if success:
added_count += 1
else:
failed_count += 1
result = {
'removed': removed_count,
'added': added_count,
'failed': failed_count,
'total_active': len(self._subscriptions),
'active_streams': len(self._streams)
}
logger.info(f"Subscription reconciliation: {result}")
return result
def _add_subscription_from_payload(self, subscription_id: str, payload: Dict[str, Any]) -> bool:
"""Add subscription from WebSocket payload format."""
try:
# Extract camera ID from subscription identifier
# Format: "display-001;cam-001" -> camera_id = "cam-001"
camera_id = subscription_id.split(';')[-1]
# Extract crop coordinates if present
crop_coords = None
if all(key in payload for key in ['cropX1', 'cropY1', 'cropX2', 'cropY2']):
crop_coords = (
payload['cropX1'],
payload['cropY1'],
payload['cropX2'],
payload['cropY2']
)
# Create stream configuration
stream_config = StreamConfig(
camera_id=camera_id,
rtsp_url=payload.get('rtspUrl'),
snapshot_url=payload.get('snapshotUrl'),
snapshot_interval=payload.get('snapshotInterval', 5000),
max_retries=3,
)
return self.add_subscription(
subscription_id,
stream_config,
crop_coords,
model_id=payload.get('modelId'),
model_url=payload.get('modelUrl')
)
except Exception as e:
logger.error(f"Error adding subscription from payload {subscription_id}: {e}")
return False
def stop_all(self):
"""Stop all streams and clear all subscriptions."""
# Signal workers to stop
self._stop_workers.set()
# Clear all camera queues
for camera_id, camera_queue in list(self._tracking_queues.items()):
while not camera_queue.empty():
try:
camera_queue.get_nowait()
except queue.Empty:
break
# Wait for workers to finish
for worker in self._tracking_workers:
worker.join(timeout=2.0)
# Clear queue management structures
self._tracking_queues.clear()
self._dropped_frame_counts.clear()
with self._round_robin_lock:
self._camera_list.clear()
self._camera_round_robin_index = 0
logger.info("Stopped all tracking worker threads")
with self._lock:
# Stop all streams
for camera_id in list(self._streams.keys()):
self._stop_stream(camera_id)
# Clear all tracking
self._subscriptions.clear()
self._camera_subscribers.clear()
shared_cache_buffer.clear_all()
logger.info("Stopped all streams and cleared all subscriptions")
def set_session_id(self, display_id: str, session_id: str):
"""Set session ID for tracking integration."""
# Ensure session_id is always a string for consistent type handling
session_id = str(session_id) if session_id is not None else None
with self._lock:
for subscription_info in self._subscriptions.values():
# Check if this subscription matches the display_id
subscription_display_id = subscription_info.subscription_id.split(';')[0]
if subscription_display_id == display_id and subscription_info.tracking_integration:
# Pass the full subscription_id (displayId;cameraId) to the tracking integration
subscription_info.tracking_integration.set_session_id(
display_id,
session_id,
subscription_id=subscription_info.subscription_id
)
logger.debug(f"Set session {session_id} for display {display_id} with subscription {subscription_info.subscription_id}")
def clear_session_id(self, session_id: str):
"""Clear session ID from the specific tracking integration handling this session."""
with self._lock:
# Find the subscription that's handling this session
session_subscription = None
for subscription_info in self._subscriptions.values():
if subscription_info.tracking_integration:
# Check if this integration is handling the given session_id
integration = subscription_info.tracking_integration
if session_id in integration.session_vehicles:
session_subscription = subscription_info
break
if session_subscription and session_subscription.tracking_integration:
session_subscription.tracking_integration.clear_session_id(session_id)
logger.debug(f"Cleared session {session_id} from subscription {session_subscription.subscription_id}")
else:
logger.warning(f"No tracking integration found for session {session_id}, broadcasting to all subscriptions")
# Fallback: broadcast to all (original behavior)
for subscription_info in self._subscriptions.values():
if subscription_info.tracking_integration:
subscription_info.tracking_integration.clear_session_id(session_id)
def set_progression_stage(self, session_id: str, stage: str):
"""Set progression stage for the specific tracking integration handling this session."""
with self._lock:
# Find the subscription that's handling this session
session_subscription = None
for subscription_info in self._subscriptions.values():
if subscription_info.tracking_integration:
# Check if this integration is handling the given session_id
# We need to check the integration's active sessions
integration = subscription_info.tracking_integration
if session_id in integration.session_vehicles:
session_subscription = subscription_info
break
if session_subscription and session_subscription.tracking_integration:
session_subscription.tracking_integration.set_progression_stage(session_id, stage)
logger.debug(f"Set progression stage for session {session_id}: {stage} on subscription {session_subscription.subscription_id}")
else:
logger.warning(f"No tracking integration found for session {session_id}, broadcasting to all subscriptions")
# Fallback: broadcast to all (original behavior)
for subscription_info in self._subscriptions.values():
if subscription_info.tracking_integration:
subscription_info.tracking_integration.set_progression_stage(session_id, stage)
def get_tracking_stats(self) -> Dict[str, Any]:
"""Get tracking statistics from all subscriptions."""
stats = {}
with self._lock:
for subscription_id, subscription_info in self._subscriptions.items():
if subscription_info.tracking_integration:
stats[subscription_id] = subscription_info.tracking_integration.get_statistics()
return stats
def get_stats(self) -> Dict[str, Any]:
"""Get comprehensive streaming statistics."""
with self._lock:
buffer_stats = shared_cache_buffer.get_stats()
tracking_stats = self.get_tracking_stats()
return {
'active_subscriptions': len(self._subscriptions),
'active_streams': len(self._streams),
'cameras_with_subscribers': len(self._camera_subscribers),
'max_streams': self.max_streams,
'subscriptions_by_camera': {
camera_id: len(subscribers)
for camera_id, subscribers in self._camera_subscribers.items()
},
'buffer_stats': buffer_stats,
'tracking_stats': tracking_stats,
'memory_usage_mb': buffer_stats.get('total_memory_mb', 0)
}
# Global shared instance for application use
# Default initialization, will be updated with config value in app.py
shared_stream_manager = StreamManager(max_streams=20)
def initialize_stream_manager(max_streams: int = 10):
"""Re-initialize the global stream manager with config value."""
global shared_stream_manager
# Release old manager if exists
if shared_stream_manager:
try:
# Stop all existing streams gracefully
shared_stream_manager.stop_all()
except Exception as e:
logger.warning(f"Error stopping previous stream manager: {e}")
shared_stream_manager = StreamManager(max_streams=max_streams)
return shared_stream_manager

View file

@ -0,0 +1,18 @@
"""
Stream readers for RTSP and HTTP camera feeds.
"""
from .base import VideoReader
from .ffmpeg_rtsp import FFmpegRTSPReader
from .http_snapshot import HTTPSnapshotReader
from .utils import log_success, log_warning, log_error, log_info, Colors
__all__ = [
'VideoReader',
'FFmpegRTSPReader',
'HTTPSnapshotReader',
'log_success',
'log_warning',
'log_error',
'log_info',
'Colors'
]

View file

@ -0,0 +1,65 @@
"""
Abstract base class for video stream readers.
"""
from abc import ABC, abstractmethod
from typing import Optional, Callable
import numpy as np
class VideoReader(ABC):
"""Abstract base class for video stream readers."""
def __init__(self, camera_id: str, source_url: str, max_retries: int = 3):
"""
Initialize the video reader.
Args:
camera_id: Unique identifier for the camera
source_url: URL or path to the video source
max_retries: Maximum number of retry attempts
"""
self.camera_id = camera_id
self.source_url = source_url
self.max_retries = max_retries
self.frame_callback: Optional[Callable[[str, np.ndarray], None]] = None
@abstractmethod
def start(self) -> None:
"""Start the video reader."""
pass
@abstractmethod
def stop(self) -> None:
"""Stop the video reader."""
pass
@abstractmethod
def set_frame_callback(self, callback: Callable[[str, np.ndarray], None]) -> None:
"""
Set callback function to handle captured frames.
Args:
callback: Function that takes (camera_id, frame) as arguments
"""
pass
@property
@abstractmethod
def is_running(self) -> bool:
"""Check if the reader is currently running."""
pass
@property
@abstractmethod
def reader_type(self) -> str:
"""Get the type of reader (e.g., 'rtsp', 'http_snapshot')."""
pass
def __enter__(self):
"""Context manager entry."""
self.start()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit."""
self.stop()

View file

@ -0,0 +1,436 @@
"""
FFmpeg RTSP stream reader using subprocess piping frames directly to buffer.
Enhanced with comprehensive health monitoring and automatic recovery.
"""
import cv2
import time
import threading
import numpy as np
import subprocess
import struct
from typing import Optional, Callable, Dict, Any
from .base import VideoReader
from .utils import log_success, log_warning, log_error, log_info
from ...monitoring.stream_health import stream_health_tracker
from ...monitoring.thread_health import thread_health_monitor
from ...monitoring.recovery import recovery_manager, RecoveryAction
class FFmpegRTSPReader(VideoReader):
"""RTSP stream reader using subprocess FFmpeg piping frames directly to buffer."""
def __init__(self, camera_id: str, rtsp_url: str, max_retries: int = 3):
super().__init__(camera_id, rtsp_url, max_retries)
self.rtsp_url = rtsp_url
self.process = None
self.stop_event = threading.Event()
self.thread = None
self.stderr_thread = None
# Expected stream specs (for reference, actual dimensions read from PPM header)
self.width = 1280
self.height = 720
# Watchdog timers for stream reliability
self.process_start_time = None
self.last_frame_time = None
self.is_restart = False # Track if this is a restart (shorter timeout)
self.first_start_timeout = 30.0 # 30s timeout on first start
self.restart_timeout = 15.0 # 15s timeout after restart
# Health monitoring setup
self.last_heartbeat = time.time()
self.consecutive_errors = 0
self.ffmpeg_restart_count = 0
# Register recovery handlers
recovery_manager.register_recovery_handler(
RecoveryAction.RESTART_STREAM,
self._handle_restart_recovery
)
recovery_manager.register_recovery_handler(
RecoveryAction.RECONNECT,
self._handle_reconnect_recovery
)
@property
def is_running(self) -> bool:
"""Check if the reader is currently running."""
return self.thread is not None and self.thread.is_alive()
@property
def reader_type(self) -> str:
"""Get the type of reader."""
return "rtsp_ffmpeg"
def set_frame_callback(self, callback: Callable[[str, np.ndarray], None]):
"""Set callback function to handle captured frames."""
self.frame_callback = callback
def start(self):
"""Start the FFmpeg subprocess reader."""
if self.thread and self.thread.is_alive():
log_warning(self.camera_id, "FFmpeg reader already running")
return
self.stop_event.clear()
self.thread = threading.Thread(target=self._read_frames, daemon=True)
self.thread.start()
# Register with health monitoring
stream_health_tracker.register_stream(self.camera_id, "rtsp_ffmpeg", self.rtsp_url)
thread_health_monitor.register_thread(self.thread, self._heartbeat_callback)
log_success(self.camera_id, "Stream started with health monitoring")
def stop(self):
"""Stop the FFmpeg subprocess reader."""
self.stop_event.set()
# Unregister from health monitoring
if self.thread:
thread_health_monitor.unregister_thread(self.thread.ident)
if self.process:
self.process.terminate()
try:
self.process.wait(timeout=5)
except subprocess.TimeoutExpired:
self.process.kill()
if self.thread:
self.thread.join(timeout=5.0)
if self.stderr_thread:
self.stderr_thread.join(timeout=2.0)
stream_health_tracker.unregister_stream(self.camera_id)
log_info(self.camera_id, "Stream stopped")
def _start_ffmpeg_process(self):
"""Start FFmpeg subprocess outputting BMP frames to stdout pipe."""
cmd = [
'ffmpeg',
# DO NOT REMOVE
'-hwaccel', 'cuda',
'-hwaccel_device', '0',
# Real-time input flags
'-fflags', 'nobuffer+genpts',
'-flags', 'low_delay',
'-max_delay', '0', # No reordering delay
# RTSP configuration
'-rtsp_transport', 'tcp',
'-i', self.rtsp_url,
# Output configuration (keeping BMP)
'-f', 'image2pipe', # Output images to pipe
'-vcodec', 'bmp', # BMP format with header containing dimensions
'-vsync', 'passthrough', # Pass frames as-is
# Use native stream resolution and framerate
'-an', # No audio
'-' # Output to stdout
]
try:
# Start FFmpeg with stdout pipe to read frames directly
self.process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE, # Capture stdout for frame data
stderr=subprocess.PIPE, # Capture stderr for error logging
bufsize=0 # Unbuffered for real-time processing
)
# Start stderr reading thread
if self.stderr_thread and self.stderr_thread.is_alive():
# Stop previous stderr thread
try:
self.stderr_thread.join(timeout=1.0)
except:
pass
self.stderr_thread = threading.Thread(target=self._read_stderr, daemon=True)
self.stderr_thread.start()
# Set process start time for watchdog
self.process_start_time = time.time()
self.last_frame_time = None # Reset frame time
# After successful restart, next timeout will be back to 30s
if self.is_restart:
log_info(self.camera_id, f"FFmpeg restarted successfully, next timeout: {self.first_start_timeout}s")
self.is_restart = False
return True
except Exception as e:
log_error(self.camera_id, f"FFmpeg startup failed: {e}")
return False
def _read_bmp_frame(self, pipe):
"""Read BMP frame from pipe - BMP header contains dimensions."""
try:
# Read BMP header (14 bytes file header + 40 bytes info header = 54 bytes minimum)
header_data = b''
bytes_to_read = 54
while len(header_data) < bytes_to_read:
chunk = pipe.read(bytes_to_read - len(header_data))
if not chunk:
return None # Silent end of stream
header_data += chunk
# Parse BMP header
if header_data[:2] != b'BM':
return None # Invalid format, skip frame silently
# Extract file size from header (bytes 2-5)
file_size = struct.unpack('<L', header_data[2:6])[0]
# Extract width and height from info header (bytes 18-21 and 22-25)
width = struct.unpack('<L', header_data[18:22])[0]
height = struct.unpack('<L', header_data[22:26])[0]
# Read remaining file data
remaining_size = file_size - 54
remaining_data = b''
while len(remaining_data) < remaining_size:
chunk = pipe.read(remaining_size - len(remaining_data))
if not chunk:
return None # Stream ended silently
remaining_data += chunk
# Complete BMP data
bmp_data = header_data + remaining_data
# Use OpenCV to decode BMP directly from memory
frame_array = np.frombuffer(bmp_data, dtype=np.uint8)
frame = cv2.imdecode(frame_array, cv2.IMREAD_COLOR)
if frame is None:
return None # Decode failed silently
return frame
except Exception:
return None # Error reading frame silently
def _read_stderr(self):
"""Read and log FFmpeg stderr output in background thread."""
if not self.process or not self.process.stderr:
return
try:
while self.process and self.process.poll() is None:
try:
line = self.process.stderr.readline()
if line:
error_msg = line.decode('utf-8', errors='ignore').strip()
if error_msg and not self.stop_event.is_set():
# Filter out common noise but log actual errors
if any(keyword in error_msg.lower() for keyword in ['error', 'failed', 'cannot', 'invalid']):
log_error(self.camera_id, f"FFmpeg: {error_msg}")
elif 'warning' in error_msg.lower():
log_warning(self.camera_id, f"FFmpeg: {error_msg}")
except Exception:
break
except Exception:
pass
def _check_watchdog_timeout(self) -> bool:
"""Check if watchdog timeout has been exceeded."""
if not self.process_start_time:
return False
current_time = time.time()
time_since_start = current_time - self.process_start_time
# Determine timeout based on whether this is a restart
timeout = self.restart_timeout if self.is_restart else self.first_start_timeout
# If no frames received yet, check against process start time
if not self.last_frame_time:
if time_since_start > timeout:
log_warning(self.camera_id, f"Watchdog timeout: No frames for {time_since_start:.1f}s (limit: {timeout}s)")
return True
else:
# Check time since last frame
time_since_frame = current_time - self.last_frame_time
if time_since_frame > timeout:
log_warning(self.camera_id, f"Watchdog timeout: No frames for {time_since_frame:.1f}s (limit: {timeout}s)")
return True
return False
def _restart_ffmpeg_process(self):
"""Restart FFmpeg process due to watchdog timeout."""
log_warning(self.camera_id, "Watchdog triggered FFmpeg restart")
# Terminate current process
if self.process:
try:
self.process.terminate()
self.process.wait(timeout=3)
except subprocess.TimeoutExpired:
self.process.kill()
except Exception:
pass
self.process = None
# Mark as restart for shorter timeout
self.is_restart = True
# Small delay before restart
time.sleep(1.0)
def _read_frames(self):
"""Read frames directly from FFmpeg stdout pipe."""
frame_count = 0
last_log_time = time.time()
while not self.stop_event.is_set():
try:
# Send heartbeat for thread health monitoring
self._send_heartbeat("reading_frames")
# Check watchdog timeout if process is running
if self.process and self.process.poll() is None:
if self._check_watchdog_timeout():
self._restart_ffmpeg_process()
continue
# Start FFmpeg if not running
if not self.process or self.process.poll() is not None:
if self.process and self.process.poll() is not None:
log_warning(self.camera_id, "Stream disconnected, reconnecting...")
stream_health_tracker.report_error(
self.camera_id,
"FFmpeg process disconnected"
)
if not self._start_ffmpeg_process():
self.consecutive_errors += 1
stream_health_tracker.report_error(
self.camera_id,
"Failed to start FFmpeg process"
)
time.sleep(5.0)
continue
# Read frames directly from FFmpeg stdout
try:
if self.process and self.process.stdout:
# Read BMP frame data
frame = self._read_bmp_frame(self.process.stdout)
if frame is None:
continue
# Update watchdog - we got a frame
self.last_frame_time = time.time()
# Reset error counter on successful frame
self.consecutive_errors = 0
# Report successful frame to health monitoring
frame_size = frame.nbytes
stream_health_tracker.report_frame_received(self.camera_id, frame_size)
# Call frame callback
if self.frame_callback:
try:
self.frame_callback(self.camera_id, frame)
except Exception as e:
stream_health_tracker.report_error(
self.camera_id,
f"Frame callback error: {e}"
)
frame_count += 1
# Log progress every 60 seconds (quieter)
current_time = time.time()
if current_time - last_log_time >= 60:
log_success(self.camera_id, f"{frame_count} frames captured ({frame.shape[1]}x{frame.shape[0]})")
last_log_time = current_time
except Exception as e:
# Process might have died, let it restart on next iteration
stream_health_tracker.report_error(
self.camera_id,
f"Frame reading error: {e}"
)
if self.process:
self.process.terminate()
self.process = None
time.sleep(1.0)
except Exception as e:
stream_health_tracker.report_error(
self.camera_id,
f"Main loop error: {e}"
)
time.sleep(1.0)
# Cleanup
if self.process:
self.process.terminate()
# Health monitoring methods
def _send_heartbeat(self, activity: str = "running"):
"""Send heartbeat to thread health monitor."""
self.last_heartbeat = time.time()
thread_health_monitor.heartbeat(activity=activity)
def _heartbeat_callback(self) -> bool:
"""Heartbeat callback for thread responsiveness testing."""
try:
# Check if thread is responsive by checking recent heartbeat
current_time = time.time()
age = current_time - self.last_heartbeat
# Thread is responsive if heartbeat is recent
return age < 30.0 # 30 second responsiveness threshold
except Exception:
return False
def _handle_restart_recovery(self, component: str, details: Dict[str, Any]) -> bool:
"""Handle restart recovery action."""
try:
log_info(self.camera_id, "Restarting FFmpeg RTSP reader for health recovery")
# Stop current instance
self.stop()
# Small delay
time.sleep(2.0)
# Restart
self.start()
# Report successful restart
stream_health_tracker.report_reconnect(self.camera_id, "health_recovery_restart")
self.ffmpeg_restart_count += 1
return True
except Exception as e:
log_error(self.camera_id, f"Failed to restart FFmpeg RTSP reader: {e}")
return False
def _handle_reconnect_recovery(self, component: str, details: Dict[str, Any]) -> bool:
"""Handle reconnect recovery action."""
try:
log_info(self.camera_id, "Reconnecting FFmpeg RTSP reader for health recovery")
# Force restart FFmpeg process
self._restart_ffmpeg_process()
# Reset error counters
self.consecutive_errors = 0
stream_health_tracker.report_reconnect(self.camera_id, "health_recovery_reconnect")
return True
except Exception as e:
log_error(self.camera_id, f"Failed to reconnect FFmpeg RTSP reader: {e}")
return False

View file

@ -0,0 +1,378 @@
"""
HTTP snapshot reader optimized for 2560x1440 (2K) high quality images.
Enhanced with comprehensive health monitoring and automatic recovery.
"""
import cv2
import logging
import time
import threading
import requests
import numpy as np
from typing import Optional, Callable, Dict, Any
from .base import VideoReader
from .utils import log_success, log_warning, log_error, log_info
from ...monitoring.stream_health import stream_health_tracker
from ...monitoring.thread_health import thread_health_monitor
from ...monitoring.recovery import recovery_manager, RecoveryAction
logger = logging.getLogger(__name__)
class HTTPSnapshotReader(VideoReader):
"""HTTP snapshot reader optimized for 2560x1440 (2K) high quality images."""
def __init__(self, camera_id: str, snapshot_url: str, interval_ms: int = 5000, max_retries: int = 3):
super().__init__(camera_id, snapshot_url, max_retries)
self.snapshot_url = snapshot_url
self.interval_ms = interval_ms
self.stop_event = threading.Event()
self.thread = None
# Expected snapshot specifications
self.expected_width = 2560
self.expected_height = 1440
self.max_file_size = 10 * 1024 * 1024 # 10MB max for 2K image
# Health monitoring setup
self.last_heartbeat = time.time()
self.consecutive_errors = 0
self.connection_test_interval = 300 # Test connection every 5 minutes
self.last_connection_test = None
# Register recovery handlers
recovery_manager.register_recovery_handler(
RecoveryAction.RESTART_STREAM,
self._handle_restart_recovery
)
recovery_manager.register_recovery_handler(
RecoveryAction.RECONNECT,
self._handle_reconnect_recovery
)
@property
def is_running(self) -> bool:
"""Check if the reader is currently running."""
return self.thread is not None and self.thread.is_alive()
@property
def reader_type(self) -> str:
"""Get the type of reader."""
return "http_snapshot"
def set_frame_callback(self, callback: Callable[[str, np.ndarray], None]):
"""Set callback function to handle captured frames."""
self.frame_callback = callback
def start(self):
"""Start the snapshot reader thread."""
if self.thread and self.thread.is_alive():
logger.warning(f"Snapshot reader for {self.camera_id} already running")
return
self.stop_event.clear()
self.thread = threading.Thread(target=self._read_snapshots, daemon=True)
self.thread.start()
# Register with health monitoring
stream_health_tracker.register_stream(self.camera_id, "http_snapshot", self.snapshot_url)
thread_health_monitor.register_thread(self.thread, self._heartbeat_callback)
logger.info(f"Started snapshot reader for camera {self.camera_id} with health monitoring")
def stop(self):
"""Stop the snapshot reader thread."""
self.stop_event.set()
# Unregister from health monitoring
if self.thread:
thread_health_monitor.unregister_thread(self.thread.ident)
self.thread.join(timeout=5.0)
stream_health_tracker.unregister_stream(self.camera_id)
logger.info(f"Stopped snapshot reader for camera {self.camera_id}")
def _read_snapshots(self):
"""Main snapshot reading loop for high quality 2K images."""
retries = 0
frame_count = 0
last_log_time = time.time()
last_connection_test = time.time()
interval_seconds = self.interval_ms / 1000.0
logger.info(f"Snapshot interval for camera {self.camera_id}: {interval_seconds}s")
while not self.stop_event.is_set():
try:
# Send heartbeat for thread health monitoring
self._send_heartbeat("fetching_snapshot")
start_time = time.time()
frame = self._fetch_snapshot()
if frame is None:
retries += 1
self.consecutive_errors += 1
# Report error to health monitoring
stream_health_tracker.report_error(
self.camera_id,
f"Failed to fetch snapshot (retry {retries}/{self.max_retries})"
)
logger.warning(f"Failed to fetch snapshot for camera {self.camera_id}, retry {retries}/{self.max_retries}")
if self.max_retries != -1 and retries > self.max_retries:
logger.error(f"Max retries reached for snapshot camera {self.camera_id}")
break
time.sleep(min(2.0, interval_seconds))
continue
# Accept any valid image dimensions - don't force specific resolution
if frame.shape[1] <= 0 or frame.shape[0] <= 0:
logger.warning(f"Camera {self.camera_id}: Invalid frame dimensions {frame.shape[1]}x{frame.shape[0]}")
stream_health_tracker.report_error(
self.camera_id,
f"Invalid frame dimensions: {frame.shape[1]}x{frame.shape[0]}"
)
continue
# Reset retry counter on successful fetch
retries = 0
self.consecutive_errors = 0
frame_count += 1
# Report successful frame to health monitoring
frame_size = frame.nbytes
stream_health_tracker.report_frame_received(self.camera_id, frame_size)
# Call frame callback
if self.frame_callback:
try:
self.frame_callback(self.camera_id, frame)
except Exception as e:
logger.error(f"Camera {self.camera_id}: Frame callback error: {e}")
stream_health_tracker.report_error(self.camera_id, f"Frame callback error: {e}")
# Periodic connection health test
current_time = time.time()
if current_time - last_connection_test >= self.connection_test_interval:
self._test_connection_health()
last_connection_test = current_time
# Log progress every 30 seconds
if current_time - last_log_time >= 30:
logger.info(f"Camera {self.camera_id}: {frame_count} snapshots processed")
last_log_time = current_time
# Wait for next interval
elapsed = time.time() - start_time
sleep_time = max(0, interval_seconds - elapsed)
if sleep_time > 0:
self.stop_event.wait(sleep_time)
except Exception as e:
logger.error(f"Error in snapshot loop for camera {self.camera_id}: {e}")
stream_health_tracker.report_error(self.camera_id, f"Snapshot loop error: {e}")
retries += 1
if self.max_retries != -1 and retries > self.max_retries:
break
time.sleep(min(2.0, interval_seconds))
logger.info(f"Snapshot reader thread ended for camera {self.camera_id}")
def _fetch_snapshot(self) -> Optional[np.ndarray]:
"""Fetch a single high quality snapshot from HTTP URL."""
try:
# Parse URL for authentication
from urllib.parse import urlparse
parsed_url = urlparse(self.snapshot_url)
headers = {
'User-Agent': 'Python-Detector-Worker/1.0',
'Accept': 'image/jpeg, image/png, image/*'
}
auth = None
if parsed_url.username and parsed_url.password:
from requests.auth import HTTPBasicAuth, HTTPDigestAuth
auth = HTTPBasicAuth(parsed_url.username, parsed_url.password)
# Reconstruct URL without credentials
clean_url = f"{parsed_url.scheme}://{parsed_url.hostname}"
if parsed_url.port:
clean_url += f":{parsed_url.port}"
clean_url += parsed_url.path
if parsed_url.query:
clean_url += f"?{parsed_url.query}"
# Try Basic Auth first
response = requests.get(clean_url, auth=auth, timeout=15, headers=headers,
stream=True, verify=False)
# If Basic Auth fails, try Digest Auth
if response.status_code == 401:
auth = HTTPDigestAuth(parsed_url.username, parsed_url.password)
response = requests.get(clean_url, auth=auth, timeout=15, headers=headers,
stream=True, verify=False)
else:
response = requests.get(self.snapshot_url, timeout=15, headers=headers,
stream=True, verify=False)
if response.status_code == 200:
# Check content size
content_length = int(response.headers.get('content-length', 0))
if content_length > self.max_file_size:
logger.warning(f"Snapshot too large for camera {self.camera_id}: {content_length} bytes")
return None
# Read content
content = response.content
# Convert to numpy array
image_array = np.frombuffer(content, np.uint8)
# Decode as high quality image
frame = cv2.imdecode(image_array, cv2.IMREAD_COLOR)
if frame is None:
logger.error(f"Failed to decode snapshot for camera {self.camera_id}")
return None
logger.debug(f"Fetched snapshot for camera {self.camera_id}: {frame.shape[1]}x{frame.shape[0]}")
return frame
else:
logger.warning(f"HTTP {response.status_code} from {self.camera_id}")
return None
except requests.RequestException as e:
logger.error(f"Request error fetching snapshot for {self.camera_id}: {e}")
return None
except Exception as e:
logger.error(f"Error decoding snapshot for {self.camera_id}: {e}")
return None
def fetch_single_snapshot(self) -> Optional[np.ndarray]:
"""
Fetch a single high-quality snapshot on demand for pipeline processing.
This method is for one-time fetch from HTTP URL, not continuous streaming.
Returns:
High quality 2K snapshot frame or None if failed
"""
logger.info(f"[SNAPSHOT] Fetching snapshot for {self.camera_id} from {self.snapshot_url}")
# Try to fetch snapshot with retries
for attempt in range(self.max_retries):
frame = self._fetch_snapshot()
if frame is not None:
logger.info(f"[SNAPSHOT] Successfully fetched {frame.shape[1]}x{frame.shape[0]} snapshot for {self.camera_id}")
return frame
if attempt < self.max_retries - 1:
logger.warning(f"[SNAPSHOT] Attempt {attempt + 1}/{self.max_retries} failed for {self.camera_id}, retrying...")
time.sleep(0.5)
logger.error(f"[SNAPSHOT] Failed to fetch snapshot for {self.camera_id} after {self.max_retries} attempts")
return None
def _resize_maintain_aspect(self, frame: np.ndarray, target_width: int, target_height: int) -> np.ndarray:
"""Resize image while maintaining aspect ratio for high quality."""
h, w = frame.shape[:2]
aspect = w / h
target_aspect = target_width / target_height
if aspect > target_aspect:
# Image is wider
new_width = target_width
new_height = int(target_width / aspect)
else:
# Image is taller
new_height = target_height
new_width = int(target_height * aspect)
# Use INTER_LANCZOS4 for high quality downsampling
resized = cv2.resize(frame, (new_width, new_height), interpolation=cv2.INTER_LANCZOS4)
# Pad to target size if needed
if new_width < target_width or new_height < target_height:
top = (target_height - new_height) // 2
bottom = target_height - new_height - top
left = (target_width - new_width) // 2
right = target_width - new_width - left
resized = cv2.copyMakeBorder(resized, top, bottom, left, right, cv2.BORDER_CONSTANT, value=[0, 0, 0])
return resized
# Health monitoring methods
def _send_heartbeat(self, activity: str = "running"):
"""Send heartbeat to thread health monitor."""
self.last_heartbeat = time.time()
thread_health_monitor.heartbeat(activity=activity)
def _heartbeat_callback(self) -> bool:
"""Heartbeat callback for thread responsiveness testing."""
try:
# Check if thread is responsive by checking recent heartbeat
current_time = time.time()
age = current_time - self.last_heartbeat
# Thread is responsive if heartbeat is recent
return age < 30.0 # 30 second responsiveness threshold
except Exception:
return False
def _test_connection_health(self):
"""Test HTTP connection health."""
try:
stream_health_tracker.test_http_connection(self.camera_id, self.snapshot_url)
except Exception as e:
logger.error(f"Error testing connection health for {self.camera_id}: {e}")
def _handle_restart_recovery(self, component: str, details: Dict[str, Any]) -> bool:
"""Handle restart recovery action."""
try:
logger.info(f"Restarting HTTP snapshot reader for {self.camera_id}")
# Stop current instance
self.stop()
# Small delay
time.sleep(2.0)
# Restart
self.start()
# Report successful restart
stream_health_tracker.report_reconnect(self.camera_id, "health_recovery_restart")
return True
except Exception as e:
logger.error(f"Failed to restart HTTP snapshot reader for {self.camera_id}: {e}")
return False
def _handle_reconnect_recovery(self, component: str, details: Dict[str, Any]) -> bool:
"""Handle reconnect recovery action."""
try:
logger.info(f"Reconnecting HTTP snapshot reader for {self.camera_id}")
# Test connection first
success = stream_health_tracker.test_http_connection(self.camera_id, self.snapshot_url)
if success:
# Reset error counters
self.consecutive_errors = 0
stream_health_tracker.report_reconnect(self.camera_id, "health_recovery_reconnect")
return True
else:
logger.warning(f"Connection test failed during recovery for {self.camera_id}")
return False
except Exception as e:
logger.error(f"Failed to reconnect HTTP snapshot reader for {self.camera_id}: {e}")
return False

View file

@ -0,0 +1,38 @@
"""
Utility functions for stream readers.
"""
import logging
import os
# Keep OpenCV errors visible but allow FFmpeg stderr logging
os.environ["OPENCV_LOG_LEVEL"] = "ERROR"
logger = logging.getLogger(__name__)
# Color codes for pretty logging
class Colors:
GREEN = '\033[92m'
YELLOW = '\033[93m'
RED = '\033[91m'
BLUE = '\033[94m'
PURPLE = '\033[95m'
CYAN = '\033[96m'
WHITE = '\033[97m'
BOLD = '\033[1m'
END = '\033[0m'
def log_success(camera_id: str, message: str):
"""Log success messages in green"""
logger.info(f"{Colors.GREEN}[{camera_id}] {message}{Colors.END}")
def log_warning(camera_id: str, message: str):
"""Log warnings in yellow"""
logger.warning(f"{Colors.YELLOW}[{camera_id}] {message}{Colors.END}")
def log_error(camera_id: str, message: str):
"""Log errors in red"""
logger.error(f"{Colors.RED}[{camera_id}] {message}{Colors.END}")
def log_info(camera_id: str, message: str):
"""Log info in cyan"""
logger.info(f"{Colors.CYAN}[{camera_id}] {message}{Colors.END}")

14
core/tracking/__init__.py Normal file
View file

@ -0,0 +1,14 @@
# Tracking module for vehicle tracking and validation
from .tracker import VehicleTracker, TrackedVehicle
from .validator import StableCarValidator, ValidationResult, VehicleState
from .integration import TrackingPipelineIntegration
__all__ = [
'VehicleTracker',
'TrackedVehicle',
'StableCarValidator',
'ValidationResult',
'VehicleState',
'TrackingPipelineIntegration'
]

View file

@ -0,0 +1,408 @@
"""
BoT-SORT Multi-Object Tracker with Camera Isolation
Based on BoT-SORT: Robust Associations Multi-Pedestrian Tracking
"""
import logging
import time
import numpy as np
from typing import Dict, List, Optional, Tuple, Any
from dataclasses import dataclass
from scipy.optimize import linear_sum_assignment
from filterpy.kalman import KalmanFilter
import cv2
logger = logging.getLogger(__name__)
@dataclass
class TrackState:
"""Track state enumeration"""
TENTATIVE = "tentative" # New track, not confirmed yet
CONFIRMED = "confirmed" # Confirmed track
DELETED = "deleted" # Track to be deleted
class Track:
"""
Individual track representation with Kalman filter for motion prediction
"""
def __init__(self, detection, track_id: int, camera_id: str):
"""
Initialize a new track
Args:
detection: Initial detection (bbox, confidence, class)
track_id: Unique track identifier within camera
camera_id: Camera identifier
"""
self.track_id = track_id
self.camera_id = camera_id
self.state = TrackState.TENTATIVE
# Time tracking
self.start_time = time.time()
self.last_update_time = time.time()
# Appearance and motion
self.bbox = detection.bbox # [x1, y1, x2, y2]
self.confidence = detection.confidence
self.class_name = detection.class_name
# Track management
self.hit_streak = 1
self.time_since_update = 0
self.age = 1
# Kalman filter for motion prediction
self.kf = self._create_kalman_filter()
self._update_kalman_filter(detection.bbox)
# Track history
self.history = [detection.bbox]
self.max_history = 10
def _create_kalman_filter(self) -> KalmanFilter:
"""Create Kalman filter for bbox tracking (x, y, w, h, vx, vy, vw, vh)"""
kf = KalmanFilter(dim_x=8, dim_z=4)
# State transition matrix (constant velocity model)
kf.F = np.array([
[1, 0, 0, 0, 1, 0, 0, 0],
[0, 1, 0, 0, 0, 1, 0, 0],
[0, 0, 1, 0, 0, 0, 1, 0],
[0, 0, 0, 1, 0, 0, 0, 1],
[0, 0, 0, 0, 1, 0, 0, 0],
[0, 0, 0, 0, 0, 1, 0, 0],
[0, 0, 0, 0, 0, 0, 1, 0],
[0, 0, 0, 0, 0, 0, 0, 1]
])
# Measurement matrix (observe x, y, w, h)
kf.H = np.array([
[1, 0, 0, 0, 0, 0, 0, 0],
[0, 1, 0, 0, 0, 0, 0, 0],
[0, 0, 1, 0, 0, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0, 0]
])
# Process noise
kf.Q *= 0.01
# Measurement noise
kf.R *= 10
# Initial covariance
kf.P *= 100
return kf
def _update_kalman_filter(self, bbox: List[float]):
"""Update Kalman filter with new bbox"""
# Convert [x1, y1, x2, y2] to [cx, cy, w, h]
x1, y1, x2, y2 = bbox
cx = (x1 + x2) / 2
cy = (y1 + y2) / 2
w = x2 - x1
h = y2 - y1
# Properly assign to column vector
self.kf.x[:4, 0] = [cx, cy, w, h]
def predict(self) -> np.ndarray:
"""Predict next position using Kalman filter"""
self.kf.predict()
# Convert back to [x1, y1, x2, y2] format
cx, cy, w, h = self.kf.x[:4, 0] # Extract from column vector
x1 = cx - w/2
y1 = cy - h/2
x2 = cx + w/2
y2 = cy + h/2
return np.array([x1, y1, x2, y2])
def update(self, detection):
"""Update track with new detection"""
self.last_update_time = time.time()
self.time_since_update = 0
self.hit_streak += 1
self.age += 1
# Update track properties
self.bbox = detection.bbox
self.confidence = detection.confidence
# Update Kalman filter
x1, y1, x2, y2 = detection.bbox
cx = (x1 + x2) / 2
cy = (y1 + y2) / 2
w = x2 - x1
h = y2 - y1
self.kf.update([cx, cy, w, h])
# Update history
self.history.append(detection.bbox)
if len(self.history) > self.max_history:
self.history.pop(0)
# Update state
if self.state == TrackState.TENTATIVE and self.hit_streak >= 3:
self.state = TrackState.CONFIRMED
def mark_missed(self):
"""Mark track as missed in this frame"""
self.time_since_update += 1
self.age += 1
if self.time_since_update > 5: # Delete after 5 missed frames
self.state = TrackState.DELETED
def is_confirmed(self) -> bool:
"""Check if track is confirmed"""
return self.state == TrackState.CONFIRMED
def is_deleted(self) -> bool:
"""Check if track should be deleted"""
return self.state == TrackState.DELETED
class CameraTracker:
"""
BoT-SORT tracker for a single camera
"""
def __init__(self, camera_id: str, max_disappeared: int = 10):
"""
Initialize camera tracker
Args:
camera_id: Unique camera identifier
max_disappeared: Maximum frames a track can be missed before deletion
"""
self.camera_id = camera_id
self.max_disappeared = max_disappeared
# Track management
self.tracks: Dict[int, Track] = {}
self.next_id = 1
self.frame_count = 0
logger.info(f"Initialized BoT-SORT tracker for camera {camera_id}")
def update(self, detections: List) -> List[Track]:
"""
Update tracker with new detections
Args:
detections: List of Detection objects
Returns:
List of active confirmed tracks
"""
self.frame_count += 1
# Predict all existing tracks
for track in self.tracks.values():
track.predict()
# Associate detections to tracks
matched_tracks, unmatched_detections, unmatched_tracks = self._associate(detections)
# Update matched tracks
for track_id, detection in matched_tracks:
self.tracks[track_id].update(detection)
# Mark unmatched tracks as missed
for track_id in unmatched_tracks:
self.tracks[track_id].mark_missed()
# Create new tracks for unmatched detections
for detection in unmatched_detections:
track = Track(detection, self.next_id, self.camera_id)
self.tracks[self.next_id] = track
self.next_id += 1
# Remove deleted tracks
tracks_to_remove = [tid for tid, track in self.tracks.items() if track.is_deleted()]
for tid in tracks_to_remove:
del self.tracks[tid]
# Return confirmed tracks
confirmed_tracks = [track for track in self.tracks.values() if track.is_confirmed()]
return confirmed_tracks
def _associate(self, detections: List) -> Tuple[List[Tuple[int, Any]], List[Any], List[int]]:
"""
Associate detections to existing tracks using IoU distance
Returns:
(matched_tracks, unmatched_detections, unmatched_tracks)
"""
if not detections or not self.tracks:
return [], detections, list(self.tracks.keys())
# Calculate IoU distance matrix
track_ids = list(self.tracks.keys())
cost_matrix = np.zeros((len(track_ids), len(detections)))
for i, track_id in enumerate(track_ids):
track = self.tracks[track_id]
predicted_bbox = track.predict()
for j, detection in enumerate(detections):
iou = self._calculate_iou(predicted_bbox, detection.bbox)
cost_matrix[i, j] = 1 - iou # Convert IoU to distance
# Solve assignment problem
row_indices, col_indices = linear_sum_assignment(cost_matrix)
# Filter matches by IoU threshold
iou_threshold = 0.3
matched_tracks = []
matched_detection_indices = set()
matched_track_indices = set()
for row, col in zip(row_indices, col_indices):
if cost_matrix[row, col] <= (1 - iou_threshold):
track_id = track_ids[row]
detection = detections[col]
matched_tracks.append((track_id, detection))
matched_detection_indices.add(col)
matched_track_indices.add(row)
# Find unmatched detections and tracks
unmatched_detections = [detections[i] for i in range(len(detections))
if i not in matched_detection_indices]
unmatched_tracks = [track_ids[i] for i in range(len(track_ids))
if i not in matched_track_indices]
return matched_tracks, unmatched_detections, unmatched_tracks
def _calculate_iou(self, bbox1: np.ndarray, bbox2: List[float]) -> float:
"""Calculate IoU between two bounding boxes"""
x1_1, y1_1, x2_1, y2_1 = bbox1
x1_2, y1_2, x2_2, y2_2 = bbox2
# Calculate intersection area
x1_i = max(x1_1, x1_2)
y1_i = max(y1_1, y1_2)
x2_i = min(x2_1, x2_2)
y2_i = min(y2_1, y2_2)
if x2_i <= x1_i or y2_i <= y1_i:
return 0.0
intersection = (x2_i - x1_i) * (y2_i - y1_i)
# Calculate union area
area1 = (x2_1 - x1_1) * (y2_1 - y1_1)
area2 = (x2_2 - x1_2) * (y2_2 - y1_2)
union = area1 + area2 - intersection
return intersection / union if union > 0 else 0.0
class MultiCameraBoTSORT:
"""
Multi-camera BoT-SORT tracker with complete camera isolation
"""
def __init__(self, trigger_classes: List[str], min_confidence: float = 0.6):
"""
Initialize multi-camera tracker
Args:
trigger_classes: List of class names to track
min_confidence: Minimum detection confidence threshold
"""
self.trigger_classes = trigger_classes
self.min_confidence = min_confidence
# Camera-specific trackers
self.camera_trackers: Dict[str, CameraTracker] = {}
logger.info(f"Initialized MultiCameraBoTSORT with classes={trigger_classes}, "
f"min_confidence={min_confidence}")
def get_or_create_tracker(self, camera_id: str) -> CameraTracker:
"""Get or create tracker for specific camera"""
if camera_id not in self.camera_trackers:
self.camera_trackers[camera_id] = CameraTracker(camera_id)
logger.info(f"Created new tracker for camera {camera_id}")
return self.camera_trackers[camera_id]
def update(self, camera_id: str, inference_result) -> List[Dict]:
"""
Update tracker for specific camera with detections
Args:
camera_id: Camera identifier
inference_result: InferenceResult with detections
Returns:
List of track information dictionaries
"""
# Filter detections by confidence and trigger classes
filtered_detections = []
if hasattr(inference_result, 'detections') and inference_result.detections:
for detection in inference_result.detections:
if (detection.confidence >= self.min_confidence and
detection.class_name in self.trigger_classes):
filtered_detections.append(detection)
# Get camera tracker and update
tracker = self.get_or_create_tracker(camera_id)
confirmed_tracks = tracker.update(filtered_detections)
# Convert tracks to output format
track_results = []
for track in confirmed_tracks:
track_results.append({
'track_id': track.track_id,
'camera_id': track.camera_id,
'bbox': track.bbox,
'confidence': track.confidence,
'class_name': track.class_name,
'hit_streak': track.hit_streak,
'age': track.age
})
return track_results
def get_statistics(self) -> Dict[str, Any]:
"""Get tracking statistics across all cameras"""
stats = {}
total_tracks = 0
for camera_id, tracker in self.camera_trackers.items():
camera_stats = {
'active_tracks': len([t for t in tracker.tracks.values() if t.is_confirmed()]),
'total_tracks': len(tracker.tracks),
'frame_count': tracker.frame_count
}
stats[camera_id] = camera_stats
total_tracks += camera_stats['active_tracks']
stats['summary'] = {
'total_cameras': len(self.camera_trackers),
'total_active_tracks': total_tracks
}
return stats
def reset_camera(self, camera_id: str):
"""Reset tracking for specific camera"""
if camera_id in self.camera_trackers:
del self.camera_trackers[camera_id]
logger.info(f"Reset tracking for camera {camera_id}")
def reset_all(self):
"""Reset all camera trackers"""
self.camera_trackers.clear()
logger.info("Reset all camera trackers")

View file

@ -0,0 +1,858 @@
"""
Tracking-Pipeline Integration Module.
Connects the tracking system with the main detection pipeline and manages the flow.
"""
import logging
import time
import uuid
from typing import Dict, Optional, Any, List, Tuple
from concurrent.futures import ThreadPoolExecutor
import asyncio
import numpy as np
from .tracker import VehicleTracker, TrackedVehicle
from .validator import StableCarValidator
from ..models.inference import YOLOWrapper
from ..models.pipeline import PipelineParser
from ..detection.pipeline import DetectionPipeline
logger = logging.getLogger(__name__)
class TrackingPipelineIntegration:
"""
Integrates vehicle tracking with the detection pipeline.
Manages tracking state transitions and pipeline execution triggers.
"""
def __init__(self, pipeline_parser: PipelineParser, model_manager: Any, model_id: int, message_sender=None):
"""
Initialize tracking-pipeline integration.
Args:
pipeline_parser: Pipeline parser with loaded configuration
model_manager: Model manager for loading models
model_id: The model ID to use for loading models
message_sender: Optional callback function for sending WebSocket messages
"""
self.pipeline_parser = pipeline_parser
self.model_manager = model_manager
self.model_id = model_id
self.message_sender = message_sender
# Store subscription info for snapshot access
self.subscription_info = None
# Initialize tracking components
tracking_config = pipeline_parser.tracking_config.__dict__ if pipeline_parser.tracking_config else {}
self.tracker = VehicleTracker(tracking_config)
self.validator = StableCarValidator()
# Tracking model
self.tracking_model: Optional[YOLOWrapper] = None
self.tracking_model_id = None
# Detection pipeline (Phase 5)
self.detection_pipeline: Optional[DetectionPipeline] = None
# Session management
self.active_sessions: Dict[str, str] = {} # display_id -> session_id
self.session_vehicles: Dict[str, int] = {} # session_id -> track_id
self.cleared_sessions: Dict[str, float] = {} # session_id -> clear_time
self.pending_vehicles: Dict[str, int] = {} # display_id -> track_id (waiting for session ID)
self.pending_processing_data: Dict[str, Dict] = {} # display_id -> processing data (waiting for session ID)
self.display_to_subscription: Dict[str, str] = {} # display_id -> subscription_id (for fallback)
# Additional validators for enhanced flow control
self.permanently_processed: Dict[str, float] = {} # "camera_id:track_id" -> process_time (never process again)
self.progression_stages: Dict[str, str] = {} # session_id -> current_stage
self.last_detection_time: Dict[str, float] = {} # display_id -> last_detection_timestamp
self.abandonment_timeout = 3.0 # seconds to wait before declaring car abandoned
# Thread pool for pipeline execution
self.executor = ThreadPoolExecutor(max_workers=2)
# Min bbox filtering configuration
# TODO: Make this configurable via pipeline.json in the future
self.min_bbox_area_percentage = 3.5 # 3.5% of frame area minimum
# Statistics
self.stats = {
'frames_processed': 0,
'vehicles_detected': 0,
'vehicles_validated': 0,
'pipelines_executed': 0,
'frontals_filtered_small': 0 # Track filtered detections
}
logger.info("TrackingPipelineIntegration initialized")
async def initialize_tracking_model(self) -> bool:
"""
Load and initialize the tracking model.
Returns:
True if successful, False otherwise
"""
try:
if not self.pipeline_parser.tracking_config:
logger.warning("No tracking configuration found in pipeline")
return False
model_file = self.pipeline_parser.tracking_config.model_file
model_id = self.pipeline_parser.tracking_config.model_id
if not model_file:
logger.warning("No tracking model file specified")
return False
# Load tracking model
logger.info(f"Loading tracking model: {model_id} ({model_file})")
self.tracking_model = self.model_manager.get_yolo_model(self.model_id, model_file)
if not self.tracking_model:
logger.error(f"Failed to load tracking model {model_file} from model {self.model_id}")
return False
self.tracking_model_id = model_id
if self.tracking_model:
logger.info(f"Tracking model {model_id} loaded successfully")
# Initialize detection pipeline (Phase 5)
await self._initialize_detection_pipeline()
return True
else:
logger.error(f"Failed to load tracking model {model_id}")
return False
except Exception as e:
logger.error(f"Error initializing tracking model: {e}", exc_info=True)
return False
async def _initialize_detection_pipeline(self) -> bool:
"""
Initialize the detection pipeline for main detection processing.
Returns:
True if successful, False otherwise
"""
try:
if not self.pipeline_parser:
logger.warning("No pipeline parser available for detection pipeline")
return False
# Create detection pipeline with message sender capability
self.detection_pipeline = DetectionPipeline(self.pipeline_parser, self.model_manager, self.model_id, self.message_sender)
# Initialize detection pipeline
if await self.detection_pipeline.initialize():
logger.info("Detection pipeline initialized successfully")
return True
else:
logger.error("Failed to initialize detection pipeline")
return False
except Exception as e:
logger.error(f"Error initializing detection pipeline: {e}", exc_info=True)
return False
async def process_frame(self,
frame: np.ndarray,
display_id: str,
subscription_id: str,
session_id: Optional[str] = None) -> Dict[str, Any]:
"""
Process a frame through tracking and potentially the detection pipeline.
Args:
frame: Input frame to process
display_id: Display identifier
subscription_id: Full subscription identifier
session_id: Optional session ID from backend
Returns:
Dictionary with processing results
"""
start_time = time.time()
result = {
'tracked_vehicles': [],
'validated_vehicle': None,
'pipeline_result': None,
'session_id': session_id,
'processing_time': 0.0
}
try:
# Update stats
self.stats['frames_processed'] += 1
# Run tracking model
if self.tracking_model:
# Run detection-only (tracking handled by our own tracker)
tracking_results = self.tracking_model.track(
frame,
confidence_threshold=self.tracker.min_confidence,
trigger_classes=self.tracker.trigger_classes,
persist=True
)
# Debug: Log raw detection results
if tracking_results and hasattr(tracking_results, 'detections'):
raw_detections = len(tracking_results.detections)
if raw_detections > 0:
class_names = [detection.class_name for detection in tracking_results.detections]
logger.debug(f"Raw detections: {raw_detections}, classes: {class_names}")
else:
logger.debug(f"No raw detections found")
else:
logger.debug(f"No tracking results or detections attribute")
# Filter out small frontal detections (neighboring pumps/distant cars)
if tracking_results and hasattr(tracking_results, 'detections'):
tracking_results = self._filter_small_frontals(tracking_results, frame)
# Process tracking results
tracked_vehicles = self.tracker.process_detections(
tracking_results,
display_id,
frame
)
# Update last detection time for abandonment detection
# Update when vehicles ARE detected, so when they leave, timestamp ages
if tracked_vehicles:
self.last_detection_time[display_id] = time.time()
logger.debug(f"Updated last_detection_time for {display_id}: {len(tracked_vehicles)} vehicles")
# Check for car abandonment (vehicle left after getting car_wait_staff stage)
await self._check_car_abandonment(display_id, subscription_id)
result['tracked_vehicles'] = [
{
'track_id': v.track_id,
'bbox': v.bbox,
'confidence': v.confidence,
'is_stable': v.is_stable,
'session_id': v.session_id
}
for v in tracked_vehicles
]
# Log tracking info periodically
if self.stats['frames_processed'] % 30 == 0: # Every 30 frames
logger.debug(f"Tracking: {len(tracked_vehicles)} vehicles, "
f"display={display_id}")
# Get stable vehicles for validation
stable_vehicles = self.tracker.get_stable_vehicles(display_id)
# Validate and potentially process stable vehicles
for vehicle in stable_vehicles:
# Check if vehicle is already processed or has session
if vehicle.processed_pipeline:
continue
# Check for session cleared (post-fueling)
if session_id and vehicle.session_id == session_id:
# Same vehicle with same session, skip
continue
# Check if this was a recently cleared session
session_cleared = False
if vehicle.session_id in self.cleared_sessions:
clear_time = self.cleared_sessions[vehicle.session_id]
if (time.time() - clear_time) < 30: # 30 second cooldown
session_cleared = True
# Skip same car after session clear or if permanently processed
if self.validator.should_skip_same_car(vehicle, session_cleared, self.permanently_processed):
continue
# Validate vehicle
validation_result = self.validator.validate_vehicle(vehicle, frame.shape)
if validation_result.is_valid and validation_result.should_process:
logger.info(f"Vehicle {vehicle.track_id} validated for processing: "
f"{validation_result.reason}")
result['validated_vehicle'] = {
'track_id': vehicle.track_id,
'state': validation_result.state.value,
'confidence': validation_result.confidence
}
# Execute detection pipeline - this will send real imageDetection when detection is found
# Mark vehicle as pending session ID assignment
self.pending_vehicles[display_id] = vehicle.track_id
logger.info(f"Vehicle {vehicle.track_id} waiting for session ID from backend")
# Execute detection pipeline (placeholder for Phase 5)
pipeline_result = await self._execute_pipeline(
frame,
vehicle,
display_id,
None, # No session ID yet
subscription_id
)
result['pipeline_result'] = pipeline_result
# No session_id in result yet - backend will provide it
self.stats['pipelines_executed'] += 1
# Only process one vehicle per frame
break
self.stats['vehicles_detected'] = len(tracked_vehicles)
self.stats['vehicles_validated'] = len(stable_vehicles)
else:
logger.warning("No tracking model available")
except Exception as e:
logger.error(f"Error in tracking pipeline: {e}", exc_info=True)
result['processing_time'] = time.time() - start_time
return result
async def _execute_pipeline(self,
frame: np.ndarray,
vehicle: TrackedVehicle,
display_id: str,
session_id: str,
subscription_id: str) -> Dict[str, Any]:
"""
Execute the main detection pipeline for a validated vehicle.
Args:
frame: Input frame
vehicle: Validated tracked vehicle
display_id: Display identifier
session_id: Session identifier
subscription_id: Full subscription identifier
Returns:
Pipeline execution results
"""
logger.info(f"Executing detection pipeline for vehicle {vehicle.track_id}, "
f"session={session_id}, display={display_id}")
try:
# Check if detection pipeline is available
if not self.detection_pipeline:
logger.warning("Detection pipeline not initialized, using fallback")
return {
'status': 'error',
'message': 'Detection pipeline not available',
'vehicle_id': vehicle.track_id,
'session_id': session_id
}
# Execute only the detection phase (first phase)
# This will run detection and send imageDetection message to backend
detection_result = await self.detection_pipeline.execute_detection_phase(
frame=frame,
display_id=display_id,
subscription_id=subscription_id
)
# Add vehicle information to result
detection_result['vehicle_id'] = vehicle.track_id
detection_result['vehicle_bbox'] = vehicle.bbox
detection_result['vehicle_confidence'] = vehicle.confidence
detection_result['phase'] = 'detection'
logger.info(f"Detection phase executed for vehicle {vehicle.track_id}: "
f"status={detection_result.get('status', 'unknown')}, "
f"message_sent={detection_result.get('message_sent', False)}, "
f"processing_time={detection_result.get('processing_time', 0):.3f}s")
# Store frame and detection results for processing phase
if detection_result['message_sent']:
# Store for later processing when sessionId is received
self.pending_processing_data[display_id] = {
'frame': frame.copy(), # Store copy of frame for processing phase
'vehicle': vehicle,
'subscription_id': subscription_id,
'detection_result': detection_result,
'timestamp': time.time()
}
logger.info(f"Stored processing data for {display_id}, waiting for sessionId from backend")
return detection_result
except Exception as e:
logger.error(f"Error executing detection pipeline: {e}", exc_info=True)
return {
'status': 'error',
'message': str(e),
'vehicle_id': vehicle.track_id,
'session_id': session_id,
'processing_time': 0.0
}
async def _execute_processing_phase(self,
processing_data: Dict[str, Any],
session_id: str,
display_id: str) -> None:
"""
Execute the processing phase after receiving sessionId from backend.
This includes branch processing and database operations.
Args:
processing_data: Stored processing data from detection phase
session_id: Session ID from backend
display_id: Display identifier
"""
try:
vehicle = processing_data['vehicle']
subscription_id = processing_data['subscription_id']
detection_result = processing_data['detection_result']
logger.info(f"Executing processing phase for session {session_id}, vehicle {vehicle.track_id}")
# Capture high-quality snapshot for pipeline processing
logger.info(f"[PROCESSING PHASE] Fetching 2K snapshot for session {session_id}")
frame = self._fetch_snapshot()
if frame is None:
logger.warning(f"[PROCESSING PHASE] Failed to capture snapshot, falling back to RTSP frame")
# Fall back to RTSP frame if snapshot fails
frame = processing_data['frame']
# Extract detected regions from detection phase result if available
detected_regions = detection_result.get('detected_regions', {})
logger.info(f"[INTEGRATION] Passing detected_regions to processing phase: {list(detected_regions.keys())}")
# Execute processing phase with detection pipeline
if self.detection_pipeline:
processing_result = await self.detection_pipeline.execute_processing_phase(
frame=frame,
display_id=display_id,
session_id=session_id,
subscription_id=subscription_id,
detected_regions=detected_regions
)
logger.info(f"Processing phase completed for session {session_id}: "
f"status={processing_result.get('status', 'unknown')}, "
f"branches={len(processing_result.get('branch_results', {}))}, "
f"actions={len(processing_result.get('actions_executed', []))}, "
f"processing_time={processing_result.get('processing_time', 0):.3f}s")
# Update stats
self.stats['pipelines_executed'] += 1
else:
logger.error("Detection pipeline not available for processing phase")
except Exception as e:
logger.error(f"Error in processing phase for session {session_id}: {e}", exc_info=True)
def set_subscription_info(self, subscription_info):
"""
Set subscription info to access snapshot URL and other stream details.
Args:
subscription_info: SubscriptionInfo object containing stream config
"""
self.subscription_info = subscription_info
logger.debug(f"Set subscription info with snapshot_url: {subscription_info.stream_config.snapshot_url if subscription_info else None}")
def set_session_id(self, display_id: str, session_id: str, subscription_id: str = None):
"""
Set session ID for a display (from backend).
This is called when backend sends setSessionId after receiving imageDetection.
Args:
display_id: Display identifier
session_id: Session identifier
subscription_id: Subscription identifier (displayId;cameraId) - needed for fallback
"""
# Ensure session_id is always a string for consistent type handling
session_id = str(session_id) if session_id is not None else None
self.active_sessions[display_id] = session_id
# Store subscription_id for fallback usage
if subscription_id:
self.display_to_subscription[display_id] = subscription_id
logger.info(f"Set session {session_id} for display {display_id} with subscription {subscription_id}")
else:
logger.info(f"Set session {session_id} for display {display_id}")
# Check if we have a pending vehicle for this display
if display_id in self.pending_vehicles:
track_id = self.pending_vehicles[display_id]
# Mark vehicle as processed with the session ID
self.tracker.mark_processed(track_id, session_id)
self.session_vehicles[session_id] = track_id
# Mark vehicle as permanently processed (won't process again even after session clear)
# Use composite key to distinguish same track IDs across different cameras
camera_id = display_id # Using display_id as camera_id for isolation
permanent_key = f"{camera_id}:{track_id}"
self.permanently_processed[permanent_key] = time.time()
# Remove from pending
del self.pending_vehicles[display_id]
logger.info(f"Assigned session {session_id} to vehicle {track_id}, marked as permanently processed")
else:
logger.warning(f"No pending vehicle found for display {display_id} when setting session {session_id}")
# Check if we have pending processing data for this display
if display_id in self.pending_processing_data:
processing_data = self.pending_processing_data[display_id]
# Trigger the processing phase asynchronously
asyncio.create_task(self._execute_processing_phase(
processing_data=processing_data,
session_id=session_id,
display_id=display_id
))
# Remove from pending processing
del self.pending_processing_data[display_id]
logger.info(f"Triggered processing phase for session {session_id} on display {display_id}")
else:
logger.warning(f"No pending processing data found for display {display_id} when setting session {session_id}")
# FALLBACK: Execute pipeline for POS-initiated sessions
# Skip if session_id is None (no car present or car has left)
if session_id is not None:
# Use stored subscription_id instead of creating fake one
stored_subscription_id = self.display_to_subscription.get(display_id)
if stored_subscription_id:
logger.info(f"[FALLBACK] Triggering fallback pipeline for session {session_id} on display {display_id} with subscription {stored_subscription_id}")
# Trigger the fallback pipeline asynchronously with real subscription_id
asyncio.create_task(self._execute_fallback_pipeline(
display_id=display_id,
session_id=session_id,
subscription_id=stored_subscription_id
))
else:
logger.error(f"[FALLBACK] No subscription_id stored for display {display_id}, cannot execute fallback pipeline")
else:
logger.debug(f"[FALLBACK] Skipping pipeline execution for session_id=None on display {display_id}")
def clear_session_id(self, session_id: str):
"""
Clear session ID (post-fueling).
Args:
session_id: Session identifier to clear
"""
# Mark session as cleared
self.cleared_sessions[session_id] = time.time()
# Clear from tracker
self.tracker.clear_session(session_id)
# Remove from active sessions
display_to_remove = None
for display_id, sess_id in self.active_sessions.items():
if sess_id == session_id:
display_to_remove = display_id
break
if display_to_remove:
del self.active_sessions[display_to_remove]
if session_id in self.session_vehicles:
del self.session_vehicles[session_id]
logger.info(f"Cleared session {session_id}")
# Clean old cleared sessions (older than 5 minutes)
current_time = time.time()
old_sessions = [
sid for sid, clear_time in self.cleared_sessions.items()
if (current_time - clear_time) > 300
]
for sid in old_sessions:
del self.cleared_sessions[sid]
def get_session_for_display(self, display_id: str) -> Optional[str]:
"""Get active session for a display."""
return self.active_sessions.get(display_id)
def reset_tracking(self):
"""Reset all tracking state."""
self.tracker.reset_tracking()
self.active_sessions.clear()
self.session_vehicles.clear()
self.cleared_sessions.clear()
self.pending_vehicles.clear()
self.pending_processing_data.clear()
self.display_to_subscription.clear()
self.permanently_processed.clear()
self.progression_stages.clear()
self.last_detection_time.clear()
logger.info("Tracking pipeline integration reset")
def get_statistics(self) -> Dict[str, Any]:
"""Get comprehensive statistics."""
tracker_stats = self.tracker.get_statistics()
validator_stats = self.validator.get_statistics()
return {
'integration': self.stats,
'tracker': tracker_stats,
'validator': validator_stats,
'active_sessions': len(self.active_sessions),
'cleared_sessions': len(self.cleared_sessions)
}
async def _check_car_abandonment(self, display_id: str, subscription_id: str):
"""
Check if a car has abandoned the fueling process (left after getting car_wait_staff stage).
Args:
display_id: Display identifier
subscription_id: Subscription identifier
"""
current_time = time.time()
# Check all sessions in car_wait_staff stage
abandoned_sessions = []
for session_id, stage in self.progression_stages.items():
if stage == "car_wait_staff":
# Check if we have recent detections for this session's display
session_display = None
for disp_id, sess_id in self.active_sessions.items():
if sess_id == session_id:
session_display = disp_id
break
if session_display:
last_detection = self.last_detection_time.get(session_display, 0)
time_since_detection = current_time - last_detection
logger.info(f"[ABANDON CHECK] Session {session_id} (display: {session_display}): "
f"time_since_detection={time_since_detection:.1f}s, "
f"timeout={self.abandonment_timeout}s")
if time_since_detection > self.abandonment_timeout:
logger.warning(f"🚨 Car abandonment detected: session {session_id}, "
f"no detection for {time_since_detection:.1f}s")
abandoned_sessions.append(session_id)
else:
logger.debug(f"[ABANDON CHECK] Session {session_id} has no associated display")
# Send abandonment detection for each abandoned session
for session_id in abandoned_sessions:
await self._send_abandonment_detection(subscription_id, session_id)
# Remove from progression stages to avoid repeated detection
if session_id in self.progression_stages:
del self.progression_stages[session_id]
logger.info(f"[ABANDON] Removed session {session_id} from progression_stages after notification")
async def _send_abandonment_detection(self, subscription_id: str, session_id: str):
"""
Send imageDetection with null detection to indicate car abandonment.
Args:
subscription_id: Subscription identifier
session_id: Session ID of the abandoned car
"""
try:
# Import here to avoid circular imports
from ..communication.messages import create_image_detection
# Create abandonment detection message with null detection
detection_message = create_image_detection(
subscription_identifier=subscription_id,
detection_data=None, # Null detection indicates abandonment
model_id=self.model_id,
model_name=self.pipeline_parser.tracking_config.model_id if self.pipeline_parser.tracking_config else "tracking_model"
)
# Send to backend via WebSocket if sender is available
if self.message_sender:
await self.message_sender(detection_message)
logger.info(f"[CAR ABANDONMENT] Sent null detection for session {session_id}")
else:
logger.info(f"[CAR ABANDONMENT] No message sender available, would send: {detection_message}")
except Exception as e:
logger.error(f"Error sending abandonment detection: {e}", exc_info=True)
def set_progression_stage(self, session_id: str, stage: str):
"""
Set progression stage for a session (from backend setProgessionStage message).
Args:
session_id: Session identifier
stage: Progression stage (e.g., "car_wait_staff")
"""
self.progression_stages[session_id] = stage
logger.info(f"Set progression stage for session {session_id}: {stage}")
# If car reaches car_wait_staff, start monitoring for abandonment
if stage == "car_wait_staff":
logger.info(f"Started monitoring session {session_id} for car abandonment")
def _fetch_snapshot(self) -> Optional[np.ndarray]:
"""
Fetch high-quality snapshot from camera's snapshot URL.
Reusable method for both processing phase and fallback pipeline.
Returns:
Snapshot frame or None if unavailable
"""
if not (self.subscription_info and self.subscription_info.stream_config.snapshot_url):
logger.warning("[SNAPSHOT] No subscription info or snapshot URL available")
return None
try:
from ..streaming.readers import HTTPSnapshotReader
logger.info(f"[SNAPSHOT] Fetching snapshot for {self.subscription_info.camera_id}")
snapshot_reader = HTTPSnapshotReader(
camera_id=self.subscription_info.camera_id,
snapshot_url=self.subscription_info.stream_config.snapshot_url,
max_retries=3
)
frame = snapshot_reader.fetch_single_snapshot()
if frame is not None:
logger.info(f"[SNAPSHOT] Successfully fetched {frame.shape[1]}x{frame.shape[0]} snapshot")
return frame
else:
logger.warning("[SNAPSHOT] Failed to fetch snapshot")
return None
except Exception as e:
logger.error(f"[SNAPSHOT] Error fetching snapshot: {e}", exc_info=True)
return None
async def _execute_fallback_pipeline(self, display_id: str, session_id: str, subscription_id: str):
"""
Execute fallback pipeline when sessionId is received without prior detection.
This handles POS-initiated sessions where backend starts transaction before car detection.
Args:
display_id: Display identifier
session_id: Session ID from backend
subscription_id: Subscription identifier for pipeline execution
"""
try:
logger.info(f"[FALLBACK PIPELINE] Executing for session {session_id}, display {display_id}")
# Fetch fresh snapshot from camera
frame = self._fetch_snapshot()
if frame is None:
logger.error(f"[FALLBACK] Failed to fetch snapshot for session {session_id}, cannot execute pipeline")
return
logger.info(f"[FALLBACK] Using snapshot frame {frame.shape[1]}x{frame.shape[0]} for session {session_id}")
# Check if detection pipeline is available
if not self.detection_pipeline:
logger.error(f"[FALLBACK] Detection pipeline not available for session {session_id}")
return
# Execute detection phase to get detected regions
detection_result = await self.detection_pipeline.execute_detection_phase(
frame=frame,
display_id=display_id,
subscription_id=subscription_id
)
logger.info(f"[FALLBACK] Detection phase completed for session {session_id}: "
f"status={detection_result.get('status', 'unknown')}, "
f"regions={list(detection_result.get('detected_regions', {}).keys())}")
# If detection found regions, execute processing phase
detected_regions = detection_result.get('detected_regions', {})
if detected_regions:
processing_result = await self.detection_pipeline.execute_processing_phase(
frame=frame,
display_id=display_id,
session_id=session_id,
subscription_id=subscription_id,
detected_regions=detected_regions
)
logger.info(f"[FALLBACK] Processing phase completed for session {session_id}: "
f"status={processing_result.get('status', 'unknown')}, "
f"branches={len(processing_result.get('branch_results', {}))}, "
f"actions={len(processing_result.get('actions_executed', []))}")
# Update statistics
self.stats['pipelines_executed'] += 1
else:
logger.warning(f"[FALLBACK] No detections found in snapshot for session {session_id}")
except Exception as e:
logger.error(f"[FALLBACK] Error executing fallback pipeline for session {session_id}: {e}", exc_info=True)
def _filter_small_frontals(self, tracking_results, frame):
"""
Filter out frontal detections that are smaller than minimum bbox area percentage.
This prevents processing of cars from neighboring pumps that appear in camera view.
Args:
tracking_results: YOLO tracking results with detections
frame: Input frame for calculating frame area
Returns:
Modified tracking_results with small frontals removed
"""
if not hasattr(tracking_results, 'detections') or not tracking_results.detections:
return tracking_results
# Calculate frame area and minimum bbox area threshold
frame_area = frame.shape[0] * frame.shape[1] # height * width
min_bbox_area = frame_area * (self.min_bbox_area_percentage / 100.0)
# Filter detections
filtered_detections = []
filtered_count = 0
for detection in tracking_results.detections:
# Calculate detection bbox area
bbox = detection.bbox # Assuming bbox is [x1, y1, x2, y2]
bbox_area = (bbox[2] - bbox[0]) * (bbox[3] - bbox[1])
if bbox_area >= min_bbox_area:
# Keep detection - bbox is large enough
filtered_detections.append(detection)
else:
# Filter out small detection
filtered_count += 1
area_percentage = (bbox_area / frame_area) * 100
logger.debug(f"Filtered small frontal: area={bbox_area:.0f}px² ({area_percentage:.1f}% of frame, "
f"min required: {self.min_bbox_area_percentage}%)")
# Update tracking results with filtered detections
tracking_results.detections = filtered_detections
# Update statistics
if filtered_count > 0:
self.stats['frontals_filtered_small'] += filtered_count
logger.info(f"Filtered {filtered_count} small frontal detections, "
f"{len(filtered_detections)} remaining (total filtered: {self.stats['frontals_filtered_small']})")
return tracking_results
def cleanup(self):
"""Cleanup resources."""
self.executor.shutdown(wait=False)
self.reset_tracking()
# Cleanup detection pipeline
if self.detection_pipeline:
self.detection_pipeline.cleanup()
logger.info("Tracking pipeline integration cleaned up")

293
core/tracking/tracker.py Normal file
View file

@ -0,0 +1,293 @@
"""
Vehicle Tracking Module - BoT-SORT based tracking with camera isolation
Implements vehicle identification, persistence, and motion analysis using external tracker.
"""
import logging
import time
import uuid
from typing import Dict, List, Optional, Tuple, Any
from dataclasses import dataclass, field
import numpy as np
from threading import Lock
from .bot_sort_tracker import MultiCameraBoTSORT
logger = logging.getLogger(__name__)
@dataclass
class TrackedVehicle:
"""Represents a tracked vehicle with all its state information."""
track_id: int
camera_id: str
first_seen: float
last_seen: float
session_id: Optional[str] = None
display_id: Optional[str] = None
confidence: float = 0.0
bbox: Tuple[int, int, int, int] = (0, 0, 0, 0) # x1, y1, x2, y2
center: Tuple[float, float] = (0.0, 0.0)
stable_frames: int = 0
total_frames: int = 0
is_stable: bool = False
processed_pipeline: bool = False
last_position_history: List[Tuple[float, float]] = field(default_factory=list)
avg_confidence: float = 0.0
hit_streak: int = 0
age: int = 0
def update_position(self, bbox: Tuple[int, int, int, int], confidence: float):
"""Update vehicle position and confidence."""
self.bbox = bbox
self.center = ((bbox[0] + bbox[2]) / 2, (bbox[1] + bbox[3]) / 2)
self.last_seen = time.time()
self.confidence = confidence
self.total_frames += 1
# Update confidence average
self.avg_confidence = ((self.avg_confidence * (self.total_frames - 1)) + confidence) / self.total_frames
# Maintain position history (last 10 positions)
self.last_position_history.append(self.center)
if len(self.last_position_history) > 10:
self.last_position_history.pop(0)
def calculate_stability(self) -> float:
"""Calculate stability score based on position history."""
if len(self.last_position_history) < 2:
return 0.0
# Calculate movement variance
positions = np.array(self.last_position_history)
if len(positions) < 2:
return 0.0
# Calculate standard deviation of positions
std_x = np.std(positions[:, 0])
std_y = np.std(positions[:, 1])
# Lower variance means more stable (inverse relationship)
# Normalize to 0-1 range (assuming max reasonable std is 50 pixels)
stability = max(0, 1 - (std_x + std_y) / 100)
return stability
def is_expired(self, timeout_seconds: float = 2.0) -> bool:
"""Check if vehicle tracking has expired."""
return (time.time() - self.last_seen) > timeout_seconds
class VehicleTracker:
"""
Main vehicle tracking implementation using BoT-SORT with camera isolation.
Manages continuous tracking, vehicle identification, and state persistence.
"""
def __init__(self, tracking_config: Optional[Dict] = None):
"""
Initialize the vehicle tracker.
Args:
tracking_config: Configuration from pipeline.json tracking section
"""
self.config = tracking_config or {}
self.trigger_classes = self.config.get('trigger_classes', self.config.get('triggerClasses', ['frontal']))
self.min_confidence = self.config.get('minConfidence', 0.6)
# BoT-SORT multi-camera tracker
self.bot_sort = MultiCameraBoTSORT(self.trigger_classes, self.min_confidence)
# Tracking state - maintain compatibility with existing code
self.tracked_vehicles: Dict[str, Dict[int, TrackedVehicle]] = {} # camera_id -> {track_id: vehicle}
self.lock = Lock()
# Tracking parameters
self.stability_threshold = 0.7
self.min_stable_frames = 5
self.timeout_seconds = 2.0
logger.info(f"VehicleTracker initialized with BoT-SORT: trigger_classes={self.trigger_classes}, "
f"min_confidence={self.min_confidence}")
def process_detections(self,
results: Any,
display_id: str,
frame: np.ndarray) -> List[TrackedVehicle]:
"""
Process detection results using BoT-SORT tracking.
Args:
results: Detection results (InferenceResult)
display_id: Display identifier for this stream
frame: Current frame being processed
Returns:
List of currently tracked vehicles
"""
current_time = time.time()
# Extract camera_id from display_id for tracking isolation
camera_id = display_id # Using display_id as camera_id for isolation
with self.lock:
# Update BoT-SORT tracker
track_results = self.bot_sort.update(camera_id, results)
# Ensure camera tracking dict exists
if camera_id not in self.tracked_vehicles:
self.tracked_vehicles[camera_id] = {}
# Update tracked vehicles based on BoT-SORT results
current_tracks = {}
active_tracks = []
for track_result in track_results:
track_id = track_result['track_id']
# Create or update TrackedVehicle
if track_id in self.tracked_vehicles[camera_id]:
# Update existing vehicle
vehicle = self.tracked_vehicles[camera_id][track_id]
vehicle.update_position(track_result['bbox'], track_result['confidence'])
vehicle.hit_streak = track_result['hit_streak']
vehicle.age = track_result['age']
# Update stability based on hit_streak
if vehicle.hit_streak >= self.min_stable_frames:
vehicle.is_stable = True
vehicle.stable_frames = vehicle.hit_streak
logger.debug(f"Updated track {track_id}: conf={vehicle.confidence:.2f}, "
f"stable={vehicle.is_stable}, hit_streak={vehicle.hit_streak}")
else:
# Create new vehicle
x1, y1, x2, y2 = track_result['bbox']
vehicle = TrackedVehicle(
track_id=track_id,
camera_id=camera_id,
first_seen=current_time,
last_seen=current_time,
display_id=display_id,
confidence=track_result['confidence'],
bbox=tuple(track_result['bbox']),
center=((x1 + x2) / 2, (y1 + y2) / 2),
total_frames=1,
hit_streak=track_result['hit_streak'],
age=track_result['age']
)
vehicle.last_position_history.append(vehicle.center)
logger.info(f"New vehicle tracked: ID={track_id}, camera={camera_id}, display={display_id}")
current_tracks[track_id] = vehicle
active_tracks.append(vehicle)
# Update the camera's tracked vehicles
self.tracked_vehicles[camera_id] = current_tracks
return active_tracks
def get_stable_vehicles(self, display_id: Optional[str] = None) -> List[TrackedVehicle]:
"""
Get all stable vehicles, optionally filtered by display.
Args:
display_id: Optional display ID to filter by
Returns:
List of stable tracked vehicles
"""
with self.lock:
stable = []
camera_id = display_id # Using display_id as camera_id
if camera_id in self.tracked_vehicles:
for vehicle in self.tracked_vehicles[camera_id].values():
if (vehicle.is_stable and not vehicle.is_expired(self.timeout_seconds) and
(display_id is None or vehicle.display_id == display_id)):
stable.append(vehicle)
return stable
def get_vehicle_by_session(self, session_id: str) -> Optional[TrackedVehicle]:
"""
Get a tracked vehicle by its session ID.
Args:
session_id: Session ID to look up
Returns:
Tracked vehicle if found, None otherwise
"""
with self.lock:
# Search across all cameras
for camera_vehicles in self.tracked_vehicles.values():
for vehicle in camera_vehicles.values():
if vehicle.session_id == session_id:
return vehicle
return None
def mark_processed(self, track_id: int, session_id: str):
"""
Mark a vehicle as processed through the pipeline.
Args:
track_id: Track ID of the vehicle
session_id: Session ID assigned to this vehicle
"""
with self.lock:
# Search across all cameras for the track_id
for camera_vehicles in self.tracked_vehicles.values():
if track_id in camera_vehicles:
vehicle = camera_vehicles[track_id]
vehicle.processed_pipeline = True
vehicle.session_id = session_id
logger.info(f"Marked vehicle {track_id} as processed with session {session_id}")
return
def clear_session(self, session_id: str):
"""
Clear session ID from a tracked vehicle (post-fueling).
Args:
session_id: Session ID to clear
"""
with self.lock:
# Search across all cameras
for camera_vehicles in self.tracked_vehicles.values():
for vehicle in camera_vehicles.values():
if vehicle.session_id == session_id:
logger.info(f"Clearing session {session_id} from vehicle {vehicle.track_id}")
vehicle.session_id = None
# Keep processed_pipeline=True to prevent re-processing
def reset_tracking(self):
"""Reset all tracking state."""
with self.lock:
self.tracked_vehicles.clear()
self.bot_sort.reset_all()
logger.info("Vehicle tracking state reset")
def get_statistics(self) -> Dict:
"""Get tracking statistics."""
with self.lock:
total = 0
stable = 0
processed = 0
all_confidences = []
# Aggregate stats across all cameras
for camera_vehicles in self.tracked_vehicles.values():
total += len(camera_vehicles)
for vehicle in camera_vehicles.values():
if vehicle.is_stable:
stable += 1
if vehicle.processed_pipeline:
processed += 1
all_confidences.append(vehicle.avg_confidence)
return {
'total_tracked': total,
'stable_vehicles': stable,
'processed_vehicles': processed,
'avg_confidence': np.mean(all_confidences) if all_confidences else 0.0,
'bot_sort_stats': self.bot_sort.get_statistics()
}

402
core/tracking/validator.py Normal file
View file

@ -0,0 +1,402 @@
"""
Vehicle Validation Module - Stable car detection and validation logic.
Differentiates between stable (fueling) cars and passing-by vehicles.
"""
import logging
import time
import numpy as np
from typing import List, Optional, Tuple, Dict, Any
from dataclasses import dataclass
from enum import Enum
from .tracker import TrackedVehicle
logger = logging.getLogger(__name__)
class VehicleState(Enum):
"""Vehicle state classification."""
UNKNOWN = "unknown"
ENTERING = "entering"
STABLE = "stable"
LEAVING = "leaving"
PASSING_BY = "passing_by"
@dataclass
class ValidationResult:
"""Result of vehicle validation."""
is_valid: bool
state: VehicleState
confidence: float
reason: str
should_process: bool = False
track_id: Optional[int] = None
class StableCarValidator:
"""
Validates whether a tracked vehicle should be processed through the pipeline.
Updated for BoT-SORT integration: Trusts the sophisticated BoT-SORT tracking algorithm
for stability determination and focuses on business logic validation:
- Duration requirements for processing
- Confidence thresholds
- Session management and cooldowns
- Camera isolation with composite keys
"""
def __init__(self, config: Optional[Dict] = None):
"""
Initialize the validator with configuration.
Args:
config: Optional configuration dictionary
"""
self.config = config or {}
# Validation thresholds
self.min_stable_duration = self.config.get('min_stable_duration', 3.0) # seconds
self.min_stable_frames = self.config.get('min_stable_frames', 10)
self.position_variance_threshold = self.config.get('position_variance_threshold', 25.0) # pixels
self.min_confidence = self.config.get('min_confidence', 0.7)
self.velocity_threshold = self.config.get('velocity_threshold', 5.0) # pixels/frame
self.entering_zone_ratio = self.config.get('entering_zone_ratio', 0.3) # 30% of frame
self.leaving_zone_ratio = self.config.get('leaving_zone_ratio', 0.3)
# Frame dimensions (will be updated on first frame)
self.frame_width = 1920
self.frame_height = 1080
# History for validation
self.validation_history: Dict[int, List[VehicleState]] = {}
self.last_processed_vehicles: Dict[int, float] = {} # track_id -> last_process_time
logger.info(f"StableCarValidator initialized with min_duration={self.min_stable_duration}s, "
f"min_frames={self.min_stable_frames}, position_variance={self.position_variance_threshold}")
def update_frame_dimensions(self, width: int, height: int):
"""Update frame dimensions for zone calculations."""
self.frame_width = width
self.frame_height = height
# Commented out verbose frame dimension logging
# logger.debug(f"Updated frame dimensions: {width}x{height}")
def validate_vehicle(self, vehicle: TrackedVehicle, frame_shape: Optional[Tuple] = None) -> ValidationResult:
"""
Validate whether a tracked vehicle is stable and should be processed.
Args:
vehicle: The tracked vehicle to validate
frame_shape: Optional frame shape (height, width, channels)
Returns:
ValidationResult with validation status and reasoning
"""
# Update frame dimensions if provided
if frame_shape:
self.update_frame_dimensions(frame_shape[1], frame_shape[0])
# Initialize validation history for new vehicles
if vehicle.track_id not in self.validation_history:
self.validation_history[vehicle.track_id] = []
# Check if already processed
if vehicle.processed_pipeline:
return ValidationResult(
is_valid=False,
state=VehicleState.STABLE,
confidence=1.0,
reason="Already processed through pipeline",
should_process=False,
track_id=vehicle.track_id
)
# Check if recently processed (cooldown period)
if vehicle.track_id in self.last_processed_vehicles:
time_since_process = time.time() - self.last_processed_vehicles[vehicle.track_id]
if time_since_process < 10.0: # 10 second cooldown
return ValidationResult(
is_valid=False,
state=VehicleState.STABLE,
confidence=1.0,
reason=f"Recently processed ({time_since_process:.1f}s ago)",
should_process=False,
track_id=vehicle.track_id
)
# Determine vehicle state
state = self._determine_vehicle_state(vehicle)
# Update history
self.validation_history[vehicle.track_id].append(state)
if len(self.validation_history[vehicle.track_id]) > 20:
self.validation_history[vehicle.track_id].pop(0)
# Validate based on state
if state == VehicleState.STABLE:
return self._validate_stable_vehicle(vehicle)
elif state == VehicleState.PASSING_BY:
return ValidationResult(
is_valid=False,
state=state,
confidence=0.8,
reason="Vehicle is passing by",
should_process=False,
track_id=vehicle.track_id
)
elif state == VehicleState.ENTERING:
return ValidationResult(
is_valid=False,
state=state,
confidence=0.5,
reason="Vehicle is entering, waiting for stability",
should_process=False,
track_id=vehicle.track_id
)
elif state == VehicleState.LEAVING:
return ValidationResult(
is_valid=False,
state=state,
confidence=0.5,
reason="Vehicle is leaving",
should_process=False,
track_id=vehicle.track_id
)
else:
return ValidationResult(
is_valid=False,
state=state,
confidence=0.0,
reason="Unknown vehicle state",
should_process=False,
track_id=vehicle.track_id
)
def _determine_vehicle_state(self, vehicle: TrackedVehicle) -> VehicleState:
"""
Determine the current state of the vehicle based on BoT-SORT tracking results.
BoT-SORT provides sophisticated tracking, so we trust its stability determination
and focus on business logic validation.
Args:
vehicle: The tracked vehicle
Returns:
Current vehicle state
"""
# Trust BoT-SORT's stability determination
if vehicle.is_stable:
# Check if it's been stable long enough for processing
duration = time.time() - vehicle.first_seen
if duration >= self.min_stable_duration:
return VehicleState.STABLE
else:
return VehicleState.ENTERING
# For non-stable vehicles, use simplified state determination
if len(vehicle.last_position_history) < 2:
return VehicleState.UNKNOWN
# Calculate velocity for movement classification
velocity = self._calculate_velocity(vehicle)
# Basic movement classification
if velocity > self.velocity_threshold:
# Vehicle is moving - classify as passing by or entering/leaving
x_position = vehicle.center[0] / self.frame_width
# Simple heuristic: vehicles near edges are entering/leaving, center vehicles are passing
if x_position < 0.2 or x_position > 0.8:
return VehicleState.ENTERING
else:
return VehicleState.PASSING_BY
# Low velocity but not marked stable by tracker - likely entering
return VehicleState.ENTERING
def _validate_stable_vehicle(self, vehicle: TrackedVehicle) -> ValidationResult:
"""
Perform business logic validation of a stable vehicle.
Since BoT-SORT already determined the vehicle is stable, we focus on:
- Duration requirements for processing
- Confidence thresholds
- Business logic constraints
Args:
vehicle: The stable vehicle to validate
Returns:
Detailed validation result
"""
# Check duration (business requirement)
duration = time.time() - vehicle.first_seen
if duration < self.min_stable_duration:
return ValidationResult(
is_valid=False,
state=VehicleState.STABLE,
confidence=0.6,
reason=f"Not stable long enough ({duration:.1f}s < {self.min_stable_duration}s)",
should_process=False,
track_id=vehicle.track_id
)
# Check confidence (business requirement)
if vehicle.avg_confidence < self.min_confidence:
return ValidationResult(
is_valid=False,
state=VehicleState.STABLE,
confidence=vehicle.avg_confidence,
reason=f"Confidence too low ({vehicle.avg_confidence:.2f} < {self.min_confidence})",
should_process=False,
track_id=vehicle.track_id
)
# Trust BoT-SORT's stability determination - skip position variance check
# BoT-SORT's sophisticated tracking already ensures consistent positioning
# Simplified state history check - just ensure recent stability
if vehicle.track_id in self.validation_history:
history = self.validation_history[vehicle.track_id][-3:] # Last 3 states
stable_count = sum(1 for s in history if s == VehicleState.STABLE)
if len(history) >= 2 and stable_count == 0: # Only fail if clear instability
return ValidationResult(
is_valid=False,
state=VehicleState.STABLE,
confidence=0.7,
reason="Recent state history shows instability",
should_process=False,
track_id=vehicle.track_id
)
# All checks passed - vehicle is valid for processing
self.last_processed_vehicles[vehicle.track_id] = time.time()
return ValidationResult(
is_valid=True,
state=VehicleState.STABLE,
confidence=vehicle.avg_confidence,
reason="Vehicle is stable and ready for processing (BoT-SORT validated)",
should_process=True,
track_id=vehicle.track_id
)
def _calculate_velocity(self, vehicle: TrackedVehicle) -> float:
"""
Calculate the velocity of the vehicle based on position history.
Args:
vehicle: The tracked vehicle
Returns:
Velocity in pixels per frame
"""
if len(vehicle.last_position_history) < 2:
return 0.0
positions = np.array(vehicle.last_position_history)
if len(positions) < 2:
return 0.0
# Calculate velocity over last 3 frames
recent_positions = positions[-min(3, len(positions)):]
velocities = []
for i in range(1, len(recent_positions)):
dx = recent_positions[i][0] - recent_positions[i-1][0]
dy = recent_positions[i][1] - recent_positions[i-1][1]
velocity = np.sqrt(dx**2 + dy**2)
velocities.append(velocity)
return np.mean(velocities) if velocities else 0.0
def _calculate_position_variance(self, vehicle: TrackedVehicle) -> float:
"""
Calculate the position variance of the vehicle.
Args:
vehicle: The tracked vehicle
Returns:
Position variance in pixels
"""
if len(vehicle.last_position_history) < 2:
return 0.0
positions = np.array(vehicle.last_position_history)
variance_x = np.var(positions[:, 0])
variance_y = np.var(positions[:, 1])
return np.sqrt(variance_x + variance_y)
def should_skip_same_car(self,
vehicle: TrackedVehicle,
session_cleared: bool = False,
permanently_processed: Dict[str, float] = None) -> bool:
"""
Determine if we should skip processing for the same car after session clear.
Args:
vehicle: The tracked vehicle
session_cleared: Whether the session was recently cleared
permanently_processed: Dict of permanently processed vehicles (camera_id:track_id -> time)
Returns:
True if we should skip this vehicle
"""
# Check if this vehicle was permanently processed (never process again)
if permanently_processed:
# Create composite key using camera_id and track_id
permanent_key = f"{vehicle.camera_id}:{vehicle.track_id}"
if permanent_key in permanently_processed:
process_time = permanently_processed[permanent_key]
time_since = time.time() - process_time
logger.debug(f"Skipping permanently processed vehicle {vehicle.track_id} on camera {vehicle.camera_id} "
f"(processed {time_since:.1f}s ago)")
return True
# If vehicle has a session_id but it was cleared, skip for a period
if vehicle.session_id is None and vehicle.processed_pipeline and session_cleared:
# Check if enough time has passed since processing
if vehicle.track_id in self.last_processed_vehicles:
time_since = time.time() - self.last_processed_vehicles[vehicle.track_id]
if time_since < 30.0: # 30 second cooldown after session clear
logger.debug(f"Skipping same car {vehicle.track_id} after session clear "
f"({time_since:.1f}s since processing)")
return True
return False
def reset_vehicle(self, track_id: int):
"""
Reset validation state for a specific vehicle.
Args:
track_id: Track ID of the vehicle to reset
"""
if track_id in self.validation_history:
del self.validation_history[track_id]
if track_id in self.last_processed_vehicles:
del self.last_processed_vehicles[track_id]
logger.debug(f"Reset validation state for vehicle {track_id}")
def get_statistics(self) -> Dict:
"""Get validation statistics."""
return {
'vehicles_in_history': len(self.validation_history),
'recently_processed': len(self.last_processed_vehicles),
'state_distribution': self._get_state_distribution()
}
def _get_state_distribution(self) -> Dict[str, int]:
"""Get distribution of current vehicle states."""
distribution = {state.value: 0 for state in VehicleState}
for history in self.validation_history.values():
if history:
current_state = history[-1]
distribution[current_state.value] += 1
return distribution

View file

@ -0,0 +1,214 @@
"""
FFmpeg hardware acceleration detection and configuration
"""
import subprocess
import logging
import re
from typing import Dict, List, Optional
logger = logging.getLogger("detector_worker")
class FFmpegCapabilities:
"""Detect and configure FFmpeg hardware acceleration capabilities."""
def __init__(self):
"""Initialize FFmpeg capabilities detector."""
self.hwaccels = []
self.codecs = {}
self.nvidia_support = False
self.vaapi_support = False
self.qsv_support = False
self._detect_capabilities()
def _detect_capabilities(self):
"""Detect available hardware acceleration methods."""
try:
# Get hardware accelerators
result = subprocess.run(
['ffmpeg', '-hide_banner', '-hwaccels'],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
self.hwaccels = [line.strip() for line in result.stdout.strip().split('\n')[1:] if line.strip()]
logger.info(f"Available FFmpeg hardware accelerators: {', '.join(self.hwaccels)}")
# Check for NVIDIA support
self.nvidia_support = any(hw in self.hwaccels for hw in ['cuda', 'cuvid', 'nvdec'])
self.vaapi_support = 'vaapi' in self.hwaccels
self.qsv_support = 'qsv' in self.hwaccels
# Get decoder information
self._detect_decoders()
# Log capabilities
if self.nvidia_support:
logger.info("NVIDIA hardware acceleration available (CUDA/CUVID/NVDEC)")
logger.info(f"Detected hardware codecs: {self.codecs}")
if self.vaapi_support:
logger.info("VAAPI hardware acceleration available")
if self.qsv_support:
logger.info("Intel QuickSync hardware acceleration available")
except Exception as e:
logger.warning(f"Failed to detect FFmpeg capabilities: {e}")
def _detect_decoders(self):
"""Detect available hardware decoders."""
try:
result = subprocess.run(
['ffmpeg', '-hide_banner', '-decoders'],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
# Parse decoder output to find hardware decoders
for line in result.stdout.split('\n'):
if 'cuvid' in line or 'nvdec' in line:
match = re.search(r'(\w+)\s+.*?(\w+(?:_cuvid|_nvdec))', line)
if match:
codec_type, decoder = match.groups()
if 'h264' in decoder:
self.codecs['h264_hw'] = decoder
elif 'hevc' in decoder or 'h265' in decoder:
self.codecs['h265_hw'] = decoder
elif 'vaapi' in line:
match = re.search(r'(\w+)\s+.*?(\w+_vaapi)', line)
if match:
codec_type, decoder = match.groups()
if 'h264' in decoder:
self.codecs['h264_vaapi'] = decoder
except Exception as e:
logger.debug(f"Failed to detect decoders: {e}")
def get_optimal_capture_options(self, codec: str = 'h264') -> Dict[str, str]:
"""
Get optimal FFmpeg capture options for the given codec.
Args:
codec: Video codec (h264, h265, etc.)
Returns:
Dictionary of FFmpeg options
"""
options = {
'rtsp_transport': 'tcp',
'buffer_size': '1024k',
'max_delay': '500000', # 500ms
'fflags': '+genpts',
'flags': '+low_delay',
'probesize': '32',
'analyzeduration': '0'
}
# Add hardware acceleration if available
if self.nvidia_support:
# Force enable CUDA hardware acceleration for H.264 if CUDA is available
if codec == 'h264':
options.update({
'hwaccel': 'cuda',
'hwaccel_device': '0'
})
logger.info("Using NVIDIA NVDEC hardware acceleration for H.264")
elif codec == 'h265':
options.update({
'hwaccel': 'cuda',
'hwaccel_device': '0',
'video_codec': 'hevc_cuvid',
'hwaccel_output_format': 'cuda'
})
logger.info("Using NVIDIA CUVID hardware acceleration for H.265")
elif self.vaapi_support:
if codec == 'h264':
options.update({
'hwaccel': 'vaapi',
'hwaccel_device': '/dev/dri/renderD128',
'video_codec': 'h264_vaapi'
})
logger.debug("Using VAAPI hardware acceleration")
return options
def format_opencv_options(self, options: Dict[str, str]) -> str:
"""
Format options for OpenCV FFmpeg backend.
Args:
options: Dictionary of FFmpeg options
Returns:
Formatted options string for OpenCV
"""
return '|'.join(f"{key};{value}" for key, value in options.items())
def get_hardware_encoder_options(self, codec: str = 'h264', quality: str = 'fast') -> Dict[str, str]:
"""
Get optimal hardware encoding options.
Args:
codec: Video codec for encoding
quality: Quality preset (fast, medium, slow)
Returns:
Dictionary of encoding options
"""
options = {}
if self.nvidia_support:
if codec == 'h264':
options.update({
'video_codec': 'h264_nvenc',
'preset': quality,
'tune': 'zerolatency',
'gpu': '0',
'rc': 'cbr_hq',
'surfaces': '64'
})
elif codec == 'h265':
options.update({
'video_codec': 'hevc_nvenc',
'preset': quality,
'tune': 'zerolatency',
'gpu': '0'
})
elif self.vaapi_support:
if codec == 'h264':
options.update({
'video_codec': 'h264_vaapi',
'vaapi_device': '/dev/dri/renderD128'
})
return options
# Global instance
_ffmpeg_caps = None
def get_ffmpeg_capabilities() -> FFmpegCapabilities:
"""Get or create the global FFmpeg capabilities instance."""
global _ffmpeg_caps
if _ffmpeg_caps is None:
_ffmpeg_caps = FFmpegCapabilities()
return _ffmpeg_caps
def get_optimal_rtsp_options(rtsp_url: str) -> str:
"""
Get optimal OpenCV FFmpeg options for RTSP streaming.
Args:
rtsp_url: RTSP stream URL
Returns:
Formatted options string for cv2.VideoCapture
"""
caps = get_ffmpeg_capabilities()
# Detect codec from URL or assume H.264
codec = 'h265' if any(x in rtsp_url.lower() for x in ['h265', 'hevc']) else 'h264'
options = caps.get_optimal_capture_options(codec)
return caps.format_opencv_options(options)

View file

@ -0,0 +1,173 @@
"""
Hardware-accelerated image encoding using NVIDIA NVENC or Intel QuickSync
"""
import cv2
import numpy as np
import logging
from typing import Optional, Tuple
import os
logger = logging.getLogger("detector_worker")
class HardwareEncoder:
"""Hardware-accelerated JPEG encoder using GPU."""
def __init__(self):
"""Initialize hardware encoder."""
self.nvenc_available = False
self.vaapi_available = False
self.turbojpeg_available = False
# Check for TurboJPEG (fastest CPU-based option)
try:
from turbojpeg import TurboJPEG
self.turbojpeg = TurboJPEG()
self.turbojpeg_available = True
logger.info("TurboJPEG accelerated encoding available")
except ImportError:
logger.debug("TurboJPEG not available")
# Check for NVIDIA NVENC support
try:
# Test if we can create an NVENC encoder
test_frame = np.zeros((720, 1280, 3), dtype=np.uint8)
fourcc = cv2.VideoWriter_fourcc(*'H264')
test_writer = cv2.VideoWriter(
"test.mp4",
fourcc,
30,
(1280, 720),
[cv2.CAP_PROP_HW_ACCELERATION, cv2.VIDEO_ACCELERATION_ANY]
)
if test_writer.isOpened():
self.nvenc_available = True
logger.info("NVENC hardware encoding available")
test_writer.release()
if os.path.exists("test.mp4"):
os.remove("test.mp4")
except Exception as e:
logger.debug(f"NVENC not available: {e}")
def encode_jpeg(self, frame: np.ndarray, quality: int = 85) -> Optional[bytes]:
"""
Encode frame to JPEG using the fastest available method.
Args:
frame: BGR image frame
quality: JPEG quality (1-100)
Returns:
Encoded JPEG bytes or None on failure
"""
try:
# Method 1: TurboJPEG (3-5x faster than cv2.imencode)
if self.turbojpeg_available:
# Convert BGR to RGB for TurboJPEG
rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
encoded = self.turbojpeg.encode(rgb_frame, quality=quality)
return encoded
# Method 2: Hardware-accelerated encoding via GStreamer (if available)
if self.nvenc_available:
return self._encode_with_nvenc(frame, quality)
# Fallback: Standard OpenCV encoding
encode_params = [cv2.IMWRITE_JPEG_QUALITY, quality]
success, encoded = cv2.imencode('.jpg', frame, encode_params)
if success:
return encoded.tobytes()
return None
except Exception as e:
logger.error(f"Failed to encode frame: {e}")
return None
def _encode_with_nvenc(self, frame: np.ndarray, quality: int) -> Optional[bytes]:
"""
Encode using NVIDIA NVENC hardware encoder.
This is complex to implement directly, so we'll use a GStreamer pipeline
if available.
"""
try:
# Create a GStreamer pipeline for hardware encoding
height, width = frame.shape[:2]
gst_pipeline = (
f"appsrc ! "
f"video/x-raw,format=BGR,width={width},height={height},framerate=30/1 ! "
f"videoconvert ! "
f"nvvideoconvert ! " # GPU color conversion
f"nvjpegenc quality={quality} ! " # Hardware JPEG encoder
f"appsink"
)
# This would require GStreamer Python bindings
# For now, fall back to TurboJPEG or standard encoding
logger.debug("NVENC JPEG encoding not fully implemented, using fallback")
encode_params = [cv2.IMWRITE_JPEG_QUALITY, quality]
success, encoded = cv2.imencode('.jpg', frame, encode_params)
if success:
return encoded.tobytes()
return None
except Exception as e:
logger.error(f"NVENC encoding failed: {e}")
return None
def encode_batch(self, frames: list, quality: int = 85) -> list:
"""
Batch encode multiple frames for better GPU utilization.
Args:
frames: List of BGR frames
quality: JPEG quality
Returns:
List of encoded JPEG bytes
"""
encoded_frames = []
if self.turbojpeg_available:
# TurboJPEG can handle batch encoding efficiently
for frame in frames:
rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
encoded = self.turbojpeg.encode(rgb_frame, quality=quality)
encoded_frames.append(encoded)
else:
# Fallback to sequential encoding
for frame in frames:
encoded = self.encode_jpeg(frame, quality)
encoded_frames.append(encoded)
return encoded_frames
# Global encoder instance
_hardware_encoder = None
def get_hardware_encoder() -> HardwareEncoder:
"""Get or create the global hardware encoder instance."""
global _hardware_encoder
if _hardware_encoder is None:
_hardware_encoder = HardwareEncoder()
return _hardware_encoder
def encode_frame_hardware(frame: np.ndarray, quality: int = 85) -> Optional[bytes]:
"""
Convenience function to encode a frame using hardware acceleration.
Args:
frame: BGR image frame
quality: JPEG quality (1-100)
Returns:
Encoded JPEG bytes or None on failure
"""
encoder = get_hardware_encoder()
return encoder.encode_jpeg(frame, quality)

View file

@ -4,4 +4,9 @@ ultralytics
opencv-python
scipy
filterpy
psycopg2-binary
psycopg2-binary
lap>=0.5.12
pynvml
PyTurboJPEG
PyNvVideoCodec
cupy-cuda12x

View file

@ -3,4 +3,7 @@ uvicorn
websockets
fastapi[standard]
redis
urllib3<2.0.0
urllib3<2.0.0
numpy
requests
watchdog

View file

@ -9,7 +9,7 @@ import time
async def test_protocol():
"""Test the worker protocol implementation"""
uri = "ws://localhost:8000"
uri = "ws://localhost:8001"
try:
async with websockets.connect(uri) as websocket:
@ -119,7 +119,7 @@ async def test_protocol():
except Exception as e:
print(f"✗ Connection failed: {e}")
print("Make sure the worker is running on localhost:8000")
print("Make sure the worker is running on localhost:8001")
if __name__ == "__main__":
asyncio.run(test_protocol())

549
worker.md
View file

@ -15,9 +15,86 @@ Communication is bidirectional and asynchronous. All messages are JSON objects w
- **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
### 2.1. Multi-Process Cluster Architecture
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.
The backend uses a sophisticated multi-process cluster architecture with Redis-based coordination to manage worker connections at scale:
**Redis Communication Channels:**
- `worker:commands` - Commands sent TO workers (subscribe, unsubscribe, setSessionId, setProgressionStage)
- `worker:responses` - Detection responses and state reports FROM workers
- `worker:events` - Worker lifecycle events (connection, disconnection, health status)
**Distributed State Management:**
- `worker:states` - Redis hash map storing real-time worker performance metrics and connection status
- `worker:assignments` - Redis hash map tracking camera-to-worker assignments across the cluster
- `worker:owners` - Redis key-based worker ownership leases with 30-second TTL for automatic failover
**Load Balancing & Failover:**
- **Assignment Algorithm**: Workers are assigned based on subscription count and CPU usage
- **Distributed Locking**: Assignment operations use Redis locks to prevent race conditions
- **Automatic Failover**: Orphaned workers are detected via lease expiration and automatically reclaimed
- **Horizontal Scaling**: New backend processes automatically join the cluster and participate in load balancing
**Inter-Process Coordination:**
- Each backend process maintains local WebSocket connections with workers
- Commands are routed via Redis pub/sub to the process that owns the target worker connection
- Master election ensures coordinated cluster management and prevents split-brain scenarios
- Process identification uses UUIDs for clean process tracking and ownership management
## 3. Message Types and Command Structure
All worker communication follows a standardized message structure with the following command types:
**Commands from Backend to Worker:**
- `setSubscriptionList` - Set complete list of camera subscriptions for declarative state management
- `setSessionId` - Associate a session ID with a display for detection linking
- `setProgressionStage` - Update the progression stage for context-aware processing
- `requestState` - Request immediate state report from worker
- `patchSessionResult` - Response to worker's patch session request
**Messages from Worker to Backend:**
- `stateReport` - Periodic heartbeat with performance metrics and subscription status
- `imageDetection` - Real-time detection results with timestamp and data
- `patchSession` - Request to modify display persistent session data
**Command Structure:**
```typescript
interface WorkerCommand {
type: string;
subscriptions?: SubscriptionObject[]; // For setSubscriptionList
payload?: {
displayIdentifier?: string;
sessionId?: number | null;
progressionStage?: string | null;
// Additional payload fields based on command type
};
}
interface SubscriptionObject {
subscriptionIdentifier: string; // Format: "displayId;cameraId"
rtspUrl: string;
snapshotUrl?: string;
snapshotInterval?: number; // milliseconds
modelUrl: string; // Fresh pre-signed URL (1-hour TTL)
modelId: number;
modelName: string;
cropX1?: number;
cropY1?: number;
cropX2?: number;
cropY2?: number;
}
```
## 4. Dynamic Configuration via MPTA File
To enable modularity and dynamic configuration, the backend will send you a URL to a `.mpta` file in each subscription within the `setSubscriptionList` command. This file is a renamed `.zip` archive that contains everything your worker needs to perform its task.
**Your worker is responsible for:**
@ -34,11 +111,66 @@ To enable modularity and dynamic configuration, the backend will send you a URL
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
## 5. Worker State Recovery and Reconnection
The system provides comprehensive state recovery mechanisms to ensure seamless operation across worker disconnections and backend restarts.
### 5.1. Automatic Resubscription
**Connection Recovery Flow:**
1. **Connection Detection**: Backend detects worker reconnection via WebSocket events
2. **State Restoration**: All subscription states are restored from backend memory and Redis
3. **Fresh Model URLs**: New model URLs are generated to handle S3 URL expiration
4. **Session Recovery**: Session IDs and progression stages are automatically restored
5. **Heartbeat Resumption**: Worker immediately begins sending state reports
### 5.2. State Persistence Architecture
**Backend State Storage:**
- **Local State**: Each backend process maintains `DetectorWorkerState` with active subscriptions
- **Redis Coordination**: Assignment mappings stored in `worker:assignments` Redis hash
- **Session Tracking**: Display session IDs tracked in display persistent data
- **Progression Stages**: Current stages maintained in display controllers
**Recovery Guarantees:**
- **Zero Configuration Loss**: All subscription parameters are preserved across disconnections
- **Session Continuity**: Active sessions remain linked after worker reconnection
- **Stage Synchronization**: Progression stages are immediately synchronized on reconnection
- **Model Availability**: Fresh model URLs ensure continuous access to detection models
### 5.3. Heartbeat and Health Monitoring
**Health Check Protocol:**
- **Heartbeat Interval**: Workers send `stateReport` every 2 seconds
- **Timeout Detection**: Backend marks workers offline after 10-second timeout
- **Automatic Recovery**: Offline workers are automatically rescheduled when they reconnect
- **Performance Tracking**: CPU, memory, and GPU usage monitored for load balancing
**Failure Scenarios:**
- **Worker Crash**: Subscriptions are reassigned to other available workers
- **Network Interruption**: Automatic reconnection with full state restoration
- **Backend Restart**: Worker assignments are restored from Redis state
- **Redis Failure**: Local state provides temporary operation until Redis recovers
### 5.4. Multi-Process Coordination
**Ownership and Leasing:**
- **Worker Ownership**: Each worker is owned by a single backend process via Redis lease
- **Lease Renewal**: 30-second TTL leases automatically renewed by owning process
- **Orphan Detection**: Expired leases allow worker reassignment to active processes
- **Graceful Handover**: Clean ownership transfer during process shutdown
## 6. Messages from Worker to Backend
These are the messages your worker is expected to send to the backend.
### 4.1. State Report (Heartbeat)
### 6.1. State Report (Heartbeat)
This message is crucial for the backend to monitor your worker's health and status, including GPU usage.
@ -73,7 +205,7 @@ This message is crucial for the backend to monitor your worker's health and stat
>
> - `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
### 6.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.
@ -101,7 +233,7 @@ Sent when the worker detects a relevant object. The `detection` object should be
}
```
### 4.3. Patch Session
### 6.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.
@ -170,68 +302,91 @@ interface DisplayPersistentData {
- **`null`** values will set the corresponding field to `null`.
- Nested objects are merged recursively.
## 5. Commands from Backend to Worker
## 7. Commands from Backend to Worker
These are the commands your worker will receive from the backend.
These are the commands your worker will receive from the backend. The subscription system uses a **fully declarative approach** with `setSubscriptionList` - the backend sends the complete desired subscription list, and workers handle reconciliation internally.
### 5.1. Subscribe to Camera
### 7.1. Set Subscription List (Declarative Subscriptions)
Instructs the worker to process a camera's RTSP stream using the configuration from the specified `.mpta` file.
**The primary subscription command that replaces individual subscribe/unsubscribe operations.**
- **Type:** `subscribe`
Instructs the worker to process the complete list of camera streams. The worker must reconcile this list with its current subscriptions, adding new ones, removing obsolete ones, and updating existing ones as needed.
- **Type:** `setSubscriptionList`
**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
}
"type": "setSubscriptionList",
"subscriptions": [
{
"subscriptionIdentifier": "display-001;cam-001",
"rtspUrl": "rtsp://user:pass@host:port/stream1",
"snapshotUrl": "http://go2rtc/snapshot/1",
"snapshotInterval": 5000,
"modelUrl": "http://storage/models/us-lpr.mpta?token=fresh-token",
"modelName": "US-LPR-and-Vehicle-ID",
"modelId": 102,
"cropX1": 100,
"cropY1": 200,
"cropX2": 300,
"cropY2": 400
},
{
"subscriptionIdentifier": "display-002;cam-001",
"rtspUrl": "rtsp://user:pass@host:port/stream1",
"snapshotUrl": "http://go2rtc/snapshot/1",
"snapshotInterval": 5000,
"modelUrl": "http://storage/models/vehicle-detect.mpta?token=fresh-token",
"modelName": "Vehicle Detection",
"modelId": 201,
"cropX1": 0,
"cropY1": 0,
"cropX2": 1920,
"cropY2": 1080
}
]
}
```
**Declarative Subscription Behavior:**
- **Complete State Definition**: The backend sends the complete desired subscription list for this worker
- **Worker-Side Reconciliation**: Workers compare the new list with current subscriptions and handle differences
- **Fresh Model URLs**: Each command includes fresh pre-signed S3 URLs (1-hour TTL) for ML models
- **Load Balancing**: The backend intelligently distributes subscriptions across available workers
- **State Recovery**: Complete subscription list is sent on worker reconnection
**Worker Reconciliation Responsibility:**
When receiving a `setSubscriptionList` command, your worker must:
1. **Compare with Current State**: Identify new subscriptions, removed subscriptions, and updated subscriptions
2. **Add New Subscriptions**: Start processing new camera streams with the provided configuration
3. **Remove Obsolete Subscriptions**: Stop processing camera streams not in the new list
4. **Update Existing Subscriptions**: Handle configuration changes (model updates, crop coordinates, etc.)
5. **Maintain Single Streams**: Ensure only one RTSP stream per camera, even with multiple display bindings
6. **Report Final State**: Send updated `stateReport` confirming the actual subscription state
> **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.
> - `cropX1`, `cropY1`, `cropX2`, `cropY2` (optional, integer) specify the crop coordinates for the camera stream
> - `snapshotUrl` and `snapshotInterval` (optional) enable periodic snapshot capture
> - Multiple subscriptions may share the same `rtspUrl` but have different `subscriptionIdentifier` values
>
> **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:
> **Camera Stream Optimization:**
> When multiple subscriptions share the same camera (same `rtspUrl`), your worker must:
>
> - 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.
> - Open the RTSP stream **once** for that camera
> - Capture each frame/snapshot **once** per cycle
> - Process the shared stream for each subscription's requirements (crop coordinates, model)
> - Route detection results separately for each `subscriptionIdentifier`
> - Apply display-specific crop coordinates during processing
>
> This optimization reduces bandwidth usage and ensures consistent detection timing across displays.
### 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
### 7.2. Request State
Direct request for the worker's current state. Respond with a `stateReport` message.
@ -245,7 +400,7 @@ Direct request for the worker's current state. Respond with a `stateReport` mess
}
```
### 5.4. Patch Session Result
### 7.3. Patch Session Result
Backend's response to a `patchSession` message.
@ -264,9 +419,11 @@ Backend's response to a `patchSession` message.
}
```
### 5.5. Set Session ID
### 7.4. 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.
**Real-time session association for linking detection events to user sessions.**
Allows the backend to instruct the worker to associate a session ID with a display. This enables linking detection events to specific user sessions. The system automatically propagates session changes across all worker processes via Redis pub/sub.
- **Type:** `setSessionId`
@ -294,11 +451,94 @@ Or to clear the session:
}
```
> **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.
**Session Management Flow:**
## Subscription Identifier Format
1. **Session Creation**: When a new session is created (user interaction), the backend immediately sends `setSessionId` to all relevant workers
2. **Cross-Process Distribution**: The command is distributed across multiple backend processes via Redis `worker:commands` channel
3. **Worker State Synchronization**: Workers maintain session IDs for each display and apply them to all matching subscriptions
4. **Automatic Recovery**: Session IDs are restored when workers reconnect, ensuring no session context is lost
5. **Multi-Subscription Support**: A single session ID applies to all camera subscriptions for the given display
**Worker Responsibility:**
- Store the session ID for the given `displayIdentifier`
- Apply the session ID to **all active subscriptions** that start with `displayIdentifier;` (e.g., `display-001;cam-001`, `display-001;cam-002`)
- Include the session ID in subsequent `imageDetection` and `patchSession` messages
- Handle session clearing when `sessionId` is `null`
- Restore session IDs from backend state after reconnection
**Multi-Process Coordination:**
The session ID command uses the distributed worker communication system:
- Commands are routed via Redis pub/sub to the process managing the target worker
- Automatic failover ensures session updates reach workers even during process changes
- Lease-based worker ownership prevents duplicate session notifications
### 7.5. Set Progression Stage
**Real-time progression stage synchronization for dynamic content adaptation.**
Notifies workers about the current progression stage of a display, enabling context-aware content selection and detection behavior. The system automatically tracks stage changes and avoids redundant updates.
- **Type:** `setProgressionStage`
**Payload:**
```json
{
"type": "setProgressionStage",
"payload": {
"displayIdentifier": "display-001",
"progressionStage": "car_fueling"
}
}
```
Or to clear the progression stage:
```json
{
"type": "setProgressionStage",
"payload": {
"displayIdentifier": "display-001",
"progressionStage": null
}
}
```
**Available Progression Stages:**
- `"welcome"` - Initial state, awaiting user interaction
- `"car_fueling"` - Vehicle is actively fueling
- `"car_waitpayment"` - Fueling complete, awaiting payment
- `"car_postpayment"` - Payment completed, transaction finishing
- `null` - No active progression stage
**Progression Stage Flow:**
1. **Automatic Detection**: Display controllers automatically detect progression stage changes based on display persistent data
2. **Change Filtering**: The system compares current stage with last sent stage to avoid redundant updates
3. **Instant Propagation**: Stage changes are immediately sent to all workers associated with the display
4. **Cross-Process Distribution**: Commands are distributed via Redis `worker:commands` channel to all backend processes
5. **State Recovery**: Progression stages are restored when workers reconnect
**Worker Responsibility:**
- Store the progression stage for the given `displayIdentifier`
- Apply the stage to **all active subscriptions** for that display
- Use progression stage for context-aware detection and content adaptation
- Handle stage clearing when `progressionStage` is `null`
- Restore progression stages from backend state after reconnection
**Use Cases:**
- **Fuel Station Displays**: Adapt content based on fueling progress (welcome ads vs. payment prompts)
- **Dynamic Detection**: Adjust detection sensitivity based on interaction stage
- **Content Personalization**: Select appropriate advertisements for current user journey stage
- **Analytics**: Track user progression through interaction stages
## 8. Subscription Identifier Format
The `subscriptionIdentifier` used in all messages is constructed as:
@ -317,11 +557,11 @@ When the backend sends a `setSessionId` command, it will only provide the `displ
- 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
## 9. 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.
This section shows a typical sequence of messages between the backend and the worker, including the new declarative subscription model, session ID management, and progression stage synchronization.
> **Note:** Unsubscribe is triggered when a user removes a camera or when the node is too heavily loaded and needs rebalancing.
> **Note:** Unsubscribe is triggered during load rebalancing or when displays/cameras are removed from the system. The system automatically handles worker reconnection with full state recovery.
1. **Connection Established** & **Heartbeat**
- **Worker -> Backend**
@ -335,21 +575,24 @@ This section shows a typical sequence of messages between the backend and the wo
"cameraConnections": []
}
```
2. **Backend Subscribes Camera**
2. **Backend Sets Subscription List**
- **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
}
"type": "setSubscriptionList",
"subscriptions": [
{
"subscriptionIdentifier": "display-001;entry-cam-01",
"rtspUrl": "rtsp://192.168.1.100/stream1",
"modelUrl": "http://storage/models/vehicle-id.mpta?token=fresh-token",
"modelName": "Vehicle Identification",
"modelId": 201,
"snapshotInterval": 5000
}
]
}
```
3. **Worker Acknowledges in Heartbeat**
3. **Worker Acknowledges with Reconciled State**
- **Worker -> Backend**
```json
{
@ -368,13 +611,44 @@ This section shows a typical sequence of messages between the backend and the wo
]
}
```
4. **Worker Detects a Car**
4. **Backend Sets Session ID**
- **Backend -> Worker**
```json
{
"type": "setSessionId",
"payload": {
"displayIdentifier": "display-001",
"sessionId": 12345
}
}
```
5. **Backend Sets Progression Stage**
- **Backend -> Worker**
```json
{
"type": "setProgressionStage",
"payload": {
"displayIdentifier": "display-001",
"progressionStage": "welcome"
}
}
```
6. **Worker Detects a Car with Session Context**
- **Worker -> Backend**
```json
{
"type": "imageDetection",
"subscriptionIdentifier": "display-001;entry-cam-01",
"timestamp": "2025-07-15T10:00:00.000Z",
"sessionId": 12345,
"data": {
"detection": {
"carBrand": "Honda",
@ -388,56 +662,89 @@ This section shows a typical sequence of messages between the backend and the wo
}
}
```
- **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**
7. **Progression Stage Change**
- **Backend -> Worker**
```json
{
"type": "unsubscribe",
"type": "setProgressionStage",
"payload": {
"subscriptionIdentifier": "display-001;entry-cam-01"
"displayIdentifier": "display-001",
"progressionStage": "car_fueling"
}
}
```
6. **Worker Acknowledges Unsubscription**
- **Worker -> Backend**
8. **Worker Reconnection with State Recovery**
- **Worker Disconnects and Reconnects**
- **Worker -> Backend** (Immediate heartbeat after reconnection)
```json
{
"type": "stateReport",
"cpuUsage": 70.0,
"memoryUsage": 38.0,
"gpuUsage": 55.0,
"gpuMemoryUsage": 20.0,
"cameraConnections": []
}
```
- **Backend -> Worker** (Automatic subscription list restoration with fresh model URLs)
```json
{
"type": "setSubscriptionList",
"subscriptions": [
{
"subscriptionIdentifier": "display-001;entry-cam-01",
"rtspUrl": "rtsp://192.168.1.100/stream1",
"modelUrl": "http://storage/models/vehicle-id.mpta?token=fresh-reconnect-token",
"modelName": "Vehicle Identification",
"modelId": 201,
"snapshotInterval": 5000
}
]
}
```
- **Backend -> Worker** (Session ID recovery)
```json
{
"type": "setSessionId",
"payload": {
"displayIdentifier": "display-001",
"sessionId": 12345
}
}
```
- **Backend -> Worker** (Progression stage recovery)
```json
{
"type": "setProgressionStage",
"payload": {
"displayIdentifier": "display-001",
"progressionStage": "car_fueling"
}
}
```
9. **Backend Updates Subscription List** (Load balancing or system cleanup)
- **Backend -> Worker** (Empty list removes all subscriptions)
```json
{
"type": "setSubscriptionList",
"subscriptions": []
}
```
10. **Worker Acknowledges Subscription Removal**
- **Worker -> Backend** (Updated heartbeat showing no active connections after reconciliation)
```json
{
"type": "stateReport",
@ -449,7 +756,17 @@ This section shows a typical sequence of messages between the backend and the wo
}
```
## 7. HTTP API: Image Retrieval
**Key Improvements in Communication Flow:**
1. **Fully Declarative Subscriptions**: Complete subscription list sent in single command, worker handles reconciliation
2. **Worker-Side Reconciliation**: Workers compare desired vs. current state and make necessary changes internally
3. **Session Context**: All detection events include session IDs for proper user linking
4. **Progression Stages**: Real-time stage updates enable context-aware content selection
5. **State Recovery**: Complete automatic recovery of subscription lists, session IDs, and progression stages
6. **Fresh Model URLs**: S3 URL expiration is handled transparently with 1-hour TTL tokens
7. **Load Balancing**: Backend intelligently distributes complete subscription lists across available workers
## 10. 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.