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,236 @@
|
|
|
1
|
+
"""YOLO detector implementation using Ultralytics for SeaVision.
|
|
2
|
+
|
|
3
|
+
This module wraps Ultralytics YOLO to implement the DetectorBase interface, so
|
|
4
|
+
it can be used in the SeaVision detection pipeline as a pure, stateless
|
|
5
|
+
detector (no built-in tracking or persistence filtering).
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
from seavision.engine.detectors.yolo import YOLODetector, YOLODetectorConfig
|
|
9
|
+
|
|
10
|
+
config = YOLODetectorConfig(
|
|
11
|
+
model_path="./models/yolo11n.pt",
|
|
12
|
+
conf_threshold=0.4,
|
|
13
|
+
iou_threshold=0.5,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
with YOLODetector(config) as detector:
|
|
17
|
+
for frame, context in source.iter_frames():
|
|
18
|
+
for det in detector.process_frame(frame, context):
|
|
19
|
+
conf = det.confidence if det.confidence is not None else float("nan")
|
|
20
|
+
print(
|
|
21
|
+
f"{det.source_file} "
|
|
22
|
+
f"frame={det.frame_number} "
|
|
23
|
+
f"label={det.label} "
|
|
24
|
+
f"conf={conf:.2f}"
|
|
25
|
+
)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
from typing import Iterator, Optional
|
|
31
|
+
|
|
32
|
+
import numpy as np
|
|
33
|
+
import ultralytics
|
|
34
|
+
|
|
35
|
+
from ...source import FrameContext
|
|
36
|
+
from ..base import Detection, DetectorBase
|
|
37
|
+
from .config import YOLODetectorConfig
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class YOLODetector(DetectorBase):
|
|
43
|
+
"""Object detector based on Ultralytics YOLO.
|
|
44
|
+
|
|
45
|
+
This detector implements the DetectorBase interface and produces
|
|
46
|
+
:class:`Detection` objects directly from YOLO predictions. It does **not**
|
|
47
|
+
perform any temporal tracking or persistence filtering; those can be
|
|
48
|
+
composed separately if needed.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, config: Optional[YOLODetectorConfig] = None):
|
|
52
|
+
"""Initialise the YOLO detector.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
config: Optional :class:`YOLODetectorConfig`. If None, defaults are
|
|
56
|
+
used.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
# This import will already have been attempted at module import time,
|
|
60
|
+
# but we keep the reference here for clarity and to make the
|
|
61
|
+
# dependency explicit.
|
|
62
|
+
if not hasattr(ultralytics, "YOLO"):
|
|
63
|
+
raise ImportError(
|
|
64
|
+
"Ultralytics YOLO is required for YOLODetector. "
|
|
65
|
+
"Install with: pip install ultralytics>=8.3.237"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
self.config = config or YOLODetectorConfig()
|
|
69
|
+
|
|
70
|
+
# Ultralytics model instance (lazy-loaded)
|
|
71
|
+
self._model: Optional[ultralytics.YOLO] = None
|
|
72
|
+
|
|
73
|
+
logger.info(
|
|
74
|
+
"YOLODetector initialised (model=%s, device=%s)",
|
|
75
|
+
self.config.model_path,
|
|
76
|
+
self.config.device,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# ------------------------------------------------------------------
|
|
80
|
+
# Internal helpers
|
|
81
|
+
# ------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def _ensure_model_loaded(self) -> None:
|
|
84
|
+
"""Lazily load the YOLO model from a local path.
|
|
85
|
+
|
|
86
|
+
This method is idempotent. It also enforces that the model path exists
|
|
87
|
+
locally to avoid implicit downloads or side-effectful behaviour.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
if self._model is not None:
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
if not os.path.exists(self.config.model_path):
|
|
94
|
+
raise FileNotFoundError(
|
|
95
|
+
"YOLO weights not found at: "
|
|
96
|
+
f"{self.config.model_path}. "
|
|
97
|
+
"Download the weights separately and set model_path to the "
|
|
98
|
+
"local file."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
logger.info("Loading YOLO model from %s", self.config.model_path)
|
|
102
|
+
self._model = ultralytics.YOLO(self.config.model_path)
|
|
103
|
+
|
|
104
|
+
# Move to device if specified
|
|
105
|
+
if self.config.device:
|
|
106
|
+
try:
|
|
107
|
+
self._model.to(self.config.device)
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
logger.warning(
|
|
110
|
+
"Failed to move YOLO model to device '%s': %s. "
|
|
111
|
+
"Using default device instead.",
|
|
112
|
+
self.config.device,
|
|
113
|
+
exc,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
logger.info("YOLO model loaded successfully")
|
|
117
|
+
|
|
118
|
+
def _class_name_from_id(self, cls_id: int) -> str:
|
|
119
|
+
"""Map a YOLO class index to a human-readable name, if available."""
|
|
120
|
+
|
|
121
|
+
if self._model is None:
|
|
122
|
+
return str(cls_id)
|
|
123
|
+
|
|
124
|
+
names = getattr(self._model, "names", None)
|
|
125
|
+
|
|
126
|
+
if isinstance(names, dict):
|
|
127
|
+
return str(names.get(cls_id, cls_id))
|
|
128
|
+
if isinstance(names, (list, tuple)):
|
|
129
|
+
if 0 <= cls_id < len(names):
|
|
130
|
+
return str(names[cls_id])
|
|
131
|
+
|
|
132
|
+
return str(cls_id)
|
|
133
|
+
|
|
134
|
+
# ------------------------------------------------------------------
|
|
135
|
+
# DetectorBase interface
|
|
136
|
+
# ------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def process_frame(
|
|
139
|
+
self,
|
|
140
|
+
frame: np.ndarray,
|
|
141
|
+
context: FrameContext,
|
|
142
|
+
) -> Iterator[Detection]:
|
|
143
|
+
"""Process a single video frame and yield YOLO detections.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
frame: BGR image as a numpy array with shape (H, W, 3).
|
|
147
|
+
context: FrameContext containing metadata for this frame.
|
|
148
|
+
|
|
149
|
+
Yields:
|
|
150
|
+
Detection objects corresponding to YOLO predictions that pass the
|
|
151
|
+
configured confidence and class filters.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
self._ensure_model_loaded()
|
|
155
|
+
|
|
156
|
+
# Run YOLO inference on the in-memory frame. All saving-related
|
|
157
|
+
# options are explicitly disabled to avoid side effects.
|
|
158
|
+
results_list = self._model.predict(
|
|
159
|
+
frame,
|
|
160
|
+
imgsz=self.config.imgsz,
|
|
161
|
+
conf=self.config.conf_threshold,
|
|
162
|
+
iou=self.config.iou_threshold,
|
|
163
|
+
max_det=self.config.max_detections,
|
|
164
|
+
classes=self.config.classes,
|
|
165
|
+
device=self.config.device,
|
|
166
|
+
half=self.config.half,
|
|
167
|
+
verbose=self.config.verbose,
|
|
168
|
+
save=False,
|
|
169
|
+
save_frames=False,
|
|
170
|
+
save_txt=False,
|
|
171
|
+
save_conf=False,
|
|
172
|
+
save_crop=False,
|
|
173
|
+
project=None,
|
|
174
|
+
name=None,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
if not results_list:
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
results = results_list[0]
|
|
181
|
+
boxes = results.boxes
|
|
182
|
+
|
|
183
|
+
if boxes is None or len(boxes) == 0:
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
for i in range(len(boxes)):
|
|
187
|
+
box = boxes[i]
|
|
188
|
+
|
|
189
|
+
# xyxy format as numpy array
|
|
190
|
+
xyxy = box.xyxy[0].cpu().numpy()
|
|
191
|
+
x1, y1, x2, y2 = xyxy
|
|
192
|
+
|
|
193
|
+
width = float(x2 - x1)
|
|
194
|
+
height = float(y2 - y1)
|
|
195
|
+
xc = float(x1 + width / 2.0)
|
|
196
|
+
yc = float(y1 + height / 2.0)
|
|
197
|
+
|
|
198
|
+
# Confidence (YOLO already applies conf threshold, but we keep
|
|
199
|
+
# this for completeness and potential future overrides).
|
|
200
|
+
confidence: Optional[float] = None
|
|
201
|
+
if box.conf is not None and len(box.conf) > 0:
|
|
202
|
+
confidence = float(box.conf[0])
|
|
203
|
+
if confidence < self.config.conf_threshold:
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
# Class index
|
|
207
|
+
cls_id: Optional[int] = None
|
|
208
|
+
if box.cls is not None and len(box.cls) > 0:
|
|
209
|
+
cls_id = int(box.cls[0])
|
|
210
|
+
|
|
211
|
+
label: Optional[str] = None
|
|
212
|
+
if self.config.output_labels and cls_id is not None:
|
|
213
|
+
label = self._class_name_from_id(cls_id)
|
|
214
|
+
|
|
215
|
+
yield Detection(
|
|
216
|
+
source_file=context.source_file,
|
|
217
|
+
timestamp=context.timestamp,
|
|
218
|
+
frame_number=context.frame_number,
|
|
219
|
+
xc=xc,
|
|
220
|
+
yc=yc,
|
|
221
|
+
width=width,
|
|
222
|
+
height=height,
|
|
223
|
+
confidence=confidence,
|
|
224
|
+
label=label,
|
|
225
|
+
track_id=None,
|
|
226
|
+
mask=None,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def reset(self) -> None:
|
|
230
|
+
"""Reset detector state between videos.
|
|
231
|
+
|
|
232
|
+
The YOLO model itself remains loaded; this method exists to satisfy
|
|
233
|
+
the DetectorBase contract and for future extension if needed.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
logger.debug("YOLODetector reset called (no persistent state to clear)")
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Model export for edge deployment.
|
|
3
|
+
|
|
4
|
+
This package converts trained YOLO .pt weights to optimised formats
|
|
5
|
+
(ONNX, TFLite, NCNN) for deployment on edge devices like Raspberry Pi.
|
|
6
|
+
|
|
7
|
+
Public API:
|
|
8
|
+
- ExportConfig: Configuration for the export process.
|
|
9
|
+
- ExportTarget: Enum of supported export formats.
|
|
10
|
+
- ExportResult: Result of an export operation.
|
|
11
|
+
- ValidationResult: Result of export validation.
|
|
12
|
+
- ModelExporter: The main export class.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .config import ExportConfig, ExportResult, ExportTarget, ValidationResult
|
|
16
|
+
from .exporter import ModelExporter
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"ExportConfig",
|
|
20
|
+
"ExportResult",
|
|
21
|
+
"ExportTarget",
|
|
22
|
+
"ModelExporter",
|
|
23
|
+
"ValidationResult",
|
|
24
|
+
]
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Configuration and result types for model exprt to edge formats."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum, auto
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ExportTarget(Enum):
|
|
10
|
+
"""
|
|
11
|
+
Supported export target formats.
|
|
12
|
+
|
|
13
|
+
Each target produces a different model artifact optimised for a specific
|
|
14
|
+
runtime or hardware accelerator.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
ONNX = auto() # ONNX Runtime - best for Raspberry Pi CPU
|
|
18
|
+
TFLITE = auto() # TensorFlow Lite - required for Coral Edge TPU
|
|
19
|
+
NCNN = auto() # Tencent ncnn - lightweight, no runtime dependency
|
|
20
|
+
TORCHSCRIPT = auto() # Torchhscript - fallback requires PyTorch on device
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Maps Export Target to the Ultralytics export format string and the file
|
|
24
|
+
# extension (or directory suffix) of the resulting artifact.
|
|
25
|
+
TARGET_EXPORT_INFO: Dict[ExportTarget, Dict[str, str]] = {
|
|
26
|
+
ExportTarget.ONNX: {
|
|
27
|
+
"format": "onnx",
|
|
28
|
+
"suffix": ".onnx",
|
|
29
|
+
"runtime_package": "onnxruntime",
|
|
30
|
+
},
|
|
31
|
+
ExportTarget.TFLITE: {
|
|
32
|
+
"format": "tflite",
|
|
33
|
+
"suffix": ".tflite",
|
|
34
|
+
"runtime_package": "tflite-runtime",
|
|
35
|
+
},
|
|
36
|
+
ExportTarget.NCNN: {
|
|
37
|
+
"format": "ncnn",
|
|
38
|
+
"suffix": "_ncnn_model",
|
|
39
|
+
"runtime_package": "ncnn",
|
|
40
|
+
},
|
|
41
|
+
ExportTarget.TORCHSCRIPT: {
|
|
42
|
+
"format": "torchscript",
|
|
43
|
+
"suffix": ".torchscript",
|
|
44
|
+
"runtime_package": "torch",
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class ExportConfig:
|
|
51
|
+
"""
|
|
52
|
+
Configuration for exporting a trained YOLO model to an edge format.
|
|
53
|
+
|
|
54
|
+
Attributes:
|
|
55
|
+
weights_path: Path to the trained .pt weights file. Must exist.
|
|
56
|
+
output_dir: Directory to write the exported model and metadata. Created
|
|
57
|
+
if it does not exist.
|
|
58
|
+
target: Export target format. Determines which runtime is needed on the
|
|
59
|
+
edge device and which optimisations are applied. Default is ONNX -
|
|
60
|
+
the most reliable and best-performaing options for Raspberry Pi
|
|
61
|
+
4/5 CPU inference.
|
|
62
|
+
imgsz: Input image size for the exported model. The edge runtime must
|
|
63
|
+
use the same size. Smaller sizes (320) run faster but detect fewer
|
|
64
|
+
small objects. Larger sizes (640) are the more accurate but slower.
|
|
65
|
+
This is hte single most impactful performance knob for edge
|
|
66
|
+
deployment.
|
|
67
|
+
half: Use float16 quanitisation. Reduces model size by ~50% with minimal
|
|
68
|
+
accuracy loss on most detection tasks. Not supported on all targets
|
|
69
|
+
or devices - ONNX on ARM CPUs does not benefit from half precision,
|
|
70
|
+
so this defaults to False.
|
|
71
|
+
int8: Use int8 quantisation. Aggressive size and speed optimisation. May
|
|
72
|
+
impact detection accuracy, especially for small or low-contrast
|
|
73
|
+
objects. Requires a calibration dataset for TFLite exports.
|
|
74
|
+
simplify: Run ONNX graph simplification passes. Folds constants,
|
|
75
|
+
eliminates dead nodes, and merges operations. Reccommended for all
|
|
76
|
+
edge deployments - the simplified graph runs faster and is smaller.
|
|
77
|
+
Only applies to ONNX target.
|
|
78
|
+
opset: ONNX opset version. Higher opsets support more operations but may
|
|
79
|
+
not be supported by older ONNX Runtime versions. Default 12 is
|
|
80
|
+
widely compatible. Only applies to ONNX target.
|
|
81
|
+
validate: Run export validation after conversion. Loads both the
|
|
82
|
+
original PyTorch model and the exported model, runs a synthetic
|
|
83
|
+
frame through each, and ocmpares outputs. Catches silent export
|
|
84
|
+
failures where the conversion completes without error but produces
|
|
85
|
+
different detections.
|
|
86
|
+
device: Device to use for the PyTorch model during export and
|
|
87
|
+
validation. Use 'cpu' unless you have a CUDA GPU available, in which
|
|
88
|
+
case 'cuda' will speed up the export process.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
weights_path: str = ""
|
|
92
|
+
output_dir: str = "./export"
|
|
93
|
+
|
|
94
|
+
target: ExportTarget = ExportTarget.ONNX
|
|
95
|
+
imgsz: int = 640
|
|
96
|
+
half: bool = False
|
|
97
|
+
int8: bool = False
|
|
98
|
+
simplify: bool = True
|
|
99
|
+
opset: int = 12
|
|
100
|
+
|
|
101
|
+
validate: bool = True
|
|
102
|
+
device: str = "cpu"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class ValidationResult:
|
|
107
|
+
"""Result of comparing PyTorch vs exported model outputs.
|
|
108
|
+
|
|
109
|
+
Attributes:
|
|
110
|
+
passed: Whether the validation passed — the exported model
|
|
111
|
+
produces detections consistent with the PyTorch original.
|
|
112
|
+
num_detections_pytorch: Number of detections from the PyTorch model.
|
|
113
|
+
num_detections_exported: Number of detections from the exported model.
|
|
114
|
+
max_box_deviation: Maximum pixel deviation between matched boxes.
|
|
115
|
+
A value under 2.0 pixels is normal and caused by floating
|
|
116
|
+
point differences between runtimes.
|
|
117
|
+
max_confidence_deviation: Maximum confidence score deviation
|
|
118
|
+
between matched detections.
|
|
119
|
+
details: Human-readable summary of the comparison.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
passed: bool = False
|
|
123
|
+
num_detections_pytorch: int = 0
|
|
124
|
+
num_detections_exported: int = 0
|
|
125
|
+
max_box_deviation: float = 0.0
|
|
126
|
+
max_confidence_deviation: float = 0.0
|
|
127
|
+
details: str = ""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class ExportResult:
|
|
132
|
+
"""Result of a model export operation.
|
|
133
|
+
|
|
134
|
+
Returned by ModelExporter.export(). Contains everything the bundle
|
|
135
|
+
builder needs to package the model for deployment.
|
|
136
|
+
|
|
137
|
+
Attributes:
|
|
138
|
+
success: Whether the export completed without error.
|
|
139
|
+
exported_path: Path to the exported model file or directory.
|
|
140
|
+
config: The ExportConfig that was used.
|
|
141
|
+
model_size_mb: Size of the exported model in megabytes.
|
|
142
|
+
num_classes: Number of detection classes in the model.
|
|
143
|
+
class_names: Mapping of class index to human-readable name,
|
|
144
|
+
extracted from the YOLO model's metadata.
|
|
145
|
+
input_shape: Expected input tensor shape, e.g. [1, 3, 640, 640].
|
|
146
|
+
output_shape: Output tensor shape, e.g. [1, 84, 8400].
|
|
147
|
+
validation: Validation result, if validation was enabled.
|
|
148
|
+
error: Error message if the export failed.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
success: bool = False
|
|
152
|
+
exported_path: str = ""
|
|
153
|
+
config: Optional[ExportConfig] = None
|
|
154
|
+
model_size_mb: float = 0.0
|
|
155
|
+
num_classes: int = 0
|
|
156
|
+
class_names: Dict[int, str] = field(default_factory=dict)
|
|
157
|
+
input_shape: List[int] = field(default_factory=list)
|
|
158
|
+
output_shape: List[int] = field(default_factory=list)
|
|
159
|
+
validation: Optional[ValidationResult] = None
|
|
160
|
+
error: str = ""
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Model Exporter - converts trained YOLO .pt weights to edge format.
|
|
3
|
+
|
|
4
|
+
This module wraps Ultralytics' export pipeline with SeaVision-specific defaults
|
|
5
|
+
and adds export validation. The user interacts with this class; they never need
|
|
6
|
+
to call ultralytics.YOLO.export() directly.
|
|
7
|
+
|
|
8
|
+
Example:
|
|
9
|
+
from seavision.engine.export import ModelExporter, ExportConfig
|
|
10
|
+
|
|
11
|
+
config = ExportConfig(
|
|
12
|
+
weights_path="models/fidh_detector.pt",
|
|
13
|
+
output_dir="./deployment",
|
|
14
|
+
imgsz=320,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
exporter = ModelExporter(config)
|
|
18
|
+
result = exporter.export()
|
|
19
|
+
|
|
20
|
+
if result.success:
|
|
21
|
+
print(f"Exported to: {result.exported_path}")
|
|
22
|
+
print(f"Model size: {result.model_size_mb:.1f} MB")
|
|
23
|
+
print(f"Classes: {result.class_names}")
|
|
24
|
+
else:
|
|
25
|
+
print(f"Export failed: {result.error}")
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
import shutil
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Dict, List, Optional
|
|
33
|
+
import json
|
|
34
|
+
|
|
35
|
+
from .config import (
|
|
36
|
+
ExportConfig,
|
|
37
|
+
ExportResult,
|
|
38
|
+
ExportTarget,
|
|
39
|
+
TARGET_EXPORT_INFO,
|
|
40
|
+
)
|
|
41
|
+
from.validation import validate_export
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ExportError(Exception):
|
|
47
|
+
"""Raised when a model export fails."""
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ModelExporter:
|
|
52
|
+
"""
|
|
53
|
+
Converts trained YOLO .pt weights to edge-optimised formats.
|
|
54
|
+
|
|
55
|
+
This class is the dingle entrypoint for model export, It:
|
|
56
|
+
|
|
57
|
+
1. Loads the PyTorch model via Ultralytics.
|
|
58
|
+
2. Extracts model metadata (class names, input/output shapes).
|
|
59
|
+
3. Calls Ultralytics export with SeaVision-specific settings.
|
|
60
|
+
4. Optionally validates the export by comparing inference outputs.
|
|
61
|
+
5. Copies the exported artifact to the configured output directory.
|
|
62
|
+
|
|
63
|
+
The exporter requires Ultralytics and PyTorch - it runs land-side, not on
|
|
64
|
+
the edge device.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, config: ExportConfig):
|
|
68
|
+
"""
|
|
69
|
+
Initialise the exporter.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
config: Export configuration
|
|
73
|
+
"""
|
|
74
|
+
self.config = config
|
|
75
|
+
|
|
76
|
+
def export(self) -> ExportResult:
|
|
77
|
+
"""
|
|
78
|
+
Export the model and return metadata about the result.
|
|
79
|
+
|
|
80
|
+
This is the main entrypoint. it handles the full export lifecycle:
|
|
81
|
+
validation of inputs, model loading, export, optional validation, and
|
|
82
|
+
artifact copying.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
ExportResult with success status, exported path, model metadata,
|
|
86
|
+
and validation results.
|
|
87
|
+
"""
|
|
88
|
+
config = self.config
|
|
89
|
+
|
|
90
|
+
# --- Input validation ---
|
|
91
|
+
weights = Path(config.weights_path)
|
|
92
|
+
if not weights.exists():
|
|
93
|
+
return ExportResult(
|
|
94
|
+
success=False,
|
|
95
|
+
config=config,
|
|
96
|
+
error=f"Weights file not found: {config.weights_path}",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if config.target not in TARGET_EXPORT_INFO:
|
|
100
|
+
return ExportResult(
|
|
101
|
+
success=False,
|
|
102
|
+
config=config,
|
|
103
|
+
error=f"Unsupported export target: {config.target}",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
target_info = TARGET_EXPORT_INFO[config.target]
|
|
107
|
+
output_dir = Path(config.output_dir)
|
|
108
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
|
|
110
|
+
# --- Load model ---
|
|
111
|
+
try:
|
|
112
|
+
import ultralytics
|
|
113
|
+
logger.info("Loading YOLO model from %s", config.weights_path)
|
|
114
|
+
model = ultralytics.YOLO(config.weights_path)
|
|
115
|
+
except ImportError:
|
|
116
|
+
return ExportResult(
|
|
117
|
+
success=False,
|
|
118
|
+
config=config,
|
|
119
|
+
error=(
|
|
120
|
+
"Ultralytics is required for model export. "
|
|
121
|
+
"Install with: pip install ultralytics"
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
return ExportResult(
|
|
126
|
+
success=False,
|
|
127
|
+
config=config,
|
|
128
|
+
error=f"Failed to load model: {exc}",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# --- Extract metadata ---
|
|
132
|
+
class_names: Dict[int, str] = {}
|
|
133
|
+
names = getattr(model, "names", None)
|
|
134
|
+
if isinstance(names, dict):
|
|
135
|
+
class_names = {int(k): str(v) for k, v in names.items()}
|
|
136
|
+
elif isinstance(names, (list, tuple)):
|
|
137
|
+
class_names = {i: str(n) for i, n in enumerate(names)}
|
|
138
|
+
|
|
139
|
+
num_classes = len(class_names) if class_names else 0
|
|
140
|
+
|
|
141
|
+
# --- Build export kwargs ---
|
|
142
|
+
export_kwargs = {
|
|
143
|
+
"format": target_info["format"],
|
|
144
|
+
"imgsz": config.imgsz,
|
|
145
|
+
"half": config.half,
|
|
146
|
+
"int8": config.int8,
|
|
147
|
+
"device": config.device,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if config.target == ExportTarget.ONNX:
|
|
151
|
+
export_kwargs["simplify"] = config.simplify
|
|
152
|
+
export_kwargs["opset"] = config.opset
|
|
153
|
+
|
|
154
|
+
# --- Run export ---
|
|
155
|
+
try:
|
|
156
|
+
logger.info(
|
|
157
|
+
"Exporting model to %s (imgsz=%d, half=%s, int8=%s)",
|
|
158
|
+
config.target.name,
|
|
159
|
+
config.imgsz,
|
|
160
|
+
config.half,
|
|
161
|
+
config.int8,
|
|
162
|
+
)
|
|
163
|
+
export_path_raw = model.export(**export_kwargs)
|
|
164
|
+
|
|
165
|
+
# Ultralytics returns the path as a string
|
|
166
|
+
export_path = str(export_path_raw)
|
|
167
|
+
logger.info("Ultralytics export completed: %s", export_path)
|
|
168
|
+
except Exception as exc:
|
|
169
|
+
return ExportResult(
|
|
170
|
+
success=False,
|
|
171
|
+
config=config,
|
|
172
|
+
error=f"Export failed: {exc}",
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# --- Copy to output directory ---
|
|
176
|
+
src = Path(export_path)
|
|
177
|
+
suffix = target_info["suffix"]
|
|
178
|
+
dest_name = weights.stem + suffix
|
|
179
|
+
dest = output_dir / dest_name
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
if src.is_dir():
|
|
183
|
+
if dest.exists():
|
|
184
|
+
shutil.rmtree(dest)
|
|
185
|
+
shutil.copytree(src, dest)
|
|
186
|
+
else:
|
|
187
|
+
shutil.copy2(src, dest)
|
|
188
|
+
|
|
189
|
+
logger.info("Exported model copied to %s", dest)
|
|
190
|
+
except Exception as exc:
|
|
191
|
+
return ExportResult(
|
|
192
|
+
success=False,
|
|
193
|
+
config=config,
|
|
194
|
+
error=f"Failed to copy exported model to output dir: {exc}",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# --- Compute model size ---
|
|
198
|
+
if dest.is_dir():
|
|
199
|
+
model_size = sum(f.stat().st_size for f in dest.rglob("*"))
|
|
200
|
+
else:
|
|
201
|
+
model_size = dest.stat().st_size
|
|
202
|
+
model_size_mb = model_size / (1024 * 1024)
|
|
203
|
+
|
|
204
|
+
# --- Determine input/output shapes ---
|
|
205
|
+
# These come from the ONNX metadata if available, otherwise inferred
|
|
206
|
+
input_shape = [1, 3, config.imgsz, config.imgsz]
|
|
207
|
+
output_shape: List[int] = []
|
|
208
|
+
|
|
209
|
+
if config.target == ExportTarget.ONNX:
|
|
210
|
+
try:
|
|
211
|
+
import onnxruntime as ort
|
|
212
|
+
|
|
213
|
+
sess = ort.InferenceSession(
|
|
214
|
+
str(dest), providers=["CPUExecutionProvider"]
|
|
215
|
+
)
|
|
216
|
+
input_shape = list(sess.get_inputs()[0].shape)
|
|
217
|
+
output_shape = list(sess.get_outputs()[0].shape)
|
|
218
|
+
except Exception:
|
|
219
|
+
# Not critical — shapes are informational
|
|
220
|
+
pass
|
|
221
|
+
|
|
222
|
+
# --- Validate ---
|
|
223
|
+
validation_result = None
|
|
224
|
+
if config.validate:
|
|
225
|
+
validation_result = validate_export(config, str(dest))
|
|
226
|
+
|
|
227
|
+
if not validation_result.passed:
|
|
228
|
+
logger.warning(
|
|
229
|
+
"Export validation failed. The exported model may "
|
|
230
|
+
"produce different results than the original. "
|
|
231
|
+
"Details: %s",
|
|
232
|
+
validation_result.details,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# --- Write metadata ---
|
|
236
|
+
# --- Write metadata ---
|
|
237
|
+
self._write_export_metadata(dest, config, class_names, num_classes)
|
|
238
|
+
|
|
239
|
+
return ExportResult(
|
|
240
|
+
success=True,
|
|
241
|
+
exported_path=str(dest),
|
|
242
|
+
config=config,
|
|
243
|
+
model_size_mb=round(model_size_mb, 2),
|
|
244
|
+
num_classes=num_classes,
|
|
245
|
+
class_names=class_names,
|
|
246
|
+
input_shape=input_shape,
|
|
247
|
+
output_shape=output_shape,
|
|
248
|
+
validation=validation_result,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def _write_export_metadata(
|
|
252
|
+
self,
|
|
253
|
+
dest: Path,
|
|
254
|
+
config: ExportConfig,
|
|
255
|
+
class_names: Dict[int, str],
|
|
256
|
+
num_classes: int,
|
|
257
|
+
) -> None:
|
|
258
|
+
"""
|
|
259
|
+
Write a JSON metadata file alongside the exported model.
|
|
260
|
+
|
|
261
|
+
The metadata file contains everything the edge runtime needs to
|
|
262
|
+
configure itself: image size, number of classes, class names, and the
|
|
263
|
+
export target. The bundle builder reads this file; the edge runtime
|
|
264
|
+
reads it at startup.
|
|
265
|
+
"""
|
|
266
|
+
metadata = {
|
|
267
|
+
"seavision_export_version": 1,
|
|
268
|
+
"source_weights": config.weights_path,
|
|
269
|
+
"target": config.target.name,
|
|
270
|
+
"imgsz": config.imgsz,
|
|
271
|
+
"half": config.half,
|
|
272
|
+
"int8": config.int8,
|
|
273
|
+
"num_classes": num_classes,
|
|
274
|
+
"class_names": class_names,
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
# Place metadata next to the model
|
|
278
|
+
if dest.is_dir():
|
|
279
|
+
meta_path = dest / "export_metadata.json"
|
|
280
|
+
else:
|
|
281
|
+
meta_path = dest.with_suffix(".json")
|
|
282
|
+
|
|
283
|
+
with open(meta_path, "w") as f:
|
|
284
|
+
json.dump(metadata, f, indent=2)
|
|
285
|
+
|
|
286
|
+
logger.info("Export metadata written to %s", meta_path)
|