Coverage for orchestr_ant_ion / yolo / monitor.py: 0%
354 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 08:36 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 08:36 +0000
1"""Main entry point for the YOLO monitoring pipeline."""
3from __future__ import annotations
5import os
6import platform
7import threading
8import time
9from collections import deque
10from dataclasses import dataclass
11from typing import TYPE_CHECKING, Any, Protocol, cast
13import cv2
14import numpy as np
15import onnxruntime as ort
16import psutil
17from loguru import logger
19from orchestr_ant_ion.pipeline.capture import CameraCapture
20from orchestr_ant_ion.pipeline.capture.gstreamer import find_gstreamer_launch
21from orchestr_ant_ion.pipeline.constants import (
22 DEBUG_LOG_INTERVAL_SECONDS,
23 DEFAULT_CPU_TDP_WATTS,
24 ENERGY_WH_INITIAL,
25 LOG_INTERVAL_SECONDS,
26 POWER_WATTS_INITIAL,
27 RESOURCE_LOG_INTERVAL_SECONDS,
28 STATS_UPDATE_INTERVAL_FRAMES,
29)
30from orchestr_ant_ion.pipeline.logging import (
31 attach_log_buffer,
32 configure_logging,
33 create_log_buffer,
34)
35from orchestr_ant_ion.pipeline.metrics.performance import PerformanceTracker
36from orchestr_ant_ion.pipeline.monitoring.power import (
37 PowerMonitor,
38 get_cpu_freq_ratio,
39)
40from orchestr_ant_ion.pipeline.monitoring.system import SystemMonitor
41from orchestr_ant_ion.pipeline.tracking.centroid import SimpleCentroidTracker
42from orchestr_ant_ion.pipeline.types import (
43 CameraConfig,
44 CaptureBackend,
45 PerformanceMetrics,
46 SystemStats,
47)
48from orchestr_ant_ion.pipeline.ui.dearpygui import DearPyGuiViewer
49from orchestr_ant_ion.yolo.cli import parse_args
50from orchestr_ant_ion.yolo.core.postprocess import DecodeConfig, postprocess
51from orchestr_ant_ion.yolo.core.preprocess import infer_input_size, preprocess
52from orchestr_ant_ion.yolo.ui.draw import draw_detections
55WxPythonViewer: Any = None
56_WX_VIEWER_IMPORT_ERROR: ImportError | None = None
57try:
58 from orchestr_ant_ion.pipeline.ui.wx import WxPythonViewer as _WxPythonViewer
59except ImportError as exc: # pragma: no cover - optional dependency
60 _WX_VIEWER_IMPORT_ERROR = exc
61else:
62 WxPythonViewer = _WxPythonViewer
63 _WX_VIEWER_IMPORT_ERROR = None
65if TYPE_CHECKING:
66 import argparse
67 from collections.abc import Callable
69 from orchestr_ant_ion.pipeline.types import (
70 Track,
71 )
74get_cpu_info: Callable[[], dict] | None = None
75try:
76 from cpuinfo import get_cpu_info as _real_get_cpu_info
78 get_cpu_info = _real_get_cpu_info
79except ImportError: # pragma: no cover - optional dependency
80 get_cpu_info = None
83class ViewerProtocol(Protocol):
84 """Runtime viewer contract used by the monitor."""
86 def is_open(self) -> bool: # noqa: D102
87 ...
89 def render(
90 self,
91 frame: np.ndarray,
92 perf_metrics: PerformanceMetrics | None = None,
93 sys_stats: SystemStats | None = None,
94 proc_stats: dict | None = None,
95 camera_info: dict | None = None,
96 detections_count: int | None = None,
97 classification: dict | None = None,
98 log_lines: list[str] | None = None,
99 hardware_info: dict | None = None,
100 power_info: dict | None = None,
101 ) -> None:
102 """Render a frame and associated telemetry in the active viewer."""
103 ...
105 def close(self) -> None: # noqa: D102
106 ...
109class ViewerLoopProtocol(ViewerProtocol, Protocol):
110 """Viewer contract for backends with their own event loop."""
112 def run(self) -> None: # noqa: D102
113 ...
116def _extract_camera_dimensions(
117 camera_info: dict[str, object],
118) -> tuple[int, int]:
119 """Extract width and height from camera info dict."""
120 width_raw = camera_info.get("width", 0)
121 height_raw = camera_info.get("height", 0)
122 width = int(width_raw) if isinstance(width_raw, int | float | str) else 0
123 height = int(height_raw) if isinstance(height_raw, int | float | str) else 0
124 return width, height
127def _get_cpu_model() -> str:
128 model = ""
129 if get_cpu_info is not None:
130 try:
131 info = get_cpu_info()
132 model = info.get("brand_raw") or info.get("brand") or ""
133 except Exception:
134 model = ""
135 if not model:
136 model = platform.processor()
137 if not model and platform.system() == "Windows":
138 model = os.environ.get("PROCESSOR_IDENTIFIER", "")
139 return model or "Unknown"
142@dataclass
143class MonitorContext:
144 """Static context for running the monitoring loop."""
146 args: argparse.Namespace
147 camera: CameraCapture
148 camera_info: dict[str, object]
149 sys_monitor: SystemMonitor
150 session: ort.InferenceSession
151 input_name: str
152 input_size: tuple[int, int]
153 perf_tracker: PerformanceTracker
154 tracker: SimpleCentroidTracker
155 tracks: dict[int, Track]
156 cpu_history: deque[float]
157 log_buffer: deque[str]
158 power_monitor: PowerMonitor
159 hardware_info: dict[str, object]
160 viewer: ViewerProtocol | None
161 cpu_tdp_watts: float
164@dataclass
165class RuntimeState:
166 """Mutable runtime state for the monitoring loop."""
168 frame_count: int
169 sys_stats: SystemStats
170 proc_stats: dict[str, float | int]
171 perf_metrics: PerformanceMetrics
172 power_last_time: float
173 power_info: dict[str, float]
174 energy_wh: float
175 output_debug_logged: bool
176 last_log_time: float
177 last_debug_log_time: float
178 last_resource_log_time: float
181def _init_runtime_state() -> RuntimeState:
182 now = time.perf_counter()
183 return RuntimeState(
184 frame_count=0,
185 sys_stats=SystemStats(),
186 proc_stats={"cpu_percent": 0.0, "memory_mb": 0.0, "threads": 0},
187 perf_metrics=PerformanceMetrics(),
188 power_last_time=now,
189 power_info={
190 "system_power_watts": POWER_WATTS_INITIAL,
191 "cpu_power_watts": POWER_WATTS_INITIAL,
192 "gpu_power_watts": POWER_WATTS_INITIAL,
193 "energy_wh": ENERGY_WH_INITIAL,
194 },
195 energy_wh=ENERGY_WH_INITIAL,
196 output_debug_logged=False,
197 last_log_time=now,
198 last_debug_log_time=now,
199 last_resource_log_time=now,
200 )
203def _update_tracks_if_enabled(
204 ctx: MonitorContext,
205 frame: np.ndarray,
206 detections: list[dict[str, object]],
207 tracks: dict[int, Track],
208) -> dict[int, Track]:
209 if not ctx.args.map:
210 return tracks
211 fh, fw = frame.shape[:2]
212 person_centroids: list[tuple[float, float]] = []
213 for det in detections:
214 if det.get("class_id") != 0:
215 continue
216 bbox_obj = det.get("bbox")
217 if not isinstance(bbox_obj, list | tuple) or len(bbox_obj) < 4:
218 continue
219 x1_obj, _y1_obj, x2_obj, y2_obj = bbox_obj[:4]
220 if not isinstance(x1_obj, int | float):
221 continue
222 if not isinstance(x2_obj, int | float):
223 continue
224 if not isinstance(y2_obj, int | float):
225 continue
226 cx = (float(x1_obj) + float(x2_obj)) / 2.0
227 cy = float(y2_obj)
228 person_centroids.append((float(cx) / max(1, fw), float(cy) / max(1, fh)))
229 return ctx.tracker.update(person_centroids, now_ts=time.perf_counter())
232def _update_stats_if_needed(
233 ctx: MonitorContext,
234 state: RuntimeState,
235) -> None:
236 if state.frame_count % STATS_UPDATE_INTERVAL_FRAMES != 0:
237 return
238 state.sys_stats = ctx.sys_monitor.get_stats()
239 state.proc_stats = ctx.sys_monitor.get_process_stats()
240 state.perf_metrics = ctx.perf_tracker.get_metrics()
241 now_power = time.perf_counter()
242 dt = max(0.0, now_power - state.power_last_time)
243 state.power_last_time = now_power
244 state.power_info = ctx.power_monitor.update(
245 sys_gpu_power=float(getattr(state.sys_stats, "gpu_power_watts", 0.0)),
246 cpu_util_percent=state.sys_stats.cpu_percent,
247 cpu_tdp_watts=ctx.cpu_tdp_watts,
248 freq_ratio=get_cpu_freq_ratio(),
249 dt_seconds=dt,
250 )
251 state.energy_wh = state.power_info["energy_wh"]
254def _update_cpu_history(
255 ctx: MonitorContext,
256 state: RuntimeState,
257) -> None:
258 if not ctx.args.cpu_plot:
259 return
260 state_value = float(np.clip(state.proc_stats.get("cpu_percent", 0.0), 0.0, 100.0))
261 ctx.cpu_history.append(state_value)
264def _draw_frame_if_needed(
265 ctx: MonitorContext,
266 state: RuntimeState,
267 frame: np.ndarray,
268 detections: list[dict[str, object]],
269 classification: dict[str, object] | None,
270) -> np.ndarray:
271 if ctx.args.no_display:
272 return frame
273 return draw_detections(
274 frame,
275 detections,
276 state.perf_metrics,
277 state.sys_stats,
278 state.proc_stats,
279 ctx.camera_info,
280 cpu_history=ctx.cpu_history if ctx.args.cpu_plot else None,
281 classification=classification,
282 tracks=ctx.tracks if ctx.args.map else None,
283 map_size=ctx.args.map_size,
284 debug_boxes=ctx.args.debug_boxes,
285 show_stats_panel=ctx.args.ui not in {"dearpygui", "wxpython"},
286 show_detection_panel=ctx.args.ui not in {"dearpygui", "wxpython"},
287 )
290def _log_periodic_metrics(
291 ctx: MonitorContext,
292 state: RuntimeState,
293 detections: list[dict[str, object]],
294 classification: dict[str, object] | None,
295) -> None:
296 current_time = time.perf_counter()
297 if current_time - state.last_log_time >= LOG_INTERVAL_SECONDS:
298 metrics = ctx.perf_tracker.get_metrics()
299 logger.info(
300 "Camera: {:.1f} FPS | Inference: {:.1f}ms | Budget: {:.0f}% | Detections: {}",
301 metrics.camera_fps,
302 metrics.inference_ms,
303 metrics.frame_budget_percent,
304 len(detections),
305 )
306 state.last_log_time = current_time
308 if (
309 ctx.args.debug_detections
310 and current_time - state.last_debug_log_time >= DEBUG_LOG_INTERVAL_SECONDS
311 ):
312 sample = detections[:3]
313 logger.info("Sample detections: {}", sample)
314 if classification is not None:
315 logger.info("Classification: {}", classification)
316 state.last_debug_log_time = current_time
318 if current_time - state.last_resource_log_time >= RESOURCE_LOG_INTERVAL_SECONDS:
319 metrics = ctx.perf_tracker.get_metrics()
320 logger.info(
321 "System CPU: {:.1f}% | RAM: {:.1f}/{:.1f}GB",
322 state.sys_stats.cpu_percent,
323 state.sys_stats.ram_used_gb,
324 state.sys_stats.ram_total_gb,
325 )
327 if state.sys_stats.gpu_name != "N/A":
328 logger.info(
329 "GPU: {:.0f}% | VRAM: {:.1f}/{:.1f}GB | Temp: {:.0f}°C",
330 state.sys_stats.gpu_percent,
331 state.sys_stats.gpu_memory_used_gb,
332 state.sys_stats.gpu_memory_total_gb,
333 state.sys_stats.gpu_temp_celsius,
334 )
336 logger.info(
337 "Process: CPU {:.1f}% | {:.0f}MB RAM",
338 state.proc_stats["cpu_percent"],
339 state.proc_stats["memory_mb"],
340 )
342 if state.power_info["system_power_watts"] > 0.0:
343 logger.info(
344 "Power (est): {:.0f}W | CPU {:.0f}W | GPU {:.0f}W | Energy {:.3f}Wh",
345 state.power_info["system_power_watts"],
346 state.power_info["cpu_power_watts"],
347 state.power_info["gpu_power_watts"],
348 state.power_info["energy_wh"],
349 )
350 elif state.power_info["gpu_power_watts"] > 0.0:
351 logger.info(
352 "Power: GPU {:.0f}W | Energy {:.3f}Wh",
353 state.power_info["gpu_power_watts"],
354 state.power_info["energy_wh"],
355 )
357 headroom = 100 - metrics.frame_budget_percent
358 potential = (
359 int(metrics.inference_capacity_fps / metrics.camera_fps)
360 if metrics.camera_fps > 0
361 else 0
362 )
363 logger.info(
364 "Performance: {:.0f}% budget | {:.0f}% headroom | ~{} streams",
365 metrics.frame_budget_percent,
366 headroom,
367 potential,
368 )
369 logger.info("-" * 60)
370 state.last_resource_log_time = current_time
373def _render_output(
374 ctx: MonitorContext,
375 state: RuntimeState,
376 frame: np.ndarray,
377 detections: list[dict[str, object]],
378 classification: dict[str, object] | None,
379) -> bool:
380 if ctx.args.no_display:
381 return True
382 if ctx.viewer is not None:
383 if not ctx.viewer.is_open():
384 logger.info("Quit requested by user")
385 return False
386 ctx.viewer.render(
387 frame,
388 perf_metrics=state.perf_metrics,
389 sys_stats=state.sys_stats,
390 proc_stats=state.proc_stats,
391 camera_info=ctx.camera_info,
392 detections_count=len(detections),
393 classification=classification,
394 log_lines=list(ctx.log_buffer),
395 hardware_info=ctx.hardware_info,
396 power_info=state.power_info,
397 )
398 return True
400 cv2.imshow("YOLOv10 Detection", frame)
401 if cv2.waitKey(1) & 0xFF == ord("q"):
402 logger.info("Quit requested by user")
403 return False
404 return True
407def _run_detection_loop(ctx: MonitorContext) -> int:
408 state = _init_runtime_state()
409 logger.info("-" * 60)
410 logger.info("Starting detection loop. Press 'q' to quit.")
411 logger.info("-" * 60)
413 try:
414 while True:
415 ret, frame = ctx.camera.read()
416 if not ret or frame is None:
417 logger.warning("Failed to grab frame")
418 continue
420 ctx.perf_tracker.tick_camera()
421 blob, scale, pad_x, pad_y = preprocess(frame, input_size=ctx.input_size)
423 inference_start = time.perf_counter()
424 outputs = ctx.session.run(None, {ctx.input_name: blob})
425 inference_ms = (time.perf_counter() - inference_start) * 1000
426 ctx.perf_tracker.add_inference_time(inference_ms)
428 detections, classification = postprocess(
429 [np.asarray(output) for output in outputs],
430 DecodeConfig(
431 scale=scale,
432 pad_x=pad_x,
433 pad_y=pad_y,
434 input_size=ctx.input_size,
435 conf_threshold=ctx.args.conf,
436 debug_boxes=ctx.args.debug_boxes,
437 ),
438 debug_output=ctx.args.debug_output and not state.output_debug_logged,
439 )
440 if ctx.args.debug_output and not state.output_debug_logged:
441 state.output_debug_logged = True
443 ctx.tracks = _update_tracks_if_enabled(
444 ctx,
445 frame,
446 detections,
447 ctx.tracks,
448 )
450 state.frame_count += 1
451 _update_stats_if_needed(ctx, state)
452 _update_cpu_history(ctx, state)
454 frame = _draw_frame_if_needed(ctx, state, frame, detections, classification)
455 _log_periodic_metrics(ctx, state, detections, classification)
457 if not _render_output(ctx, state, frame, detections, classification):
458 break
460 except KeyboardInterrupt:
461 logger.info("Interrupted by user")
462 except Exception as exc:
463 logger.exception("Error during detection: {}", exc)
464 finally:
465 logger.info("=" * 60)
466 logger.info("Session Summary")
468 final_metrics = ctx.perf_tracker.get_metrics()
469 logger.info("Capture backend: {}", ctx.camera_info["backend"])
470 logger.info("Total frames: {}", state.frame_count)
471 logger.info("Avg throughput: {:.1f} FPS", final_metrics.actual_throughput_fps)
472 logger.info("Avg inference: {:.1f}ms", final_metrics.inference_ms)
473 logger.info("Avg budget used: {:.1f}%", final_metrics.frame_budget_percent)
475 ctx.camera.release()
476 if ctx.viewer is not None:
477 ctx.viewer.close()
478 cv2.destroyAllWindows()
479 ctx.power_monitor.shutdown()
480 ctx.sys_monitor.shutdown()
481 logger.success("Cleanup complete. Goodbye!")
483 return 0
486class MonitorInitError(RuntimeError):
487 """Raised when monitor initialization fails."""
490def _init_viewer(
491 args: argparse.Namespace,
492 camera_info: dict[str, object],
493 camera: CameraCapture,
494 sys_monitor: SystemMonitor,
495) -> ViewerProtocol | None:
496 if args.ui not in {"dearpygui", "wxpython"} or args.no_display:
497 return None
499 width, height = _extract_camera_dimensions(camera_info)
501 if args.ui == "dearpygui":
502 try:
503 viewer = DearPyGuiViewer(
504 width=width,
505 height=height,
506 title="YOLO Monitor",
507 )
508 except Exception as exc:
509 logger.error("Failed to initialize DearPyGui viewer: {}", exc)
510 camera.release()
511 sys_monitor.shutdown()
512 message = "DearPyGui viewer initialization failed"
513 raise MonitorInitError(message) from exc
514 else:
515 logger.success("DearPyGui viewer initialized")
516 return viewer
518 if WxPythonViewer is None:
519 logger.error("wxPython viewer requested but not available")
520 if _WX_VIEWER_IMPORT_ERROR is not None:
521 logger.error("wxPython import error: {}", _WX_VIEWER_IMPORT_ERROR)
522 camera.release()
523 sys_monitor.shutdown()
524 message = "wxPython viewer not available"
525 raise MonitorInitError(message)
527 try:
528 viewer = WxPythonViewer(
529 width=width,
530 height=height,
531 title="YOLO Monitor",
532 )
533 except Exception as exc:
534 logger.error("Failed to initialize wxPython viewer: {}", exc)
535 camera.release()
536 sys_monitor.shutdown()
537 message = "wxPython viewer initialization failed"
538 raise MonitorInitError(message) from exc
539 else:
540 logger.success("wxPython viewer initialized")
541 return viewer
544def _log_platform_info(initial_stats: SystemStats) -> None:
545 logger.info("System RAM: {:.1f} GB", initial_stats.ram_total_gb)
546 logger.info("CPU model: {}", _get_cpu_model())
547 try:
548 cpu_freq = psutil.cpu_freq()
549 if cpu_freq and cpu_freq.max:
550 logger.info(
551 "CPU freq: {:.0f} MHz (max {:.0f} MHz)",
552 cpu_freq.current or 0.0,
553 cpu_freq.max,
554 )
555 except Exception as exc:
556 logger.debug("Unable to read CPU frequency: {}", exc)
557 logger.info(
558 "CPU cores: {} physical, {} logical",
559 psutil.cpu_count(logical=False),
560 psutil.cpu_count(),
561 )
562 if initial_stats.gpu_name != "N/A":
563 logger.info(
564 "GPU: {} ({:.1f} GB VRAM)",
565 initial_stats.gpu_name,
566 initial_stats.gpu_memory_total_gb,
567 )
570def _build_context(args: argparse.Namespace, log_buffer: deque[str]) -> MonitorContext:
571 logger.info("=" * 60)
572 logger.info("YOLOv10 Object Detection with System Monitoring")
573 logger.info("=" * 60)
575 logger.info("Platform: {} {}", platform.system(), platform.release())
576 logger.info("Python: {}", platform.python_version())
577 logger.info("OpenCV: {}", cv2.__version__)
579 gst_path, gst_status = find_gstreamer_launch()
580 if gst_path:
581 logger.info("GStreamer: {}", gst_status)
582 else:
583 logger.warning("GStreamer: {}", gst_status)
585 if args.backend == "gstreamer" and gst_path is None:
586 logger.error("GStreamer backend requested but not found!")
587 logger.error(
588 "Install GStreamer from: https://gstreamer.freedesktop.org/download/"
589 )
590 message = "GStreamer backend requested but not found"
591 raise MonitorInitError(message)
593 camera_config = CameraConfig(
594 device_index=args.camera,
595 width=args.width,
596 height=args.height,
597 fps=args.fps,
598 backend=CaptureBackend(args.backend),
599 )
601 logger.info("Requested: {}x{} @ {} FPS", args.width, args.height, args.fps)
602 logger.info("Backend: {}", args.backend)
603 logger.info("UI: {}", args.ui)
605 sys_monitor = SystemMonitor(gpu_device_id=args.gpu)
606 initial_stats = sys_monitor.get_stats()
607 _log_platform_info(initial_stats)
609 providers = [
610 (
611 "CUDAExecutionProvider",
612 {"device_id": args.gpu, "arena_extend_strategy": "kNextPowerOfTwo"},
613 ),
614 "CPUExecutionProvider",
615 ]
617 logger.info("Loading model: {}", args.model)
618 try:
619 session = ort.InferenceSession(args.model, providers=providers)
620 except Exception as exc:
621 logger.error("Failed to load model: {}", exc)
622 sys_monitor.shutdown()
623 message = "Failed to load model"
624 raise MonitorInitError(message) from exc
626 active_provider = session.get_providers()[0]
627 logger.success("Model loaded using: {}", active_provider)
629 input_name = session.get_inputs()[0].name
630 input_shape = session.get_inputs()[0].shape
631 logger.debug("Model input: {}, shape: {}", input_name, input_shape)
632 input_size = infer_input_size(input_shape)
634 logger.info("-" * 60)
635 camera = CameraCapture(camera_config)
636 if not camera.open():
637 logger.error("Failed to open camera!")
638 sys_monitor.shutdown()
639 message = "Failed to open camera"
640 raise MonitorInitError(message)
642 camera_info = camera.get_info()
643 logger.info("Active backend: {}", camera_info["backend"])
644 logger.info("Capture pipeline: {}", camera_info.get("pipeline", ""))
646 hardware_info = {
647 "cpu_model": _get_cpu_model(),
648 "ram_total_gb": initial_stats.ram_total_gb,
649 "gpu_model": initial_stats.gpu_name,
650 "vram_total_gb": initial_stats.gpu_memory_total_gb,
651 }
653 perf_tracker = PerformanceTracker(avg_frames=30)
654 tracker = SimpleCentroidTracker()
655 tracks: dict[int, Track] = {}
656 cpu_history: deque[float] = deque(maxlen=max(2, int(args.cpu_history)))
658 cpu_tdp_watts = float(
659 os.getenv("KATAGLYPHIS_CPU_TDP_WATTS", str(DEFAULT_CPU_TDP_WATTS))
660 or DEFAULT_CPU_TDP_WATTS
661 )
662 logger.info("CPU power baseline (TDP): {:.0f} W", cpu_tdp_watts)
664 power_monitor = PowerMonitor()
665 viewer = _init_viewer(args, camera_info, camera, sys_monitor)
667 return MonitorContext(
668 args=args,
669 camera=camera,
670 camera_info=camera_info,
671 sys_monitor=sys_monitor,
672 session=session,
673 input_name=input_name,
674 input_size=input_size,
675 perf_tracker=perf_tracker,
676 tracker=tracker,
677 tracks=tracks,
678 cpu_history=cpu_history,
679 log_buffer=log_buffer,
680 power_monitor=power_monitor,
681 hardware_info=hardware_info,
682 viewer=viewer,
683 cpu_tdp_watts=cpu_tdp_watts,
684 )
687def run_yolo_monitor(argv: list[str] | None = None) -> int:
688 """Entry point for running the YOLOv10 monitor."""
689 args = parse_args(argv)
690 configure_logging(args.log_level)
691 log_buffer = create_log_buffer(max_lines=200)
692 attach_log_buffer(log_buffer, level="INFO")
693 try:
694 ctx = _build_context(args, log_buffer)
695 except MonitorInitError:
696 return 1
698 use_wx = args.ui == "wxpython" and not args.no_display and ctx.viewer is not None
699 if use_wx:
700 exit_code = 0
701 wx_viewer = cast("ViewerLoopProtocol", ctx.viewer)
703 def _run_loop() -> None:
704 nonlocal exit_code
705 exit_code = _run_detection_loop(ctx)
707 worker = threading.Thread(
708 target=_run_loop,
709 name="yolo-detection",
710 daemon=True,
711 )
712 worker.start()
713 wx_viewer.run()
714 worker.join()
715 return exit_code
717 return _run_detection_loop(ctx)
720if __name__ == "__main__":
721 raise SystemExit(run_yolo_monitor())