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.
- dvr_scan/LICENSE +47 -0
- dvr_scan/__init__.py +55 -0
- dvr_scan/__main__.py +80 -0
- dvr_scan/app/__init__.py +10 -0
- dvr_scan/app/__main__.py +218 -0
- dvr_scan/app/about_window.py +137 -0
- dvr_scan/app/application.py +1700 -0
- dvr_scan/app/common.py +31 -0
- dvr_scan/app/region_editor.py +1416 -0
- dvr_scan/app/scan_window.py +258 -0
- dvr_scan/app/widgets.py +213 -0
- dvr_scan/cli.py +511 -0
- dvr_scan/config.py +607 -0
- dvr_scan/controller.py +215 -0
- dvr_scan/detector.py +124 -0
- dvr_scan/docs/404.html +1 -0
- dvr_scan/docs/assets/javascripts/bundle.88dd0f4e.min.js +15 -0
- dvr_scan/docs/assets/stylesheets/main.6f8fc17f.min.css +1 -0
- dvr_scan/docs/assets/stylesheets/palette.06af60db.min.css +1 -0
- dvr_scan/docs/changelog.html +1 -0
- dvr_scan/docs/docs.html +73 -0
- dvr_scan/docs/faq.html +13 -0
- dvr_scan/docs/guide.html +1 -0
- dvr_scan/docs/index.html +5 -0
- dvr_scan/docs/resources.html +30 -0
- dvr_scan/docs/style.min.css +1 -0
- dvr_scan/dvr-scan-logo.png +0 -0
- dvr_scan/dvr-scan.ico +0 -0
- dvr_scan/dvr-scan.png +0 -0
- dvr_scan/opencv_loader.py +37 -0
- dvr_scan/overlays.py +251 -0
- dvr_scan/platform.py +236 -0
- dvr_scan/region.py +65 -0
- dvr_scan/scanner.py +1303 -0
- dvr_scan/shared/__init__.py +287 -0
- dvr_scan/shared/cli.py +291 -0
- dvr_scan/shared/settings.py +72 -0
- dvr_scan/subtractor.py +163 -0
- dvr_scan/video_joiner.py +188 -0
- dvr_scan_headless-1.8.dist-info/METADATA +119 -0
- dvr_scan_headless-1.8.dist-info/RECORD +45 -0
- dvr_scan_headless-1.8.dist-info/WHEEL +5 -0
- dvr_scan_headless-1.8.dist-info/entry_points.txt +2 -0
- dvr_scan_headless-1.8.dist-info/licenses/LICENSE +47 -0
- 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
|