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/edge/writer.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Detection CSV writer for the edge runtime.
|
|
3
|
+
|
|
4
|
+
Produces CSV files with the same column schema as SeaVIsion's DetectionWriter,
|
|
5
|
+
so edge detections can be loaded directly into the validation GUI. The writer
|
|
6
|
+
buffers rows in memory and flushes periodically to balance write performance
|
|
7
|
+
against data safety and memory usage.
|
|
8
|
+
|
|
9
|
+
Dependencies: Only python standard libraries.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import csv
|
|
13
|
+
import logging
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Dict, List, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
# CSV column order — must match seavision.engine.postprocessor.CSV_HEADERS
|
|
21
|
+
CSV_COLUMNS = [
|
|
22
|
+
"source_file",
|
|
23
|
+
"timestamp",
|
|
24
|
+
"frame_number",
|
|
25
|
+
"xc",
|
|
26
|
+
"yc",
|
|
27
|
+
"width",
|
|
28
|
+
"height",
|
|
29
|
+
"confidence",
|
|
30
|
+
"label",
|
|
31
|
+
"track_id",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class EdgeDetectionWriter:
|
|
36
|
+
"""
|
|
37
|
+
Writes detections to CSV files in SeaVision-compatible format.
|
|
38
|
+
|
|
39
|
+
Creates one CSV per recording session (or per video file if processing
|
|
40
|
+
files rather than live camera). Buffers rows in memory and flushes every
|
|
41
|
+
`flush_interval` frames.
|
|
42
|
+
|
|
43
|
+
Attributes:
|
|
44
|
+
output_dir: Directory where CSV files will be saved.
|
|
45
|
+
flush_interval: Number of frames between flushes to disk.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
output_dir: str,
|
|
51
|
+
source_name: str = "camera",
|
|
52
|
+
flush_interval: int = 100,
|
|
53
|
+
):
|
|
54
|
+
"""
|
|
55
|
+
Initialise the writer.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
output_dir: Directory for output files. Created if needed.
|
|
59
|
+
source_name: Name of the source, used in filenames and in
|
|
60
|
+
the source_file column.
|
|
61
|
+
flush_interval: Flush to disk every N frames.
|
|
62
|
+
"""
|
|
63
|
+
self.output_dir = Path(output_dir)
|
|
64
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
|
|
66
|
+
self.source_name = source_name
|
|
67
|
+
self.flush_interval = flush_interval
|
|
68
|
+
|
|
69
|
+
# Generate a timestamped filename
|
|
70
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
71
|
+
self._csv_path = self.output_dir / f"detections_{timestamp}.csv"
|
|
72
|
+
|
|
73
|
+
self._buffer: List[Dict] = []
|
|
74
|
+
self._file = None
|
|
75
|
+
self._writer = None
|
|
76
|
+
self._frames_since_flush = 0
|
|
77
|
+
self._total_detections = 0
|
|
78
|
+
|
|
79
|
+
# Open and write header
|
|
80
|
+
self._file = open(self._csv_path, "w", newline="")
|
|
81
|
+
self._writer = csv.DictWriter(self._file, fieldnames=CSV_COLUMNS)
|
|
82
|
+
self._writer.writeheader()
|
|
83
|
+
self._file.flush()
|
|
84
|
+
|
|
85
|
+
logger.info("Detection writer opened: %s", self._csv_path)
|
|
86
|
+
|
|
87
|
+
def write(
|
|
88
|
+
self,
|
|
89
|
+
detections: List[Tuple[float, float, float, float, float, int]],
|
|
90
|
+
frame_number: int,
|
|
91
|
+
timestamp: float = 0.0,
|
|
92
|
+
class_names: Optional[Dict[int, str]] = None,
|
|
93
|
+
) -> None:
|
|
94
|
+
"""
|
|
95
|
+
Write detections for a single frame.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
detections: List of (x1, y1, x2, y2, confidence, class_id)
|
|
99
|
+
tuples in pixel coordinates.
|
|
100
|
+
frame_number: Frame index.
|
|
101
|
+
timestamp: Timestamp in seconds from start of source.
|
|
102
|
+
class_names: Optional mapping of class_id to label string.
|
|
103
|
+
"""
|
|
104
|
+
for x1, y1, x2, y2, conf, cls_id in detections:
|
|
105
|
+
# Convert xyxy to xc,yc,w,h to match SeaVision's Detection format
|
|
106
|
+
w = x2 - x1
|
|
107
|
+
h = y2 - y1
|
|
108
|
+
xc = x1 + w / 2.0
|
|
109
|
+
yc = y1 + h / 2.0
|
|
110
|
+
|
|
111
|
+
label = ""
|
|
112
|
+
if class_names and cls_id in class_names:
|
|
113
|
+
label = class_names[cls_id]
|
|
114
|
+
|
|
115
|
+
self._buffer.append({
|
|
116
|
+
"source_file": self.source_name,
|
|
117
|
+
"timestamp": f"{timestamp:.3f}",
|
|
118
|
+
"frame_number": frame_number,
|
|
119
|
+
"xc": f"{xc:.1f}",
|
|
120
|
+
"yc": f"{yc:.1f}",
|
|
121
|
+
"width": f"{w:.1f}",
|
|
122
|
+
"height": f"{h:.1f}",
|
|
123
|
+
"confidence": f"{conf:.4f}",
|
|
124
|
+
"label": label,
|
|
125
|
+
"track_id": "",
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
self._total_detections += len(detections)
|
|
129
|
+
self._frames_since_flush += 1
|
|
130
|
+
|
|
131
|
+
if self._frames_since_flush >= self.flush_interval:
|
|
132
|
+
self.flush()
|
|
133
|
+
|
|
134
|
+
def flush(self) -> None:
|
|
135
|
+
"""Write buffered rows to disk."""
|
|
136
|
+
if not self._buffer:
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
if self._writer is not None and self._file is not None:
|
|
140
|
+
self._writer.writerows(self._buffer)
|
|
141
|
+
self._file.flush()
|
|
142
|
+
self._buffer.clear()
|
|
143
|
+
self._frames_since_flush = 0
|
|
144
|
+
|
|
145
|
+
def close(self) -> None:
|
|
146
|
+
"""Flush remaining buffer and close the file."""
|
|
147
|
+
self.flush()
|
|
148
|
+
if self._file is not None:
|
|
149
|
+
self._file.close()
|
|
150
|
+
self._file = None
|
|
151
|
+
|
|
152
|
+
logger.info(
|
|
153
|
+
"Detection writer closed: %s (%d detections)",
|
|
154
|
+
self._csv_path, self._total_detections,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def csv_path(self) -> Path:
|
|
159
|
+
"""Path to the current CSV file."""
|
|
160
|
+
return self._csv_path
|
|
161
|
+
|
|
162
|
+
def __enter__(self):
|
|
163
|
+
return self
|
|
164
|
+
|
|
165
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
166
|
+
self.close()
|
|
167
|
+
return False
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""M3B detection package."""
|
|
2
|
+
|
|
3
|
+
from .source import (
|
|
4
|
+
FrameContext,
|
|
5
|
+
VideoMetadata,
|
|
6
|
+
VideoSource,
|
|
7
|
+
LocalVideoSource,
|
|
8
|
+
S3VideoSource,
|
|
9
|
+
discover_local_videos,
|
|
10
|
+
discover_s3_videos,
|
|
11
|
+
)
|
|
12
|
+
from .detectors import Detection, DetectorBase
|
|
13
|
+
from .postprocessor import (
|
|
14
|
+
OutputMode,
|
|
15
|
+
CSVWriterConfig,
|
|
16
|
+
DetectionWriter,
|
|
17
|
+
FramePostprocessor,
|
|
18
|
+
VideoPostprocessor,
|
|
19
|
+
LabelFilterConfig,
|
|
20
|
+
LabelFilter,
|
|
21
|
+
PerFrameNmsConfig,
|
|
22
|
+
PerFrameNmsPostprocessor,
|
|
23
|
+
MotionTrackVideoConfig,
|
|
24
|
+
MotionTrackVideoPostprocessor,
|
|
25
|
+
PostprocessStage,
|
|
26
|
+
build_postprocess_stages,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"FrameContext",
|
|
31
|
+
"VideoMetadata",
|
|
32
|
+
"VideoSource",
|
|
33
|
+
"Detection",
|
|
34
|
+
"DetectorBase",
|
|
35
|
+
"LocalVideoSource",
|
|
36
|
+
"S3VideoSource",
|
|
37
|
+
"discover_local_videos",
|
|
38
|
+
"discover_s3_videos",
|
|
39
|
+
"OutputMode",
|
|
40
|
+
"CSVWriterConfig",
|
|
41
|
+
"DetectionWriter",
|
|
42
|
+
"FramePostprocessor",
|
|
43
|
+
"VideoPostprocessor",
|
|
44
|
+
"LabelFilterConfig",
|
|
45
|
+
"LabelFilter",
|
|
46
|
+
"PerFrameNmsConfig",
|
|
47
|
+
"PerFrameNmsPostprocessor",
|
|
48
|
+
"MotionTrackVideoConfig",
|
|
49
|
+
"MotionTrackVideoPostprocessor",
|
|
50
|
+
"PostprocessStage",
|
|
51
|
+
"build_postprocess_stages",
|
|
52
|
+
]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Detection data structures and detector interface.
|
|
2
|
+
|
|
3
|
+
Base classes (Detection, DetectorBase) are imported eagerly.
|
|
4
|
+
Optional detector implementations (SAM3, YOLO) use lazy imports
|
|
5
|
+
to avoid pulling in torch/ultralytics at package initialisation time.
|
|
6
|
+
This allows lightweight consumers (like the GUI) to import Detection
|
|
7
|
+
without triggering heavy dependency loads.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .base import Detection, DetectorBase
|
|
11
|
+
|
|
12
|
+
__all__ = ["Detection", "DetectorBase"]
|
|
13
|
+
|
|
14
|
+
# Lazy-loaded flags — None means "not yet checked"
|
|
15
|
+
_SAM3_AVAILABLE = None
|
|
16
|
+
_YOLO_AVAILABLE = None
|
|
17
|
+
|
|
18
|
+
# Names that trigger lazy loading when accessed
|
|
19
|
+
_SAM3_NAMES = frozenset({
|
|
20
|
+
"SAM3Detector", "SAM3DetectorConfig", "PromptType",
|
|
21
|
+
"HybridStrategy", "PrompterConfig", "PromptConfig",
|
|
22
|
+
"SAM3_AVAILABLE",
|
|
23
|
+
})
|
|
24
|
+
_YOLO_NAMES = frozenset({
|
|
25
|
+
"YOLODetector", "YOLODetectorConfig",
|
|
26
|
+
"YOLO_AVAILABLE",
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def __getattr__(name):
|
|
31
|
+
"""Lazy import for optional detector implementations."""
|
|
32
|
+
global _SAM3_AVAILABLE, _YOLO_AVAILABLE
|
|
33
|
+
|
|
34
|
+
if name in _SAM3_NAMES:
|
|
35
|
+
if _SAM3_AVAILABLE is None:
|
|
36
|
+
try:
|
|
37
|
+
from .sam3 import (
|
|
38
|
+
SAM3Detector,
|
|
39
|
+
SAM3DetectorConfig,
|
|
40
|
+
PromptType,
|
|
41
|
+
HybridStrategy,
|
|
42
|
+
PrompterConfig,
|
|
43
|
+
PromptConfig,
|
|
44
|
+
)
|
|
45
|
+
_SAM3_AVAILABLE = True
|
|
46
|
+
# Inject into module namespace so subsequent access is fast
|
|
47
|
+
globals().update({
|
|
48
|
+
"SAM3Detector": SAM3Detector,
|
|
49
|
+
"SAM3DetectorConfig": SAM3DetectorConfig,
|
|
50
|
+
"PromptType": PromptType,
|
|
51
|
+
"HybridStrategy": HybridStrategy,
|
|
52
|
+
"PrompterConfig": PrompterConfig,
|
|
53
|
+
"PromptConfig": PromptConfig,
|
|
54
|
+
"SAM3_AVAILABLE": True,
|
|
55
|
+
})
|
|
56
|
+
except Exception:
|
|
57
|
+
_SAM3_AVAILABLE = False
|
|
58
|
+
globals()["SAM3_AVAILABLE"] = False
|
|
59
|
+
|
|
60
|
+
if name == "SAM3_AVAILABLE":
|
|
61
|
+
return _SAM3_AVAILABLE
|
|
62
|
+
if not _SAM3_AVAILABLE:
|
|
63
|
+
raise ImportError(
|
|
64
|
+
f"{name} is not available (SAM3 dependencies not installed)"
|
|
65
|
+
)
|
|
66
|
+
return globals()[name]
|
|
67
|
+
|
|
68
|
+
if name in _YOLO_NAMES:
|
|
69
|
+
if _YOLO_AVAILABLE is None:
|
|
70
|
+
try:
|
|
71
|
+
from .yolo import YOLODetector, YOLODetectorConfig
|
|
72
|
+
_YOLO_AVAILABLE = True
|
|
73
|
+
globals().update({
|
|
74
|
+
"YOLODetector": YOLODetector,
|
|
75
|
+
"YOLODetectorConfig": YOLODetectorConfig,
|
|
76
|
+
"YOLO_AVAILABLE": True,
|
|
77
|
+
})
|
|
78
|
+
except Exception:
|
|
79
|
+
_YOLO_AVAILABLE = False
|
|
80
|
+
globals()["YOLO_AVAILABLE"] = False
|
|
81
|
+
|
|
82
|
+
if name == "YOLO_AVAILABLE":
|
|
83
|
+
return _YOLO_AVAILABLE
|
|
84
|
+
if not _YOLO_AVAILABLE:
|
|
85
|
+
raise ImportError(
|
|
86
|
+
f"{name} is not available (YOLO dependencies not installed)"
|
|
87
|
+
)
|
|
88
|
+
return globals()[name]
|
|
89
|
+
|
|
90
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Base classes for the detectors."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Iterator, Optional
|
|
6
|
+
import numpy as np
|
|
7
|
+
from ..source import FrameContext
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Detection:
|
|
11
|
+
"""
|
|
12
|
+
Represents a single detection in a video frame.
|
|
13
|
+
|
|
14
|
+
Attributes:
|
|
15
|
+
source_file: Path of the original source file.
|
|
16
|
+
timestamp: Timestamp within the video (seconds from the start).
|
|
17
|
+
frame_number: Frame index within the video.
|
|
18
|
+
xc: X coordinate of the detection center (pixels).
|
|
19
|
+
yc: Y coordinate of the detection center (pixels).
|
|
20
|
+
width: Width of the detection bounding box (pixels).
|
|
21
|
+
height: Height of the detection bounding box (pixels).
|
|
22
|
+
confidence: Confidence score of the detection (0.0 to 1.0), or None
|
|
23
|
+
if not applicable.
|
|
24
|
+
label: Optional class label for the detection.
|
|
25
|
+
track_id: Optional track ID for the detection (if tracking is used).
|
|
26
|
+
mask: Optional Segmentation mask as numpy array (H x W, uint8, 255=object).
|
|
27
|
+
metadata: Optional additional detector-specific data
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
source_file: str
|
|
31
|
+
timestamp: float
|
|
32
|
+
frame_number: int
|
|
33
|
+
xc: float
|
|
34
|
+
yc: float
|
|
35
|
+
width: float
|
|
36
|
+
height: float
|
|
37
|
+
confidence: Optional[float] = None
|
|
38
|
+
label: Optional[str] = None
|
|
39
|
+
track_id: Optional[int] = None
|
|
40
|
+
mask: Optional[np.ndarray] = field(default=None, repr=False)
|
|
41
|
+
metadata: Optional[dict] = field(default=None, repr=False)
|
|
42
|
+
|
|
43
|
+
def to_csv_row(self) -> dict:
|
|
44
|
+
"""Convert the detection to a CSV row string. (dictionary)"""
|
|
45
|
+
row = {
|
|
46
|
+
"source_file": self.source_file,
|
|
47
|
+
"timestamp": f"{self.timestamp:.3f}",
|
|
48
|
+
"frame_number": self.frame_number,
|
|
49
|
+
"xc": f"{self.xc:.1f}",
|
|
50
|
+
"yc": f"{self.yc:.1f}",
|
|
51
|
+
"width": f"{self.width:.1f}",
|
|
52
|
+
"height": f"{self.height:.1f}",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# Add optional fields if present
|
|
56
|
+
if self.confidence is not None:
|
|
57
|
+
row["confidence"] = f"{self.confidence:.3f}"
|
|
58
|
+
else:
|
|
59
|
+
row["confidence"] = ""
|
|
60
|
+
|
|
61
|
+
if self.label is not None:
|
|
62
|
+
row["label"] = self.label
|
|
63
|
+
else:
|
|
64
|
+
row["label"] = ""
|
|
65
|
+
|
|
66
|
+
if self.track_id is not None:
|
|
67
|
+
row["track_id"] = str(self.track_id)
|
|
68
|
+
else:
|
|
69
|
+
row["track_id"] = ""
|
|
70
|
+
|
|
71
|
+
# Note: mask and metadata are not included in CSV
|
|
72
|
+
# (masks should be saved separately if needed)
|
|
73
|
+
|
|
74
|
+
return row
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def bbox(self) -> tuple:
|
|
78
|
+
"""Get bounding box as (x1, y1, x2, y2)."""
|
|
79
|
+
x1 = self.xc - self.width / 2
|
|
80
|
+
y1 = self.yc - self.height / 2
|
|
81
|
+
x2 = self.xc + self.width / 2
|
|
82
|
+
y2 = self.yc + self.height / 2
|
|
83
|
+
return (x1, y1, x2, y2)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def area(self) -> float:
|
|
87
|
+
"""Get area of the bounding box."""
|
|
88
|
+
return self.width * self.height
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_bbox(
|
|
92
|
+
cls,
|
|
93
|
+
source_file: str,
|
|
94
|
+
timestamp: float,
|
|
95
|
+
frame_number: int,
|
|
96
|
+
x1: float,
|
|
97
|
+
y1: float,
|
|
98
|
+
x2: float,
|
|
99
|
+
y2: float,
|
|
100
|
+
**kwargs,
|
|
101
|
+
) -> "Detection":
|
|
102
|
+
"""
|
|
103
|
+
Create a Detection from corner-format bounding box.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
source_file: Path of the source file.
|
|
107
|
+
timestamp: Timestamp in seconds.
|
|
108
|
+
frame_number: Frame index.
|
|
109
|
+
x1, y1: Top-left corner.
|
|
110
|
+
x2, y2: Bottom-right corner.
|
|
111
|
+
**kwargs: Optional fields (confidence, label, track_id, etc.)
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Detection with center-format coordinates.
|
|
115
|
+
"""
|
|
116
|
+
xc = (x1 + x2) / 2
|
|
117
|
+
yc = (y1 + y2) / 2
|
|
118
|
+
width = x2 - x1
|
|
119
|
+
height = y2 - y1
|
|
120
|
+
|
|
121
|
+
return cls(
|
|
122
|
+
source_file=source_file,
|
|
123
|
+
timestamp=timestamp,
|
|
124
|
+
frame_number=frame_number,
|
|
125
|
+
xc=xc,
|
|
126
|
+
yc=yc,
|
|
127
|
+
width=width,
|
|
128
|
+
height=height,
|
|
129
|
+
**kwargs,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class DetectorBase(ABC):
|
|
134
|
+
"""
|
|
135
|
+
Abstract base class for all detectors.
|
|
136
|
+
|
|
137
|
+
Subclasses must implement the `process_frame` method to analyse individual
|
|
138
|
+
frame and yield detections.
|
|
139
|
+
|
|
140
|
+
The detector maintains internal state between frames (e.g. background
|
|
141
|
+
models, tracking state) and should be reset between video files.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
@abstractmethod
|
|
145
|
+
def process_frame(
|
|
146
|
+
self,
|
|
147
|
+
frame: np.ndarray,
|
|
148
|
+
context: FrameContext
|
|
149
|
+
) -> Iterator[Detection]:
|
|
150
|
+
"""
|
|
151
|
+
Process a single video frame and yield detections.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
frame: BGR image as a numpy array (H x W x 3).
|
|
155
|
+
context: Metadata about the current frame (FrameContext class).
|
|
156
|
+
|
|
157
|
+
Yields:
|
|
158
|
+
Detection objects for each candidate found in the frame.
|
|
159
|
+
"""
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
def reset(self) -> None:
|
|
163
|
+
"""
|
|
164
|
+
Reset detector state for a new video file.
|
|
165
|
+
|
|
166
|
+
Override this method if your detector maintains state that should be
|
|
167
|
+
cleared between video files (e.g. background models, trackers).
|
|
168
|
+
The default implementation does nothing.
|
|
169
|
+
"""
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
def __enter__(self):
|
|
173
|
+
"""Context manager entry."""
|
|
174
|
+
return self
|
|
175
|
+
|
|
176
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
177
|
+
"""Context manager exit - performs cleanup."""
|
|
178
|
+
self.reset()
|
|
179
|
+
return False
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Motion detection module."""
|
|
2
|
+
|
|
3
|
+
from .detector import MotionDetector, MotionDetectorConfig
|
|
4
|
+
from .stabiliser import FrameStabiliser, StabiliserConfig
|
|
5
|
+
from .background import BackgroundModel, BackgroundConfig
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"MotionDetector",
|
|
9
|
+
"MotionDetectorConfig",
|
|
10
|
+
"FrameStabiliser",
|
|
11
|
+
"StabiliserConfig",
|
|
12
|
+
"BackgroundModel",
|
|
13
|
+
"BackgroundConfig"
|
|
14
|
+
]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Background subtraction for foreground detection."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional
|
|
5
|
+
import cv2
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class BackgroundConfig:
|
|
11
|
+
"""
|
|
12
|
+
Configuration for background subtraction.
|
|
13
|
+
|
|
14
|
+
Attributes:
|
|
15
|
+
history: Number of frames used to build the background model.
|
|
16
|
+
var_threshold:Variance threshold for foreground classification.
|
|
17
|
+
detect_shadows: Whether to detect shadows in the foreground mask.
|
|
18
|
+
learning_rate: Background model learning rate. Use -1 for auto,
|
|
19
|
+
or a value in [0,1] where higher = faster adaptation.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
history: int = 600 # 1 minute at 10 FPS
|
|
23
|
+
var_threshold: float = 16.0
|
|
24
|
+
detect_shadows: bool = True
|
|
25
|
+
learning_rate: float = -1.0 # Auto
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BackgroundModel:
|
|
29
|
+
"""
|
|
30
|
+
Adaptive background subtraction using MOG2.
|
|
31
|
+
|
|
32
|
+
Maintains a per-pixel Gaussian mixture model (GMM) to distinguish forground
|
|
33
|
+
(moving objects) from background (static scene).
|
|
34
|
+
|
|
35
|
+
Example:
|
|
36
|
+
bg_model = BackgroundModel()
|
|
37
|
+
|
|
38
|
+
for frame in stabilised_frames:
|
|
39
|
+
fg_mask = bg_model.apply(frame)
|
|
40
|
+
# mask is binary image where 255 = foreground; 0 = background
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, config: Optional[BackgroundConfig] = None):
|
|
44
|
+
"""
|
|
45
|
+
Initialise the background model.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
config: Configuration for the background model. If None, defaults
|
|
49
|
+
are used.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
self.config = config or BackgroundConfig()
|
|
53
|
+
|
|
54
|
+
self._subtractor = cv2.createBackgroundSubtractorMOG2(
|
|
55
|
+
history=self.config.history,
|
|
56
|
+
varThreshold=self.config.var_threshold,
|
|
57
|
+
detectShadows=self.config.detect_shadows,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def apply(self, frame: np.ndarray) -> np.ndarray:
|
|
61
|
+
"""
|
|
62
|
+
Apply background subtraction to a frame.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
frame: Input BGR frame as a numpy array - should already be
|
|
66
|
+
stabilised.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Foreground mask as a binary numpy array (uint8) where:
|
|
70
|
+
255 = foreground; 0 = background.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
mask = self._subtractor.apply(
|
|
74
|
+
frame,
|
|
75
|
+
learningRate=self.config.learning_rate
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Ensure binary output (MOG2 can produce grey values for shadows)
|
|
79
|
+
_, mask = cv2.threshold(mask, 127, 255, cv2.THRESH_BINARY)
|
|
80
|
+
|
|
81
|
+
return mask
|
|
82
|
+
|
|
83
|
+
def reset(self) -> None:
|
|
84
|
+
"""
|
|
85
|
+
Reset the background model to initial state.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
self._subtractor = cv2.createBackgroundSubtractorMOG2(
|
|
89
|
+
history=self.config.history,
|
|
90
|
+
varThreshold=self.config.var_threshold,
|
|
91
|
+
detectShadows=self.config.detect_shadows,
|
|
92
|
+
)
|