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,1416 @@
|
|
|
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 math
|
|
13
|
+
import os
|
|
14
|
+
import os.path
|
|
15
|
+
import sys
|
|
16
|
+
import tkinter as tk
|
|
17
|
+
import tkinter.filedialog
|
|
18
|
+
import tkinter.messagebox
|
|
19
|
+
import tkinter.scrolledtext
|
|
20
|
+
import tkinter.ttk as ttk
|
|
21
|
+
import typing as ty
|
|
22
|
+
import webbrowser
|
|
23
|
+
from copy import deepcopy
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from logging import getLogger
|
|
26
|
+
|
|
27
|
+
import cv2
|
|
28
|
+
import numpy as np
|
|
29
|
+
import PIL
|
|
30
|
+
import PIL.Image
|
|
31
|
+
import PIL.ImageTk
|
|
32
|
+
|
|
33
|
+
import dvr_scan
|
|
34
|
+
from dvr_scan.app.about_window import AboutWindow
|
|
35
|
+
from dvr_scan.app.common import register_icon
|
|
36
|
+
from dvr_scan.region import Point, Size, bound_point, load_regions
|
|
37
|
+
|
|
38
|
+
WINDOW_TITLE = "DVR-Scan Region Editor"
|
|
39
|
+
OWNED_WINDOW_TITLE = "Region Editor"
|
|
40
|
+
PROMPT_TITLE = "DVR-Scan"
|
|
41
|
+
PROMPT_MESSAGE = "You have unsaved region changes.\nDo you want to save them?"
|
|
42
|
+
SAVE_TITLE = "Save Region File"
|
|
43
|
+
LOAD_TITLE = "Load Region File"
|
|
44
|
+
SAVE_REGIONS = "Save Regions"
|
|
45
|
+
SAVE_REGIONS_PROMPT = "Save Regions..."
|
|
46
|
+
ABOUT_WINDOW_COPYRIGHT = (
|
|
47
|
+
f"DVR-Scan {dvr_scan.__version__}\n\nCopyright © Brandon Castellano.\nAll rights reserved."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# TODO: Need to figure out DPI scaling for *everything*. Lots of magic numbers for sizes right now.
|
|
51
|
+
MIN_SIZE = 16
|
|
52
|
+
"""Minimum height/width for a ROI created using the mouse."""
|
|
53
|
+
|
|
54
|
+
logger = getLogger("dvr_scan")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Snapshot:
|
|
59
|
+
regions: ty.List[ty.List[Point]]
|
|
60
|
+
active_shape: ty.Optional[int]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# TODO: Allow controlling some of these settings in the config file.
|
|
64
|
+
@dataclass
|
|
65
|
+
class EditorSettings:
|
|
66
|
+
video_path: str
|
|
67
|
+
"""The first input video path specified by -i/--input."""
|
|
68
|
+
save_path: ty.Optional[str] = False
|
|
69
|
+
"""The path specified by the -s/--save-regions option if set."""
|
|
70
|
+
use_aa: bool = True
|
|
71
|
+
mask_source: bool = False
|
|
72
|
+
line_color: ty.Tuple[int, int, int] = (255, 0, 0)
|
|
73
|
+
line_color_alt: ty.Tuple[int, int, int] = (255, 153, 51)
|
|
74
|
+
line_color_inactive: ty.Tuple[int, int, int] = (172, 80, 24)
|
|
75
|
+
hover_color: ty.Tuple[int, int, int] = (0, 127, 255)
|
|
76
|
+
hover_color_alt: ty.Tuple[int, int, int] = (0, 0, 255)
|
|
77
|
+
interact_color: ty.Tuple[int, int, int] = (0, 255, 255)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class AutoHideScrollbar(tk.Scrollbar):
|
|
81
|
+
def set(self, lo, hi):
|
|
82
|
+
if float(lo) <= 0.0 and float(hi) >= 1.0:
|
|
83
|
+
self.grid_remove()
|
|
84
|
+
else:
|
|
85
|
+
self.grid()
|
|
86
|
+
tk.Scrollbar.set(self, lo, hi)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
MIN_NUM_POINTS = 3
|
|
90
|
+
MAX_HISTORY_SIZE = 1024
|
|
91
|
+
MIN_DOWNSCALE_FACTOR = 1
|
|
92
|
+
MAX_DOWNSCALE_FACTOR = 10
|
|
93
|
+
MAX_UPDATE_RATE_NORMAL = 20
|
|
94
|
+
MAX_UPDATE_RATE_DRAGGING = 5
|
|
95
|
+
HOVER_DISPLAY_DISTANCE = 260**2
|
|
96
|
+
MAX_DOWNSCALE_AA_LEVEL = 4
|
|
97
|
+
|
|
98
|
+
ACCELERATOR_KEY = "Command" if sys.platform == "darwin" else "Ctrl"
|
|
99
|
+
KEYBIND_POINT_ADD = "+"
|
|
100
|
+
KEYBIND_POINT_ADD_ALT1 = "="
|
|
101
|
+
KEYBIND_POINT_ADD_ALT2 = "KP_Add"
|
|
102
|
+
KEYBIND_POINT_DELETE = "-"
|
|
103
|
+
KEYBIND_POINT_DELETE_ALT1 = "_"
|
|
104
|
+
KEYBIND_POINT_DELETE_ALT2 = "KP_Minus"
|
|
105
|
+
KEYBIND_REGION_ADD = f"{ACCELERATOR_KEY}+N"
|
|
106
|
+
KEYBIND_REGION_DELETE = f"{ACCELERATOR_KEY}+D"
|
|
107
|
+
KEYBIND_REGION_NEXT = "Tab"
|
|
108
|
+
KEYBIND_REGION_PREVIOUS = "Shift+Tab"
|
|
109
|
+
KEYBIND_TOGGLE_AA = f"{ACCELERATOR_KEY}+A"
|
|
110
|
+
KEYBIND_DOWNSCALE_INC = f"{ACCELERATOR_KEY}+(+)"
|
|
111
|
+
KEYBIND_DOWNSCALE_DEC = f"{ACCELERATOR_KEY}+(-)"
|
|
112
|
+
KEYBIND_BREAKPOINT = f"{ACCELERATOR_KEY}+B"
|
|
113
|
+
KEYBIND_MASK = f"{ACCELERATOR_KEY}+M"
|
|
114
|
+
|
|
115
|
+
KEYBIND_LOAD = f"{ACCELERATOR_KEY}+O"
|
|
116
|
+
KEYBIND_SAVE = f"{ACCELERATOR_KEY}+S"
|
|
117
|
+
KEYBIND_COPY_COMMAND = f"{ACCELERATOR_KEY}+C"
|
|
118
|
+
KEYBIND_HELP = f"{ACCELERATOR_KEY}+H"
|
|
119
|
+
KEYBIND_QUIT = f"{ACCELERATOR_KEY}+Q"
|
|
120
|
+
KEYBIND_START_SCAN = f"{ACCELERATOR_KEY}+Space"
|
|
121
|
+
|
|
122
|
+
KEYBIND_UNDO = "Ctrl+Z" if os.name == "nt" else f"{ACCELERATOR_KEY}+Z"
|
|
123
|
+
KEYBIND_REDO = "Ctrl+Y" if os.name == "nt" else f"{ACCELERATOR_KEY}+Shift+Z"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def control_handle_radius(scale: int):
|
|
127
|
+
"""Get size of point control handles based on scale factor."""
|
|
128
|
+
# TODO: This should be based on the video resolution in addition to scale factor.
|
|
129
|
+
if scale == 1:
|
|
130
|
+
return 12
|
|
131
|
+
elif scale == 2:
|
|
132
|
+
return 7
|
|
133
|
+
elif scale == 3:
|
|
134
|
+
return 5
|
|
135
|
+
elif scale <= 10:
|
|
136
|
+
return 4
|
|
137
|
+
elif scale <= 20:
|
|
138
|
+
return 3
|
|
139
|
+
elif scale <= 64:
|
|
140
|
+
return 2
|
|
141
|
+
return 1
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def edge_thickness(scale: int, ext: int = 0):
|
|
145
|
+
"""Get thickness of polygon connecting edges based on scale factor."""
|
|
146
|
+
# TODO: This should be based on the video resolution in addition to scale factor.
|
|
147
|
+
if scale < 2:
|
|
148
|
+
return 4 + ext
|
|
149
|
+
elif scale < 5:
|
|
150
|
+
return 3 + ext
|
|
151
|
+
elif scale <= 10:
|
|
152
|
+
return 2 + ext
|
|
153
|
+
return 1 + ext if scale <= 20 else 0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def initial_point_list(frame_size: Size) -> ty.List[Point]:
|
|
157
|
+
# For now start with a rectangle covering 1/4 of the frame in the middle.
|
|
158
|
+
top_left = Point(x=frame_size.w // 4, y=frame_size.h // 4)
|
|
159
|
+
box_size = Size(w=frame_size.w // 2, h=frame_size.h // 2)
|
|
160
|
+
return [
|
|
161
|
+
top_left,
|
|
162
|
+
Point(x=top_left.x + box_size.w, y=top_left.y),
|
|
163
|
+
Point(x=top_left.x + box_size.w, y=top_left.y + box_size.h),
|
|
164
|
+
Point(x=top_left.x, y=top_left.y + box_size.h),
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def squared_distance(a: Point, b: Point) -> int:
|
|
169
|
+
return (a.x - b.x) ** 2 + (a.y - b.y) ** 2
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# TODO: Allow translating polygons using middle mouse button.
|
|
173
|
+
class RegionEditor:
|
|
174
|
+
def __init__(
|
|
175
|
+
self,
|
|
176
|
+
frame: np.ndarray,
|
|
177
|
+
initial_shapes: ty.Optional[ty.List[ty.List[Point]]],
|
|
178
|
+
initial_scale: ty.Optional[int],
|
|
179
|
+
debug_mode: bool,
|
|
180
|
+
video_path: str,
|
|
181
|
+
save_path: ty.Optional[str],
|
|
182
|
+
on_close: ty.Optional[ty.Callable[[], None]] = None,
|
|
183
|
+
):
|
|
184
|
+
self._settings = EditorSettings(video_path=video_path, save_path=save_path)
|
|
185
|
+
self._source_frame: np.ndarray = frame.copy() # Frame before downscaling
|
|
186
|
+
self._source_size: Size = Size(w=frame.shape[1], h=frame.shape[0])
|
|
187
|
+
self._frame: np.ndarray = frame.copy() # Workspace
|
|
188
|
+
self._frame_size: Size = Size(w=frame.shape[1], h=frame.shape[0])
|
|
189
|
+
self._original_frame: np.ndarray = frame.copy() # Copy to redraw on
|
|
190
|
+
self._regions: ty.List[ty.List[Point]] = (
|
|
191
|
+
initial_shapes if initial_shapes else [initial_point_list(self._frame_size)]
|
|
192
|
+
)
|
|
193
|
+
self._active_shape: int = len(self._regions) - 1
|
|
194
|
+
self._history: ty.List[Snapshot] = []
|
|
195
|
+
self._history_pos: int = 0
|
|
196
|
+
|
|
197
|
+
self._curr_mouse_pos: Point = None
|
|
198
|
+
self._hover_point: ty.Optional[int] = None
|
|
199
|
+
self._nearest_points: ty.Optional[ty.Tuple[int, int]] = None
|
|
200
|
+
|
|
201
|
+
self._redraw: bool = True
|
|
202
|
+
self._recalculate: bool = True
|
|
203
|
+
self._dragging: bool = False
|
|
204
|
+
self._drag_start: ty.Optional[Point] = None
|
|
205
|
+
self._debug_mode: bool = debug_mode
|
|
206
|
+
self._segment_dist: ty.List[int] = [] # Square distance of segment from point i to i+1
|
|
207
|
+
self._mouse_dist: ty.List[int] = [] # Square distance of mouse to point i
|
|
208
|
+
self._scale: int = 1 if initial_scale is None else initial_scale
|
|
209
|
+
self._persisted: bool = True # Indicates if we've saved outstanding changes to disk.
|
|
210
|
+
self._persisted_path: str = "" # Path we've saved changes to, if any.
|
|
211
|
+
|
|
212
|
+
self._launched_from_app: bool = False
|
|
213
|
+
self._on_close: ty.Optional[ty.Callable[[], None]] = on_close
|
|
214
|
+
self._root: ty.Union[tk.Tk, tk.Toplevel] = None
|
|
215
|
+
self._edit_menu: tk.Menu = None
|
|
216
|
+
self._editor_window: tk.Toplevel = None
|
|
217
|
+
self._editor_canvas: tk.Canvas = None
|
|
218
|
+
self._editor_scroll: ty.Tuple[tk.Scrollbar, tk.Scrollbar] = None
|
|
219
|
+
self._should_scan: bool = False
|
|
220
|
+
self._scale_widget: ttk.Scale = None
|
|
221
|
+
self._pan_enabled: bool = False
|
|
222
|
+
self._panning: bool = False
|
|
223
|
+
self._controls_window: tk.Toplevel = None
|
|
224
|
+
self._region_selector: ttk.Combobox = None
|
|
225
|
+
|
|
226
|
+
self._context_menu: tk.Menu = None
|
|
227
|
+
# Clones of the normal state variables since sometimes we can still interact with the main
|
|
228
|
+
# window after the context menu is posted.
|
|
229
|
+
self._context_curr_mouse_pos: Point = None
|
|
230
|
+
self._context_hover_point: ty.Optional[int] = None
|
|
231
|
+
self._context_nearest_points: ty.Optional[ty.Tuple[int, int]] = None
|
|
232
|
+
|
|
233
|
+
self._log_stats = False
|
|
234
|
+
self._redraws = 0
|
|
235
|
+
self._recalculates = 0
|
|
236
|
+
|
|
237
|
+
self._commit(persisted=True) # Add initial history for undo.
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def shapes(self) -> ty.Iterable[ty.Iterable[Point]]:
|
|
241
|
+
return self._regions
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def active_region(self) -> ty.Optional[ty.List[Point]]:
|
|
245
|
+
return (
|
|
246
|
+
self._regions[self._active_shape]
|
|
247
|
+
if (self._active_shape is not None and bool(self._regions))
|
|
248
|
+
else None
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
@property
|
|
252
|
+
def persisted(self) -> bool:
|
|
253
|
+
return self._persisted
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def persisted_path(self) -> str:
|
|
257
|
+
return self._persisted_path
|
|
258
|
+
|
|
259
|
+
def _rescale(self, draw=True, allow_resize=True):
|
|
260
|
+
assert self._scale > 0
|
|
261
|
+
logger.info(f"Downscale factor: {self._scale}")
|
|
262
|
+
self._original_frame = self._source_frame[:: self._scale, :: self._scale, :].copy()
|
|
263
|
+
self._frame = self._original_frame.copy()
|
|
264
|
+
self._frame_size = Size(w=self._frame.shape[1], h=self._frame.shape[0])
|
|
265
|
+
self._redraw = True
|
|
266
|
+
self._editor_canvas["scrollregion"] = (0, 0, self._frame.shape[1], self._frame.shape[0])
|
|
267
|
+
if allow_resize:
|
|
268
|
+
max_width_auto = int(self._root.winfo_screenwidth() * 0.8)
|
|
269
|
+
max_height_auto = int(self._root.winfo_screenheight() * 0.8)
|
|
270
|
+
self._editor_canvas["width"] = min(max_width_auto, self._frame.shape[1])
|
|
271
|
+
self._editor_canvas["height"] = min(max_height_auto, self._frame.shape[0])
|
|
272
|
+
else:
|
|
273
|
+
old_geom = self._root.geometry()
|
|
274
|
+
self._editor_canvas["width"] = self._frame.shape[1]
|
|
275
|
+
self._editor_canvas["height"] = self._frame.shape[0]
|
|
276
|
+
self._root.geometry(old_geom)
|
|
277
|
+
|
|
278
|
+
if draw:
|
|
279
|
+
self._draw()
|
|
280
|
+
logger.debug(
|
|
281
|
+
"Resize: scale = 1/%d%s, res = %d x %d",
|
|
282
|
+
self._scale,
|
|
283
|
+
" (off)" if self._scale == 1 else "",
|
|
284
|
+
self._frame_size.w,
|
|
285
|
+
self._frame_size.h,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
def _undo(self):
|
|
289
|
+
if self._history_pos < (len(self._history) - 1):
|
|
290
|
+
self._history_pos += 1
|
|
291
|
+
snapshot = deepcopy(self._history[self._history_pos])
|
|
292
|
+
self._regions = snapshot.regions
|
|
293
|
+
self._active_shape = snapshot.active_shape
|
|
294
|
+
self._recalculate = True
|
|
295
|
+
self._redraw = True
|
|
296
|
+
self._draw()
|
|
297
|
+
logger.debug("Undo: [%d/%d]", self._history_pos, len(self._history) - 1)
|
|
298
|
+
self._update_ui_state()
|
|
299
|
+
|
|
300
|
+
def _redo(self):
|
|
301
|
+
if self._history_pos > 0:
|
|
302
|
+
self._history_pos -= 1
|
|
303
|
+
snapshot = deepcopy(self._history[self._history_pos])
|
|
304
|
+
self._regions = snapshot.regions
|
|
305
|
+
self._active_shape = snapshot.active_shape
|
|
306
|
+
self._recalculate = True
|
|
307
|
+
self._redraw = True
|
|
308
|
+
self._draw()
|
|
309
|
+
logger.debug("Redo: [%d/%d]", self._history_pos, len(self._history) - 1)
|
|
310
|
+
self._update_ui_state()
|
|
311
|
+
|
|
312
|
+
def _commit(self, persisted=False):
|
|
313
|
+
# Take a copy of the current state and put it in the history buffer.
|
|
314
|
+
snapshot = deepcopy(Snapshot(regions=self._regions, active_shape=self._active_shape))
|
|
315
|
+
self._history = self._history[self._history_pos :]
|
|
316
|
+
self._history.insert(0, snapshot)
|
|
317
|
+
self._history = self._history[:MAX_HISTORY_SIZE]
|
|
318
|
+
self._history_pos = 0
|
|
319
|
+
# Update state.
|
|
320
|
+
self._recalculate = True
|
|
321
|
+
self._redraw = True
|
|
322
|
+
self._persisted = persisted
|
|
323
|
+
self._update_ui_state()
|
|
324
|
+
|
|
325
|
+
def _update_ui_state(self):
|
|
326
|
+
if self._root is None:
|
|
327
|
+
return
|
|
328
|
+
can_undo = self._history_pos < (len(self._history) - 1)
|
|
329
|
+
self._edit_menu.entryconfigure("Undo", state=tk.ACTIVE if can_undo else tk.DISABLED)
|
|
330
|
+
can_redo = self._history_pos > 0
|
|
331
|
+
self._edit_menu.entryconfigure("Redo", state=tk.ACTIVE if can_redo else tk.DISABLED)
|
|
332
|
+
if self._regions:
|
|
333
|
+
self._region_selector["values"] = tuple(
|
|
334
|
+
f"Shape {i + 1}" for i in range(len(self._regions))
|
|
335
|
+
)
|
|
336
|
+
assert self._active_shape is not None
|
|
337
|
+
self._region_selector.current(self._active_shape)
|
|
338
|
+
self._region_selector.config(state="readonly")
|
|
339
|
+
else:
|
|
340
|
+
self._region_selector["values"] = ("",)
|
|
341
|
+
assert self._active_shape is not None
|
|
342
|
+
self._region_selector.current(self._active_shape)
|
|
343
|
+
self._region_selector.config(state="disabled")
|
|
344
|
+
self._region_selector.selection_clear()
|
|
345
|
+
|
|
346
|
+
def _copy_scan_command_to_clipboard(self):
|
|
347
|
+
region_info = []
|
|
348
|
+
for shape in self._regions:
|
|
349
|
+
region_info.append("-a %s" % " ".join(f"{x} {y}" for x, y in shape))
|
|
350
|
+
data = " ".join(region_info)
|
|
351
|
+
scan_command = f"dvr-scan -i {self._settings.video_path} {data}"
|
|
352
|
+
self._root.clipboard_append(scan_command)
|
|
353
|
+
logger.info(
|
|
354
|
+
f"Region data copied to clipboard. To scan via command-line, run:\n{scan_command}"
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
def _set_cursor(self):
|
|
358
|
+
if self._pan_enabled or self._panning:
|
|
359
|
+
self._editor_canvas.config(cursor="fleur")
|
|
360
|
+
elif self._hover_point is None:
|
|
361
|
+
self._editor_canvas.config(cursor="crosshair")
|
|
362
|
+
else:
|
|
363
|
+
self._editor_canvas.config(cursor="hand2")
|
|
364
|
+
|
|
365
|
+
def _draw(self):
|
|
366
|
+
self._set_cursor()
|
|
367
|
+
if self._recalculate:
|
|
368
|
+
self._recalculate_data()
|
|
369
|
+
if not self._redraw:
|
|
370
|
+
return
|
|
371
|
+
if self._log_stats:
|
|
372
|
+
self._redraws += 1
|
|
373
|
+
logger.debug(f"redraw {self._redraws}")
|
|
374
|
+
|
|
375
|
+
curr_aa = (
|
|
376
|
+
cv2.LINE_AA
|
|
377
|
+
if self._settings.use_aa and self._scale <= MAX_DOWNSCALE_AA_LEVEL
|
|
378
|
+
else cv2.LINE_4
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
frame = self._original_frame.copy()
|
|
382
|
+
|
|
383
|
+
# Mask pixels outside of the defined region if we're in mask mode.
|
|
384
|
+
if self._settings.mask_source:
|
|
385
|
+
mask = np.zeros_like(frame, dtype=np.uint8)
|
|
386
|
+
for shape in self._regions:
|
|
387
|
+
points = np.array([shape], np.int32)
|
|
388
|
+
if self._scale > 1:
|
|
389
|
+
points = points // self._scale
|
|
390
|
+
mask = cv2.fillPoly(mask, points, color=(255, 255, 255), lineType=curr_aa)
|
|
391
|
+
frame = np.bitwise_and(frame, mask).astype(np.uint8)
|
|
392
|
+
|
|
393
|
+
thickness = edge_thickness(self._scale)
|
|
394
|
+
thickness_active = edge_thickness(self._scale, 1)
|
|
395
|
+
for shape_index, shape in enumerate(self._regions):
|
|
396
|
+
points = np.array([shape], np.int32)
|
|
397
|
+
if self._scale > 1:
|
|
398
|
+
points = points // self._scale
|
|
399
|
+
#
|
|
400
|
+
if not self._settings.mask_source:
|
|
401
|
+
frame = cv2.polylines(
|
|
402
|
+
frame,
|
|
403
|
+
points,
|
|
404
|
+
isClosed=True,
|
|
405
|
+
color=self._settings.line_color
|
|
406
|
+
if shape_index == self._active_shape
|
|
407
|
+
else self._settings.line_color_inactive,
|
|
408
|
+
thickness=thickness,
|
|
409
|
+
lineType=curr_aa,
|
|
410
|
+
)
|
|
411
|
+
if self._hover_point is not None and not self._settings.mask_source:
|
|
412
|
+
first, mid, last = (
|
|
413
|
+
(self._hover_point - 1) % len(self.active_region),
|
|
414
|
+
self._hover_point,
|
|
415
|
+
(self._hover_point + 1) % len(self.active_region),
|
|
416
|
+
)
|
|
417
|
+
points = np.array(
|
|
418
|
+
[
|
|
419
|
+
[
|
|
420
|
+
self.active_region[first],
|
|
421
|
+
self.active_region[mid],
|
|
422
|
+
self.active_region[last],
|
|
423
|
+
]
|
|
424
|
+
],
|
|
425
|
+
np.int32,
|
|
426
|
+
)
|
|
427
|
+
if self._scale > 1:
|
|
428
|
+
points = points // self._scale
|
|
429
|
+
frame = cv2.polylines(
|
|
430
|
+
frame,
|
|
431
|
+
points,
|
|
432
|
+
isClosed=False,
|
|
433
|
+
color=self._settings.hover_color
|
|
434
|
+
if not self._dragging
|
|
435
|
+
else self._settings.hover_color_alt,
|
|
436
|
+
thickness=thickness_active,
|
|
437
|
+
lineType=curr_aa,
|
|
438
|
+
)
|
|
439
|
+
elif self._nearest_points is not None and not self._settings.mask_source:
|
|
440
|
+
points = np.array(
|
|
441
|
+
[
|
|
442
|
+
[
|
|
443
|
+
self.active_region[self._nearest_points[0]],
|
|
444
|
+
self.active_region[self._nearest_points[1]],
|
|
445
|
+
]
|
|
446
|
+
],
|
|
447
|
+
np.int32,
|
|
448
|
+
)
|
|
449
|
+
if self._scale > 1:
|
|
450
|
+
points = points // self._scale
|
|
451
|
+
frame = cv2.polylines(
|
|
452
|
+
frame,
|
|
453
|
+
points,
|
|
454
|
+
isClosed=False,
|
|
455
|
+
color=self._settings.hover_color,
|
|
456
|
+
thickness=thickness_active,
|
|
457
|
+
lineType=curr_aa,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
if self.active_region is not None:
|
|
461
|
+
radius = control_handle_radius(self._scale)
|
|
462
|
+
for i, point in enumerate(self.active_region):
|
|
463
|
+
color = self._settings.line_color_alt
|
|
464
|
+
if self._hover_point is not None:
|
|
465
|
+
if self._hover_point == i:
|
|
466
|
+
color = (
|
|
467
|
+
self._settings.hover_color_alt
|
|
468
|
+
if not self._dragging
|
|
469
|
+
else self._settings.interact_color
|
|
470
|
+
)
|
|
471
|
+
elif self._nearest_points is not None and i in self._nearest_points:
|
|
472
|
+
color = (
|
|
473
|
+
self._settings.hover_color
|
|
474
|
+
if self._dragging
|
|
475
|
+
else self._settings.interact_color
|
|
476
|
+
)
|
|
477
|
+
start, end = (
|
|
478
|
+
Point(
|
|
479
|
+
(point.x // self._scale) - radius,
|
|
480
|
+
(point.y // self._scale) - radius,
|
|
481
|
+
),
|
|
482
|
+
Point(
|
|
483
|
+
(point.x // self._scale) + radius,
|
|
484
|
+
(point.y // self._scale) + radius,
|
|
485
|
+
),
|
|
486
|
+
)
|
|
487
|
+
cv2.rectangle(
|
|
488
|
+
frame,
|
|
489
|
+
start,
|
|
490
|
+
end,
|
|
491
|
+
color,
|
|
492
|
+
thickness=cv2.FILLED,
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
self._frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
|
496
|
+
self._image = PIL.ImageTk.PhotoImage(image=PIL.Image.fromarray(self._frame))
|
|
497
|
+
self._editor_canvas.create_image(0, 0, anchor=tk.NW, image=self._image)
|
|
498
|
+
self._redraw = False
|
|
499
|
+
|
|
500
|
+
def _find_nearest_segment(self) -> ty.Tuple[int, int]:
|
|
501
|
+
nearest_seg, nearest_dist, largest_cosine = 0, 2**31, math.pi
|
|
502
|
+
for i in range(len(self.active_region)):
|
|
503
|
+
# Create a triangle where side a's length is the mouse to closest point on the line,
|
|
504
|
+
# side c is the length to the furthest point, and side b is the line segment length.
|
|
505
|
+
next = (i + 1) % len(self.active_region)
|
|
506
|
+
a_sq = min(self._mouse_dist[i], self._mouse_dist[next])
|
|
507
|
+
c_sq = max(self._mouse_dist[i], self._mouse_dist[next])
|
|
508
|
+
b_sq = self._segment_dist[i]
|
|
509
|
+
assert a_sq > 0 # Should never hit this since we check _hovering_over first.
|
|
510
|
+
if b_sq == 0:
|
|
511
|
+
# Two adjacent points are overlapping, just skip this one.
|
|
512
|
+
continue
|
|
513
|
+
a, b = math.sqrt(a_sq), math.sqrt(b_sq)
|
|
514
|
+
cos_C = ((a_sq + b_sq) - c_sq) / (2.0 * a * b)
|
|
515
|
+
# If cos_C is between [0,1] the triangle is acute. If it's not, just take the distance
|
|
516
|
+
# of the closest point.
|
|
517
|
+
dist = int(a_sq - (int(a * cos_C) ** 2)) if cos_C > 0 else a_sq
|
|
518
|
+
if dist < nearest_dist or (dist == nearest_dist and cos_C > largest_cosine):
|
|
519
|
+
nearest_seg, nearest_dist, largest_cosine = i, dist, cos_C
|
|
520
|
+
self._nearest_points = (
|
|
521
|
+
nearest_seg,
|
|
522
|
+
(nearest_seg + 1) % len(self.active_region),
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
def _find_hover_point(self) -> ty.Optional[int]:
|
|
526
|
+
min_i = 0
|
|
527
|
+
for i in range(1, len(self._mouse_dist)):
|
|
528
|
+
if self._mouse_dist[i] < self._mouse_dist[min_i]:
|
|
529
|
+
min_i = i
|
|
530
|
+
# If we've shrunk the image, we need to compensate for the size difference in the image.
|
|
531
|
+
# The control handles remain the same size but the image is smaller
|
|
532
|
+
return (
|
|
533
|
+
min_i
|
|
534
|
+
if self._mouse_dist[min_i]
|
|
535
|
+
<= (4 * control_handle_radius(self._scale) * self._scale) ** 2
|
|
536
|
+
else None
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
def _breakpoint(self):
|
|
540
|
+
if self._debug_mode:
|
|
541
|
+
breakpoint()
|
|
542
|
+
|
|
543
|
+
def _bind_keyboard(self) -> ty.Dict[int, ty.Callable]:
|
|
544
|
+
for key, fn in {
|
|
545
|
+
KEYBIND_BREAKPOINT: lambda _: self._breakpoint(),
|
|
546
|
+
KEYBIND_POINT_ADD: lambda _: self._add_point(),
|
|
547
|
+
KEYBIND_POINT_ADD_ALT1: lambda _: self._add_point(),
|
|
548
|
+
KEYBIND_POINT_ADD_ALT2: lambda _: self._add_point(),
|
|
549
|
+
KEYBIND_POINT_DELETE: lambda _: self._delete_point(),
|
|
550
|
+
KEYBIND_POINT_DELETE_ALT1: lambda _: self._delete_point(),
|
|
551
|
+
KEYBIND_POINT_DELETE_ALT2: lambda _: self._delete_point(),
|
|
552
|
+
}.items():
|
|
553
|
+
self._root.bind(key.lower(), fn)
|
|
554
|
+
self._root.bind("<<Undo>>", lambda _: self._undo())
|
|
555
|
+
self._root.bind("<<Redo>>", lambda _: self._redo())
|
|
556
|
+
self._root.bind("<<Copy>>", lambda _: self._copy_scan_command_to_clipboard())
|
|
557
|
+
|
|
558
|
+
self._root.bind("<Left>", lambda _: self._prev_region())
|
|
559
|
+
self._root.bind("<Right>", lambda _: self._next_region())
|
|
560
|
+
self._root.bind("<Tab>", lambda _: self._next_region())
|
|
561
|
+
self._root.bind("<Shift-Tab>", lambda _: self._prev_region())
|
|
562
|
+
|
|
563
|
+
self._root.bind("<Control-s>", lambda _: self._prompt_save())
|
|
564
|
+
self._root.bind("<Control-o>", lambda _: self._prompt_load())
|
|
565
|
+
self._root.bind("<Control-n>", lambda _: self._add_region())
|
|
566
|
+
self._root.bind("<Control-h>", lambda _: self._show_help())
|
|
567
|
+
self._root.bind("<Control-d>", lambda _: self._delete_region())
|
|
568
|
+
self._root.bind("<Control-a>", lambda _: self._toggle_antialiasing())
|
|
569
|
+
self._root.bind("<Control-m>", lambda _: self._toggle_mask())
|
|
570
|
+
# +
|
|
571
|
+
self._root.bind("<Control-plus>", lambda _: self._adjust_downscale(-1))
|
|
572
|
+
self._root.bind("<Control-equal>", lambda _: self._adjust_downscale(-1))
|
|
573
|
+
self._root.bind("<Control-KP_Add>", lambda _: self._adjust_downscale(-1))
|
|
574
|
+
# -
|
|
575
|
+
self._root.bind("<Control-minus>", lambda _: self._adjust_downscale(1))
|
|
576
|
+
self._root.bind("<Control-underscore>", lambda _: self._adjust_downscale(1))
|
|
577
|
+
self._root.bind("<Control-KP_Subtract>", lambda _: self._adjust_downscale(1))
|
|
578
|
+
|
|
579
|
+
self._root.bind("<Escape>", lambda _: self._cancel_active_action())
|
|
580
|
+
|
|
581
|
+
def quit():
|
|
582
|
+
self._close(False)
|
|
583
|
+
|
|
584
|
+
self._root.bind("<Control-q>", lambda _: quit())
|
|
585
|
+
self._root.bind("<Control-Return>", lambda _: self._close(True))
|
|
586
|
+
self._root.bind("<Control-space>", lambda _: self._close(True))
|
|
587
|
+
|
|
588
|
+
def set_pan_mode(mode: bool):
|
|
589
|
+
if self._dragging:
|
|
590
|
+
self._pan_enabled = False
|
|
591
|
+
else:
|
|
592
|
+
self._pan_enabled = mode
|
|
593
|
+
self._set_cursor()
|
|
594
|
+
|
|
595
|
+
self._root.bind("<KeyPress-Control_L>", lambda _: set_pan_mode(True))
|
|
596
|
+
self._root.bind("<KeyPress-Control_R>", lambda _: set_pan_mode(True))
|
|
597
|
+
self._root.bind("<KeyRelease-Control_L>", lambda _: set_pan_mode(False))
|
|
598
|
+
self._root.bind("<KeyRelease-Control_R>", lambda _: set_pan_mode(False))
|
|
599
|
+
|
|
600
|
+
self._root.protocol("WM_DELETE_WINDOW", lambda: self._close(False))
|
|
601
|
+
self._root.bind("<FocusOut>", lambda _: self._cancel_active_action())
|
|
602
|
+
|
|
603
|
+
for i in range(9):
|
|
604
|
+
|
|
605
|
+
def select_region(index):
|
|
606
|
+
return lambda _: self._select_region(index)
|
|
607
|
+
|
|
608
|
+
self._root.bind(f"{i + 1}", select_region(i))
|
|
609
|
+
|
|
610
|
+
# TODO: If Shift is held down, allow translating current shape
|
|
611
|
+
# by left-click and drag.
|
|
612
|
+
|
|
613
|
+
def _close(self, should_scan: bool):
|
|
614
|
+
if self._launched_from_app:
|
|
615
|
+
self._root.destroy()
|
|
616
|
+
self._on_close()
|
|
617
|
+
return
|
|
618
|
+
|
|
619
|
+
self._should_scan = should_scan
|
|
620
|
+
if self.prompt_save_on_scan():
|
|
621
|
+
self._root.destroy()
|
|
622
|
+
|
|
623
|
+
def run(
|
|
624
|
+
self,
|
|
625
|
+
root: ty.Optional[tk.Tk] = None,
|
|
626
|
+
) -> ty.Optional[bool]:
|
|
627
|
+
"""Run the region editor. Returns True if the video should be scanned, False otherwise."""
|
|
628
|
+
logger.debug("Creating window for frame (scale = %d)", self._scale)
|
|
629
|
+
|
|
630
|
+
if root:
|
|
631
|
+
self._root = tk.Toplevel(master=root)
|
|
632
|
+
self._launched_from_app = True
|
|
633
|
+
else:
|
|
634
|
+
self._root = tk.Tk()
|
|
635
|
+
|
|
636
|
+
if not self._regions:
|
|
637
|
+
self._regions = [initial_point_list(self._frame_size)]
|
|
638
|
+
|
|
639
|
+
# Withdraw root window until we're done adding everything to avoid visual flicker.
|
|
640
|
+
self._root.withdraw()
|
|
641
|
+
self._root.option_add("*tearOff", False)
|
|
642
|
+
self._root.title(OWNED_WINDOW_TITLE if self._launched_from_app else WINDOW_TITLE)
|
|
643
|
+
register_icon(self._root)
|
|
644
|
+
self._root.resizable(True, True)
|
|
645
|
+
self._root.minsize(width=320, height=240)
|
|
646
|
+
self._editor_canvas = tk.Canvas(
|
|
647
|
+
self._root,
|
|
648
|
+
)
|
|
649
|
+
self._root.columnconfigure(0, weight=1)
|
|
650
|
+
self._root.rowconfigure(0, weight=1)
|
|
651
|
+
self._editor_canvas.grid(row=0, column=0, sticky="nsew")
|
|
652
|
+
self._editor_scroll = (
|
|
653
|
+
AutoHideScrollbar(self._root, command=self._editor_canvas.xview, orient=tk.HORIZONTAL),
|
|
654
|
+
AutoHideScrollbar(self._root, command=self._editor_canvas.yview, orient=tk.VERTICAL),
|
|
655
|
+
)
|
|
656
|
+
self._editor_canvas["xscrollcommand"] = self._editor_scroll[0].set
|
|
657
|
+
self._editor_canvas["yscrollcommand"] = self._editor_scroll[1].set
|
|
658
|
+
self._editor_scroll[0].grid(row=1, column=0, sticky="ew")
|
|
659
|
+
self._editor_scroll[1].grid(row=0, column=1, sticky="ns")
|
|
660
|
+
|
|
661
|
+
ttk.Separator(self._root).grid(row=2, column=0, columnspan=2, sticky="ew")
|
|
662
|
+
|
|
663
|
+
def set_scale(val: str):
|
|
664
|
+
new_val = round(float(val))
|
|
665
|
+
if self._scale != new_val:
|
|
666
|
+
self._scale = new_val
|
|
667
|
+
# Disable resize since we are probably using the mouse to control this widget.
|
|
668
|
+
self._rescale(allow_resize=False)
|
|
669
|
+
|
|
670
|
+
frame = tk.Frame(self._root)
|
|
671
|
+
frame.grid(row=3, column=0, sticky="ew", columnspan=2)
|
|
672
|
+
frame.columnconfigure(4, weight=1)
|
|
673
|
+
frame.rowconfigure(0, weight=1)
|
|
674
|
+
self._scale_widget = ttk.Scale(
|
|
675
|
+
frame,
|
|
676
|
+
orient=tk.HORIZONTAL,
|
|
677
|
+
length=200,
|
|
678
|
+
from_=MAX_DOWNSCALE_FACTOR,
|
|
679
|
+
to=MIN_DOWNSCALE_FACTOR,
|
|
680
|
+
command=set_scale,
|
|
681
|
+
value=self._scale,
|
|
682
|
+
)
|
|
683
|
+
tk.Label(frame, text="Active Region", anchor=tk.W).grid(
|
|
684
|
+
row=0, column=0, sticky=tk.W, padx=8.0
|
|
685
|
+
)
|
|
686
|
+
self._region_selector = ttk.Combobox(
|
|
687
|
+
frame,
|
|
688
|
+
values=list(f"Shape {i + 1}" for i in range(len(self._regions))),
|
|
689
|
+
width=12,
|
|
690
|
+
justify=tk.CENTER,
|
|
691
|
+
)
|
|
692
|
+
self._region_selector.state(["readonly"])
|
|
693
|
+
self._region_selector.grid(row=0, column=1, sticky="w")
|
|
694
|
+
self._region_selector.current(0)
|
|
695
|
+
self._region_selector.bind("<<ComboboxSelected>>", lambda _: self._on_shape_select())
|
|
696
|
+
# Looks weird when the widget takes focus, but still seems to work when we redirect focus to
|
|
697
|
+
# the root.
|
|
698
|
+
self._region_selector.bind("<FocusIn>", lambda _: self._root.focus())
|
|
699
|
+
ttk.Button(frame, text="Toggle Mask", command=self._toggle_mask).grid(
|
|
700
|
+
row=0, column=10, padx=8.0
|
|
701
|
+
)
|
|
702
|
+
self._scale_widget.grid(row=0, column=9, sticky="e", padx=8.0)
|
|
703
|
+
tk.Label(frame, text="Zoom", anchor=tk.E).grid(row=0, column=8, sticky=tk.E, padx=(0, 8.0))
|
|
704
|
+
|
|
705
|
+
self._bind_mouse()
|
|
706
|
+
self._bind_keyboard()
|
|
707
|
+
self._create_menubar()
|
|
708
|
+
self._rescale(draw=False)
|
|
709
|
+
self._redraw = True
|
|
710
|
+
self._draw()
|
|
711
|
+
|
|
712
|
+
self._root.deiconify()
|
|
713
|
+
logger.info(f"Region editor active. Press {KEYBIND_HELP} to show controls.")
|
|
714
|
+
|
|
715
|
+
self._root.focus()
|
|
716
|
+
self._root.grab_release()
|
|
717
|
+
if not self._launched_from_app:
|
|
718
|
+
self._root.mainloop()
|
|
719
|
+
return self._should_scan
|
|
720
|
+
|
|
721
|
+
def _create_menubar(self):
|
|
722
|
+
root_menu = tk.Menu(self._root)
|
|
723
|
+
file_menu = tk.Menu(root_menu)
|
|
724
|
+
root_menu.add_cascade(menu=file_menu, label="File", underline=0)
|
|
725
|
+
if not self._launched_from_app:
|
|
726
|
+
file_menu.add_command(
|
|
727
|
+
label="Start Scan",
|
|
728
|
+
command=lambda: self._close(True),
|
|
729
|
+
accelerator=KEYBIND_START_SCAN,
|
|
730
|
+
underline=1,
|
|
731
|
+
)
|
|
732
|
+
file_menu.add_separator()
|
|
733
|
+
file_menu.add_command(
|
|
734
|
+
label="Open Regions...",
|
|
735
|
+
command=self._prompt_load,
|
|
736
|
+
accelerator=KEYBIND_LOAD,
|
|
737
|
+
underline=0,
|
|
738
|
+
)
|
|
739
|
+
file_menu.add_command(
|
|
740
|
+
label=SAVE_REGIONS if self._settings.save_path else SAVE_REGIONS_PROMPT,
|
|
741
|
+
command=self._prompt_save,
|
|
742
|
+
accelerator=KEYBIND_SAVE,
|
|
743
|
+
underline=0,
|
|
744
|
+
)
|
|
745
|
+
file_menu.add_separator()
|
|
746
|
+
file_menu.add_command(
|
|
747
|
+
label="Close" if self._launched_from_app else "Quit",
|
|
748
|
+
command=lambda: self._close(False),
|
|
749
|
+
accelerator=KEYBIND_QUIT,
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
edit_menu = tk.Menu(root_menu)
|
|
753
|
+
root_menu.add_cascade(menu=edit_menu, label="Edit", underline=0)
|
|
754
|
+
edit_menu.add_command(
|
|
755
|
+
label="Undo", command=self._undo, accelerator=KEYBIND_UNDO, underline=0
|
|
756
|
+
)
|
|
757
|
+
edit_menu.add_command(
|
|
758
|
+
label="Redo", command=self._redo, accelerator=KEYBIND_REDO, underline=0
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
view_menu = tk.Menu(root_menu)
|
|
762
|
+
root_menu.add_cascade(menu=view_menu, label="View", underline=0)
|
|
763
|
+
view_menu.add_command(
|
|
764
|
+
label="Mask Mode", command=self._toggle_mask, accelerator=KEYBIND_MASK, underline=0
|
|
765
|
+
)
|
|
766
|
+
view_menu.add_command(
|
|
767
|
+
label="Antialiasing",
|
|
768
|
+
command=self._toggle_antialiasing,
|
|
769
|
+
accelerator=KEYBIND_TOGGLE_AA,
|
|
770
|
+
underline=0,
|
|
771
|
+
)
|
|
772
|
+
view_menu.add_separator()
|
|
773
|
+
view_menu.add_command(
|
|
774
|
+
label="Zoom In",
|
|
775
|
+
command=lambda: self._adjust_downscale(-1),
|
|
776
|
+
accelerator=KEYBIND_DOWNSCALE_DEC,
|
|
777
|
+
underline=0,
|
|
778
|
+
)
|
|
779
|
+
view_menu.add_command(
|
|
780
|
+
label="Zoom Out",
|
|
781
|
+
command=lambda: self._adjust_downscale(1),
|
|
782
|
+
accelerator=KEYBIND_DOWNSCALE_INC,
|
|
783
|
+
underline=3,
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
help_menu = tk.Menu(root_menu)
|
|
787
|
+
root_menu.add_cascade(menu=help_menu, label="Help", underline=0)
|
|
788
|
+
|
|
789
|
+
help_menu.add_command(
|
|
790
|
+
label="Show Controls", command=self._show_help, accelerator=KEYBIND_HELP, underline=5
|
|
791
|
+
)
|
|
792
|
+
# TODO: Link to local copy of documentation included with distributions instead where
|
|
793
|
+
# possible. This isn't always possible with how Python manages package resources, so we
|
|
794
|
+
# might need to probe the directory the application was run from.
|
|
795
|
+
help_menu.add_command(
|
|
796
|
+
label="Online Manual",
|
|
797
|
+
command=lambda: webbrowser.open_new_tab("www.dvr-scan.com/guide"),
|
|
798
|
+
underline=0,
|
|
799
|
+
)
|
|
800
|
+
help_menu.add_command(
|
|
801
|
+
label="Join Discord Chat",
|
|
802
|
+
command=lambda: webbrowser.open_new_tab("https://discord.gg/69kf6f2Exb"),
|
|
803
|
+
underline=5,
|
|
804
|
+
)
|
|
805
|
+
help_menu.add_separator()
|
|
806
|
+
|
|
807
|
+
help_menu.add_command(
|
|
808
|
+
label="About DVR-Scan", command=lambda: AboutWindow().show(root=self._root), underline=0
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
self._edit_menu = edit_menu
|
|
812
|
+
self._root["menu"] = root_menu
|
|
813
|
+
self._update_ui_state()
|
|
814
|
+
|
|
815
|
+
def _show_help(self):
|
|
816
|
+
if self._controls_window is None:
|
|
817
|
+
self._controls_window = tk.Toplevel(master=self._root)
|
|
818
|
+
self._controls_window.withdraw()
|
|
819
|
+
self._controls_window.title("Controls")
|
|
820
|
+
self._controls_window.resizable(True, True)
|
|
821
|
+
self._controls_window.transient(self._root)
|
|
822
|
+
|
|
823
|
+
def dismiss():
|
|
824
|
+
self._controls_window.destroy()
|
|
825
|
+
self._controls_window = None
|
|
826
|
+
self._root.focus()
|
|
827
|
+
|
|
828
|
+
self._controls_window.protocol("WM_DELETE_WINDOW", dismiss)
|
|
829
|
+
self._controls_window.attributes("-topmost", True)
|
|
830
|
+
self._controls_window.bind("<Destroy>", lambda _: dismiss())
|
|
831
|
+
self._controls_window.bind("<FocusIn>", lambda _: self._root.focus())
|
|
832
|
+
|
|
833
|
+
def handle_escape(_: tk.Event):
|
|
834
|
+
self._controls_window.destroy()
|
|
835
|
+
self._controls_window = None
|
|
836
|
+
|
|
837
|
+
self._controls_window.bind("<Escape>", handle_escape)
|
|
838
|
+
|
|
839
|
+
regions = ttk.Labelframe(self._controls_window, text="Regions", padding=8.0)
|
|
840
|
+
regions.columnconfigure(0, weight=1)
|
|
841
|
+
regions.columnconfigure(1, weight=1)
|
|
842
|
+
|
|
843
|
+
ttk.Label(regions, text="Add Point").grid(row=0, column=0, sticky="w")
|
|
844
|
+
ttk.Label(regions, text="Left Click\nKeyboard: +", justify=tk.RIGHT).grid(
|
|
845
|
+
row=0, column=1, sticky="e"
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
ttk.Label(regions, text="Remove Point").grid(row=1, column=0, sticky="w")
|
|
849
|
+
ttk.Label(regions, text="Right Click\nKeyboard: -", justify=tk.RIGHT).grid(
|
|
850
|
+
row=1, column=1, sticky="e"
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
ttk.Label(regions, text="Move Point").grid(row=2, column=0, sticky="w")
|
|
854
|
+
ttk.Label(regions, text="Left Click + Drag").grid(row=2, column=1, sticky="e")
|
|
855
|
+
|
|
856
|
+
ttk.Label(regions, text="").grid(row=4, column=0, sticky="w")
|
|
857
|
+
|
|
858
|
+
ttk.Label(regions, text="Add/Remove Shape").grid(row=5, column=0, sticky="w")
|
|
859
|
+
ttk.Label(
|
|
860
|
+
regions,
|
|
861
|
+
text=f"Mouse: Right Click\nKeyboard: {KEYBIND_REGION_ADD}",
|
|
862
|
+
justify=tk.RIGHT,
|
|
863
|
+
).grid(row=5, column=1, sticky="e")
|
|
864
|
+
|
|
865
|
+
ttk.Label(regions, text="Active Shape").grid(row=6, column=0, sticky="w")
|
|
866
|
+
ttk.Label(
|
|
867
|
+
regions,
|
|
868
|
+
text="Mouse: Right Click\nKeyboard: "
|
|
869
|
+
f"{KEYBIND_REGION_NEXT}/{KEYBIND_REGION_PREVIOUS}",
|
|
870
|
+
justify=tk.RIGHT,
|
|
871
|
+
).grid(row=6, column=1, sticky="e")
|
|
872
|
+
|
|
873
|
+
viewport = ttk.Labelframe(self._controls_window, text="Viewport", padding=8.0)
|
|
874
|
+
viewport.columnconfigure(0, weight=1)
|
|
875
|
+
viewport.columnconfigure(1, weight=1)
|
|
876
|
+
|
|
877
|
+
ttk.Label(viewport, text="Zoom").grid(row=0, column=0, sticky="w")
|
|
878
|
+
ttk.Label(
|
|
879
|
+
viewport,
|
|
880
|
+
text=f"Mouse: {ACCELERATOR_KEY} + Scroll\nKeyboard: "
|
|
881
|
+
f"{KEYBIND_DOWNSCALE_INC}/{KEYBIND_DOWNSCALE_DEC}",
|
|
882
|
+
justify=tk.RIGHT,
|
|
883
|
+
).grid(row=0, column=1, sticky="e")
|
|
884
|
+
|
|
885
|
+
ttk.Label(viewport, text="Move/Pan").grid(row=3, column=0, sticky="w")
|
|
886
|
+
ttk.Label(viewport, text=f"{ACCELERATOR_KEY} + Left Click").grid(
|
|
887
|
+
row=3, column=1, sticky="e"
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
ttk.Label(viewport, text="").grid(row=4, column=0, sticky="w")
|
|
891
|
+
|
|
892
|
+
ttk.Label(viewport, text="Toggle Mask Mode").grid(row=5, column=0, sticky="w")
|
|
893
|
+
ttk.Label(viewport, text=f"Keyboard: {KEYBIND_MASK}").grid(row=5, column=1, sticky="e")
|
|
894
|
+
|
|
895
|
+
ttk.Label(viewport, text="Toggle Antialiasing").grid(row=6, column=0, sticky="w")
|
|
896
|
+
ttk.Label(viewport, text=f"Keyboard: {KEYBIND_TOGGLE_AA}").grid(
|
|
897
|
+
row=6, column=1, sticky="e"
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
general = ttk.Labelframe(self._controls_window, text="General", padding=8.0)
|
|
901
|
+
general.columnconfigure(0, weight=1)
|
|
902
|
+
general.columnconfigure(1, weight=1)
|
|
903
|
+
|
|
904
|
+
ttk.Label(general, text="Start Scan").grid(row=0, column=0, sticky="w")
|
|
905
|
+
ttk.Label(general, text=f"Keyboard: {KEYBIND_START_SCAN}").grid(
|
|
906
|
+
row=0, column=1, sticky="e"
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
ttk.Label(general, text="Quit").grid(row=1, column=0, sticky="w")
|
|
910
|
+
ttk.Label(general, text=f"Keyboard: {KEYBIND_QUIT}").grid(row=1, column=1, sticky="e")
|
|
911
|
+
|
|
912
|
+
ttk.Label(general, text="Show Help").grid(row=2, column=0, sticky="w")
|
|
913
|
+
ttk.Label(general, text=f"Keyboard: {KEYBIND_HELP}").grid(row=2, column=1, sticky="e")
|
|
914
|
+
|
|
915
|
+
ttk.Label(general, text="Copy Scan Command").grid(row=3, column=0, sticky="w")
|
|
916
|
+
ttk.Label(general, text=f"Keyboard: {KEYBIND_COPY_COMMAND}").grid(
|
|
917
|
+
row=3, column=1, sticky="e"
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
regions.grid(row=0, sticky="nsew", padx=8.0, pady=8.0)
|
|
921
|
+
viewport.grid(row=1, sticky="nsew", padx=8.0, pady=8.0)
|
|
922
|
+
general.grid(row=2, sticky="nsew", padx=8.0, pady=8.0)
|
|
923
|
+
|
|
924
|
+
self._controls_window.columnconfigure(0, weight=1)
|
|
925
|
+
self._controls_window.rowconfigure(0, weight=1)
|
|
926
|
+
self._controls_window.rowconfigure(1, weight=1)
|
|
927
|
+
self._controls_window.rowconfigure(2, weight=1)
|
|
928
|
+
self._controls_window.update()
|
|
929
|
+
|
|
930
|
+
self._controls_window.deiconify()
|
|
931
|
+
self._root.focus()
|
|
932
|
+
|
|
933
|
+
def _adjust_downscale(self, amount: int, allow_resize=True):
|
|
934
|
+
# scale is clamped to MIN_DOWNSCALE_FACTOR/MAX_DOWNSCALE_FACTOR.
|
|
935
|
+
scale = self._scale + amount
|
|
936
|
+
self._scale = min(MAX_DOWNSCALE_FACTOR, max(MIN_DOWNSCALE_FACTOR, scale))
|
|
937
|
+
self._scale_widget.set(self._scale)
|
|
938
|
+
self._rescale(allow_resize=allow_resize)
|
|
939
|
+
|
|
940
|
+
def _prompt_save(self):
|
|
941
|
+
"""Save region data, prompting the user if a save path wasn't specified by command line."""
|
|
942
|
+
if self._save():
|
|
943
|
+
return
|
|
944
|
+
save_path = tkinter.filedialog.asksaveasfilename(
|
|
945
|
+
title=SAVE_TITLE,
|
|
946
|
+
filetypes=[("Region File", "*.txt")],
|
|
947
|
+
defaultextension=".txt",
|
|
948
|
+
confirmoverwrite=True,
|
|
949
|
+
parent=self._root,
|
|
950
|
+
)
|
|
951
|
+
if save_path:
|
|
952
|
+
self._save(save_path)
|
|
953
|
+
|
|
954
|
+
def prompt_save_on_scan(self, root: ty.Optional[tk.Widget] = None):
|
|
955
|
+
"""Saves any changes that weren't persisted, prompting the user if a path wasn't specified.
|
|
956
|
+
Returns True if we should quit the program, False if we should not quit."""
|
|
957
|
+
# Don't prompt user if changes are already saved.
|
|
958
|
+
if self._persisted:
|
|
959
|
+
return True
|
|
960
|
+
# Don't prompt the user if they lanched the region editor from the CLI and already specified
|
|
961
|
+
# a path to save the region data to.
|
|
962
|
+
if self._save():
|
|
963
|
+
return True
|
|
964
|
+
should_save = tkinter.messagebox.askyesnocancel(
|
|
965
|
+
title=PROMPT_TITLE,
|
|
966
|
+
message=PROMPT_MESSAGE,
|
|
967
|
+
icon=tkinter.messagebox.WARNING,
|
|
968
|
+
parent=root if root else self._root,
|
|
969
|
+
)
|
|
970
|
+
if should_save is None:
|
|
971
|
+
return False
|
|
972
|
+
if should_save and not self._save():
|
|
973
|
+
save_path = tkinter.filedialog.asksaveasfilename(
|
|
974
|
+
title=SAVE_TITLE,
|
|
975
|
+
filetypes=[("Region File", "*.txt")],
|
|
976
|
+
defaultextension=".txt",
|
|
977
|
+
confirmoverwrite=True,
|
|
978
|
+
parent=root if root else self._root,
|
|
979
|
+
)
|
|
980
|
+
if not save_path:
|
|
981
|
+
return False
|
|
982
|
+
self._save(save_path)
|
|
983
|
+
else:
|
|
984
|
+
logger.debug("Continuing with unsaved changes.")
|
|
985
|
+
return True
|
|
986
|
+
|
|
987
|
+
def _save(self, path=None):
|
|
988
|
+
if path is None:
|
|
989
|
+
if not self._settings.save_path:
|
|
990
|
+
return False
|
|
991
|
+
path = self._settings.save_path
|
|
992
|
+
with open(path, "w") as region_file:
|
|
993
|
+
for shape in self._regions:
|
|
994
|
+
region_file.write(" ".join(f"{x} {y}" for x, y in shape))
|
|
995
|
+
region_file.write("\n")
|
|
996
|
+
logger.info("Saved region data to: %s", path)
|
|
997
|
+
self._persisted = True
|
|
998
|
+
self._persisted_path = path
|
|
999
|
+
return True
|
|
1000
|
+
|
|
1001
|
+
def _prompt_load(self):
|
|
1002
|
+
# TODO: Rename this function.
|
|
1003
|
+
if not self.prompt_save_on_scan():
|
|
1004
|
+
return
|
|
1005
|
+
load_path = tkinter.filedialog.askopenfilename(
|
|
1006
|
+
title=LOAD_TITLE,
|
|
1007
|
+
filetypes=[("Region File", "*.txt")],
|
|
1008
|
+
defaultextension=".txt",
|
|
1009
|
+
parent=self._root,
|
|
1010
|
+
)
|
|
1011
|
+
if not load_path:
|
|
1012
|
+
return
|
|
1013
|
+
if not os.path.exists(load_path):
|
|
1014
|
+
logger.error(f"File does not exist: {load_path}")
|
|
1015
|
+
return
|
|
1016
|
+
regions = []
|
|
1017
|
+
try:
|
|
1018
|
+
logger.debug(f"Loading regions from file: {load_path}")
|
|
1019
|
+
regions = load_regions(load_path)
|
|
1020
|
+
except ValueError as ex:
|
|
1021
|
+
reason = " ".join(str(arg) for arg in ex.args)
|
|
1022
|
+
if not reason:
|
|
1023
|
+
reason = "Could not parse region file!"
|
|
1024
|
+
logger.error(f"Error loading region from {load_path}: {reason}")
|
|
1025
|
+
else:
|
|
1026
|
+
logger.debug(
|
|
1027
|
+
"Loaded %d region%s from region file:\n%s",
|
|
1028
|
+
len(regions),
|
|
1029
|
+
"s" if len(regions) > 1 else "",
|
|
1030
|
+
"\n".join(f"[{i}] = {points}" for i, points in enumerate(regions)),
|
|
1031
|
+
)
|
|
1032
|
+
|
|
1033
|
+
self._regions = [
|
|
1034
|
+
[bound_point(point, self._source_size) for point in shape] for shape in regions
|
|
1035
|
+
]
|
|
1036
|
+
self._commit()
|
|
1037
|
+
self._persisted = True
|
|
1038
|
+
self._active_shape = 0 if len(self._regions) > 0 else None
|
|
1039
|
+
|
|
1040
|
+
def _delete_point(self):
|
|
1041
|
+
if self._dragging or self._pan_enabled:
|
|
1042
|
+
logger.debug("Cannot remove point while dragging or panning.")
|
|
1043
|
+
return
|
|
1044
|
+
if self._hover_point is not None:
|
|
1045
|
+
if len(self.active_region) > MIN_NUM_POINTS:
|
|
1046
|
+
hover = self._hover_point
|
|
1047
|
+
x, y = self.active_region[hover]
|
|
1048
|
+
del self.active_region[hover]
|
|
1049
|
+
self._hover_point = None
|
|
1050
|
+
logger.debug("Del: [%d] = %s", hover, f"P({x},{y})")
|
|
1051
|
+
self._commit()
|
|
1052
|
+
self._draw()
|
|
1053
|
+
else:
|
|
1054
|
+
logger.debug("Region cannot have less than 3 points, removing shape.")
|
|
1055
|
+
self._delete_region()
|
|
1056
|
+
|
|
1057
|
+
def _toggle_antialiasing(self):
|
|
1058
|
+
self._settings.use_aa = not self._settings.use_aa
|
|
1059
|
+
self._redraw = True
|
|
1060
|
+
logger.debug("AA: %s", "ON" if self._settings.use_aa else "OFF")
|
|
1061
|
+
if self._scale >= MAX_DOWNSCALE_AA_LEVEL:
|
|
1062
|
+
logger.warning("AA is disabled due to current scale factor.")
|
|
1063
|
+
self._draw()
|
|
1064
|
+
|
|
1065
|
+
def _toggle_mask(self):
|
|
1066
|
+
self._settings.mask_source = not self._settings.mask_source
|
|
1067
|
+
logger.debug("Masking: %s", "ON" if self._settings.mask_source else "OFF")
|
|
1068
|
+
self._redraw = True
|
|
1069
|
+
self._draw()
|
|
1070
|
+
|
|
1071
|
+
def _on_shape_select(self):
|
|
1072
|
+
if self._regions:
|
|
1073
|
+
self._select_region(self._region_selector.current())
|
|
1074
|
+
self._region_selector.selection_clear()
|
|
1075
|
+
|
|
1076
|
+
def _add_point(self, drag: bool = False) -> bool:
|
|
1077
|
+
if self._nearest_points is not None:
|
|
1078
|
+
insert_pos = (
|
|
1079
|
+
1 + self._nearest_points[0]
|
|
1080
|
+
if self._nearest_points[0] < self._nearest_points[1]
|
|
1081
|
+
else self._nearest_points[1]
|
|
1082
|
+
)
|
|
1083
|
+
insert_pos = insert_pos % len(self.active_region)
|
|
1084
|
+
self.active_region.insert(insert_pos, self._curr_mouse_pos)
|
|
1085
|
+
logger.debug(
|
|
1086
|
+
f"Add: [{insert_pos}] = P({self._curr_mouse_pos.x},{self._curr_mouse_pos.y})"
|
|
1087
|
+
)
|
|
1088
|
+
self._nearest_points = None
|
|
1089
|
+
self._hover_point = insert_pos
|
|
1090
|
+
if drag:
|
|
1091
|
+
self._dragging = True
|
|
1092
|
+
self._drag_start = self._curr_mouse_pos
|
|
1093
|
+
self._redraw = True
|
|
1094
|
+
self._draw()
|
|
1095
|
+
return True
|
|
1096
|
+
return False
|
|
1097
|
+
|
|
1098
|
+
def _recalculate_data(self):
|
|
1099
|
+
if self._log_stats:
|
|
1100
|
+
self._recalculates += 1
|
|
1101
|
+
logger.debug(f"recalculation {self._recalculates}")
|
|
1102
|
+
# TODO: Optimize further, only need to do a lot of work below if certain things changed
|
|
1103
|
+
# from the last calculation point.
|
|
1104
|
+
if self._curr_mouse_pos is None:
|
|
1105
|
+
return
|
|
1106
|
+
if not self._regions or self.active_region is None:
|
|
1107
|
+
self._hover_point = None
|
|
1108
|
+
self._nearest_points = None
|
|
1109
|
+
return
|
|
1110
|
+
last_hover = self._hover_point
|
|
1111
|
+
last_nearest = self._nearest_points
|
|
1112
|
+
# Calculate distance from mouse cursor to each point.
|
|
1113
|
+
self._mouse_dist = [
|
|
1114
|
+
squared_distance(self._curr_mouse_pos, point) for point in self.active_region
|
|
1115
|
+
]
|
|
1116
|
+
# Check if we're hovering over a point.
|
|
1117
|
+
self._hover_point = self._find_hover_point()
|
|
1118
|
+
# Optimization: Only recalculate segment distances if we aren't hovering over a point.
|
|
1119
|
+
if self._hover_point is None:
|
|
1120
|
+
num_points = len(self.active_region)
|
|
1121
|
+
self._segment_dist = [
|
|
1122
|
+
squared_distance(self.active_region[i], self.active_region[(i + 1) % num_points])
|
|
1123
|
+
for i in range(num_points)
|
|
1124
|
+
]
|
|
1125
|
+
self._find_nearest_segment()
|
|
1126
|
+
if last_hover != self._hover_point or last_nearest != self._nearest_points:
|
|
1127
|
+
self._redraw = True
|
|
1128
|
+
self._recalculate = False
|
|
1129
|
+
|
|
1130
|
+
def _to_canvas_coordinates(self, point: Point) -> ty.Tuple[Point, bool]:
|
|
1131
|
+
"""Adjust mouse coordinates to be relative to the editor canvas.
|
|
1132
|
+
|
|
1133
|
+
Returns bounded point as well as a boolean if the cursor is in or outside of the canvas."""
|
|
1134
|
+
# TODO: We should disallow adding new points when the mouse is outside
|
|
1135
|
+
# of the canvas.
|
|
1136
|
+
x = int(self._editor_canvas.canvasx(point.x))
|
|
1137
|
+
y = int(self._editor_canvas.canvasy(point.y))
|
|
1138
|
+
inside_canvas = x >= 0 and y >= 0 and x <= self._frame_size.w and y <= self._frame_size.h
|
|
1139
|
+
bounded = bound_point(point=Point(x, y), size=self._frame_size)
|
|
1140
|
+
return Point(bounded.x * self._scale, bounded.y * self._scale), inside_canvas
|
|
1141
|
+
|
|
1142
|
+
def _handle_mouse_input(self, event, point: Point):
|
|
1143
|
+
# TODO: Map mouse events to callbacks rather than handling each event conditionally.
|
|
1144
|
+
# TODO: Store `inside_canvas` so we can avoid highlighting/calculating when not necessary.
|
|
1145
|
+
self._curr_mouse_pos, inside_canvas = self._to_canvas_coordinates(point)
|
|
1146
|
+
if event == cv2.EVENT_LBUTTONDOWN:
|
|
1147
|
+
if self._pan_enabled:
|
|
1148
|
+
self._editor_canvas.scan_mark(point.x, point.y)
|
|
1149
|
+
self._panning = True
|
|
1150
|
+
self._drag_start = self._curr_mouse_pos
|
|
1151
|
+
# We can just return without redrawing, we only have to draw once the canvas moves.
|
|
1152
|
+
return
|
|
1153
|
+
if not inside_canvas:
|
|
1154
|
+
return
|
|
1155
|
+
|
|
1156
|
+
if not self._regions:
|
|
1157
|
+
logger.info("No regions to edit, add a new one by right clicking.")
|
|
1158
|
+
if self._hover_point is not None:
|
|
1159
|
+
self._dragging = True
|
|
1160
|
+
self._drag_start = self._curr_mouse_pos
|
|
1161
|
+
else:
|
|
1162
|
+
self._add_point(drag=True)
|
|
1163
|
+
|
|
1164
|
+
elif event == cv2.EVENT_MOUSEMOVE:
|
|
1165
|
+
if self._dragging:
|
|
1166
|
+
self.active_region[self._hover_point] = self._curr_mouse_pos
|
|
1167
|
+
self._recalculate = False
|
|
1168
|
+
self._redraw = True
|
|
1169
|
+
elif self._pan_enabled or self._panning:
|
|
1170
|
+
if self._panning:
|
|
1171
|
+
self._editor_canvas.scan_dragto(point.x, point.y, gain=1)
|
|
1172
|
+
elif inside_canvas:
|
|
1173
|
+
# Need to recalculate to see what points are closest to current mouse pos.
|
|
1174
|
+
self._recalculate = True
|
|
1175
|
+
else:
|
|
1176
|
+
self._curr_mouse_pos = None
|
|
1177
|
+
self._hover_point = None
|
|
1178
|
+
self._nearest_points = None
|
|
1179
|
+
self._recalculate = True
|
|
1180
|
+
self._redraw = True
|
|
1181
|
+
|
|
1182
|
+
elif event == cv2.EVENT_LBUTTONUP:
|
|
1183
|
+
if self._dragging:
|
|
1184
|
+
assert self._hover_point is not None
|
|
1185
|
+
if (
|
|
1186
|
+
len(self.active_region) != len(self._history[self._history_pos].regions)
|
|
1187
|
+
or self._curr_mouse_pos != self._drag_start
|
|
1188
|
+
):
|
|
1189
|
+
self.active_region[self._hover_point] = self._curr_mouse_pos
|
|
1190
|
+
x, y = self.active_region[self._hover_point]
|
|
1191
|
+
if self._curr_mouse_pos != self._drag_start:
|
|
1192
|
+
logger.debug("Move: [%d] = %s", self._hover_point, f"P({x},{y})")
|
|
1193
|
+
self._commit()
|
|
1194
|
+
self._redraw = True
|
|
1195
|
+
self._dragging = False
|
|
1196
|
+
self._panning = False
|
|
1197
|
+
self._set_cursor() # Pan mode could have changed.
|
|
1198
|
+
|
|
1199
|
+
self._draw()
|
|
1200
|
+
|
|
1201
|
+
def _on_mouse_leave(self):
|
|
1202
|
+
logger.debug("mouse left window")
|
|
1203
|
+
if not (self._dragging or self._panning):
|
|
1204
|
+
self._curr_mouse_pos = None
|
|
1205
|
+
self._hover_point = None
|
|
1206
|
+
self._nearest_points = None
|
|
1207
|
+
self._redraw = True
|
|
1208
|
+
self._draw()
|
|
1209
|
+
|
|
1210
|
+
def _cancel_active_action(self) -> bool:
|
|
1211
|
+
logger.debug("cancelling active action")
|
|
1212
|
+
if self._dragging:
|
|
1213
|
+
assert self._hover_point is not None
|
|
1214
|
+
snapshot = self._history[self._history_pos]
|
|
1215
|
+
self._regions[self._active_shape] = snapshot.regions[self._active_shape].copy()
|
|
1216
|
+
self._hover_point = None
|
|
1217
|
+
self._redraw = True
|
|
1218
|
+
|
|
1219
|
+
self._dragging = False
|
|
1220
|
+
self._panning = False
|
|
1221
|
+
self._pan_enabled = False
|
|
1222
|
+
|
|
1223
|
+
self._set_cursor()
|
|
1224
|
+
self._draw()
|
|
1225
|
+
|
|
1226
|
+
def _bind_mouse(self):
|
|
1227
|
+
def on_mouse_move(e: tk.Event):
|
|
1228
|
+
self._handle_mouse_input(cv2.EVENT_MOUSEMOVE, Point(e.x, e.y))
|
|
1229
|
+
|
|
1230
|
+
def on_left_mouse_down(e: tk.Event):
|
|
1231
|
+
if self._context_curr_mouse_pos is not None:
|
|
1232
|
+
self._context_curr_mouse_pos = None
|
|
1233
|
+
self._context_hover_point = None
|
|
1234
|
+
self._context_nearest_points = None
|
|
1235
|
+
self._context_menu.unpost()
|
|
1236
|
+
return
|
|
1237
|
+
self._handle_mouse_input(cv2.EVENT_LBUTTONDOWN, Point(e.x, e.y))
|
|
1238
|
+
|
|
1239
|
+
def on_left_mouse_up(e: tk.Event):
|
|
1240
|
+
self._handle_mouse_input(cv2.EVENT_LBUTTONUP, Point(e.x, e.y))
|
|
1241
|
+
|
|
1242
|
+
def on_zoom(e: tk.Event):
|
|
1243
|
+
increment = -1 if (e.num == 5 or e.delta > 0) else 1
|
|
1244
|
+
self._adjust_downscale(increment, allow_resize=False)
|
|
1245
|
+
|
|
1246
|
+
self._context_menu = tk.Menu(self._root)
|
|
1247
|
+
|
|
1248
|
+
self._context_menu.add_command(
|
|
1249
|
+
label="New Point",
|
|
1250
|
+
accelerator=KEYBIND_POINT_ADD,
|
|
1251
|
+
command=self._invoke_with_stashed_context(self._add_point),
|
|
1252
|
+
)
|
|
1253
|
+
self._context_menu.add_command(
|
|
1254
|
+
label="Delete Point",
|
|
1255
|
+
accelerator=KEYBIND_POINT_DELETE,
|
|
1256
|
+
command=self._invoke_with_stashed_context(self._delete_point),
|
|
1257
|
+
)
|
|
1258
|
+
self._context_menu.add_separator()
|
|
1259
|
+
self._context_menu.add_command(
|
|
1260
|
+
label="Next Region", accelerator=KEYBIND_REGION_NEXT, command=self._next_region
|
|
1261
|
+
)
|
|
1262
|
+
self._context_menu.add_command(
|
|
1263
|
+
label="Previous Region", accelerator=KEYBIND_REGION_PREVIOUS, command=self._prev_region
|
|
1264
|
+
)
|
|
1265
|
+
self._context_menu.add_separator()
|
|
1266
|
+
self._context_menu.add_command(
|
|
1267
|
+
label="New Region",
|
|
1268
|
+
accelerator=KEYBIND_REGION_ADD,
|
|
1269
|
+
command=self._invoke_with_stashed_context(self._add_region),
|
|
1270
|
+
)
|
|
1271
|
+
self._context_menu.add_command(
|
|
1272
|
+
label="Delete Region",
|
|
1273
|
+
accelerator=KEYBIND_REGION_DELETE,
|
|
1274
|
+
command=self._invoke_with_stashed_context(self._delete_region),
|
|
1275
|
+
)
|
|
1276
|
+
# TODO: Allow configuring mouse buttons.
|
|
1277
|
+
self._editor_canvas.bind("<Button-1>", on_left_mouse_down)
|
|
1278
|
+
self._editor_canvas.bind("<ButtonRelease-1>", on_left_mouse_up)
|
|
1279
|
+
# OSX uses mouse 2 but Windows/Linux use Mouse3.
|
|
1280
|
+
context_menu_button = "<Button-2>" if sys.platform == "darwin" else "<Button-3>"
|
|
1281
|
+
self._editor_canvas.bind(context_menu_button, self._activate_context_menu)
|
|
1282
|
+
self._editor_canvas.bind("<Motion>", on_mouse_move)
|
|
1283
|
+
# Windows
|
|
1284
|
+
self._editor_canvas.bind("<Control-MouseWheel>", on_zoom)
|
|
1285
|
+
|
|
1286
|
+
# Linux: Testing on Ubuntu shows scroll up/down as button clicks 4/5.
|
|
1287
|
+
# This will also capture these buttons on Windows.
|
|
1288
|
+
def on_scroll(up: bool):
|
|
1289
|
+
def scroll_handler(_: tk.Event):
|
|
1290
|
+
# On Linux we must have control held to scroll.
|
|
1291
|
+
if os.name != "nt" and not self._pan_enabled:
|
|
1292
|
+
return
|
|
1293
|
+
self._adjust_downscale(-1 if up else 1, allow_resize=False)
|
|
1294
|
+
|
|
1295
|
+
return scroll_handler
|
|
1296
|
+
|
|
1297
|
+
self._editor_canvas.bind("<Button-4>", on_scroll(True))
|
|
1298
|
+
self._editor_canvas.bind("<Button-5>", on_scroll(False))
|
|
1299
|
+
|
|
1300
|
+
def _invoke_with_stashed_context(self, f):
|
|
1301
|
+
def _invoke():
|
|
1302
|
+
self._curr_mouse_pos = self._context_curr_mouse_pos
|
|
1303
|
+
self._hover_point = self._context_hover_point
|
|
1304
|
+
self._nearest_points = self._context_nearest_points
|
|
1305
|
+
return f()
|
|
1306
|
+
|
|
1307
|
+
return _invoke
|
|
1308
|
+
|
|
1309
|
+
def _activate_context_menu(self, e: tk.Event):
|
|
1310
|
+
self._curr_mouse_pos, inside_canvas = self._to_canvas_coordinates(Point(e.x, e.y))
|
|
1311
|
+
if not inside_canvas:
|
|
1312
|
+
return
|
|
1313
|
+
self._recalculate_data()
|
|
1314
|
+
|
|
1315
|
+
# Stash the state since sometimes we can interact with the canvas while the menu is posted.
|
|
1316
|
+
self._context_curr_mouse_pos = self._curr_mouse_pos
|
|
1317
|
+
self._context_hover_point = self._hover_point
|
|
1318
|
+
self._context_nearest_points = self._nearest_points
|
|
1319
|
+
|
|
1320
|
+
# Update the menu state before posting.
|
|
1321
|
+
can_add_point = self._hover_point is None and self._nearest_points is not None
|
|
1322
|
+
self._context_menu.entryconfigure(
|
|
1323
|
+
"New Point", state=tk.ACTIVE if can_add_point else tk.DISABLED
|
|
1324
|
+
)
|
|
1325
|
+
can_delete_point = self._hover_point is not None
|
|
1326
|
+
self._context_menu.entryconfigure(
|
|
1327
|
+
"Delete Point", state=tk.ACTIVE if can_delete_point else tk.DISABLED
|
|
1328
|
+
)
|
|
1329
|
+
has_multiple_regions = len(self._regions) > 1
|
|
1330
|
+
self._context_menu.entryconfigure(
|
|
1331
|
+
"Next Region", state=tk.ACTIVE if has_multiple_regions else tk.DISABLED
|
|
1332
|
+
)
|
|
1333
|
+
self._context_menu.entryconfigure(
|
|
1334
|
+
"Previous Region", state=tk.ACTIVE if has_multiple_regions else tk.DISABLED
|
|
1335
|
+
)
|
|
1336
|
+
can_delete_region = len(self._regions) > 0
|
|
1337
|
+
self._context_menu.entryconfigure(
|
|
1338
|
+
"Delete Region", state=tk.ACTIVE if can_delete_region else tk.DISABLED
|
|
1339
|
+
)
|
|
1340
|
+
self._context_menu.post(e.x_root, e.y_root)
|
|
1341
|
+
|
|
1342
|
+
def _on_pan(self):
|
|
1343
|
+
shift_x, shift_y = (
|
|
1344
|
+
(self._curr_mouse_pos.x - self._drag_start.x) / float(self._frame.shape[1]),
|
|
1345
|
+
(self._curr_mouse_pos.y - self._drag_start.y) / float(self._frame.shape[0]),
|
|
1346
|
+
)
|
|
1347
|
+
self._editor_canvas.xview_moveto(shift_x / self._scale)
|
|
1348
|
+
self._editor_canvas.yview_moveto(shift_y / self._scale)
|
|
1349
|
+
|
|
1350
|
+
def _add_region(self):
|
|
1351
|
+
if self._dragging:
|
|
1352
|
+
return
|
|
1353
|
+
|
|
1354
|
+
# Add a box around the current mouse position that's roughly 20% of the frame.
|
|
1355
|
+
width, height = (
|
|
1356
|
+
max(1, self._source_size.w // 10),
|
|
1357
|
+
max(1, self._source_size.h // 10),
|
|
1358
|
+
)
|
|
1359
|
+
|
|
1360
|
+
top_left = Point(x=self._curr_mouse_pos.x - width, y=self._curr_mouse_pos.y - height)
|
|
1361
|
+
points = [
|
|
1362
|
+
top_left,
|
|
1363
|
+
Point(x=top_left.x + 2 * width, y=top_left.y),
|
|
1364
|
+
Point(x=top_left.x + 2 * width, y=top_left.y + 2 * height),
|
|
1365
|
+
Point(x=top_left.x, y=top_left.y + 2 * height),
|
|
1366
|
+
]
|
|
1367
|
+
|
|
1368
|
+
self._regions.append([bound_point(point, self._source_size) for point in points])
|
|
1369
|
+
self._active_shape = len(self._regions) - 1
|
|
1370
|
+
self._commit()
|
|
1371
|
+
self._recalculate = True
|
|
1372
|
+
self._redraw = True
|
|
1373
|
+
self._draw()
|
|
1374
|
+
|
|
1375
|
+
def _delete_region(self):
|
|
1376
|
+
if self._dragging:
|
|
1377
|
+
return
|
|
1378
|
+
if self._regions:
|
|
1379
|
+
del self._regions[self._active_shape]
|
|
1380
|
+
self._active_shape = max(0, self._active_shape - 1)
|
|
1381
|
+
self._commit()
|
|
1382
|
+
self._recalculate = True
|
|
1383
|
+
self._redraw = True
|
|
1384
|
+
self._draw()
|
|
1385
|
+
|
|
1386
|
+
def _select_region(self, index: int):
|
|
1387
|
+
logger.debug(f"selecting region {index}")
|
|
1388
|
+
if self._dragging:
|
|
1389
|
+
return
|
|
1390
|
+
assert index >= 0
|
|
1391
|
+
if self._regions and index < len(self._regions):
|
|
1392
|
+
self._active_shape = index
|
|
1393
|
+
self._region_selector.current(self._active_shape)
|
|
1394
|
+
self._recalculate = True
|
|
1395
|
+
self._redraw = True
|
|
1396
|
+
self._draw()
|
|
1397
|
+
|
|
1398
|
+
def _next_region(self):
|
|
1399
|
+
if self._dragging:
|
|
1400
|
+
return
|
|
1401
|
+
if self._regions:
|
|
1402
|
+
self._active_shape = (self._active_shape + 1) % len(self._regions)
|
|
1403
|
+
self._region_selector.current(self._active_shape)
|
|
1404
|
+
self._recalculate = True
|
|
1405
|
+
self._redraw = True
|
|
1406
|
+
self._draw()
|
|
1407
|
+
|
|
1408
|
+
def _prev_region(self):
|
|
1409
|
+
if self._dragging:
|
|
1410
|
+
return
|
|
1411
|
+
if self._regions:
|
|
1412
|
+
self._active_shape = (self._active_shape - 1) % len(self._regions)
|
|
1413
|
+
self._region_selector.current(self._active_shape)
|
|
1414
|
+
self._recalculate = True
|
|
1415
|
+
self._redraw = True
|
|
1416
|
+
self._draw()
|