matrice-analytics 0.1.43__py3-none-any.whl → 0.1.45__py3-none-any.whl

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-analytics might be problematic. Click here for more details.

@@ -0,0 +1,165 @@
1
+ import os
2
+ import sys
3
+ import cv2
4
+ import json
5
+ import importlib
6
+ import argparse
7
+ from ultralytics import YOLO
8
+ from src.matrice_analytics.post_processing.core.base import ProcessingContext
9
+
10
+
11
+ class UseCaseTestProcessor:
12
+ """
13
+ A flexible YOLO-based video processor for testing different post-processing use cases.
14
+ """
15
+
16
+ def __init__(self, file_name, config_name, usecase_name, model_path, video_path, post_process=None, max_frames=None):
17
+ self.file_name = file_name
18
+ self.config_name = config_name
19
+ self.usecase_name = usecase_name
20
+ self.model_path = model_path
21
+ self.video_path = video_path
22
+ self.post_process = post_process
23
+ self.max_frames = max_frames
24
+ self.json_dir = "jsons"
25
+
26
+ self._setup_environment()
27
+ self.ConfigClass, self.UsecaseClass = self._load_usecase()
28
+ self.config = self._initialize_config()
29
+ self.processor = self.UsecaseClass()
30
+ self.model = YOLO(self.model_path)
31
+ os.makedirs(self.json_dir, exist_ok=True)
32
+
33
+ def _setup_environment(self):
34
+ """Ensure project root is added to sys.path."""
35
+ project_root = os.path.abspath("/content/py_analytics")
36
+ if project_root not in sys.path:
37
+ sys.path.append(project_root)
38
+
39
+ def _load_usecase(self):
40
+ """Dynamically import config and usecase classes."""
41
+ module_path = f"src.matrice_analytics.post_processing.usecases.{self.file_name}"
42
+ module = importlib.import_module(module_path)
43
+ return getattr(module, self.config_name), getattr(module, self.usecase_name)
44
+
45
+ def _initialize_config(self):
46
+ """Initialize config object, applying overrides if provided."""
47
+ if self.post_process:
48
+ return self.ConfigClass(**self.post_process)
49
+ return self.ConfigClass()
50
+
51
+ def _serialize_result(self, result):
52
+ """Convert result object into JSON-serializable dict."""
53
+ def to_serializable(obj):
54
+ if hasattr(obj, "to_dict"):
55
+ return obj.to_dict()
56
+ if hasattr(obj, "__dict__"):
57
+ return obj.__dict__
58
+ return str(obj)
59
+ return json.loads(json.dumps(result, default=to_serializable))
60
+
61
+
62
+ def process_video(self):
63
+ """Run YOLO inference on video and post-process frame by frame."""
64
+ cap = cv2.VideoCapture(self.video_path)
65
+ if not cap.isOpened():
66
+ raise ValueError(f"Failed to open video at {self.video_path}")
67
+
68
+ frame_idx = 0
69
+ stream_info = {
70
+ 'input_settings': {
71
+ 'start_frame': 0,
72
+ 'original_fps': cap.get(cv2.CAP_PROP_FPS),
73
+ 'camera_info': {'id': 'cam1', 'name': 'Test Camera'}
74
+ }
75
+ }
76
+
77
+ print(f"\nStarting video processing: {self.video_path}")
78
+ print(f"Model: {self.model_path}")
79
+ print(f"Output directory: {self.json_dir}\n")
80
+
81
+ while cap.isOpened():
82
+ ret, frame = cap.read()
83
+ if not ret:
84
+ break
85
+
86
+ frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
87
+ results = self.model(frame_rgb)
88
+
89
+ detections = []
90
+ for xyxy, conf, cls in zip(results[0].boxes.xyxy, results[0].boxes.conf, results[0].boxes.cls):
91
+ x1, y1, x2, y2 = xyxy.tolist()
92
+ detections.append({
93
+ 'category_id': int(cls),
94
+ 'confidence': conf.item(),
95
+ 'bounding_box': {
96
+ 'xmin': int(x1),
97
+ 'ymin': int(y1),
98
+ 'xmax': int(x2),
99
+ 'ymax': int(y2)
100
+ }
101
+ })
102
+
103
+ success, encoded_image = cv2.imencode(".jpg", frame)
104
+ input_bytes = encoded_image.tobytes() if success else None
105
+
106
+ try:
107
+ result = self.processor.process(
108
+ detections, self.config, input_bytes, ProcessingContext(), stream_info
109
+ )
110
+ except TypeError:
111
+ result = self.processor.process(
112
+ detections, self.config, ProcessingContext(), stream_info
113
+ )
114
+
115
+ json_path = os.path.join(self.json_dir, f"frame_{frame_idx:04d}.json")
116
+ with open(json_path, "w") as f:
117
+ json.dump(self._serialize_result(result), f, indent=2)
118
+
119
+ print(f"Frame {frame_idx} processed — detections: {len(detections)} — saved: {json_path}")
120
+
121
+ frame_idx += 1
122
+ stream_info['input_settings']['start_frame'] += 1
123
+
124
+ if self.max_frames and frame_idx >= self.max_frames:
125
+ print(f"\nMax frame limit ({self.max_frames}) reached.")
126
+ break
127
+
128
+ cap.release()
129
+ print(f"\nProcessing complete. JSON outputs saved in: {self.json_dir}")
130
+
131
+
132
+ def main():
133
+ parser = argparse.ArgumentParser(description="YOLO Use Case Test Processor")
134
+
135
+ parser.add_argument("--file_name", type=str, required=True,
136
+ help="Usecase file name under src/matrice_analytics/post_processing/usecases/")
137
+ parser.add_argument("--config_name", type=str, required=True,
138
+ help="Config class name (e.g., PeopleCountingConfig)")
139
+ parser.add_argument("--usecase_name", type=str, required=True,
140
+ help="Use case class name (e.g., PeopleCountingUseCase)")
141
+ parser.add_argument("--model_path", type=str, required=True,
142
+ help="Path to YOLO model file (.pt)")
143
+ parser.add_argument("--video_path", type=str, required=True,
144
+ help="Path to input video")
145
+ parser.add_argument("--post_process", type=json.loads, default=None,
146
+ help="JSON string for config overrides, e.g. '{\"min_confidence\": 0.5}'")
147
+ parser.add_argument("--max_frames", type=int, default=None,
148
+ help="Limit number of frames processed")
149
+
150
+ args = parser.parse_args()
151
+
152
+ processor = UseCaseTestProcessor(
153
+ file_name=args.file_name,
154
+ config_name=args.config_name,
155
+ usecase_name=args.usecase_name,
156
+ model_path=args.model_path,
157
+ video_path=args.video_path,
158
+ post_process=args.post_process,
159
+ max_frames=args.max_frames
160
+ )
161
+ processor.process_video()
162
+
163
+
164
+ if __name__ == "__main__":
165
+ main()
@@ -11,20 +11,21 @@ subprocess.run(
11
11
  cmd,
12
12
  stdout=log_file,
13
13
  stderr=subprocess.STDOUT,
14
- preexec_fn=os.setpgrp
14
+ # preexec_fn=os.setpgrp
15
15
  )
16
16
  cmd = ["pip", "install", "httpx", "aiohttp", "filterpy"]
17
17
  subprocess.run(
18
18
  cmd,
19
19
  stdout=log_file,
20
20
  stderr=subprocess.STDOUT,
21
- preexec_fn=os.setpgrp
21
+ # preexec_fn=os.setpgrp
22
22
  )
23
23
  log_file.close()
24
24
 
25
25
  import numpy as np
26
26
  from typing import List, Dict, Tuple, Optional
27
27
  from dataclasses import dataclass, field
28
+ from pathlib import Path
28
29
  import cv2
29
30
  import io
30
31
  import threading
@@ -371,7 +372,7 @@ class ClipProcessor:
371
372
  cmd,
372
373
  stdout=log_file,
373
374
  stderr=subprocess.STDOUT,
374
- preexec_fn=os.setpgrp
375
+ # preexec_fn=os.setpgrp
375
376
  )
376
377
 
377
378
  # Determine and enforce providers (prefer CUDA only)
@@ -465,4 +465,4 @@ def process_video_with_color_detection(
465
465
  classifier = VideoColorClassifier(top_k_colors, min_confidence)
466
466
  return classifier.process_video_with_predictions(
467
467
  video_bytes, yolo_predictions, output_dir, fps
468
- )
468
+ )
@@ -1617,6 +1617,53 @@ class ColorDetectionUseCase(BaseProcessor):
1617
1617
  seconds = round(float(timestamp % 60),2)
1618
1618
  return f"{hours:02d}:{minutes:02d}:{seconds:.1f}"
1619
1619
 
1620
+ def _format_timestamp(self, timestamp: Any) -> str:
1621
+ """Format a timestamp to match the current timestamp format: YYYY:MM:DD HH:MM:SS.
1622
+
1623
+ The input can be either:
1624
+ 1. A numeric Unix timestamp (``float`` / ``int``) – it will be converted to datetime.
1625
+ 2. A string in the format ``YYYY-MM-DD-HH:MM:SS.ffffff UTC``.
1626
+
1627
+ The returned value will be in the format: YYYY:MM:DD HH:MM:SS (no milliseconds, no UTC suffix).
1628
+
1629
+ Example
1630
+ -------
1631
+ >>> self._format_timestamp("2025-10-27-19:31:20.187574 UTC")
1632
+ '2025:10:27 19:31:20'
1633
+ """
1634
+
1635
+ # Convert numeric timestamps to datetime first
1636
+ if isinstance(timestamp, (int, float)):
1637
+ dt = datetime.fromtimestamp(timestamp, timezone.utc)
1638
+ return dt.strftime('%Y:%m:%d %H:%M:%S')
1639
+
1640
+ # Ensure we are working with a string from here on
1641
+ if not isinstance(timestamp, str):
1642
+ return str(timestamp)
1643
+
1644
+ # Remove ' UTC' suffix if present
1645
+ timestamp_clean = timestamp.replace(' UTC', '').strip()
1646
+
1647
+ # Remove milliseconds if present (everything after the last dot)
1648
+ if '.' in timestamp_clean:
1649
+ timestamp_clean = timestamp_clean.split('.')[0]
1650
+
1651
+ # Parse the timestamp string and convert to desired format
1652
+ try:
1653
+ # Handle format: YYYY-MM-DD-HH:MM:SS
1654
+ if timestamp_clean.count('-') >= 2:
1655
+ # Replace first two dashes with colons for date part, third with space
1656
+ parts = timestamp_clean.split('-')
1657
+ if len(parts) >= 4:
1658
+ # parts = ['2025', '10', '27', '19:31:20']
1659
+ formatted = f"{parts[0]}:{parts[1]}:{parts[2]} {'-'.join(parts[3:])}"
1660
+ return formatted
1661
+ except Exception:
1662
+ pass
1663
+
1664
+ # If parsing fails, return the cleaned string as-is
1665
+ return timestamp_clean
1666
+
1620
1667
  def _get_current_timestamp_str(self, stream_info: Optional[Dict[str, Any]], precision=False, frame_id: Optional[str]=None) -> str:
1621
1668
  """Get formatted current timestamp based on stream type."""
1622
1669
 
@@ -1630,7 +1677,6 @@ class ColorDetectionUseCase(BaseProcessor):
1630
1677
  start_time = stream_info.get("input_settings", {}).get("start_frame", 30)/stream_info.get("input_settings", {}).get("original_fps", 30)
1631
1678
  stream_time_str = self._format_timestamp_for_video(start_time)
1632
1679
 
1633
-
1634
1680
  return self._format_timestamp(stream_info.get("input_settings", {}).get("stream_time", "NA"))
1635
1681
  else:
1636
1682
  return datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
@@ -1642,7 +1688,8 @@ class ColorDetectionUseCase(BaseProcessor):
1642
1688
  start_time = stream_info.get("input_settings", {}).get("start_frame", 30)/stream_info.get("input_settings", {}).get("original_fps", 30)
1643
1689
 
1644
1690
  stream_time_str = self._format_timestamp_for_video(start_time)
1645
-
1691
+
1692
+
1646
1693
  return self._format_timestamp(stream_info.get("input_settings", {}).get("stream_time", "NA"))
1647
1694
  else:
1648
1695
  stream_time_str = stream_info.get("input_settings", {}).get("stream_info", {}).get("stream_time", "")
@@ -1661,26 +1708,60 @@ class ColorDetectionUseCase(BaseProcessor):
1661
1708
  """Get formatted start timestamp for 'TOTAL SINCE' based on stream type."""
1662
1709
  if not stream_info:
1663
1710
  return "00:00:00"
1664
-
1711
+
1665
1712
  if precision:
1666
1713
  if self.start_timer is None:
1667
- self.start_timer = stream_info.get("input_settings", {}).get("stream_time", datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC"))
1714
+ candidate = stream_info.get("input_settings", {}).get("stream_time")
1715
+ if not candidate or candidate == "NA":
1716
+ candidate = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
1717
+ self.start_timer = candidate
1668
1718
  return self._format_timestamp(self.start_timer)
1669
1719
  elif stream_info.get("input_settings", {}).get("start_frame", "na") == 1:
1670
- self.start_timer = stream_info.get("input_settings", {}).get("stream_time", datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC"))
1720
+ candidate = stream_info.get("input_settings", {}).get("stream_time")
1721
+ if not candidate or candidate == "NA":
1722
+ candidate = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
1723
+ self.start_timer = candidate
1671
1724
  return self._format_timestamp(self.start_timer)
1672
1725
  else:
1673
1726
  return self._format_timestamp(self.start_timer)
1674
1727
 
1675
1728
  if self.start_timer is None:
1676
- self.start_timer = stream_info.get("input_settings", {}).get("stream_time", datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC"))
1729
+ # Prefer direct input_settings.stream_time if available and not NA
1730
+ candidate = stream_info.get("input_settings", {}).get("stream_time")
1731
+ if not candidate or candidate == "NA":
1732
+ # Fallback to nested stream_info.stream_time used by current timestamp path
1733
+ stream_time_str = stream_info.get("input_settings", {}).get("stream_info", {}).get("stream_time", "")
1734
+ if stream_time_str:
1735
+ try:
1736
+ timestamp_str = stream_time_str.replace(" UTC", "")
1737
+ dt = datetime.strptime(timestamp_str, "%Y-%m-%d-%H:%M:%S.%f")
1738
+ self._tracking_start_time = dt.replace(tzinfo=timezone.utc).timestamp()
1739
+ candidate = datetime.fromtimestamp(self._tracking_start_time, timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
1740
+ except:
1741
+ candidate = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
1742
+ else:
1743
+ candidate = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
1744
+ self.start_timer = candidate
1677
1745
  return self._format_timestamp(self.start_timer)
1678
1746
  elif stream_info.get("input_settings", {}).get("start_frame", "na") == 1:
1679
- self.start_timer = stream_info.get("input_settings", {}).get("stream_time", datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC"))
1747
+ candidate = stream_info.get("input_settings", {}).get("stream_time")
1748
+ if not candidate or candidate == "NA":
1749
+ stream_time_str = stream_info.get("input_settings", {}).get("stream_info", {}).get("stream_time", "")
1750
+ if stream_time_str:
1751
+ try:
1752
+ timestamp_str = stream_time_str.replace(" UTC", "")
1753
+ dt = datetime.strptime(timestamp_str, "%Y-%m-%d-%H:%M:%S.%f")
1754
+ ts = dt.replace(tzinfo=timezone.utc).timestamp()
1755
+ candidate = datetime.fromtimestamp(ts, timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
1756
+ except:
1757
+ candidate = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
1758
+ else:
1759
+ candidate = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
1760
+ self.start_timer = candidate
1680
1761
  return self._format_timestamp(self.start_timer)
1681
1762
 
1682
1763
  else:
1683
- if self.start_timer is not None:
1764
+ if self.start_timer is not None and self.start_timer != "NA":
1684
1765
  return self._format_timestamp(self.start_timer)
1685
1766
 
1686
1767
  if self._tracking_start_time is None:
@@ -1699,52 +1780,6 @@ class ColorDetectionUseCase(BaseProcessor):
1699
1780
  dt = dt.replace(minute=0, second=0, microsecond=0)
1700
1781
  return dt.strftime('%Y:%m:%d %H:%M:%S')
1701
1782
 
1702
- def _format_timestamp(self, timestamp: Any) -> str:
1703
- """Format a timestamp so that exactly two digits follow the decimal point (milliseconds).
1704
-
1705
- The input can be either:
1706
- 1. A numeric Unix timestamp (``float`` / ``int``) – it will first be converted to a
1707
- string in the format ``YYYY-MM-DD-HH:MM:SS.ffffff UTC``.
1708
- 2. A string already following the same layout.
1709
-
1710
- The returned value preserves the overall format of the input but truncates or pads
1711
- the fractional seconds portion to **exactly two digits**.
1712
-
1713
- Example
1714
- -------
1715
- >>> self._format_timestamp("2025-08-19-04:22:47.187574 UTC")
1716
- '2025-08-19-04:22:47.18 UTC'
1717
- """
1718
-
1719
- # Convert numeric timestamps to the expected string representation first
1720
- if isinstance(timestamp, (int, float)):
1721
- timestamp = datetime.fromtimestamp(timestamp, timezone.utc).strftime(
1722
- '%Y-%m-%d-%H:%M:%S.%f UTC'
1723
- )
1724
-
1725
- # Ensure we are working with a string from here on
1726
- if not isinstance(timestamp, str):
1727
- return str(timestamp)
1728
-
1729
- # If there is no fractional component, simply return the original string
1730
- if '.' not in timestamp:
1731
- return timestamp
1732
-
1733
- # Split out the main portion (up to the decimal point)
1734
- main_part, fractional_and_suffix = timestamp.split('.', 1)
1735
-
1736
- # Separate fractional digits from the suffix (typically ' UTC')
1737
- if ' ' in fractional_and_suffix:
1738
- fractional_part, suffix = fractional_and_suffix.split(' ', 1)
1739
- suffix = ' ' + suffix # Re-attach the space removed by split
1740
- else:
1741
- fractional_part, suffix = fractional_and_suffix, ''
1742
-
1743
- # Guarantee exactly two digits for the fractional part
1744
- fractional_part = (fractional_part + '00')[:2]
1745
-
1746
- return f"{main_part}.{fractional_part}{suffix}"
1747
-
1748
1783
  def _get_tracking_start_time(self) -> str:
1749
1784
  """Get the tracking start time, formatted as a string."""
1750
1785
  if self._tracking_start_time is None:
@@ -892,11 +892,58 @@ class FireSmokeUseCase(BaseProcessor):
892
892
  seconds = round(float(timestamp % 60), 2)
893
893
  return f"{hours:02d}:{minutes:02d}:{seconds:.1f}"
894
894
 
895
+ def _format_timestamp(self, timestamp: Any) -> str:
896
+ """Format a timestamp to match the current timestamp format: YYYY:MM:DD HH:MM:SS.
897
+
898
+ The input can be either:
899
+ 1. A numeric Unix timestamp (``float`` / ``int``) – it will be converted to datetime.
900
+ 2. A string in the format ``YYYY-MM-DD-HH:MM:SS.ffffff UTC``.
901
+
902
+ The returned value will be in the format: YYYY:MM:DD HH:MM:SS (no milliseconds, no UTC suffix).
903
+
904
+ Example
905
+ -------
906
+ >>> self._format_timestamp("2025-10-27-19:31:20.187574 UTC")
907
+ '2025:10:27 19:31:20'
908
+ """
909
+
910
+ # Convert numeric timestamps to datetime first
911
+ if isinstance(timestamp, (int, float)):
912
+ dt = datetime.fromtimestamp(timestamp, timezone.utc)
913
+ return dt.strftime('%Y:%m:%d %H:%M:%S')
914
+
915
+ # Ensure we are working with a string from here on
916
+ if not isinstance(timestamp, str):
917
+ return str(timestamp)
918
+
919
+ # Remove ' UTC' suffix if present
920
+ timestamp_clean = timestamp.replace(' UTC', '').strip()
921
+
922
+ # Remove milliseconds if present (everything after the last dot)
923
+ if '.' in timestamp_clean:
924
+ timestamp_clean = timestamp_clean.split('.')[0]
925
+
926
+ # Parse the timestamp string and convert to desired format
927
+ try:
928
+ # Handle format: YYYY-MM-DD-HH:MM:SS
929
+ if timestamp_clean.count('-') >= 2:
930
+ # Replace first two dashes with colons for date part, third with space
931
+ parts = timestamp_clean.split('-')
932
+ if len(parts) >= 4:
933
+ # parts = ['2025', '10', '27', '19:31:20']
934
+ formatted = f"{parts[0]}:{parts[1]}:{parts[2]} {'-'.join(parts[3:])}"
935
+ return formatted
936
+ except Exception:
937
+ pass
938
+
939
+ # If parsing fails, return the cleaned string as-is
940
+ return timestamp_clean
941
+
895
942
  def _get_current_timestamp_str(self, stream_info: Optional[Dict[str, Any]], precision=False, frame_id: Optional[str]=None) -> str:
896
943
  """Get formatted current timestamp based on stream type."""
944
+
897
945
  if not stream_info:
898
946
  return "00:00:00.00"
899
-
900
947
  if precision:
901
948
  if stream_info.get("input_settings", {}).get("start_frame", "na") != "na":
902
949
  if frame_id:
@@ -905,7 +952,6 @@ class FireSmokeUseCase(BaseProcessor):
905
952
  start_time = stream_info.get("input_settings", {}).get("start_frame", 30)/stream_info.get("input_settings", {}).get("original_fps", 30)
906
953
  stream_time_str = self._format_timestamp_for_video(start_time)
907
954
 
908
-
909
955
  return self._format_timestamp(stream_info.get("input_settings", {}).get("stream_time", "NA"))
910
956
  else:
911
957
  return datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
@@ -917,7 +963,8 @@ class FireSmokeUseCase(BaseProcessor):
917
963
  start_time = stream_info.get("input_settings", {}).get("start_frame", 30)/stream_info.get("input_settings", {}).get("original_fps", 30)
918
964
 
919
965
  stream_time_str = self._format_timestamp_for_video(start_time)
920
-
966
+
967
+
921
968
  return self._format_timestamp(stream_info.get("input_settings", {}).get("stream_time", "NA"))
922
969
  else:
923
970
  stream_time_str = stream_info.get("input_settings", {}).get("stream_info", {}).get("stream_time", "")
@@ -939,23 +986,57 @@ class FireSmokeUseCase(BaseProcessor):
939
986
 
940
987
  if precision:
941
988
  if self.start_timer is None:
942
- self.start_timer = stream_info.get("input_settings", {}).get("stream_time", "NA")
989
+ candidate = stream_info.get("input_settings", {}).get("stream_time")
990
+ if not candidate or candidate == "NA":
991
+ candidate = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
992
+ self.start_timer = candidate
943
993
  return self._format_timestamp(self.start_timer)
944
994
  elif stream_info.get("input_settings", {}).get("start_frame", "na") == 1:
945
- self.start_timer = stream_info.get("input_settings", {}).get("stream_time", "NA")
995
+ candidate = stream_info.get("input_settings", {}).get("stream_time")
996
+ if not candidate or candidate == "NA":
997
+ candidate = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
998
+ self.start_timer = candidate
946
999
  return self._format_timestamp(self.start_timer)
947
1000
  else:
948
1001
  return self._format_timestamp(self.start_timer)
949
1002
 
950
1003
  if self.start_timer is None:
951
- self.start_timer = stream_info.get("input_settings", {}).get("stream_time", "NA")
1004
+ # Prefer direct input_settings.stream_time if available and not NA
1005
+ candidate = stream_info.get("input_settings", {}).get("stream_time")
1006
+ if not candidate or candidate == "NA":
1007
+ # Fallback to nested stream_info.stream_time used by current timestamp path
1008
+ stream_time_str = stream_info.get("input_settings", {}).get("stream_info", {}).get("stream_time", "")
1009
+ if stream_time_str:
1010
+ try:
1011
+ timestamp_str = stream_time_str.replace(" UTC", "")
1012
+ dt = datetime.strptime(timestamp_str, "%Y-%m-%d-%H:%M:%S.%f")
1013
+ self._tracking_start_time = dt.replace(tzinfo=timezone.utc).timestamp()
1014
+ candidate = datetime.fromtimestamp(self._tracking_start_time, timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
1015
+ except:
1016
+ candidate = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
1017
+ else:
1018
+ candidate = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
1019
+ self.start_timer = candidate
952
1020
  return self._format_timestamp(self.start_timer)
953
1021
  elif stream_info.get("input_settings", {}).get("start_frame", "na") == 1:
954
- self.start_timer = stream_info.get("input_settings", {}).get("stream_time", "NA")
1022
+ candidate = stream_info.get("input_settings", {}).get("stream_time")
1023
+ if not candidate or candidate == "NA":
1024
+ stream_time_str = stream_info.get("input_settings", {}).get("stream_info", {}).get("stream_time", "")
1025
+ if stream_time_str:
1026
+ try:
1027
+ timestamp_str = stream_time_str.replace(" UTC", "")
1028
+ dt = datetime.strptime(timestamp_str, "%Y-%m-%d-%H:%M:%S.%f")
1029
+ ts = dt.replace(tzinfo=timezone.utc).timestamp()
1030
+ candidate = datetime.fromtimestamp(ts, timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
1031
+ except:
1032
+ candidate = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
1033
+ else:
1034
+ candidate = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
1035
+ self.start_timer = candidate
955
1036
  return self._format_timestamp(self.start_timer)
956
1037
 
957
1038
  else:
958
- if self.start_timer is not None:
1039
+ if self.start_timer is not None and self.start_timer != "NA":
959
1040
  return self._format_timestamp(self.start_timer)
960
1041
 
961
1042
  if self._tracking_start_time is None:
@@ -974,52 +1055,6 @@ class FireSmokeUseCase(BaseProcessor):
974
1055
  dt = dt.replace(minute=0, second=0, microsecond=0)
975
1056
  return dt.strftime('%Y:%m:%d %H:%M:%S')
976
1057
 
977
- def _format_timestamp(self, timestamp: Any) -> str:
978
- """Format a timestamp so that exactly two digits follow the decimal point (milliseconds).
979
-
980
- The input can be either:
981
- 1. A numeric Unix timestamp (``float`` / ``int``) – it will first be converted to a
982
- string in the format ``YYYY-MM-DD-HH:MM:SS.ffffff UTC``.
983
- 2. A string already following the same layout.
984
-
985
- The returned value preserves the overall format of the input but truncates or pads
986
- the fractional seconds portion to **exactly two digits**.
987
-
988
- Example
989
- -------
990
- >>> self._format_timestamp("2025-08-19-04:22:47.187574 UTC")
991
- '2025-08-19-04:22:47.18 UTC'
992
- """
993
-
994
- # Convert numeric timestamps to the expected string representation first
995
- if isinstance(timestamp, (int, float)):
996
- timestamp = datetime.fromtimestamp(timestamp, timezone.utc).strftime(
997
- '%Y-%m-%d-%H:%M:%S.%f UTC'
998
- )
999
-
1000
- # Ensure we are working with a string from here on
1001
- if not isinstance(timestamp, str):
1002
- return str(timestamp)
1003
-
1004
- # If there is no fractional component, simply return the original string
1005
- if '.' not in timestamp:
1006
- return timestamp
1007
-
1008
- # Split out the main portion (up to the decimal point)
1009
- main_part, fractional_and_suffix = timestamp.split('.', 1)
1010
-
1011
- # Separate fractional digits from the suffix (typically ' UTC')
1012
- if ' ' in fractional_and_suffix:
1013
- fractional_part, suffix = fractional_and_suffix.split(' ', 1)
1014
- suffix = ' ' + suffix # Re-attach the space removed by split
1015
- else:
1016
- fractional_part, suffix = fractional_and_suffix, ''
1017
-
1018
- # Guarantee exactly two digits for the fractional part
1019
- fractional_part = (fractional_part + '00')[:2]
1020
-
1021
- return f"{main_part}.{fractional_part}{suffix}"
1022
-
1023
1058
  def get_duration_seconds(self, start_time, end_time):
1024
1059
  def parse_relative_time(t):
1025
1060
  """Parse HH:MM:SS(.f) manually into timedelta"""
@@ -1109,4 +1144,3 @@ class FireSmokeUseCase(BaseProcessor):
1109
1144
  return (1,1)
1110
1145
 
1111
1146
 
1112
-