@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 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
@@ -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
@@ -0,0 +1,3 @@
1
+ {
2
+ "schema": {}
3
+ }
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
+ }
@@ -0,0 +1,6 @@
1
+ # Python version: 3.11.9
2
+ # Server version: 0.0.6
3
+ # Dependency version: 0.1.64
4
+ numpy==1.26.4
5
+ opencv-python==4.10.0.84
6
+ camera-ui-python-types==0.1.75
@@ -0,0 +1,3 @@
1
+ numpy==1.26.4
2
+ opencv-python==4.10.0.84
3
+ camera-ui-python-types==0.1.76
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)