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,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)