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,1700 @@
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 copy
13
+ import tkinter as tk
14
+ import tkinter.filedialog
15
+ import tkinter.messagebox
16
+ import tkinter.ttk as ttk
17
+ import typing as ty
18
+ import webbrowser
19
+ from datetime import datetime
20
+ from logging import getLogger
21
+ from pathlib import Path
22
+ from tempfile import NamedTemporaryFile
23
+
24
+ from scenedetect import AVAILABLE_BACKENDS, FrameTimecode, VideoOpenFailure, open_video
25
+
26
+ from dvr_scan.app.about_window import AboutWindow
27
+ from dvr_scan.app.common import register_icon
28
+ from dvr_scan.app.region_editor import RegionEditor
29
+ from dvr_scan.app.scan_window import ScanWindow
30
+ from dvr_scan.app.widgets import ColorPicker, Spinbox, TimecodeEntry
31
+ from dvr_scan.config import (
32
+ CHOICE_MAP,
33
+ CONFIG_MAP,
34
+ USER_CONFIG_FILE_PATH,
35
+ ConfigLoadFailure,
36
+ ConfigRegistry,
37
+ )
38
+ from dvr_scan.platform import open_path
39
+ from dvr_scan.scanner import OutputMode, Point
40
+ from dvr_scan.shared import ScanSettings, logfile_path
41
+ from dvr_scan.subtractor import SubtractorCudaMOG2
42
+ from dvr_scan.video_joiner import BackendUnavailable
43
+
44
+ WINDOW_TITLE = "DVR-Scan"
45
+ PADDING = 8
46
+ SETTING_INPUT_WIDTH = 12
47
+ LONG_SETTING_INPUT_WIDTH = 72
48
+ PATH_INPUT_WIDTH = 32
49
+ MAX_KERNEL_SIZE = 21
50
+ MAX_DURATION = 120.0
51
+ DURATION_INCREMENT = 0.1
52
+ DURATION_FORMAT = "%.1fs"
53
+ MIN_WINDOW_WIDTH = 128
54
+ MIN_WINDOW_HEIGHT = 128
55
+ LARGE_BUTTON_WIDTH = 40
56
+ MAX_THRESHOLD = 255.0
57
+ MAX_DOWNSCALE_FACTOR = 128
58
+ NO_REGIONS_SPECIFIED_TEXT = "No Region(s) Specified"
59
+ EXPAND_HORIZONTAL = tk.EW
60
+
61
+ logger = getLogger("dvr_scan")
62
+
63
+
64
+ # TODO: Allow this to be sorted by columns.
65
+ # TODO: Should we have a default sort method when bulk adding videos?
66
+ class InputArea:
67
+ def __init__(self, root: tk.Widget):
68
+ self._root = root
69
+ self._region_editor: ty.Optional[RegionEditor] = None
70
+ root.rowconfigure(0, pad=PADDING, weight=1)
71
+ root.rowconfigure(1, pad=PADDING)
72
+ root.rowconfigure(2, pad=PADDING)
73
+ root.rowconfigure(3, pad=PADDING)
74
+ root.rowconfigure(4, pad=PADDING)
75
+ root.columnconfigure(0, weight=1)
76
+ root.columnconfigure(1, weight=1)
77
+ root.columnconfigure(2, weight=1)
78
+ root.columnconfigure(3, weight=1)
79
+ root.columnconfigure(4, weight=1)
80
+ root.columnconfigure(5, weight=1)
81
+ root.columnconfigure(6, weight=12, minsize=0)
82
+
83
+ frame = tk.Frame(root)
84
+ frame.rowconfigure(0, weight=1)
85
+ frame.columnconfigure(0, weight=1)
86
+ frame.columnconfigure(1, weight=1)
87
+
88
+ self._videos = ttk.Treeview(
89
+ frame,
90
+ columns=("duration", "framerate", "resolution", "path", "date"),
91
+ )
92
+
93
+ scroll_horizontal = ttk.Scrollbar(frame, orient=tk.HORIZONTAL, command=self._videos.xview)
94
+ scroll_vertical = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=self._videos.yview)
95
+ scroll_horizontal.grid(row=1, column=0, columnspan=6, sticky="NEW")
96
+ scroll_vertical.grid(row=0, column=6, rowspan=6, sticky="NSW")
97
+ self._videos.configure(
98
+ xscrollcommand=scroll_horizontal.set, yscrollcommand=scroll_vertical.set
99
+ )
100
+
101
+ self._videos.grid(row=0, column=0, sticky=tk.NSEW)
102
+ frame.grid(row=0, column=0, rowspan=2, columnspan=6, sticky=tk.NSEW)
103
+
104
+ self._videos.heading("#0", text="Name", command=lambda: self._sort_column("#0", False))
105
+ self._videos.column("#0", width=180, minwidth=80, stretch=False)
106
+ self._videos.heading(
107
+ "duration", text="Duration", command=lambda: self._sort_column("duration", False)
108
+ )
109
+ self._videos.column("duration", width=80, minwidth=80, stretch=False)
110
+ self._videos.heading(
111
+ "framerate", text="Framerate", command=lambda: self._sort_column("framerate", False)
112
+ )
113
+ self._videos.column("framerate", width=80, minwidth=80, stretch=False)
114
+ self._videos.heading(
115
+ "resolution", text="Resolution", command=lambda: self._sort_column("resolution", False)
116
+ )
117
+ self._videos.column("resolution", width=80, minwidth=80, stretch=False)
118
+ self._videos.heading("path", text="Path", command=lambda: self._sort_column("path", False))
119
+ self._videos.column("path", width=80, minwidth=80, stretch=False)
120
+ self._videos.heading("date", text="Date", command=lambda: self._sort_column("date", False))
121
+ self._videos.column("date", width=140, minwidth=80, stretch=False)
122
+
123
+ self._videos.grid(row=0, column=0, columnspan=6, sticky=tk.NSEW)
124
+
125
+ ttk.Button(root, text="Add", command=self._add_video).grid(
126
+ row=2, column=0, sticky=EXPAND_HORIZONTAL, padx=PADDING
127
+ )
128
+ self._remove_button = ttk.Button(
129
+ root, text="Remove", state=tk.DISABLED, command=self._on_remove
130
+ )
131
+ self._remove_button.grid(row=2, column=1, sticky=EXPAND_HORIZONTAL, padx=PADDING)
132
+ ttk.Button(
133
+ root,
134
+ text="Move Up",
135
+ command=self._on_move_up,
136
+ ).grid(row=2, column=2, sticky=EXPAND_HORIZONTAL, padx=PADDING)
137
+ ttk.Button(
138
+ root,
139
+ text="Move Down",
140
+ command=self._on_move_down,
141
+ ).grid(row=2, column=3, sticky=EXPAND_HORIZONTAL, padx=PADDING)
142
+
143
+ self._concatenate = tk.BooleanVar(root, value=True)
144
+ ttk.Checkbutton(
145
+ root,
146
+ text="Concatenate",
147
+ variable=self._concatenate,
148
+ onvalue=True,
149
+ offvalue=False,
150
+ command=self._on_set_time,
151
+ state=tk.DISABLED, # TODO: Enable when implemented.
152
+ ).grid(row=2, column=4, padx=PADDING, sticky=EXPAND_HORIZONTAL)
153
+
154
+ # TODO: Need to prevent start_time >= end_time.
155
+ self._set_time = tk.BooleanVar(root, value=False)
156
+ self._set_time_button = ttk.Checkbutton(
157
+ root,
158
+ text="Set Time",
159
+ variable=self._set_time,
160
+ onvalue=True,
161
+ offvalue=False,
162
+ command=self._on_set_time,
163
+ )
164
+ self._set_time_button.grid(row=3, column=0, padx=PADDING, sticky=tk.W)
165
+ self._start_time_label = tk.Label(root, text="Start Time", state=tk.DISABLED)
166
+ self._start_time = TimecodeEntry(root, value="00:00:00.000")
167
+
168
+ self._end_time_label = tk.Label(root, text="End Time", state=tk.DISABLED)
169
+ self._end_time = TimecodeEntry(root, "00:00:00.000")
170
+
171
+ self._set_region = tk.BooleanVar(root, value=False)
172
+ ttk.Checkbutton(
173
+ root,
174
+ text="Set Regions",
175
+ variable=self._set_region,
176
+ onvalue=True,
177
+ offvalue=False,
178
+ command=self._on_use_regions,
179
+ ).grid(row=4, column=0, padx=PADDING, sticky=tk.W)
180
+ self._region_editor_button = ttk.Button(
181
+ root, text="Region Editor", state=tk.DISABLED, command=self._on_edit_regions
182
+ )
183
+ self._current_region = tk.StringVar(value=NO_REGIONS_SPECIFIED_TEXT)
184
+ self._current_region_label = tk.Entry(
185
+ root, width=PATH_INPUT_WIDTH, state=tk.DISABLED, textvariable=self._current_region
186
+ )
187
+ self._region_editor_button.grid(row=4, column=1, padx=PADDING, sticky=EXPAND_HORIZONTAL)
188
+ self._regions: ty.List[ty.List[Point]] = []
189
+ self._current_region_label.grid(
190
+ row=4, column=3, sticky=EXPAND_HORIZONTAL, padx=PADDING, columnspan=2
191
+ )
192
+
193
+ # Update internal state
194
+ self._on_set_time()
195
+ self._on_use_regions()
196
+
197
+ @property
198
+ def videos(self) -> ty.List[str]:
199
+ videos = []
200
+ for item in self._videos.get_children():
201
+ # TODO: File bug against PySceneDetect as we can't seem to use Path objects here.
202
+ videos.append(self._videos.item(item)["values"][3])
203
+ return videos
204
+
205
+ def update(self, settings: ScanSettings) -> ty.Optional[ScanSettings]:
206
+ videos = self.videos
207
+ if not videos:
208
+ return None
209
+ settings.set("input", videos)
210
+ if self.start_end_time:
211
+ (start, end) = self.start_end_time
212
+ if start != "00:00:00.000":
213
+ settings.set("start-time", start)
214
+ if end != "00:00:00.000":
215
+ settings.set("end-time", end)
216
+ start_frame = FrameTimecode(start, 1000.0).get_frames()
217
+ end_frame = FrameTimecode(end, 1000.0).get_frames()
218
+ if end_frame and end_frame <= start_frame:
219
+ logger.error("No frames to process (start time must be less than than end time)")
220
+ return None
221
+ if self._set_region.get() and self._region_editor and self._region_editor.shapes:
222
+ settings.set("regions", self._region_editor.shapes)
223
+ return settings
224
+
225
+ def _sort_column(self, col, reverse):
226
+ if col == "#0":
227
+ items = [(self._videos.item(k, "text"), k) for k in self._videos.get_children("")]
228
+ else:
229
+ items = [(self._videos.set(k, col), k) for k in self._videos.get_children("")]
230
+ items.sort(reverse=reverse)
231
+ for index, (_val, k) in enumerate(items):
232
+ self._videos.move(k, "", index)
233
+ self._videos.heading(col, command=lambda: self._sort_column(col, not reverse))
234
+
235
+ def _add_video(self, path: str = ""):
236
+ paths = []
237
+ if not path:
238
+ paths = tkinter.filedialog.askopenfilename(
239
+ title="Open video(s)...",
240
+ # TODO: More extensions.
241
+ filetypes=[("Video", "*.mp4"), ("Video", "*.avi"), ("Other", "*")],
242
+ multiple=True,
243
+ )
244
+ if not paths:
245
+ return
246
+ else:
247
+ paths = [path]
248
+ failed_to_load = False
249
+ for path in paths:
250
+ if not Path(path).exists():
251
+ logger.error(f"File does not exist: {path}")
252
+ return
253
+ try:
254
+ video = open_video(path, backend="opencv") # logs errors on failure
255
+ try:
256
+ # On Windows, ctime is creation time. On other systems, it may be the
257
+ # last metadata change time.
258
+ create_time = datetime.fromtimestamp(Path(path).stat().st_ctime)
259
+ create_time_str = create_time.strftime("%Y-%m-%d %H:%M:%S")
260
+ except OSError as ex:
261
+ logger.debug(f"Failed to stat {path}: {ex}")
262
+ create_time_str = "N/A"
263
+ except VideoOpenFailure:
264
+ failed_to_load = True
265
+ continue
266
+ duration = video.duration.get_timecode()
267
+ framerate = f"{video.frame_rate:g}"
268
+ resolution = f"{video.frame_size[0]} x {video.frame_size[1]}"
269
+ path = Path(video.path).absolute()
270
+ self._videos.insert(
271
+ "",
272
+ tk.END,
273
+ text=video.name,
274
+ values=(duration, framerate, resolution, path, create_time_str),
275
+ )
276
+ if failed_to_load:
277
+ tkinter.messagebox.showwarning(
278
+ "Video Open Failure", "Failed to open one or more videos."
279
+ )
280
+ self._remove_button["state"] = tk.NORMAL if self._videos.get_children() else tk.DISABLED
281
+
282
+ @property
283
+ def concatenate(self) -> bool:
284
+ return self._concatenate.get()
285
+
286
+ def _on_remove(self):
287
+ for selection in self._videos.selection():
288
+ self._videos.delete(selection)
289
+
290
+ def _on_move_up(self):
291
+ for selection in self._videos.selection():
292
+ index = self._videos.index(selection)
293
+ if index > 0:
294
+ self._videos.move(selection, "", index - 1)
295
+ else:
296
+ break
297
+
298
+ def _on_move_down(self):
299
+ for selection in self._videos.selection()[::-1]:
300
+ index = self._videos.index(selection)
301
+ next = self._videos.next(selection)
302
+ if next:
303
+ self._videos.move(next, "", index)
304
+ else:
305
+ break
306
+
307
+ def _on_set_time(self):
308
+ # TODO: When disabled, set start time 0 and end time duration of video.
309
+ state = tk.NORMAL if self._set_time.get() else tk.DISABLED
310
+ self._set_time_button["state"] = tk.NORMAL if self.concatenate else tk.DISABLED
311
+ self._start_time_label["state"] = state
312
+ self._start_time["state"] = state
313
+ self._end_time_label["state"] = state
314
+ self._end_time["state"] = state
315
+ if state == tk.NORMAL and self.concatenate:
316
+ self._start_time_label.grid(row=3, column=1, sticky=EXPAND_HORIZONTAL)
317
+ self._start_time.grid(row=3, column=2, padx=PADDING, sticky=EXPAND_HORIZONTAL)
318
+ self._end_time.grid(row=3, column=4, padx=PADDING, sticky=EXPAND_HORIZONTAL)
319
+ self._end_time_label.grid(row=3, column=3, sticky=EXPAND_HORIZONTAL)
320
+ else:
321
+ self._start_time_label.grid_remove()
322
+ self._start_time.grid_remove()
323
+ self._end_time.grid_remove()
324
+ self._end_time_label.grid_remove()
325
+
326
+ def _on_use_regions(self):
327
+ if self._set_region.get():
328
+ self._region_editor_button["state"] = tk.NORMAL
329
+ self._current_region_label.grid(
330
+ row=4, column=3, sticky=EXPAND_HORIZONTAL, padx=PADDING, columnspan=2
331
+ )
332
+ else:
333
+ self._region_editor_button["state"] = tk.DISABLED
334
+ self._current_region_label.grid_remove()
335
+
336
+ def _on_edit_regions(self):
337
+ videos = self.videos
338
+ if videos:
339
+ frame = open_video(videos[0], backend="opencv").read()
340
+ if self._region_editor is None:
341
+ self._region_editor = RegionEditor(
342
+ frame=frame,
343
+ initial_shapes=[],
344
+ initial_scale=None,
345
+ video_path=videos[0],
346
+ debug_mode=False, # TODO: Wire up debug mode.
347
+ save_path=None,
348
+ on_close=self._on_region_editor_close,
349
+ )
350
+ self._region_editor.run(
351
+ root=self._root,
352
+ )
353
+ self._region_editor._root.grab_set()
354
+
355
+ def _on_region_editor_close(self):
356
+ assert self._region_editor is not None
357
+ self._regions = self._region_editor.shapes
358
+ self._current_region.set(
359
+ f"Using {len(self._regions)} regions." if self._regions else NO_REGIONS_SPECIFIED_TEXT
360
+ )
361
+ logger.debug(f"Regions updated: {self._regions}")
362
+
363
+ self._region_editor._root.grab_release()
364
+ self._root.focus()
365
+
366
+ @property
367
+ def start_end_time(self) -> ty.Optional[ty.Tuple[str, str]]:
368
+ if not self._set_time.get():
369
+ return None
370
+ return self._start_time.get(), self._end_time.get()
371
+
372
+
373
+ class InputSettingsWindow:
374
+ def __init__(self, root: tk.Widget):
375
+ self._root = root
376
+ self._window = tk.Toplevel(master=root)
377
+ self._window.withdraw()
378
+
379
+ root.rowconfigure(0, pad=PADDING, weight=1)
380
+ root.columnconfigure(0, pad=PADDING, weight=1)
381
+ root.columnconfigure(1, pad=PADDING, weight=12, minsize=0)
382
+
383
+ self._window.minsize(width=MIN_WINDOW_WIDTH, height=MIN_WINDOW_HEIGHT)
384
+ self._window.title("Input Settings")
385
+ self._window.resizable(True, True)
386
+ self._window.protocol("WM_DELETE_WINDOW", self._dismiss)
387
+ self._window.rowconfigure(0, weight=1)
388
+ self._window.rowconfigure(1, weight=1)
389
+ self._window.columnconfigure(0, weight=1)
390
+
391
+ frame = ttk.LabelFrame(self._window, text="Video", padding=PADDING)
392
+ frame.grid(row=0, sticky=tk.NSEW, padx=PADDING, pady=PADDING)
393
+ frame.rowconfigure(0, pad=PADDING, weight=1)
394
+ frame.rowconfigure(1, pad=PADDING, weight=1)
395
+ frame.rowconfigure(2, pad=PADDING, weight=1)
396
+ frame.columnconfigure(0, pad=PADDING, weight=1)
397
+ frame.columnconfigure(1, pad=PADDING, weight=1)
398
+ frame.columnconfigure(2, pad=PADDING, weight=2)
399
+ frame.columnconfigure(3, pad=PADDING, weight=1)
400
+ frame.columnconfigure(4, pad=PADDING, weight=1)
401
+ frame.columnconfigure(5, pad=PADDING, weight=12, minsize=0)
402
+
403
+ self._use_pts = tk.BooleanVar()
404
+ ttk.Checkbutton(
405
+ frame,
406
+ text="Use Presentation Time (PTS)\nfor Timestamps",
407
+ variable=self._use_pts,
408
+ onvalue=True,
409
+ offvalue=False,
410
+ ).grid(
411
+ row=2,
412
+ column=0,
413
+ columnspan=2,
414
+ padx=PADDING,
415
+ sticky=tk.N + EXPAND_HORIZONTAL,
416
+ pady=PADDING,
417
+ )
418
+
419
+ tk.Button(self._window, text="Close", command=self._dismiss).grid(
420
+ row=2, column=0, sticky=tk.E, padx=PADDING, pady=PADDING
421
+ )
422
+
423
+ tk.Label(frame, text="Frame Skip").grid(
424
+ row=2, column=3, padx=PADDING, sticky=tk.N + EXPAND_HORIZONTAL, pady=PADDING
425
+ )
426
+ self._frame_skip = Spinbox(
427
+ frame,
428
+ value=str(CONFIG_MAP["frame-skip"]),
429
+ from_=0.0,
430
+ to=1000.0,
431
+ increment=1.0,
432
+ )
433
+ self._frame_skip.grid(
434
+ row=2, column=4, padx=PADDING, sticky=tk.N + EXPAND_HORIZONTAL, pady=PADDING
435
+ )
436
+ self._downscale_factor = tk.StringVar(value=1)
437
+ tk.Label(frame, text="Downscale Factor").grid(
438
+ row=0, column=3, sticky=EXPAND_HORIZONTAL, padx=PADDING, pady=PADDING
439
+ )
440
+ self._downscale_factor = Spinbox(
441
+ frame,
442
+ value=float(CONFIG_MAP["downscale-factor"]),
443
+ from_=0.0,
444
+ to=float(MAX_DOWNSCALE_FACTOR),
445
+ increment=1.0,
446
+ convert=lambda val: round(float(val)),
447
+ )
448
+ self._downscale_factor.grid(
449
+ row=0, column=4, sticky=EXPAND_HORIZONTAL, padx=PADDING, pady=PADDING
450
+ )
451
+ tk.Label(frame, text="Input Mode").grid(row=0, column=0, sticky=EXPAND_HORIZONTAL)
452
+ self._input_mode = tk.StringVar()
453
+ combo = ttk.Combobox(frame, textvariable=self._input_mode, width=SETTING_INPUT_WIDTH)
454
+ combo.state(["readonly"])
455
+ # Make sure OpenCV is always at the top.
456
+ combo["values"] = ["opencv"] + [
457
+ backend for backend in AVAILABLE_BACKENDS if backend != "opencv"
458
+ ]
459
+ self._input_mode.set("opencv")
460
+ combo.grid(row=0, column=1, sticky=EXPAND_HORIZONTAL)
461
+
462
+ def show(self):
463
+ logger.debug("showing input settings window")
464
+ self._window.transient(self._root)
465
+ self._window.deiconify()
466
+ self._window.focus()
467
+ self._window.grab_set()
468
+ self._window.wait_window()
469
+
470
+ def _dismiss(self):
471
+ logger.debug("closing input settings window")
472
+ self._window.withdraw()
473
+ self._window.grab_release()
474
+ self._root.focus()
475
+
476
+ def set(self, settings: ScanSettings):
477
+ self._use_pts.set(settings.get("use-pts"))
478
+ self._downscale_factor.set(settings.get("downscale-factor"))
479
+ self._frame_skip.set(settings.get("frame-skip"))
480
+ self._input_mode.set(settings.get("input-mode"))
481
+
482
+ def update(self, settings: ScanSettings) -> ScanSettings:
483
+ settings.set("use-pts", self._use_pts.get())
484
+ settings.set("downscale-factor", int(self._downscale_factor.get()))
485
+ settings.set("frame-skip", int(self._frame_skip.get()))
486
+ settings.set("input-mode", self._input_mode.get())
487
+ return settings
488
+
489
+
490
+ class MotionSettingsWindow:
491
+ def __init__(self, root: tk.Widget):
492
+ self._root = root
493
+ self._window = tk.Toplevel(master=root)
494
+ self._window.withdraw()
495
+
496
+ root.rowconfigure(0, pad=PADDING, weight=1)
497
+ root.columnconfigure(0, pad=PADDING, weight=1)
498
+ root.columnconfigure(1, pad=PADDING, weight=12, minsize=0)
499
+
500
+ self._window.minsize(width=MIN_WINDOW_WIDTH, height=MIN_WINDOW_HEIGHT)
501
+ self._window.title("Motion Settings")
502
+ self._window.resizable(True, True)
503
+ self._window.protocol("WM_DELETE_WINDOW", self._dismiss)
504
+ self._window.rowconfigure(0, weight=1)
505
+ self._window.rowconfigure(1, weight=1)
506
+ self._window.columnconfigure(0, weight=1)
507
+
508
+ frame = ttk.LabelFrame(self._window, text="Motion", padding=PADDING)
509
+ frame.grid(row=0, sticky=tk.NSEW, padx=PADDING, pady=PADDING)
510
+ frame.rowconfigure(0, pad=2 * PADDING, weight=1)
511
+ frame.rowconfigure(1, pad=2 * PADDING, weight=1)
512
+ frame.rowconfigure(2, pad=2 * PADDING, weight=1)
513
+ frame.columnconfigure(0, pad=PADDING, weight=1)
514
+ frame.columnconfigure(1, pad=PADDING, weight=1)
515
+ frame.columnconfigure(2, pad=PADDING, weight=2)
516
+ frame.columnconfigure(3, pad=PADDING, weight=1)
517
+ frame.columnconfigure(4, pad=PADDING, weight=1)
518
+ frame.columnconfigure(5, pad=PADDING, weight=1)
519
+ frame.columnconfigure(6, pad=PADDING, weight=12, minsize=0)
520
+
521
+ tk.Label(frame, text="Subtractor").grid(row=0, column=0, sticky=EXPAND_HORIZONTAL)
522
+ self._bg_subtractor = tk.StringVar()
523
+ combo = ttk.Combobox(frame, textvariable=self._bg_subtractor, width=SETTING_INPUT_WIDTH)
524
+ combo.state(["readonly"])
525
+ if SubtractorCudaMOG2.is_available():
526
+ combo["values"] = ("MOG2_CUDA", "MOG2", "CNT")
527
+ self._bg_subtractor.set("MOG2_CUDA")
528
+ else:
529
+ combo["values"] = ("MOG2", "CNT")
530
+ self._bg_subtractor.set("MOG2")
531
+
532
+ combo.grid(row=0, column=1, sticky=EXPAND_HORIZONTAL)
533
+
534
+ tk.Label(frame, text="Kernel Size").grid(row=1, column=0, sticky=EXPAND_HORIZONTAL)
535
+
536
+ self._kernel_size_combobox = ttk.Combobox(
537
+ frame, width=SETTING_INPUT_WIDTH, state="readonly"
538
+ )
539
+ # 0: Auto
540
+ # 1: Off
541
+ # 2: 3x3
542
+ # 3: 5x5
543
+ # 4: 7x7
544
+ # 5: 9x9...
545
+ self._kernel_size_combobox["values"] = (
546
+ "Off",
547
+ "Auto",
548
+ *tuple(f"{n}x{n}" for n in range(3, MAX_KERNEL_SIZE + 1, 2)),
549
+ )
550
+ self._kernel_size_combobox.grid(row=1, column=1, sticky=EXPAND_HORIZONTAL)
551
+ self._kernel_size_combobox.current(1)
552
+
553
+ tk.Label(frame, text="Threshold").grid(row=2, column=0, sticky=EXPAND_HORIZONTAL)
554
+ self._threshold = Spinbox(
555
+ frame,
556
+ value=str(CONFIG_MAP["threshold"]),
557
+ from_=0.0,
558
+ to=255.0,
559
+ increment=0.01,
560
+ )
561
+ self._threshold.grid(row=2, column=1, sticky=EXPAND_HORIZONTAL)
562
+
563
+ # Events
564
+ tk.Label(frame, text="Min. Event Length").grid(row=0, column=3, sticky=EXPAND_HORIZONTAL)
565
+ self._min_event_length = Spinbox(
566
+ frame,
567
+ value=str(CONFIG_MAP["min-event-length"]),
568
+ from_=0.0,
569
+ to=MAX_DURATION,
570
+ increment=DURATION_INCREMENT,
571
+ suffix="s",
572
+ )
573
+ self._min_event_length.grid(row=0, column=4, sticky=EXPAND_HORIZONTAL)
574
+
575
+ tk.Label(frame, text="Time Pre-Event").grid(row=1, column=3, sticky=EXPAND_HORIZONTAL)
576
+ self._time_before_event = Spinbox(
577
+ frame,
578
+ value=str(CONFIG_MAP["time-before-event"]),
579
+ from_=0.0,
580
+ to=MAX_DURATION,
581
+ increment=DURATION_INCREMENT,
582
+ suffix="s",
583
+ )
584
+ self._time_before_event.grid(row=1, column=4, sticky=EXPAND_HORIZONTAL)
585
+
586
+ tk.Label(frame, text="Time Post-Event").grid(row=2, column=3, sticky=EXPAND_HORIZONTAL)
587
+ self._time_post_event = Spinbox(
588
+ frame,
589
+ value=str(CONFIG_MAP["time-post-event"]),
590
+ from_=0.0,
591
+ to=MAX_DURATION,
592
+ increment=DURATION_INCREMENT,
593
+ suffix="s",
594
+ )
595
+ self._time_post_event.grid(row=2, column=4, sticky=EXPAND_HORIZONTAL)
596
+
597
+ frame = ttk.LabelFrame(self._window, text="Advanced", padding=PADDING)
598
+ frame.grid(row=1, sticky=tk.NSEW, padx=PADDING, pady=PADDING)
599
+ frame.rowconfigure(0, pad=PADDING, weight=1)
600
+ frame.rowconfigure(1, pad=PADDING, weight=1)
601
+ frame.columnconfigure(0, weight=1)
602
+ frame.columnconfigure(1, weight=1)
603
+ frame.columnconfigure(2, weight=2)
604
+ frame.columnconfigure(3, weight=1)
605
+ frame.columnconfigure(4, weight=1)
606
+ frame.columnconfigure(5, weight=2)
607
+ frame.columnconfigure(6, weight=12)
608
+
609
+ self._mask_output = tk.BooleanVar(value=False)
610
+ ttk.Checkbutton(
611
+ frame,
612
+ text="Save Motion Mask",
613
+ variable=self._mask_output,
614
+ onvalue=True,
615
+ offvalue=False,
616
+ ).grid(row=1, column=0, columnspan=2, sticky=tk.W, padx=PADDING, pady=PADDING)
617
+
618
+ self._learning_rate_auto = tk.BooleanVar(value=True)
619
+ tk.Label(frame, text="Learning Rate").grid(
620
+ row=0, column=3, padx=PADDING, sticky=EXPAND_HORIZONTAL
621
+ )
622
+ self._learning_rate_value = Spinbox(
623
+ frame,
624
+ value=0.5,
625
+ from_=0.0,
626
+ to=1.0,
627
+ increment=0.01,
628
+ state=tk.DISABLED,
629
+ )
630
+ self._learning_rate_value.grid(
631
+ row=0, column=4, padx=PADDING, sticky=EXPAND_HORIZONTAL, pady=PADDING
632
+ )
633
+ ttk.Checkbutton(
634
+ frame,
635
+ text="Auto",
636
+ variable=self._learning_rate_auto,
637
+ onvalue=True,
638
+ offvalue=False,
639
+ command=self._on_auto_learning_rate,
640
+ ).grid(row=0, column=5, padx=PADDING, sticky=EXPAND_HORIZONTAL, pady=PADDING)
641
+
642
+ tk.Label(frame, text="Max Threshold").grid(
643
+ row=0, column=0, padx=PADDING, sticky=EXPAND_HORIZONTAL, pady=PADDING
644
+ )
645
+ self._max_threshold = Spinbox(
646
+ frame,
647
+ value=str(CONFIG_MAP["max-threshold"]),
648
+ from_=0.0,
649
+ to=MAX_THRESHOLD,
650
+ increment=1.0,
651
+ )
652
+ self._max_threshold.grid(
653
+ row=0, column=1, padx=PADDING, sticky=EXPAND_HORIZONTAL, pady=PADDING
654
+ )
655
+
656
+ tk.Label(frame, text="Variance Threshold").grid(
657
+ row=1, column=3, padx=PADDING, sticky=EXPAND_HORIZONTAL, pady=PADDING
658
+ )
659
+ self._variance_threshold = Spinbox(
660
+ frame,
661
+ value=str(CONFIG_MAP["threshold"]),
662
+ from_=0.0,
663
+ to=255.0,
664
+ increment=0.01,
665
+ )
666
+ self._variance_threshold.grid(
667
+ row=1, column=4, padx=PADDING, sticky=EXPAND_HORIZONTAL, pady=PADDING
668
+ )
669
+
670
+ tk.Label(frame, text="Max Area").grid(
671
+ row=0, column=0, padx=PADDING, sticky=EXPAND_HORIZONTAL, pady=PADDING
672
+ )
673
+ self._max_area = Spinbox(
674
+ frame,
675
+ value=str(CONFIG_MAP["max-area"]),
676
+ from_=0.0,
677
+ to=1.0,
678
+ increment=0.01,
679
+ )
680
+ self._max_area.grid(row=0, column=1, padx=PADDING, sticky=EXPAND_HORIZONTAL, pady=PADDING)
681
+
682
+ tk.Label(frame, text="Max Width").grid(
683
+ row=0, column=0, padx=PADDING, sticky=EXPAND_HORIZONTAL, pady=PADDING
684
+ )
685
+ self._max_width = Spinbox(
686
+ frame,
687
+ value=str(CONFIG_MAP["max-width"]),
688
+ from_=0.0,
689
+ to=1.0,
690
+ increment=0.01,
691
+ )
692
+ self._max_width.grid(row=0, column=1, padx=PADDING, sticky=EXPAND_HORIZONTAL, pady=PADDING)
693
+
694
+ tk.Label(frame, text="Max Height").grid(
695
+ row=0, column=0, padx=PADDING, sticky=EXPAND_HORIZONTAL, pady=PADDING
696
+ )
697
+ self._max_height = Spinbox(
698
+ frame,
699
+ value=str(CONFIG_MAP["max-height"]),
700
+ from_=0.0,
701
+ to=1.0,
702
+ increment=0.01,
703
+ )
704
+ self._max_height.grid(row=0, column=1, padx=PADDING, sticky=EXPAND_HORIZONTAL, pady=PADDING)
705
+
706
+ tk.Button(self._window, text="Close", command=self._dismiss).grid(
707
+ row=2, column=0, sticky=tk.E, padx=PADDING, pady=PADDING
708
+ )
709
+
710
+ def _on_auto_learning_rate(self):
711
+ self._learning_rate_value["state"] = (
712
+ tk.DISABLED if self._learning_rate_auto.get() else tk.NORMAL
713
+ )
714
+
715
+ def show(self):
716
+ logger.debug("showing motion settings window")
717
+ self._window.transient(self._root)
718
+ self._window.deiconify()
719
+ self._window.focus()
720
+ self._window.grab_set()
721
+ self._window.wait_window()
722
+
723
+ def _dismiss(self):
724
+ logger.debug("closing motion settings window")
725
+ self._window.withdraw()
726
+ self._window.grab_release()
727
+ self._root.focus()
728
+
729
+ @property
730
+ def _kernel_size(self) -> int:
731
+ index = self._kernel_size_combobox.current()
732
+ if index == 0:
733
+ return 0
734
+ elif index == 1:
735
+ return -1
736
+ else:
737
+ assert index > 0
738
+ return (index * 2) - 1
739
+
740
+ @_kernel_size.setter
741
+ def _kernel_size(self, size):
742
+ # TODO: Handle this discrepency properly, we're clipping the user config right now.
743
+ if size > MAX_KERNEL_SIZE:
744
+ logger.warning("Kernel sizes above 21 are not supported by the UI, clipping to 21.")
745
+ kernel_size = min(size, MAX_KERNEL_SIZE)
746
+ auto_kernel = bool(kernel_size < 0)
747
+ none_kernel = bool(kernel_size == 0)
748
+ index = 0 if none_kernel else 1 if auto_kernel else (1 + (kernel_size // 2))
749
+ self._kernel_size_combobox.current(index)
750
+
751
+ @property
752
+ def _learning_rate(self) -> float:
753
+ if self._learning_rate_auto.get():
754
+ return CONFIG_MAP["learning-rate"]
755
+ return self._learning_rate_value.get()
756
+
757
+ @_learning_rate.setter
758
+ def _learning_rate(self, newval: float):
759
+ if newval < 0.0:
760
+ self._learning_rate_auto.set(True)
761
+ else:
762
+ self._learning_rate_auto.set(False)
763
+ self._learning_rate_value.set(newval)
764
+ self._on_auto_learning_rate()
765
+
766
+ def set(self, settings: ScanSettings):
767
+ self._kernel_size = settings.get("kernel-size")
768
+ self._learning_rate = settings.get("learning-rate")
769
+ self._bg_subtractor.set(settings.get("bg-subtractor"))
770
+ self._threshold.set(settings.get("threshold"))
771
+ self._min_event_length.set(settings.get("min-event-length"))
772
+ self._time_before_event.set(settings.get("time-before-event"))
773
+ self._time_post_event.set(settings.get("time-post-event"))
774
+ self._max_threshold.set(settings.get("max-threshold"))
775
+ self._max_area.set(settings.get("max-area"))
776
+ self._max_width.set(settings.get("max-width"))
777
+ self._max_height.set(settings.get("max-height"))
778
+ self._variance_threshold.set(settings.get("variance-threshold"))
779
+
780
+ def update(self, settings: ScanSettings) -> ScanSettings:
781
+ settings.set("kernel-size", self._kernel_size)
782
+ settings.set("bg-subtractor", self._bg_subtractor.get())
783
+ settings.set("threshold", float(self._threshold.get()))
784
+ settings.set("min-event-length", self._min_event_length.get())
785
+ settings.set("time-before-event", self._time_before_event.get())
786
+ settings.set("time-post-event", self._time_post_event.get())
787
+ settings.set("learning-rate", float(self._learning_rate))
788
+ settings.set("max-threshold", float(self._max_threshold.get()))
789
+ settings.set("max-area", float(self._max_area.get()))
790
+ settings.set("max-width", float(self._max_width.get()))
791
+ settings.set("max-height", float(self._max_height.get()))
792
+ settings.set("variance-threshold", float(self._variance_threshold.get()))
793
+ # NOTE: There is no mask-output in the get function above as it does not exist in the
794
+ # config file (CLI only).
795
+ settings.set("mask-output", self._mask_output.get())
796
+ return settings
797
+
798
+
799
+ class OutputArea:
800
+ def __init__(self, root: tk.Widget):
801
+ self._root = root
802
+ self._last_value: ty.Optional[str] = None
803
+ self._options_window = tk.Toplevel(master=root)
804
+ self._options_window.withdraw()
805
+
806
+ root.columnconfigure(0, pad=PADDING, weight=1)
807
+ root.columnconfigure(1, pad=PADDING, weight=1)
808
+ root.columnconfigure(2, pad=PADDING, weight=1)
809
+ root.columnconfigure(3, pad=PADDING, weight=12)
810
+
811
+ tk.Label(root, text="Mode").grid(
812
+ row=0, column=0, sticky=EXPAND_HORIZONTAL, padx=PADDING, pady=(0, PADDING)
813
+ )
814
+ self._output_mode_combo = ttk.Combobox(root, width=SETTING_INPUT_WIDTH, state="readonly")
815
+ self._output_mode_combo.grid(
816
+ row=0, column=1, sticky=EXPAND_HORIZONTAL, padx=PADDING, pady=(0, PADDING)
817
+ )
818
+ self._output_mode_combo.bind(
819
+ "<<ComboboxSelected>>", lambda _: self._on_mode_combo_selected()
820
+ )
821
+
822
+ self._options_button = ttk.Button(
823
+ root, text="Options...", command=self._show_options, width=SETTING_INPUT_WIDTH
824
+ )
825
+ self._options_button.grid(row=0, column=2, sticky=EXPAND_HORIZONTAL, pady=(0, PADDING))
826
+
827
+ self._combine = tk.BooleanVar(root, value=False)
828
+ self._combine_button = ttk.Checkbutton(
829
+ root,
830
+ text="Combine Events",
831
+ variable=self._combine,
832
+ onvalue=True,
833
+ offvalue=False,
834
+ )
835
+ self._combine_button.grid(row=0, column=3, sticky=tk.W, padx=PADDING)
836
+
837
+ self._output_mode_combo["values"] = (
838
+ "OpenCV (.avi)",
839
+ "ffmpeg",
840
+ "ffmpeg (copy)",
841
+ )
842
+ self._output_mode_combo.current(1)
843
+
844
+ self._output_dir_str = ""
845
+ self._output_dir_label = tk.StringVar(root, value="Ask Me")
846
+
847
+ tk.Label(root, text="Directory").grid(
848
+ row=1,
849
+ column=0,
850
+ sticky=EXPAND_HORIZONTAL,
851
+ pady=(PADDING, 0),
852
+ )
853
+ ttk.Entry(
854
+ root, width=PATH_INPUT_WIDTH, state=tk.DISABLED, textvariable=self._output_dir_label
855
+ ).grid(
856
+ row=1,
857
+ column=1,
858
+ sticky=EXPAND_HORIZONTAL,
859
+ columnspan=3,
860
+ pady=(PADDING, 0),
861
+ padx=PADDING,
862
+ )
863
+
864
+ self._select_button = ttk.Button(root, text="Select...", command=self._on_select)
865
+ self._select_button.grid(row=2, column=2, sticky=EXPAND_HORIZONTAL, pady=(PADDING, 0))
866
+
867
+ def clear_output_directory():
868
+ self._output_dir = ""
869
+
870
+ self._clear_button = ttk.Button(
871
+ root, text="Clear", state=tk.DISABLED, command=clear_output_directory
872
+ )
873
+ self._clear_button.grid(
874
+ row=2, column=1, sticky=EXPAND_HORIZONTAL, padx=PADDING, pady=(PADDING, 0)
875
+ )
876
+
877
+ #
878
+ #
879
+ # Advanced Window
880
+ #
881
+ #
882
+ self._options_window.minsize(width=MIN_WINDOW_WIDTH, height=MIN_WINDOW_HEIGHT)
883
+ self._options_window.title("Output Options")
884
+ self._options_window.resizable(True, True)
885
+ self._options_window.protocol("WM_DELETE_WINDOW", self._dismiss_options)
886
+ self._options_window.rowconfigure(0, weight=1)
887
+ self._options_window.rowconfigure(1, weight=1)
888
+ self._options_window.columnconfigure(0, weight=1)
889
+
890
+ self._ffmpeg_options = ttk.LabelFrame(
891
+ self._options_window, text="ffmpeg Options", padding=PADDING
892
+ )
893
+
894
+ self._ffmpeg_options.columnconfigure(0, weight=2)
895
+ self._ffmpeg_options.columnconfigure(1, weight=8)
896
+ self._ffmpeg_options.columnconfigure(2, weight=1)
897
+
898
+ self._ffmpeg_input_label = tk.Label(self._ffmpeg_options, text="ffmpeg\nInput Args")
899
+ self._ffmpeg_input_label.grid(
900
+ row=0, column=0, sticky=EXPAND_HORIZONTAL, padx=PADDING, pady=PADDING
901
+ )
902
+ self._ffmpeg_input_args = tk.StringVar(value=CONFIG_MAP["ffmpeg-input-args"])
903
+ self._ffmpeg_input_entry = ttk.Entry(
904
+ self._ffmpeg_options,
905
+ textvariable=self._ffmpeg_input_args,
906
+ width=LONG_SETTING_INPUT_WIDTH,
907
+ )
908
+ self._ffmpeg_input_entry.grid(
909
+ row=0, column=1, sticky=EXPAND_HORIZONTAL, padx=PADDING, pady=PADDING
910
+ )
911
+
912
+ self._ffmpeg_output_label = tk.Label(self._ffmpeg_options, text="ffmpeg\nOutput Args")
913
+ self._ffmpeg_output_label.grid(
914
+ row=1, column=0, sticky=EXPAND_HORIZONTAL, padx=PADDING, pady=PADDING
915
+ )
916
+ self._ffmpeg_output_args = tk.StringVar(value=CONFIG_MAP["ffmpeg-output-args"])
917
+ self._ffmpeg_output_entry = ttk.Entry(
918
+ self._ffmpeg_options,
919
+ textvariable=self._ffmpeg_output_args,
920
+ width=LONG_SETTING_INPUT_WIDTH,
921
+ )
922
+ self._ffmpeg_output_entry.grid(
923
+ row=1, column=1, sticky=EXPAND_HORIZONTAL, padx=PADDING, pady=PADDING
924
+ )
925
+
926
+ tk.Button(self._options_window, text="Close", command=self._dismiss_options).grid(
927
+ row=2, column=0, sticky=tk.E, padx=PADDING, pady=PADDING
928
+ )
929
+
930
+ self._opencv_options = ttk.LabelFrame(
931
+ self._options_window, text="OpenCV Options", padding=PADDING
932
+ )
933
+
934
+ self._opencv_options.columnconfigure(0, weight=1)
935
+ self._opencv_options.columnconfigure(1, weight=1)
936
+ self._opencv_options.columnconfigure(2, weight=1)
937
+ self._opencv_options.columnconfigure(3, weight=1)
938
+ self._opencv_options.columnconfigure(4, weight=1)
939
+ self._opencv_options.columnconfigure(5, weight=12)
940
+
941
+ self._opencv_codec_label = tk.Label(self._opencv_options, text="Codec")
942
+ self._opencv_codec_label.grid(
943
+ row=0, column=0, sticky=EXPAND_HORIZONTAL, padx=PADDING, pady=PADDING
944
+ )
945
+ self._opencv_codec = tk.StringVar(value=CONFIG_MAP["opencv-codec"])
946
+ self._opencv_codec_combo = ttk.Combobox(
947
+ self._opencv_options,
948
+ width=SETTING_INPUT_WIDTH,
949
+ state="readonly",
950
+ textvariable=self._opencv_codec,
951
+ )
952
+ self._opencv_codec_combo["values"] = CHOICE_MAP["opencv-codec"]
953
+ self._opencv_codec_combo.grid(row=0, column=1, sticky=tk.W, padx=PADDING, pady=PADDING)
954
+
955
+ self._timecode = tk.BooleanVar(value=False)
956
+ self._timecode_checkbutton = ttk.Checkbutton(
957
+ self._opencv_options,
958
+ text="Timecode",
959
+ variable=self._timecode,
960
+ onvalue=True,
961
+ offvalue=False,
962
+ command=self._on_text_overlay,
963
+ )
964
+ self._timecode_checkbutton.grid(
965
+ row=1, column=0, columnspan=2, sticky=EXPAND_HORIZONTAL, padx=PADDING, pady=(PADDING, 0)
966
+ )
967
+
968
+ self._frame_metrics = tk.BooleanVar(value=False)
969
+ self._frame_metrics_checkbutton = ttk.Checkbutton(
970
+ self._opencv_options,
971
+ text="Frame Metrics",
972
+ variable=self._frame_metrics,
973
+ onvalue=True,
974
+ offvalue=False,
975
+ command=self._on_text_overlay,
976
+ )
977
+ self._frame_metrics_checkbutton.grid(
978
+ row=1, column=1, sticky=tk.W, padx=PADDING, pady=(PADDING, 0)
979
+ )
980
+
981
+ tk.Label(self._opencv_options, text="Text Color").grid(
982
+ row=2, column=0, sticky=EXPAND_HORIZONTAL, padx=PADDING, pady=(PADDING, 0)
983
+ )
984
+ self._text_font_color = ColorPicker(self._opencv_options)
985
+ self._text_font_color.grid(row=2, column=1, sticky=tk.NSEW, pady=(PADDING, 0), padx=PADDING)
986
+ tk.Label(self._opencv_options, text="Background").grid(
987
+ row=2, column=2, sticky=EXPAND_HORIZONTAL, padx=PADDING, pady=(PADDING, 0)
988
+ )
989
+ self._text_bg_color = ColorPicker(self._opencv_options)
990
+ self._text_bg_color.grid(row=2, column=3, sticky=tk.NSEW, pady=(PADDING, 0), padx=PADDING)
991
+
992
+ tk.Label(self._opencv_options, text="Font Scale").grid(
993
+ row=3, column=0, sticky=EXPAND_HORIZONTAL, padx=PADDING, pady=(PADDING, 0)
994
+ )
995
+ self._text_font_scale = Spinbox(
996
+ self._opencv_options,
997
+ value=str(CONFIG_MAP["text-font-scale"]),
998
+ from_=0.0,
999
+ to=1000.0,
1000
+ increment=0.1,
1001
+ )
1002
+ self._text_font_scale.grid(
1003
+ row=3, column=1, sticky=EXPAND_HORIZONTAL, padx=PADDING, pady=(PADDING, 0)
1004
+ )
1005
+
1006
+ tk.Label(self._opencv_options, text="Font Weight").grid(
1007
+ row=3, column=2, sticky=EXPAND_HORIZONTAL, padx=PADDING, pady=(PADDING, 0)
1008
+ )
1009
+ self._text_font_thickness = Spinbox(
1010
+ self._opencv_options,
1011
+ value=str(CONFIG_MAP["text-font-thickness"]),
1012
+ from_=0.0,
1013
+ to=1000.0,
1014
+ increment=1.0,
1015
+ convert=lambda val: round(float(val)),
1016
+ )
1017
+ self._text_font_thickness.grid(
1018
+ row=3, column=3, sticky=EXPAND_HORIZONTAL, padx=PADDING, pady=(PADDING, 0)
1019
+ )
1020
+
1021
+ tk.Label(self._opencv_options, text="Text Border").grid(
1022
+ row=4, column=0, sticky=EXPAND_HORIZONTAL
1023
+ )
1024
+ # TODO: Constrain to be <= text margin
1025
+ self._text_border = Spinbox(
1026
+ self._opencv_options,
1027
+ value=str(CONFIG_MAP["text-border"]),
1028
+ from_=0.0,
1029
+ to=1000.0,
1030
+ increment=1.0,
1031
+ convert=lambda val: round(float(val)),
1032
+ )
1033
+ self._text_border.grid(
1034
+ row=4, column=1, sticky=EXPAND_HORIZONTAL, padx=PADDING, pady=PADDING
1035
+ )
1036
+
1037
+ tk.Label(self._opencv_options, text="Text Margin").grid(
1038
+ row=4, column=2, sticky=EXPAND_HORIZONTAL, padx=PADDING, pady=PADDING
1039
+ )
1040
+ self._text_margin = Spinbox(
1041
+ self._opencv_options,
1042
+ value=str(CONFIG_MAP["text-margin"]),
1043
+ from_=0.0,
1044
+ to=1000.0,
1045
+ increment=1.0,
1046
+ convert=lambda val: round(float(val)),
1047
+ )
1048
+ self._text_margin.grid(
1049
+ row=4, column=3, sticky=EXPAND_HORIZONTAL, padx=PADDING, pady=PADDING
1050
+ )
1051
+
1052
+ self._bounding_box = tk.BooleanVar(value=False)
1053
+ self._bounding_box_button = ttk.Checkbutton(
1054
+ self._opencv_options,
1055
+ text="Bounding Box",
1056
+ variable=self._bounding_box,
1057
+ onvalue=True,
1058
+ offvalue=False,
1059
+ command=self._on_bounding_box,
1060
+ )
1061
+ self._bounding_box_button.grid(
1062
+ row=5, column=0, columnspan=2, sticky=EXPAND_HORIZONTAL, padx=PADDING, pady=(PADDING, 0)
1063
+ )
1064
+
1065
+ tk.Label(self._opencv_options, text="Line Color").grid(
1066
+ row=6, column=0, sticky=EXPAND_HORIZONTAL, padx=PADDING, pady=(PADDING, 0)
1067
+ )
1068
+ self._bounding_box_color = ColorPicker(self._opencv_options)
1069
+ self._bounding_box_color.grid(
1070
+ row=6, column=1, sticky=tk.NSEW, pady=(PADDING, 0), padx=PADDING
1071
+ )
1072
+
1073
+ tk.Label(self._opencv_options, text="Line Thickness").grid(
1074
+ row=6, column=2, sticky=EXPAND_HORIZONTAL, padx=PADDING, pady=(PADDING, 0)
1075
+ )
1076
+ self._bounding_box_thickness = Spinbox(
1077
+ self._opencv_options,
1078
+ value=str(CONFIG_MAP["bounding-box-thickness"]),
1079
+ from_=0.0,
1080
+ to=1.0,
1081
+ increment=0.001,
1082
+ )
1083
+ self._bounding_box_thickness.grid(
1084
+ row=6, column=3, sticky=EXPAND_HORIZONTAL, padx=PADDING, pady=(PADDING, 0)
1085
+ )
1086
+
1087
+ tk.Label(self._opencv_options, text="Smooth Time").grid(
1088
+ row=7, column=0, sticky=EXPAND_HORIZONTAL, padx=PADDING, pady=PADDING
1089
+ )
1090
+ self._bounding_box_smooth_time = Spinbox(
1091
+ self._opencv_options,
1092
+ str(CONFIG_MAP["bounding-box-smooth-time"]),
1093
+ from_=0.0,
1094
+ to=MAX_DURATION,
1095
+ increment=DURATION_INCREMENT,
1096
+ suffix="s",
1097
+ )
1098
+ self._bounding_box_smooth_time.grid(
1099
+ row=7, column=1, sticky=EXPAND_HORIZONTAL, padx=PADDING, pady=PADDING
1100
+ )
1101
+
1102
+ tk.Label(self._opencv_options, text="Min. Box Size").grid(
1103
+ row=7, column=2, sticky=EXPAND_HORIZONTAL, padx=PADDING, pady=PADDING
1104
+ )
1105
+ self._bounding_box_min_size = Spinbox(
1106
+ self._opencv_options,
1107
+ value=str(CONFIG_MAP["bounding-box-min-size"]),
1108
+ from_=0.0,
1109
+ to=1.0,
1110
+ increment=0.001,
1111
+ )
1112
+ self._bounding_box_min_size.grid(
1113
+ row=7, column=3, sticky=EXPAND_HORIZONTAL, padx=PADDING, pady=PADDING
1114
+ )
1115
+
1116
+ self._on_text_overlay()
1117
+ self._on_bounding_box()
1118
+ self._on_mode_combo_selected()
1119
+
1120
+ def _on_duration_focus_in(self, var: tk.StringVar):
1121
+ def callback(_: tk.Event):
1122
+ self._last_value = var.get()
1123
+
1124
+ return callback
1125
+
1126
+ def _validate_duration(self, var: tk.StringVar):
1127
+ def callback(_: tk.Event):
1128
+ validated = 0.0
1129
+ try:
1130
+ validated = float(var.get().removesuffix("s"))
1131
+ except ValueError:
1132
+ try:
1133
+ validated = float(self._last_value.removesuffix("s"))
1134
+ except ValueError:
1135
+ validated = 0.0
1136
+ var.set(DURATION_FORMAT % validated)
1137
+ self._on_duration_focus_in(var)(None)
1138
+
1139
+ return callback
1140
+
1141
+ def _show_options(self):
1142
+ if self._output_mode == OutputMode.OPENCV:
1143
+ self._opencv_options.grid(row=0, column=0, sticky=tk.NSEW, padx=PADDING, pady=PADDING)
1144
+ self._ffmpeg_options.grid_remove()
1145
+ else:
1146
+ self._ffmpeg_options.grid(row=0, sticky=tk.NSEW, padx=PADDING, pady=PADDING)
1147
+ self._opencv_options.grid_remove()
1148
+
1149
+ self._on_text_overlay()
1150
+ self._on_bounding_box()
1151
+ self._options_window.transient(self._root)
1152
+ self._options_window.deiconify()
1153
+ self._options_window.focus()
1154
+ self._options_window.grab_set()
1155
+ self._options_window.wait_window()
1156
+
1157
+ def _dismiss_options(self):
1158
+ self._options_window.withdraw()
1159
+ self._options_window.grab_release()
1160
+ self._options_button.focus()
1161
+
1162
+ def _on_select(self):
1163
+ output_path = tkinter.filedialog.askdirectory(title="Set Output Directory", mustexist=True)
1164
+ if output_path:
1165
+ self._output_dir_label.set(output_path)
1166
+ self._output_dir_str = output_path
1167
+ self._clear_button["state"] = tk.NORMAL
1168
+
1169
+ def _clear_output_directory(self):
1170
+ self._output_dir_label.set("Ask Me")
1171
+ self._output_dir_str = ""
1172
+ self._clear_button["state"] = tk.DISABLED
1173
+
1174
+ def _on_text_overlay(self):
1175
+ self._timecode_checkbutton["state"] = tk.NORMAL
1176
+ self._frame_metrics_checkbutton["state"] = tk.NORMAL
1177
+ state = tk.NORMAL if (self._frame_metrics.get() or self._timecode.get()) else tk.DISABLED
1178
+ self._text_border["state"] = state
1179
+ self._text_font_scale["state"] = state
1180
+ self._text_font_thickness["state"] = state
1181
+ self._text_margin["state"] = state
1182
+ self._text_bg_color["state"] = state
1183
+ self._text_font_color["state"] = state
1184
+
1185
+ def _on_bounding_box(self):
1186
+ self._bounding_box_button["state"] = tk.NORMAL
1187
+ state = tk.NORMAL if self._bounding_box.get() else tk.DISABLED
1188
+ self._bounding_box_color["state"] = state
1189
+ self._bounding_box_thickness["state"] = state
1190
+ self._bounding_box_smooth_time["state"] = state
1191
+ self._bounding_box_min_size["state"] = state
1192
+
1193
+ def _on_mode_combo_selected(self):
1194
+ self._options_button["state"] = (
1195
+ tk.DISABLED if self._output_mode == OutputMode.COPY else tk.NORMAL
1196
+ )
1197
+ combine_allowed = self._output_mode is OutputMode.OPENCV
1198
+ self._combine_button["state"] = tk.NORMAL if combine_allowed else tk.DISABLED
1199
+
1200
+ @property
1201
+ def combine(self) -> bool:
1202
+ return self._output_mode == OutputMode.OPENCV and self._combine.get()
1203
+
1204
+ @property
1205
+ def _output_mode(self) -> OutputMode:
1206
+ index = self._output_mode_combo.current()
1207
+ if index == 0:
1208
+ return OutputMode.OPENCV
1209
+ elif index == 1:
1210
+ return OutputMode.FFMPEG
1211
+ else:
1212
+ return OutputMode.COPY
1213
+
1214
+ @_output_mode.setter
1215
+ def _output_mode(self, newval: OutputMode):
1216
+ assert newval is not OutputMode.SCAN_ONLY # Scan only is a separate checkbox in the UI.
1217
+ if newval is OutputMode.OPENCV:
1218
+ self._output_mode_combo.current(0)
1219
+ elif newval is OutputMode.FFMPEG:
1220
+ self._output_mode_combo.current(1)
1221
+ elif newval is OutputMode.COPY:
1222
+ self._output_mode_combo.current(2)
1223
+
1224
+ @property
1225
+ def _output_dir(self) -> str:
1226
+ return self._output_dir_str
1227
+
1228
+ @_output_dir.setter
1229
+ def _output_dir(self, newval: str):
1230
+ if newval:
1231
+ self._output_dir_str = newval
1232
+ self._output_dir_label.set(newval)
1233
+ self._clear_button["state"] = tk.NORMAL
1234
+ else:
1235
+ self._output_dir_label.set("Ask Me")
1236
+ self._output_dir_str = ""
1237
+ self._clear_button["state"] = tk.DISABLED
1238
+
1239
+ def load(self, settings: ScanSettings):
1240
+ output_mode = OutputMode[settings.get("output-mode").upper()]
1241
+ if output_mode is not OutputMode.SCAN_ONLY:
1242
+ self._output_mode = output_mode
1243
+ output_dir = settings.get("output-dir")
1244
+ if output_dir:
1245
+ self._output_dir = output_dir
1246
+ self._ffmpeg_input_args.set(settings.get("ffmpeg-input-args"))
1247
+ self._ffmpeg_output_args.set(settings.get("ffmpeg-output-args"))
1248
+ self._opencv_codec.set(settings.get("opencv-codec"))
1249
+ # Text Overlays
1250
+ self._timecode.set(settings.get("time-code"))
1251
+ self._frame_metrics.set(settings.get("frame-metrics"))
1252
+ self._text_font_color.set(settings.get("text-font-color"))
1253
+ self._text_bg_color.set(settings.get("text-bg-color"))
1254
+ self._text_font_scale.set(settings.get("text-font-scale"))
1255
+ self._text_font_thickness.set(settings.get("text-font-thickness"))
1256
+ self._text_border.set(settings.get("text-border"))
1257
+ self._text_margin.set(settings.get("text-margin"))
1258
+ # Bounding Box
1259
+ self._bounding_box.set(settings.get("bounding-box"))
1260
+ self._bounding_box_color.set(settings.get("bounding-box-color"))
1261
+ self._bounding_box_min_size.set(settings.get("bounding-box-min-size"))
1262
+ self._bounding_box_smooth_time.set(settings.get("bounding-box-smooth-time"))
1263
+ self._bounding_box_thickness.set(settings.get("bounding-box-thickness"))
1264
+ self._on_mode_combo_selected()
1265
+
1266
+ def save(self, settings: ScanSettings) -> ScanSettings:
1267
+ settings.set("output-mode", self._output_mode)
1268
+ if self._output_dir:
1269
+ settings.set("output-dir", self._output_dir)
1270
+ if self._output_mode == OutputMode.FFMPEG:
1271
+ settings.set("ffmpeg-input-args", self._ffmpeg_input_args.get())
1272
+ settings.set("ffmpeg-output-args", self._ffmpeg_output_args.get())
1273
+ elif self._output_mode == OutputMode.OPENCV:
1274
+ settings.set("opencv-codec", self._opencv_codec.get())
1275
+ # Text Overlays
1276
+ settings.set("time-code", self._timecode.get())
1277
+ settings.set("frame-metrics", self._frame_metrics.get())
1278
+ settings.set("text-font-color", self._text_font_color.get())
1279
+ settings.set("text-bg-color", self._text_bg_color.get())
1280
+ settings.set("text-font-scale", float(self._text_font_scale.get()))
1281
+ settings.set("text-font-thickness", int(self._text_font_thickness.get()))
1282
+ settings.set("text-border", int(self._text_border.get()))
1283
+ settings.set("text-margin", int(self._text_margin.get()))
1284
+ # Bounding Box
1285
+ settings.set("bounding-box", self._bounding_box.get())
1286
+ settings.set("bounding-box-smooth-time", self._bounding_box_smooth_time.get())
1287
+ settings.set("bounding-box-color", self._bounding_box_color.get())
1288
+ settings.set("bounding-box-min-size", float(self._bounding_box_min_size.get()))
1289
+ settings.set("bounding-box-thickness", float(self._bounding_box_thickness.get()))
1290
+ return settings
1291
+
1292
+
1293
+ class ScanArea:
1294
+ def __init__(self, root: tk.Tk, frame: tk.Widget):
1295
+ frame.columnconfigure(0, weight=1)
1296
+ frame.columnconfigure(1, weight=1)
1297
+ frame.columnconfigure(2, weight=4)
1298
+
1299
+ self._start_button = ttk.Button(
1300
+ frame,
1301
+ text="Start",
1302
+ command=lambda: root.event_generate("<<StartScan>>"),
1303
+ width=LARGE_BUTTON_WIDTH,
1304
+ )
1305
+ self._start_button.grid(
1306
+ row=0,
1307
+ column=0,
1308
+ columnspan=2,
1309
+ sticky=tk.NSEW,
1310
+ ipady=PADDING,
1311
+ pady=(0, PADDING),
1312
+ )
1313
+ self._scan_only = tk.BooleanVar(frame, value=False)
1314
+ self._scan_only_button = ttk.Checkbutton(
1315
+ frame,
1316
+ text="Scan Only",
1317
+ variable=self._scan_only,
1318
+ onvalue=True,
1319
+ offvalue=False,
1320
+ )
1321
+ self._scan_only_button.grid(row=1, column=0, sticky=tk.W)
1322
+
1323
+ self._open_on_completion = tk.BooleanVar(frame, value=True)
1324
+ self._open_on_completion_button = ttk.Checkbutton(
1325
+ frame,
1326
+ text="Open folder on completion",
1327
+ variable=self._open_on_completion,
1328
+ onvalue=True,
1329
+ offvalue=False,
1330
+ )
1331
+ self._open_on_completion_button.grid(row=1, column=1, sticky=tk.W)
1332
+
1333
+ def disable(self):
1334
+ self._start_button["text"] = "Scanning..."
1335
+ self._start_button["state"] = tk.DISABLED
1336
+ self._scan_only_button["state"] = tk.DISABLED
1337
+ self._open_on_completion_button["state"] = tk.DISABLED
1338
+
1339
+ def enable(self):
1340
+ self._start_button["text"] = "Start"
1341
+ self._start_button["state"] = tk.NORMAL
1342
+ self._scan_only_button["state"] = tk.NORMAL
1343
+ self._open_on_completion_button["state"] = tk.NORMAL
1344
+
1345
+ @property
1346
+ def scan_only(self) -> bool:
1347
+ return self._scan_only.get()
1348
+
1349
+ @scan_only.setter
1350
+ def scan_only(self, newval: bool):
1351
+ self._scan_only.set(newval)
1352
+
1353
+ @property
1354
+ def open_on_completion(self) -> bool:
1355
+ return self._open_on_completion.get()
1356
+
1357
+ @open_on_completion.setter
1358
+ def open_on_completion(self, newval: bool):
1359
+ self._open_on_completion.set(newval)
1360
+
1361
+ def save(self, settings: ScanSettings) -> ScanSettings:
1362
+ settings.set("scan-only", self.scan_only)
1363
+ settings.set("open-output-dir", self.open_on_completion)
1364
+ return settings
1365
+
1366
+ def load(self, settings: ScanSettings):
1367
+ if OutputMode[settings.get("output-mode").upper()] == OutputMode.SCAN_ONLY:
1368
+ self.scan_only = True
1369
+ self.open_on_completion = settings.get("open-output-dir")
1370
+
1371
+
1372
+ class Application:
1373
+ def run(self):
1374
+ logger.debug("starting main loop")
1375
+ self._root.deiconify()
1376
+ self._root.focus()
1377
+ self._root.mainloop()
1378
+
1379
+ def __init__(self, settings: ScanSettings, initial_videos: ty.List[str]):
1380
+ self._root = tk.Tk()
1381
+ self._root.withdraw()
1382
+ self._settings: ScanSettings = None
1383
+
1384
+ self._root.option_add("*tearOff", False)
1385
+ self._root.title(WINDOW_TITLE)
1386
+ register_icon(self._root)
1387
+ self._root.resizable(True, True)
1388
+ self._root.minsize(width=MIN_WINDOW_WIDTH, height=MIN_WINDOW_HEIGHT)
1389
+ self._root.columnconfigure(0, weight=1, pad=PADDING)
1390
+ self._root.rowconfigure(0, weight=1)
1391
+
1392
+ self._input_settings_window = InputSettingsWindow(self._root)
1393
+ self._motion_settings_window = MotionSettingsWindow(self._root)
1394
+
1395
+ input_frame = ttk.Labelframe(self._root, text="Input", padding=PADDING)
1396
+ self._input_area = InputArea(input_frame)
1397
+ input_frame.grid(row=0, sticky=tk.NSEW, padx=PADDING, pady=(PADDING, 0))
1398
+
1399
+ output_frame = ttk.Labelframe(self._root, text="Output", padding=PADDING)
1400
+ self._output_area = OutputArea(output_frame)
1401
+ output_frame.grid(row=2, sticky=EXPAND_HORIZONTAL, padx=PADDING, pady=(PADDING, 0))
1402
+
1403
+ scan_frame = ttk.Labelframe(self._root, text="Run", padding=PADDING)
1404
+ self._scan_area = ScanArea(self._root, scan_frame)
1405
+ scan_frame.grid(row=3, sticky=EXPAND_HORIZONTAL, padx=PADDING, pady=PADDING)
1406
+
1407
+ self._scan_window: ty.Optional[ScanWindow] = None
1408
+ self._root.bind("<<StartScan>>", lambda _: self._start_scan())
1409
+ self._root.protocol("WM_DELETE_WINDOW", self._destroy)
1410
+
1411
+ self._create_menubar()
1412
+
1413
+ # Make sure we don't suppress exceptions in debug mode.
1414
+ # TODO: This should probably use the logger in release mode rather than the default from Tk.
1415
+ if settings.get("debug"):
1416
+
1417
+ def error_handler(*args):
1418
+ raise
1419
+
1420
+ self._root.report_callback_exception = error_handler
1421
+
1422
+ # Initialize UI state from config.
1423
+ self._initialize(settings)
1424
+ for path in initial_videos:
1425
+ self._input_area._add_video(path)
1426
+
1427
+ def _create_menubar(self):
1428
+ root_menu = tk.Menu(self._root)
1429
+ self._root["menu"] = root_menu
1430
+
1431
+ file_menu = tk.Menu(root_menu)
1432
+ root_menu.add_cascade(menu=file_menu, label="File", underline=0)
1433
+
1434
+ file_menu.add_command(
1435
+ label="Start Scan",
1436
+ underline=0,
1437
+ command=lambda: self._root.event_generate("<<StartScan>>"),
1438
+ )
1439
+ file_menu.add_separator()
1440
+ file_menu.add_command(
1441
+ label="Quit",
1442
+ underline=0,
1443
+ command=self._destroy,
1444
+ )
1445
+
1446
+ settings_menu = tk.Menu(root_menu)
1447
+ root_menu.add_cascade(menu=settings_menu, label="Settings", underline=0)
1448
+ settings_menu.add_command(
1449
+ label="Input",
1450
+ underline=0,
1451
+ command=self._input_settings_window.show,
1452
+ )
1453
+ settings_menu.add_command(
1454
+ label="Motion",
1455
+ underline=0,
1456
+ command=self._motion_settings_window.show,
1457
+ )
1458
+ settings_menu.add_separator()
1459
+ settings_menu.add_command(
1460
+ label="Load...",
1461
+ underline=0,
1462
+ command=self._load_config,
1463
+ )
1464
+ settings_menu.add_command(label="Save...", underline=0, command=self._on_save_config)
1465
+ settings_menu.add_command(
1466
+ label="Save As User Default", underline=2, command=self._on_save_config_as_user_default
1467
+ )
1468
+ settings_menu.add_separator()
1469
+ settings_menu.add_command(
1470
+ label="Reset To User Default", underline=12, command=self._reset_config
1471
+ )
1472
+ settings_menu.add_command(
1473
+ label="Reset To Program Default",
1474
+ underline=0,
1475
+ command=lambda: self._reset_config(program_default=True),
1476
+ )
1477
+
1478
+ help_menu = tk.Menu(root_menu)
1479
+ root_menu.add_cascade(menu=help_menu, label="Help", underline=0)
1480
+ help_menu.add_command(
1481
+ label="Help Guide",
1482
+ command=lambda: webbrowser.open_new_tab("www.dvr-scan.com/guide"),
1483
+ underline=0,
1484
+ )
1485
+ help_menu.add_command(
1486
+ label="Open Discord",
1487
+ command=lambda: webbrowser.open_new_tab("https://discord.gg/69kf6f2Exb"),
1488
+ underline=5,
1489
+ )
1490
+ help_menu.add_separator()
1491
+ help_menu.add_command(
1492
+ label="Open Logs",
1493
+ command=lambda: open_path(logfile_path("dvr-scan.log").parent),
1494
+ underline=5,
1495
+ )
1496
+ # TODO: Add window to show log messages and copy them to clipboard or save to a logfile.
1497
+ # help_menu.add_command(label="Debug Log", underline=0, state=tk.DISABLED)
1498
+
1499
+ help_menu.add_command(
1500
+ label="About DVR-Scan",
1501
+ command=lambda: AboutWindow().show(root=self._root),
1502
+ underline=0,
1503
+ )
1504
+
1505
+ def _destroy(self):
1506
+ logger.debug("shutting down")
1507
+ if self._scan_window is not None:
1508
+ # NOTE: We do not actually wait here,
1509
+ logger.debug("waiting for worker threads")
1510
+ # Signal all active worker threads to start shutting down.
1511
+ self._root.event_generate("<<Shutdown>>")
1512
+ # Make sure they actually have stopped.
1513
+ self._root.after(0, lambda: self._scan_window.stop())
1514
+ if self._input_area._region_editor:
1515
+ self._input_area._region_editor.prompt_save_on_scan(self._root)
1516
+ self._root.after(0, lambda: self._root.destroy())
1517
+ self._root.withdraw()
1518
+
1519
+ def _start_scan(self):
1520
+ # It should not be possible to start two scans in parallel with the current UI.
1521
+ # Once we start a scan, the scan window should grab input focus until it is closed.
1522
+ assert self._scan_window is None
1523
+
1524
+ # TODO: Instead of just returning None below if the settings are invalid or we can't start
1525
+ # the scan (e.g. no input videos selected), we should throw an exception and catch it here.
1526
+ # We should then display a messagebox to the user indicating why the scan couldn't start.
1527
+ settings = self._get_settings()
1528
+ if not settings:
1529
+ return
1530
+
1531
+ logger.debug(f"ui settings:\n{settings.app_settings}")
1532
+
1533
+ def on_closed():
1534
+ logger.debug("scan window closed, restoring focus")
1535
+ self._scan_window = None
1536
+ self._scan_area.enable()
1537
+ self._root.deiconify()
1538
+ self._root.focus()
1539
+
1540
+ try:
1541
+ self._scan_window = ScanWindow(self._root, settings, on_closed, PADDING)
1542
+ except BackendUnavailable:
1543
+ tkinter.messagebox.showerror(
1544
+ title="Input Mode Unavailable",
1545
+ message=f"The specified input mode ({settings.get('input-mode')}) "
1546
+ "is not available on this system.",
1547
+ )
1548
+ return
1549
+
1550
+ self._scan_area.disable()
1551
+ self._scan_window.show()
1552
+
1553
+ def _load_config(self):
1554
+ load_path = tkinter.filedialog.askopenfilename(
1555
+ title="Load Config File...",
1556
+ filetypes=[("Config File", "*.cfg")],
1557
+ defaultextension=".cfg",
1558
+ parent=self._root,
1559
+ )
1560
+ if not load_path:
1561
+ return
1562
+ try:
1563
+ config = ConfigRegistry()
1564
+ config.load(load_path)
1565
+ except ConfigLoadFailure as ex:
1566
+ for log_level, log_str in ex.init_log:
1567
+ logger.log(log_level, log_str)
1568
+ tkinter.messagebox.showerror(
1569
+ title="Config Load Failure",
1570
+ message="Invalid config file. See log messages for details.",
1571
+ )
1572
+ return
1573
+
1574
+ for log_level, log_str in config.consume_init_log():
1575
+ logger.log(log_level, log_str)
1576
+ self._reload_config(config)
1577
+
1578
+ def _reset_config(self, program_default: bool = False):
1579
+ if not tkinter.messagebox.askyesno(
1580
+ title="Reset Settings",
1581
+ message="All settings will be reset. Do you want to continue?",
1582
+ icon=tkinter.messagebox.WARNING,
1583
+ ):
1584
+ return
1585
+ try:
1586
+ config = ConfigRegistry()
1587
+ if not program_default:
1588
+ config.load()
1589
+ except ConfigLoadFailure as ex:
1590
+ for log_level, log_str in ex.init_log:
1591
+ logger.log(log_level, log_str)
1592
+ tkinter.messagebox.showerror(
1593
+ title="Config Load Failure",
1594
+ message="Failed to load specified config file. See log messages for details.",
1595
+ )
1596
+ return
1597
+
1598
+ for log_level, log_str in config.consume_init_log():
1599
+ logger.log(log_level, log_str)
1600
+ self._reload_config(config)
1601
+
1602
+ def _reload_config(self, config: ConfigRegistry):
1603
+ """Reinitialize UI from another config."""
1604
+ self._initialize(ScanSettings(args=self._settings._args, config=config))
1605
+
1606
+ def _initialize(self, settings: ScanSettings):
1607
+ """Initialize UI from both UI command-line arguments and config file."""
1608
+ logger.debug("initializing UI state from settings")
1609
+ # Store copy of settings internally.
1610
+ self._settings = settings
1611
+ if settings.get("load-region"):
1612
+ tkinter.messagebox.showwarning(
1613
+ title="Regions Not Loaded",
1614
+ message="Warning: region file from config was not loaded.\n\n"
1615
+ "You can load it from the Region Editor.",
1616
+ )
1617
+ self._input_settings_window.set(settings)
1618
+ self._motion_settings_window.set(settings)
1619
+ self._output_area.load(settings)
1620
+ self._scan_area.load(settings)
1621
+
1622
+ def _on_save_config(self):
1623
+ save_path = tkinter.filedialog.asksaveasfilename(
1624
+ title="Save Config File...",
1625
+ filetypes=[("Config File", "*.cfg")],
1626
+ defaultextension=".cfg",
1627
+ confirmoverwrite=True,
1628
+ parent=self._root,
1629
+ )
1630
+ if not save_path:
1631
+ return
1632
+ settings = self._get_config_settings()
1633
+ logger.debug(f"saving config to {save_path}")
1634
+ with open(save_path, "w") as file:
1635
+ settings.write_to_file(file)
1636
+
1637
+ def _on_save_config_as_user_default(self):
1638
+ if not tkinter.messagebox.askyesno(
1639
+ title="Save As User Default",
1640
+ message="Existing user config will be overwritten. Do you want to continue?",
1641
+ icon=tkinter.messagebox.WARNING,
1642
+ ):
1643
+ return
1644
+
1645
+ settings = self._get_config_settings()
1646
+ logger.debug(f"saving config to {USER_CONFIG_FILE_PATH}")
1647
+ with NamedTemporaryFile(mode="w", delete_on_close=False) as file:
1648
+ settings.write_to_file(file)
1649
+ file.close()
1650
+ Path(file.name).replace(USER_CONFIG_FILE_PATH)
1651
+
1652
+ def _get_config_settings(self) -> ScanSettings:
1653
+ """Get current UI state for writing a config file."""
1654
+ settings = ScanSettings(args=None, config=ConfigRegistry())
1655
+ settings = self._input_settings_window.update(settings)
1656
+ settings = self._motion_settings_window.update(settings)
1657
+ settings = self._output_area.save(settings)
1658
+ settings = self._scan_area.save(settings)
1659
+ return settings
1660
+
1661
+ def _get_settings(self) -> ty.Optional[ScanSettings]:
1662
+ """Get current UI state with all options to run a scan."""
1663
+ settings = copy.deepcopy(self._settings)
1664
+ settings = self._input_area.update(settings)
1665
+ if not settings:
1666
+ return None # No videos to process.
1667
+ settings = self._input_settings_window.update(settings)
1668
+ settings = self._motion_settings_window.update(settings)
1669
+ settings = self._output_area.save(settings)
1670
+ settings = self._scan_area.save(settings)
1671
+
1672
+ # Check if we are going to create any output files.
1673
+ # *NOTE*: Depending on the user's settings, we may generate output files even in scan-only
1674
+ # mode (e.g. if the user is generating a mask file).
1675
+ if not settings.get("output-dir") and (
1676
+ not settings.get("scan-only")
1677
+ or settings.get("mask-output")
1678
+ or self._output_area.combine
1679
+ ):
1680
+ # We will create files but an output directory wasn't set ahead of time - prompt the
1681
+ # user to select one.
1682
+ output_dir = tkinter.filedialog.askdirectory(
1683
+ title="Set Output Directory", mustexist=True
1684
+ )
1685
+ if not output_dir:
1686
+ return None
1687
+ settings.set("output-dir", output_dir)
1688
+
1689
+ video_name = Path(settings.get("input")[0]).stem
1690
+ if self._output_area.combine:
1691
+ settings.set("output", f"{video_name}-events.avi")
1692
+
1693
+ if settings.get("mask-output"):
1694
+ settings.set("mask-output", f"{video_name}-mask.avi")
1695
+
1696
+ if not self._input_area.concatenate and len(settings.get_arg("input")) > 1:
1697
+ logger.error("ERROR - TODO: Handle non-concatenated inputs.")
1698
+ return None
1699
+
1700
+ return settings