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
seavision/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """SeaVision - Human-in-the-loop Computer Vision for Marine Environments."""
2
+
3
+ __version__ = "0.1.1"
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"]
@@ -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
@@ -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)