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.
- seavision/__init__.py +3 -0
- seavision/defaults.py +38 -0
- seavision/edge/__init__.py +38 -0
- seavision/edge/bundle.py +189 -0
- seavision/edge/capture.py +107 -0
- seavision/edge/config.py +219 -0
- seavision/edge/postprocess.py +185 -0
- seavision/edge/runtime.py +416 -0
- seavision/edge/writer.py +167 -0
- seavision/engine/__init__.py +52 -0
- seavision/engine/detectors/__init__.py +90 -0
- seavision/engine/detectors/base.py +179 -0
- seavision/engine/detectors/motion/__init__.py +14 -0
- seavision/engine/detectors/motion/background.py +92 -0
- seavision/engine/detectors/motion/detector.py +218 -0
- seavision/engine/detectors/motion/stabiliser.py +174 -0
- seavision/engine/detectors/motion/tracker.py +174 -0
- seavision/engine/detectors/sam3/__init__.py +23 -0
- seavision/engine/detectors/sam3/config.py +309 -0
- seavision/engine/detectors/sam3/detector.py +1316 -0
- seavision/engine/detectors/sam3/native.py +490 -0
- seavision/engine/detectors/yolo/__init__.py +12 -0
- seavision/engine/detectors/yolo/config.py +39 -0
- seavision/engine/detectors/yolo/detector.py +236 -0
- seavision/engine/export/__init__.py +24 -0
- seavision/engine/export/config.py +160 -0
- seavision/engine/export/exporter.py +286 -0
- seavision/engine/export/validation.py +314 -0
- seavision/engine/postprocessor.py +738 -0
- seavision/engine/source/__init__.py +16 -0
- seavision/engine/source/base.py +87 -0
- seavision/engine/source/discovery.py +154 -0
- seavision/engine/source/local.py +155 -0
- seavision/engine/source/s3.py +231 -0
- seavision/engine/visualiser/__init__.py +62 -0
- seavision/engine/visualiser/annotator.py +382 -0
- seavision/engine/visualiser/config.py +127 -0
- seavision/engine/visualiser/loader.py +558 -0
- seavision/engine/visualiser/visualiser.py +402 -0
- seavision/engine/visualiser/writer.py +245 -0
- seavision/gui/__init__.py +0 -0
- seavision/gui/app.py +30 -0
- seavision/gui/main_window.py +891 -0
- seavision/gui/shared/__init__.py +0 -0
- seavision/gui/shared/conversion.py +55 -0
- seavision/gui/shared/session_utils.py +37 -0
- seavision/gui/validation/__init__.py +0 -0
- seavision/gui/validation/button_styles.py +144 -0
- seavision/gui/validation/detection_detail.py +164 -0
- seavision/gui/validation/detection_rect_item.py +486 -0
- seavision/gui/validation/detection_table.py +563 -0
- seavision/gui/validation/interactive_frame_scene.py +340 -0
- seavision/gui/validation/interactive_frame_view.py +252 -0
- seavision/gui/validation/s3_browser.py +645 -0
- seavision/gui/validation/seekable_source.py +262 -0
- seavision/gui/validation/session.py +376 -0
- seavision/gui/validation/tab.py +2503 -0
- seavision/gui/validation/transport_bar.py +172 -0
- seavision/gui/validation/validation_model.py +482 -0
- seavision/gui/validation/video_cache.py +215 -0
- seavision/gui/validation/video_list.py +140 -0
- seavision/gui/validation/video_viewer.py +150 -0
- seavision/gui/validation/video_worker.py +422 -0
- seavision/pipeline.py +856 -0
- seavision/resources/default.yaml +139 -0
- seavision/run_edge.py +96 -0
- seavision/run_export.py +176 -0
- seavision/run_pipeline.py +388 -0
- seavision_python-0.1.1.dist-info/METADATA +286 -0
- seavision_python-0.1.1.dist-info/RECORD +73 -0
- seavision_python-0.1.1.dist-info/WHEEL +5 -0
- seavision_python-0.1.1.dist-info/entry_points.txt +5 -0
- 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
|
+
)
|