matrice-inference 0.1.2__tar.gz → 0.1.22__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of matrice-inference might be problematic. Click here for more details.

Files changed (50) hide show
  1. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/PKG-INFO +1 -1
  2. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/matrice_inference.egg-info/PKG-INFO +1 -1
  3. matrice_inference-0.1.22/src/matrice_inference/__init__.py +89 -0
  4. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/src/matrice_inference/server/__init__.py +17 -11
  5. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/src/matrice_inference/server/model/triton_server.py +1 -3
  6. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/src/matrice_inference/server/server.py +3 -4
  7. matrice_inference-0.1.22/src/matrice_inference/server/stream/consumer_worker.py +458 -0
  8. matrice_inference-0.1.22/src/matrice_inference/server/stream/frame_cache.py +222 -0
  9. matrice_inference-0.1.22/src/matrice_inference/server/stream/inference_worker.py +252 -0
  10. matrice_inference-0.1.22/src/matrice_inference/server/stream/post_processing_worker.py +295 -0
  11. matrice_inference-0.1.22/src/matrice_inference/server/stream/producer_worker.py +204 -0
  12. matrice_inference-0.1.22/src/matrice_inference/server/stream/stream_pipeline.py +423 -0
  13. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/src/matrice_inference/tmp/aggregator/analytics.py +1 -1
  14. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/src/matrice_inference/tmp/overall_inference_testing.py +0 -4
  15. matrice_inference-0.1.2/src/matrice_inference/__init__.py +0 -72
  16. matrice_inference-0.1.2/src/matrice_inference/server/stream/consumer_worker.py +0 -201
  17. matrice_inference-0.1.2/src/matrice_inference/server/stream/frame_cache.py +0 -127
  18. matrice_inference-0.1.2/src/matrice_inference/server/stream/inference_worker.py +0 -163
  19. matrice_inference-0.1.2/src/matrice_inference/server/stream/post_processing_worker.py +0 -230
  20. matrice_inference-0.1.2/src/matrice_inference/server/stream/producer_worker.py +0 -147
  21. matrice_inference-0.1.2/src/matrice_inference/server/stream/stream_pipeline.py +0 -451
  22. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/LICENSE.txt +0 -0
  23. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/README.md +0 -0
  24. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/matrice_inference.egg-info/SOURCES.txt +0 -0
  25. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/matrice_inference.egg-info/dependency_links.txt +0 -0
  26. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/matrice_inference.egg-info/not-zip-safe +0 -0
  27. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/matrice_inference.egg-info/top_level.txt +0 -0
  28. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/pyproject.toml +0 -0
  29. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/setup.cfg +0 -0
  30. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/setup.py +0 -0
  31. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/src/matrice_inference/py.typed +0 -0
  32. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/src/matrice_inference/server/inference_interface.py +0 -0
  33. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/src/matrice_inference/server/model/__init__.py +0 -0
  34. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/src/matrice_inference/server/model/model_manager.py +0 -0
  35. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/src/matrice_inference/server/model/model_manager_wrapper.py +0 -0
  36. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/src/matrice_inference/server/model/triton_model_manager.py +0 -0
  37. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/src/matrice_inference/server/proxy_interface.py +0 -0
  38. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/src/matrice_inference/server/stream/__init__.py +0 -0
  39. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/src/matrice_inference/server/stream/app_deployment.py +0 -0
  40. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/src/matrice_inference/server/stream/utils.py +0 -0
  41. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/src/matrice_inference/tmp/abstract_model_manager.py +0 -0
  42. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/src/matrice_inference/tmp/aggregator/__init__.py +0 -0
  43. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/src/matrice_inference/tmp/aggregator/aggregator.py +0 -0
  44. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/src/matrice_inference/tmp/aggregator/ingestor.py +0 -0
  45. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/src/matrice_inference/tmp/aggregator/latency.py +0 -0
  46. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/src/matrice_inference/tmp/aggregator/pipeline.py +0 -0
  47. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/src/matrice_inference/tmp/aggregator/publisher.py +0 -0
  48. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/src/matrice_inference/tmp/aggregator/synchronizer.py +0 -0
  49. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/src/matrice_inference/tmp/batch_manager.py +0 -0
  50. {matrice_inference-0.1.2 → matrice_inference-0.1.22}/src/matrice_inference/tmp/triton_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matrice_inference
3
- Version: 0.1.2
3
+ Version: 0.1.22
4
4
  Summary: Common server utilities for Matrice.ai services
5
5
  Author-email: "Matrice.ai" <dipendra@matrice.ai>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matrice_inference
3
- Version: 0.1.2
3
+ Version: 0.1.22
4
4
  Summary: Common server utilities for Matrice.ai services
5
5
  Author-email: "Matrice.ai" <dipendra@matrice.ai>
6
6
  License-Expression: MIT
@@ -0,0 +1,89 @@
1
+ """Module providing __init__ functionality."""
2
+
3
+ import os
4
+ import sys
5
+ import platform
6
+ from matrice_common.utils import dependencies_check
7
+
8
+ base = [
9
+ "httpx",
10
+ "fastapi",
11
+ "uvicorn",
12
+ "pillow",
13
+ "confluent_kafka[snappy]",
14
+ "aiokafka",
15
+ "aiohttp",
16
+ "filterpy",
17
+ "scipy",
18
+ "scikit-learn",
19
+ "matplotlib",
20
+ "scikit-image",
21
+ "python-snappy",
22
+ "pyyaml",
23
+ "imagehash",
24
+ "Pillow",
25
+ "transformers"
26
+ ]
27
+
28
+ # Helper to attempt installation and verify importability
29
+ def _install_and_verify(pkg: str, import_name: str):
30
+ """Install a package expression and return True if the import succeeds."""
31
+ try:
32
+ if pkg=='onnxruntime-gpu':
33
+ pkg = 'onnxruntime'
34
+ __import__(pkg)
35
+ return True
36
+ except:
37
+ if dependencies_check([pkg]):
38
+ try:
39
+ __import__(import_name)
40
+ return True
41
+ except ImportError:
42
+ return False
43
+ return False
44
+
45
+ # Runtime gating for optional OCR bootstrap (default OFF), and never on Jetson
46
+ _ENABLE_OCR_BOOTSTRAP = os.getenv("MATRICE_ENABLE_OCR_BOOTSTRAP", "0")
47
+ _IS_JETSON = (platform.machine().lower() in ("aarch64", "arm64"))
48
+
49
+ print("*******************************Deployment ENV Info**********************************")
50
+ print(f"ENABLE_JETSON_PIP_SETTINGS: {_ENABLE_OCR_BOOTSTRAP}") #0 if OFF, 1 if ON, this will be set to 1 in jetson byom codebase.
51
+ print(f"IS_JETSON_ARCH?: {_IS_JETSON}") #True if Jetson, False otherwise
52
+ print("*************************************************************************************")
53
+
54
+ if not int(_ENABLE_OCR_BOOTSTRAP) and not _IS_JETSON:
55
+ # Install base dependencies first
56
+ dependencies_check(base)
57
+
58
+ if not dependencies_check(["opencv-python"]):
59
+ dependencies_check(["opencv-python-headless"])
60
+
61
+ # Attempt GPU-specific dependencies first
62
+ _gpu_ok = _install_and_verify("onnxruntime-gpu", "onnxruntime") and _install_and_verify(
63
+ "fast-plate-ocr[onnx-gpu]", "fast_plate_ocr"
64
+ )
65
+
66
+ if not _gpu_ok:
67
+ # Fallback to CPU variants
68
+ _cpu_ok = _install_and_verify("onnxruntime", "onnxruntime") and _install_and_verify(
69
+ "fast-plate-ocr[onnx]", "fast_plate_ocr"
70
+ )
71
+ if not _cpu_ok:
72
+ # Last-chance fallback without extras tag (PyPI sometimes lacks them)
73
+ _install_and_verify("fast-plate-ocr", "fast_plate_ocr")
74
+
75
+ # matrice_deps = ["matrice_common", "matrice_analytics", "matrice"]
76
+
77
+ # dependencies_check(matrice_deps)
78
+ sys.path.append(os.path.dirname(os.path.abspath(__file__)))
79
+ from server.server import MatriceDeployServer # noqa: E402
80
+ from server.server import MatriceDeployServer as MatriceDeploy # noqa: E402 # Keep this for backwards compatibility
81
+ from server.inference_interface import InferenceInterface # noqa: E402
82
+ from server.proxy_interface import MatriceProxyInterface # noqa: E402
83
+
84
+ __all__ = [
85
+ "MatriceDeploy",
86
+ "MatriceDeployServer",
87
+ "InferenceInterface",
88
+ "MatriceProxyInterface",
89
+ ]
@@ -1,23 +1,29 @@
1
1
  import os
2
2
  import logging
3
3
 
4
- # Root logger
5
- logging.basicConfig(level=logging.DEBUG)
4
+ # Define paths
5
+ log_path = os.path.join(os.getcwd(), "deploy_server.log")
6
6
 
7
- # Console handler (INFO+)
7
+ # Create handlers explicitly
8
8
  console_handler = logging.StreamHandler()
9
- console_handler.setLevel(logging.INFO)
10
-
11
- # File handler (DEBUG+)
12
- log_path = os.path.join(os.getcwd(), "deploy_server.log")
13
9
  file_handler = logging.FileHandler(log_path)
10
+
11
+ # Set levels
12
+ console_handler.setLevel(logging.INFO)
14
13
  file_handler.setLevel(logging.DEBUG)
15
14
 
16
- # Formatter
15
+ # Define a formatter
17
16
  formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
18
17
  console_handler.setFormatter(formatter)
19
18
  file_handler.setFormatter(formatter)
20
19
 
21
- # Add handlers to root logger
22
- logging.getLogger().addHandler(console_handler)
23
- logging.getLogger().addHandler(file_handler)
20
+ # Get the root logger
21
+ logger = logging.getLogger()
22
+ logger.setLevel(logging.DEBUG) # Root level must be the lowest (DEBUG)
23
+
24
+ # Optional: remove any default handlers if basicConfig was called earlier
25
+ if logger.hasHandlers():
26
+ logger.handlers.clear()
27
+
28
+ logger.addHandler(console_handler)
29
+ logger.addHandler(file_handler)
@@ -17,8 +17,6 @@ from matrice_common.utils import dependencies_check
17
17
  TRITON_DOCKER_IMAGE = "nvcr.io/nvidia/tritonserver:23.08-py3"
18
18
  BASE_PATH = "./model_repository"
19
19
 
20
- logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
21
-
22
20
  class TritonServer:
23
21
  def __init__(
24
22
  self,
@@ -1161,7 +1159,7 @@ class TritonInference:
1161
1159
  model_alphabet: str = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_",
1162
1160
  return_confidence: bool = True,
1163
1161
  confidence_threshold: float = 0.0, # Disabled threshold to match ONNX
1164
- ) -> Tuple[list[str], np.ndarray] | list[str]:
1162
+ ) -> Union[Tuple[List[str], np.ndarray], List[str]]:
1165
1163
  """Postprocess OCR model outputs into license plate strings.
1166
1164
 
1167
1165
  Args:
@@ -320,7 +320,10 @@ class MatriceDeployServer:
320
320
  post_processing_config = {}
321
321
  if isinstance(post_processing_config, dict):
322
322
  post_processing_config["facial_recognition_server_id"] = self.job_params.get("facial_recognition_server_id", None)
323
+ post_processing_config["lpr_server_id"] = self.job_params.get("lpr_server_id", None)
323
324
  post_processing_config["session"] = self.session # Pass the session to post-processing
325
+ # Pass deployment_id for facial recognition deployment update
326
+ post_processing_config["deployment_id"] = self.deployment_id
324
327
 
325
328
  # Get index_to_category from action_tracker if available
326
329
  index_to_category = None
@@ -374,10 +377,6 @@ class MatriceDeployServer:
374
377
  self.streaming_pipeline = StreamingPipeline(
375
378
  inference_interface=self.inference_interface,
376
379
  post_processor=self.post_processor,
377
- consumer_threads=self.job_params.get("consumer_threads", 4),
378
- producer_threads=self.job_params.get("producer_threads", 2),
379
- inference_threads=self.job_params.get("inference_threads", 4),
380
- postprocessing_threads=self.job_params.get("postprocessing_threads", 2),
381
380
  inference_queue_maxsize=self.job_params.get("inference_queue_maxsize", 5000),
382
381
  postproc_queue_maxsize=self.job_params.get("postproc_queue_maxsize", 5000),
383
382
  output_queue_maxsize=self.job_params.get("output_queue_maxsize", 5000),
@@ -0,0 +1,458 @@
1
+ # Import moved to method where it's needed to avoid circular imports
2
+ import asyncio
3
+ import json
4
+ import logging
5
+ import queue
6
+ import threading
7
+ import time
8
+ import base64
9
+ import copy
10
+ import cv2
11
+ from datetime import datetime, timezone
12
+ from typing import Dict, Any, Optional
13
+ from matrice_inference.server.stream.utils import CameraConfig, StreamMessage
14
+
15
+ class ConsumerWorker:
16
+ """Handles message consumption from streams with optimized processing."""
17
+
18
+ DEFAULT_PRIORITY = 1
19
+ DEFAULT_DB = 0
20
+ DEFAULT_CONNECTION_TIMEOUT = 120
21
+
22
+ def __init__(
23
+ self,
24
+ camera_id: str,
25
+ worker_id: int,
26
+ stream_config: Dict[str, Any],
27
+ input_topic: str,
28
+ inference_queue: queue.PriorityQueue,
29
+ message_timeout: float,
30
+ camera_config: CameraConfig,
31
+ frame_cache: Optional[Any] = None
32
+ ):
33
+ self.camera_id = camera_id
34
+ self.worker_id = worker_id
35
+ self.stream_config = stream_config
36
+ self.input_topic = input_topic
37
+ self.inference_queue = inference_queue
38
+ self.message_timeout = message_timeout
39
+ self.camera_config = camera_config
40
+ self.running = False
41
+ self.stream: Optional[Any] = None
42
+ self.logger = logging.getLogger(f"{__name__}.consumer.{camera_id}.{worker_id}")
43
+ # H.265 stream decoder instance (initialized lazily per worker)
44
+ self._h265_stream_decoder = None
45
+ # Optional frame cache for low-latency caching at ingestion
46
+ self.frame_cache = frame_cache
47
+
48
+ def start(self) -> threading.Thread:
49
+ """Start the consumer worker in a separate thread."""
50
+ self.running = True
51
+ thread = threading.Thread(
52
+ target=self._run,
53
+ name=f"Consumer-{self.camera_id}-{self.worker_id}",
54
+ daemon=False
55
+ )
56
+ thread.start()
57
+ return thread
58
+
59
+ def stop(self):
60
+ """Stop the consumer worker."""
61
+ self.running = False
62
+ try:
63
+ if self._h265_stream_decoder is not None:
64
+ self._h265_stream_decoder.stop()
65
+ except Exception:
66
+ pass
67
+
68
+ def _run(self) -> None:
69
+ """Main consumer loop with proper resource management."""
70
+ loop = asyncio.new_event_loop()
71
+ asyncio.set_event_loop(loop)
72
+
73
+ self.logger.info(f"Started consumer worker for camera {self.camera_id}")
74
+
75
+ try:
76
+ loop.run_until_complete(self._initialize_stream())
77
+ self._consume_messages(loop)
78
+ except Exception as e:
79
+ self.logger.error(f"Fatal error in consumer worker: {e}")
80
+ finally:
81
+ self._cleanup_resources(loop)
82
+
83
+ def _consume_messages(self, loop: asyncio.AbstractEventLoop) -> None:
84
+ """Main message consumption loop."""
85
+ while self.running and self.camera_config.enabled:
86
+ try:
87
+ message_data = loop.run_until_complete(self._get_message_safely())
88
+ if message_data:
89
+ self._process_message(message_data)
90
+ except Exception as e:
91
+ self.logger.error(f"Error processing message: {e}")
92
+ time.sleep(1.0)
93
+
94
+ def _cleanup_resources(self, loop: asyncio.AbstractEventLoop) -> None:
95
+ """Clean up stream and event loop resources."""
96
+ if self.stream:
97
+ try:
98
+ loop.run_until_complete(self.stream.async_close())
99
+ except Exception as e:
100
+ self.logger.error(f"Error closing stream: {e}")
101
+
102
+ try:
103
+ loop.close()
104
+ except Exception as e:
105
+ self.logger.error(f"Error closing event loop: {e}")
106
+
107
+ self.logger.info(f"Consumer worker stopped for camera {self.camera_id}")
108
+
109
+ async def _initialize_stream(self) -> None:
110
+ """Initialize MatriceStream with proper configuration."""
111
+ try:
112
+ from matrice_common.stream.matrice_stream import MatriceStream, StreamType
113
+
114
+ stream_type = self._get_stream_type()
115
+ stream_params = self._build_stream_params(stream_type)
116
+
117
+ self.stream = MatriceStream(stream_type, **stream_params)
118
+ consumer_group = f"inference_consumer_{self.camera_id}_{self.worker_id}"
119
+ await self.stream.async_setup(self.input_topic, consumer_group)
120
+
121
+ self.logger.info(f"Initialized {stream_type.value} stream for consumer worker {self.worker_id}")
122
+
123
+ except Exception as e:
124
+ self.logger.error(f"Failed to initialize stream: {e}")
125
+ raise
126
+
127
+ def _get_stream_type(self):
128
+ """Determine stream type from configuration."""
129
+ from matrice_common.stream.matrice_stream import StreamType
130
+ stream_type_str = self.stream_config.get("stream_type", "kafka").lower()
131
+ return StreamType.KAFKA if stream_type_str == "kafka" else StreamType.REDIS
132
+
133
+ def _build_stream_params(self, stream_type) -> Dict[str, Any]:
134
+ """Build stream parameters based on type."""
135
+ from matrice_common.stream.matrice_stream import StreamType
136
+
137
+ if stream_type == StreamType.KAFKA:
138
+ return {
139
+ "bootstrap_servers": self.stream_config.get("bootstrap_servers", "localhost:9092"),
140
+ "sasl_username": self.stream_config.get("sasl_username", "matrice-sdk-user"),
141
+ "sasl_password": self.stream_config.get("sasl_password", "matrice-sdk-password"),
142
+ "sasl_mechanism": self.stream_config.get("sasl_mechanism", "SCRAM-SHA-256"),
143
+ "security_protocol": self.stream_config.get("security_protocol", "SASL_PLAINTEXT"),
144
+ }
145
+ else:
146
+ return {
147
+ "host": self.stream_config.get("host", "localhost"),
148
+ "port": self.stream_config.get("port", 6379),
149
+ "password": self.stream_config.get("password"),
150
+ "username": self.stream_config.get("username"),
151
+ "db": self.stream_config.get("db", self.DEFAULT_DB),
152
+ "connection_timeout": self.stream_config.get("connection_timeout", self.DEFAULT_CONNECTION_TIMEOUT),
153
+ }
154
+
155
+ async def _get_message_safely(self) -> Optional[Dict[str, Any]]:
156
+ """Safely get message from stream."""
157
+ if not self.stream:
158
+ self.logger.error("Stream not initialized")
159
+ return None
160
+
161
+ try:
162
+ return await self.stream.async_get_message(self.message_timeout)
163
+ except Exception as e:
164
+ self.logger.debug(f"Error getting message: {e}")
165
+ return None
166
+
167
+ # -------------------- H.265 helpers --------------------
168
+ def _decode_h265_frame(self, h265_bytes: bytes, width: int, height: int):
169
+ """Decode a single H.265-encoded frame to OpenCV BGR image."""
170
+ try:
171
+ try:
172
+ # Prefer local matrice_common implementation if available
173
+ from matrice_common.video.h265_processor import H265FrameDecoder
174
+ decoder = H265FrameDecoder()
175
+ frame = decoder.decode_frame(h265_bytes, width=width, height=height)
176
+ return frame
177
+ except Exception as e:
178
+ self.logger.error(f"H.265 single-frame decode failed: {e}")
179
+ return None
180
+ except Exception as e:
181
+ self.logger.error(f"Unexpected error in H.265 frame decode: {e}")
182
+ return None
183
+
184
+ def _ensure_h265_stream_decoder(self, width: int, height: int):
185
+ """Ensure a continuous H.265 stream decoder exists with given dimensions."""
186
+ if self._h265_stream_decoder is not None:
187
+ return True
188
+ try:
189
+ from matrice_common.video.h265_processor import H265StreamDecoder
190
+ decoder = H265StreamDecoder(width=width, height=height)
191
+ if not decoder.start():
192
+ self.logger.error("Failed to start H.265 stream decoder")
193
+ return False
194
+ self._h265_stream_decoder = decoder
195
+ return True
196
+ except Exception as e:
197
+ self.logger.error(f"Failed to initialize H.265 stream decoder: {e}")
198
+ return False
199
+
200
+ def _frame_to_jpeg_bytes(self, frame) -> bytes:
201
+ """Encode an OpenCV BGR frame to JPEG bytes."""
202
+ try:
203
+ ok, buf = cv2.imencode('.jpg', frame, [int(cv2.IMWRITE_JPEG_QUALITY), 90])
204
+ if not ok:
205
+ raise RuntimeError("cv2.imencode failed")
206
+ return buf.tobytes()
207
+ except Exception as e:
208
+ self.logger.error(f"Failed to encode frame to JPEG: {e}")
209
+ return b""
210
+
211
+ def _process_message(self, message_data: Dict[str, Any]) -> None:
212
+ """Process incoming message and add to inference queue."""
213
+ try:
214
+ message_key = self._extract_message_key(message_data)
215
+ data = self._parse_message_data(message_data)
216
+ input_stream = self._extract_input_stream(data)
217
+ extra_params = self._normalize_extra_params(data)
218
+ frame_id = self._determine_frame_id(data, message_data)
219
+
220
+ self._enrich_input_stream(input_stream, frame_id)
221
+
222
+ # Codec detection
223
+ codec = None
224
+ codec_lower = None
225
+ try:
226
+ if isinstance(input_stream, dict):
227
+ codec = input_stream.get("video_codec") or input_stream.get("compression_format")
228
+ if isinstance(codec, str):
229
+ codec_lower = codec.lower()
230
+ except Exception:
231
+ codec_lower = None
232
+
233
+ # H.264 handling (frame-wise) - upstream always sends JPEG-encoded frames
234
+ # Content is base64-encoded JPEG, ready for PIL/inference
235
+ if codec_lower == "h264" and isinstance(input_stream, dict):
236
+ stream_unit = input_stream.get("stream_unit", "frame")
237
+ if isinstance(stream_unit, str) and stream_unit.lower() != "frame":
238
+ self.logger.warning("Received H.264 with non-frame stream_unit; skipping")
239
+ return
240
+ content_b64 = input_stream.get("content")
241
+ if isinstance(content_b64, str) and content_b64:
242
+ # Cache JPEG base64 as-is
243
+ self._cache_frame(frame_id, content_b64)
244
+ stream_msg = self._create_stream_message(message_key, data)
245
+ task_data = self._build_task_data(stream_msg, input_stream, extra_params, frame_id)
246
+ self.inference_queue.put((stream_msg.priority, time.time(), task_data))
247
+ return
248
+ self.logger.warning("H.264 frame missing content; skipping")
249
+ return
250
+
251
+ # H.265 handling: convert to JPEG base64 before enqueuing
252
+ if codec_lower in ["h265", "hevc"] and isinstance(input_stream, dict):
253
+ # Resolve resolution
254
+ width = None
255
+ height = None
256
+ try:
257
+ res = input_stream.get("stream_resolution") or input_stream.get("original_resolution") or {}
258
+ width = int(res.get("width")) if res and res.get("width") else None
259
+ height = int(res.get("height")) if res and res.get("height") else None
260
+ except Exception:
261
+ width, height = None, None
262
+
263
+ payload_b64 = input_stream.get("content")
264
+ payload_bytes = b""
265
+ if isinstance(payload_b64, str) and payload_b64:
266
+ try:
267
+ payload_bytes = base64.b64decode(payload_b64)
268
+ except Exception:
269
+ payload_bytes = b""
270
+
271
+ stream_unit = input_stream.get("stream_unit", "frame")
272
+ is_stream_chunk = bool(input_stream.get("is_video_chunk")) or (isinstance(stream_unit, str) and stream_unit.lower() != "frame")
273
+
274
+ stream_msg = self._create_stream_message(message_key, data)
275
+
276
+ if not is_stream_chunk:
277
+ # Single-frame H.265
278
+ if payload_bytes and width and height:
279
+ frame_img = self._decode_h265_frame(payload_bytes, width, height)
280
+ if frame_img is not None:
281
+ jpeg_bytes = self._frame_to_jpeg_bytes(frame_img)
282
+ if jpeg_bytes:
283
+ input_stream_jpeg = copy.deepcopy(input_stream)
284
+ input_stream_jpeg["content"] = base64.b64encode(jpeg_bytes).decode("utf-8")
285
+ input_stream_jpeg["video_codec"] = "jpeg"
286
+ # Low-latency cache write
287
+ self._cache_frame(frame_id, input_stream_jpeg["content"])
288
+ task_data = self._build_task_data(stream_msg, input_stream_jpeg, extra_params, frame_id)
289
+ self.inference_queue.put((stream_msg.priority, time.time(), task_data))
290
+ return
291
+ # Drop undecodable H.265 frame
292
+ self.logger.warning("Dropping H.265 frame due to missing payload/resolution or decode failure")
293
+ return
294
+ else:
295
+ # Stream-chunk H.265 (emit at most one frame per message using upstream frame_id)
296
+ if width and height and self._ensure_h265_stream_decoder(width, height) and payload_bytes:
297
+ try:
298
+ self._h265_stream_decoder.decode_bytes(payload_bytes)
299
+ latest_frame = None
300
+ while True:
301
+ frame_img = self._h265_stream_decoder.read_frame()
302
+ if frame_img is None:
303
+ break
304
+ latest_frame = frame_img
305
+ if latest_frame is not None:
306
+ jpeg_bytes = self._frame_to_jpeg_bytes(latest_frame)
307
+ if jpeg_bytes:
308
+ input_stream_jpeg = copy.deepcopy(input_stream)
309
+ input_stream_jpeg["content"] = base64.b64encode(jpeg_bytes).decode("utf-8")
310
+ input_stream_jpeg["video_codec"] = "jpeg"
311
+ # Keep upstream frame_id as-is
312
+ try:
313
+ input_stream_jpeg["frame_id"] = frame_id
314
+ except Exception:
315
+ pass
316
+ # Low-latency cache write
317
+ self._cache_frame(frame_id, input_stream_jpeg["content"])
318
+ task_data = self._build_task_data(stream_msg, input_stream_jpeg, extra_params, frame_id)
319
+ self.inference_queue.put((stream_msg.priority, time.time(), task_data))
320
+ return
321
+ except Exception as e:
322
+ self.logger.error(f"H.265 stream decode error: {e}")
323
+ # No complete frame available yet for this chunk; skip forwarding
324
+ self.logger.debug("No decoded frame available from H.265 stream chunk for this message")
325
+ return
326
+
327
+ # Default path (other formats): enqueue as-is
328
+ stream_msg = self._create_stream_message(message_key, data)
329
+ # Cache if there is a base64 content present
330
+ try:
331
+ if isinstance(input_stream, dict) and isinstance(input_stream.get("content"), str) and input_stream.get("content"):
332
+ self._cache_frame(frame_id, input_stream.get("content"))
333
+ except Exception:
334
+ pass
335
+ task_data = self._build_task_data(stream_msg, input_stream, extra_params, frame_id)
336
+ self.inference_queue.put((stream_msg.priority, time.time(), task_data))
337
+
338
+ except json.JSONDecodeError as e:
339
+ self.logger.error(f"Failed to parse message JSON: {e}")
340
+ except Exception as e:
341
+ self.logger.error(f"Error processing message: {e}")
342
+
343
+ def _extract_message_key(self, message_data: Dict[str, Any]) -> Optional[str]:
344
+ """Extract message key from Kafka/Redis message."""
345
+ if not isinstance(message_data, dict):
346
+ return None
347
+
348
+ key = message_data.get('key') or message_data.get('message_key')
349
+ if isinstance(key, bytes):
350
+ return key.decode('utf-8')
351
+ return key
352
+
353
+ def _parse_message_data(self, message_data: Dict[str, Any]) -> Dict[str, Any]:
354
+ """Parse message data from different stream formats."""
355
+ for field in ['value', 'data']:
356
+ if field in message_data:
357
+ value = message_data[field]
358
+ if isinstance(value, dict):
359
+ return value
360
+ elif isinstance(value, (str, bytes)):
361
+ if isinstance(value, bytes):
362
+ value = value.decode('utf-8')
363
+ return json.loads(value)
364
+ return message_data
365
+
366
+ def _extract_input_stream(self, data: Dict[str, Any]) -> Dict[str, Any]:
367
+ """Extract input stream from message data."""
368
+ input_stream = data.get("input_stream", {})
369
+ return input_stream if input_stream else data
370
+
371
+ def _normalize_extra_params(self, data: Dict[str, Any]) -> Dict[str, Any]:
372
+ """Normalize extra_params to ensure it's a dictionary."""
373
+ extra_params = data.get("extra_params", {})
374
+
375
+ if isinstance(extra_params, dict):
376
+ return extra_params
377
+ elif isinstance(extra_params, list):
378
+ return self._merge_list_params(extra_params)
379
+ else:
380
+ self.logger.warning(f"Invalid extra_params type {type(extra_params)}, using empty dict")
381
+ return {}
382
+
383
+ def _merge_list_params(self, params_list: list) -> Dict[str, Any]:
384
+ """Merge list of dictionaries into single dictionary."""
385
+ if not params_list:
386
+ return {}
387
+
388
+ if all(isinstance(item, dict) for item in params_list):
389
+ merged = {}
390
+ for item in params_list:
391
+ merged.update(item)
392
+ return merged
393
+
394
+ return {}
395
+
396
+ def _determine_frame_id(self, data: Dict[str, Any], message_data: Dict[str, Any]) -> str:
397
+ """Determine frame ID from message data."""
398
+ frame_id = data.get("frame_id")
399
+ if frame_id:
400
+ return frame_id
401
+
402
+ fallback_key = message_data.get("key") or data.get("input_name")
403
+ if fallback_key:
404
+ return str(fallback_key)
405
+
406
+ return f"{self.camera_id}_{int(time.time() * 1000)}"
407
+
408
+ def _enrich_input_stream(self, input_stream: Dict[str, Any], frame_id: str) -> None:
409
+ """Add frame_id to input_stream if not present."""
410
+ try:
411
+ if isinstance(input_stream, dict) and "frame_id" not in input_stream:
412
+ input_stream["frame_id"] = frame_id
413
+ except Exception:
414
+ pass
415
+
416
+ def _create_stream_message(self, message_key: Optional[str], data: Dict[str, Any]) -> StreamMessage:
417
+ """Create StreamMessage instance."""
418
+ final_key = message_key or data.get("input_name") or f"{self.camera_id}_{int(time.time())}"
419
+
420
+ return StreamMessage(
421
+ camera_id=self.camera_id,
422
+ message_key=final_key,
423
+ data=data,
424
+ timestamp=datetime.now(timezone.utc),
425
+ priority=self.DEFAULT_PRIORITY
426
+ )
427
+
428
+ def _build_task_data(self, stream_msg: StreamMessage, input_stream: Dict[str, Any],
429
+ extra_params: Dict[str, Any], frame_id: str) -> Dict[str, Any]:
430
+ """Build task data for inference queue."""
431
+ return {
432
+ "message": stream_msg,
433
+ "input_stream": input_stream,
434
+ "stream_key": stream_msg.message_key,
435
+ "extra_params": extra_params,
436
+ "camera_config": self.camera_config.__dict__,
437
+ "frame_id": frame_id
438
+ }
439
+
440
+ def _cache_frame(self, frame_id: Optional[str], content_b64: Optional[str]) -> None:
441
+ """Write frame to Redis cache if configured, non-blocking.
442
+
443
+ Args:
444
+ frame_id: Unique frame identifier (uuid expected)
445
+ content_b64: Base64-encoded JPEG string
446
+ """
447
+ try:
448
+ if not self.frame_cache:
449
+ return
450
+ if not frame_id or not isinstance(frame_id, str):
451
+ return
452
+ if not content_b64 or not isinstance(content_b64, str):
453
+ return
454
+ self.frame_cache.put(frame_id, content_b64)
455
+ except Exception as e:
456
+ # Do not block pipeline on cache errors
457
+ self.logger.debug(f"Frame cache put failed for frame_id={frame_id}: {e}")
458
+