matrice 1.0.99126__py3-none-any.whl → 1.0.99128__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.
- matrice/deploy/utils/post_processing/config.py +5 -1
- matrice/deploy/utils/post_processing/usecases/banana_defect_detection.py +362 -394
- matrice/deploy/utils/post_processing/usecases/weld_defect_detection.py +382 -350
- {matrice-1.0.99126.dist-info → matrice-1.0.99128.dist-info}/METADATA +1 -1
- {matrice-1.0.99126.dist-info → matrice-1.0.99128.dist-info}/RECORD +8 -8
- {matrice-1.0.99126.dist-info → matrice-1.0.99128.dist-info}/WHEEL +0 -0
- {matrice-1.0.99126.dist-info → matrice-1.0.99128.dist-info}/licenses/LICENSE.txt +0 -0
- {matrice-1.0.99126.dist-info → matrice-1.0.99128.dist-info}/top_level.txt +0 -0
@@ -1,22 +1,28 @@
|
|
1
1
|
from typing import Any, Dict, List, Optional
|
2
|
-
from dataclasses import
|
2
|
+
from dataclasses import asdict
|
3
3
|
import time
|
4
4
|
from datetime import datetime, timezone
|
5
5
|
|
6
6
|
from ..core.base import BaseProcessor, ProcessingContext, ProcessingResult, ConfigProtocol
|
7
7
|
from ..utils import (
|
8
8
|
filter_by_confidence,
|
9
|
+
filter_by_categories,
|
9
10
|
apply_category_mapping,
|
10
11
|
count_objects_by_category,
|
12
|
+
count_objects_in_zones,
|
13
|
+
calculate_counting_summary,
|
14
|
+
match_results_structure,
|
11
15
|
bbox_smoothing,
|
12
16
|
BBoxSmoothingConfig,
|
13
17
|
BBoxSmoothingTracker
|
14
18
|
)
|
15
|
-
from
|
19
|
+
from dataclasses import dataclass, field
|
20
|
+
from ..core.config import BaseConfig, AlertConfig, ZoneConfig
|
21
|
+
|
16
22
|
|
17
23
|
@dataclass
|
18
24
|
class WeldDefectConfig(BaseConfig):
|
19
|
-
"""Configuration for
|
25
|
+
"""Configuration for weld defect detection use case."""
|
20
26
|
enable_smoothing: bool = True
|
21
27
|
smoothing_algorithm: str = "observability"
|
22
28
|
smoothing_window_size: int = 20
|
@@ -41,13 +47,23 @@ class WeldDefectConfig(BaseConfig):
|
|
41
47
|
}
|
42
48
|
)
|
43
49
|
|
50
|
+
|
44
51
|
class WeldDefectUseCase(BaseProcessor):
|
45
|
-
|
46
|
-
|
52
|
+
CATEGORY_DISPLAY = {
|
53
|
+
"Bad Welding": "Bad Welding",
|
54
|
+
"Crack": "Crack",
|
55
|
+
"Porosity": "Porosity",
|
56
|
+
"Spatters": "Spatters",
|
57
|
+
"Good Welding": "Good Welding",
|
58
|
+
"Reinforcement": "Reinforcement"
|
59
|
+
}
|
60
|
+
|
47
61
|
def __init__(self):
|
48
62
|
super().__init__("weld_defect_detection")
|
49
63
|
self.category = "weld"
|
50
|
-
self.
|
64
|
+
self.CASE_TYPE: Optional[str] = 'weld_defect_detection'
|
65
|
+
self.CASE_VERSION: Optional[str] = '1.0'
|
66
|
+
self.target_categories = ['Bad Welding', 'Crack', 'Porosity', 'Spatters']
|
51
67
|
self.smoothing_tracker = None
|
52
68
|
self.tracker = None
|
53
69
|
self._total_frame_counter = 0
|
@@ -57,142 +73,36 @@ class WeldDefectUseCase(BaseProcessor):
|
|
57
73
|
self._canonical_tracks: Dict[Any, Dict[str, Any]] = {}
|
58
74
|
self._track_merge_iou_threshold: float = 0.05
|
59
75
|
self._track_merge_time_window: float = 7.0
|
76
|
+
self._ascending_alert_list: List[int] = []
|
77
|
+
self.current_incident_end_timestamp: str = "N/A"
|
60
78
|
|
61
|
-
def
|
62
|
-
|
63
|
-
for det in detections:
|
64
|
-
tid = det.get('track_id')
|
65
|
-
if tid is not None:
|
66
|
-
frame_track_ids.add(tid)
|
67
|
-
total_track_ids = set()
|
68
|
-
for s in getattr(self, '_defect_total_track_ids', {}).values():
|
69
|
-
total_track_ids.update(s)
|
70
|
-
return {
|
71
|
-
"total_count": len(total_track_ids),
|
72
|
-
"current_frame_count": len(frame_track_ids),
|
73
|
-
"total_unique_track_ids": len(total_track_ids),
|
74
|
-
"current_frame_track_ids": list(frame_track_ids),
|
75
|
-
"last_update_time": time.time(),
|
76
|
-
"total_frames_processed": getattr(self, '_total_frame_counter', 0)
|
77
|
-
}
|
78
|
-
|
79
|
-
@staticmethod
|
80
|
-
def _iou(bbox1, bbox2):
|
81
|
-
x1 = max(bbox1["xmin"], bbox2["xmin"])
|
82
|
-
y1 = max(bbox1["ymin"], bbox2["ymin"])
|
83
|
-
x2 = min(bbox1["xmax"], bbox2["xmax"])
|
84
|
-
y2 = min(bbox1["ymax"], bbox2["ymax"])
|
85
|
-
inter_w = max(0, x2 - x1)
|
86
|
-
inter_h = max(0, y2 - y1)
|
87
|
-
inter_area = inter_w * inter_h
|
88
|
-
area1 = (bbox1["xmax"] - bbox1["xmin"]) * (bbox1["ymax"] - bbox1["ymin"])
|
89
|
-
area2 = (bbox2["xmax"] - bbox2["xmin"]) * (bbox2["ymax"] - bbox2["ymin"])
|
90
|
-
union = area1 + area2 - inter_area
|
91
|
-
return 0.0 if union == 0 else inter_area / union
|
92
|
-
|
93
|
-
@staticmethod
|
94
|
-
def _deduplicate_defects(detections, iou_thresh=0.7):
|
95
|
-
filtered = []
|
96
|
-
used = [False] * len(detections)
|
97
|
-
for i, det in enumerate(detections):
|
98
|
-
if used[i]:
|
99
|
-
continue
|
100
|
-
group = [i]
|
101
|
-
for j in range(i+1, len(detections)):
|
102
|
-
if used[j]:
|
103
|
-
continue
|
104
|
-
if det.get("category") == detections[j].get("category"):
|
105
|
-
bbox1 = det.get("bounding_box")
|
106
|
-
bbox2 = detections[j].get("bounding_box")
|
107
|
-
if bbox1 and bbox2:
|
108
|
-
iou = WeldDefectUseCase._iou(bbox1, bbox2)
|
109
|
-
if iou > iou_thresh:
|
110
|
-
used[j] = True
|
111
|
-
group.append(j)
|
112
|
-
best_idx = max(group, key=lambda idx: detections[idx].get("confidence", 0))
|
113
|
-
filtered.append(detections[best_idx])
|
114
|
-
used[best_idx] = True
|
115
|
-
return filtered
|
116
|
-
|
117
|
-
def _update_defect_tracking_state(self, detections: list):
|
118
|
-
if not hasattr(self, "_defect_total_track_ids"):
|
119
|
-
self._defect_total_track_ids = {cat: set() for cat in self.defect_categories}
|
120
|
-
self._defect_current_frame_track_ids = {cat: set() for cat in self.defect_categories}
|
121
|
-
|
122
|
-
for det in detections:
|
123
|
-
cat = det.get("category")
|
124
|
-
raw_track_id = det.get("track_id")
|
125
|
-
if cat not in self.defect_categories or raw_track_id is None:
|
126
|
-
continue
|
127
|
-
bbox = det.get("bounding_box", det.get("bbox"))
|
128
|
-
canonical_id = self._merge_or_register_track(raw_track_id, bbox)
|
129
|
-
det["track_id"] = canonical_id
|
130
|
-
self._defect_total_track_ids.setdefault(cat, set()).add(canonical_id)
|
131
|
-
self._defect_current_frame_track_ids[cat].add(canonical_id)
|
132
|
-
|
133
|
-
def get_total_defect_counts(self):
|
134
|
-
return {cat: len(ids) for cat, ids in getattr(self, '_defect_total_track_ids', {}).items()}
|
135
|
-
|
136
|
-
def _format_timestamp_for_video(self, timestamp: float) -> str:
|
137
|
-
hours = int(timestamp // 3600)
|
138
|
-
minutes = int((timestamp % 3600) // 60)
|
139
|
-
seconds = timestamp % 60
|
140
|
-
return f"{hours:02d}:{minutes:02d}:{seconds:06.2f}"
|
141
|
-
|
142
|
-
def _format_timestamp_for_stream(self, timestamp: float) -> str:
|
143
|
-
dt = datetime.fromtimestamp(timestamp, tz=timezone.utc)
|
144
|
-
return dt.strftime('%Y:%m:%d %H:%M:%S')
|
145
|
-
|
146
|
-
def _get_current_timestamp_str(self, stream: Optional[Dict[str, Any]]) -> str:
|
147
|
-
if not stream:
|
148
|
-
return "00:00:00.00"
|
149
|
-
if stream.get("input_settings", {}).get("stream_type", "video_file") == "video_file":
|
150
|
-
stream_time_str = stream.get("video_timestamp", "")
|
151
|
-
return stream_time_str[:8]
|
152
|
-
else:
|
153
|
-
stream_time_str = stream.get("stream_time", "")
|
154
|
-
if stream_time_str:
|
155
|
-
try:
|
156
|
-
timestamp_str = stream_time_str.replace(" UTC", "")
|
157
|
-
dt = datetime.strptime(timestamp_str, "%Y-%m-%d-%H:%M:%S.%f")
|
158
|
-
timestamp = dt.replace(tzinfo=timezone.utc).timestamp()
|
159
|
-
return self._format_timestamp_for_stream(timestamp)
|
160
|
-
except:
|
161
|
-
return self._format_timestamp_for_stream(time.time())
|
162
|
-
return self._format_timestamp_for_stream(time.time())
|
163
|
-
|
164
|
-
def _get_start_timestamp_str(self, stream: Optional[Dict[str, Any]]) -> str:
|
165
|
-
if not stream:
|
166
|
-
return "00:00:00"
|
167
|
-
if stream.get("input_settings", {}).get("is_video_chunk", False) or stream.get("input_settings", {}).get("stream_type", "video_file") == "video_file":
|
168
|
-
return "00:00:00"
|
169
|
-
else:
|
170
|
-
if self._tracking_start_time is None:
|
171
|
-
stream_time_str = stream.get("stream_time", "")
|
172
|
-
if stream_time_str:
|
173
|
-
try:
|
174
|
-
timestamp_str = stream_time_str.replace(" UTC", "")
|
175
|
-
dt = datetime.strptime(timestamp_str, "%Y-%m-%d-%H:%M:%S.%f")
|
176
|
-
self._tracking_start_time = dt.replace(tzinfo=timezone.utc).timestamp()
|
177
|
-
except:
|
178
|
-
self._tracking_start_time = time.time()
|
179
|
-
else:
|
180
|
-
self._tracking_start_time = time.time()
|
181
|
-
dt = datetime.fromtimestamp(self._tracking_start_time, tz=timezone.utc)
|
182
|
-
dt = dt.replace(minute=0, second=0, microsecond=0)
|
183
|
-
return dt.strftime('%Y:%m:%d %H:%M:%S')
|
184
|
-
|
185
|
-
def process(self, data: Any, config: ConfigProtocol, context: Optional[ProcessingContext] = None, stream: Optional[Dict[str, Any]] = None) -> ProcessingResult:
|
79
|
+
def process(self, data: Any, config: ConfigProtocol, context: Optional[ProcessingContext] = None,
|
80
|
+
stream_info: Optional[Dict[str, Any]] = None) -> ProcessingResult:
|
186
81
|
start_time = time.time()
|
187
82
|
if not isinstance(config, WeldDefectConfig):
|
188
|
-
return self.create_error_result("Invalid config type", usecase=self.name, category=self.category,
|
83
|
+
return self.create_error_result("Invalid config type", usecase=self.name, category=self.category,
|
84
|
+
context=context)
|
189
85
|
if context is None:
|
190
86
|
context = ProcessingContext()
|
191
87
|
|
192
|
-
|
88
|
+
input_format = match_results_structure(data)
|
89
|
+
context.input_format = input_format
|
90
|
+
context.confidence_threshold = config.confidence_threshold
|
91
|
+
|
92
|
+
if config.confidence_threshold is not None:
|
93
|
+
processed_data = filter_by_confidence(data, config.confidence_threshold)
|
94
|
+
self.logger.debug(f"Applied confidence filtering with threshold {config.confidence_threshold}")
|
95
|
+
else:
|
96
|
+
processed_data = data
|
97
|
+
self.logger.debug("No confidence filtering applied")
|
98
|
+
|
193
99
|
if config.index_to_category:
|
194
100
|
processed_data = apply_category_mapping(processed_data, config.index_to_category)
|
195
|
-
|
101
|
+
self.logger.debug("Applied category mapping")
|
102
|
+
|
103
|
+
if config.target_defect_categories:
|
104
|
+
processed_data = [d for d in processed_data if d.get('category') in self.target_categories]
|
105
|
+
self.logger.debug("Applied category filtering")
|
196
106
|
|
197
107
|
if config.enable_smoothing:
|
198
108
|
if self.smoothing_tracker is None:
|
@@ -213,185 +123,387 @@ class WeldDefectUseCase(BaseProcessor):
|
|
213
123
|
if self.tracker is None:
|
214
124
|
tracker_config = TrackerConfig()
|
215
125
|
self.tracker = AdvancedTracker(tracker_config)
|
126
|
+
self.logger.info("Initialized AdvancedTracker for Weld Defect Detection")
|
216
127
|
processed_data = self.tracker.update(processed_data)
|
217
128
|
except Exception as e:
|
218
129
|
self.logger.warning(f"AdvancedTracker failed: {e}")
|
219
130
|
|
220
|
-
|
221
|
-
self._update_defect_tracking_state(processed_data)
|
131
|
+
self._update_tracking_state(processed_data)
|
222
132
|
self._total_frame_counter += 1
|
223
133
|
|
224
134
|
frame_number = None
|
225
|
-
if
|
226
|
-
input_settings =
|
135
|
+
if stream_info:
|
136
|
+
input_settings = stream_info.get("input_settings", {})
|
227
137
|
start_frame = input_settings.get("start_frame")
|
228
138
|
end_frame = input_settings.get("end_frame")
|
229
139
|
if start_frame is not None and end_frame is not None and start_frame == end_frame:
|
230
140
|
frame_number = start_frame
|
231
141
|
|
142
|
+
general_counting_summary = calculate_counting_summary(data)
|
232
143
|
counting_summary = self._count_categories(processed_data, config)
|
233
|
-
|
234
|
-
counting_summary['
|
235
|
-
|
236
|
-
alerts = self._check_alerts(counting_summary, config)
|
144
|
+
total_counts = self.get_total_counts()
|
145
|
+
counting_summary['total_counts'] = total_counts
|
146
|
+
alerts = self._check_alerts(counting_summary, frame_number, config)
|
237
147
|
predictions = self._extract_predictions(processed_data)
|
238
|
-
summary = self._generate_summary(counting_summary, alerts)
|
239
148
|
|
240
|
-
|
241
|
-
tracking_stats_list = self._generate_tracking_stats(counting_summary,
|
149
|
+
incidents_list = self._generate_incidents(counting_summary, alerts, config, frame_number, stream_info)
|
150
|
+
tracking_stats_list = self._generate_tracking_stats(counting_summary, alerts, config, frame_number, stream_info)
|
151
|
+
business_analytics_list = self._generate_business_analytics(counting_summary, alerts, config, stream_info, is_empty=True)
|
152
|
+
summary_list = self._generate_summary(counting_summary, incidents_list, tracking_stats_list, business_analytics_list, alerts)
|
242
153
|
|
243
|
-
|
154
|
+
incidents = incidents_list[0] if incidents_list else {}
|
244
155
|
tracking_stats = tracking_stats_list[0] if tracking_stats_list else {}
|
156
|
+
business_analytics = business_analytics_list[0] if business_analytics_list else {}
|
157
|
+
summary = summary_list[0] if summary_list else {}
|
158
|
+
agg_summary = {str(frame_number): {
|
159
|
+
"incidents": incidents,
|
160
|
+
"tracking_stats": tracking_stats,
|
161
|
+
"business_analytics": business_analytics,
|
162
|
+
"alerts": alerts,
|
163
|
+
"human_text": summary}
|
164
|
+
}
|
245
165
|
|
246
166
|
context.mark_completed()
|
247
167
|
result = self.create_result(
|
248
|
-
data={
|
249
|
-
"counting_summary": counting_summary,
|
250
|
-
"alerts": alerts,
|
251
|
-
"total_defects": counting_summary.get("total_count", 0),
|
252
|
-
"events": events,
|
253
|
-
"tracking_stats": tracking_stats,
|
254
|
-
},
|
168
|
+
data={"agg_summary": agg_summary},
|
255
169
|
usecase=self.name,
|
256
170
|
category=self.category,
|
257
171
|
context=context
|
258
172
|
)
|
259
|
-
result.summary = summary
|
260
|
-
result.insights = insights
|
261
|
-
result.predictions = predictions
|
262
173
|
return result
|
263
174
|
|
264
|
-
def
|
265
|
-
|
266
|
-
|
267
|
-
|
175
|
+
def _check_alerts(self, summary: dict, frame_number: Any, config: WeldDefectConfig) -> List[Dict]:
|
176
|
+
def get_trend(data, lookback=900, threshold=0.6):
|
177
|
+
window = data[-lookback:] if len(data) >= lookback else data
|
178
|
+
if len(window) < 2:
|
179
|
+
return True
|
180
|
+
increasing = 0
|
181
|
+
total = 0
|
182
|
+
for i in range(1, len(window)):
|
183
|
+
if window[i] >= window[i - 1]:
|
184
|
+
increasing += 1
|
185
|
+
total += 1
|
186
|
+
ratio = increasing / total
|
187
|
+
return ratio >= threshold
|
268
188
|
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
self._track_aliases.clear()
|
275
|
-
self._canonical_tracks.clear()
|
276
|
-
self.logger.info("Weld Defect tracking state reset")
|
189
|
+
frame_key = str(frame_number) if frame_number is not None else "current_frame"
|
190
|
+
alerts = []
|
191
|
+
total_detections = summary.get("total_count", 0)
|
192
|
+
total_counts_dict = summary.get("total_counts", {})
|
193
|
+
per_category_count = summary.get("per_category_count", {})
|
277
194
|
|
278
|
-
|
279
|
-
|
280
|
-
self.reset_defect_tracking()
|
281
|
-
self.logger.info("All Weld Defect tracking state reset")
|
195
|
+
if not config.alert_config:
|
196
|
+
return alerts
|
282
197
|
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
|
198
|
+
if hasattr(config.alert_config, 'count_thresholds') and config.alert_config.count_thresholds:
|
199
|
+
for category, threshold in config.alert_config.count_thresholds.items():
|
200
|
+
if category == "all" and total_detections > threshold:
|
201
|
+
alerts.append({
|
202
|
+
"alert_type": getattr(config.alert_config, 'alert_type', ['Default']),
|
203
|
+
"alert_id": f"alert_{category}_{frame_key}",
|
204
|
+
"incident_category": self.CASE_TYPE,
|
205
|
+
"threshold_level": threshold,
|
206
|
+
"ascending": get_trend(self._ascending_alert_list, lookback=900, threshold=0.8),
|
207
|
+
"settings": {t: v for t, v in zip(getattr(config.alert_config, 'alert_type', ['Default']),
|
208
|
+
getattr(config.alert_config, 'alert_value', ['JSON']))}
|
209
|
+
})
|
210
|
+
elif category in per_category_count:
|
211
|
+
count = per_category_count[category]
|
212
|
+
if count > threshold:
|
213
|
+
alerts.append({
|
214
|
+
"alert_type": getattr(config.alert_config, 'alert_type', ['Default']),
|
215
|
+
"alert_id": f"alert_{category}_{frame_key}",
|
216
|
+
"incident_category": self.CASE_TYPE,
|
217
|
+
"threshold_level": threshold,
|
218
|
+
"ascending": get_trend(self._ascending_alert_list, lookback=900, threshold=0.8),
|
219
|
+
"settings": {t: v for t, v in zip(getattr(config.alert_config, 'alert_type', ['Default']),
|
220
|
+
getattr(config.alert_config, 'alert_value', ['JSON']))}
|
221
|
+
})
|
222
|
+
return alerts
|
288
223
|
|
289
|
-
|
290
|
-
|
224
|
+
def _generate_incidents(self, counting_summary: Dict, alerts: List, config: WeldDefectConfig,
|
225
|
+
frame_number: Optional[int] = None, stream_info: Optional[Dict[str, Any]] = None) -> List[Dict]:
|
226
|
+
incidents = []
|
227
|
+
total_detections = counting_summary.get("total_count", 0)
|
228
|
+
current_timestamp = self._get_current_timestamp_str(stream_info)
|
229
|
+
camera_info = self.get_camera_info_from_stream(stream_info)
|
230
|
+
self._ascending_alert_list = self._ascending_alert_list[-900:] if len(self._ascending_alert_list) > 900 else self._ascending_alert_list
|
231
|
+
|
232
|
+
if total_detections > 0:
|
233
|
+
level = "low"
|
291
234
|
intensity = 5.0
|
235
|
+
start_timestamp = self._get_start_timestamp_str(stream_info)
|
236
|
+
if start_timestamp and self.current_incident_end_timestamp == 'N/A':
|
237
|
+
self.current_incident_end_timestamp = 'Incident still active'
|
238
|
+
elif start_timestamp and self.current_incident_end_timestamp == 'Incident still active':
|
239
|
+
if len(self._ascending_alert_list) >= 15 and sum(self._ascending_alert_list[-15:]) / 15 < 1.5:
|
240
|
+
self.current_incident_end_timestamp = current_timestamp
|
241
|
+
elif self.current_incident_end_timestamp != 'Incident still active' and self.current_incident_end_timestamp != 'N/A':
|
242
|
+
self.current_incident_end_timestamp = 'N/A'
|
243
|
+
|
292
244
|
if config.alert_config and config.alert_config.count_thresholds:
|
293
|
-
threshold = config.alert_config.count_thresholds.get("all",
|
294
|
-
intensity = min(10.0, (
|
295
|
-
|
245
|
+
threshold = config.alert_config.count_thresholds.get("all", 15)
|
246
|
+
intensity = min(10.0, (total_detections / threshold) * 10)
|
247
|
+
if intensity >= 9:
|
248
|
+
level = "critical"
|
249
|
+
self._ascending_alert_list.append(3)
|
250
|
+
elif intensity >= 7:
|
251
|
+
level = "significant"
|
252
|
+
self._ascending_alert_list.append(2)
|
253
|
+
elif intensity >= 5:
|
254
|
+
level = "medium"
|
255
|
+
self._ascending_alert_list.append(1)
|
256
|
+
else:
|
257
|
+
level = "low"
|
258
|
+
self._ascending_alert_list.append(0)
|
296
259
|
else:
|
297
|
-
|
298
|
-
|
260
|
+
if total_detections > 30:
|
261
|
+
level = "critical"
|
262
|
+
intensity = 10.0
|
263
|
+
self._ascending_alert_list.append(3)
|
264
|
+
elif total_detections > 25:
|
265
|
+
level = "significant"
|
266
|
+
intensity = 9.0
|
267
|
+
self._ascending_alert_list.append(2)
|
268
|
+
elif total_detections > 15:
|
269
|
+
level = "medium"
|
270
|
+
intensity = 7.0
|
271
|
+
self._ascending_alert_list.append(1)
|
272
|
+
else:
|
273
|
+
level = "low"
|
274
|
+
intensity = min(10.0, total_detections / 3.0)
|
275
|
+
self._ascending_alert_list.append(0)
|
299
276
|
|
300
|
-
human_text_lines = ["
|
301
|
-
human_text_lines.append(f"
|
277
|
+
human_text_lines = [f"INCIDENTS DETECTED @ {current_timestamp}:"]
|
278
|
+
human_text_lines.append(f"\tSeverity Level: {(self.CASE_TYPE, level)}")
|
302
279
|
human_text = "\n".join(human_text_lines)
|
303
280
|
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
|
317
|
-
|
318
|
-
|
319
|
-
|
320
|
-
|
321
|
-
|
322
|
-
|
323
|
-
|
324
|
-
|
325
|
-
|
326
|
-
|
327
|
-
|
328
|
-
|
329
|
-
|
330
|
-
|
331
|
-
"intensity": 8.0,
|
332
|
-
"config": {"min_value": 0, "max_value": 10, "level_settings": {"info": 2, "warning": 5, "critical": 7}},
|
333
|
-
"application_name": "Defect Alert System",
|
334
|
-
"application_version": "1.0",
|
335
|
-
"location_info": alert.get("zone"),
|
336
|
-
"human_text": f"{datetime.now(timezone.utc).strftime('%Y-%m-%d-%H:%M:%S UTC')} : {intensity_message}"
|
337
|
-
}
|
338
|
-
frame_events.append(alert_event)
|
281
|
+
alert_settings = []
|
282
|
+
if config.alert_config and hasattr(config.alert_config, 'alert_type'):
|
283
|
+
alert_settings.append({
|
284
|
+
"alert_type": getattr(config.alert_config, 'alert_type', ['Default']),
|
285
|
+
"incident_category": self.CASE_TYPE,
|
286
|
+
"threshold_level": config.alert_config.count_thresholds if hasattr(config.alert_config, 'count_thresholds') else {},
|
287
|
+
"ascending": True,
|
288
|
+
"settings": {t: v for t, v in zip(getattr(config.alert_config, 'alert_type', ['Default']),
|
289
|
+
getattr(config.alert_config, 'alert_value', ['JSON']))}
|
290
|
+
})
|
291
|
+
|
292
|
+
event = self.create_incident(
|
293
|
+
incident_id=f"{self.CASE_TYPE}_{str(frame_number)}",
|
294
|
+
incident_type=self.CASE_TYPE,
|
295
|
+
severity_level=level,
|
296
|
+
human_text=human_text,
|
297
|
+
camera_info=camera_info,
|
298
|
+
alerts=alerts,
|
299
|
+
alert_settings=alert_settings,
|
300
|
+
start_time=start_timestamp,
|
301
|
+
end_time=self.current_incident_end_timestamp,
|
302
|
+
level_settings={"low": 1, "medium": 3, "significant": 4, "critical": 7}
|
303
|
+
)
|
304
|
+
incidents.append(event)
|
305
|
+
else:
|
306
|
+
self._ascending_alert_list.append(0)
|
307
|
+
incidents.append({})
|
339
308
|
|
340
|
-
return
|
309
|
+
return incidents
|
341
310
|
|
342
|
-
def _generate_tracking_stats(self, counting_summary: Dict,
|
343
|
-
|
344
|
-
|
345
|
-
|
346
|
-
|
347
|
-
|
348
|
-
total_defect_counts = counting_summary.get("total_defect_counts", {})
|
349
|
-
cumulative_total = sum(total_defect_counts.values()) if total_defect_counts else 0
|
311
|
+
def _generate_tracking_stats(self, counting_summary: Dict, alerts: List, config: WeldDefectConfig,
|
312
|
+
frame_number: Optional[int] = None, stream_info: Optional[Dict[str, Any]] = None) -> List[Dict]:
|
313
|
+
camera_info = self.get_camera_info_from_stream(stream_info)
|
314
|
+
tracking_stats = []
|
315
|
+
total_detections = counting_summary.get("total_count", 0)
|
316
|
+
total_counts_dict = counting_summary.get("total_counts", {})
|
350
317
|
per_category_count = counting_summary.get("per_category_count", {})
|
351
|
-
|
352
|
-
|
353
|
-
|
354
|
-
|
355
|
-
|
356
|
-
|
357
|
-
|
358
|
-
|
359
|
-
|
360
|
-
|
361
|
-
|
362
|
-
|
363
|
-
|
318
|
+
current_timestamp = self._get_current_timestamp_str(stream_info, precision=False)
|
319
|
+
start_timestamp = self._get_start_timestamp_str(stream_info, precision=False)
|
320
|
+
high_precision_start_timestamp = self._get_current_timestamp_str(stream_info, precision=True)
|
321
|
+
high_precision_reset_timestamp = self._get_start_timestamp_str(stream_info, precision=True)
|
322
|
+
|
323
|
+
total_counts = [{"category": cat, "count": count} for cat, count in total_counts_dict.items() if count > 0]
|
324
|
+
current_counts = [{"category": cat, "count": count} for cat, count in per_category_count.items() if count > 0 or total_detections > 0]
|
325
|
+
|
326
|
+
detections = []
|
327
|
+
for detection in counting_summary.get("detections", []):
|
328
|
+
bbox = detection.get("bounding_box", {})
|
329
|
+
category = detection.get("category", "defect")
|
330
|
+
if detection.get("masks"):
|
331
|
+
segmentation = detection.get("masks", [])
|
332
|
+
detection_obj = self.create_detection_object(category, bbox, segmentation=segmentation)
|
333
|
+
elif detection.get("segmentation"):
|
334
|
+
segmentation = detection.get("segmentation")
|
335
|
+
detection_obj = self.create_detection_object(category, bbox, segmentation=segmentation)
|
336
|
+
elif detection.get("mask"):
|
337
|
+
segmentation = detection.get("mask")
|
338
|
+
detection_obj = self.create_detection_object(category, bbox, segmentation=segmentation)
|
364
339
|
else:
|
365
|
-
|
366
|
-
|
340
|
+
detection_obj = self.create_detection_object(category, bbox)
|
341
|
+
detections.append(detection_obj)
|
342
|
+
|
343
|
+
alert_settings = []
|
344
|
+
if config.alert_config and hasattr(config.alert_config, 'alert_type'):
|
345
|
+
alert_settings.append({
|
346
|
+
"alert_type": getattr(config.alert_config, 'alert_type', ['Default']),
|
347
|
+
"incident_category": self.CASE_TYPE,
|
348
|
+
"threshold_level": config.alert_config.count_thresholds if hasattr(config.alert_config, 'count_thresholds') else {},
|
349
|
+
"ascending": True,
|
350
|
+
"settings": {t: v for t, v in zip(getattr(config.alert_config, 'alert_type', ['Default']),
|
351
|
+
getattr(config.alert_config, 'alert_value', ['JSON']))}
|
352
|
+
})
|
353
|
+
|
354
|
+
human_text_lines = [f"Tracking Statistics:"]
|
355
|
+
human_text_lines.append(f"CURRENT FRAME @ {current_timestamp}")
|
356
|
+
for cat, count in per_category_count.items():
|
357
|
+
human_text_lines.append(f"\t{cat}: {count}")
|
358
|
+
human_text_lines.append(f"TOTAL SINCE {start_timestamp}")
|
359
|
+
for cat, count in total_counts_dict.items():
|
360
|
+
if count > 0:
|
361
|
+
human_text_lines.append(f"\t{cat}: {count}")
|
362
|
+
if alerts:
|
363
|
+
for alert in alerts:
|
364
|
+
human_text_lines.append(f"Alerts: {alert.get('settings', {})} sent @ {current_timestamp}")
|
367
365
|
else:
|
368
|
-
human_text_lines.append(
|
366
|
+
human_text_lines.append("Alerts: None")
|
367
|
+
human_text = "\n".join(human_text_lines)
|
369
368
|
|
370
|
-
|
371
|
-
|
372
|
-
|
373
|
-
|
374
|
-
|
375
|
-
|
369
|
+
reset_settings = [{"interval_type": "daily", "reset_time": {"value": 9, "time_unit": "hour"}}]
|
370
|
+
tracking_stat = self.create_tracking_stats(
|
371
|
+
total_counts=total_counts,
|
372
|
+
current_counts=current_counts,
|
373
|
+
detections=detections,
|
374
|
+
human_text=human_text,
|
375
|
+
camera_info=camera_info,
|
376
|
+
alerts=alerts,
|
377
|
+
alert_settings=alert_settings,
|
378
|
+
reset_settings=reset_settings,
|
379
|
+
start_time=high_precision_start_timestamp,
|
380
|
+
reset_time=high_precision_reset_timestamp
|
381
|
+
)
|
382
|
+
tracking_stats.append(tracking_stat)
|
383
|
+
return tracking_stats
|
376
384
|
|
377
|
-
|
385
|
+
def _generate_business_analytics(self, counting_summary: Dict, alerts: Any, config: WeldDefectConfig,
|
386
|
+
stream_info: Optional[Dict[str, Any]] = None, is_empty=False) -> List[Dict]:
|
387
|
+
if is_empty:
|
388
|
+
return []
|
389
|
+
|
390
|
+
def _generate_summary(self, summary: dict, incidents: List, tracking_stats: List, business_analytics: List, alerts: List) -> List[dict]:
|
391
|
+
lines = {}
|
392
|
+
lines["Application Name"] = self.CASE_TYPE
|
393
|
+
lines["Application Version"] = self.CASE_VERSION
|
394
|
+
if len(incidents) > 0:
|
395
|
+
lines["Incidents"] = f"\n\t{incidents[0].get('human_text', 'No incidents detected')}\n"
|
396
|
+
if len(tracking_stats) > 0:
|
397
|
+
lines["Tracking Statistics"] = f"\t{tracking_stats[0].get('human_text', 'No tracking statistics detected')}\n"
|
398
|
+
if len(business_analytics) > 0:
|
399
|
+
lines["Business Analytics"] = f"\t{business_analytics[0].get('human_text', 'No business analytics detected')}\n"
|
400
|
+
if len(incidents) == 0 and len(tracking_stats) == 0 and len(business_analytics) == 0:
|
401
|
+
lines["Summary"] = "No Summary Data"
|
402
|
+
return [lines]
|
378
403
|
|
379
|
-
|
380
|
-
|
381
|
-
|
382
|
-
|
383
|
-
|
384
|
-
|
385
|
-
|
386
|
-
|
387
|
-
|
388
|
-
|
389
|
-
"
|
390
|
-
"
|
404
|
+
def _get_track_ids_info(self, detections: list) -> Dict[str, Any]:
|
405
|
+
frame_track_ids = set()
|
406
|
+
for det in detections:
|
407
|
+
tid = det.get('track_id')
|
408
|
+
if tid is not None:
|
409
|
+
frame_track_ids.add(tid)
|
410
|
+
total_track_ids = set()
|
411
|
+
for s in getattr(self, '_per_category_total_track_ids', {}).values():
|
412
|
+
total_track_ids.update(s)
|
413
|
+
return {
|
414
|
+
"total_count": len(total_track_ids),
|
415
|
+
"current_frame_count": len(frame_track_ids),
|
416
|
+
"total_unique_track_ids": len(total_track_ids),
|
417
|
+
"current_frame_track_ids": list(frame_track_ids),
|
418
|
+
"last_update_time": time.time(),
|
419
|
+
"total_frames_processed": getattr(self, '_total_frame_counter', 0)
|
391
420
|
}
|
392
421
|
|
393
|
-
|
394
|
-
|
422
|
+
def _update_tracking_state(self, detections: list):
|
423
|
+
if not hasattr(self, "_per_category_total_track_ids"):
|
424
|
+
self._per_category_total_track_ids = {cat: set() for cat in self.target_categories}
|
425
|
+
self._current_frame_track_ids = {cat: set() for cat in self.target_categories}
|
426
|
+
|
427
|
+
for det in detections:
|
428
|
+
cat = det.get("category")
|
429
|
+
raw_track_id = det.get("track_id")
|
430
|
+
if cat not in self.target_categories or raw_track_id is None:
|
431
|
+
continue
|
432
|
+
bbox = det.get("bounding_box", det.get("bbox"))
|
433
|
+
canonical_id = self._merge_or_register_track(raw_track_id, bbox)
|
434
|
+
det["track_id"] = canonical_id
|
435
|
+
self._per_category_total_track_ids.setdefault(cat, set()).add(canonical_id)
|
436
|
+
self._current_frame_track_ids[cat].add(canonical_id)
|
437
|
+
|
438
|
+
def get_total_counts(self):
|
439
|
+
return {cat: len(ids) for cat, ids in getattr(self, '_per_category_total_track_ids', {}).items()}
|
440
|
+
|
441
|
+
def _format_timestamp_for_stream(self, timestamp: float) -> str:
|
442
|
+
dt = datetime.fromtimestamp(timestamp, tz=timezone.utc)
|
443
|
+
return dt.strftime('%Y:%m:%d %H:%M:%S')
|
444
|
+
|
445
|
+
def _format_timestamp_for_video(self, timestamp: float) -> str:
|
446
|
+
hours = int(timestamp // 3600)
|
447
|
+
minutes = int((timestamp % 3600) // 60)
|
448
|
+
seconds = round(float(timestamp % 60), 2)
|
449
|
+
return f"{hours:02d}:{minutes:02d}:{seconds:.1f}"
|
450
|
+
|
451
|
+
def _get_current_timestamp_str(self, stream_info: Optional[Dict[str, Any]], precision=False, frame_id: Optional[str] = None) -> str:
|
452
|
+
if not stream_info:
|
453
|
+
return "00:00:00.00"
|
454
|
+
if precision:
|
455
|
+
if stream_info.get("input_settings", {}).get("start_frame", "na") != "na":
|
456
|
+
if frame_id:
|
457
|
+
start_time = int(frame_id) / stream_info.get("input_settings", {}).get("original_fps", 30)
|
458
|
+
else:
|
459
|
+
start_time = stream_info.get("input_settings", {}).get("start_frame", 30) / stream_info.get("input_settings", {}).get("original_fps", 30)
|
460
|
+
return self._format_timestamp_for_video(start_time)
|
461
|
+
else:
|
462
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
|
463
|
+
if stream_info.get("input_settings", {}).get("start_frame", "na") != "na":
|
464
|
+
if frame_id:
|
465
|
+
start_time = int(frame_id) / stream_info.get("input_settings", {}).get("original_fps", 30)
|
466
|
+
else:
|
467
|
+
start_time = stream_info.get("input_settings", {}).get("start_frame", 30) / stream_info.get("input_settings", {}).get("original_fps", 30)
|
468
|
+
return self._format_timestamp_for_video(start_time)
|
469
|
+
else:
|
470
|
+
stream_time_str = stream_info.get("input_settings", {}).get("stream_info", {}).get("stream_time", "")
|
471
|
+
if stream_time_str:
|
472
|
+
try:
|
473
|
+
timestamp_str = stream_time_str.replace(" UTC", "")
|
474
|
+
dt = datetime.strptime(timestamp_str, "%Y-%m-%d-%H:%M:%S.%f")
|
475
|
+
timestamp = dt.replace(tzinfo=timezone.utc).timestamp()
|
476
|
+
return self._format_timestamp_for_stream(timestamp)
|
477
|
+
except:
|
478
|
+
return self._format_timestamp_for_stream(time.time())
|
479
|
+
else:
|
480
|
+
return self._format_timestamp_for_stream(time.time())
|
481
|
+
|
482
|
+
def _get_start_timestamp_str(self, stream_info: Optional[Dict[str, Any]], precision=False) -> str:
|
483
|
+
if not stream_info:
|
484
|
+
return "00:00:00"
|
485
|
+
if precision:
|
486
|
+
if stream_info.get("input_settings", {}).get("start_frame", "na") != "na":
|
487
|
+
return "00:00:00"
|
488
|
+
else:
|
489
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%d-%H:%M:%S.%f UTC")
|
490
|
+
if stream_info.get("input_settings", {}).get("start_frame", "na") != "na":
|
491
|
+
return "00:00:00"
|
492
|
+
else:
|
493
|
+
if self._tracking_start_time is None:
|
494
|
+
stream_time_str = stream_info.get("input_settings", {}).get("stream_info", {}).get("stream_time", "")
|
495
|
+
if stream_time_str:
|
496
|
+
try:
|
497
|
+
timestamp_str = stream_time_str.replace(" UTC", "")
|
498
|
+
dt = datetime.strptime(timestamp_str, "%Y-%m-%d-%H:%M:%S.%f")
|
499
|
+
self._tracking_start_time = dt.replace(tzinfo=timezone.utc).timestamp()
|
500
|
+
except:
|
501
|
+
self._tracking_start_time = time.time()
|
502
|
+
else:
|
503
|
+
self._tracking_start_time = time.time()
|
504
|
+
dt = datetime.fromtimestamp(self._tracking_start_time, tz=timezone.utc)
|
505
|
+
dt = dt.replace(minute=0, second=0, microsecond=0)
|
506
|
+
return dt.strftime('%Y:%m:%d %H:%M:%S')
|
395
507
|
|
396
508
|
def _count_categories(self, detections: list, config: WeldDefectConfig) -> dict:
|
397
509
|
counts = {}
|
@@ -413,61 +525,6 @@ class WeldDefectUseCase(BaseProcessor):
|
|
413
525
|
]
|
414
526
|
}
|
415
527
|
|
416
|
-
CATEGORY_DISPLAY = {
|
417
|
-
"Bad Welding": "Bad Welding",
|
418
|
-
"Crack": "Crack",
|
419
|
-
"Porosity": "Porosity",
|
420
|
-
"Spatters": "Spatters",
|
421
|
-
"Good Welding": "Good Welding",
|
422
|
-
"Reinforcement": "Reinforcement"
|
423
|
-
}
|
424
|
-
|
425
|
-
def _generate_insights(self, summary: dict, config: WeldDefectConfig) -> List[str]:
|
426
|
-
insights = []
|
427
|
-
per_cat = summary.get("per_category_count", {})
|
428
|
-
total_defects = summary.get("total_count", 0)
|
429
|
-
|
430
|
-
if total_defects == 0:
|
431
|
-
insights.append("No weld defects detected in the scene")
|
432
|
-
return insights
|
433
|
-
insights.append(f"EVENT: Detected {total_defects} weld defect(s) in the scene")
|
434
|
-
intensity_threshold = config.alert_config.count_thresholds.get("all", 5) if config.alert_config and config.alert_config.count_thresholds else 5
|
435
|
-
percentage = (total_defects / intensity_threshold) * 100
|
436
|
-
insights.append(f"INTENSITY: {'Severe' if percentage > 70 else 'Heavy' if percentage > 50 else 'Moderate' if percentage > 20 else 'Low'} defect density ({percentage:.1f}% of capacity)")
|
437
|
-
for cat, count in per_cat.items():
|
438
|
-
display = self.CATEGORY_DISPLAY.get(cat, cat)
|
439
|
-
insights.append(f"{display}: {count}")
|
440
|
-
return insights
|
441
|
-
|
442
|
-
def _check_alerts(self, summary: dict, config: WeldDefectConfig) -> List[Dict]:
|
443
|
-
alerts = []
|
444
|
-
if not config.alert_config:
|
445
|
-
return alerts
|
446
|
-
total = summary.get("total_count", 0)
|
447
|
-
if config.alert_config.count_thresholds:
|
448
|
-
for category, threshold in config.alert_config.count_thresholds.items():
|
449
|
-
if category == "all" and total >= threshold:
|
450
|
-
alerts.append({
|
451
|
-
"type": "count_threshold",
|
452
|
-
"severity": "warning",
|
453
|
-
"message": f"Total defect count ({total}) exceeds threshold ({threshold})",
|
454
|
-
"category": category,
|
455
|
-
"current_count": total,
|
456
|
-
"threshold": threshold
|
457
|
-
})
|
458
|
-
elif category in summary.get("per_category_count", {}):
|
459
|
-
count = summary.get("per_category_count", {})[category]
|
460
|
-
if count >= threshold:
|
461
|
-
alerts.append({
|
462
|
-
"type": "count_threshold",
|
463
|
-
"severity": "warning",
|
464
|
-
"message": f"{category} count ({count}) exceeds threshold ({threshold})",
|
465
|
-
"category": category,
|
466
|
-
"current_count": count,
|
467
|
-
"threshold": threshold
|
468
|
-
})
|
469
|
-
return alerts
|
470
|
-
|
471
528
|
def _extract_predictions(self, detections: list) -> List[Dict[str, Any]]:
|
472
529
|
return [
|
473
530
|
{
|
@@ -478,25 +535,6 @@ class WeldDefectUseCase(BaseProcessor):
|
|
478
535
|
for det in detections
|
479
536
|
]
|
480
537
|
|
481
|
-
def _generate_summary(self, summary: dict, alerts: List) -> str:
|
482
|
-
total = summary.get("total_count", 0)
|
483
|
-
per_cat = summary.get("per_category_count", {})
|
484
|
-
cumulative = summary.get("total_defect_counts", {})
|
485
|
-
cumulative_total = sum(cumulative.values()) if cumulative else 0
|
486
|
-
lines = []
|
487
|
-
if total > 0:
|
488
|
-
lines.append(f"{total} Weld Defect(s) detected")
|
489
|
-
if per_cat:
|
490
|
-
lines.append("Defects:")
|
491
|
-
for cat, count in per_cat.items():
|
492
|
-
lines.append(f"\t{cat}: {count}")
|
493
|
-
else:
|
494
|
-
lines.append("No weld defect detected")
|
495
|
-
lines.append(f"Total defects detected: {cumulative_total}")
|
496
|
-
if alerts:
|
497
|
-
lines.append(f"{len(alerts)} alert(s)")
|
498
|
-
return "\n".join(lines)
|
499
|
-
|
500
538
|
def _compute_iou(self, box1: Any, box2: Any) -> float:
|
501
539
|
def _bbox_to_list(bbox):
|
502
540
|
if bbox is None:
|
@@ -518,31 +556,25 @@ class WeldDefectUseCase(BaseProcessor):
|
|
518
556
|
return 0.0
|
519
557
|
x1_min, y1_min, x1_max, y1_max = l1
|
520
558
|
x2_min, y2_min, x2_max, y2_max = l2
|
521
|
-
|
522
559
|
x1_min, x1_max = min(x1_min, x1_max), max(x1_min, x1_max)
|
523
560
|
y1_min, y1_max = min(y1_min, y1_max), max(y1_min, y1_max)
|
524
561
|
x2_min, x2_max = min(x2_min, x2_max), max(x2_min, x2_max)
|
525
562
|
y2_min, y2_max = min(y2_min, y2_max), max(y2_min, y2_max)
|
526
|
-
|
527
563
|
inter_x_min = max(x1_min, x2_min)
|
528
564
|
inter_y_min = max(y1_min, y2_min)
|
529
565
|
inter_x_max = min(x1_max, x2_max)
|
530
566
|
inter_y_max = min(y1_max, y2_max)
|
531
|
-
|
532
567
|
inter_w = max(0.0, inter_x_max - inter_x_min)
|
533
568
|
inter_h = max(0.0, inter_y_max - inter_y_min)
|
534
569
|
inter_area = inter_w * inter_h
|
535
|
-
|
536
570
|
area1 = (x1_max - x1_min) * (y1_max - y1_min)
|
537
571
|
area2 = (x2_max - x2_min) * (y2_max - y2_min)
|
538
572
|
union_area = area1 + area2 - inter_area
|
539
|
-
|
540
573
|
return (inter_area / union_area) if union_area > 0 else 0.0
|
541
574
|
|
542
575
|
def _merge_or_register_track(self, raw_id: Any, bbox: Any) -> Any:
|
543
576
|
if raw_id is None or bbox is None:
|
544
577
|
return raw_id
|
545
|
-
|
546
578
|
now = time.time()
|
547
579
|
if raw_id in self._track_aliases:
|
548
580
|
canonical_id = self._track_aliases[raw_id]
|
@@ -552,7 +584,6 @@ class WeldDefectUseCase(BaseProcessor):
|
|
552
584
|
track_info["last_update"] = now
|
553
585
|
track_info["raw_ids"].add(raw_id)
|
554
586
|
return canonical_id
|
555
|
-
|
556
587
|
for canonical_id, info in self._canonical_tracks.items():
|
557
588
|
if now - info["last_update"] > self._track_merge_time_window:
|
558
589
|
continue
|
@@ -563,7 +594,6 @@ class WeldDefectUseCase(BaseProcessor):
|
|
563
594
|
info["last_update"] = now
|
564
595
|
info["raw_ids"].add(raw_id)
|
565
596
|
return canonical_id
|
566
|
-
|
567
597
|
canonical_id = raw_id
|
568
598
|
self._track_aliases[raw_id] = canonical_id
|
569
599
|
self._canonical_tracks[canonical_id] = {
|
@@ -577,7 +607,9 @@ class WeldDefectUseCase(BaseProcessor):
|
|
577
607
|
return datetime.fromtimestamp(timestamp, timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')
|
578
608
|
|
579
609
|
def _get_tracking_start_time(self) -> str:
|
580
|
-
|
610
|
+
if self._tracking_start_time is None:
|
611
|
+
return "N/A"
|
612
|
+
return self._format_timestamp(self._tracking_start_time)
|
581
613
|
|
582
614
|
def _set_tracking_start_time(self) -> None:
|
583
615
|
self._tracking_start_time = time.time()
|