seavision-python 0.1.1__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.
Files changed (73) hide show
  1. seavision/__init__.py +3 -0
  2. seavision/defaults.py +38 -0
  3. seavision/edge/__init__.py +38 -0
  4. seavision/edge/bundle.py +189 -0
  5. seavision/edge/capture.py +107 -0
  6. seavision/edge/config.py +219 -0
  7. seavision/edge/postprocess.py +185 -0
  8. seavision/edge/runtime.py +416 -0
  9. seavision/edge/writer.py +167 -0
  10. seavision/engine/__init__.py +52 -0
  11. seavision/engine/detectors/__init__.py +90 -0
  12. seavision/engine/detectors/base.py +179 -0
  13. seavision/engine/detectors/motion/__init__.py +14 -0
  14. seavision/engine/detectors/motion/background.py +92 -0
  15. seavision/engine/detectors/motion/detector.py +218 -0
  16. seavision/engine/detectors/motion/stabiliser.py +174 -0
  17. seavision/engine/detectors/motion/tracker.py +174 -0
  18. seavision/engine/detectors/sam3/__init__.py +23 -0
  19. seavision/engine/detectors/sam3/config.py +309 -0
  20. seavision/engine/detectors/sam3/detector.py +1316 -0
  21. seavision/engine/detectors/sam3/native.py +490 -0
  22. seavision/engine/detectors/yolo/__init__.py +12 -0
  23. seavision/engine/detectors/yolo/config.py +39 -0
  24. seavision/engine/detectors/yolo/detector.py +236 -0
  25. seavision/engine/export/__init__.py +24 -0
  26. seavision/engine/export/config.py +160 -0
  27. seavision/engine/export/exporter.py +286 -0
  28. seavision/engine/export/validation.py +314 -0
  29. seavision/engine/postprocessor.py +738 -0
  30. seavision/engine/source/__init__.py +16 -0
  31. seavision/engine/source/base.py +87 -0
  32. seavision/engine/source/discovery.py +154 -0
  33. seavision/engine/source/local.py +155 -0
  34. seavision/engine/source/s3.py +231 -0
  35. seavision/engine/visualiser/__init__.py +62 -0
  36. seavision/engine/visualiser/annotator.py +382 -0
  37. seavision/engine/visualiser/config.py +127 -0
  38. seavision/engine/visualiser/loader.py +558 -0
  39. seavision/engine/visualiser/visualiser.py +402 -0
  40. seavision/engine/visualiser/writer.py +245 -0
  41. seavision/gui/__init__.py +0 -0
  42. seavision/gui/app.py +30 -0
  43. seavision/gui/main_window.py +891 -0
  44. seavision/gui/shared/__init__.py +0 -0
  45. seavision/gui/shared/conversion.py +55 -0
  46. seavision/gui/shared/session_utils.py +37 -0
  47. seavision/gui/validation/__init__.py +0 -0
  48. seavision/gui/validation/button_styles.py +144 -0
  49. seavision/gui/validation/detection_detail.py +164 -0
  50. seavision/gui/validation/detection_rect_item.py +486 -0
  51. seavision/gui/validation/detection_table.py +563 -0
  52. seavision/gui/validation/interactive_frame_scene.py +340 -0
  53. seavision/gui/validation/interactive_frame_view.py +252 -0
  54. seavision/gui/validation/s3_browser.py +645 -0
  55. seavision/gui/validation/seekable_source.py +262 -0
  56. seavision/gui/validation/session.py +376 -0
  57. seavision/gui/validation/tab.py +2503 -0
  58. seavision/gui/validation/transport_bar.py +172 -0
  59. seavision/gui/validation/validation_model.py +482 -0
  60. seavision/gui/validation/video_cache.py +215 -0
  61. seavision/gui/validation/video_list.py +140 -0
  62. seavision/gui/validation/video_viewer.py +150 -0
  63. seavision/gui/validation/video_worker.py +422 -0
  64. seavision/pipeline.py +856 -0
  65. seavision/resources/default.yaml +139 -0
  66. seavision/run_edge.py +96 -0
  67. seavision/run_export.py +176 -0
  68. seavision/run_pipeline.py +388 -0
  69. seavision_python-0.1.1.dist-info/METADATA +286 -0
  70. seavision_python-0.1.1.dist-info/RECORD +73 -0
  71. seavision_python-0.1.1.dist-info/WHEEL +5 -0
  72. seavision_python-0.1.1.dist-info/entry_points.txt +5 -0
  73. seavision_python-0.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,185 @@
1
+ """
2
+ YOLO ONNX pre/post processing for the edge runtime.
3
+
4
+ Preporcessing steps letterbox + normalise + transpose) and post processing
5
+ (confidence filter + NMS) must match the export settings exactly.
6
+
7
+ Dependencies: numpy, opencv-headless.
8
+ """
9
+
10
+ from typing import List, Tuple
11
+
12
+ import cv2
13
+ import numpy as np
14
+
15
+
16
+ def letterbox(
17
+ img: np.ndarray,
18
+ new_shape: Tuple[int, int] = (640, 640),
19
+ ) -> Tuple[np.ndarray, float, Tuple[float, float]]:
20
+ """
21
+ Resize and pad image to target size, preserving aspect ratio.
22
+
23
+ Matches the Ultralytics letterbox exactly so that box coordinates
24
+ from the model can be correctly scaled back to the original frame.
25
+
26
+ Args:
27
+ img: BGR image as numpy array.
28
+ new_shape: Target (height, width).
29
+
30
+ Returns:
31
+ Tuple of (padded_image, scale_ratio, (pad_w, pad_h)).
32
+ """
33
+ h, w = img.shape[:2]
34
+ r = min(new_shape[0] / h, new_shape[1] / w)
35
+ new_unpad = (int(round(w * r)), int(round(h * r)))
36
+
37
+ dw = (new_shape[1] - new_unpad[0]) / 2
38
+ dh = (new_shape[0] - new_unpad[1]) / 2
39
+
40
+ if (w, h) != new_unpad:
41
+ img = cv2.resize(img, new_unpad, interpolation=cv2.INTER_LINEAR)
42
+
43
+ top = int(round(dh - 0.1))
44
+ bottom = int(round(dh + 0.1))
45
+ left = int(round(dw - 0.1))
46
+ right = int(round(dw + 0.1))
47
+ img = cv2.copyMakeBorder(
48
+ img, top, bottom, left, right,
49
+ cv2.BORDER_CONSTANT, value=(114, 114, 114),
50
+ )
51
+
52
+ return img, r, (dw, dh)
53
+
54
+
55
+ def preprocess(
56
+ img_bgr: np.ndarray,
57
+ imgsz: int,
58
+ ) -> Tuple[np.ndarray, float, Tuple[float, float]]:
59
+ """Preprocess a BGR frame for YOLO ONNX inference.
60
+
61
+ Steps:
62
+ 1. Letterbox resize to (imgsz, imgsz).
63
+ 2. BGR -> RGB colour conversion.
64
+ 3. Normalise uint8 [0, 255] to float32 [0.0, 1.0].
65
+ 4. Transpose HWC -> CHW.
66
+ 5. Add batch dimension.
67
+
68
+ Args:
69
+ img_bgr: Original BGR frame.
70
+ imgsz: Target size (square).
71
+
72
+ Returns:
73
+ Tuple of (input_tensor, scale_ratio, (pad_w, pad_h)).
74
+ input_tensor has shape [1, 3, imgsz, imgsz], dtype float32.
75
+ """
76
+ img, ratio, pad = letterbox(img_bgr, new_shape=(imgsz, imgsz))
77
+ img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
78
+ img = img.astype(np.float32) / 255.0
79
+ img = np.transpose(img, (2, 0, 1)) # HWC -> CHW
80
+ img = np.expand_dims(img, axis=0) # add batch dim
81
+ return img, ratio, pad
82
+
83
+
84
+ def postprocess(
85
+ output: List[np.ndarray],
86
+ conf_threshold: float = 0.25,
87
+ iou_threshold: float = 0.45,
88
+ ) -> List[Tuple[float, float, float, float, float, int]]:
89
+ """Post-process YOLO ONNX output to detection tuples.
90
+
91
+ YOLO11 ONNX output shape: [1, 4+num_classes, N]
92
+ - 4 = cx, cy, w, h (box coordinates in input tensor space)
93
+ - num_classes = class scores
94
+ - N = number of candidate anchors
95
+
96
+ Args:
97
+ output: Raw ONNX Runtime output (list of numpy arrays).
98
+ conf_threshold: Minimum confidence to keep a detection.
99
+ iou_threshold: IoU threshold for non-maximum suppression.
100
+
101
+ Returns:
102
+ List of (x1, y1, x2, y2, confidence, class_id) tuples.
103
+ Coordinates are in the input tensor's pixel space (i.e.
104
+ letterboxed). The caller is responsible for scaling back to
105
+ original frame coordinates if needed.
106
+ """
107
+ # Transpose from [1, 4+C, N] to [N, 4+C]
108
+ preds = output[0].squeeze(0).T
109
+
110
+ boxes_cxcywh = preds[:, :4]
111
+ class_scores = preds[:, 4:]
112
+
113
+ max_scores = np.max(class_scores, axis=1)
114
+ class_ids = np.argmax(class_scores, axis=1)
115
+
116
+ # Confidence filter
117
+ mask = max_scores > conf_threshold
118
+ boxes_cxcywh = boxes_cxcywh[mask]
119
+ max_scores = max_scores[mask]
120
+ class_ids = class_ids[mask]
121
+
122
+ if len(boxes_cxcywh) == 0:
123
+ return []
124
+
125
+ # Convert cx,cy,w,h -> x,y,w,h for NMSBoxes (expects top-left origin)
126
+ boxes_xywh = np.zeros_like(boxes_cxcywh)
127
+ boxes_xywh[:, 0] = boxes_cxcywh[:, 0] - boxes_cxcywh[:, 2] / 2 # x1
128
+ boxes_xywh[:, 1] = boxes_cxcywh[:, 1] - boxes_cxcywh[:, 3] / 2 # y1
129
+ boxes_xywh[:, 2] = boxes_cxcywh[:, 2] # w
130
+ boxes_xywh[:, 3] = boxes_cxcywh[:, 3] # h
131
+
132
+ # Convert cx,cy,w,h -> x1,y1,x2,y2 for output
133
+ boxes_xyxy = np.zeros_like(boxes_cxcywh)
134
+ boxes_xyxy[:, 0] = boxes_xywh[:, 0]
135
+ boxes_xyxy[:, 1] = boxes_xywh[:, 1]
136
+ boxes_xyxy[:, 2] = boxes_xywh[:, 0] + boxes_xywh[:, 2]
137
+ boxes_xyxy[:, 3] = boxes_xywh[:, 1] + boxes_xywh[:, 3]
138
+
139
+ # NMS via OpenCV
140
+ indices = cv2.dnn.NMSBoxes(
141
+ boxes_xywh.tolist(),
142
+ max_scores.tolist(),
143
+ conf_threshold,
144
+ iou_threshold,
145
+ )
146
+
147
+ detections = []
148
+ if len(indices) > 0:
149
+ for i in np.asarray(indices).reshape(-1): # fixes Sequence[int] type error
150
+ detections.append((
151
+ float(boxes_xyxy[i, 0]),
152
+ float(boxes_xyxy[i, 1]),
153
+ float(boxes_xyxy[i, 2]),
154
+ float(boxes_xyxy[i, 3]),
155
+ float(max_scores[i]),
156
+ int(class_ids[i]),
157
+ ))
158
+
159
+ return detections
160
+
161
+
162
+ def scale_boxes_to_original(
163
+ detections: List[Tuple[float, float, float, float, float, int]],
164
+ ratio: float,
165
+ pad: Tuple[float, float],
166
+ ) -> List[Tuple[float, float, float, float, float, int]]:
167
+ """Scale detection boxes from letterboxed space back to original frame.
168
+
169
+ Args:
170
+ detections: Detections in letterboxed pixel space.
171
+ ratio: Scale ratio from letterbox.
172
+ pad: Padding offsets (pad_w, pad_h) from letterbox.
173
+
174
+ Returns:
175
+ Detections with coordinates in the original frame's pixel space.
176
+ """
177
+ pad_w, pad_h = pad
178
+ scaled = []
179
+ for x1, y1, x2, y2, conf, cls_id in detections:
180
+ x1 = (x1 - pad_w) / ratio
181
+ y1 = (y1 - pad_h) / ratio
182
+ x2 = (x2 - pad_w) / ratio
183
+ y2 = (y2 - pad_h) / ratio
184
+ scaled.append((x1, y1, x2, y2, conf, cls_id))
185
+ return scaled
@@ -0,0 +1,416 @@
1
+ """SeaVision edge inference runtime.
2
+
3
+ This is the main class deployed to edge devices. It reads frames from a
4
+ capture source (camera or video file), runs ONNX inference, writes
5
+ detections to CSV, and optionally records periodic validation clips.
6
+
7
+ Dependencies:
8
+ - onnxruntime (inference)
9
+ - opencv-python-headless (frame capture, preprocessing, NMS)
10
+ - numpy < 2.0 (array operations — numpy 2.0+ crashes on Pi 4)
11
+
12
+ Does NOT depend on:
13
+ - ultralytics
14
+ - torch / torchvision
15
+ - PySide6
16
+
17
+ Usage:
18
+ from seavision.edge import EdgeRuntime, EdgeConfig
19
+
20
+ config = EdgeConfig.from_file("config.json")
21
+ runtime = EdgeRuntime(config)
22
+ runtime.run()
23
+ """
24
+
25
+ import logging
26
+ import os
27
+ import time
28
+ import hashlib
29
+ from pathlib import Path
30
+ from typing import Optional
31
+ from datetime import datetime
32
+
33
+ import cv2
34
+ import numpy as np
35
+ import onnxruntime as ort
36
+
37
+ from seavision import __version__
38
+
39
+ from .capture import CaptureError, FrameCapture
40
+ from .config import ARTIFACT_MANIFEST_FILENAME, ArtifactManifest, EdgeConfig
41
+ from .postprocess import postprocess, preprocess, scale_boxes_to_original
42
+ from .writer import EdgeDetectionWriter
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+
47
+ def _parse_version(version: str) -> tuple[int, ...]:
48
+ return tuple(int(part) for part in version.split("."))
49
+
50
+
51
+ def _version_satisfies(version: str, version_range: str) -> bool:
52
+ if not version_range:
53
+ return True
54
+
55
+ current = _parse_version(version)
56
+ for raw_clause in version_range.split(","):
57
+ clause = raw_clause.strip()
58
+ if not clause:
59
+ continue
60
+
61
+ operator = None
62
+ for candidate in (">=", "<=", "==", ">", "<"):
63
+ if clause.startswith(candidate):
64
+ operator = candidate
65
+ break
66
+
67
+ if operator is None:
68
+ raise ValueError(f"Unsupported version range clause: {clause}")
69
+
70
+ target = _parse_version(clause[len(operator):].strip())
71
+ if operator == ">=" and not (current >= target):
72
+ return False
73
+ if operator == "<=" and not (current <= target):
74
+ return False
75
+ if operator == ">" and not (current > target):
76
+ return False
77
+ if operator == "<" and not (current < target):
78
+ return False
79
+ if operator == "==" and not (current == target):
80
+ return False
81
+
82
+ return True
83
+
84
+
85
+ class EdgeRuntime:
86
+ """
87
+ Lightweight ONNX inference runtime for edge deployment.
88
+
89
+ This class orchestrates the full inference pipeline on an edge device:
90
+
91
+ 1. Load the ONNX model into an ONNX Runtime session.
92
+ 2. Open the video source (camera or file)
93
+ 3. For each frame: preprocess -> infer -> postprocess -> write.
94
+ 4. Optionally record short validation clips at regular intervals.
95
+
96
+ The runtime is designed to run continuously (for camera sources) or until
97
+ the source is exhausted (for video files). It handles cleanup on both
98
+ normal exit and interruption (CTRL+C).
99
+ """
100
+
101
+ def __init__(self, config: Optional[EdgeConfig] = None):
102
+ """
103
+ Initilaise the runtime.
104
+
105
+ Args:
106
+ config: Runtime configuration. If None, defaults are used.
107
+ """
108
+ self.config = config or EdgeConfig()
109
+
110
+ self._session: Optional[ort.InferenceSession] = None
111
+ self._input_name: Optional[str] = None
112
+ self._capture: Optional[FrameCapture] = None
113
+ self._writer: Optional[EdgeDetectionWriter] = None
114
+
115
+ # Validation clip state
116
+ self._clip_writer: Optional[cv2.VideoWriter] = None
117
+ self._clip_frame_count: int = 0
118
+ self._last_clip_time: float = 0.0
119
+
120
+ @staticmethod
121
+ def _sha256_for_file(path: Path) -> str:
122
+ digest = hashlib.sha256()
123
+ with open(path, "rb") as file_obj:
124
+ for chunk in iter(lambda: file_obj.read(8192), b""):
125
+ digest.update(chunk)
126
+ return digest.hexdigest()
127
+
128
+ def _validate_artifact(self) -> None:
129
+ """Validate the artifact manifest, required files, and checksums."""
130
+ if not self.config.artifact_dir:
131
+ return
132
+
133
+ artifact_dir = Path(self.config.artifact_dir)
134
+ manifest = ArtifactManifest.from_file(
135
+ artifact_dir / ARTIFACT_MANIFEST_FILENAME
136
+ )
137
+
138
+ if not _version_satisfies(__version__, manifest.runtime_version_range):
139
+ raise ValueError(
140
+ "Artifact runtime compatibility check failed: "
141
+ f"SeaVision {__version__} does not satisfy "
142
+ f"{manifest.runtime_version_range}"
143
+ )
144
+
145
+ for label, file_entry in {
146
+ "model": manifest.model,
147
+ "runtime_config": manifest.runtime_config,
148
+ "export_metadata": manifest.export_metadata,
149
+ }.items():
150
+ file_path = artifact_dir / file_entry.path
151
+ if not file_path.exists():
152
+ raise FileNotFoundError(
153
+ f"Artifact {label} file is missing: {file_path}"
154
+ )
155
+
156
+ if file_entry.sha256:
157
+ actual_sha256 = self._sha256_for_file(file_path)
158
+ if actual_sha256 != file_entry.sha256:
159
+ raise ValueError(
160
+ f"Artifact {label} checksum mismatch for {file_path}: "
161
+ f"expected {file_entry.sha256}, got {actual_sha256}"
162
+ )
163
+
164
+ def _load_model(self) -> None:
165
+ """
166
+ Load the ONNX model into an inference session.
167
+
168
+ Configures ONNX runtime with settings optimised for edge hardware.
169
+ """
170
+ model_path = self.config.model_path
171
+
172
+ if not os.path.exists(model_path):
173
+ raise FileNotFoundError(
174
+ f"Model file not found: {model_path}. "
175
+ f"Ensure the deployment bundle was extracted correctly."
176
+ )
177
+
178
+ sess_options = ort.SessionOptions()
179
+
180
+ # Graph optimisation.
181
+ if self.config.ort_optimization == "basic":
182
+ sess_options.graph_optimization_level = (
183
+ ort.GraphOptimizationLevel.ORT_ENABLE_BASIC
184
+ )
185
+ else:
186
+ sess_options.graph_optimization_level = (
187
+ ort.GraphOptimizationLevel.ORT_ENABLE_ALL
188
+ )
189
+
190
+ # Thread count — 0 means "all cores"
191
+ threads = self.config.ort_threads
192
+ if threads <= 0:
193
+ import multiprocessing
194
+ threads = multiprocessing.cpu_count()
195
+ sess_options.intra_op_num_threads = threads
196
+
197
+ logger.info(
198
+ "Loading ONNX model: %s (threads=%d, optimization=%s)",
199
+ model_path, threads, self.config.ort_optimization,
200
+ )
201
+
202
+ self._session = ort.InferenceSession(
203
+ model_path, sess_options,
204
+ providers=["CPUExecutionProvider"],
205
+ )
206
+ self._input_name = self._session.get_inputs()[0].name
207
+
208
+ logger.info("Model loaded successfully")
209
+
210
+ def _open_capture(self) -> None:
211
+ """Open the video source."""
212
+ self._capture = FrameCapture(self.config.source)
213
+
214
+ def _open_writer(self) -> None:
215
+ """Open the detection CSV writer."""
216
+ source_name = self.config.source
217
+ # Use just the filename for video files, or "camera_N" for devices
218
+ try:
219
+ int(source_name)
220
+ source_name = f"camera_{source_name}"
221
+ except ValueError:
222
+ source_name = Path(source_name).stem
223
+
224
+ self._writer = EdgeDetectionWriter(
225
+ output_dir=self.config.output_dir,
226
+ source_name=source_name,
227
+ flush_interval=self.config.csv_flush_interval,
228
+ )
229
+
230
+ def _should_record_clip(self, elapsed: float) -> bool:
231
+ """Check if it's time to start a new validation clip."""
232
+ if self.config.validation_clip_interval_s <= 0:
233
+ return False
234
+ if self._clip_writer is not None:
235
+ return False # Already recording a clip
236
+ return (
237
+ (elapsed - self._last_clip_time) >=
238
+ self.config.validation_clip_interval_s
239
+ )
240
+
241
+ def _start_validation_clip(self, frame:np.ndarray) -> None:
242
+ """
243
+ Begin recording a validation clip.
244
+
245
+ The clip is written at the effective frame rate (accounting for
246
+ frame_skip) so that playback speed matches real time.
247
+ """
248
+ if self._capture is None:
249
+ logger.warning("Cannot start validation clip: capture not open")
250
+ return
251
+
252
+ clips_dir = Path(self.config.output_dir) / "validation_clips"
253
+ clips_dir.mkdir(parents=True, exist_ok=True)
254
+
255
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
256
+ clip_path = clips_dir / f"validation_{timestamp}.mp4"
257
+
258
+ # Use the effective fps (after frame_skip) so the clip plays
259
+ # back at the correct speed. If the source is 25fps and
260
+ # frame_skip=5, we're only seeing 5 frames per second, so the
261
+ # clip should be written at 5fps.
262
+ native_fps = self._capture.fps if self._capture.fps > 0 else 25.0
263
+ effective_fps = native_fps / max(self.config.frame_skip, 1)
264
+ h, w = frame.shape[:2]
265
+
266
+ fourcc = cv2.VideoWriter.fourcc(*"mp4v")
267
+ self._clip_writer = cv2.VideoWriter(
268
+ str(clip_path), fourcc, effective_fps, (w, h)
269
+ )
270
+ self._clip_frame_count = 0
271
+ self._clip_effective_fps = effective_fps
272
+
273
+ logger.info(
274
+ "Recording validation clip: %s (%.1f fps effective)",
275
+ clip_path, effective_fps,
276
+ )
277
+
278
+ def _write_clip_frame(self, frame: np.ndarray) -> None:
279
+ """Write a frame to the current validation clip."""
280
+ if self._clip_writer is None:
281
+ return
282
+
283
+ self._clip_writer.write(frame)
284
+ self._clip_frame_count += 1
285
+
286
+ # Use effective fps for duration calculation so the clip actually lasts
287
+ # validation_clip_duration_s in real timme.
288
+ max_frames = int(
289
+ self.config.validation_clip_duration_s * self._clip_effective_fps
290
+ )
291
+
292
+ if self._clip_frame_count >= max_frames:
293
+ self._clip_writer.release()
294
+ self._clip_writer = None
295
+ self._last_clip_time = time.monotonic() - self._start_time
296
+ logger.info(
297
+ "Validation clip complete (%d frames, %.0fs)",
298
+ self._clip_frame_count,
299
+ self.config.validation_clip_duration_s,
300
+ )
301
+
302
+ def run(self) -> None:
303
+ """
304
+ Main inference loop.
305
+
306
+ Runs until the source is exhausted (video file) or interrupted
307
+ (camera / Ctrl+C). Handles cleanup on exit.
308
+ """
309
+ # Setup logging
310
+ logging.basicConfig(
311
+ level=getattr(logging, self.config.log_level.upper(), logging.INFO),
312
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
313
+ )
314
+
315
+ # Initialise _start_time BEFORE any setup that might raise, so
316
+ # the finally block can always compute elapsed time safely.
317
+ self._start_time = time.monotonic()
318
+ processed = 0
319
+ total_inference_ms = 0.0
320
+
321
+ self._validate_artifact()
322
+ self._load_model()
323
+ self._open_capture()
324
+ self._open_writer()
325
+
326
+ logger.info(
327
+ "Starting inference (imgsz=%d, conf=%.2f, frame_skip=%d)",
328
+ self.config.imgsz, self.config.conf_threshold,
329
+ self.config.frame_skip,
330
+ )
331
+
332
+ if (self._capture is None
333
+ or self._session is None
334
+ or self._writer is None):
335
+ logger.error("Runtime not properly initialised. Exiting.")
336
+ return
337
+
338
+ try:
339
+ for frame, frame_number in self._capture.iter_frames(
340
+ frame_skip=self.config.frame_skip,
341
+ ):
342
+ elapsed = time.monotonic() - self._start_time
343
+
344
+ # Preprocess
345
+ blob, ratio, pad = preprocess(frame, self.config.imgsz)
346
+
347
+ # Inference
348
+ t0 = time.perf_counter()
349
+ raw_output = self._session.run(None, {self._input_name: blob})
350
+ t1 = time.perf_counter()
351
+ inference_ms = (t1 - t0) * 1000
352
+ total_inference_ms += inference_ms
353
+
354
+ # Narrow type from ONNX union to list[np.ndarray]
355
+ output = [
356
+ arr for arr in raw_output
357
+ if isinstance(arr, np.ndarray)
358
+ ]
359
+ if len(output) != len(raw_output):
360
+ raise TypeError("ONNX model returned non-ndarray output(s)")
361
+
362
+ # Postprocess
363
+ detections = postprocess(
364
+ output,
365
+ conf_threshold=self.config.conf_threshold,
366
+ iou_threshold=self.config.iou_threshold,
367
+ )
368
+
369
+ # Scale boxes back to original frame coordinates
370
+ detections = scale_boxes_to_original(detections, ratio, pad)
371
+
372
+ # Write detections
373
+ timestamp = frame_number / self._capture.fps if self._capture.fps > 0 else 0.0
374
+ if detections:
375
+ self._writer.write(
376
+ detections, frame_number, timestamp,
377
+ class_names=self.config.class_names,
378
+ )
379
+
380
+ # Validation clip
381
+ if self._should_record_clip(elapsed):
382
+ self._start_validation_clip(frame)
383
+ if self._clip_writer is not None:
384
+ self._write_clip_frame(frame)
385
+
386
+ processed += 1
387
+
388
+ # Periodic log
389
+ if processed % 500 == 0:
390
+ avg_ms = total_inference_ms / processed
391
+ logger.info(
392
+ "Processed %d frames | avg inference: %.1f ms | "
393
+ "elapsed: %.0f s",
394
+ processed, avg_ms, elapsed,
395
+ )
396
+
397
+ except KeyboardInterrupt:
398
+ logger.info("Inference interrupted by user")
399
+
400
+ finally:
401
+ # Cleanup
402
+ if self._clip_writer is not None:
403
+ self._clip_writer.release()
404
+ if self._writer is not None:
405
+ self._writer.close()
406
+ if self._capture is not None:
407
+ self._capture.release()
408
+
409
+ elapsed = time.monotonic() - self._start_time
410
+ avg_ms = total_inference_ms / max(processed, 1)
411
+ logger.info(
412
+ "Runtime finished: %d frames in %.1f s (avg %.1f ms/frame, "
413
+ "%.1f effective FPS)",
414
+ processed, elapsed, avg_ms,
415
+ processed / max(elapsed, 0.001),
416
+ )