Coverage for orchestrant/yolo/monitor.py: 28%

373 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-19 17:06 +0000

1"""Main entry point for the YOLO monitoring pipeline.""" 

2 

3from __future__ import annotations 

4 

5import json 

6import os 

7import platform 

8import sys 

9import threading 

10import time 

11from collections import deque 

12from dataclasses import dataclass 

13from pathlib import Path 

14from typing import TYPE_CHECKING, Any, Protocol, cast 

15 

16import cv2 

17import numpy as np 

18import onnxruntime as ort 

19import psutil 

20from loguru import logger 

21 

22from orchestrant.paths import default_log_dir, missing_model_message, model_search_paths 

23from orchestrant.pipeline.capture import CameraCapture 

24from orchestrant.pipeline.capture.gstreamer import find_gstreamer_launch 

25from orchestrant.pipeline.constants import ( 

26 DEBUG_LOG_INTERVAL_SECONDS, 

27 DEFAULT_CPU_TDP_WATTS, 

28 ENERGY_WH_INITIAL, 

29 LOG_INTERVAL_SECONDS, 

30 POWER_WATTS_INITIAL, 

31 RESOURCE_LOG_INTERVAL_SECONDS, 

32 STATS_UPDATE_INTERVAL_FRAMES, 

33) 

34from orchestrant.pipeline.logging import ( 

35 attach_log_buffer, 

36 configure_logging, 

37 create_log_buffer, 

38 log_buffer_lines, 

39) 

40from orchestrant.pipeline.metrics.performance import PerformanceTracker 

41from orchestrant.pipeline.monitoring.power import ( 

42 PowerMonitor, 

43 get_cpu_freq_ratio, 

44) 

45from orchestrant.pipeline.monitoring.system import SystemMonitor 

46from orchestrant.pipeline.tracking.centroid import SimpleCentroidTracker 

47from orchestrant.pipeline.types import ( 

48 CameraConfig, 

49 CaptureBackend, 

50 PerformanceMetrics, 

51 SystemStats, 

52) 

53from orchestrant.pipeline.ui.flet_viewer import FLET_AVAILABLE, FletViewer 

54from orchestrant.yolo.cli import parse_args 

55from orchestrant.yolo.core.postprocess import DecodeConfig, postprocess 

56from orchestrant.yolo.core.preprocess import infer_input_size, preprocess, wants_stretch 

57from orchestrant.yolo.ui.draw import draw_detections 

58 

59 

60if TYPE_CHECKING: 

61 import argparse 

62 from collections.abc import Callable 

63 

64 from orchestrant.pipeline.types import ( 

65 Track, 

66 ) 

67 

68 

69get_cpu_info: Callable[[], dict] | None = None 

70try: 

71 from cpuinfo import get_cpu_info as _real_get_cpu_info 

72 

73 get_cpu_info = _real_get_cpu_info 

74except ImportError: # pragma: no cover - optional dependency 

75 get_cpu_info = None 

76 

77 

78class ViewerProtocol(Protocol): 

79 """Runtime viewer contract used by the monitor.""" 

80 

81 def is_open(self) -> bool: # noqa: D102 

82 ... 

83 

84 def render( 

85 self, 

86 frame: np.ndarray, 

87 *, 

88 perf_metrics: PerformanceMetrics | None = None, 

89 sys_stats: SystemStats | None = None, 

90 proc_stats: dict | None = None, 

91 camera_info: dict | None = None, 

92 detections_count: int | None = None, 

93 classification: dict | None = None, 

94 log_lines: list[str] | None = None, 

95 hardware_info: dict | None = None, 

96 power_info: dict | None = None, 

97 ) -> None: 

98 """Render a frame and associated telemetry in the active viewer.""" 

99 ... 

100 

101 def close(self) -> None: # noqa: D102 

102 ... 

103 

104 

105class ViewerLoopProtocol(ViewerProtocol, Protocol): 

106 """Viewer contract for backends with their own event loop.""" 

107 

108 def run(self) -> None: # noqa: D102 

109 ... 

110 

111 

112def _extract_camera_dimensions( 

113 camera_info: dict[str, object], 

114) -> tuple[int, int]: 

115 """Extract width and height from camera info dict.""" 

116 width_raw = camera_info.get("width", 0) 

117 height_raw = camera_info.get("height", 0) 

118 width = int(width_raw) if isinstance(width_raw, int | float | str) else 0 

119 height = int(height_raw) if isinstance(height_raw, int | float | str) else 0 

120 return width, height 

121 

122 

123def _get_cpu_model() -> str: 

124 model = "" 

125 if get_cpu_info is not None: 

126 try: 

127 info = get_cpu_info() 

128 model = info.get("brand_raw") or info.get("brand") or "" 

129 except Exception: 

130 model = "" 

131 if not model: 

132 model = platform.processor() 

133 if not model and platform.system() == "Windows": 

134 model = os.environ.get("PROCESSOR_IDENTIFIER", "") 

135 return model or "Unknown" 

136 

137 

138@dataclass 

139class MonitorContext: 

140 """Static context for running the monitoring loop.""" 

141 

142 args: argparse.Namespace 

143 camera: CameraCapture 

144 camera_info: dict[str, object] 

145 sys_monitor: SystemMonitor 

146 session: ort.InferenceSession 

147 input_name: str 

148 input_size: tuple[int, int] 

149 stretch: bool 

150 perf_tracker: PerformanceTracker 

151 tracker: SimpleCentroidTracker 

152 tracks: dict[int, Track] 

153 cpu_history: deque[float] 

154 log_buffer: deque[str] 

155 power_monitor: PowerMonitor 

156 hardware_info: dict[str, object] 

157 viewer: ViewerProtocol | None 

158 cpu_tdp_watts: float 

159 

160 

161@dataclass 

162class RuntimeState: 

163 """Mutable runtime state for the monitoring loop.""" 

164 

165 frame_count: int 

166 sys_stats: SystemStats 

167 proc_stats: dict[str, float | int] 

168 perf_metrics: PerformanceMetrics 

169 power_last_time: float 

170 power_info: dict[str, float] 

171 energy_wh: float 

172 output_debug_logged: bool 

173 last_log_time: float 

174 last_debug_log_time: float 

175 last_resource_log_time: float 

176 

177 

178def _init_runtime_state() -> RuntimeState: 

179 now = time.perf_counter() 

180 return RuntimeState( 

181 frame_count=0, 

182 sys_stats=SystemStats(), 

183 proc_stats={"cpu_percent": 0.0, "memory_mb": 0.0, "threads": 0}, 

184 perf_metrics=PerformanceMetrics(), 

185 power_last_time=now, 

186 power_info={ 

187 "system_power_watts": POWER_WATTS_INITIAL, 

188 "cpu_power_watts": POWER_WATTS_INITIAL, 

189 "gpu_power_watts": POWER_WATTS_INITIAL, 

190 "energy_wh": ENERGY_WH_INITIAL, 

191 }, 

192 energy_wh=ENERGY_WH_INITIAL, 

193 output_debug_logged=False, 

194 last_log_time=now, 

195 last_debug_log_time=now, 

196 last_resource_log_time=now, 

197 ) 

198 

199 

200def _update_tracks_if_enabled( 

201 ctx: MonitorContext, 

202 frame: np.ndarray, 

203 detections: list[dict[str, object]], 

204 tracks: dict[int, Track], 

205) -> dict[int, Track]: 

206 if not ctx.args.map: 

207 return tracks 

208 fh, fw = frame.shape[:2] 

209 person_centroids: list[tuple[float, float]] = [] 

210 for det in detections: 

211 if det.get("class_id") != 0: 

212 continue 

213 bbox_obj = det.get("bbox") 

214 if not isinstance(bbox_obj, list | tuple) or len(bbox_obj) < 4: 

215 continue 

216 x1_obj, _y1_obj, x2_obj, y2_obj = bbox_obj[:4] 

217 if not isinstance(x1_obj, int | float): 

218 continue 

219 if not isinstance(x2_obj, int | float): 

220 continue 

221 if not isinstance(y2_obj, int | float): 

222 continue 

223 cx = (float(x1_obj) + float(x2_obj)) / 2.0 

224 cy = float(y2_obj) 

225 person_centroids.append((float(cx) / max(1, fw), float(cy) / max(1, fh))) 

226 return ctx.tracker.update(person_centroids, now_ts=time.perf_counter()) 

227 

228 

229def _update_stats_if_needed( 

230 ctx: MonitorContext, 

231 state: RuntimeState, 

232) -> None: 

233 if state.frame_count % STATS_UPDATE_INTERVAL_FRAMES != 0: 

234 return 

235 state.sys_stats = ctx.sys_monitor.get_stats() 

236 state.proc_stats = ctx.sys_monitor.get_process_stats() 

237 state.perf_metrics = ctx.perf_tracker.get_metrics() 

238 now_power = time.perf_counter() 

239 dt = max(0.0, now_power - state.power_last_time) 

240 state.power_last_time = now_power 

241 state.power_info = ctx.power_monitor.update( 

242 sys_gpu_power=float(getattr(state.sys_stats, "gpu_power_watts", 0.0)), 

243 cpu_util_percent=state.sys_stats.cpu_percent, 

244 cpu_tdp_watts=ctx.cpu_tdp_watts, 

245 freq_ratio=get_cpu_freq_ratio(), 

246 dt_seconds=dt, 

247 ) 

248 state.energy_wh = state.power_info["energy_wh"] 

249 

250 

251def _update_cpu_history( 

252 ctx: MonitorContext, 

253 state: RuntimeState, 

254) -> None: 

255 if not ctx.args.cpu_plot: 

256 return 

257 state_value = float(np.clip(state.proc_stats.get("cpu_percent", 0.0), 0.0, 100.0)) 

258 ctx.cpu_history.append(state_value) 

259 

260 

261def _draw_frame_if_needed( 

262 ctx: MonitorContext, 

263 state: RuntimeState, 

264 frame: np.ndarray, 

265 detections: list[dict[str, object]], 

266 classification: dict[str, object] | None, 

267) -> np.ndarray: 

268 if ctx.args.no_display: 

269 return frame 

270 return draw_detections( 

271 frame, 

272 detections, 

273 state.perf_metrics, 

274 state.sys_stats, 

275 state.proc_stats, 

276 ctx.camera_info, 

277 cpu_history=ctx.cpu_history if ctx.args.cpu_plot else None, 

278 classification=classification, 

279 tracks=ctx.tracks if ctx.args.map else None, 

280 map_size=ctx.args.map_size, 

281 debug_boxes=ctx.args.debug_boxes, 

282 show_stats_panel=ctx.args.ui != "flet", 

283 show_detection_panel=ctx.args.ui != "flet", 

284 ) 

285 

286 

287def _log_periodic_metrics( 

288 ctx: MonitorContext, 

289 state: RuntimeState, 

290 detections: list[dict[str, object]], 

291 classification: dict[str, object] | None, 

292) -> None: 

293 current_time = time.perf_counter() 

294 if current_time - state.last_log_time >= LOG_INTERVAL_SECONDS: 

295 metrics = ctx.perf_tracker.get_metrics() 

296 logger.info( 

297 "Camera: {:.1f} FPS | Inference: {:.1f}ms | Budget: {:.0f}% | Detections: {}", 

298 metrics.camera_fps, 

299 metrics.inference_ms, 

300 metrics.frame_budget_percent, 

301 len(detections), 

302 ) 

303 state.last_log_time = current_time 

304 

305 if ( 

306 ctx.args.debug_detections 

307 and current_time - state.last_debug_log_time >= DEBUG_LOG_INTERVAL_SECONDS 

308 ): 

309 sample = detections[:3] 

310 logger.info("Sample detections: {}", sample) 

311 if classification is not None: 

312 logger.info("Classification: {}", classification) 

313 state.last_debug_log_time = current_time 

314 

315 if current_time - state.last_resource_log_time >= RESOURCE_LOG_INTERVAL_SECONDS: 

316 metrics = ctx.perf_tracker.get_metrics() 

317 logger.info( 

318 "System CPU: {:.1f}% | RAM: {:.1f}/{:.1f}GB", 

319 state.sys_stats.cpu_percent, 

320 state.sys_stats.ram_used_gb, 

321 state.sys_stats.ram_total_gb, 

322 ) 

323 

324 if state.sys_stats.gpu_name != "N/A": 

325 logger.info( 

326 "GPU: {:.0f}% | VRAM: {:.1f}/{:.1f}GB | Temp: {:.0f}°C", 

327 state.sys_stats.gpu_percent, 

328 state.sys_stats.gpu_memory_used_gb, 

329 state.sys_stats.gpu_memory_total_gb, 

330 state.sys_stats.gpu_temp_celsius, 

331 ) 

332 

333 logger.info( 

334 "Process: CPU {:.1f}% | {:.0f}MB RAM", 

335 state.proc_stats["cpu_percent"], 

336 state.proc_stats["memory_mb"], 

337 ) 

338 

339 if state.power_info["system_power_watts"] > 0.0: 

340 logger.info( 

341 "Power (est): {:.0f}W | CPU {:.0f}W | GPU {:.0f}W | Energy {:.3f}Wh", 

342 state.power_info["system_power_watts"], 

343 state.power_info["cpu_power_watts"], 

344 state.power_info["gpu_power_watts"], 

345 state.power_info["energy_wh"], 

346 ) 

347 elif state.power_info["gpu_power_watts"] > 0.0: 

348 logger.info( 

349 "Power: GPU {:.0f}W | Energy {:.3f}Wh", 

350 state.power_info["gpu_power_watts"], 

351 state.power_info["energy_wh"], 

352 ) 

353 

354 headroom = 100 - metrics.frame_budget_percent 

355 potential = ( 

356 int(metrics.inference_capacity_fps / metrics.camera_fps) 

357 if metrics.camera_fps > 0 

358 else 0 

359 ) 

360 logger.info( 

361 "Performance: {:.0f}% budget | {:.0f}% headroom | ~{} streams", 

362 metrics.frame_budget_percent, 

363 headroom, 

364 potential, 

365 ) 

366 logger.info("-" * 60) 

367 state.last_resource_log_time = current_time 

368 

369 

370def _render_output( 

371 ctx: MonitorContext, 

372 state: RuntimeState, 

373 frame: np.ndarray, 

374 detections: list[dict[str, object]], 

375 classification: dict[str, object] | None, 

376) -> bool: 

377 if ctx.args.no_display: 

378 return True 

379 if ctx.viewer is not None: 

380 if not ctx.viewer.is_open(): 

381 logger.info("Quit requested by user") 

382 return False 

383 ctx.viewer.render( 

384 frame, 

385 perf_metrics=state.perf_metrics, 

386 sys_stats=state.sys_stats, 

387 proc_stats=state.proc_stats, 

388 camera_info=ctx.camera_info, 

389 detections_count=len(detections), 

390 classification=classification, 

391 log_lines=log_buffer_lines(ctx.log_buffer), 

392 hardware_info=ctx.hardware_info, 

393 power_info=state.power_info, 

394 ) 

395 return True 

396 

397 cv2.imshow("Object Detection", frame) 

398 if cv2.waitKey(1) & 0xFF == ord("q"): 

399 logger.info("Quit requested by user") 

400 return False 

401 return True 

402 

403 

404def _run_detection_loop(ctx: MonitorContext) -> int: 

405 state = _init_runtime_state() 

406 logger.info("-" * 60) 

407 logger.info("Starting detection loop. Press 'q' to quit.") 

408 logger.info("-" * 60) 

409 

410 try: 

411 while True: 

412 ret, frame = ctx.camera.read() 

413 if not ret or frame is None: 

414 logger.warning("Failed to grab frame") 

415 continue 

416 

417 ctx.perf_tracker.tick_camera() 

418 blob, scale, pad_x, pad_y = preprocess( 

419 frame, input_size=ctx.input_size, stretch=ctx.stretch 

420 ) 

421 

422 inference_start = time.perf_counter() 

423 outputs = ctx.session.run(None, {ctx.input_name: blob}) 

424 inference_ms = (time.perf_counter() - inference_start) * 1000 

425 ctx.perf_tracker.add_inference_time(inference_ms) 

426 

427 detections, classification = postprocess( 

428 [np.asarray(output) for output in outputs], 

429 DecodeConfig( 

430 scale=scale, 

431 pad_x=pad_x, 

432 pad_y=pad_y, 

433 input_size=ctx.input_size, 

434 conf_threshold=ctx.args.conf, 

435 debug_boxes=ctx.args.debug_boxes, 

436 ), 

437 debug_output=ctx.args.debug_output and not state.output_debug_logged, 

438 ) 

439 if ctx.args.debug_output and not state.output_debug_logged: 

440 state.output_debug_logged = True 

441 

442 ctx.tracks = _update_tracks_if_enabled( 

443 ctx, 

444 frame, 

445 detections, 

446 ctx.tracks, 

447 ) 

448 

449 state.frame_count += 1 

450 _update_stats_if_needed(ctx, state) 

451 _update_cpu_history(ctx, state) 

452 

453 frame = _draw_frame_if_needed(ctx, state, frame, detections, classification) 

454 _log_periodic_metrics(ctx, state, detections, classification) 

455 

456 if not _render_output(ctx, state, frame, detections, classification): 

457 break 

458 

459 except KeyboardInterrupt: 

460 logger.info("Interrupted by user") 

461 except Exception as exc: 

462 logger.exception("Error during detection: {}", exc) 

463 finally: 

464 logger.info("=" * 60) 

465 logger.info("Session Summary") 

466 

467 final_metrics = ctx.perf_tracker.get_metrics() 

468 logger.info("Capture backend: {}", ctx.camera_info["backend"]) 

469 logger.info("Total frames: {}", state.frame_count) 

470 logger.info("Avg throughput: {:.1f} FPS", final_metrics.actual_throughput_fps) 

471 logger.info("Avg inference: {:.1f}ms", final_metrics.inference_ms) 

472 logger.info("Avg budget used: {:.1f}%", final_metrics.frame_budget_percent) 

473 

474 ctx.camera.release() 

475 if ctx.viewer is not None: 

476 ctx.viewer.close() 

477 cv2.destroyAllWindows() 

478 ctx.power_monitor.shutdown() 

479 ctx.sys_monitor.shutdown() 

480 logger.success("Cleanup complete. Goodbye!") 

481 

482 return 0 

483 

484 

485class MonitorInitError(RuntimeError): 

486 """Raised when monitor initialization fails.""" 

487 

488 

489def _init_viewer( 

490 args: argparse.Namespace, 

491 camera_info: dict[str, object], 

492 camera: CameraCapture, 

493 sys_monitor: SystemMonitor, 

494) -> ViewerProtocol | None: 

495 if args.ui != "flet" or args.no_display: 

496 return None 

497 

498 width, height = _extract_camera_dimensions(camera_info) 

499 try: 

500 viewer = FletViewer( 

501 width, 

502 height, 

503 "YOLO Monitor", 

504 web=args.web, 

505 host=args.host, 

506 port=args.port, 

507 ) 

508 except Exception as exc: 

509 logger.error("Failed to initialize the Flet viewer: {}", exc) 

510 camera.release() 

511 sys_monitor.shutdown() 

512 message = "Flet viewer initialization failed" 

513 raise MonitorInitError(message) from exc 

514 else: 

515 logger.success("Flet viewer initialized") 

516 return viewer 

517 

518 

519def resolve_ui(args: argparse.Namespace) -> str: 

520 """`auto` is Flet where installed, else OpenCV's window; --web always means Flet.""" 

521 if args.ui == "auto": 

522 return "flet" if FLET_AVAILABLE or args.web else "opencv" 

523 return args.ui 

524 

525 

526def _log_platform_info(initial_stats: SystemStats) -> None: 

527 logger.info("System RAM: {:.1f} GB", initial_stats.ram_total_gb) 

528 logger.info("CPU model: {}", _get_cpu_model()) 

529 try: 

530 cpu_freq = psutil.cpu_freq() 

531 if cpu_freq and cpu_freq.max: 

532 logger.info( 

533 "CPU freq: {:.0f} MHz (max {:.0f} MHz)", 

534 cpu_freq.current or 0.0, 

535 cpu_freq.max, 

536 ) 

537 except Exception as exc: 

538 logger.debug("Unable to read CPU frequency: {}", exc) 

539 logger.info( 

540 "CPU cores: {} physical, {} logical", 

541 psutil.cpu_count(logical=False), 

542 psutil.cpu_count(), 

543 ) 

544 if initial_stats.gpu_name != "N/A": 

545 logger.info( 

546 "GPU: {} ({:.1f} GB VRAM)", 

547 initial_stats.gpu_name, 

548 initial_stats.gpu_memory_total_gb, 

549 ) 

550 

551 

552def _build_context(args: argparse.Namespace, log_buffer: deque[str]) -> MonitorContext: 

553 logger.info("=" * 60) 

554 logger.info("Object Detection with System Monitoring") 

555 logger.info("=" * 60) 

556 

557 logger.info("Platform: {} {}", platform.system(), platform.release()) 

558 logger.info("Python: {}", platform.python_version()) 

559 logger.info("OpenCV: {}", cv2.__version__) 

560 

561 gst_path, gst_status = find_gstreamer_launch() 

562 if gst_path: 

563 logger.info("GStreamer: {}", gst_status) 

564 else: 

565 logger.warning("GStreamer: {}", gst_status) 

566 

567 if args.backend == "gstreamer" and gst_path is None: 

568 logger.error("GStreamer backend requested but not found!") 

569 logger.error( 

570 "Install GStreamer from: https://gstreamer.freedesktop.org/download/" 

571 ) 

572 message = "GStreamer backend requested but not found" 

573 raise MonitorInitError(message) 

574 

575 camera_config = CameraConfig( 

576 device_index=args.camera, 

577 width=args.width, 

578 height=args.height, 

579 fps=args.fps, 

580 backend=CaptureBackend(args.backend), 

581 ) 

582 

583 logger.info("Requested: {}x{} @ {} FPS", args.width, args.height, args.fps) 

584 logger.info("Backend: {}", args.backend) 

585 logger.info("UI: {}", args.ui) 

586 

587 sys_monitor = SystemMonitor(gpu_device_id=args.gpu) 

588 initial_stats = sys_monitor.get_stats() 

589 _log_platform_info(initial_stats) 

590 

591 logger.info("Loading model: {}", args.model) 

592 try: 

593 session = ort.InferenceSession(args.model, providers=select_providers(args.gpu)) 

594 except Exception as exc: 

595 logger.error("Failed to load model: {}", exc) 

596 sys_monitor.shutdown() 

597 message = "Failed to load model" 

598 raise MonitorInitError(message) from exc 

599 

600 active_provider = session.get_providers()[0] 

601 logger.success("Model loaded using: {}", active_provider) 

602 

603 model_input = session.get_inputs()[0] 

604 logger.debug("Model input: {}, shape: {}", model_input.name, model_input.shape) 

605 input_size = infer_input_size(model_input.shape) 

606 

607 logger.info("-" * 60) 

608 camera = CameraCapture(camera_config) 

609 if not camera.open(): 

610 logger.error("Failed to open camera!") 

611 sys_monitor.shutdown() 

612 message = "Failed to open camera" 

613 raise MonitorInitError(message) 

614 

615 camera_info = camera.get_info() 

616 logger.info("Active backend: {}", camera_info["backend"]) 

617 logger.info("Capture pipeline: {}", camera_info.get("pipeline", "")) 

618 

619 hardware_info = { 

620 "cpu_model": _get_cpu_model(), 

621 "ram_total_gb": initial_stats.ram_total_gb, 

622 "gpu_model": initial_stats.gpu_name, 

623 "vram_total_gb": initial_stats.gpu_memory_total_gb, 

624 } 

625 

626 perf_tracker = PerformanceTracker(avg_frames=30) 

627 tracker = SimpleCentroidTracker() 

628 tracks: dict[int, Track] = {} 

629 cpu_history: deque[float] = deque(maxlen=max(2, int(args.cpu_history))) 

630 

631 cpu_tdp_watts = float( 

632 os.getenv("KATAGLYPHIS_CPU_TDP_WATTS", str(DEFAULT_CPU_TDP_WATTS)) 

633 or DEFAULT_CPU_TDP_WATTS 

634 ) 

635 logger.info("CPU power baseline (TDP): {:.0f} W", cpu_tdp_watts) 

636 

637 power_monitor = PowerMonitor() 

638 viewer = _init_viewer(args, camera_info, camera, sys_monitor) 

639 

640 return MonitorContext( 

641 args=args, 

642 camera=camera, 

643 camera_info=camera_info, 

644 sys_monitor=sys_monitor, 

645 session=session, 

646 input_name=model_input.name, 

647 input_size=input_size, 

648 stretch=wants_stretch(session), 

649 perf_tracker=perf_tracker, 

650 tracker=tracker, 

651 tracks=tracks, 

652 cpu_history=cpu_history, 

653 log_buffer=log_buffer, 

654 power_monitor=power_monitor, 

655 hardware_info=hardware_info, 

656 viewer=viewer, 

657 cpu_tdp_watts=cpu_tdp_watts, 

658 ) 

659 

660 

661def select_providers(gpu: int) -> list[Any]: 

662 """CUDA, then DirectML, then CPU, as far as this ONNX Runtime build offers them.""" 

663 available = set(ort.get_available_providers()) 

664 providers: list[Any] = [] 

665 if "CUDAExecutionProvider" in available: 

666 providers.append( 

667 ( 

668 "CUDAExecutionProvider", 

669 {"device_id": gpu, "arena_extend_strategy": "kNextPowerOfTwo"}, 

670 ) 

671 ) 

672 if "DmlExecutionProvider" in available: 

673 providers.append(("DmlExecutionProvider", {"device_id": gpu})) 

674 providers.append("CPUExecutionProvider") 

675 return providers 

676 

677 

678def run_self_test(args: argparse.Namespace) -> int: 

679 """One inference on a blank frame, printed as JSON: the install check CI runs with no camera or display.""" 

680 model = Path(args.model) 

681 if not model.is_file(): 

682 searched = ", ".join(str(path) for path in model_search_paths()) 

683 failure = { 

684 "ok": False, 

685 "error": missing_model_message(model), 

686 "searched": searched, 

687 } 

688 sys.stdout.write(json.dumps(failure) + "\n") 

689 return 1 

690 session = ort.InferenceSession(str(model), providers=select_providers(args.gpu)) 

691 meta = session.get_inputs()[0] 

692 input_size = infer_input_size(meta.shape) 

693 stretch = wants_stretch(session) 

694 frame = np.zeros((args.height, args.width, 3), dtype=np.uint8) 

695 blob, scale, pad_x, pad_y = preprocess( 

696 frame, input_size=input_size, stretch=stretch 

697 ) 

698 started = time.perf_counter() 

699 outputs = session.run(None, {meta.name: blob}) 

700 inference_ms = (time.perf_counter() - started) * 1000 

701 detections, _ = postprocess( 

702 [np.asarray(output) for output in outputs], 

703 DecodeConfig( 

704 scale=scale, 

705 pad_x=pad_x, 

706 pad_y=pad_y, 

707 input_size=input_size, 

708 conf_threshold=args.conf, 

709 debug_boxes=False, 

710 ), 

711 ) 

712 report = { 

713 "ok": True, 

714 "python": platform.python_version(), 

715 "onnxruntime": ort.__version__, 

716 "onnxruntime_module": str(Path(ort.__file__).parent), 

717 "opencv": cv2.__version__, 

718 "providers": session.get_providers(), 

719 "model": str(model.resolve()), 

720 "input_size": list(input_size), 

721 "preprocess": "stretch" if stretch else "letterbox", 

722 "outputs": [list(np.asarray(output).shape) for output in outputs], 

723 "detections_on_blank_frame": len(detections), 

724 "inference_ms": round(inference_ms, 2), 

725 "log_dir": str(default_log_dir()), 

726 } 

727 sys.stdout.write(json.dumps(report, indent=2) + "\n") 

728 return 0 

729 

730 

731def run_yolo_monitor(argv: list[str] | None = None) -> int: 

732 """Entry point for running the YOLO monitor.""" 

733 args = parse_args(argv) 

734 if args.self_test: 

735 return run_self_test(args) 

736 configure_logging(args.log_level, log_dir=str(default_log_dir())) 

737 args.ui = resolve_ui(args) 

738 if args.web and args.ui != "flet": 

739 logger.error("--web serves the Flet viewer; it cannot go with --ui {}", args.ui) 

740 return 1 

741 if not Path(args.model).is_file(): 

742 logger.error("{}", missing_model_message(args.model)) 

743 return 1 

744 log_buffer = create_log_buffer(max_lines=200) 

745 attach_log_buffer(log_buffer, level="INFO") 

746 try: 

747 ctx = _build_context(args, log_buffer) 

748 except MonitorInitError: 

749 return 1 

750 

751 if ctx.viewer is None: 

752 return _run_detection_loop(ctx) 

753 

754 # The Flet viewer owns the main thread until its window closes; detection runs beside it. 

755 exit_code = 0 

756 viewer = cast("ViewerLoopProtocol", ctx.viewer) 

757 

758 def _run_loop() -> None: 

759 nonlocal exit_code 

760 exit_code = _run_detection_loop(ctx) 

761 

762 worker = threading.Thread(target=_run_loop, name="yolo-detection", daemon=True) 

763 worker.start() 

764 viewer.run() 

765 worker.join() 

766 return exit_code 

767 

768 

769if __name__ == "__main__": 

770 raise SystemExit(run_yolo_monitor())