DVR-Scan-Headless 1.8__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.
Files changed (45) hide show
  1. dvr_scan/LICENSE +47 -0
  2. dvr_scan/__init__.py +55 -0
  3. dvr_scan/__main__.py +80 -0
  4. dvr_scan/app/__init__.py +10 -0
  5. dvr_scan/app/__main__.py +218 -0
  6. dvr_scan/app/about_window.py +137 -0
  7. dvr_scan/app/application.py +1700 -0
  8. dvr_scan/app/common.py +31 -0
  9. dvr_scan/app/region_editor.py +1416 -0
  10. dvr_scan/app/scan_window.py +258 -0
  11. dvr_scan/app/widgets.py +213 -0
  12. dvr_scan/cli.py +511 -0
  13. dvr_scan/config.py +607 -0
  14. dvr_scan/controller.py +215 -0
  15. dvr_scan/detector.py +124 -0
  16. dvr_scan/docs/404.html +1 -0
  17. dvr_scan/docs/assets/javascripts/bundle.88dd0f4e.min.js +15 -0
  18. dvr_scan/docs/assets/stylesheets/main.6f8fc17f.min.css +1 -0
  19. dvr_scan/docs/assets/stylesheets/palette.06af60db.min.css +1 -0
  20. dvr_scan/docs/changelog.html +1 -0
  21. dvr_scan/docs/docs.html +73 -0
  22. dvr_scan/docs/faq.html +13 -0
  23. dvr_scan/docs/guide.html +1 -0
  24. dvr_scan/docs/index.html +5 -0
  25. dvr_scan/docs/resources.html +30 -0
  26. dvr_scan/docs/style.min.css +1 -0
  27. dvr_scan/dvr-scan-logo.png +0 -0
  28. dvr_scan/dvr-scan.ico +0 -0
  29. dvr_scan/dvr-scan.png +0 -0
  30. dvr_scan/opencv_loader.py +37 -0
  31. dvr_scan/overlays.py +251 -0
  32. dvr_scan/platform.py +236 -0
  33. dvr_scan/region.py +65 -0
  34. dvr_scan/scanner.py +1303 -0
  35. dvr_scan/shared/__init__.py +287 -0
  36. dvr_scan/shared/cli.py +291 -0
  37. dvr_scan/shared/settings.py +72 -0
  38. dvr_scan/subtractor.py +163 -0
  39. dvr_scan/video_joiner.py +188 -0
  40. dvr_scan_headless-1.8.dist-info/METADATA +119 -0
  41. dvr_scan_headless-1.8.dist-info/RECORD +45 -0
  42. dvr_scan_headless-1.8.dist-info/WHEEL +5 -0
  43. dvr_scan_headless-1.8.dist-info/entry_points.txt +2 -0
  44. dvr_scan_headless-1.8.dist-info/licenses/LICENSE +47 -0
  45. dvr_scan_headless-1.8.dist-info/top_level.txt +1 -0
@@ -0,0 +1,258 @@
1
+ #
2
+ # DVR-Scan: Video Motion Event Detection & Extraction Tool
3
+ # --------------------------------------------------------------
4
+ # [ Site: https://www.dvr-scan.com/ ]
5
+ # [ Repo: https://github.com/Breakthrough/DVR-Scan ]
6
+ #
7
+ # Copyright (C) 2024 Brandon Castellano <http://www.bcastell.com>.
8
+ # DVR-Scan is licensed under the BSD 2-Clause License; see the included
9
+ # LICENSE file, or visit one of the above pages for details.
10
+ #
11
+
12
+ import threading
13
+ import time
14
+ import tkinter as tk
15
+ import tkinter.messagebox as messagebox
16
+ import tkinter.ttk as ttk
17
+ import traceback
18
+ import typing as ty
19
+ from logging import getLogger
20
+
21
+ from scenedetect import FrameTimecode
22
+ from tqdm import tqdm
23
+
24
+ from dvr_scan.platform import open_path
25
+ from dvr_scan.shared import ScanSettings, init_scanner
26
+
27
+ TITLE = "Scanning..."
28
+
29
+ logger = getLogger("dvr_scan")
30
+
31
+ UI_UPDATE_RATE_MS = 50
32
+ STATS_UPDATE_RATE_NS = 250 * (1000 * 1000)
33
+
34
+
35
+ class ScanWindow:
36
+ def __init__(
37
+ self, root: tk.Tk, settings: ScanSettings, on_destroyed: ty.Callable[[], None], padding: int
38
+ ):
39
+ self._root = tk.Toplevel(master=root)
40
+ self._root.withdraw()
41
+ self._root.title(TITLE)
42
+ self._root.resizable(True, True)
43
+ self._scanner = init_scanner(settings)
44
+ self._scanner.set_callbacks(
45
+ scan_started=self._on_scan_started,
46
+ processed_frame=self._on_processed_frame,
47
+ )
48
+ self._open_on_completion = (
49
+ settings.get("output-dir") if settings.get("open-output-dir") else None
50
+ )
51
+
52
+ self._root.bind("<<Shutdown>>", self.stop)
53
+ self._root.protocol("WM_DELETE_WINDOW", self.prompt_stop)
54
+
55
+ # Widgets
56
+ self._scan_thread = threading.Thread(target=self._do_scan)
57
+ self._on_destroyed = on_destroyed
58
+
59
+ self._progress = tk.IntVar(self._root, value=0)
60
+ self._progress_bar = ttk.Progressbar(self._root, variable=self._progress, maximum=10)
61
+ self._elapsed_label = tk.Label(self._root)
62
+ self._remaining_label = tk.Label(self._root)
63
+ self._stop_button = tk.Button(self._root, text="Stop", command=self.prompt_stop)
64
+ self._close_button = tk.Button(
65
+ self._root, text="Close", command=self._destroy, state=tk.DISABLED
66
+ )
67
+ self._events_label = tk.Label(self._root)
68
+ self._processed_label = tk.Label(self._root)
69
+ self._speed_label = tk.Label(self._root)
70
+ self._total_label = tk.Label(self._root)
71
+
72
+ # Layout
73
+ width = self._root.winfo_reqwidth()
74
+ self._root.columnconfigure(0, weight=1, minsize=width / 2)
75
+ self._root.columnconfigure(1, weight=1, minsize=width / 2)
76
+ self._root.rowconfigure(0, weight=1)
77
+ self._root.rowconfigure(1, weight=1)
78
+ self._root.rowconfigure(2, weight=1)
79
+ self._root.rowconfigure(3, weight=32)
80
+ self._root.rowconfigure(4, weight=1)
81
+ self._root.rowconfigure(5, weight=1)
82
+ self._root.rowconfigure(6, weight=1)
83
+ self._root.rowconfigure(7, weight=2)
84
+ self._root.minsize(
85
+ width=2 * self._root.winfo_reqwidth(), height=self._root.winfo_reqheight()
86
+ )
87
+
88
+ self._events_label.grid(row=0, column=0, columnspan=2, sticky=tk.NSEW, pady=(padding, 0))
89
+ self._progress_bar.grid(sticky=tk.NSEW, row=1, columnspan=2, pady=padding, padx=padding)
90
+ self._elapsed_label.grid(row=5, column=1, sticky=tk.NE, padx=padding)
91
+ self._remaining_label.grid(row=2, column=0, columnspan=2, sticky=tk.NSEW)
92
+ self._processed_label.grid(row=4, column=0, sticky=tk.NW, padx=padding)
93
+ self._total_label.grid(row=5, column=0, sticky=tk.NW, padx=padding)
94
+ self._speed_label.grid(row=4, column=1, sticky=tk.NE, padx=padding)
95
+ frame = tk.Frame(self._root)
96
+ frame.rowconfigure(0, weight=1)
97
+ frame.columnconfigure(0, weight=1)
98
+ frame.columnconfigure(1, weight=1)
99
+ self._stop_button = tk.Button(frame, text="Stop", command=self.prompt_stop)
100
+ self._close_button = tk.Button(
101
+ frame, text="Close", command=self._destroy, state=tk.DISABLED
102
+ )
103
+ self._stop_button.grid(row=0, column=0, pady=padding, sticky=tk.NSEW, padx=padding)
104
+ self._close_button.grid(row=0, column=1, pady=padding, sticky=tk.NSEW, padx=padding)
105
+ frame.grid(row=7, column=0, columnspan=2, sticky=tk.NSEW)
106
+
107
+ self._scan_started = threading.Event()
108
+ self._scan_finished = threading.Event()
109
+ self._scan_exception = None
110
+ self._start_time = 0.0
111
+ self._last_stats_update_ns = 0
112
+ self._expected_num_frames = 0
113
+ self._num_events = 0
114
+ self._frames_processed = 0
115
+ self._elapsed = "00:00"
116
+ self._remaining = "N/A"
117
+ self._rate = "N/A"
118
+ self._padding = padding
119
+
120
+ def _update(self):
121
+ if self._scan_finished.is_set():
122
+ if self._scan_exception:
123
+ formatted_exception = "\n".join(traceback.format_exception(self._scan_exception))
124
+ logger.critical(f"error during scan:\n{formatted_exception}")
125
+ messagebox.showerror(
126
+ "Scan Error",
127
+ "Error during scanning. See log messages for more info."
128
+ f"\nSummary: {self._scan_exception}",
129
+ parent=self._root,
130
+ )
131
+ self._destroy()
132
+ return
133
+ else:
134
+ logger.debug("scan complete")
135
+ self._progress.set(self._expected_num_frames)
136
+ self._elapsed_label["text"] = f"Elapsed: {self._elapsed}"
137
+ self._remaining_label["text"] = "\n"
138
+ self._stop_button["state"] = tk.DISABLED
139
+ self._close_button["state"] = tk.NORMAL
140
+ self._processed_label["text"] = f"Processed: {self._expected_num_frames} frames"
141
+ self._speed_label["text"] = f"Rate: {self._rate} FPS"
142
+ return False
143
+
144
+ if self._scan_started.is_set():
145
+ self._progress_bar = ttk.Progressbar(
146
+ self._root,
147
+ variable=self._progress,
148
+ maximum=self._expected_num_frames,
149
+ )
150
+ self._progress_bar.grid(
151
+ sticky=tk.NSEW,
152
+ row=1,
153
+ columnspan=2,
154
+ padx=self._padding,
155
+ pady=self._padding,
156
+ )
157
+ self._scan_started.clear()
158
+
159
+ # Frames processed is updated from the worker thread, but we don't care about stale values.
160
+ self._progress.set(self._frames_processed)
161
+ self._events_label["text"] = f"Events Found: {self._num_events}"
162
+ self._elapsed_label["text"] = f"Elapsed: {self._elapsed}"
163
+ self._remaining_label["text"] = f"Time Remaining:\n{self._remaining}"
164
+ self._processed_label["text"] = f"Processed: {self._frames_processed:4} frames"
165
+ self._total_label["text"] = f"Total: {self._expected_num_frames} frames"
166
+ self._speed_label["text"] = f"Rate: {self._rate} FPS"
167
+ return True
168
+
169
+ def _update_continuous(self):
170
+ if self._update():
171
+ self._root.after(UI_UPDATE_RATE_MS, self._update_continuous)
172
+
173
+ def _destroy(self):
174
+ logger.info("Stopping current scan.")
175
+ self._root.grab_release()
176
+ self._root.destroy()
177
+ self._on_destroyed()
178
+ # TODO: If there's a long running task this could hang the GUI, see if we can schedule this
179
+ # to occur in the parent.
180
+ self.stop()
181
+ logger.debug("root destroyed")
182
+
183
+ def prompt_stop(self):
184
+ if self._scanner.is_stopped():
185
+ return
186
+ if messagebox.askyesno(
187
+ title="Stop scan?",
188
+ message="Are you sure you want to stop the current scan?",
189
+ icon=messagebox.WARNING,
190
+ parent=self._root,
191
+ ):
192
+ self._destroy()
193
+
194
+ def stop(self):
195
+ if not self._scanner.is_stopped():
196
+ logger.debug("stopping scan thread")
197
+ self._scanner.stop()
198
+ self._scan_thread.join()
199
+
200
+ def show(self):
201
+ logger.debug("starting scan thread showing scan window.")
202
+ self._scan_thread.start()
203
+ self._root.deiconify()
204
+ self._root.focus()
205
+ self._root.grab_set()
206
+ self._root.after(10, self._update_continuous)
207
+ self._root.wait_window()
208
+
209
+ def _on_scan_started(self, num_frames: int):
210
+ # We've now seeked to the start of the motion detection area and are processing frames.
211
+ # We also have an *estimate* of how many frames we'll be processing.
212
+ self._expected_num_frames = num_frames
213
+ self._scan_started.set()
214
+ # We set start time here to avoid including seek time in the overall FPS calculation.
215
+ self._start_time = time.time()
216
+
217
+ def _on_processed_frame(self, progress_bar: tqdm, num_events: int):
218
+ self._num_events = num_events
219
+ self._frames_processed += 1
220
+ curr = time.time_ns()
221
+ # Update internal state used for UI labels using the progress bar in the scanner.
222
+ if (curr - self._last_stats_update_ns) > STATS_UPDATE_RATE_NS:
223
+ self._last_stats_update_ns = curr
224
+ format_dict = progress_bar.format_dict
225
+ format_dict.update(bar_format="{elapsed} {remaining} {rate_fmt}")
226
+ values = tqdm.format_meter(**format_dict).strip().split(" ")
227
+ # We have to filter out empty entries after we split by spaces because the meter format
228
+ # adds padding spaces to align numbers.
229
+ values = list(filter(bool, values))
230
+ if len(values) == 4:
231
+ (self._elapsed, self._remaining, self._rate, _unit) = values
232
+
233
+ def _do_scan(self):
234
+ # We'll handle any errors below in the main Tkinter thread.
235
+ try:
236
+ result = self._scanner.scan()
237
+ self._frames_processed = result.num_frames
238
+ except Exception as ex: # noqa: E722
239
+ self._scan_exception = ex
240
+ finally:
241
+ self._scan_finished.set()
242
+ elapsed = time.time() - self._start_time
243
+ self._elapsed = (
244
+ FrameTimecode(elapsed, 1000.0)
245
+ .get_timecode(precision=0, use_rounding=True)
246
+ .removeprefix("00:")
247
+ )
248
+ # TODO: This rate will always be a tiny bit slower than the one shown in stdout since we
249
+ # use different timers. This can be fixed if we add a callback to the MotionScanner that
250
+ # we can use to access the progress bar the scanner created.
251
+ self._rate = (
252
+ "%.2f" % (float(self._frames_processed) / elapsed) if self._frames_processed else "N/A"
253
+ )
254
+ # Open the output folder on a successful scan. On error, or if the user stopped the scan,
255
+ # we don't open the window.
256
+ if self._open_on_completion and not self._scanner.is_stopped() and not self._scan_exception:
257
+ logger.debug("scan complete, opening output folder")
258
+ open_path(self._open_on_completion)
@@ -0,0 +1,213 @@
1
+ #
2
+ # DVR-Scan: Video Motion Event Detection & Extraction Tool
3
+ # --------------------------------------------------------------
4
+ # [ Site: https://www.dvr-scan.com/ ]
5
+ # [ Repo: https://github.com/Breakthrough/DVR-Scan ]
6
+ #
7
+ # Copyright (C) 2024 Brandon Castellano <http://www.bcastell.com>.
8
+ # DVR-Scan is licensed under the BSD 2-Clause License; see the included
9
+ # LICENSE file, or visit one of the above pages for details.
10
+ #
11
+
12
+ import tkinter as tk
13
+ import tkinter.colorchooser
14
+ import tkinter.ttk as ttk
15
+ import typing as ty
16
+
17
+ from scenedetect import FrameTimecode
18
+
19
+ from dvr_scan.config import RGBValue
20
+
21
+ PADDING = 8
22
+ SETTING_INPUT_WIDTH = 12
23
+
24
+
25
+ class ColorPicker:
26
+ def __init__(
27
+ self, root: tk.Widget, initial_color: str = "#FF0000", padding=PADDING, sticky=tk.EW
28
+ ):
29
+ self._frame = tk.Frame(root)
30
+ self._frame.columnconfigure(0, weight=1)
31
+ self._frame.columnconfigure(1, weight=1)
32
+ self._frame.rowconfigure(0, weight=1)
33
+ self._color = tk.Label(self._frame, bg=initial_color, width=2)
34
+ self._color.grid(row=0, column=0, sticky=sticky, padx=(0, padding))
35
+
36
+ def set_color():
37
+ color = tkinter.colorchooser.askcolor(self._color["bg"])
38
+ if color and color[1]:
39
+ self._color["bg"] = color[1]
40
+
41
+ self._set_button = tk.Button(self._frame, text="Set Color", command=set_color)
42
+ self._set_button.grid(row=0, column=1, sticky=sticky, padx=(padding, 0))
43
+
44
+ self.grid = self._frame.grid
45
+
46
+ def __setitem__(self, key, item):
47
+ self._set_button[key] = item
48
+
49
+ def __getitem__(self, key):
50
+ return self._set_button[key]
51
+
52
+ def get(self) -> ty.Tuple[int, int, int]:
53
+ return RGBValue(f"0x{self._color['bg'][1:]}").value
54
+
55
+ def set(self, newval: ty.Tuple[int, int, int]):
56
+ color_code = (newval[0] << 16) + (newval[1] << 8) + newval[2]
57
+ self._color["bg"] = f"#{str(RGBValue(color_code))[2:]}"
58
+
59
+
60
+ class TimecodeEntry:
61
+ def __init__(
62
+ self,
63
+ root: tk.Widget,
64
+ value: str,
65
+ width: int = SETTING_INPUT_WIDTH,
66
+ ):
67
+ value = str(value)
68
+ self._value = tk.StringVar(value=value)
69
+ self._last_valid = value
70
+ self._last_focus = value
71
+ self._entry = ttk.Entry(
72
+ root,
73
+ textvariable=self._value,
74
+ width=width,
75
+ validate="all",
76
+ validatecommand=(root.register(self._validate), "%P", "%V"),
77
+ )
78
+ self._entry.bind("<Return>", lambda _: self._refresh(clear_selection=True))
79
+ self._entry.bind("<Escape>", lambda _: self._cancel())
80
+
81
+ # Expose grid method from underlying widget
82
+ self.grid = self._entry.grid
83
+ self.grid_remove = self._entry.grid_remove
84
+
85
+ def __setitem__(self, key, item):
86
+ self._entry[key] = item
87
+
88
+ def __getitem__(self, key):
89
+ return self._entry[key]
90
+
91
+ def get(self) -> str:
92
+ return self._value.get()
93
+
94
+ def set(self, newval: str):
95
+ self._value.set(str(newval))
96
+ self._refresh(clear_selection=True)
97
+
98
+ def _refresh(self, clear_selection=False):
99
+ try:
100
+ newval = self._value.get()
101
+
102
+ if newval.isdigit():
103
+ newval = float(newval)
104
+ # TODO: There's a bug here in PySceneDetect when there are more than 3 :'s.
105
+ if isinstance(newval, str) and newval.count(":") >= 3:
106
+ raise ValueError()
107
+ validated = FrameTimecode(newval, 1000.0).get_timecode()
108
+ self._value.set(validated)
109
+ self._last_valid = validated
110
+ self._last_focus = validated
111
+ except (TypeError, ValueError):
112
+ self._value.set(self._last_valid)
113
+ if clear_selection:
114
+ self._entry.selection_clear()
115
+ self._entry.icursor(0)
116
+
117
+ def _cancel(self):
118
+ self._value.set(self._last_focus)
119
+ self._refresh(clear_selection=True)
120
+
121
+ def _validate(self, newval: str, event: str):
122
+ if event == "focusin":
123
+ self._last_valid = newval
124
+ self._last_focus = newval
125
+ elif event == "focusout":
126
+ self._refresh()
127
+
128
+ return True
129
+
130
+
131
+ class Spinbox:
132
+ def __init__(
133
+ self,
134
+ root: tk.Widget,
135
+ value: str,
136
+ from_: float,
137
+ to: float,
138
+ increment: float,
139
+ width: int = SETTING_INPUT_WIDTH,
140
+ format: str = "%g",
141
+ suffix: ty.Optional[str] = None,
142
+ convert: ty.Callable[[str], ty.Any] = float,
143
+ **kwargs,
144
+ ):
145
+ self._value = tk.StringVar(value=value)
146
+ self._format = format
147
+ self._suffix = suffix
148
+ if self._suffix:
149
+ self._format += self._suffix
150
+ self._last_valid = value
151
+ self._last_focus = value
152
+ self._from = from_
153
+ self._to = to
154
+ self._spinbox = ttk.Spinbox(
155
+ root,
156
+ from_=from_,
157
+ to=to,
158
+ increment=increment,
159
+ textvariable=self._value,
160
+ width=width,
161
+ format=self._format,
162
+ validate="all",
163
+ validatecommand=(root.register(self._validate), "%P", "%V"),
164
+ **kwargs,
165
+ )
166
+ self._spinbox.bind("<Return>", lambda _: self._refresh(clear_selection=True))
167
+ self._spinbox.bind("<Escape>", lambda _: self._cancel())
168
+ self._convert = convert
169
+
170
+ # Expose grid method from underlying widget
171
+ self.grid = self._spinbox.grid
172
+
173
+ def __setitem__(self, key, item):
174
+ self._spinbox[key] = item
175
+
176
+ def __getitem__(self, key):
177
+ return self._spinbox[key]
178
+
179
+ def get(self) -> str:
180
+ return self._value.get()
181
+
182
+ def set(self, newval: str):
183
+ self._value.set(str(newval))
184
+ self._refresh(clear_selection=True)
185
+
186
+ def _refresh(self, clear_selection=False):
187
+ try:
188
+ newval = self._value.get()
189
+ if self._suffix:
190
+ newval = newval.removesuffix(self._suffix)
191
+ newval = max(self._from, min(self._to, self._convert(newval)))
192
+ newval = self._format % newval
193
+ self._value.set(newval)
194
+ self._last_valid = newval
195
+ self._last_focus = newval
196
+ except ValueError:
197
+ self._value.set(self._last_valid)
198
+ if clear_selection:
199
+ self._spinbox.selection_clear()
200
+ self._spinbox.icursor(0)
201
+
202
+ def _cancel(self):
203
+ self._value.set(self._last_focus)
204
+ self._refresh(clear_selection=True)
205
+
206
+ def _validate(self, newval: str, event: str):
207
+ if event == "focusin":
208
+ self._last_valid = newval
209
+ self._last_focus = newval
210
+ elif event == "focusout":
211
+ self._refresh()
212
+
213
+ return True