@camera.ui/camera-ui-opencv 0.0.1
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.
- package/CHANGELOG.md +8 -0
- package/CONTRIBUTING.md +1 -0
- package/LICENSE.md +22 -0
- package/README.md +1 -0
- package/config.schema.json +3 -0
- package/logo.png +0 -0
- package/package.json +44 -0
- package/requirements-lock.txt +6 -0
- package/requirements.txt +3 -0
- package/ruff.toml +90 -0
- package/src/camera_detector.py +440 -0
- package/src/main.py +351 -0
- package/src/opencv_utils.py +172 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
All notable changes to this project will be documented in this file.
|
|
2
|
+
|
|
3
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
4
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
|
+
|
|
6
|
+
## [X.X.X] - ???
|
|
7
|
+
|
|
8
|
+
- Initial Release
|
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Contributing
|
package/LICENSE.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023-2024 seydx <dev@seydx.com>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
6
|
+
a copy of this software and associated documentation files (the
|
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
11
|
+
the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be
|
|
14
|
+
included in all copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# @camera.ui/camera-ui-opencv
|
package/logo.png
ADDED
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"displayName": "OpenCV",
|
|
3
|
+
"name": "@camera.ui/camera-ui-opencv",
|
|
4
|
+
"version": "0.0.1",
|
|
5
|
+
"description": "camera.ui opencv plugin",
|
|
6
|
+
"author": "seydx (https://github.com/seydx/camera.ui)",
|
|
7
|
+
"main": "./src/main.py",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"format": "ruff format",
|
|
11
|
+
"install-updates": "npm i --save",
|
|
12
|
+
"lint": "ruff check --fix",
|
|
13
|
+
"update": "updates --update ./",
|
|
14
|
+
"prepublishOnly": "npm i --package-lock-only && npm run format && npm run lint"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"updates": "^16.3.7"
|
|
19
|
+
},
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/seydx/camera.ui/issues"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"camera.ui": ">=0.0.1",
|
|
25
|
+
"node": ">=18.12.0"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/seydx/camera.ui#readme",
|
|
28
|
+
"keywords": [
|
|
29
|
+
"camera-ui-plugin",
|
|
30
|
+
"motion",
|
|
31
|
+
"opencv",
|
|
32
|
+
"detection"
|
|
33
|
+
],
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/seydx/camera.ui.git"
|
|
38
|
+
},
|
|
39
|
+
"camera.ui": {
|
|
40
|
+
"pythonVersion": "3.11",
|
|
41
|
+
"extension": "motionDetection",
|
|
42
|
+
"dependencies": []
|
|
43
|
+
}
|
|
44
|
+
}
|
package/requirements.txt
ADDED
package/ruff.toml
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Exclude a variety of commonly ignored directories.
|
|
2
|
+
exclude = [
|
|
3
|
+
".bzr",
|
|
4
|
+
".direnv",
|
|
5
|
+
".eggs",
|
|
6
|
+
".git",
|
|
7
|
+
".git-rewrite",
|
|
8
|
+
".hg",
|
|
9
|
+
".ipynb_checkpoints",
|
|
10
|
+
".mypy_cache",
|
|
11
|
+
".nox",
|
|
12
|
+
".pants.d",
|
|
13
|
+
".pyenv",
|
|
14
|
+
".pytest_cache",
|
|
15
|
+
".pytype",
|
|
16
|
+
".ruff_cache",
|
|
17
|
+
".svn",
|
|
18
|
+
".tox",
|
|
19
|
+
".venv",
|
|
20
|
+
".vscode",
|
|
21
|
+
"__pypackages__",
|
|
22
|
+
"_build",
|
|
23
|
+
"buck-out",
|
|
24
|
+
"build",
|
|
25
|
+
"dist",
|
|
26
|
+
"node_modules",
|
|
27
|
+
"site-packages",
|
|
28
|
+
"venv",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
# Same as Black.
|
|
32
|
+
line-length = 110
|
|
33
|
+
indent-width = 4
|
|
34
|
+
|
|
35
|
+
# Assume Python 3.8
|
|
36
|
+
target-version = "py39"
|
|
37
|
+
|
|
38
|
+
[lint]
|
|
39
|
+
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.
|
|
40
|
+
# Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or
|
|
41
|
+
# McCabe complexity (`C901`) by default.
|
|
42
|
+
select = [
|
|
43
|
+
# pycodestyle
|
|
44
|
+
"E",
|
|
45
|
+
# Pyflakes
|
|
46
|
+
"F",
|
|
47
|
+
# pyupgrade
|
|
48
|
+
"UP",
|
|
49
|
+
# flake8-bugbear
|
|
50
|
+
"B",
|
|
51
|
+
# flake8-simplify
|
|
52
|
+
"SIM",
|
|
53
|
+
# isort
|
|
54
|
+
"I",
|
|
55
|
+
]
|
|
56
|
+
ignore = ["E501", "B023", "B006"]
|
|
57
|
+
|
|
58
|
+
# Allow fix for all enabled rules (when `--fix`) is provided.
|
|
59
|
+
fixable = ["ALL"]
|
|
60
|
+
unfixable = []
|
|
61
|
+
|
|
62
|
+
# Allow unused variables when underscore-prefixed.
|
|
63
|
+
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
|
|
64
|
+
|
|
65
|
+
[format]
|
|
66
|
+
# Like Black, use double quotes for strings.
|
|
67
|
+
quote-style = "double"
|
|
68
|
+
|
|
69
|
+
# Like Black, indent with spaces, rather than tabs.
|
|
70
|
+
indent-style = "space"
|
|
71
|
+
|
|
72
|
+
# Like Black, respect magic trailing commas.
|
|
73
|
+
skip-magic-trailing-comma = false
|
|
74
|
+
|
|
75
|
+
# Like Black, automatically detect the appropriate line ending.
|
|
76
|
+
line-ending = "auto"
|
|
77
|
+
|
|
78
|
+
# Enable auto-formatting of code examples in docstrings. Markdown,
|
|
79
|
+
# reStructuredText code/literal blocks and doctests are all supported.
|
|
80
|
+
#
|
|
81
|
+
# This is currently disabled by default, but it is planned for this
|
|
82
|
+
# to be opt-out in the future.
|
|
83
|
+
docstring-code-format = true
|
|
84
|
+
|
|
85
|
+
# Set the line length limit used when formatting code snippets in
|
|
86
|
+
# docstrings.
|
|
87
|
+
#
|
|
88
|
+
# This only has an effect when the `docstring-code-format` setting is
|
|
89
|
+
# enabled.
|
|
90
|
+
docstring-code-line-length = "dynamic"
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
4
|
+
from typing import Any, Literal, Optional
|
|
5
|
+
|
|
6
|
+
import cv2
|
|
7
|
+
import numpy as np
|
|
8
|
+
from camera_ui_python_types import (
|
|
9
|
+
CameraDevice,
|
|
10
|
+
CameraStorage,
|
|
11
|
+
Detection,
|
|
12
|
+
PluginAPI,
|
|
13
|
+
PluginLogger,
|
|
14
|
+
)
|
|
15
|
+
from opencv_utils import get_detections, get_detections_bs, get_detections_fd
|
|
16
|
+
|
|
17
|
+
background_tasks = set[asyncio.Task[Any]]()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
defaultArea = 250
|
|
21
|
+
defaultAreaFd = 500
|
|
22
|
+
defaultAreaBs = 500
|
|
23
|
+
|
|
24
|
+
defaultThreshold = 50
|
|
25
|
+
defaultThresholdBs = 200
|
|
26
|
+
|
|
27
|
+
defaultBlur = 9
|
|
28
|
+
defaultDilt = 3
|
|
29
|
+
|
|
30
|
+
defaultReferenceFrameFrequency = 5
|
|
31
|
+
|
|
32
|
+
available_models = ["Frame Difference", "Background Substraction", "Default"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CameraDetector:
|
|
36
|
+
def __init__(self, api: PluginAPI, camera: CameraDevice, logger: PluginLogger) -> None:
|
|
37
|
+
super().__init__()
|
|
38
|
+
|
|
39
|
+
self.api = api
|
|
40
|
+
self.camera = camera
|
|
41
|
+
self.logger = logger
|
|
42
|
+
|
|
43
|
+
self.started = False
|
|
44
|
+
self.closed = False
|
|
45
|
+
self.frame_generation_task = None
|
|
46
|
+
self.executor = None
|
|
47
|
+
|
|
48
|
+
self.camera_storage = self.__create_camera_storage()
|
|
49
|
+
self.camera.on_connected.subscribe(lambda connected: self.start() if connected else self.close())
|
|
50
|
+
|
|
51
|
+
def start(self) -> None:
|
|
52
|
+
if not self.started:
|
|
53
|
+
self.started = True
|
|
54
|
+
self.closed = False
|
|
55
|
+
|
|
56
|
+
self.executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="OpenCV")
|
|
57
|
+
|
|
58
|
+
self.logger.log(
|
|
59
|
+
f"Starting motion detection for camera {self.camera.name} ({self.__detector_model()})..."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
self.frame_generation_task = asyncio.create_task(
|
|
63
|
+
self.__detect(), name=f"MotionDetection-{self.camera.name}"
|
|
64
|
+
)
|
|
65
|
+
background_tasks.add(self.frame_generation_task)
|
|
66
|
+
self.frame_generation_task.add_done_callback(
|
|
67
|
+
lambda _: background_tasks.remove(self.frame_generation_task)
|
|
68
|
+
if self.frame_generation_task in background_tasks
|
|
69
|
+
else None
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def close(self) -> None:
|
|
73
|
+
if self.started and not self.closed:
|
|
74
|
+
self.started = False
|
|
75
|
+
self.closed = True
|
|
76
|
+
|
|
77
|
+
self.logger.log(f"Stopping motion detection for camera {self.camera.name}...")
|
|
78
|
+
|
|
79
|
+
if self.frame_generation_task is not None:
|
|
80
|
+
self.frame_generation_task.cancel()
|
|
81
|
+
self.frame_generation_task = None
|
|
82
|
+
|
|
83
|
+
if self.executor is not None:
|
|
84
|
+
self.executor.shutdown(wait=False)
|
|
85
|
+
self.executor = None
|
|
86
|
+
|
|
87
|
+
def restart(self) -> None:
|
|
88
|
+
self.logger.log(f"Restarting motion detection for camera {self.camera.name}...")
|
|
89
|
+
|
|
90
|
+
self.close()
|
|
91
|
+
|
|
92
|
+
if self.camera.connected:
|
|
93
|
+
self.start()
|
|
94
|
+
|
|
95
|
+
async def __detect(self) -> None:
|
|
96
|
+
try:
|
|
97
|
+
first_frame = None
|
|
98
|
+
last_frame_time = 0
|
|
99
|
+
backSub: Optional[cv2.BackgroundSubtractorMOG2] = None
|
|
100
|
+
|
|
101
|
+
async for frame in self.camera.get_frames():
|
|
102
|
+
if self.closed:
|
|
103
|
+
break
|
|
104
|
+
|
|
105
|
+
frame_image = await frame.to_image({"format": {"to": "gray"}})
|
|
106
|
+
image_array = np.array(frame_image["image"])
|
|
107
|
+
|
|
108
|
+
now = time.time() * 1000
|
|
109
|
+
|
|
110
|
+
detector_model = self.__detector_model()
|
|
111
|
+
|
|
112
|
+
if detector_model == "Frame Difference":
|
|
113
|
+
area = self.camera_storage.values["frame_difference"]["area"]
|
|
114
|
+
reference_frame_frequency = self.camera_storage.values["frame_difference"][
|
|
115
|
+
"reference_frame_frequency"
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
if first_frame is None or (
|
|
119
|
+
reference_frame_frequency
|
|
120
|
+
and now - last_frame_time > (reference_frame_frequency * 1000)
|
|
121
|
+
):
|
|
122
|
+
first_frame = image_array
|
|
123
|
+
last_frame_time = now
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
dets = await asyncio.get_event_loop().run_in_executor(
|
|
127
|
+
self.executor,
|
|
128
|
+
get_detections_fd,
|
|
129
|
+
first_frame,
|
|
130
|
+
image_array,
|
|
131
|
+
area,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
elif detector_model == "Background Substraction":
|
|
135
|
+
if backSub is None:
|
|
136
|
+
backSub = cv2.createBackgroundSubtractorMOG2(varThreshold=18, detectShadows=False)
|
|
137
|
+
|
|
138
|
+
threshold = self.camera_storage.values["background_substraction"]["threshold"]
|
|
139
|
+
area = self.camera_storage.values["background_substraction"]["area"]
|
|
140
|
+
|
|
141
|
+
dets = await asyncio.get_event_loop().run_in_executor(
|
|
142
|
+
self.executor,
|
|
143
|
+
get_detections_bs,
|
|
144
|
+
image_array,
|
|
145
|
+
backSub,
|
|
146
|
+
threshold,
|
|
147
|
+
area,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
else: # default
|
|
151
|
+
blur = self.camera_storage.values["default"]["blur"]
|
|
152
|
+
threshold = self.camera_storage.values["default"]["threshold"]
|
|
153
|
+
area = self.camera_storage.values["default"]["area"]
|
|
154
|
+
reference_frame_frequency = self.camera_storage.values["default"][
|
|
155
|
+
"reference_frame_frequency"
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
image_array = cv2.stackBlur(image_array, (blur, blur))
|
|
159
|
+
|
|
160
|
+
if first_frame is None or (
|
|
161
|
+
reference_frame_frequency
|
|
162
|
+
and now - last_frame_time > (reference_frame_frequency * 1000)
|
|
163
|
+
):
|
|
164
|
+
first_frame = image_array
|
|
165
|
+
last_frame_time = now
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
dets = await asyncio.get_event_loop().run_in_executor(
|
|
169
|
+
self.executor,
|
|
170
|
+
get_detections,
|
|
171
|
+
first_frame,
|
|
172
|
+
image_array,
|
|
173
|
+
threshold,
|
|
174
|
+
area,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
detections: list[Detection] = []
|
|
178
|
+
|
|
179
|
+
for det in dets:
|
|
180
|
+
(x1, y1, x2, y2) = det
|
|
181
|
+
|
|
182
|
+
detection: Detection = {
|
|
183
|
+
"label": "motion",
|
|
184
|
+
"confidence": 1,
|
|
185
|
+
"boundingBox": (x1, y1, x2, y2),
|
|
186
|
+
"inputWidth": frame_image["info"]["width"],
|
|
187
|
+
"inputHeight": frame_image["info"]["height"],
|
|
188
|
+
"origWidth": frame.metadata["origWidth"],
|
|
189
|
+
"origHeight": frame.metadata["origHeight"],
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
detections.append(detection)
|
|
193
|
+
|
|
194
|
+
await self.camera.update_state("motion", {"detections": detections}, frame)
|
|
195
|
+
|
|
196
|
+
except asyncio.CancelledError:
|
|
197
|
+
pass
|
|
198
|
+
except Exception as error:
|
|
199
|
+
self.logger.error(self.camera.name, "Error generating frames", error)
|
|
200
|
+
|
|
201
|
+
def __detector_model(self) -> Literal["Frame Difference", "Background Substraction", "Default"]:
|
|
202
|
+
return self.camera_storage.values["motion_detector"]
|
|
203
|
+
|
|
204
|
+
def __create_camera_storage(self) -> CameraStorage:
|
|
205
|
+
camera_storage = self.api.storage_controller.get_camera_storage(self.camera.id)
|
|
206
|
+
|
|
207
|
+
if camera_storage is not None:
|
|
208
|
+
return camera_storage
|
|
209
|
+
|
|
210
|
+
camera_storage = self.api.storage_controller.create_camera_storage(
|
|
211
|
+
self,
|
|
212
|
+
self.camera.id,
|
|
213
|
+
{
|
|
214
|
+
"motion_detector": {
|
|
215
|
+
"type": "string",
|
|
216
|
+
"key": "motion_detector",
|
|
217
|
+
"title": "Motion Detector",
|
|
218
|
+
"description": "Select the motion detection model to use",
|
|
219
|
+
"group": "Manage",
|
|
220
|
+
"enum": available_models,
|
|
221
|
+
"store": True,
|
|
222
|
+
"defaultValue": "Default",
|
|
223
|
+
"required": True,
|
|
224
|
+
"onSet": self.__change_model,
|
|
225
|
+
},
|
|
226
|
+
"default": {
|
|
227
|
+
"type": "object",
|
|
228
|
+
"key": "default",
|
|
229
|
+
"title": "Default",
|
|
230
|
+
"description": "Settings for the default model",
|
|
231
|
+
"group": "Default",
|
|
232
|
+
"opened": True,
|
|
233
|
+
"hidden": True,
|
|
234
|
+
"properties": {
|
|
235
|
+
"area": {
|
|
236
|
+
"type": "number",
|
|
237
|
+
"key": "area",
|
|
238
|
+
"title": "Area",
|
|
239
|
+
"description": "Minimum area for a motion detection",
|
|
240
|
+
"store": True,
|
|
241
|
+
"defaultValue": defaultArea,
|
|
242
|
+
"minimum": 10,
|
|
243
|
+
"maximum": 1000,
|
|
244
|
+
"step": 1,
|
|
245
|
+
"required": True,
|
|
246
|
+
"onSet": lambda old, new: self.logger.log(
|
|
247
|
+
self.camera.name, f"Default model motion area changed from {old} to {new}"
|
|
248
|
+
),
|
|
249
|
+
},
|
|
250
|
+
"threshold": {
|
|
251
|
+
"type": "number",
|
|
252
|
+
"key": "threshold",
|
|
253
|
+
"title": "Threshold",
|
|
254
|
+
"description": "Threshold for the default model",
|
|
255
|
+
"store": True,
|
|
256
|
+
"defaultValue": defaultThreshold,
|
|
257
|
+
"minimum": 1,
|
|
258
|
+
"maximum": 255,
|
|
259
|
+
"step": 1,
|
|
260
|
+
"required": True,
|
|
261
|
+
"onSet": lambda old, new: self.logger.log(
|
|
262
|
+
self.camera.name, f"Default model threshold changed from {old} to {new}"
|
|
263
|
+
),
|
|
264
|
+
},
|
|
265
|
+
"blur": {
|
|
266
|
+
"type": "number",
|
|
267
|
+
"key": "blur",
|
|
268
|
+
"title": "Blur",
|
|
269
|
+
"description": "Blur for the default model",
|
|
270
|
+
"store": True,
|
|
271
|
+
"defaultValue": defaultBlur,
|
|
272
|
+
"minimum": 1,
|
|
273
|
+
"maximum": 21,
|
|
274
|
+
"step": 1,
|
|
275
|
+
"required": True,
|
|
276
|
+
"onSet": lambda old, new: self.logger.log(
|
|
277
|
+
self.camera.name, f"Default model blur radius changed from {old} to {new}"
|
|
278
|
+
),
|
|
279
|
+
},
|
|
280
|
+
"dilation": {
|
|
281
|
+
"type": "number",
|
|
282
|
+
"key": "dilation",
|
|
283
|
+
"title": "Dilation",
|
|
284
|
+
"description": "Dilation for the default model",
|
|
285
|
+
"store": True,
|
|
286
|
+
"defaultValue": defaultDilt,
|
|
287
|
+
"minimum": 1,
|
|
288
|
+
"maximum": 21,
|
|
289
|
+
"step": 1,
|
|
290
|
+
"required": True,
|
|
291
|
+
"onSet": lambda old, new: self.logger.log(
|
|
292
|
+
self.camera.name, f"Default model dilation size changed from {old} to {new}"
|
|
293
|
+
),
|
|
294
|
+
},
|
|
295
|
+
"reference_frame_frequency": {
|
|
296
|
+
"type": "number",
|
|
297
|
+
"key": "reference_frame_frequency",
|
|
298
|
+
"title": "Reference Frame Frequency",
|
|
299
|
+
"description": "Frequency to update the reference frame",
|
|
300
|
+
"store": True,
|
|
301
|
+
"defaultValue": defaultReferenceFrameFrequency,
|
|
302
|
+
"minimum": 1,
|
|
303
|
+
"maximum": 60,
|
|
304
|
+
"step": 1,
|
|
305
|
+
"required": True,
|
|
306
|
+
"onSet": lambda old, new: self.logger.log(
|
|
307
|
+
self.camera.name,
|
|
308
|
+
f"Default model reference frame frequency changed from {old} to {new}",
|
|
309
|
+
),
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
"background_substraction": {
|
|
314
|
+
"type": "object",
|
|
315
|
+
"key": "background_substraction",
|
|
316
|
+
"title": "Background Substraction",
|
|
317
|
+
"description": "Settings for the background substraction model",
|
|
318
|
+
"group": "Background Substraction",
|
|
319
|
+
"opened": True,
|
|
320
|
+
"hidden": True,
|
|
321
|
+
"properties": {
|
|
322
|
+
"area": {
|
|
323
|
+
"type": "number",
|
|
324
|
+
"key": "area",
|
|
325
|
+
"title": "Area",
|
|
326
|
+
"description": "Minimum area for a motion detection",
|
|
327
|
+
"store": True,
|
|
328
|
+
"defaultValue": defaultAreaBs,
|
|
329
|
+
"minimum": 10,
|
|
330
|
+
"maximum": 1000,
|
|
331
|
+
"step": 1,
|
|
332
|
+
"required": True,
|
|
333
|
+
"onSet": lambda old, new: self.logger.log(
|
|
334
|
+
self.camera.name,
|
|
335
|
+
f"Background substraction model area changed from {old} to {new}",
|
|
336
|
+
),
|
|
337
|
+
},
|
|
338
|
+
"threshold": {
|
|
339
|
+
"type": "number",
|
|
340
|
+
"key": "threshold",
|
|
341
|
+
"title": "Threshold",
|
|
342
|
+
"description": "Threshold for the background substraction",
|
|
343
|
+
"store": True,
|
|
344
|
+
"defaultValue": defaultThresholdBs,
|
|
345
|
+
"minimum": 1,
|
|
346
|
+
"maximum": 255,
|
|
347
|
+
"step": 1,
|
|
348
|
+
"required": True,
|
|
349
|
+
"onSet": lambda old, new: self.logger.log(
|
|
350
|
+
self.camera.name,
|
|
351
|
+
f"Background substraction model threshold changed from {old} to {new}",
|
|
352
|
+
),
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
"frame_difference": {
|
|
357
|
+
"type": "object",
|
|
358
|
+
"key": "frame_difference",
|
|
359
|
+
"title": "Frame Difference",
|
|
360
|
+
"description": "Settings for the frame difference model",
|
|
361
|
+
"group": "Frame Difference",
|
|
362
|
+
"opened": True,
|
|
363
|
+
"hidden": True,
|
|
364
|
+
"properties": {
|
|
365
|
+
"area": {
|
|
366
|
+
"type": "number",
|
|
367
|
+
"key": "area",
|
|
368
|
+
"title": "Area",
|
|
369
|
+
"description": "Minimum area for a motion detection",
|
|
370
|
+
"store": True,
|
|
371
|
+
"defaultValue": defaultAreaFd,
|
|
372
|
+
"minimum": 10,
|
|
373
|
+
"maximum": 1000,
|
|
374
|
+
"step": 1,
|
|
375
|
+
"required": True,
|
|
376
|
+
"onSet": lambda old, new: self.logger.log(
|
|
377
|
+
self.camera.name, f"Frame difference model area changed from {old} to {new}"
|
|
378
|
+
),
|
|
379
|
+
},
|
|
380
|
+
"reference_frame_frequency": {
|
|
381
|
+
"type": "number",
|
|
382
|
+
"key": "reference_frame_frequency",
|
|
383
|
+
"title": "Reference Frame Frequency",
|
|
384
|
+
"description": "Frequency to update the reference frame",
|
|
385
|
+
"store": True,
|
|
386
|
+
"defaultValue": defaultReferenceFrameFrequency,
|
|
387
|
+
"minimum": 1,
|
|
388
|
+
"maximum": 60,
|
|
389
|
+
"step": 1,
|
|
390
|
+
"required": True,
|
|
391
|
+
"onSet": lambda old, new: self.logger.log(
|
|
392
|
+
self.camera.name,
|
|
393
|
+
f"Frae difference model reference frame frequency changed from {old} to {new}",
|
|
394
|
+
),
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
current_model = camera_storage.values["motion_detector"]
|
|
402
|
+
current_model_schema = None
|
|
403
|
+
|
|
404
|
+
if current_model == "Default":
|
|
405
|
+
current_model_schema = camera_storage.get_schema("default")
|
|
406
|
+
elif current_model == "Background Substraction":
|
|
407
|
+
current_model_schema = camera_storage.get_schema("background_substraction")
|
|
408
|
+
elif current_model == "Frame Difference":
|
|
409
|
+
current_model_schema = camera_storage.get_schema("frame_difference")
|
|
410
|
+
|
|
411
|
+
if current_model_schema:
|
|
412
|
+
current_model_schema["hidden"] = False
|
|
413
|
+
|
|
414
|
+
return camera_storage
|
|
415
|
+
|
|
416
|
+
def __change_model(self, old_model: str, new_model: str) -> None:
|
|
417
|
+
self.logger.log(self.camera.name, f"Motion detection model changed from {old_model} to {new_model}")
|
|
418
|
+
|
|
419
|
+
old_schema = None
|
|
420
|
+
new_schema = None
|
|
421
|
+
|
|
422
|
+
if old_model == "Default":
|
|
423
|
+
old_schema = self.camera_storage.get_schema("default")
|
|
424
|
+
elif old_model == "Background Substraction":
|
|
425
|
+
old_schema = self.camera_storage.get_schema("background_substraction")
|
|
426
|
+
elif old_model == "Frame Difference":
|
|
427
|
+
old_schema = self.camera_storage.get_schema("frame_difference")
|
|
428
|
+
|
|
429
|
+
if new_model == "Default":
|
|
430
|
+
new_schema = self.camera_storage.get_schema("default")
|
|
431
|
+
elif new_model == "Background Substraction":
|
|
432
|
+
new_schema = self.camera_storage.get_schema("background_substraction")
|
|
433
|
+
elif new_model == "Frame Difference":
|
|
434
|
+
new_schema = self.camera_storage.get_schema("frame_difference")
|
|
435
|
+
|
|
436
|
+
if old_schema:
|
|
437
|
+
old_schema["hidden"] = True
|
|
438
|
+
|
|
439
|
+
if new_schema:
|
|
440
|
+
new_schema["hidden"] = False
|
package/src/main.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import tempfile
|
|
3
|
+
import time
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
|
+
from typing import Any, Union
|
|
6
|
+
|
|
7
|
+
import cv2
|
|
8
|
+
from camera_detector import (
|
|
9
|
+
CameraDetector,
|
|
10
|
+
defaultArea,
|
|
11
|
+
defaultAreaBs,
|
|
12
|
+
defaultAreaFd,
|
|
13
|
+
defaultBlur,
|
|
14
|
+
defaultReferenceFrameFrequency,
|
|
15
|
+
defaultThreshold,
|
|
16
|
+
defaultThresholdBs,
|
|
17
|
+
)
|
|
18
|
+
from camera_ui_python_types import (
|
|
19
|
+
CameraDevice,
|
|
20
|
+
CameraExtension,
|
|
21
|
+
FormSubmitResponse,
|
|
22
|
+
MotionDetectionPlugin,
|
|
23
|
+
MotionDetectionPluginResponse,
|
|
24
|
+
PluginAPI,
|
|
25
|
+
PluginLogger,
|
|
26
|
+
RootSchema,
|
|
27
|
+
)
|
|
28
|
+
from opencv_utils import get_detections, get_detections_bs, get_detections_fd
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class OpenCV(MotionDetectionPlugin):
|
|
32
|
+
def __init__(self, logger: PluginLogger, api: PluginAPI):
|
|
33
|
+
self.api = api
|
|
34
|
+
self.logger = logger
|
|
35
|
+
self.cameras: dict[str, CameraDevice] = {}
|
|
36
|
+
self.detectors: dict[str, CameraDetector] = {}
|
|
37
|
+
|
|
38
|
+
self.api.on("finishLaunching", self.start)
|
|
39
|
+
self.api.on("shutdown", self.stop)
|
|
40
|
+
|
|
41
|
+
self.api.device_manager.on("cameraSelected", self.camera_selected)
|
|
42
|
+
self.api.device_manager.on("cameraDeselected", self.camera_deselected)
|
|
43
|
+
|
|
44
|
+
async def camera_selected(self, camera: CameraDevice, extension: CameraExtension) -> None:
|
|
45
|
+
self.logger.log(f"New camera added ({extension}): {camera.name}")
|
|
46
|
+
self.cameras[camera.id] = camera
|
|
47
|
+
self.create_detector(camera)
|
|
48
|
+
|
|
49
|
+
def camera_deselected(self, id: str, extension: CameraExtension) -> None:
|
|
50
|
+
camera = self.cameras.get(id)
|
|
51
|
+
|
|
52
|
+
if camera:
|
|
53
|
+
self.logger.log(f"Camera removed ({extension}): {camera.name}")
|
|
54
|
+
self.remove_detector(self.cameras[id])
|
|
55
|
+
del self.cameras[id]
|
|
56
|
+
|
|
57
|
+
def start(self) -> None:
|
|
58
|
+
self.logger.log("Plugin Started")
|
|
59
|
+
|
|
60
|
+
for camera_device in self.cameras.values():
|
|
61
|
+
self.create_detector(camera_device)
|
|
62
|
+
|
|
63
|
+
def stop(self) -> None:
|
|
64
|
+
self.logger.log("Plugin Stopped")
|
|
65
|
+
|
|
66
|
+
detectors_copy = list(self.detectors.values())
|
|
67
|
+
|
|
68
|
+
for detector in detectors_copy:
|
|
69
|
+
self.remove_detector(detector.camera)
|
|
70
|
+
|
|
71
|
+
async def onFormSubmit(self, action_id: str, payload: Any) -> Union[FormSubmitResponse, None]:
|
|
72
|
+
print("Form submitted", action_id, payload)
|
|
73
|
+
|
|
74
|
+
def configure_cameras(self, cameras: list[CameraDevice]) -> None:
|
|
75
|
+
for camera in cameras:
|
|
76
|
+
self.cameras[camera.id] = camera
|
|
77
|
+
|
|
78
|
+
def create_detector(self, camera: CameraDevice) -> None:
|
|
79
|
+
detector = CameraDetector(self.api, camera, self.logger)
|
|
80
|
+
self.detectors[camera.id] = detector
|
|
81
|
+
|
|
82
|
+
def remove_detector(self, camera: CameraDevice) -> None:
|
|
83
|
+
detector = self.detectors[camera.id]
|
|
84
|
+
|
|
85
|
+
if detector:
|
|
86
|
+
detector.close()
|
|
87
|
+
del self.detectors[camera.id]
|
|
88
|
+
|
|
89
|
+
def interfaceSchema(self) -> RootSchema | None:
|
|
90
|
+
return {
|
|
91
|
+
"schema": {
|
|
92
|
+
"motion_detector": {
|
|
93
|
+
"type": "string",
|
|
94
|
+
"key": "motion_detector",
|
|
95
|
+
"title": "Motion Detector",
|
|
96
|
+
"description": "Select the motion detection model to use",
|
|
97
|
+
"enum": ["Frame Difference", "Background Substraction", "Default"],
|
|
98
|
+
"store": False,
|
|
99
|
+
"defaultValue": "Default",
|
|
100
|
+
"required": True,
|
|
101
|
+
},
|
|
102
|
+
"default": {
|
|
103
|
+
"type": "object",
|
|
104
|
+
"key": "default",
|
|
105
|
+
"title": "Default",
|
|
106
|
+
"description": "Settings for the default model",
|
|
107
|
+
"opened": False,
|
|
108
|
+
"properties": {
|
|
109
|
+
"area": {
|
|
110
|
+
"type": "number",
|
|
111
|
+
"key": "area",
|
|
112
|
+
"title": "Area",
|
|
113
|
+
"description": "Minimum area for a motion detection",
|
|
114
|
+
"store": False,
|
|
115
|
+
"defaultValue": 250,
|
|
116
|
+
"minimum": 10,
|
|
117
|
+
"maximum": 1000,
|
|
118
|
+
"step": 1,
|
|
119
|
+
"required": True,
|
|
120
|
+
},
|
|
121
|
+
"threshold": {
|
|
122
|
+
"type": "number",
|
|
123
|
+
"key": "threshold",
|
|
124
|
+
"title": "Threshold",
|
|
125
|
+
"description": "Threshold for the default model",
|
|
126
|
+
"store": False,
|
|
127
|
+
"defaultValue": 50,
|
|
128
|
+
"minimum": 1,
|
|
129
|
+
"maximum": 255,
|
|
130
|
+
"step": 1,
|
|
131
|
+
"required": True,
|
|
132
|
+
},
|
|
133
|
+
"blur": {
|
|
134
|
+
"type": "number",
|
|
135
|
+
"key": "blur",
|
|
136
|
+
"title": "Blur",
|
|
137
|
+
"description": "Blur for the default model",
|
|
138
|
+
"store": False,
|
|
139
|
+
"defaultValue": 9,
|
|
140
|
+
"minimum": 1,
|
|
141
|
+
"maximum": 21,
|
|
142
|
+
"step": 1,
|
|
143
|
+
"required": True,
|
|
144
|
+
},
|
|
145
|
+
"dilation": {
|
|
146
|
+
"type": "number",
|
|
147
|
+
"key": "dilation",
|
|
148
|
+
"title": "Dilation",
|
|
149
|
+
"description": "Dilation for the default model",
|
|
150
|
+
"store": False,
|
|
151
|
+
"defaultValue": 3,
|
|
152
|
+
"minimum": 1,
|
|
153
|
+
"maximum": 21,
|
|
154
|
+
"step": 1,
|
|
155
|
+
"required": True,
|
|
156
|
+
},
|
|
157
|
+
"reference_frame_frequency": {
|
|
158
|
+
"type": "number",
|
|
159
|
+
"key": "reference_frame_frequency",
|
|
160
|
+
"title": "Reference Frame Frequency",
|
|
161
|
+
"description": "Frequency to update the reference frame",
|
|
162
|
+
"store": False,
|
|
163
|
+
"defaultValue": 5,
|
|
164
|
+
"minimum": 1,
|
|
165
|
+
"maximum": 60,
|
|
166
|
+
"step": 1,
|
|
167
|
+
"required": True,
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
"background_substraction": {
|
|
172
|
+
"type": "object",
|
|
173
|
+
"key": "background_substraction",
|
|
174
|
+
"title": "Background Substraction",
|
|
175
|
+
"description": "Settings for the background substraction model",
|
|
176
|
+
"opened": False,
|
|
177
|
+
"properties": {
|
|
178
|
+
"area": {
|
|
179
|
+
"type": "number",
|
|
180
|
+
"key": "area",
|
|
181
|
+
"title": "Area",
|
|
182
|
+
"description": "Minimum area for a motion detection",
|
|
183
|
+
"store": False,
|
|
184
|
+
"defaultValue": 500,
|
|
185
|
+
"minimum": 10,
|
|
186
|
+
"maximum": 1000,
|
|
187
|
+
"step": 1,
|
|
188
|
+
"required": True,
|
|
189
|
+
},
|
|
190
|
+
"threshold": {
|
|
191
|
+
"type": "number",
|
|
192
|
+
"key": "threshold",
|
|
193
|
+
"title": "Threshold",
|
|
194
|
+
"description": "Threshold for the background substraction",
|
|
195
|
+
"store": False,
|
|
196
|
+
"defaultValue": 200,
|
|
197
|
+
"minimum": 1,
|
|
198
|
+
"maximum": 255,
|
|
199
|
+
"step": 1,
|
|
200
|
+
"required": True,
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
"frame_difference": {
|
|
205
|
+
"type": "object",
|
|
206
|
+
"key": "frame_difference",
|
|
207
|
+
"title": "Frame Difference",
|
|
208
|
+
"description": "Settings for the frame difference model",
|
|
209
|
+
"opened": False,
|
|
210
|
+
"properties": {
|
|
211
|
+
"area": {
|
|
212
|
+
"type": "number",
|
|
213
|
+
"key": "area",
|
|
214
|
+
"title": "Area",
|
|
215
|
+
"description": "Minimum area for a motion detection",
|
|
216
|
+
"store": False,
|
|
217
|
+
"defaultValue": 500,
|
|
218
|
+
"minimum": 10,
|
|
219
|
+
"maximum": 1000,
|
|
220
|
+
"step": 1,
|
|
221
|
+
"required": True,
|
|
222
|
+
},
|
|
223
|
+
"reference_frame_frequency": {
|
|
224
|
+
"type": "number",
|
|
225
|
+
"key": "reference_frame_frequency",
|
|
226
|
+
"title": "Reference Frame Frequency",
|
|
227
|
+
"description": "Frequency to update the reference frame",
|
|
228
|
+
"store": False,
|
|
229
|
+
"defaultValue": 5,
|
|
230
|
+
"minimum": 1,
|
|
231
|
+
"maximum": 60,
|
|
232
|
+
"step": 1,
|
|
233
|
+
"required": True,
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async def detectMotion(self, video_path: str, config: dict[str, Any]) -> MotionDetectionPluginResponse:
|
|
241
|
+
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as temp_file:
|
|
242
|
+
output_file = temp_file.name
|
|
243
|
+
|
|
244
|
+
cap = cv2.VideoCapture(video_path)
|
|
245
|
+
executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="OpenCV")
|
|
246
|
+
|
|
247
|
+
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
|
248
|
+
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
249
|
+
fps = int(cap.get(cv2.CAP_PROP_FPS))
|
|
250
|
+
|
|
251
|
+
fourcc = cv2.VideoWriter.fourcc("a", "v", "c", "1")
|
|
252
|
+
out = cv2.VideoWriter(output_file, fourcc, fps, (width, height))
|
|
253
|
+
|
|
254
|
+
first_frame = None
|
|
255
|
+
last_frame_time = 0
|
|
256
|
+
backSub = None
|
|
257
|
+
|
|
258
|
+
detector_model = config.get("motion_detector", "Default")
|
|
259
|
+
|
|
260
|
+
while cap.isOpened():
|
|
261
|
+
ret, frame = cap.read()
|
|
262
|
+
if not ret:
|
|
263
|
+
break
|
|
264
|
+
|
|
265
|
+
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
|
266
|
+
now = time.time() * 1000
|
|
267
|
+
|
|
268
|
+
if detector_model == "Frame Difference":
|
|
269
|
+
area = config.get("frame_difference", {}).get("area", defaultAreaFd)
|
|
270
|
+
reference_frame_frequency = config.get("frame_difference", {}).get(
|
|
271
|
+
"reference_frame_frequency", defaultReferenceFrameFrequency
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
if first_frame is None or (
|
|
275
|
+
reference_frame_frequency and now - last_frame_time > (reference_frame_frequency * 1000)
|
|
276
|
+
):
|
|
277
|
+
first_frame = gray
|
|
278
|
+
last_frame_time = now
|
|
279
|
+
continue
|
|
280
|
+
|
|
281
|
+
dets = await asyncio.get_event_loop().run_in_executor(
|
|
282
|
+
executor,
|
|
283
|
+
get_detections_fd,
|
|
284
|
+
first_frame,
|
|
285
|
+
gray,
|
|
286
|
+
area,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
elif detector_model == "Background Substraction":
|
|
290
|
+
if backSub is None:
|
|
291
|
+
backSub = cv2.createBackgroundSubtractorMOG2(varThreshold=18, detectShadows=False)
|
|
292
|
+
|
|
293
|
+
threshold = config.get("background_substraction", {}).get("threshold", defaultThresholdBs)
|
|
294
|
+
area = config.get("background_substraction", {}).get("area", defaultAreaBs)
|
|
295
|
+
|
|
296
|
+
dets = await asyncio.get_event_loop().run_in_executor(
|
|
297
|
+
executor,
|
|
298
|
+
get_detections_bs,
|
|
299
|
+
gray,
|
|
300
|
+
backSub,
|
|
301
|
+
threshold,
|
|
302
|
+
area,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
else: # default
|
|
306
|
+
blur = config.get("default", {}).get("blur", defaultBlur)
|
|
307
|
+
threshold = config.get("default", {}).get("threshold", defaultThreshold)
|
|
308
|
+
area = config.get("default", {}).get("area", defaultArea)
|
|
309
|
+
reference_frame_frequency = config.get("default", {}).get(
|
|
310
|
+
"reference_frame_frequency", defaultReferenceFrameFrequency
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
gray = cv2.stackBlur(gray, (blur, blur))
|
|
314
|
+
|
|
315
|
+
if first_frame is None or (
|
|
316
|
+
reference_frame_frequency and now - last_frame_time > (reference_frame_frequency * 1000)
|
|
317
|
+
):
|
|
318
|
+
first_frame = gray
|
|
319
|
+
last_frame_time = now
|
|
320
|
+
continue
|
|
321
|
+
|
|
322
|
+
dets = await asyncio.get_event_loop().run_in_executor(
|
|
323
|
+
executor,
|
|
324
|
+
get_detections,
|
|
325
|
+
first_frame,
|
|
326
|
+
gray,
|
|
327
|
+
threshold,
|
|
328
|
+
area,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
for det in dets:
|
|
332
|
+
(x1, y1, x2, y2) = det
|
|
333
|
+
|
|
334
|
+
pt1: cv2.typing.Point = (int(x1), int(y1))
|
|
335
|
+
pt2: cv2.typing.Point = (int(x2), int(y2))
|
|
336
|
+
|
|
337
|
+
cv2.rectangle(frame, pt1, pt2, (0, 255, 0), 2)
|
|
338
|
+
|
|
339
|
+
out.write(frame)
|
|
340
|
+
|
|
341
|
+
cap.release()
|
|
342
|
+
out.release()
|
|
343
|
+
executor.shutdown()
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
"filePath": output_file,
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def __main__():
|
|
351
|
+
return OpenCV
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
from typing import Any, Union, cast
|
|
2
|
+
|
|
3
|
+
import cv2
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
DEFAULT_AREA_THRESHOLD = 500
|
|
7
|
+
DEFAULT_THRESHOLD = 50
|
|
8
|
+
DEFAULT_MASK_KERNEL = np.array((9, 9), dtype=np.uint8)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_mask(
|
|
12
|
+
frame1: np.ndarray[Any, Any],
|
|
13
|
+
frame2: np.ndarray[Any, Any],
|
|
14
|
+
kernel: np.ndarray[Any, Any] = DEFAULT_MASK_KERNEL,
|
|
15
|
+
) -> cv2.typing.MatLike:
|
|
16
|
+
frame_diff = cv2.subtract(frame2, frame1)
|
|
17
|
+
frame_diff = cv2.medianBlur(frame_diff, 3)
|
|
18
|
+
|
|
19
|
+
mask = cv2.adaptiveThreshold(
|
|
20
|
+
frame_diff, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 11, 3
|
|
21
|
+
)
|
|
22
|
+
mask = cv2.medianBlur(mask, 3)
|
|
23
|
+
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=1)
|
|
24
|
+
|
|
25
|
+
return mask
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_motion_mask(
|
|
29
|
+
fg_mask: cv2.typing.MatLike,
|
|
30
|
+
threshold: int = DEFAULT_THRESHOLD,
|
|
31
|
+
) -> cv2.typing.MatLike:
|
|
32
|
+
thresh = cv2.threshold(fg_mask, threshold, 255, cv2.THRESH_BINARY)[1]
|
|
33
|
+
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
|
|
34
|
+
|
|
35
|
+
motion_mask = cv2.medianBlur(thresh, 3)
|
|
36
|
+
motion_mask = cv2.morphologyEx(motion_mask, cv2.MORPH_OPEN, kernel, iterations=1)
|
|
37
|
+
motion_mask = cv2.morphologyEx(motion_mask, cv2.MORPH_CLOSE, kernel, iterations=1)
|
|
38
|
+
|
|
39
|
+
return motion_mask
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_contour_detections(
|
|
43
|
+
mask: cv2.typing.MatLike, area_threshold: int = DEFAULT_AREA_THRESHOLD
|
|
44
|
+
) -> list[tuple[float, float, float, float]]:
|
|
45
|
+
cnts, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
46
|
+
detections: list[tuple[float, float, float, float]] = []
|
|
47
|
+
for cnt in cnts:
|
|
48
|
+
x, y, w, h = cv2.boundingRect(cnt)
|
|
49
|
+
area = w * h
|
|
50
|
+
if area > area_threshold:
|
|
51
|
+
detections.append((x, y, x + w, y + h))
|
|
52
|
+
return detections
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_in_bbox(point: tuple[float, float], bbox: tuple[float, float, float, float]) -> bool:
|
|
56
|
+
return point[0] >= bbox[0] and point[0] <= bbox[2] and point[1] >= bbox[1] and point[1] <= bbox[3]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def intersect(bbox: tuple[float, float, float, float], bbox_: tuple[float, float, float, float]) -> bool:
|
|
60
|
+
for i in range(int(len(bbox) / 2)):
|
|
61
|
+
for j in range(int(len(bbox) / 2)):
|
|
62
|
+
# Check if one of the corner of bbox inside bbox_
|
|
63
|
+
if is_in_bbox((bbox[2 * i], bbox[2 * j + 1]), bbox_):
|
|
64
|
+
return True
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def good_intersect(bbox: tuple[float, float, float, float], bbox_: tuple[float, float, float, float]) -> bool:
|
|
69
|
+
return bbox[0] < bbox_[2] and bbox[2] > bbox_[0] and bbox[1] < bbox_[3] and bbox[3] > bbox_[1]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def calculate_iou(
|
|
73
|
+
bbox1: tuple[float, float, float, float], bbox2: tuple[float, float, float, float]
|
|
74
|
+
) -> float:
|
|
75
|
+
x1 = max(bbox1[0], bbox2[0])
|
|
76
|
+
y1 = max(bbox1[1], bbox2[1])
|
|
77
|
+
x2 = min(bbox1[2], bbox2[2])
|
|
78
|
+
y2 = min(bbox1[3], bbox2[3])
|
|
79
|
+
|
|
80
|
+
intersection_area = max(0, x2 - x1) * max(0, y2 - y1)
|
|
81
|
+
|
|
82
|
+
bbox1_area = (bbox1[2] - bbox1[0]) * (bbox1[3] - bbox1[1])
|
|
83
|
+
bbox2_area = (bbox2[2] - bbox2[0]) * (bbox2[3] - bbox2[1])
|
|
84
|
+
|
|
85
|
+
union_area = bbox1_area + bbox2_area - intersection_area
|
|
86
|
+
|
|
87
|
+
iou = intersection_area / union_area
|
|
88
|
+
|
|
89
|
+
return iou
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def merge_bboxes(
|
|
93
|
+
bboxes: list[tuple[float, float, float, float]],
|
|
94
|
+
delta_x: float = 0.4,
|
|
95
|
+
delta_y: float = 0.4,
|
|
96
|
+
iou_threshold: float = 0,
|
|
97
|
+
) -> list[tuple[float, float, float, float]]:
|
|
98
|
+
# Sort bboxes by ymin
|
|
99
|
+
bboxes = sorted(bboxes, key=lambda x: x[1])
|
|
100
|
+
|
|
101
|
+
merged_bboxes: list[tuple[float, float, float, float]] = []
|
|
102
|
+
|
|
103
|
+
for bbox in bboxes:
|
|
104
|
+
is_merged = False
|
|
105
|
+
|
|
106
|
+
for i in range(len(merged_bboxes)):
|
|
107
|
+
merged_bbox = merged_bboxes[i]
|
|
108
|
+
|
|
109
|
+
# Compute the bboxes with a margin
|
|
110
|
+
bmargin: tuple[float, float, float, float] = (
|
|
111
|
+
bbox[0] - (bbox[2] - bbox[0]) * delta_x,
|
|
112
|
+
bbox[1] - (bbox[3] - bbox[1]) * delta_y,
|
|
113
|
+
bbox[2] + (bbox[2] - bbox[0]) * delta_x,
|
|
114
|
+
bbox[3] + (bbox[3] - bbox[1]) * delta_y,
|
|
115
|
+
)
|
|
116
|
+
merged_bmargin: tuple[float, float, float, float] = (
|
|
117
|
+
merged_bbox[0] - (merged_bbox[2] - merged_bbox[0]) * delta_x,
|
|
118
|
+
merged_bbox[1] - (merged_bbox[3] - merged_bbox[1]) * delta_y,
|
|
119
|
+
merged_bbox[2] + (merged_bbox[2] - merged_bbox[0]) * delta_x,
|
|
120
|
+
merged_bbox[3] + (merged_bbox[3] - merged_bbox[1]) * delta_y,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Check if bboxes with margin have an intersection or if IoU is above the threshold
|
|
124
|
+
if intersect(bmargin, merged_bmargin) or calculate_iou(bbox, merged_bbox) >= iou_threshold:
|
|
125
|
+
merged_bboxes[i] = (
|
|
126
|
+
min(bbox[0], merged_bbox[0]),
|
|
127
|
+
min(bbox[1], merged_bbox[1]),
|
|
128
|
+
max(bbox[2], merged_bbox[2]),
|
|
129
|
+
max(bbox[3], merged_bbox[3]),
|
|
130
|
+
)
|
|
131
|
+
is_merged = True
|
|
132
|
+
break
|
|
133
|
+
|
|
134
|
+
if not is_merged:
|
|
135
|
+
merged_bboxes.append(bbox)
|
|
136
|
+
|
|
137
|
+
return merged_bboxes
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_detections(
|
|
141
|
+
blurred_frame1: cv2.typing.MatLike,
|
|
142
|
+
blurred_frame2: cv2.typing.MatLike,
|
|
143
|
+
threshold: int = DEFAULT_THRESHOLD,
|
|
144
|
+
area_threshold: int = DEFAULT_AREA_THRESHOLD,
|
|
145
|
+
) -> list[tuple[float, float, float, float]]:
|
|
146
|
+
delta_frame = cv2.absdiff(blurred_frame1, blurred_frame2)
|
|
147
|
+
thresh_frame = cv2.threshold(delta_frame, threshold, 255, cv2.THRESH_BINARY)[1]
|
|
148
|
+
thresh_frame = cast(cv2.typing.MatLike, cv2.dilate(thresh_frame, None, iterations=2)) # type: ignore
|
|
149
|
+
detections = get_contour_detections(thresh_frame, area_threshold)
|
|
150
|
+
return merge_bboxes(detections)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def get_detections_fd(
|
|
154
|
+
frame1: np.ndarray[Any, Any],
|
|
155
|
+
frame2: np.ndarray[Any, Any],
|
|
156
|
+
area_threshold: int = DEFAULT_AREA_THRESHOLD,
|
|
157
|
+
) -> list[tuple[float, float, float, float]]:
|
|
158
|
+
mask = get_mask(frame1, frame2, DEFAULT_MASK_KERNEL)
|
|
159
|
+
detections = get_contour_detections(mask, area_threshold)
|
|
160
|
+
return merge_bboxes(detections)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def get_detections_bs(
|
|
164
|
+
frame: np.ndarray[Any, Any],
|
|
165
|
+
backSub: Union[cv2.BackgroundSubtractorMOG2, cv2.BackgroundSubtractorKNN],
|
|
166
|
+
threshold: int = DEFAULT_THRESHOLD,
|
|
167
|
+
area_threshold: int = DEFAULT_AREA_THRESHOLD,
|
|
168
|
+
) -> list[tuple[float, float, float, float]]:
|
|
169
|
+
fg_mask = backSub.apply(frame)
|
|
170
|
+
motion_mask = get_motion_mask(fg_mask, threshold=threshold)
|
|
171
|
+
detections = get_contour_detections(motion_mask, area_threshold)
|
|
172
|
+
return merge_bboxes(detections)
|