DVR-Scan-Headless 1.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. dvr_scan/LICENSE +47 -0
  2. dvr_scan/__init__.py +55 -0
  3. dvr_scan/__main__.py +80 -0
  4. dvr_scan/app/__init__.py +10 -0
  5. dvr_scan/app/__main__.py +218 -0
  6. dvr_scan/app/about_window.py +137 -0
  7. dvr_scan/app/application.py +1700 -0
  8. dvr_scan/app/common.py +31 -0
  9. dvr_scan/app/region_editor.py +1416 -0
  10. dvr_scan/app/scan_window.py +258 -0
  11. dvr_scan/app/widgets.py +213 -0
  12. dvr_scan/cli.py +511 -0
  13. dvr_scan/config.py +607 -0
  14. dvr_scan/controller.py +215 -0
  15. dvr_scan/detector.py +124 -0
  16. dvr_scan/docs/404.html +1 -0
  17. dvr_scan/docs/assets/javascripts/bundle.88dd0f4e.min.js +15 -0
  18. dvr_scan/docs/assets/stylesheets/main.6f8fc17f.min.css +1 -0
  19. dvr_scan/docs/assets/stylesheets/palette.06af60db.min.css +1 -0
  20. dvr_scan/docs/changelog.html +1 -0
  21. dvr_scan/docs/docs.html +73 -0
  22. dvr_scan/docs/faq.html +13 -0
  23. dvr_scan/docs/guide.html +1 -0
  24. dvr_scan/docs/index.html +5 -0
  25. dvr_scan/docs/resources.html +30 -0
  26. dvr_scan/docs/style.min.css +1 -0
  27. dvr_scan/dvr-scan-logo.png +0 -0
  28. dvr_scan/dvr-scan.ico +0 -0
  29. dvr_scan/dvr-scan.png +0 -0
  30. dvr_scan/opencv_loader.py +37 -0
  31. dvr_scan/overlays.py +251 -0
  32. dvr_scan/platform.py +236 -0
  33. dvr_scan/region.py +65 -0
  34. dvr_scan/scanner.py +1303 -0
  35. dvr_scan/shared/__init__.py +287 -0
  36. dvr_scan/shared/cli.py +291 -0
  37. dvr_scan/shared/settings.py +72 -0
  38. dvr_scan/subtractor.py +163 -0
  39. dvr_scan/video_joiner.py +188 -0
  40. dvr_scan_headless-1.8.dist-info/METADATA +119 -0
  41. dvr_scan_headless-1.8.dist-info/RECORD +45 -0
  42. dvr_scan_headless-1.8.dist-info/WHEEL +5 -0
  43. dvr_scan_headless-1.8.dist-info/entry_points.txt +2 -0
  44. dvr_scan_headless-1.8.dist-info/licenses/LICENSE +47 -0
  45. dvr_scan_headless-1.8.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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()