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,262 @@
|
|
|
1
|
+
"""Random access video source for the GUI viewer."""
|
|
2
|
+
|
|
3
|
+
import cv2
|
|
4
|
+
import numpy as np
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
from seavision.engine.source.base import FrameContext, VideoMetadata
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
# Container formats where cv2.VideoCapture seeking is unreliable.
|
|
13
|
+
# These use byte-offset estimation rather than a frame index table,
|
|
14
|
+
# causing decoder state corruption on seek-back.
|
|
15
|
+
_PRELOAD_EXTENSIONS = frozenset({
|
|
16
|
+
".ts", ".mts", ".m2ts", # MPEG transport streams
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _needs_preload(filepath: str) -> bool:
|
|
21
|
+
"""Check whether a file's container format requires pre-loading."""
|
|
22
|
+
return Path(filepath).suffix.lower() in _PRELOAD_EXTENSIONS
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _estimate_memory_mb(width: int, height: int, frame_count: int) -> float:
|
|
26
|
+
"""Estimate memory usage for pre-loading all frames, in MB."""
|
|
27
|
+
bytes_per_frame = width * height * 3 # BGR uint8
|
|
28
|
+
return (bytes_per_frame * frame_count) / (1024 * 1024)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _available_memory_mb() -> float | None:
|
|
32
|
+
"""
|
|
33
|
+
Return available system RAM in MB, or None if it can't be determined.
|
|
34
|
+
|
|
35
|
+
Uses psutil if available. This is an optional dependency — the GUI
|
|
36
|
+
works without it, the dialog just won't show available RAM.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
import psutil
|
|
40
|
+
return psutil.virtual_memory().available / (1024 * 1024)
|
|
41
|
+
except ImportError:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SeekableVideoSource:
|
|
46
|
+
"""
|
|
47
|
+
Random-access wrapper around cv2.VideoCapture.
|
|
48
|
+
|
|
49
|
+
Unlike LocalVideoSource (which iterates sequentially), this class supports
|
|
50
|
+
seeking to arbitrary frame numbers. Designed for the GUI viewer where the
|
|
51
|
+
user may jump around in the video timeline.
|
|
52
|
+
|
|
53
|
+
Compatible only with local video files, not live network streams.
|
|
54
|
+
|
|
55
|
+
Supports two modes:
|
|
56
|
+
1. preload=True: all frames decoded sequentially into memory on
|
|
57
|
+
construction. Every access is an 0(1) array lookup. Frame-accurate
|
|
58
|
+
for all container formats. Uses (width * height * 3 * frames)
|
|
59
|
+
bytes of RAM.
|
|
60
|
+
2. preload=False: uses cv2.VideoCapture seeking directly. Fast and
|
|
61
|
+
memory-efficient, but seeking accuracy depends on the container
|
|
62
|
+
format.
|
|
63
|
+
|
|
64
|
+
Usage:
|
|
65
|
+
with SeekableVideoSource("video.ts") as src:
|
|
66
|
+
frame, ctx = src.read_at(47)
|
|
67
|
+
frame, ctx = src.read_at(183)
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, filepath: str, preload: bool = False) -> None:
|
|
71
|
+
self._filepath = filepath
|
|
72
|
+
self._preload = preload
|
|
73
|
+
|
|
74
|
+
if not Path(self._filepath).exists():
|
|
75
|
+
raise ValueError(f"Video file not found: {self._filepath}")
|
|
76
|
+
|
|
77
|
+
self._metadata = self._read_metadata(filepath)
|
|
78
|
+
self._frame_counter = 0
|
|
79
|
+
self._frames: list[np.ndarray] = []
|
|
80
|
+
self._cap: cv2.VideoCapture | None = None
|
|
81
|
+
|
|
82
|
+
if preload:
|
|
83
|
+
self._frames = self._preload_frames(filepath)
|
|
84
|
+
# Correct frame count to match actual decoded frames
|
|
85
|
+
if len(self._frames) != self._metadata.frame_count:
|
|
86
|
+
self._metadata = VideoMetadata(
|
|
87
|
+
source_file=self._metadata.source_file,
|
|
88
|
+
fps=self._metadata.fps,
|
|
89
|
+
frame_count=len(self._frames),
|
|
90
|
+
width=self._metadata.width,
|
|
91
|
+
height=self._metadata.height,
|
|
92
|
+
duration=len(self._frames) / self._metadata.fps
|
|
93
|
+
if self._metadata.fps > 0 else 0.0,
|
|
94
|
+
)
|
|
95
|
+
else:
|
|
96
|
+
self._cap = cv2.VideoCapture(self._filepath)
|
|
97
|
+
if not self._cap.isOpened():
|
|
98
|
+
raise ValueError(f"Failed to open video: {self._filepath}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _read_metadata(filepath:str) -> VideoMetadata:
|
|
103
|
+
"""Read video metadata without disturbing the main decode."""
|
|
104
|
+
cap = cv2.VideoCapture(filepath)
|
|
105
|
+
if not cap.isOpened():
|
|
106
|
+
raise ValueError(f"Failed to open video file: {filepath}")
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
fps = cap.get(cv2.CAP_PROP_FPS)
|
|
110
|
+
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
111
|
+
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
|
112
|
+
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
113
|
+
duration = frame_count / fps if fps > 0 else 0.0
|
|
114
|
+
|
|
115
|
+
return VideoMetadata(
|
|
116
|
+
source_file=filepath,
|
|
117
|
+
fps=fps,
|
|
118
|
+
frame_count=frame_count,
|
|
119
|
+
width=width,
|
|
120
|
+
height=height,
|
|
121
|
+
duration=duration,
|
|
122
|
+
)
|
|
123
|
+
finally:
|
|
124
|
+
cap.release()
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
def _preload_frames(filepath: str) -> list[np.ndarray]:
|
|
128
|
+
"""
|
|
129
|
+
Sequentially read every frame into memory.
|
|
130
|
+
|
|
131
|
+
Uses a fresh capture with no prior seeking — the exact same
|
|
132
|
+
decode path as LocalVideoSource.iter_frames(). This guarantees
|
|
133
|
+
frame numbering matches the pipeline that generated detections.
|
|
134
|
+
"""
|
|
135
|
+
cap = cv2.VideoCapture(filepath)
|
|
136
|
+
if not cap.isOpened():
|
|
137
|
+
raise ValueError(f"Failed to open video file: {filepath}")
|
|
138
|
+
|
|
139
|
+
frames: list[np.ndarray] = []
|
|
140
|
+
try:
|
|
141
|
+
while True:
|
|
142
|
+
ret, frame = cap.read()
|
|
143
|
+
if not ret:
|
|
144
|
+
break
|
|
145
|
+
frames.append(frame)
|
|
146
|
+
finally:
|
|
147
|
+
cap.release()
|
|
148
|
+
|
|
149
|
+
size_mb = sum(f.nbytes for f in frames) / (1024 * 1024)
|
|
150
|
+
logger.info(
|
|
151
|
+
"Pre-loaded %d frames from %s (%.1f MB)",
|
|
152
|
+
len(frames),
|
|
153
|
+
Path(filepath).name,
|
|
154
|
+
size_mb,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
if not frames:
|
|
158
|
+
raise ValueError(f"No frames could be read from: {filepath}")
|
|
159
|
+
|
|
160
|
+
return frames
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def metadata(self) -> VideoMetadata:
|
|
164
|
+
"""Cached video metadata."""
|
|
165
|
+
return self._metadata
|
|
166
|
+
|
|
167
|
+
def _make_context(self, frame_number: int) -> FrameContext:
|
|
168
|
+
"""Build a FrameContext for the given frame number."""
|
|
169
|
+
fps = self._metadata.fps
|
|
170
|
+
return FrameContext(
|
|
171
|
+
frame_number=frame_number,
|
|
172
|
+
timestamp=frame_number / fps if fps > 0 else 0.0,
|
|
173
|
+
source_file=self._filepath,
|
|
174
|
+
fps=fps,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def _clamp(self, frame_number: int) -> int:
|
|
178
|
+
"""Clamp a frame number to the valid range."""
|
|
179
|
+
if self._preload:
|
|
180
|
+
max_frame = len(self._frames) - 1
|
|
181
|
+
else:
|
|
182
|
+
max_frame = self._metadata.frame_count - 1
|
|
183
|
+
return max(0, min(frame_number, max_frame))
|
|
184
|
+
|
|
185
|
+
def seek(self, frame_number: int) -> None:
|
|
186
|
+
"""
|
|
187
|
+
Seek to a specific frame number.
|
|
188
|
+
|
|
189
|
+
The frame number is clamped to the valid range. This prevents invalid
|
|
190
|
+
frames being read causing hidden failures.
|
|
191
|
+
|
|
192
|
+
In preload mode, the internal counter is updated. Otherwise, the
|
|
193
|
+
underlying capture is seeked using CAP_PROP_POS_FRAMES.
|
|
194
|
+
"""
|
|
195
|
+
clamped = self._clamp(frame_number)
|
|
196
|
+
|
|
197
|
+
if self._preload:
|
|
198
|
+
self._frame_counter = clamped
|
|
199
|
+
else:
|
|
200
|
+
if self._cap is None:
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
self._cap.set(cv2.CAP_PROP_POS_FRAMES, clamped)
|
|
204
|
+
self._frame_counter = clamped
|
|
205
|
+
|
|
206
|
+
def read(self) -> tuple[np.ndarray, FrameContext] | None:
|
|
207
|
+
"""
|
|
208
|
+
Read the current frame and advance the position.
|
|
209
|
+
|
|
210
|
+
returns:
|
|
211
|
+
Tuple of (frame, context) or None if at the end of the video or the
|
|
212
|
+
frame could not be decoded.
|
|
213
|
+
"""
|
|
214
|
+
if self._preload:
|
|
215
|
+
if self._frame_counter >= len(self._frames):
|
|
216
|
+
return None
|
|
217
|
+
frame = self._frames[self._frame_counter].copy()
|
|
218
|
+
else:
|
|
219
|
+
if self._cap is None:
|
|
220
|
+
return None
|
|
221
|
+
ret, frame = self._cap.read()
|
|
222
|
+
if not ret:
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
ctx = self._make_context(self._frame_counter)
|
|
226
|
+
self._frame_counter += 1
|
|
227
|
+
return frame, ctx
|
|
228
|
+
|
|
229
|
+
def read_at(self, frame_number: int) -> tuple[np.ndarray, FrameContext] | None:
|
|
230
|
+
"""
|
|
231
|
+
Seek to a frame and read it in one call.
|
|
232
|
+
"""
|
|
233
|
+
if self._preload:
|
|
234
|
+
if not self._frames:
|
|
235
|
+
return None
|
|
236
|
+
clamped = self._clamp(frame_number)
|
|
237
|
+
frame = self._frames[clamped].copy()
|
|
238
|
+
else:
|
|
239
|
+
if self._cap is None:
|
|
240
|
+
return None
|
|
241
|
+
clamped = self._clamp(frame_number)
|
|
242
|
+
self._cap.set(cv2.CAP_PROP_POS_FRAMES, clamped)
|
|
243
|
+
ret, frame = self._cap.read()
|
|
244
|
+
if not ret:
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
ctx = self._make_context(clamped)
|
|
248
|
+
self._frame_counter = clamped + 1
|
|
249
|
+
return frame, ctx
|
|
250
|
+
|
|
251
|
+
def close(self) -> None:
|
|
252
|
+
"""Release the video capture."""
|
|
253
|
+
self._frames = []
|
|
254
|
+
if self._cap is not None:
|
|
255
|
+
self._cap.release()
|
|
256
|
+
self._cap = None
|
|
257
|
+
|
|
258
|
+
def __enter__(self) -> "SeekableVideoSource":
|
|
259
|
+
return self
|
|
260
|
+
|
|
261
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
262
|
+
self.close()
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session manager for save/load/export of validation state.
|
|
3
|
+
|
|
4
|
+
This module is intentionally free of Qt dependcies. It operates on the
|
|
5
|
+
ValidationModel's data and produces/consumes files. The Qt-side integration
|
|
6
|
+
(file dialogs, status bar updates) lives in tab.py and main_window.py.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import csv
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from seavision.gui.validation.validation_model import (
|
|
17
|
+
ValidatedDetection,
|
|
18
|
+
ValidationModel,
|
|
19
|
+
ValidationStatus,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _serialise_manual_detection(
|
|
26
|
+
vd: ValidatedDetection,
|
|
27
|
+
) -> dict[str, Any]:
|
|
28
|
+
"""
|
|
29
|
+
Serialise a manually added detection to a JSON-friendly dict.
|
|
30
|
+
|
|
31
|
+
Manual detections need their full geometry and label stored because they
|
|
32
|
+
don't exist in the CSV. Pipeline detections only need their status stored
|
|
33
|
+
because the CSV provides the rest.
|
|
34
|
+
"""
|
|
35
|
+
det = vd.detection
|
|
36
|
+
return {
|
|
37
|
+
"source_file": det.source_file,
|
|
38
|
+
"frame_number": det.frame_number,
|
|
39
|
+
"timestamp": det.timestamp,
|
|
40
|
+
"xc": det.xc,
|
|
41
|
+
"yc": det.yc,
|
|
42
|
+
"width": det.width,
|
|
43
|
+
"height": det.height,
|
|
44
|
+
"label": det.label or "",
|
|
45
|
+
"status": vd.status.name,
|
|
46
|
+
"corrected_geometry": vd.corrected_geometry,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SessionManager:
|
|
51
|
+
"""
|
|
52
|
+
Handles save, load, and export operations for validation sessions.
|
|
53
|
+
|
|
54
|
+
All methods are stateless class-level operations - the manager does not hold
|
|
55
|
+
a reference to the model or remember previous save paths. State tracking
|
|
56
|
+
(current save path, unsaved changes flag) lives on the ValidationTab.
|
|
57
|
+
|
|
58
|
+
File format:
|
|
59
|
+
Session files use the .seavision-session extension and contain JSON with
|
|
60
|
+
this structure:
|
|
61
|
+
{
|
|
62
|
+
"version": 1,
|
|
63
|
+
"csv_path": "/path/to/detections.csv",
|
|
64
|
+
"video_dir": "/path/to/videos/",
|
|
65
|
+
"timestamp": "2025-06-15T14:30:00Z",
|
|
66
|
+
"decisions": {
|
|
67
|
+
"video.ts::47::0": {
|
|
68
|
+
"status": "CONFIRMED",
|
|
69
|
+
"corrected_geometry": null,
|
|
70
|
+
"is_manual": false
|
|
71
|
+
},
|
|
72
|
+
...
|
|
73
|
+
},
|
|
74
|
+
"manual_detections": [
|
|
75
|
+
{
|
|
76
|
+
"source_file": "video.ts",
|
|
77
|
+
"frame_number": 52,
|
|
78
|
+
"xc": 320.0, "yc": 240.0,
|
|
79
|
+
"width": 60.0, "height": 60.0,
|
|
80
|
+
"label": "seal",
|
|
81
|
+
"status": "CONFIRMED"
|
|
82
|
+
},
|
|
83
|
+
...
|
|
84
|
+
]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
Decision keys use the format "{source_file}::{frame_number}::{index}" where
|
|
88
|
+
index is the detection's position within its frame. This is deterministic
|
|
89
|
+
for a given CSV because CSVDetectionLoader loads in file order.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def save_session(
|
|
94
|
+
path: Path | str,
|
|
95
|
+
model: ValidationModel,
|
|
96
|
+
csv_path: str,
|
|
97
|
+
video_dir: str,
|
|
98
|
+
aws_profile: str | None,
|
|
99
|
+
cache_dir: str | None = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""
|
|
102
|
+
Save the current validation state to a .seavision-session JSON file.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
path: Destination file path for the session file. Ends with
|
|
106
|
+
.seavision-session.
|
|
107
|
+
model: The ValidationModel containing all detection states.
|
|
108
|
+
csv_path: Path to the detection CSV (stored for reload).
|
|
109
|
+
video_dir: Path to the video directory (stored for reload).
|
|
110
|
+
aws_profile: The AWS profile name to use for S3 operations.
|
|
111
|
+
cache_dir: Path to the cache directory (stored for reload).
|
|
112
|
+
"""
|
|
113
|
+
path = Path(path)
|
|
114
|
+
|
|
115
|
+
decisions: dict[str, dict[str, Any]] = {}
|
|
116
|
+
manual_detections: list[dict[str, Any]] = []
|
|
117
|
+
|
|
118
|
+
for source_file in model.get_all_source_files():
|
|
119
|
+
detections = model.get_detections_for_video(source_file)
|
|
120
|
+
frame_counters: dict[int, int] = {}
|
|
121
|
+
|
|
122
|
+
for vd in detections:
|
|
123
|
+
if vd.is_manual:
|
|
124
|
+
manual_detections.append(
|
|
125
|
+
_serialise_manual_detection(vd),
|
|
126
|
+
)
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
frame_num = vd.detection.frame_number
|
|
130
|
+
idx = frame_counters.get(frame_num, 0)
|
|
131
|
+
frame_counters[frame_num] = idx + 1
|
|
132
|
+
|
|
133
|
+
# Skip saving PENDING detectons - these can be read from CSV
|
|
134
|
+
# on load
|
|
135
|
+
if vd.status == ValidationStatus.PENDING:
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
key = f"{source_file}::{frame_num}::{idx}"
|
|
139
|
+
decisions[key] = {
|
|
140
|
+
"status": vd.status.name,
|
|
141
|
+
"corrected_geometry": vd.corrected_geometry,
|
|
142
|
+
"is_manual": False,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
session_data = {
|
|
146
|
+
"version": 1,
|
|
147
|
+
"csv_path": str(csv_path),
|
|
148
|
+
"video_dir": str(video_dir),
|
|
149
|
+
"aws_profile": aws_profile,
|
|
150
|
+
"cache_dir": cache_dir,
|
|
151
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
152
|
+
"decisions": decisions,
|
|
153
|
+
"manual_detections": manual_detections,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
157
|
+
json.dump(session_data, f, indent=2)
|
|
158
|
+
|
|
159
|
+
logger.info("Session saved to %s (%d decisions, %d manual)",
|
|
160
|
+
path, len(decisions), len(manual_detections))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def load_session(path: Path | str) -> dict[str, Any]:
|
|
165
|
+
"""
|
|
166
|
+
Read and validate a session file.
|
|
167
|
+
|
|
168
|
+
Returns the parsed JSON as a dict. Does NOT apply the state to a model -
|
|
169
|
+
that is the caller's responsibility.
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
FileNotFoundError: If the file doesn't exist.
|
|
173
|
+
ValueError: If the file is not valid.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
path = Path(path)
|
|
177
|
+
|
|
178
|
+
# TODO: Add check that file extension is .seavision-session?
|
|
179
|
+
|
|
180
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
181
|
+
try:
|
|
182
|
+
data: dict = json.load(f)
|
|
183
|
+
except json.JSONDecodeError as e:
|
|
184
|
+
raise ValueError(
|
|
185
|
+
f"Session file is not valid JSON: {e}"
|
|
186
|
+
) from e
|
|
187
|
+
|
|
188
|
+
required = {"version", "csv_path", "video_dir", "decisions"}
|
|
189
|
+
missing = required - set(data.keys())
|
|
190
|
+
if missing:
|
|
191
|
+
raise ValueError(
|
|
192
|
+
f"Session file missing required keys: {missing}"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
if data["version"] != 1:
|
|
196
|
+
raise ValueError(
|
|
197
|
+
f"Unsupported session version: {data['version']}. "
|
|
198
|
+
f"This tool supports version 1."
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
data.setdefault("manual_detections", [])
|
|
202
|
+
data.setdefault("aws_profile", None)
|
|
203
|
+
data.setdefault("cache_dir", None)
|
|
204
|
+
|
|
205
|
+
logger.info(
|
|
206
|
+
"Loaded session from %s (%d decisions, %d manual)",
|
|
207
|
+
path, len(data["decisions"]),
|
|
208
|
+
len(data["manual_detections"]),
|
|
209
|
+
)
|
|
210
|
+
return data
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@staticmethod
|
|
214
|
+
def apply_session_state(
|
|
215
|
+
model: ValidationModel,
|
|
216
|
+
session_data: dict[str, Any],
|
|
217
|
+
) -> dict[str, str]:
|
|
218
|
+
"""
|
|
219
|
+
Apply saved decisions and manual detections to a live model.
|
|
220
|
+
|
|
221
|
+
Called after the CSV has been re-loaded and the ValidationModel
|
|
222
|
+
constructed with all detections in PENDING state.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
States dict: {"applied", "skipped", "manual_added"}
|
|
226
|
+
"""
|
|
227
|
+
stats = {"applied": 0, "skipped": 0, "manual_added": 0}
|
|
228
|
+
|
|
229
|
+
# build lookup keyed the same way as the session file
|
|
230
|
+
model_keys: dict[str, ValidatedDetection] = {}
|
|
231
|
+
for source_file in model.get_all_source_files():
|
|
232
|
+
detections = model.get_detections_for_video(source_file)
|
|
233
|
+
frame_counters: dict[int, int] = {}
|
|
234
|
+
for vd in detections:
|
|
235
|
+
if vd.is_manual:
|
|
236
|
+
continue
|
|
237
|
+
frame_num = vd.detection.frame_number
|
|
238
|
+
idx = frame_counters.get(frame_num, 0)
|
|
239
|
+
frame_counters[frame_num] = idx + 1
|
|
240
|
+
key = f"{source_file}::{frame_num}::{idx}"
|
|
241
|
+
model_keys[key] = vd
|
|
242
|
+
|
|
243
|
+
for key, decision in session_data["decisions"].items():
|
|
244
|
+
vd = model_keys.get(key)
|
|
245
|
+
if vd is None:
|
|
246
|
+
logger.warning("Session key not found in model: %s", key)
|
|
247
|
+
stats["skipped"] += 1
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
status = ValidationStatus[decision["status"]]
|
|
252
|
+
except KeyError:
|
|
253
|
+
logger.warning(
|
|
254
|
+
"Unknown status '%s' for key %s",
|
|
255
|
+
decision["status"], key,
|
|
256
|
+
)
|
|
257
|
+
stats["skipped"] += 1
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
model.set_status(vd.id, status)
|
|
261
|
+
|
|
262
|
+
if decision.get("corrected_geometry") is not None:
|
|
263
|
+
geom = decision["corrected_geometry"]
|
|
264
|
+
model.set_corrected_geometry(
|
|
265
|
+
vd.id, geom["xc"], geom["yc"],
|
|
266
|
+
geom["width"], geom["height"],
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
stats["applied"] += 1
|
|
270
|
+
|
|
271
|
+
# restore manual detections
|
|
272
|
+
for manual in session_data.get("manual_detections", []):
|
|
273
|
+
try:
|
|
274
|
+
model.add_detection(
|
|
275
|
+
source_file=manual["source_file"],
|
|
276
|
+
frame_number=manual["frame_number"],
|
|
277
|
+
xc=manual["xc"], yc=manual["yc"],
|
|
278
|
+
width=manual["width"], height=manual["height"],
|
|
279
|
+
label=manual["label"],
|
|
280
|
+
timestamp=manual.get("timestamp", 0.0),
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
saved_status = ValidationStatus[manual.get(
|
|
284
|
+
"status", "CONFIRMED"
|
|
285
|
+
)]
|
|
286
|
+
if saved_status != ValidationStatus.CONFIRMED:
|
|
287
|
+
all_dets = model.get_detections_for_video(
|
|
288
|
+
manual["source_file"]
|
|
289
|
+
)
|
|
290
|
+
last_manual = all_dets[-1]
|
|
291
|
+
model.set_status(last_manual.id, saved_status)
|
|
292
|
+
|
|
293
|
+
stats["manual_added"] += 1
|
|
294
|
+
|
|
295
|
+
except (ValueError, KeyError) as e:
|
|
296
|
+
logger.warning(
|
|
297
|
+
"Failed to restore manual detection: %s", e
|
|
298
|
+
)
|
|
299
|
+
stats["skipped"] += 1
|
|
300
|
+
|
|
301
|
+
logger.info(
|
|
302
|
+
"Applied session state: %d applied, %d skipped, "
|
|
303
|
+
"%d manual restored",
|
|
304
|
+
stats["applied"], stats["skipped"], stats["manual_added"],
|
|
305
|
+
)
|
|
306
|
+
return stats
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@staticmethod
|
|
310
|
+
def export_detections(
|
|
311
|
+
path: Path | str,
|
|
312
|
+
model: ValidationModel,
|
|
313
|
+
) -> int:
|
|
314
|
+
"""
|
|
315
|
+
Export confirmed and corrected detections to a CSV file.
|
|
316
|
+
|
|
317
|
+
Only CONFIRMED and CORRECTED detections are included. Adds
|
|
318
|
+
'status' and 'source' columns to distinguish pipeline from
|
|
319
|
+
manual detections. Uses corrected geometry where available.
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
Number of detections written.
|
|
323
|
+
"""
|
|
324
|
+
path = Path(path)
|
|
325
|
+
exportable = {
|
|
326
|
+
ValidationStatus.CONFIRMED, ValidationStatus.CORRECTED
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
fieldnames = [
|
|
330
|
+
"source_file", "frame_number", "timestamp",
|
|
331
|
+
"xc", "yc", "width", "height",
|
|
332
|
+
"confidence", "label", "track_id",
|
|
333
|
+
"status", "source",
|
|
334
|
+
]
|
|
335
|
+
|
|
336
|
+
count = 0
|
|
337
|
+
with open(path, "w", newline="", encoding="utf-8") as f:
|
|
338
|
+
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
|
339
|
+
writer.writeheader()
|
|
340
|
+
|
|
341
|
+
for source_file in model.get_all_source_files():
|
|
342
|
+
for vd in model.get_detections_for_video(source_file):
|
|
343
|
+
if vd.status not in exportable:
|
|
344
|
+
continue
|
|
345
|
+
|
|
346
|
+
det = vd.detection
|
|
347
|
+
if vd.corrected_geometry is not None:
|
|
348
|
+
geom = vd.corrected_geometry
|
|
349
|
+
xc, yc = geom["xc"], geom["yc"]
|
|
350
|
+
width, height = geom["width"], geom["height"]
|
|
351
|
+
else:
|
|
352
|
+
xc, yc = det.xc, det.yc
|
|
353
|
+
width, height = det.width, det.height
|
|
354
|
+
|
|
355
|
+
writer.writerow({
|
|
356
|
+
"source_file": det.source_file,
|
|
357
|
+
"frame_number": det.frame_number,
|
|
358
|
+
"timestamp": det.timestamp,
|
|
359
|
+
"xc": xc, "yc": yc,
|
|
360
|
+
"width": width, "height": height,
|
|
361
|
+
"confidence": (
|
|
362
|
+
det.confidence if det.confidence is not None
|
|
363
|
+
else ""
|
|
364
|
+
),
|
|
365
|
+
"label": det.label or "",
|
|
366
|
+
"track_id": (
|
|
367
|
+
det.track_id if det.track_id is not None
|
|
368
|
+
else ""
|
|
369
|
+
),
|
|
370
|
+
"status": vd.status.name,
|
|
371
|
+
"source": "manual" if vd.is_manual else "pipeline",
|
|
372
|
+
})
|
|
373
|
+
count += 1
|
|
374
|
+
|
|
375
|
+
logger.info("Exported %d detections to %s", count, path)
|
|
376
|
+
return count
|