homesec 1.2.1__py3-none-any.whl → 1.2.3__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.
- homesec/app.py +5 -14
- homesec/cli.py +5 -4
- homesec/config/__init__.py +8 -1
- homesec/config/loader.py +17 -2
- homesec/config/validation.py +99 -6
- homesec/interfaces.py +2 -2
- homesec/maintenance/cleanup_clips.py +17 -4
- homesec/models/__init__.py +3 -23
- homesec/models/clip.py +1 -1
- homesec/models/config.py +10 -259
- homesec/models/enums.py +8 -0
- homesec/models/events.py +1 -1
- homesec/models/filter.py +3 -21
- homesec/models/vlm.py +11 -20
- homesec/pipeline/__init__.py +1 -2
- homesec/pipeline/core.py +9 -10
- homesec/plugins/alert_policies/__init__.py +5 -5
- homesec/plugins/alert_policies/default.py +21 -2
- homesec/plugins/analyzers/__init__.py +1 -3
- homesec/plugins/analyzers/openai.py +20 -13
- homesec/plugins/filters/__init__.py +1 -2
- homesec/plugins/filters/yolo.py +25 -5
- homesec/plugins/notifiers/__init__.py +1 -6
- homesec/plugins/notifiers/mqtt.py +21 -1
- homesec/plugins/notifiers/sendgrid_email.py +52 -1
- homesec/plugins/registry.py +27 -0
- homesec/plugins/sources/__init__.py +4 -4
- homesec/plugins/sources/ftp.py +1 -1
- homesec/plugins/sources/local_folder.py +1 -1
- homesec/plugins/sources/rtsp.py +2 -2
- homesec/plugins/storage/__init__.py +1 -9
- homesec/plugins/storage/dropbox.py +13 -1
- homesec/plugins/storage/local.py +8 -1
- homesec/repository/clip_repository.py +1 -1
- homesec/sources/__init__.py +3 -4
- homesec/sources/ftp.py +95 -2
- homesec/sources/local_folder.py +27 -2
- homesec/sources/rtsp/__init__.py +5 -0
- homesec/sources/rtsp/clock.py +18 -0
- homesec/sources/rtsp/core.py +1424 -0
- homesec/sources/rtsp/frame_pipeline.py +325 -0
- homesec/sources/rtsp/hardware.py +143 -0
- homesec/sources/rtsp/motion.py +94 -0
- homesec/sources/rtsp/recorder.py +180 -0
- homesec/sources/rtsp/utils.py +35 -0
- {homesec-1.2.1.dist-info → homesec-1.2.3.dist-info}/METADATA +13 -16
- homesec-1.2.3.dist-info/RECORD +73 -0
- homesec/models/source.py +0 -81
- homesec/pipeline/alert_policy.py +0 -5
- homesec/sources/rtsp.py +0 -1304
- homesec-1.2.1.dist-info/RECORD +0 -68
- /homesec/{plugins/notifiers → notifiers}/multiplex.py +0 -0
- {homesec-1.2.1.dist-info → homesec-1.2.3.dist-info}/WHEEL +0 -0
- {homesec-1.2.1.dist-info → homesec-1.2.3.dist-info}/entry_points.txt +0 -0
- {homesec-1.2.1.dist-info → homesec-1.2.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import subprocess
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from queue import Empty, Full, Queue
|
|
8
|
+
from threading import Event, Thread
|
|
9
|
+
from typing import Any, Protocol
|
|
10
|
+
|
|
11
|
+
from homesec.sources.rtsp.clock import Clock
|
|
12
|
+
from homesec.sources.rtsp.hardware import HardwareAccelConfig
|
|
13
|
+
from homesec.sources.rtsp.utils import _format_cmd, _is_timeout_option_error, _redact_rtsp_url
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FramePipeline(Protocol):
|
|
19
|
+
"""Decode RTSP frames for motion detection.
|
|
20
|
+
|
|
21
|
+
Implementations should assume `start()` is called only after `stop()`,
|
|
22
|
+
and `stop()` must be safe to call when already stopped.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
frame_width: int | None
|
|
26
|
+
frame_height: int | None
|
|
27
|
+
|
|
28
|
+
def start(self, rtsp_url: str) -> None: ...
|
|
29
|
+
|
|
30
|
+
def stop(self) -> None: ...
|
|
31
|
+
|
|
32
|
+
def read_frame(self, timeout_s: float) -> bytes | None: ...
|
|
33
|
+
|
|
34
|
+
def is_running(self) -> bool: ...
|
|
35
|
+
|
|
36
|
+
def exit_code(self) -> int | None: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class FfmpegFramePipeline:
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
*,
|
|
43
|
+
output_dir: Path,
|
|
44
|
+
frame_queue_size: int,
|
|
45
|
+
rtsp_connect_timeout_s: float,
|
|
46
|
+
rtsp_io_timeout_s: float,
|
|
47
|
+
ffmpeg_flags: list[str],
|
|
48
|
+
hwaccel_config: HardwareAccelConfig,
|
|
49
|
+
hwaccel_failed: bool,
|
|
50
|
+
on_frame: Callable[[], None],
|
|
51
|
+
clock: Clock,
|
|
52
|
+
) -> None:
|
|
53
|
+
self._output_dir = output_dir
|
|
54
|
+
self._frame_queue_size = frame_queue_size
|
|
55
|
+
self._rtsp_connect_timeout_s = rtsp_connect_timeout_s
|
|
56
|
+
self._rtsp_io_timeout_s = rtsp_io_timeout_s
|
|
57
|
+
self._ffmpeg_flags = ffmpeg_flags
|
|
58
|
+
self._hwaccel_config = hwaccel_config
|
|
59
|
+
self._hwaccel_failed = hwaccel_failed
|
|
60
|
+
self._on_frame = on_frame
|
|
61
|
+
self._clock = clock
|
|
62
|
+
|
|
63
|
+
self._process: subprocess.Popen[bytes] | None = None
|
|
64
|
+
self._stderr: Any | None = None
|
|
65
|
+
self._reader_thread: Thread | None = None
|
|
66
|
+
self._reader_stop: Event | None = None
|
|
67
|
+
self._frame_queue: Queue[bytes] | None = None
|
|
68
|
+
self.frame_width: int | None = None
|
|
69
|
+
self.frame_height: int | None = None
|
|
70
|
+
self._frame_size: int | None = None
|
|
71
|
+
|
|
72
|
+
def start(self, rtsp_url: str) -> None:
|
|
73
|
+
(
|
|
74
|
+
self._process,
|
|
75
|
+
self._stderr,
|
|
76
|
+
self.frame_width,
|
|
77
|
+
self.frame_height,
|
|
78
|
+
) = self._get_frame_pipe(rtsp_url)
|
|
79
|
+
self._frame_size = int(self.frame_width) * int(self.frame_height)
|
|
80
|
+
self._frame_queue = Queue(maxsize=self._frame_queue_size)
|
|
81
|
+
self._reader_stop = Event()
|
|
82
|
+
|
|
83
|
+
process = self._process
|
|
84
|
+
frame_size = self._frame_size
|
|
85
|
+
frame_queue = self._frame_queue
|
|
86
|
+
stop_event = self._reader_stop
|
|
87
|
+
|
|
88
|
+
def reader_loop() -> None:
|
|
89
|
+
stdout = process.stdout if process else None
|
|
90
|
+
if stdout is None:
|
|
91
|
+
logger.error("Frame pipeline stdout is None")
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
while not stop_event.is_set():
|
|
95
|
+
try:
|
|
96
|
+
raw = stdout.read(frame_size)
|
|
97
|
+
except Exception:
|
|
98
|
+
logger.exception("Error reading from frame pipeline stdout")
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
if not raw or len(raw) != frame_size:
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
self._on_frame()
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
frame_queue.put_nowait(raw)
|
|
108
|
+
except Full:
|
|
109
|
+
try:
|
|
110
|
+
_ = frame_queue.get_nowait()
|
|
111
|
+
except Empty:
|
|
112
|
+
pass
|
|
113
|
+
try:
|
|
114
|
+
frame_queue.put_nowait(raw)
|
|
115
|
+
except Full:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
self._reader_thread = Thread(target=reader_loop, name="frame-reader", daemon=True)
|
|
119
|
+
self._reader_thread.start()
|
|
120
|
+
|
|
121
|
+
def stop(self) -> None:
|
|
122
|
+
if self._reader_stop is not None:
|
|
123
|
+
self._reader_stop.set()
|
|
124
|
+
|
|
125
|
+
if self._process is not None:
|
|
126
|
+
try:
|
|
127
|
+
self._stop_process(self._process, "Frame pipeline", terminate_timeout_s=2)
|
|
128
|
+
except Exception:
|
|
129
|
+
logger.exception("Error stopping frame pipeline process")
|
|
130
|
+
self._process = None
|
|
131
|
+
|
|
132
|
+
if self._reader_thread is not None:
|
|
133
|
+
try:
|
|
134
|
+
self._reader_thread.join(timeout=5)
|
|
135
|
+
except Exception:
|
|
136
|
+
logger.exception("Error joining frame reader thread")
|
|
137
|
+
self._reader_thread = None
|
|
138
|
+
|
|
139
|
+
self._reader_stop = None
|
|
140
|
+
|
|
141
|
+
if self._stderr is not None:
|
|
142
|
+
try:
|
|
143
|
+
self._stderr.close()
|
|
144
|
+
except Exception:
|
|
145
|
+
logger.exception("Error closing frame pipeline stderr log")
|
|
146
|
+
self._stderr = None
|
|
147
|
+
|
|
148
|
+
self._frame_queue = None
|
|
149
|
+
self.frame_width = None
|
|
150
|
+
self.frame_height = None
|
|
151
|
+
self._frame_size = None
|
|
152
|
+
|
|
153
|
+
def read_frame(self, timeout_s: float) -> bytes | None:
|
|
154
|
+
if not self._frame_queue:
|
|
155
|
+
return None
|
|
156
|
+
try:
|
|
157
|
+
return self._frame_queue.get(timeout=float(timeout_s))
|
|
158
|
+
except Empty:
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
def is_running(self) -> bool:
|
|
162
|
+
return self._process is not None and self._process.poll() is None
|
|
163
|
+
|
|
164
|
+
def exit_code(self) -> int | None:
|
|
165
|
+
if self._process is None:
|
|
166
|
+
return None
|
|
167
|
+
return self._process.poll()
|
|
168
|
+
|
|
169
|
+
def _get_frame_pipe(self, rtsp_url: str) -> tuple[subprocess.Popen[bytes], Any, int, int]:
|
|
170
|
+
detect_width, detect_height = 320, 240
|
|
171
|
+
|
|
172
|
+
stderr_log = self._output_dir / "frame_pipeline.log"
|
|
173
|
+
self._output_dir.mkdir(parents=True, exist_ok=True)
|
|
174
|
+
|
|
175
|
+
def _read_tail(path: Path, max_bytes: int = 4000) -> str:
|
|
176
|
+
try:
|
|
177
|
+
data = path.read_bytes()
|
|
178
|
+
except Exception as exc:
|
|
179
|
+
logger.warning("Failed to read stderr tail: %s", exc, exc_info=True)
|
|
180
|
+
return ""
|
|
181
|
+
if len(data) <= max_bytes:
|
|
182
|
+
return data.decode(errors="replace")
|
|
183
|
+
return data[-max_bytes:].decode(errors="replace")
|
|
184
|
+
|
|
185
|
+
cmd = ["ffmpeg"]
|
|
186
|
+
|
|
187
|
+
# 1. Global Flags (Hardware Acceleration)
|
|
188
|
+
if self._hwaccel_config.is_available and not self._hwaccel_failed:
|
|
189
|
+
hwaccel = self._hwaccel_config.hwaccel
|
|
190
|
+
if hwaccel is not None:
|
|
191
|
+
cmd.extend(["-hwaccel", hwaccel])
|
|
192
|
+
if self._hwaccel_config.hwaccel_device:
|
|
193
|
+
cmd.extend(["-hwaccel_device", self._hwaccel_config.hwaccel_device])
|
|
194
|
+
elif self._hwaccel_failed:
|
|
195
|
+
logger.info("Hardware acceleration disabled due to previous failures")
|
|
196
|
+
|
|
197
|
+
# 2. Global Flags (Robustness & Logging)
|
|
198
|
+
user_flags = self._ffmpeg_flags
|
|
199
|
+
|
|
200
|
+
has_loglevel = any(x == "-loglevel" for x in user_flags)
|
|
201
|
+
if not has_loglevel:
|
|
202
|
+
cmd.extend(["-loglevel", "warning"])
|
|
203
|
+
|
|
204
|
+
has_fflags = any(x == "-fflags" for x in user_flags)
|
|
205
|
+
if not has_fflags:
|
|
206
|
+
cmd.extend(["-fflags", "+genpts+igndts"])
|
|
207
|
+
|
|
208
|
+
# Add all user flags to global scope.
|
|
209
|
+
cmd.extend(user_flags)
|
|
210
|
+
|
|
211
|
+
has_stimeout = any(x == "-stimeout" for x in user_flags)
|
|
212
|
+
has_rw_timeout = any(x == "-rw_timeout" for x in user_flags)
|
|
213
|
+
|
|
214
|
+
timeout_us_connect = str(int(max(0.1, self._rtsp_connect_timeout_s) * 1_000_000))
|
|
215
|
+
timeout_us_io = str(int(max(0.1, self._rtsp_io_timeout_s) * 1_000_000))
|
|
216
|
+
|
|
217
|
+
base_input_prefix = ["-rtsp_transport", "tcp"]
|
|
218
|
+
base_input_args = [
|
|
219
|
+
"-i",
|
|
220
|
+
rtsp_url,
|
|
221
|
+
"-f",
|
|
222
|
+
"rawvideo",
|
|
223
|
+
"-pix_fmt",
|
|
224
|
+
"gray",
|
|
225
|
+
"-vf",
|
|
226
|
+
f"fps=10,scale={detect_width}:{detect_height}",
|
|
227
|
+
"-an",
|
|
228
|
+
"-",
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
timeout_args: list[str] = []
|
|
232
|
+
if not has_stimeout and self._rtsp_connect_timeout_s > 0:
|
|
233
|
+
timeout_args.extend(["-stimeout", timeout_us_connect])
|
|
234
|
+
if not has_rw_timeout and self._rtsp_io_timeout_s > 0:
|
|
235
|
+
timeout_args.extend(["-rw_timeout", timeout_us_io])
|
|
236
|
+
|
|
237
|
+
attempts: list[tuple[str, list[str]]] = []
|
|
238
|
+
if timeout_args:
|
|
239
|
+
attempts.append(("timeouts", base_input_prefix + timeout_args + base_input_args))
|
|
240
|
+
attempts.append(("no_timeouts", base_input_prefix + base_input_args))
|
|
241
|
+
|
|
242
|
+
process: subprocess.Popen[bytes] | None = None
|
|
243
|
+
stderr_file: Any | None = None
|
|
244
|
+
|
|
245
|
+
for label, extra_args in attempts:
|
|
246
|
+
cmd_attempt = list(cmd) + extra_args
|
|
247
|
+
logger.debug("Starting frame pipeline (%s), logging to: %s", label, stderr_log)
|
|
248
|
+
safe_cmd = list(cmd_attempt)
|
|
249
|
+
try:
|
|
250
|
+
idx = safe_cmd.index("-i")
|
|
251
|
+
safe_cmd[idx + 1] = _redact_rtsp_url(str(safe_cmd[idx + 1]))
|
|
252
|
+
except Exception as exc:
|
|
253
|
+
logger.warning(
|
|
254
|
+
"Failed to redact frame pipeline RTSP URL: %s",
|
|
255
|
+
exc,
|
|
256
|
+
exc_info=True,
|
|
257
|
+
)
|
|
258
|
+
logger.debug("Frame pipeline ffmpeg (%s): %s", label, _format_cmd(safe_cmd))
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
stderr_file = open(stderr_log, "w")
|
|
262
|
+
process = subprocess.Popen(
|
|
263
|
+
cmd_attempt,
|
|
264
|
+
stdout=subprocess.PIPE,
|
|
265
|
+
stderr=stderr_file,
|
|
266
|
+
bufsize=detect_width * detect_height,
|
|
267
|
+
)
|
|
268
|
+
except Exception:
|
|
269
|
+
try:
|
|
270
|
+
if stderr_file:
|
|
271
|
+
stderr_file.close()
|
|
272
|
+
except Exception as exc:
|
|
273
|
+
logger.warning("Failed to close stderr log: %s", exc, exc_info=True)
|
|
274
|
+
logger.exception("Failed to start frame pipeline subprocess (%s)", label)
|
|
275
|
+
continue
|
|
276
|
+
|
|
277
|
+
self._clock.sleep(1)
|
|
278
|
+
if process.poll() is None:
|
|
279
|
+
return process, stderr_file, detect_width, detect_height
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
stderr_file.close()
|
|
283
|
+
except Exception as exc:
|
|
284
|
+
logger.warning("Failed to close stderr log: %s", exc, exc_info=True)
|
|
285
|
+
stderr_tail = _read_tail(stderr_log)
|
|
286
|
+
timeout_option_error = (
|
|
287
|
+
label == "timeouts" and bool(stderr_tail) and _is_timeout_option_error(stderr_tail)
|
|
288
|
+
)
|
|
289
|
+
if timeout_option_error:
|
|
290
|
+
logger.warning(
|
|
291
|
+
"Frame pipeline died immediately (%s, exit code: %s); timeout options unsupported",
|
|
292
|
+
label,
|
|
293
|
+
process.returncode,
|
|
294
|
+
)
|
|
295
|
+
if stderr_tail:
|
|
296
|
+
logger.warning("Frame pipeline stderr tail (%s):\n%s", label, stderr_tail)
|
|
297
|
+
else:
|
|
298
|
+
logger.error(
|
|
299
|
+
"Frame pipeline died immediately (%s, exit code: %s)",
|
|
300
|
+
label,
|
|
301
|
+
process.returncode,
|
|
302
|
+
)
|
|
303
|
+
if stderr_tail:
|
|
304
|
+
logger.error("Frame pipeline stderr tail (%s):\n%s", label, stderr_tail)
|
|
305
|
+
process = None
|
|
306
|
+
stderr_file = None
|
|
307
|
+
continue
|
|
308
|
+
|
|
309
|
+
raise RuntimeError("Frame pipeline failed to start")
|
|
310
|
+
|
|
311
|
+
def _stop_process(
|
|
312
|
+
self, proc: subprocess.Popen[bytes], name: str, terminate_timeout_s: float
|
|
313
|
+
) -> None:
|
|
314
|
+
if proc.poll() is not None:
|
|
315
|
+
return
|
|
316
|
+
try:
|
|
317
|
+
proc.terminate()
|
|
318
|
+
proc.wait(timeout=terminate_timeout_s)
|
|
319
|
+
except subprocess.TimeoutExpired:
|
|
320
|
+
logger.warning("%s did not terminate, killing (PID: %s)", name, proc.pid)
|
|
321
|
+
proc.kill()
|
|
322
|
+
try:
|
|
323
|
+
proc.wait(timeout=2)
|
|
324
|
+
except Exception:
|
|
325
|
+
logger.exception("Failed waiting after kill for %s (PID: %s)", name, proc.pid)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import platform
|
|
5
|
+
import subprocess
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class HardwareAccelConfig:
|
|
14
|
+
"""Configuration for hardware-accelerated video decoding."""
|
|
15
|
+
|
|
16
|
+
hwaccel: str | None
|
|
17
|
+
hwaccel_device: str | None = None
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def is_available(self) -> bool:
|
|
21
|
+
"""Check if hardware acceleration is available."""
|
|
22
|
+
return self.hwaccel is not None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class HardwareAccelDetector:
|
|
26
|
+
"""Detect available hardware acceleration options for ffmpeg."""
|
|
27
|
+
|
|
28
|
+
@staticmethod
|
|
29
|
+
def detect(rtsp_url: str) -> HardwareAccelConfig:
|
|
30
|
+
"""Detect the best available hardware acceleration method."""
|
|
31
|
+
if Path("/dev/dri/renderD128").exists():
|
|
32
|
+
if HardwareAccelDetector._test_hwaccel("vaapi"):
|
|
33
|
+
config = HardwareAccelConfig(
|
|
34
|
+
hwaccel="vaapi",
|
|
35
|
+
hwaccel_device="/dev/dri/renderD128",
|
|
36
|
+
)
|
|
37
|
+
if HardwareAccelDetector._test_decode(rtsp_url, config):
|
|
38
|
+
return config
|
|
39
|
+
logger.warning("VAAPI detected but failed to decode stream - disabling")
|
|
40
|
+
|
|
41
|
+
if HardwareAccelDetector._check_nvidia():
|
|
42
|
+
if HardwareAccelDetector._test_hwaccel("cuda"):
|
|
43
|
+
config = HardwareAccelConfig(hwaccel="cuda")
|
|
44
|
+
if HardwareAccelDetector._test_decode(rtsp_url, config):
|
|
45
|
+
return config
|
|
46
|
+
logger.warning("CUDA detected but failed to decode stream - disabling")
|
|
47
|
+
|
|
48
|
+
if platform.system() == "Darwin":
|
|
49
|
+
if HardwareAccelDetector._test_hwaccel("videotoolbox"):
|
|
50
|
+
config = HardwareAccelConfig(hwaccel="videotoolbox")
|
|
51
|
+
if HardwareAccelDetector._test_decode(rtsp_url, config):
|
|
52
|
+
return config
|
|
53
|
+
logger.warning("VideoToolbox detected but failed to decode stream - disabling")
|
|
54
|
+
|
|
55
|
+
if HardwareAccelDetector._test_hwaccel("qsv"):
|
|
56
|
+
config = HardwareAccelConfig(hwaccel="qsv")
|
|
57
|
+
if HardwareAccelDetector._test_decode(rtsp_url, config):
|
|
58
|
+
return config
|
|
59
|
+
logger.warning("QSV detected but failed to decode stream - disabling")
|
|
60
|
+
|
|
61
|
+
logger.info("Using software decoding (no working hardware acceleration found)")
|
|
62
|
+
return HardwareAccelConfig(hwaccel=None)
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def _test_hwaccel(method: str) -> bool:
|
|
66
|
+
"""Test if a hardware acceleration method is available in ffmpeg."""
|
|
67
|
+
try:
|
|
68
|
+
result = subprocess.run(
|
|
69
|
+
["ffmpeg", "-hwaccels"],
|
|
70
|
+
capture_output=True,
|
|
71
|
+
text=True,
|
|
72
|
+
timeout=2,
|
|
73
|
+
check=False,
|
|
74
|
+
)
|
|
75
|
+
return method in result.stdout
|
|
76
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def _test_decode(rtsp_url: str, config: HardwareAccelConfig) -> bool:
|
|
81
|
+
"""Test if hardware acceleration works by decoding a few frames."""
|
|
82
|
+
cmd = ["ffmpeg"]
|
|
83
|
+
|
|
84
|
+
if config.hwaccel:
|
|
85
|
+
cmd.extend(["-hwaccel", config.hwaccel])
|
|
86
|
+
if config.hwaccel_device:
|
|
87
|
+
cmd.extend(["-hwaccel_device", config.hwaccel_device])
|
|
88
|
+
|
|
89
|
+
cmd.extend(
|
|
90
|
+
[
|
|
91
|
+
"-rtsp_transport",
|
|
92
|
+
"tcp",
|
|
93
|
+
"-i",
|
|
94
|
+
rtsp_url,
|
|
95
|
+
"-frames:v",
|
|
96
|
+
"5",
|
|
97
|
+
"-f",
|
|
98
|
+
"null",
|
|
99
|
+
"-",
|
|
100
|
+
]
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
result = subprocess.run(
|
|
105
|
+
cmd,
|
|
106
|
+
capture_output=True,
|
|
107
|
+
timeout=10,
|
|
108
|
+
text=True,
|
|
109
|
+
check=False,
|
|
110
|
+
)
|
|
111
|
+
if result.returncode == 0:
|
|
112
|
+
return True
|
|
113
|
+
if any(
|
|
114
|
+
err in result.stderr
|
|
115
|
+
for err in (
|
|
116
|
+
"No VA display found",
|
|
117
|
+
"Device creation failed",
|
|
118
|
+
"No device available for decoder",
|
|
119
|
+
"Failed to initialise VAAPI",
|
|
120
|
+
"Cannot load",
|
|
121
|
+
)
|
|
122
|
+
):
|
|
123
|
+
return False
|
|
124
|
+
return result.returncode == 0
|
|
125
|
+
except subprocess.TimeoutExpired:
|
|
126
|
+
return True
|
|
127
|
+
except Exception as exc:
|
|
128
|
+
logger.warning("VAAPI check failed: %s", exc, exc_info=True)
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
@staticmethod
|
|
132
|
+
def _check_nvidia() -> bool:
|
|
133
|
+
"""Check if NVIDIA GPU is available."""
|
|
134
|
+
try:
|
|
135
|
+
subprocess.run(
|
|
136
|
+
["nvidia-smi"],
|
|
137
|
+
capture_output=True,
|
|
138
|
+
check=True,
|
|
139
|
+
timeout=2,
|
|
140
|
+
)
|
|
141
|
+
return True
|
|
142
|
+
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
|
|
143
|
+
return False
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import cast
|
|
5
|
+
|
|
6
|
+
import cv2
|
|
7
|
+
import numpy as np
|
|
8
|
+
import numpy.typing as npt
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MotionDetector:
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
*,
|
|
17
|
+
pixel_threshold: int,
|
|
18
|
+
min_changed_pct: float,
|
|
19
|
+
blur_kernel: int,
|
|
20
|
+
debug: bool,
|
|
21
|
+
) -> None:
|
|
22
|
+
self._pixel_threshold = int(pixel_threshold)
|
|
23
|
+
self._min_changed_pct = float(min_changed_pct)
|
|
24
|
+
self._blur_kernel = int(blur_kernel)
|
|
25
|
+
self._debug = bool(debug)
|
|
26
|
+
|
|
27
|
+
self._prev_frame: npt.NDArray[np.uint8] | None = None
|
|
28
|
+
self._last_changed_pct = 0.0
|
|
29
|
+
self._last_changed_pixels = 0
|
|
30
|
+
self._debug_frame_count = 0
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def last_changed_pct(self) -> float:
|
|
34
|
+
return self._last_changed_pct
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def last_changed_pixels(self) -> int:
|
|
38
|
+
return self._last_changed_pixels
|
|
39
|
+
|
|
40
|
+
def reset(self) -> None:
|
|
41
|
+
self._prev_frame = None
|
|
42
|
+
self._last_changed_pct = 0.0
|
|
43
|
+
self._last_changed_pixels = 0
|
|
44
|
+
self._debug_frame_count = 0
|
|
45
|
+
|
|
46
|
+
def detect(self, frame: npt.NDArray[np.uint8], *, threshold: float | None = None) -> bool:
|
|
47
|
+
if frame.ndim == 3:
|
|
48
|
+
gray = cast(npt.NDArray[np.uint8], cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY))
|
|
49
|
+
else:
|
|
50
|
+
gray = frame
|
|
51
|
+
|
|
52
|
+
if self._blur_kernel > 1:
|
|
53
|
+
gray = cast(
|
|
54
|
+
npt.NDArray[np.uint8],
|
|
55
|
+
cv2.GaussianBlur(gray, (self._blur_kernel, self._blur_kernel), 0),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if self._prev_frame is None:
|
|
59
|
+
self._prev_frame = gray
|
|
60
|
+
self._last_changed_pct = 0.0
|
|
61
|
+
self._last_changed_pixels = 0
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
diff = cv2.absdiff(self._prev_frame, gray)
|
|
65
|
+
_, mask = cv2.threshold(diff, self._pixel_threshold, 255, cv2.THRESH_BINARY)
|
|
66
|
+
changed_pixels = int(cv2.countNonZero(mask))
|
|
67
|
+
|
|
68
|
+
total_pixels = int(gray.shape[0]) * int(gray.shape[1])
|
|
69
|
+
changed_pct = (changed_pixels / total_pixels * 100.0) if total_pixels else 0.0
|
|
70
|
+
|
|
71
|
+
self._prev_frame = gray
|
|
72
|
+
self._last_changed_pct = changed_pct
|
|
73
|
+
self._last_changed_pixels = changed_pixels
|
|
74
|
+
|
|
75
|
+
if threshold is None:
|
|
76
|
+
threshold = self._min_changed_pct
|
|
77
|
+
if threshold < 0:
|
|
78
|
+
threshold = 0.0
|
|
79
|
+
|
|
80
|
+
motion = changed_pct >= float(threshold)
|
|
81
|
+
|
|
82
|
+
if self._debug:
|
|
83
|
+
self._debug_frame_count += 1
|
|
84
|
+
if self._debug_frame_count % 100 == 0:
|
|
85
|
+
logger.debug(
|
|
86
|
+
"Motion check: changed_pct=%.3f%% changed_px=%s pixel_threshold=%s min_changed_pct=%.3f%% blur=%s",
|
|
87
|
+
changed_pct,
|
|
88
|
+
changed_pixels,
|
|
89
|
+
self._pixel_threshold,
|
|
90
|
+
self._min_changed_pct,
|
|
91
|
+
self._blur_kernel,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return motion
|