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
seavision/__init__.py
ADDED
seavision/defaults.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Helpers for packaged default configuration files."""
|
|
2
|
+
|
|
3
|
+
from importlib.resources import files
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
_DEFAULT_PIPELINE_CONFIG = files("seavision").joinpath("resources/default.yaml")
|
|
7
|
+
|
|
8
|
+
def default_pipeline_config_text() -> str:
|
|
9
|
+
"""Return the packaged default pipeline configuration as text."""
|
|
10
|
+
return _DEFAULT_PIPELINE_CONFIG.read_text(encoding="utf-8")
|
|
11
|
+
|
|
12
|
+
def write_default_pipeline_config(
|
|
13
|
+
destination: str | Path,
|
|
14
|
+
overwrite: bool = False,
|
|
15
|
+
) -> Path:
|
|
16
|
+
"""
|
|
17
|
+
Write the packaged default pipeline configuration to a user-selected path.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
destination: Output path for the YAML file.
|
|
21
|
+
overwrite: If True, replace an existing file.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
The resolved output path.
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
FileExistsError: If the file already exists and overwrite is False.
|
|
28
|
+
"""
|
|
29
|
+
output_path = Path(destination)
|
|
30
|
+
|
|
31
|
+
if output_path.exists() and not overwrite:
|
|
32
|
+
raise FileExistsError(
|
|
33
|
+
f"Refusing to overwrite existing file: {output_path}"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
output_path.write_text(default_pipeline_config_text(), encoding="utf-8")
|
|
38
|
+
return output_path.resolve()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SeaVision edge inference runtime.
|
|
3
|
+
|
|
4
|
+
Lightweight inference package for edge devices (Raspberry Pi, Jetson,
|
|
5
|
+
etc.). Runs ONNX models with minimal dependencies — no PyTorch, no
|
|
6
|
+
Ultralytics.
|
|
7
|
+
|
|
8
|
+
Public API:
|
|
9
|
+
- EdgeConfig: Runtime configuration (always importable).
|
|
10
|
+
- EdgeRuntime: Main inference loop (requires onnxruntime at access time).
|
|
11
|
+
- FrameCapture: Video/camera input (requires opencv).
|
|
12
|
+
- EdgeDetectionWriter: CSV output (no special dependencies).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
from .config import EdgeConfig
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
# Only imported during type checking, not at runtime.
|
|
22
|
+
# Keeps onnxruntime optional on the land-side tooling.
|
|
23
|
+
from .runtime import EdgeRuntime
|
|
24
|
+
|
|
25
|
+
# EdgeRuntime is imported lazily because it depends on onnxruntime,
|
|
26
|
+
# which is only installed on the edge device. This allows the land-side
|
|
27
|
+
# export tooling to import seavision.edge (for EdgeConfig and
|
|
28
|
+
# artifact builders) without requiring onnxruntime.
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def __getattr__(name: str):
|
|
32
|
+
if name == "EdgeRuntime":
|
|
33
|
+
from .runtime import EdgeRuntime
|
|
34
|
+
return EdgeRuntime
|
|
35
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
__all__ = ["EdgeConfig", "EdgeRuntime"]
|
seavision/edge/bundle.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Artifact builder for wheel-first edge deployment.
|
|
2
|
+
|
|
3
|
+
Creates an artifact directory containing the exported model, runtime config,
|
|
4
|
+
export metadata, and a manifest with compatibility and integrity information.
|
|
5
|
+
The SeaVision wheel is installed separately on the edge device.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import shutil
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from seavision import __version__
|
|
16
|
+
|
|
17
|
+
from .config import (
|
|
18
|
+
ARTIFACT_MANIFEST_FILENAME,
|
|
19
|
+
ArtifactFile,
|
|
20
|
+
ArtifactManifest,
|
|
21
|
+
EdgeConfig,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ArtifactBuilder:
|
|
28
|
+
"""
|
|
29
|
+
Package an exported model into a deployable edge artifact directory.
|
|
30
|
+
|
|
31
|
+
The resulting directory is copied to the edge device separately from the
|
|
32
|
+
SeaVision wheel. The runtime then loads the artifact via
|
|
33
|
+
``seavision-edge --artifact-dir <path>``.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
exported_model_path: str,
|
|
39
|
+
export_metadata_path: str,
|
|
40
|
+
output_dir: str = "./artifact",
|
|
41
|
+
edge_config: Optional[EdgeConfig] = None,
|
|
42
|
+
):
|
|
43
|
+
"""
|
|
44
|
+
Initialise the builder.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
exported_model_path: Path to the exported model file (e.g.
|
|
48
|
+
model.onnx).
|
|
49
|
+
export_metadata_path: Path to the export_metadata.json file
|
|
50
|
+
written by ModelExporter.
|
|
51
|
+
output_dir: Directory to create the artifact in.
|
|
52
|
+
edge_config: Optional pre-configured EdgeConfig. If None,
|
|
53
|
+
defaults are populated from the export metadata.
|
|
54
|
+
"""
|
|
55
|
+
self.exported_model_path = Path(exported_model_path)
|
|
56
|
+
self.export_metadata_path = Path(export_metadata_path)
|
|
57
|
+
self.output_dir = Path(output_dir)
|
|
58
|
+
self.edge_config = edge_config
|
|
59
|
+
|
|
60
|
+
def build(self) -> str:
|
|
61
|
+
"""
|
|
62
|
+
Build the deployment artifact.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Path to the artifact directory.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
FileNotFoundError: If the model or metadata file is missing.
|
|
69
|
+
"""
|
|
70
|
+
if not self.exported_model_path.exists():
|
|
71
|
+
raise FileNotFoundError(
|
|
72
|
+
f"Exported model not found: {self.exported_model_path}"
|
|
73
|
+
)
|
|
74
|
+
if not self.export_metadata_path.exists():
|
|
75
|
+
raise FileNotFoundError(
|
|
76
|
+
f"Export metadata not found: {self.export_metadata_path}"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Load export metadata
|
|
80
|
+
with open(self.export_metadata_path, "r", encoding="utf-8") as f:
|
|
81
|
+
metadata = json.load(f)
|
|
82
|
+
|
|
83
|
+
artifact_dir = self.output_dir
|
|
84
|
+
artifact_dir.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
|
|
86
|
+
# --- 1. Copy model ---
|
|
87
|
+
model_dest = artifact_dir / self.exported_model_path.name
|
|
88
|
+
if self.exported_model_path.is_dir():
|
|
89
|
+
if model_dest.exists():
|
|
90
|
+
shutil.rmtree(model_dest)
|
|
91
|
+
shutil.copytree(self.exported_model_path, model_dest)
|
|
92
|
+
else:
|
|
93
|
+
shutil.copy2(self.exported_model_path, model_dest)
|
|
94
|
+
logger.info("Copied model to artifact: %s", model_dest.name)
|
|
95
|
+
|
|
96
|
+
# --- 2. Copy metadata ---
|
|
97
|
+
shutil.copy2(
|
|
98
|
+
self.export_metadata_path, artifact_dir / "export_metadata.json"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# --- 3. Build and write EdgeConfig ---
|
|
102
|
+
config = self._build_edge_config(metadata, model_dest.name)
|
|
103
|
+
config_path = artifact_dir / "config.json"
|
|
104
|
+
config.to_file(str(config_path))
|
|
105
|
+
|
|
106
|
+
# --- 4. Write artifact manifest ---
|
|
107
|
+
manifest = self._build_manifest(
|
|
108
|
+
artifact_version=__version__,
|
|
109
|
+
model_path=model_dest,
|
|
110
|
+
config_path=config_path,
|
|
111
|
+
metadata_path=artifact_dir / "export_metadata.json",
|
|
112
|
+
)
|
|
113
|
+
manifest.to_file(artifact_dir / ARTIFACT_MANIFEST_FILENAME)
|
|
114
|
+
|
|
115
|
+
logger.info("Artifact created: %s", artifact_dir)
|
|
116
|
+
return str(artifact_dir)
|
|
117
|
+
|
|
118
|
+
def _build_edge_config(
|
|
119
|
+
self,
|
|
120
|
+
metadata: dict,
|
|
121
|
+
model_filename: str,
|
|
122
|
+
) -> EdgeConfig:
|
|
123
|
+
"""
|
|
124
|
+
Build an EdgeConfig from export metadata, overriding with any
|
|
125
|
+
user-provided config values.
|
|
126
|
+
"""
|
|
127
|
+
# Start from user config or defaults
|
|
128
|
+
config = self.edge_config or EdgeConfig()
|
|
129
|
+
|
|
130
|
+
# Override with export metadata (these must match the export)
|
|
131
|
+
config.model_path = model_filename
|
|
132
|
+
config.imgsz = metadata.get("imgsz", config.imgsz)
|
|
133
|
+
config.num_classes = metadata.get("num_classes", config.num_classes)
|
|
134
|
+
|
|
135
|
+
class_names_raw = metadata.get("class_names", {})
|
|
136
|
+
config.class_names = {int(k): v for k, v in class_names_raw.items()}
|
|
137
|
+
|
|
138
|
+
return config
|
|
139
|
+
|
|
140
|
+
def _build_manifest(
|
|
141
|
+
self,
|
|
142
|
+
artifact_version: str,
|
|
143
|
+
model_path: Path,
|
|
144
|
+
config_path: Path,
|
|
145
|
+
metadata_path: Path,
|
|
146
|
+
) -> ArtifactManifest:
|
|
147
|
+
"""Build the artifact manifest for runtime loading and validation."""
|
|
148
|
+
return ArtifactManifest(
|
|
149
|
+
artifact_version=artifact_version,
|
|
150
|
+
runtime_version_range=self._default_runtime_version_range(),
|
|
151
|
+
model=ArtifactFile(
|
|
152
|
+
path=model_path.name,
|
|
153
|
+
sha256=self._sha256_for_path(model_path),
|
|
154
|
+
),
|
|
155
|
+
runtime_config=ArtifactFile(
|
|
156
|
+
path=config_path.name,
|
|
157
|
+
sha256=self._sha256_for_path(config_path),
|
|
158
|
+
),
|
|
159
|
+
export_metadata=ArtifactFile(
|
|
160
|
+
path=metadata_path.name,
|
|
161
|
+
sha256=self._sha256_for_path(metadata_path),
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
@staticmethod
|
|
166
|
+
def _default_runtime_version_range() -> str:
|
|
167
|
+
import re
|
|
168
|
+
|
|
169
|
+
match = re.match(r"^(\d+)\.(\d+)\.(\d+)", __version__)
|
|
170
|
+
if not match:
|
|
171
|
+
raise ValueError(f"Unsupported SeaVision version format: {__version__}")
|
|
172
|
+
|
|
173
|
+
major = int(match.group(1))
|
|
174
|
+
minor = int(match.group(2))
|
|
175
|
+
return f">={__version__},<{major}.{minor + 1}.0"
|
|
176
|
+
|
|
177
|
+
@staticmethod
|
|
178
|
+
def _sha256_for_path(path: Path) -> str:
|
|
179
|
+
if path.is_dir():
|
|
180
|
+
return ""
|
|
181
|
+
|
|
182
|
+
digest = hashlib.sha256()
|
|
183
|
+
with open(path, "rb") as file_obj:
|
|
184
|
+
for chunk in iter(lambda: file_obj.read(8192), b""):
|
|
185
|
+
digest.update(chunk)
|
|
186
|
+
return digest.hexdigest()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
BundleBuilder = ArtifactBuilder
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Frame capture for the edge runtime.
|
|
3
|
+
|
|
4
|
+
Provides a simple iterator over frames from a camera device or video
|
|
5
|
+
file. This is the edge equivalent of SeaVision's VideoSource, stripped
|
|
6
|
+
to the minimum needed for continuous inference.
|
|
7
|
+
|
|
8
|
+
Dependencies: opencv-python-headless only.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Iterator, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
import cv2
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CaptureError(Exception):
|
|
21
|
+
"""Raised when the video source cannot be opened."""
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FrameCapture:
|
|
26
|
+
"""
|
|
27
|
+
Reads frames from a camera or video file via OpenCV.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
source: The source string or device index passed at construction.
|
|
31
|
+
fps: Frames per second of the source (0 if unknown/camera).
|
|
32
|
+
width: Frame width in pixels.
|
|
33
|
+
height: Frame height in pixels.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, source: str):
|
|
37
|
+
"""
|
|
38
|
+
Open a video source.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
source: Either a string path to a video file, an RTSP URL, or a
|
|
42
|
+
string-encoded integer for a cmaera device index
|
|
43
|
+
(e.g. "0" for /dev/video0).
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
CaptureError: If the source cannot be opened.
|
|
47
|
+
"""
|
|
48
|
+
self.source = source
|
|
49
|
+
|
|
50
|
+
# Try to interpret as camera index
|
|
51
|
+
try:
|
|
52
|
+
device_index = int(source)
|
|
53
|
+
self._cap = cv2.VideoCapture(device_index)
|
|
54
|
+
except ValueError:
|
|
55
|
+
# If it is a filepath or URL.
|
|
56
|
+
self._cap = cv2.VideoCapture(source)
|
|
57
|
+
|
|
58
|
+
if not self._cap.isOpened():
|
|
59
|
+
raise CaptureError(f"Could not open video source: {source}")
|
|
60
|
+
|
|
61
|
+
self.fps = self._cap.get(cv2.CAP_PROP_FPS) or 0.0
|
|
62
|
+
self.width = int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
|
63
|
+
self.height = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
64
|
+
|
|
65
|
+
logger.info(
|
|
66
|
+
"Opened source: %s (%dx%d @ %.1f fps)",
|
|
67
|
+
source, self.width, self.height, self.fps,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def iter_frames(
|
|
71
|
+
self,
|
|
72
|
+
frame_skip: int = 1,
|
|
73
|
+
) -> Iterator[Tuple[np.ndarray, int]]:
|
|
74
|
+
"""
|
|
75
|
+
Yield (frame, frame_number) tuples.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
frame_skip: Process every Nth frame (1 = every frame)
|
|
79
|
+
|
|
80
|
+
Yields:
|
|
81
|
+
Tuple of (BGR numpy array, frame number).
|
|
82
|
+
"""
|
|
83
|
+
frame_number = 0
|
|
84
|
+
|
|
85
|
+
while True:
|
|
86
|
+
ret, frame = self._cap.read()
|
|
87
|
+
if not ret:
|
|
88
|
+
logger.info("End of source reached at frame %d", frame_number)
|
|
89
|
+
break
|
|
90
|
+
|
|
91
|
+
if frame_number % frame_skip == 0:
|
|
92
|
+
yield frame, frame_number
|
|
93
|
+
|
|
94
|
+
frame_number += 1
|
|
95
|
+
|
|
96
|
+
def release(self) -> None:
|
|
97
|
+
"""Release the underlying VideoCapture."""
|
|
98
|
+
if self._cap is not None:
|
|
99
|
+
self._cap.release()
|
|
100
|
+
logger.info("Capture released: %s", self.source)
|
|
101
|
+
|
|
102
|
+
def __enter__(self):
|
|
103
|
+
return self
|
|
104
|
+
|
|
105
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
106
|
+
self.release()
|
|
107
|
+
return False
|
seavision/edge/config.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration for the SeaVision edge inference runtime.
|
|
3
|
+
|
|
4
|
+
This module has no dependencies on PyTorch or Ultralytics. It must be importable
|
|
5
|
+
in the minimal edge enviroment: Python + numpy + OpenCV + PNNX Runtime.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import dataclasses
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Dict
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
ARTIFACT_MANIFEST_FILENAME = "manifest.json"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ArtifactFile:
|
|
22
|
+
"""File entry recorded in an edge artifact manifest."""
|
|
23
|
+
|
|
24
|
+
path: str
|
|
25
|
+
sha256: str = ""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ArtifactManifest:
|
|
30
|
+
"""Manifest describing a wheel-first edge artifact directory."""
|
|
31
|
+
|
|
32
|
+
schema_version: int = 1
|
|
33
|
+
artifact_version: str = "0.1.0"
|
|
34
|
+
runtime_version_range: str = ">=0.1.0,<0.2.0"
|
|
35
|
+
model: ArtifactFile = field(default_factory=lambda: ArtifactFile("model.onnx"))
|
|
36
|
+
runtime_config: ArtifactFile = field(
|
|
37
|
+
default_factory=lambda: ArtifactFile("config.json")
|
|
38
|
+
)
|
|
39
|
+
export_metadata: ArtifactFile = field(
|
|
40
|
+
default_factory=lambda: ArtifactFile("export_metadata.json")
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_file(cls, path: str | Path) -> "ArtifactManifest":
|
|
45
|
+
"""Load an artifact manifest from disk."""
|
|
46
|
+
manifest_path = Path(path)
|
|
47
|
+
if not manifest_path.exists():
|
|
48
|
+
raise FileNotFoundError(f"Artifact manifest not found: {manifest_path}")
|
|
49
|
+
|
|
50
|
+
with open(manifest_path, "r", encoding="utf-8") as file_obj:
|
|
51
|
+
data = json.load(file_obj)
|
|
52
|
+
|
|
53
|
+
return cls(
|
|
54
|
+
schema_version=data.get("schema_version", 1),
|
|
55
|
+
artifact_version=data.get("artifact_version", "0.1.0"),
|
|
56
|
+
runtime_version_range=data.get(
|
|
57
|
+
"runtime_version_range", ">=0.1.0,<0.2.0"
|
|
58
|
+
),
|
|
59
|
+
model=ArtifactFile(**data.get("model", {"path": "model.onnx"})),
|
|
60
|
+
runtime_config=ArtifactFile(
|
|
61
|
+
**data.get("runtime_config", {"path": "config.json"})
|
|
62
|
+
),
|
|
63
|
+
export_metadata=ArtifactFile(
|
|
64
|
+
**data.get(
|
|
65
|
+
"export_metadata", {"path": "export_metadata.json"}
|
|
66
|
+
)
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def to_file(self, path: str | Path) -> None:
|
|
71
|
+
"""Write the manifest to disk."""
|
|
72
|
+
with open(path, "w", encoding="utf-8") as file_obj:
|
|
73
|
+
json.dump(dataclasses.asdict(self), file_obj, indent=2)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class EdgeConfig:
|
|
78
|
+
"""
|
|
79
|
+
COnfiguration for the edge inference runtime.
|
|
80
|
+
|
|
81
|
+
This dataclass is the single source of truth for all runtime parameters. It
|
|
82
|
+
can be loaded from a JSON config file (generated by the bundle builder) or
|
|
83
|
+
constructed programmatically.
|
|
84
|
+
|
|
85
|
+
Attributes:
|
|
86
|
+
model_path: Path to the exported model file (e.g. model.onnx).
|
|
87
|
+
source: Video source — either a string-encoded integer for a
|
|
88
|
+
camera device index (e.g. "0" for /dev/video0) or a string
|
|
89
|
+
path to a video file or RTSP URL. Default "0" = default
|
|
90
|
+
camera.
|
|
91
|
+
output_dir: Directory for detection CSVs and validation clips.
|
|
92
|
+
Created if it does not exist.
|
|
93
|
+
imgsz: Input image size. Must match the export imgsz exactly.
|
|
94
|
+
Using a different size will produce incorrect or degraded
|
|
95
|
+
detections. The bundle builder sets this automatically from
|
|
96
|
+
the export metadata.
|
|
97
|
+
conf_threshold: Minimum confidence score for detections. Higher
|
|
98
|
+
values reduce false positives but may miss real detections.
|
|
99
|
+
iou_threshold: IoU threshold for non-maximum suppression.
|
|
100
|
+
num_classes: Number of detection classes in the model. Set
|
|
101
|
+
automatically from export metadata.
|
|
102
|
+
class_names: Mapping of class index to human-readable name.
|
|
103
|
+
Set automatically from export metadata.
|
|
104
|
+
validation_clip_interval_s: Seconds between validation clips.
|
|
105
|
+
Set to 0 to disable validation clip recording. Default is
|
|
106
|
+
3600 (one clip per hour).
|
|
107
|
+
validation_clip_duration_s: Duration of each validation clip in
|
|
108
|
+
seconds. Default 30.
|
|
109
|
+
frame_skip: Process every Nth frame. Set to 1 to process every
|
|
110
|
+
frame (default). Higher values reduce CPU load at the cost
|
|
111
|
+
of potentially missing fast-moving objects. For continuous
|
|
112
|
+
monitoring at 25fps, frame_skip=5 (effective 5fps) is often
|
|
113
|
+
a good trade-off on Pi 4.
|
|
114
|
+
ort_threads: Number of ONNX Runtime intra-op threads. Default 0
|
|
115
|
+
means "use all available CPU cores", which matches the
|
|
116
|
+
behaviour in the m3b_pi_benchmark.
|
|
117
|
+
ort_optimization: ONNX Runtime graph optimization level.
|
|
118
|
+
Default "all" enables all optimisations (equivalent to
|
|
119
|
+
ORT_ENABLE_ALL). Use "basic" if you encounter issues.
|
|
120
|
+
csv_flush_interval: Write buffered detections to disk every N
|
|
121
|
+
frames. Lower values are safer (less data loss on crash) but
|
|
122
|
+
cause more disk I/O. Default 100.
|
|
123
|
+
log_level: Python logging level string. Default "INFO".
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
model_path: str = "model.onnx"
|
|
127
|
+
artifact_dir: str = ""
|
|
128
|
+
source: str = "0"
|
|
129
|
+
output_dir: str = "./detections"
|
|
130
|
+
|
|
131
|
+
imgsz: int = 640
|
|
132
|
+
conf_threshold: float = 0.25
|
|
133
|
+
iou_threshold: float = 0.45
|
|
134
|
+
num_classes: int = 80
|
|
135
|
+
class_names: Dict[int, str] = field(default_factory=dict)
|
|
136
|
+
|
|
137
|
+
validation_clip_interval_s: int = 3600
|
|
138
|
+
validation_clip_duration_s: int = 30
|
|
139
|
+
frame_skip: int = 1
|
|
140
|
+
|
|
141
|
+
ort_threads: int = 0
|
|
142
|
+
ort_optimization: str = "all"
|
|
143
|
+
|
|
144
|
+
csv_flush_interval: int = 100
|
|
145
|
+
log_level: str = "INFO"
|
|
146
|
+
runtime_version_range: str = ""
|
|
147
|
+
|
|
148
|
+
@classmethod
|
|
149
|
+
def from_file(cls, path: str) -> "EdgeConfig":
|
|
150
|
+
"""
|
|
151
|
+
Load config from a JSON file.
|
|
152
|
+
|
|
153
|
+
The bundle builder generates this file automatically. Users can edit it
|
|
154
|
+
to tune runtime parameters without modifying code.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
path: Path to the JSON configuration file.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
An EdgeConfig instance populated with the values from the file.
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
FileNotFoundError: If the config file does not exist.
|
|
164
|
+
json.JSONDecodeError: If the file is not valid JSON.
|
|
165
|
+
"""
|
|
166
|
+
config_path = Path(path)
|
|
167
|
+
if not config_path.exists():
|
|
168
|
+
raise FileNotFoundError(f"Config file not found: {path}")
|
|
169
|
+
|
|
170
|
+
with open(config_path, "r") as f:
|
|
171
|
+
data = json.load(f)
|
|
172
|
+
|
|
173
|
+
# Convert class_names keys from string (JSON limitation) to int
|
|
174
|
+
if "class_names" in data and isinstance(data["class_names"], dict):
|
|
175
|
+
data["class_names"] = {
|
|
176
|
+
int(k): v for k, v in data["class_names"].items()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return cls(**{
|
|
180
|
+
k: v for k, v in data.items()
|
|
181
|
+
if k in cls.__dataclass_fields__
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
@classmethod
|
|
185
|
+
def from_artifact_dir(cls, path: str | Path) -> "EdgeConfig":
|
|
186
|
+
"""Load runtime config from a wheel-first artifact directory."""
|
|
187
|
+
artifact_dir = Path(path)
|
|
188
|
+
manifest = ArtifactManifest.from_file(
|
|
189
|
+
artifact_dir / ARTIFACT_MANIFEST_FILENAME
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
config = cls.from_file(artifact_dir / manifest.runtime_config.path)
|
|
193
|
+
|
|
194
|
+
config.artifact_dir = str(artifact_dir)
|
|
195
|
+
model_path = artifact_dir / manifest.model.path
|
|
196
|
+
config.model_path = str(model_path)
|
|
197
|
+
config.runtime_version_range = manifest.runtime_version_range
|
|
198
|
+
return config
|
|
199
|
+
|
|
200
|
+
def to_file(self, path: str) -> None:
|
|
201
|
+
"""
|
|
202
|
+
Save configuration to a JSON file.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
path: Path to write the configuration file.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
data = dataclasses.asdict(self)
|
|
209
|
+
|
|
210
|
+
# Convert class_names keys to strings for JSON compatibility
|
|
211
|
+
if "class_names" in data:
|
|
212
|
+
data["class_names"] = {
|
|
213
|
+
str(k): v for k, v in data["class_names"].items()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
217
|
+
json.dump(data, f, indent=2)
|
|
218
|
+
|
|
219
|
+
logger.info("Edge config saved to %s", path)
|