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,218 @@
|
|
|
1
|
+
"""Motion detector combining stabilisation and background modelling."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Dict, Iterator, Optional
|
|
5
|
+
import cv2
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from ...source import FrameContext
|
|
9
|
+
from ..base import Detection, DetectorBase
|
|
10
|
+
from .stabiliser import FrameStabiliser, StabiliserConfig
|
|
11
|
+
from .background import BackgroundModel, BackgroundConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class MotionDetectorConfig:
|
|
16
|
+
"""
|
|
17
|
+
Configuration for the motion detector.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
stabiliser: Configuration for the frame stabiliser.
|
|
21
|
+
background: Confiuguration for the background subtraction.
|
|
22
|
+
stabilisation_enabled: Whether to enable frame stabilisation.
|
|
23
|
+
min_area: Minimum area (pixels) for motion contours to be considered.
|
|
24
|
+
max_area: Maximum area (pixels) for motion contours to be considered.
|
|
25
|
+
morph_kernel_size: Kernel size for morphological operations.
|
|
26
|
+
morph_iterations: Number of iterations for morphological operations.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
stabiliser: StabiliserConfig = field(
|
|
30
|
+
default_factory=StabiliserConfig
|
|
31
|
+
)
|
|
32
|
+
background: BackgroundConfig = field(
|
|
33
|
+
default_factory=BackgroundConfig
|
|
34
|
+
)
|
|
35
|
+
stabilisation_enabled: bool = True
|
|
36
|
+
min_area: int = 100 # pixels
|
|
37
|
+
max_area: int = 50000 # pixels
|
|
38
|
+
morph_kernel_size: int = 5
|
|
39
|
+
morph_iterations: int = 2
|
|
40
|
+
|
|
41
|
+
# Persistence filtering
|
|
42
|
+
persistence_enabled: bool = True
|
|
43
|
+
min_persistence: int = 3 # Frames before emitting detection
|
|
44
|
+
max_frames_missing: int = 5 # Frames before dropping track
|
|
45
|
+
iou_threshold: float = 0.3 # Minimum IoU to match detections
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MotionDetector(DetectorBase):
|
|
49
|
+
"""
|
|
50
|
+
Detects motion in video frames using background subtraction in the
|
|
51
|
+
following pipeline:
|
|
52
|
+
1. (Optional) Stabilise frame to compensate for camera motion.
|
|
53
|
+
2. Apply background subtraction to get a foreground mask.
|
|
54
|
+
3. Clean mask with morphological operations.
|
|
55
|
+
4. Find countours and filter by area.
|
|
56
|
+
5. Emit Detection objects for valid contours.
|
|
57
|
+
|
|
58
|
+
Example:
|
|
59
|
+
config = MotionDetectorConfig(min_area=200)
|
|
60
|
+
|
|
61
|
+
with MotionDetector(config) as detector:
|
|
62
|
+
for frame, context in video_source.iter_frames():
|
|
63
|
+
for detection in detector.process_frame(frame, context):
|
|
64
|
+
# process detection
|
|
65
|
+
pass
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, config: Optional[MotionDetectorConfig] = None):
|
|
69
|
+
"""
|
|
70
|
+
Initialise the motion detector.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
config: Configuration for the motion detector. If None, defaults
|
|
74
|
+
are used.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
self.config = config or MotionDetectorConfig()
|
|
78
|
+
|
|
79
|
+
self._stabiliser = FrameStabiliser(self.config.stabiliser)
|
|
80
|
+
self._background = BackgroundModel(self.config.background)
|
|
81
|
+
|
|
82
|
+
# Morphological operation kernel
|
|
83
|
+
self._morph_kernel = cv2.getStructuringElement(
|
|
84
|
+
cv2.MORPH_ELLIPSE,
|
|
85
|
+
(self.config.morph_kernel_size, self.config.morph_kernel_size)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Persistence tracker
|
|
89
|
+
if self.config.persistence_enabled:
|
|
90
|
+
from .tracker import PersistenceTracker, TrackerConfig
|
|
91
|
+
tracker_config = TrackerConfig(
|
|
92
|
+
min_persistence=self.config.min_persistence,
|
|
93
|
+
max_frames_missing=self.config.max_frames_missing,
|
|
94
|
+
iou_threshold=self.config.iou_threshold
|
|
95
|
+
)
|
|
96
|
+
self._tracker = PersistenceTracker(tracker_config)
|
|
97
|
+
else:
|
|
98
|
+
self._tracker = None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def process_frame(
|
|
102
|
+
self,
|
|
103
|
+
frame: np.ndarray,
|
|
104
|
+
context: FrameContext
|
|
105
|
+
) -> Iterator[Detection]:
|
|
106
|
+
"""
|
|
107
|
+
Process a single video frame and yield motion detections.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
frame: BGR image as a numpy array (H x W x 3).
|
|
111
|
+
context: Metadata about the current frame (FrameContext class).
|
|
112
|
+
|
|
113
|
+
Yields:
|
|
114
|
+
Detection objects for each motion contour found in the frame.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
# Stage 1: Stabilisation
|
|
118
|
+
if self.config.stabilisation_enabled:
|
|
119
|
+
stabilised, _ = self._stabiliser.stabilise(frame)
|
|
120
|
+
else:
|
|
121
|
+
stabilised = frame
|
|
122
|
+
|
|
123
|
+
# Stage 2: Background subtraction
|
|
124
|
+
mask = self._background.apply(stabilised)
|
|
125
|
+
|
|
126
|
+
# Stage 3: Morphological cleaning
|
|
127
|
+
mask = self._clean_mask(mask)
|
|
128
|
+
|
|
129
|
+
# Stage 4: Extract raw detections
|
|
130
|
+
raw_detections = list(self._extract_detections(mask, context))
|
|
131
|
+
|
|
132
|
+
# Stage 5: Persistence filtering
|
|
133
|
+
if self._tracker is not None:
|
|
134
|
+
confirmed_tracks = self._tracker.update(raw_detections)
|
|
135
|
+
|
|
136
|
+
for track in confirmed_tracks:
|
|
137
|
+
yield Detection(
|
|
138
|
+
source_file=context.source_file,
|
|
139
|
+
timestamp=context.timestamp,
|
|
140
|
+
frame_number=context.frame_number,
|
|
141
|
+
xc=track.xc,
|
|
142
|
+
yc=track.yc,
|
|
143
|
+
width=track.width,
|
|
144
|
+
height=track.height,
|
|
145
|
+
confidence=None # No confidence score for motion detection
|
|
146
|
+
)
|
|
147
|
+
else:
|
|
148
|
+
# No tracking - emit all detections
|
|
149
|
+
for det in raw_detections:
|
|
150
|
+
yield Detection(
|
|
151
|
+
source_file=context.source_file,
|
|
152
|
+
timestamp=context.timestamp,
|
|
153
|
+
frame_number=context.frame_number,
|
|
154
|
+
**det
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def _clean_mask(self, mask: np.ndarray) -> np.ndarray:
|
|
158
|
+
"""
|
|
159
|
+
Apply morphological operations to clean the foreground mask.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
# Opening (erosion then dilation) removes small noise
|
|
163
|
+
mask = cv2.morphologyEx(
|
|
164
|
+
mask,
|
|
165
|
+
cv2.MORPH_OPEN,
|
|
166
|
+
self._morph_kernel,
|
|
167
|
+
iterations=self.config.morph_iterations
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Closing (dilation then erosion) fills small holes
|
|
171
|
+
mask = cv2.morphologyEx(
|
|
172
|
+
mask,
|
|
173
|
+
cv2.MORPH_CLOSE,
|
|
174
|
+
self._morph_kernel,
|
|
175
|
+
iterations=self.config.morph_iterations
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
return mask
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _extract_detections(
|
|
182
|
+
self,
|
|
183
|
+
mask: np.ndarray,
|
|
184
|
+
context: FrameContext
|
|
185
|
+
) -> Iterator[Dict]:
|
|
186
|
+
"""
|
|
187
|
+
Find contours in the mask and yield Detection dicts (for tracker input).
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
contours, _ = cv2.findContours(
|
|
191
|
+
mask,
|
|
192
|
+
cv2.RETR_EXTERNAL,
|
|
193
|
+
cv2.CHAIN_APPROX_SIMPLE
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
for contour in contours:
|
|
197
|
+
area = cv2.contourArea(contour)
|
|
198
|
+
|
|
199
|
+
# Filter by size
|
|
200
|
+
if area < self.config.min_area or area > self.config.max_area:
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
# Get bounding box
|
|
204
|
+
x, y, w, h = cv2.boundingRect(contour)
|
|
205
|
+
|
|
206
|
+
yield {
|
|
207
|
+
"xc": x + w / 2,
|
|
208
|
+
"yc": y + h / 2,
|
|
209
|
+
"width": w,
|
|
210
|
+
"height": h,
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
def reset(self) -> None:
|
|
214
|
+
"""Reset internal state between videos."""
|
|
215
|
+
self._stabiliser.reset()
|
|
216
|
+
self._background.reset()
|
|
217
|
+
if self._tracker is not None:
|
|
218
|
+
self._tracker.reset()
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Frame stabilisation using feature-based homography."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional, Tuple
|
|
5
|
+
import cv2
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class StabiliserConfig:
|
|
11
|
+
"""
|
|
12
|
+
Configuration for frame stabilisation.
|
|
13
|
+
|
|
14
|
+
# TODO: Glossary document which defines these things?
|
|
15
|
+
Attributes:
|
|
16
|
+
feature_detector: Feature detector type (e.g. 'ORB', 'AKAZE').
|
|
17
|
+
max_features: Maximum number of features to detect.
|
|
18
|
+
match_ratio: Lowe's ratio test threshold for feature matching.
|
|
19
|
+
min_matches: Minimum number of matches to compute homography.
|
|
20
|
+
ransac_threshold: RANSAC reprojection threshold for homography.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
feature_detector: str = "ORB"
|
|
24
|
+
max_features: int = 500
|
|
25
|
+
match_ratio: float = 0.75
|
|
26
|
+
min_matches: int = 10
|
|
27
|
+
ransac_threshold: float = 5.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FrameStabiliser:
|
|
31
|
+
"""
|
|
32
|
+
Stabilises frames by aligning to the previous frame.
|
|
33
|
+
|
|
34
|
+
Uses feature-based homography estimation to compensate for camera motion.
|
|
35
|
+
|
|
36
|
+
Example:
|
|
37
|
+
stabiliser = FrameStabiliser()
|
|
38
|
+
|
|
39
|
+
for frame in video_frames:
|
|
40
|
+
stable_frame = stabiliser.stabilise(frame)
|
|
41
|
+
# process stable_frame
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, config: Optional[StabiliserConfig] = None):
|
|
45
|
+
"""
|
|
46
|
+
Initialise the frame stabiliser.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
config: Configuration for the stabiliser. If None, defaults are used.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
self.config = config or StabiliserConfig()
|
|
53
|
+
|
|
54
|
+
# Initialise the feature detector
|
|
55
|
+
# TODO: Are there any more obvious detector options that should be implemented?
|
|
56
|
+
if self.config.feature_detector == "ORB":
|
|
57
|
+
self._detector = cv2.ORB_create(nfeatures=self.config.max_features)
|
|
58
|
+
elif self.config.feature_detector == "AKAZE":
|
|
59
|
+
self._detector = cv2.AKAZE_create()
|
|
60
|
+
else:
|
|
61
|
+
raise ValueError(f"Unknown detector: {self.config.feature_detector}")
|
|
62
|
+
|
|
63
|
+
# Brute-force matcher with Hamming distance (for binary descriptors)
|
|
64
|
+
# TODO: What is a matcher? this should be in the glossary
|
|
65
|
+
self._matcher = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=False)
|
|
66
|
+
|
|
67
|
+
# State
|
|
68
|
+
self._prev_grey: Optional[np.ndarray] = None
|
|
69
|
+
self._prev_keypoints: Optional[list] = None
|
|
70
|
+
self._prev_descriptors: Optional[np.ndarray] = None
|
|
71
|
+
|
|
72
|
+
def stabilise(self, frame: np.ndarray) -> Tuple[np.ndarray, bool]:
|
|
73
|
+
"""
|
|
74
|
+
Stabilise a frame by aligning it to the previous frame.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
frame: Input BGR frame as a numpy array.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Tuple of (stabilised_frame, is_stabilised) where stabilised_frame
|
|
81
|
+
is the aligned frame and is_stabilised indicates if alignment was
|
|
82
|
+
applied. Returns the original frame if is_stabilised is False.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
# Convert to greyscale - feature detection works on intensity values
|
|
86
|
+
grey = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
|
87
|
+
|
|
88
|
+
# Detect distinctive visual features (corners, edges) and compute
|
|
89
|
+
# binary descriptors that summarise the local appearance around each
|
|
90
|
+
keypoints, descriptors = self._detector.detectAndCompute(grey, None)
|
|
91
|
+
|
|
92
|
+
# First frame - nothing to align to
|
|
93
|
+
if self._prev_grey is None:
|
|
94
|
+
self._update_reference(grey, keypoints, descriptors)
|
|
95
|
+
return frame, True
|
|
96
|
+
|
|
97
|
+
# Not enough features in current frame
|
|
98
|
+
if descriptors is None or len(keypoints) < self.config.min_matches:
|
|
99
|
+
self._update_reference(grey, keypoints, descriptors)
|
|
100
|
+
return frame, False
|
|
101
|
+
|
|
102
|
+
# Match features to previous frame. For each feature in the previous
|
|
103
|
+
# frame, find the 2 most similar features in the current frame (k=2).
|
|
104
|
+
# Similarity is measured by Hamming distance between binary descriptors.
|
|
105
|
+
matches = self._matcher.knnMatch(
|
|
106
|
+
self._prev_descriptors, descriptors, k=2
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Apply Lowe's ratio test to filter ambiguous matches. A match is
|
|
110
|
+
# considered good if the best match (m) is significantly better than
|
|
111
|
+
# the second-best match (n). This rejects features that could match
|
|
112
|
+
# multiple locations (e.g., repetitive patterns).
|
|
113
|
+
good_matches = []
|
|
114
|
+
for m, n in matches:
|
|
115
|
+
if m.distance < self.config.match_ratio * n.distance:
|
|
116
|
+
good_matches.append(m)
|
|
117
|
+
|
|
118
|
+
# Not enough good matches
|
|
119
|
+
if len(good_matches) < self.config.min_matches:
|
|
120
|
+
self._update_reference(grey, keypoints, descriptors)
|
|
121
|
+
return frame, False
|
|
122
|
+
|
|
123
|
+
# Extract matched point coordinates:
|
|
124
|
+
# - src_pts: where features were located in the PREVIOUS frame
|
|
125
|
+
# - dst_pts: where those same features are in the CURRENT frame
|
|
126
|
+
# The difference between these tells us how the camera moved.
|
|
127
|
+
src_pts = np.float32([
|
|
128
|
+
self._prev_keypoints[m.queryIdx].pt for m in good_matches
|
|
129
|
+
]).reshape(-1, 1, 2)
|
|
130
|
+
dst_pts = np.float32([
|
|
131
|
+
keypoints[m.trainIdx].pt for m in good_matches
|
|
132
|
+
]).reshape(-1, 1, 2)
|
|
133
|
+
|
|
134
|
+
# Compute homography matrix H that maps dst_pts -> src_pts.
|
|
135
|
+
# H is a 3x3 transformation matrix that describes the camera motion
|
|
136
|
+
# (rotation, translation, perspective change) between frames.
|
|
137
|
+
# RANSAC robustly estimates H by ignoring outlier matches (e.g., from
|
|
138
|
+
# moving objects or mismatches).
|
|
139
|
+
H, mask = cv2.findHomography(
|
|
140
|
+
dst_pts, src_pts,
|
|
141
|
+
cv2.RANSAC,
|
|
142
|
+
self.config.ransac_threshold
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
if H is None:
|
|
146
|
+
self._update_reference(grey, keypoints, descriptors)
|
|
147
|
+
return frame, False
|
|
148
|
+
|
|
149
|
+
# Warp current frame to align with previous frame.
|
|
150
|
+
# This applies the inverse camera motion, so stationary background
|
|
151
|
+
# objects appear in the same position as the previous frame.
|
|
152
|
+
height, width = frame.shape[:2]
|
|
153
|
+
stabilised_frame = cv2.warpPerspective(frame, H, (width, height))
|
|
154
|
+
|
|
155
|
+
# Update reference frame
|
|
156
|
+
self._update_reference(grey, keypoints, descriptors)
|
|
157
|
+
return stabilised_frame, True
|
|
158
|
+
|
|
159
|
+
def _update_reference(
|
|
160
|
+
self,
|
|
161
|
+
grey: np.ndarray,
|
|
162
|
+
keypoints: Tuple,
|
|
163
|
+
descriptors: Optional[np.ndarray]
|
|
164
|
+
) -> None:
|
|
165
|
+
"""Update the reference frame and features."""
|
|
166
|
+
self._prev_grey = grey
|
|
167
|
+
self._prev_keypoints = keypoints
|
|
168
|
+
self._prev_descriptors = descriptors
|
|
169
|
+
|
|
170
|
+
def reset(self) -> None:
|
|
171
|
+
"""Reset the stabiliser state."""
|
|
172
|
+
self._prev_grey = None
|
|
173
|
+
self._prev_keypoints = None
|
|
174
|
+
self._prev_descriptors = None
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Simple multi-object tracker for persistence filtering."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Dict, List, Optional, Tuple
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class TrackedObject:
|
|
10
|
+
"""A tracked detection across multiple frames."""
|
|
11
|
+
|
|
12
|
+
track_id: int
|
|
13
|
+
xc: float
|
|
14
|
+
yc: float
|
|
15
|
+
width: float
|
|
16
|
+
height: float
|
|
17
|
+
age: int = 1 # Frames since first seen
|
|
18
|
+
frames_since_update: int = 0 # Frames since last matched
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def update(self, xc: float, yc: float, width: float, height: float) -> None:
|
|
22
|
+
"""Update the tracked object with new detection data."""
|
|
23
|
+
self.xc = xc
|
|
24
|
+
self.yc = yc
|
|
25
|
+
self.width = width
|
|
26
|
+
self.height = height
|
|
27
|
+
self.age += 1
|
|
28
|
+
self.frames_since_update = 0
|
|
29
|
+
|
|
30
|
+
def mark_missed(self) -> None:
|
|
31
|
+
"""Mark that no detection matched this frame."""
|
|
32
|
+
self.frames_since_update += 1
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def bbox(self) -> Tuple[float, float, float, float]:
|
|
36
|
+
"""Get bounding box as (x1, y1, x2, y2)."""
|
|
37
|
+
x1 = self.xc - self.width / 2
|
|
38
|
+
y1 = self.yc - self.height / 2
|
|
39
|
+
x2 = self.xc + self.width / 2
|
|
40
|
+
y2 = self.yc + self.height / 2
|
|
41
|
+
return (x1, y1, x2, y2)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class TrackerConfig:
|
|
46
|
+
"""Configuration for the persistence tracker."""
|
|
47
|
+
|
|
48
|
+
min_persistence: int = 3 # Frames before emitting detection
|
|
49
|
+
max_frames_missing: int = 5 # Frames before dropping track
|
|
50
|
+
iou_threshold: float = 0.3 # Minimum IoU to match detections
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def compute_iou(box1: Tuple[float, float, float, float],
|
|
54
|
+
box2: Tuple[float, float, float, float]) -> float:
|
|
55
|
+
"""Compute Intersection over Union between two boxes (x1,y1,x2,y2)."""
|
|
56
|
+
x1 = max(box1[0], box2[0])
|
|
57
|
+
y1 = max(box1[1], box2[1])
|
|
58
|
+
x2 = min(box1[2], box2[2])
|
|
59
|
+
y2 = min(box1[3], box2[3])
|
|
60
|
+
|
|
61
|
+
intersection = max(0, x2 - x1) * max(0, y2 - y1)
|
|
62
|
+
|
|
63
|
+
area1 = (box1[2] - box1[0]) * (box1[3] - box1[1])
|
|
64
|
+
area2 = (box2[2] - box2[0]) * (box2[3] - box2[1])
|
|
65
|
+
union = area1 + area2 - intersection
|
|
66
|
+
|
|
67
|
+
return intersection / union if union > 0 else 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class PersistenceTracker:
|
|
71
|
+
"""
|
|
72
|
+
tracks detections across frames and filters out transient noise.
|
|
73
|
+
|
|
74
|
+
Only emits detections that have persisted for min_persistence frames.
|
|
75
|
+
|
|
76
|
+
Example:
|
|
77
|
+
tracker = PersistenceTracker(TrackerConfig(min_persistence=3))
|
|
78
|
+
|
|
79
|
+
for frame_detections in all_detections:
|
|
80
|
+
confirmed = tracker.update(frame_detections)
|
|
81
|
+
# confirmed only contains detections seen for 3+ frames
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(self, config: Optional[TrackerConfig] = None):
|
|
85
|
+
self.config = config or TrackerConfig()
|
|
86
|
+
self._tracks: Dict[int, TrackedObject] = {}
|
|
87
|
+
self._next_id: int = 0
|
|
88
|
+
|
|
89
|
+
def update(self, detections: List[dict]) -> List[TrackedObject]:
|
|
90
|
+
"""
|
|
91
|
+
Update tracker with new frame detections.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
detections: List of detection dicts with keys:
|
|
95
|
+
'xc', 'yc', 'width', 'height'.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
A list of tracked objects that have a persistence >=
|
|
99
|
+
min_persistence frames.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
# Convert detections to bbox format for matching
|
|
103
|
+
det_boxes = []
|
|
104
|
+
for det in detections:
|
|
105
|
+
x1 = det["xc"] - det["width"] / 2
|
|
106
|
+
y1 = det["yc"] - det["height"] / 2
|
|
107
|
+
x2 = det["xc"] + det["width"] / 2
|
|
108
|
+
y2 = det["yc"] + det["height"] / 2
|
|
109
|
+
det_boxes.append((x1, y1, x2, y2))
|
|
110
|
+
|
|
111
|
+
# Match detections to existing tracks using IoU
|
|
112
|
+
matched_tracks = set()
|
|
113
|
+
matched_dets = set()
|
|
114
|
+
|
|
115
|
+
# Greedy matching, highest IoU first
|
|
116
|
+
if self._tracks and det_boxes:
|
|
117
|
+
iou_matrix = []
|
|
118
|
+
|
|
119
|
+
for track_id, track in self._tracks.items():
|
|
120
|
+
for det_idx, det_box in enumerate(det_boxes):
|
|
121
|
+
iou = compute_iou(track.bbox, det_box)
|
|
122
|
+
if iou >= self.config.iou_threshold:
|
|
123
|
+
iou_matrix.append((iou, track_id, det_idx))
|
|
124
|
+
|
|
125
|
+
# Sort by IoU descending
|
|
126
|
+
iou_matrix.sort(reverse=True, key=lambda x: x[0])
|
|
127
|
+
|
|
128
|
+
for iou, track_id, det_idx in iou_matrix:
|
|
129
|
+
if track_id not in matched_tracks and det_idx not in matched_dets:
|
|
130
|
+
# Update track with matched detection
|
|
131
|
+
det = detections[det_idx]
|
|
132
|
+
self._tracks[track_id].update(
|
|
133
|
+
det["xc"], det["yc"], det["width"], det["height"]
|
|
134
|
+
)
|
|
135
|
+
matched_tracks.add(track_id)
|
|
136
|
+
matched_dets.add(det_idx)
|
|
137
|
+
|
|
138
|
+
# Mark unmatched tracks as missed
|
|
139
|
+
for track_id in self._tracks:
|
|
140
|
+
if track_id not in matched_tracks:
|
|
141
|
+
self._tracks[track_id].mark_missed()
|
|
142
|
+
|
|
143
|
+
# Create new tracks for unmatched detections
|
|
144
|
+
for det_idx, det in enumerate(detections):
|
|
145
|
+
if det_idx not in matched_dets:
|
|
146
|
+
self._tracks[self._next_id] = TrackedObject(
|
|
147
|
+
track_id=self._next_id,
|
|
148
|
+
xc=det["xc"],
|
|
149
|
+
yc=det["yc"],
|
|
150
|
+
width=det["width"],
|
|
151
|
+
height=det["height"],
|
|
152
|
+
)
|
|
153
|
+
self._next_id += 1
|
|
154
|
+
|
|
155
|
+
# Remove stale tracks
|
|
156
|
+
stale_ids = [
|
|
157
|
+
track_id for track_id, track in self._tracks.items()
|
|
158
|
+
if track.frames_since_update > self.config.max_frames_missing
|
|
159
|
+
]
|
|
160
|
+
for track_id in stale_ids:
|
|
161
|
+
del self._tracks[track_id]
|
|
162
|
+
|
|
163
|
+
# Return only tracks that have persisted long enough
|
|
164
|
+
confirmed = [
|
|
165
|
+
track for track in self._tracks.values()
|
|
166
|
+
if track.age >= self.config.min_persistence
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
return confirmed
|
|
170
|
+
|
|
171
|
+
def reset(self) -> None:
|
|
172
|
+
"""Reset the tracker state for a new video."""
|
|
173
|
+
self._tracks.clear()
|
|
174
|
+
self._next_id = 0
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""SAM 3 (Segment Anything Model 3) detector module."""
|
|
2
|
+
|
|
3
|
+
from .config import (
|
|
4
|
+
PromptType,
|
|
5
|
+
HybridStrategy,
|
|
6
|
+
PrompterConfig,
|
|
7
|
+
PromptConfig,
|
|
8
|
+
SAM3DetectorConfig,
|
|
9
|
+
)
|
|
10
|
+
from .detector import SAM3Detector
|
|
11
|
+
from .native import SAM3NativeDetector
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
# Config
|
|
15
|
+
"PromptType",
|
|
16
|
+
"HybridStrategy",
|
|
17
|
+
"PrompterConfig",
|
|
18
|
+
"PromptConfig",
|
|
19
|
+
"SAM3DetectorConfig",
|
|
20
|
+
# Detector
|
|
21
|
+
"SAM3Detector",
|
|
22
|
+
"SAM3NativeDetector",
|
|
23
|
+
]
|