From a96762517c59db28936cfd911bb193fb94239b6a Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Thu, 5 Dec 2024 18:18:11 -0500 Subject: [PATCH] [region-editor] Transition to Tcl/Tk for GUI #181 This allows for better control over window events and makes input handling more consistent across platforms. It also allows better customization, for example, the system undo/redo commands are used instead of key binds. There is still much work to do, however this is a good starting point, and has feature parity with the existing editor. --- dvr_scan/platform.py | 127 ++++--- dvr_scan/region.py | 820 +++++++++++++++++++++++++++++++------------ dvr_scan/scanner.py | 6 +- 3 files changed, 655 insertions(+), 298 deletions(-) diff --git a/dvr_scan/platform.py b/dvr_scan/platform.py index 307d198..b6167ea 100644 --- a/dvr_scan/platform.py +++ b/dvr_scan/platform.py @@ -13,11 +13,12 @@ Provides logging and platform/operating system compatibility. """ +import importlib import logging import os +import platform import subprocess import sys -from contextlib import contextmanager from typing import AnyStr, Optional try: @@ -25,7 +26,7 @@ except ImportError: screeninfo = None -from scenedetect.platform import get_and_create_path +from scenedetect.platform import get_and_create_path, get_ffmpeg_version, get_mkvmerge_version try: import tkinter @@ -33,37 +34,14 @@ tkinter = None -# TODO(v1.7): Figure out how to make icon work on Linux. Might need a PNG version. -def get_icon_path() -> str: - if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): - app_folder = os.path.abspath(os.path.dirname(sys.executable)) - icon_path = os.path.join(app_folder, "dvr-scan.ico") - if os.path.exists(icon_path): - return icon_path - # TODO(v1.7): Figure out how to properly get icon path in the package. The folder will be - # different in the final Windows build, may have to check if this is a frozen instance or not. - # Also need to ensure the icon is included in the package metadata. - # For Python distributions, may have to put dvr-scan.ico with the source files, and use - # os.path.dirname(sys.modules[package].__file__) (or just __file__ here). - for path in ("dvr-scan.ico", "dist/dvr-scan.ico"): - if os.path.exists(path): - return path - return "" - - HAS_TKINTER = tkinter is not None -IS_WINDOWS = os.name == "nt" - -if IS_WINDOWS: - import ctypes - import ctypes.wintypes - def get_min_screen_bounds(): """Attempts to get the minimum screen resolution of all monitors using the `screeninfo` package. Returns the minimum of all monitor's heights and widths with 10% padding, or None if the package is unavailable.""" + # TODO: See if we can replace this with Tkinter (`winfo_screenwidth` / `winfo_screenheight`). if screeninfo is not None: try: monitors = screeninfo.get_monitors() @@ -157,42 +135,61 @@ def get_filename(path: AnyStr, include_extension: bool) -> AnyStr: return filename -def set_icon(window_name: str): - icon_path = get_icon_path() - if not icon_path: - return - if not IS_WINDOWS: - # TODO: Set icon on Linux/OSX. - return - SendMessage = ctypes.windll.user32.SendMessageW - FindWindow = ctypes.windll.user32.FindWindowW - LoadImage = ctypes.windll.user32.LoadImageW - SetFocus = ctypes.windll.user32.SetFocus - IMAGE_ICON = 1 - ICON_SMALL = 1 - ICON_BIG = 1 - LR_LOADFROMFILE = 0x00000010 - LR_CREATEDIBSECTION = 0x00002000 - WM_SETICON = 0x0080 - hWnd = FindWindow(None, window_name) - hIcon = LoadImage(None, icon_path, IMAGE_ICON, 0, 0, LR_LOADFROMFILE | LR_CREATEDIBSECTION) - SendMessage(hWnd, WM_SETICON, ICON_SMALL, hIcon) - SendMessage(hWnd, WM_SETICON, ICON_BIG, hIcon) - SetFocus(hWnd) - - -@contextmanager -def temp_tk_window(): - """Used to provide a hidden Tk window as a root for pop-up dialog boxes to return focus to - main region window when destroyed.""" - root = tkinter.Tk() - try: - root.withdraw() - # TODO: Set icon on Linux/OSX. - if IS_WINDOWS: - icon_path = get_icon_path() - if icon_path: - root.iconbitmap(os.path.abspath(icon_path)) - yield root - finally: - root.destroy() +def get_system_version_info() -> str: + """Get the system's operating system, Python, packages, and external tool versions. + Useful for debugging or filing bug reports. + + Used for the `scenedetect version -a` command. + """ + output_template = "{:<8} {}" + line_separator = "-" * 40 + not_found_str = "Not Installed" + out_lines = [] + + # System (Python, OS) + out_lines += ["System Info", line_separator] + out_lines += [ + output_template.format(name, version) + for name, version in ( + ("OS:", "%s" % platform.platform()), + ("Python:", "%s %s" % (platform.python_implementation(), platform.python_version())), + ("Arch:", " + ".join(platform.architecture())), + ) + ] + output_template = "{:<16} {}" + + # Third-Party Packages + out_lines += ["", "Packages", line_separator] + third_party_packages = ( + "cv2", + "dvr_scan", + "numpy", + "platformdirs", + "scenedetect", + "screeninfo", + "tqdm", + ) + for module_name in third_party_packages: + try: + module = importlib.import_module(module_name) + if hasattr(module, "__version__"): + out_lines.append(output_template.format(module_name, module.__version__)) + else: + out_lines.append(output_template.format(module_name, not_found_str)) + except ModuleNotFoundError: + out_lines.append(output_template.format(module_name, not_found_str)) + + # External Tools + out_lines += ["", "Tools", line_separator] + + tool_version_info = ( + ("ffmpeg", get_ffmpeg_version()), + ("mkvmerge", get_mkvmerge_version()), + ) + + for tool_name, tool_version in tool_version_info: + out_lines.append( + output_template.format(tool_name, tool_version if tool_version else not_found_str) + ) + + return "\n".join(out_lines) diff --git a/dvr_scan/region.py b/dvr_scan/region.py index 53a99a0..b1da781 100644 --- a/dvr_scan/region.py +++ b/dvr_scan/region.py @@ -15,7 +15,10 @@ import math import os +import os.path +import sys import typing as ty +import webbrowser from collections import namedtuple from copy import deepcopy from dataclasses import dataclass @@ -24,30 +27,33 @@ import cv2 import numpy as np -from dvr_scan.platform import HAS_TKINTER, IS_WINDOWS, set_icon, temp_tk_window +import dvr_scan +from dvr_scan.platform import HAS_TKINTER, get_system_version_info if HAS_TKINTER: - import tkinter + import tkinter as tk import tkinter.filedialog import tkinter.messagebox + import tkinter.scrolledtext + import tkinter.ttk as ttk + + import PIL + import PIL.Image + import PIL.ImageTk # TODO: Update screenshots to reflect release title. WINDOW_TITLE = "DVR-Scan Region Editor" PROMPT_TITLE = "DVR-Scan" +# TODO: Use a different prompt on quit vs. scan start. PROMPT_MESSAGE = "You have unsaved changes.\nDo you want to save?" SAVE_TITLE = "Save Region File" LOAD_TITLE = "Load Region File" -KEYCODE_ESCAPE = ord("\x1b") -KEYCODE_RETURN = ord("\r") -KEYCODE_SPACE = ord(" ") -# Control + Z/Y for undo/redo only seem to work on Windows. -KEYCODE_WINDOWS_UNDO = 26 -KEYCODE_WINDOWS_REDO = 25 - -DEFAULT_WINDOW_MODE = cv2.WINDOW_AUTOSIZE if IS_WINDOWS else cv2.WINDOW_KEEPRATIO -"""Minimum height/width for a ROI created using the mouse.""" +ABOUT_WINDOW_COPYRIGHT = ( + f"DVR-Scan {dvr_scan.__version__}\n\nCopyright © Brandon Castellano.\nAll rights reserved." +) +# TODO: Need to figure out DPI scaling for *everything*. Lots of magic numbers for sizes right now. MIN_SIZE = 16 """Minimum height/width for a ROI created using the mouse.""" @@ -57,20 +63,35 @@ InputRectangle = ty.Tuple[Point, Point] +# TODO(v1.7): Figure out how to make icon work on Linux. Might need a PNG version. +def get_icon_path() -> str: + if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): + app_folder = os.path.abspath(os.path.dirname(sys.executable)) + icon_path = os.path.join(app_folder, "dvr-scan.ico") + if os.path.exists(icon_path): + return icon_path + # TODO(v1.7): Figure out how to properly get icon path in the package. The folder will be + # different in the final Windows build, may have to check if this is a frozen instance or not. + # Also need to ensure the icon is included in the package metadata. + # For Python distributions, may have to put dvr-scan.ico with the source files, and use + # os.path.dirname(sys.modules[package].__file__) (or just __file__ here). + for path in ("dvr-scan.ico", "dist/dvr-scan.ico"): + if os.path.exists(path): + return path + return "" + + +def get_logo_path() -> str: + # HACK + return "docs/assets/dvr-scan-logo.png" + + @dataclass class Snapshot: regions: ty.List[ty.List[Point]] active_shape: ty.Optional[int] -def warn_if_tkinter_missing(): - if not HAS_TKINTER: - logger.warning( - "Warning: Tkinter is not installed. Install the python3-tk package to ensure region " - "data is saved, or specify -s/--save-region." - ) - - class RegionValidator: """Validator for a set of points representing a closed polygon.""" @@ -112,30 +133,40 @@ class EditorSettings: """The path specified by the -s/--save-regions option if set.""" use_aa: bool = True mask_source: bool = False - window_mode: int = DEFAULT_WINDOW_MODE line_color: ty.Tuple[int, int, int] = (255, 0, 0) line_color_alt: ty.Tuple[int, int, int] = (255, 153, 51) hover_color: ty.Tuple[int, int, int] = (0, 127, 255) hover_color_alt: ty.Tuple[int, int, int] = (0, 0, 255) interact_color: ty.Tuple[int, int, int] = (0, 255, 255) highlight_insert: bool = False + # TODO: Save window position. + # TODO: Save these settings automagically in the user settings folder. + + +class AutoHideScrollbar(tk.Scrollbar): + def set(self, lo, hi): + if float(lo) <= 0.0 and float(hi) >= 1.0: + self.grid_remove() + else: + self.grid() + tk.Scrollbar.set(self, lo, hi) # TODO(v1.7): Move more of these constants to EditorSettings. MIN_NUM_POINTS = 3 MAX_HISTORY_SIZE = 1024 MIN_DOWNSCALE_FACTOR = 1 -MAX_DOWNSCALE_FACTOR = 50 +MAX_DOWNSCALE_FACTOR = 20 MAX_UPDATE_RATE_NORMAL = 20 MAX_UPDATE_RATE_DRAGGING = 5 HOVER_DISPLAY_DISTANCE = 260**2 MAX_DOWNSCALE_AA_LEVEL = 4 +# TODO: In v1.8 we need to have actual UI elements for this stuff and remove keyboard shortcuts. KEYBIND_BREAKPOINT = "b" KEYBIND_DOWNSCALE_INC = "w" KEYBIND_DOWNSCALE_DEC = "e" KEYBIND_HELP = "h" -KEYBIND_LOAD = "o" KEYBIND_MASK = "m" KEYBIND_OUTPUT_LIST = "c" KEYBIND_POINT_ADD = "a" @@ -144,11 +175,10 @@ class EditorSettings: KEYBIND_REGION_DELETE = "g" KEYBIND_REGION_NEXT = "l" KEYBIND_REGION_PREVIOUS = "k" -KEYBIND_REDO = "y" KEYBIND_TOGGLE_AA = "q" +# TODO: Require Ctrl to be held down with these shortcuts. +KEYBIND_LOAD = "o" KEYBIND_SAVE = "s" -KEYBIND_UNDO = "z" -KEYBIND_WINDOW_MODE = "r" def control_handle_radius(scale: int): @@ -181,27 +211,24 @@ def edge_thickness(scale: int, ext: int = 0): return 1 + ext if scale <= 20 else 0 -def show_controls(): - """Display keyboard/mouse controls.""" - # Right click is disabled on Linux/OSX due to a context manager provided by the UI framework - # showing up when right clicking. - _WINDOWS_ONLY = "Right, " if IS_WINDOWS else "" - - logger.info(f"""ROI Window Controls: +def get_controls() -> str: + """Get keyboard/mouse controls.""" + # TODO: Undo/redo are now the correct system inputs so they are platform dependent. + return f"""ROI Window Controls: Editor: Mask On/Off Key: {KEYBIND_MASK.upper()} Start Scan Key: Space, Enter Quit Key: Escape - Save Key: {KEYBIND_SAVE.upper()} - Load Key: {KEYBIND_LOAD.upper()} - Undo Key: {"CTRL + " if IS_WINDOWS else ""}{KEYBIND_UNDO.upper()} - Redo Key: {"CTRL + " if IS_WINDOWS else ""}{KEYBIND_REDO.upper()} + Save Key: CTRL + {KEYBIND_SAVE.upper()} + Load Key: CTRL + {KEYBIND_LOAD.upper()} + Undo Key: CTRL + Z + Redo Key: CTRL + Y Print Points Key: {KEYBIND_OUTPUT_LIST.upper()} Regions: - Add Point Key: {KEYBIND_POINT_ADD.upper()}, Mouse: Left - Delete Point Key: {KEYBIND_POINT_DELETE.upper()}, Mouse: {_WINDOWS_ONLY}Middle + Add Point Key: {KEYBIND_POINT_ADD.upper()}, Mouse: Left Click + Delete Point Key: {KEYBIND_POINT_DELETE.upper()}, Mouse: Middle/Right Click Add Region Key: {KEYBIND_REGION_ADD.upper()} Delete Region Key: {KEYBIND_REGION_DELETE.upper()} Select Region Key: 1 - 9 @@ -211,8 +238,7 @@ def show_controls(): Display: Downscale +/- Key: {KEYBIND_DOWNSCALE_INC.upper()}(+), {KEYBIND_DOWNSCALE_DEC.upper()} (-) Antialiasing Key: {KEYBIND_TOGGLE_AA.upper()} - Window Mode Key: {KEYBIND_WINDOW_MODE.upper()} -""") +""" def initial_point_list(frame_size: Size) -> ty.List[Point]: @@ -227,11 +253,11 @@ def initial_point_list(frame_size: Size) -> ty.List[Point]: ] -def squared_distance(a: Point, b: Point): +def squared_distance(a: Point, b: Point) -> int: return (a.x - b.x) ** 2 + (a.y - b.y) ** 2 -def bound_point(point: Point, size: Size): +def bound_point(point: Point, size: Size) -> Point: return Point(min(max(0, point.x), size.w), min(max(0, point.y), size.h)) @@ -259,37 +285,42 @@ def __init__( video_path: str, save_path: ty.Optional[str], ): - # TODO: Move more fields from this class into `EditorSettings`. self._settings = EditorSettings(video_path=video_path, save_path=save_path) - - self._source_frame = frame.copy() # Frame before downscaling - self._source_size = Size(w=frame.shape[1], h=frame.shape[0]) + self._source_frame: np.nd = frame.copy() # Frame before downscaling + self._source_size: Size = Size(w=frame.shape[1], h=frame.shape[0]) + self._frame: np.ndarray = frame.copy() # Workspace + self._frame_size: Size = Size(w=frame.shape[1], h=frame.shape[0]) + self._original_frame: np.ndarray = frame.copy() # Copy to redraw on + self._regions: ty.List[ty.List[Point]] = ( + initial_shapes if initial_shapes else [initial_point_list(self._frame_size)] + ) + self._active_shape: int = len(self._regions) - 1 + self._history: ty.List[Snapshot] = [] + self._history_pos: int = 0 + self._curr_mouse_pos: Point = None + self._redraw: bool = True + self._recalculate: bool = True + self._hover_point: ty.Optional[int] = None + self._nearest_points: ty.Optional[ty.Tuple[int, int]] = None + self._dragging: bool = False + self._drag_start: ty.Optional[Point] = None + self._debug_mode: bool = debug_mode + self._segment_dist: ty.List[int] = [] # Square distance of segment from point i to i+1 + self._mouse_dist: ty.List[int] = [] # Square distance of mouse to point i self._scale: int = 1 if initial_scale is None else initial_scale - self._frame = frame.copy() # Workspace - self._frame_size = Size(w=frame.shape[1], h=frame.shape[0]) - self._original_frame = frame.copy() # Copy to redraw on - if initial_shapes: - self._regions = initial_shapes - else: - self._regions = [initial_point_list(self._frame_size)] - self._active_shape = len(self._regions) - 1 - self._history = [] - self._history_pos = 0 - self._curr_mouse_pos = None - self._redraw = True - self._recalculate = True - self._hover_point = None - self._nearest_points = None - self._dragging = False - self._drag_start = None - self._debug_mode = debug_mode - self._segment_dist = [] # Square distance of segment from point i to i+1 - self._mouse_dist = [] # Square distance of mouse to point i - if self._scale > 1: - self._rescale() - self._persisted = True # Indicates if we've saved outstanding changes to disk. + self._persisted: bool = True # Indicates if we've saved outstanding changes to disk. self._commit(persisted=True) # Add initial history for undo. + self._root: tk.Tk = None + self._editor_window: tk.Toplevel = None + self._editor_canvas: tk.Canvas = None + self._editor_scroll: ty.Tuple[tk.Scrollbar, tk.Scrollbar] = None + self._should_scan: bool = False + self._version_info: ty.Optional[str] = None + self._scale_widget: ttk.Scale = None + self._pan_enabled: bool = False + self._panning: bool = False + @property def shapes(self) -> ty.Iterable[ty.Iterable[Point]]: return self._regions @@ -302,12 +333,28 @@ def active_region(self) -> ty.Optional[ty.List[Point]]: else None ) - def _rescale(self): + # TODO: Using a virtual event for this would be much cleaner. + def _rescale(self, draw=True, allow_resize=True): assert self._scale > 0 + logger.info(f"Downscale factor: {self._scale}") self._original_frame = self._source_frame[:: self._scale, :: self._scale, :].copy() self._frame = self._original_frame.copy() self._frame_size = Size(w=self._frame.shape[1], h=self._frame.shape[0]) self._redraw = True + self._editor_canvas["scrollregion"] = (0, 0, self._frame.shape[1], self._frame.shape[0]) + if allow_resize: + max_width_auto = int(self._root.winfo_screenwidth() * 0.8) + max_height_auto = int(self._root.winfo_screenheight() * 0.8) + self._editor_canvas["width"] = min(max_width_auto, self._frame.shape[1]) + self._editor_canvas["height"] = min(max_height_auto, self._frame.shape[0]) + else: + old_geom = self._root.geometry() + self._editor_canvas["width"] = self._frame.shape[1] + self._editor_canvas["height"] = self._frame.shape[0] + self._root.geometry(old_geom) + + if draw: + self._draw() logger.debug( "Resize: scale = 1/%d%s, res = %d x %d", self._scale, @@ -324,6 +371,7 @@ def _undo(self): self._active_shape = snapshot.active_shape self._recalculate = True self._redraw = True + self._draw() logger.debug("Undo: [%d/%d]", self._history_pos, len(self._history) - 1) def _redo(self): @@ -334,6 +382,7 @@ def _redo(self): self._active_shape = snapshot.active_shape self._recalculate = True self._redraw = True + self._draw() logger.debug("Redo: [%d/%d]", self._history_pos, len(self._history) - 1) def _commit(self, persisted=False): @@ -357,13 +406,30 @@ def _emit_points(self): data = " ".join(region_info) logger.info("Command to scan region:\n" f"dvr-scan -i {self._settings.video_path} {data}") + def _set_cursor(self): + if self._pan_enabled: + self._editor_canvas.config(cursor="sizing") + elif self._hover_point is None: + self._editor_canvas.config(cursor="crosshair") + else: + self._editor_canvas.config(cursor="") + def _draw(self): + self._set_cursor() if self._recalculate: self._recalculate_data() if not self._redraw: + self._editor_canvas.create_image(0, 0, anchor=tk.NW, image=self._image) return + curr_aa = ( + cv2.LINE_AA + if self._settings.use_aa and self._scale <= MAX_DOWNSCALE_AA_LEVEL + else cv2.LINE_4 + ) + frame = self._original_frame.copy() + # Mask pixels outside of the defined region if we're in mask mode. if self._settings.mask_source: mask = np.zeros_like(frame, dtype=np.uint8) @@ -371,7 +437,7 @@ def _draw(self): points = np.array([shape], np.int32) if self._scale > 1: points = points // self._scale - mask = cv2.fillPoly(mask, points, color=(255, 255, 255), lineType=cv2.LINE_4) + mask = cv2.fillPoly(mask, points, color=(255, 255, 255), lineType=curr_aa) # TODO: We can pre-calculate a masked version of the frame and just swap both out. frame = np.bitwise_and(frame, mask).astype(np.uint8) @@ -381,11 +447,6 @@ def _draw(self): points = np.array([shape], np.int32) if self._scale > 1: points = points // self._scale - line_type = ( - cv2.LINE_AA - if self._settings.use_aa and self._scale <= MAX_DOWNSCALE_AA_LEVEL - else cv2.LINE_4 - ) # if not self._settings.mask_source: frame = cv2.polylines( @@ -394,7 +455,7 @@ def _draw(self): isClosed=True, color=self._settings.line_color, thickness=thickness, - lineType=line_type, + lineType=curr_aa, ) if self._hover_point is not None and not self._settings.mask_source: first, mid, last = ( @@ -422,7 +483,7 @@ def _draw(self): if not self._dragging else self._settings.hover_color_alt, thickness=thickness_active, - lineType=line_type, + lineType=curr_aa, ) elif ( self._nearest_points is not None @@ -446,7 +507,7 @@ def _draw(self): isClosed=False, color=self._settings.hover_color, thickness=thickness_active, - lineType=line_type, + lineType=curr_aa, ) if self.active_region is not None: @@ -483,8 +544,10 @@ def _draw(self): color, thickness=cv2.FILLED, ) - self._frame = frame - cv2.imshow(WINDOW_TITLE, self._frame) + + self._frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + self._image = PIL.ImageTk.PhotoImage(image=PIL.Image.fromarray(self._frame)) + self._editor_canvas.create_image(0, 0, anchor=tk.NW, image=self._image) self._redraw = False def _find_nearest(self) -> ty.Tuple[int, int]: @@ -530,86 +593,343 @@ def _hovering_over(self) -> ty.Optional[int]: else None ) - def _init_window(self): - cv2.namedWindow(WINDOW_TITLE, self._settings.window_mode) - if self._settings.window_mode == cv2.WINDOW_AUTOSIZE: - cv2.resizeWindow(WINDOW_TITLE, width=self._frame_size.w, height=self._frame_size.h) - cv2.imshow(WINDOW_TITLE, mat=self._frame) - cv2.setMouseCallback(WINDOW_TITLE, on_mouse=self._handle_mouse_input) - set_icon(WINDOW_TITLE) - def _breakpoint(self): if self._debug_mode: breakpoint() - def _create_keymap(self) -> ty.Dict[int, ty.Callable]: - return { - KEYBIND_BREAKPOINT: lambda: self._breakpoint, - KEYBIND_DOWNSCALE_INC: lambda: self._adjust_downscale(1), - KEYBIND_DOWNSCALE_DEC: lambda: self._adjust_downscale(-1), - KEYBIND_HELP: lambda: show_controls(), - KEYBIND_LOAD: lambda: self._prompt_load(), - KEYBIND_MASK: lambda: self._toggle_mask(), - KEYBIND_OUTPUT_LIST: lambda: self._emit_points(), - KEYBIND_POINT_ADD: lambda: self._add_point(), - KEYBIND_POINT_DELETE: lambda: self._delete_point(), - KEYBIND_REGION_ADD: lambda: self._add_region(), - KEYBIND_REGION_DELETE: lambda: self._delete_region(), - KEYBIND_REGION_NEXT: lambda: self._next_region(), - KEYBIND_REGION_PREVIOUS: lambda: self._prev_region(), - KEYBIND_TOGGLE_AA: lambda: self._toggle_antialiasing(), - KEYBIND_SAVE: lambda: self._prompt_save(), - KEYBIND_WINDOW_MODE: lambda: self._toggle_window_mode(), - chr(KEYCODE_WINDOWS_REDO) if IS_WINDOWS else KEYBIND_REDO: lambda: self._redo(), - chr(KEYCODE_WINDOWS_UNDO) if IS_WINDOWS else KEYBIND_UNDO: lambda: self._undo(), - } + def _bind_keyboard(self) -> ty.Dict[int, ty.Callable]: + for key, fn in { + KEYBIND_BREAKPOINT: lambda _: self._breakpoint, + KEYBIND_DOWNSCALE_INC: lambda _: self._adjust_downscale(1), + KEYBIND_DOWNSCALE_DEC: lambda _: self._adjust_downscale(-1), + KEYBIND_HELP: lambda _: logger.info(get_controls()), + KEYBIND_MASK: lambda _: self._toggle_mask(), + KEYBIND_OUTPUT_LIST: lambda _: self._emit_points(), + KEYBIND_POINT_ADD: lambda _: self._add_point(), + KEYBIND_POINT_DELETE: lambda _: self._delete_point(), + KEYBIND_REGION_ADD: lambda _: self._add_region(), + KEYBIND_REGION_DELETE: lambda _: self._delete_region(), + KEYBIND_TOGGLE_AA: lambda _: self._toggle_antialiasing(), + }.items(): + self._root.bind(key, fn) + self._root.bind("<>", lambda _: self._undo()) + self._root.bind("<>", lambda _: self._redo()) + self._root.protocol("WM_DELETE_WINDOW", lambda: self._close(False)) + + self._root.bind("", lambda _: self._prev_region()) + self._root.bind("", lambda _: self._next_region()) + self._root.bind("", lambda _: self._next_region()) + self._root.bind("", lambda _: self._prev_region()) + + self._root.bind("", lambda _: self._prompt_save()) + self._root.bind("", lambda _: self._prompt_load()) + self._root.bind("", lambda _: self._delete_region()) + + self._root.bind("", lambda _: self._close(False)) + self._root.bind("", lambda _: self._close(True)) + self._root.bind("", lambda _: self._close(True)) + + def set_pan_mode(mode: bool): + self._pan_enabled = mode + if not self._pan_enabled: + # Clear any existing pan state. + self._panning = False + self._set_cursor() + + self._root.bind("", lambda _: set_pan_mode(True)) + self._root.bind("", lambda _: set_pan_mode(True)) + self._root.bind("", lambda _: set_pan_mode(False)) + self._root.bind("", lambda _: set_pan_mode(False)) + + for i in range(9): + + def select_region(index): + return lambda _: self._select_region(index) + + self._root.bind(f"{i+1}", select_region(i)) + + # TODO: If Shift is held down, allow translating current shape + # by left-click and drag. + + def _close(self, should_scan: bool): + self._should_scan = should_scan + if self._prompt_save_on_quit(): + self._root.destroy() def run(self) -> bool: """Run the region editor. Returns True if the video should be scanned, False otherwise.""" - try: - if not self._settings.save_path: - # Warn the user if changes to region data won't be saved if a path wasn't specified, - # and a file dialog box cannot be shown to choose a path. - warn_if_tkinter_missing() - logger.debug("Creating window for frame (scale = %d)", self._scale) - self._init_window() - should_scan = False - logger.info(f"Region editor active. Press {KEYBIND_HELP.upper()} to show controls.") - keyboard_callbacks = self._create_keymap() - while True: - if not cv2.getWindowProperty(WINDOW_TITLE, cv2.WND_PROP_VISIBLE): - logger.debug("Main window closed.") - if self._prompt_save_on_quit(): - break - logger.debug("Re-initializing window.") - self._init_window() - continue - self._draw() - key = ( - cv2.waitKey( - MAX_UPDATE_RATE_NORMAL if not self._dragging else MAX_UPDATE_RATE_DRAGGING - ) - & 0xFF - ) - if key == KEYCODE_ESCAPE: - if self._prompt_save_on_quit(): - break - elif key in (KEYCODE_SPACE, KEYCODE_RETURN): - if self._prompt_save_on_quit(): - should_scan = True - break - elif key >= ord("0") and key <= ord("9"): - self._select_region((key - ord("1")) % 10) - elif chr(key) in keyboard_callbacks: - keyboard_callbacks[chr(key)]() - elif key != 0xFF and self._debug_mode: - logger.debug("Unhandled key: %s", str(key)) - return should_scan - - finally: - cv2.destroyAllWindows() - - def _adjust_downscale(self, amount: int): + logger.debug("Creating window for frame (scale = %d)", self._scale) + + self._root = tk.Tk() + # Withdraw root window until we're done adding everything to avoid visual flicker. + self._root.withdraw() + self._root.option_add("*tearOff", False) + self._root.title(WINDOW_TITLE) + self._root.iconbitmap(get_icon_path()) + self._root.resizable(True, True) + self._root.minsize(width=320, height=240) + self._editor_canvas = tk.Canvas( + self._root, + ) + self._root.columnconfigure(0, weight=1) + self._root.rowconfigure(0, weight=1) + self._editor_canvas.grid(row=0, column=0, sticky="nsew") + self._editor_scroll = ( + AutoHideScrollbar(self._root, command=self._editor_canvas.xview, orient=tk.HORIZONTAL), + AutoHideScrollbar(self._root, command=self._editor_canvas.yview, orient=tk.VERTICAL), + ) + self._editor_canvas["xscrollcommand"] = self._editor_scroll[0].set + self._editor_canvas["yscrollcommand"] = self._editor_scroll[1].set + self._editor_scroll[0].grid(row=1, column=0, sticky="ew") + self._editor_scroll[1].grid(row=0, column=1, sticky="ns") + + ttk.Separator(self._root).grid(row=2, column=0, columnspan=2, sticky="ew") + + def clamp_scale(val: str): + new_val = round(float(val)) + if self._scale != new_val: + self._scale = new_val + # Disable resize since we're using the mouse input here. + self._rescale(allow_resize=False) + + self._scale_widget = ttk.Scale( + self._root, + orient=tk.HORIZONTAL, + length=200, + from_=MAX_DOWNSCALE_FACTOR, + to=MIN_DOWNSCALE_FACTOR, + command=clamp_scale, + value=self._scale, + ) + self._scale_widget.grid(row=3, column=0, sticky="sew", padx=16.0) + + self._bind_mouse() + self._bind_keyboard() + self._attach_menubar() + self._rescale(draw=False) + self._redraw = True + self._draw() + + self._root.deiconify() + logger.info(f"Region editor active. Press {KEYBIND_HELP.upper()} to show controls.") + + self._root.grab_set() + self._root.focus() + self._root.mainloop() + return self._should_scan + + def _attach_menubar(self): + root_menu = tk.Menu(self._root) + self._root["menu"] = root_menu + + file_menu = tk.Menu(root_menu) + root_menu.add_cascade(menu=file_menu, label="File") + file_menu.add_command(label="Start Scan", command=lambda: self._close(True)) + file_menu.add_separator() + file_menu.add_command(label="Open Region File...", command=self._prompt_load) + file_menu.add_command(label="Save...", command=self._prompt_save) + file_menu.add_separator() + file_menu.add_command(label="Quit", command=lambda: self._close(False)) + + help_menu = tk.Menu(root_menu) + root_menu.add_cascade(menu=help_menu, label="Help") + + help_menu.add_command(label="Show Controls", command=self._show_help) + help_menu.add_command( + label="Online Manual", command=lambda: webbrowser.open_new_tab("www.dvr-scan.com/guide") + ) + help_menu.add_separator() + help_menu.add_command(label="About DVR-Scan", command=self._show_about) + + def _show_about(self): + about_window = tk.Toplevel(master=self._root) + about_window.withdraw() + about_window.title("About DVR-Scan") + about_window.iconbitmap(default=get_icon_path()) + about_window.resizable(True, True) + + about_image = PIL.Image.open(get_logo_path()) + self._about_image = about_image.crop( + (8, 8, about_image.width - 132, about_image.height - 8) + ) + self._about_image_tk = PIL.ImageTk.PhotoImage(self._about_image) + canvas = tk.Canvas( + about_window, width=self._about_image.width, height=self._about_image.height + ) + canvas.grid() + canvas.create_image(0, 0, anchor=tk.NW, image=self._about_image_tk) + + ttk.Separator(about_window, orient=tk.HORIZONTAL).grid(row=1, sticky="ew", padx=16.0) + ttk.Label( + about_window, + text=ABOUT_WINDOW_COPYRIGHT, + ).grid(row=2, sticky="nw", padx=24.0, pady=24.0) + + # TODO: These should be buttons not labels. + website_link = ttk.Label( + about_window, text="www.dvr-scan.com", cursor="hand2", foreground="medium blue" + ) + website_link.grid(row=2, sticky="ne", padx=24.0, pady=24.0) + website_link.bind("", lambda _: webbrowser.open_new_tab("www.dvr-scan.com")) + + about_tabs = ttk.Notebook(about_window) + version_tab = ttk.Frame(about_tabs) + version_area = tkinter.scrolledtext.ScrolledText( + version_tab, wrap=tk.NONE, width=40, height=1 + ) + # TODO: See if we can add another button that will copy debug logs. + if not self._version_info: + self._version_info = get_system_version_info() + version_area.insert(tk.INSERT, self._version_info) + version_area.grid(sticky="nsew") + version_area.config(state="disabled") + version_tab.columnconfigure(0, weight=1) + version_tab.rowconfigure(0, weight=1) + tk.Button( + version_tab, + text="Copy to Clipboard", + command=lambda: self._root.clipboard_append(self._version_info), + ).grid(row=1, column=0) + + license_tab = ttk.Frame(about_tabs) + scrollbar = tk.Scrollbar(license_tab, orient=tk.HORIZONTAL) + license_area = tkinter.scrolledtext.ScrolledText( + license_tab, wrap=tk.NONE, width=40, xscrollcommand=scrollbar.set, height=1 + ) + license_area.insert(tk.INSERT, dvr_scan.get_license_info()) + license_area.grid(sticky="nsew") + scrollbar.config(command=license_area.xview) + scrollbar.grid(row=1, sticky="swe") + license_area.config(state="disabled") + license_tab.columnconfigure(0, weight=1) + license_tab.rowconfigure(0, weight=1) + + # TODO: Add tab that has some useful links like submitting bug report, etc + about_tabs.add(version_tab, text="Version Info") + about_tabs.add(license_tab, text="License Info") + + about_tabs.grid( + row=0, column=1, rowspan=4, padx=(0.0, 16.0), pady=(16.0, 16.0), sticky="nsew" + ) + about_window.update() + about_window.columnconfigure(0, minsize=self._about_image.width) + # minsize includes padding + about_window.columnconfigure(1, weight=1, minsize=100) + about_window.rowconfigure(3, weight=1) + + about_window.minsize( + width=about_window.winfo_reqwidth(), height=about_window.winfo_reqheight() + ) + # can we query widget height? + + self._root.grab_release() + # TODO: Verify if -disabled works on Linux. + self._root.attributes("-disabled", True) + about_window.transient(self._root) + about_window.focus() + about_window.grab_set() + + def dismiss(): + self._root.attributes("-disabled", False) + about_window.grab_release() + about_window.destroy() + self._root.grab_set() + self._root.focus() + + about_window.protocol("WM_DELETE_WINDOW", dismiss) + about_window.attributes("-topmost", True) + about_window.bind("", lambda _: about_window.destroy()) + about_window.bind("", lambda _: dismiss()) + + about_window.deiconify() + about_window.wait_window() + + self._draw() + + def _show_help(self): + controls_window = tk.Toplevel(master=self._root) + controls_window.withdraw() + controls_window.title("Controls") + controls_window.iconbitmap(default=get_icon_path()) + controls_window.resizable(True, True) + controls_window.transient(self._root) + + def dismiss(): + controls_window.destroy() + self._root.focus() + + controls_window.protocol("WM_DELETE_WINDOW", dismiss) + controls_window.attributes("-topmost", True) + controls_window.bind("", lambda _: controls_window.destroy()) + controls_window.bind("", lambda _: dismiss()) + + regions = ttk.Labelframe(controls_window, text="Regions", padding=8.0) + regions.columnconfigure(0, weight=1) + regions.columnconfigure(1, weight=1) + + ttk.Label(regions, text="Add/Move Point").grid(row=0, column=0, sticky="w") + ttk.Label(regions, text="Left Click").grid(row=0, column=1, sticky="w") + + ttk.Label(regions, text="Remove Point").grid(row=1, column=0, sticky="w") + ttk.Label(regions, text="Right Click").grid(row=1, column=1, sticky="w") + + ttk.Label(regions, text="Add New Shape").grid(row=2, column=0, sticky="w") + ttk.Label(regions, text=f"Keyboard: {KEYBIND_REGION_ADD.upper()}").grid( + row=2, column=1, sticky="w" + ) + + ttk.Label(regions, text="Cycle Active Shape").grid(row=3, column=0, sticky="w") + ttk.Label(regions, text="Keyboard: Tab").grid(row=3, column=1, sticky="w") + + viewport = ttk.Labelframe(controls_window, text="Viewport", padding=8.0) + viewport.columnconfigure(0, weight=1) + viewport.columnconfigure(1, weight=1) + + ttk.Label(viewport, text="Zoom").grid(row=0, column=0, sticky="w") + ttk.Label(viewport, text="Ctrl + Scroll Mouse").grid(row=0, column=1, sticky="w") + + ttk.Label(viewport, text="Move/Pan").grid(row=1, column=0, sticky="w") + ttk.Label(viewport, text="Ctrl + Left Click").grid(row=1, column=1, sticky="w") + + ttk.Label(viewport, text="Toggle Mask Mode").grid(row=2, column=0, sticky="w") + ttk.Label(viewport, text=f"Keyboard: {KEYBIND_MASK.upper()}").grid( + row=2, column=1, sticky="w" + ) + + ttk.Label(viewport, text="Toggle Antialiasing").grid(row=3, column=0, sticky="w") + ttk.Label(viewport, text=f"Keyboard: {KEYBIND_TOGGLE_AA.upper()}").grid( + row=3, column=1, sticky="w" + ) + + other = ttk.Labelframe(controls_window, text="Other", padding=8.0) + other.columnconfigure(0, weight=1) + other.columnconfigure(1, weight=1) + + ttk.Label(other, text="These commands will output to terminal.").grid( + row=0, column=0, columnspan=2, sticky="w", pady=(0.0, 16.0) + ) + + ttk.Label(other, text="Show Full Help").grid(row=1, column=0, sticky="w") + ttk.Label(other, text=f"Keyboard: {KEYBIND_HELP.upper()}").grid(row=1, column=1, sticky="w") + + ttk.Label(other, text="Output Point List").grid(row=2, column=0, sticky="w") + ttk.Label(other, text=f"Keyboard: {KEYBIND_OUTPUT_LIST.upper()}").grid( + row=2, column=1, sticky="w" + ) + + regions.grid(row=0, sticky="nsew", padx=8.0, pady=8.0) + viewport.grid(row=1, sticky="nsew", padx=8.0, pady=8.0) + other.grid(row=2, sticky="nsew", padx=8.0, pady=8.0) + + controls_window.columnconfigure(0, weight=1) + controls_window.rowconfigure(0, weight=1) + controls_window.rowconfigure(1, weight=1) + controls_window.rowconfigure(2, weight=1) + controls_window.update() + + controls_window.deiconify() + + def _adjust_downscale(self, amount: int, allow_resize=True): # scale is clamped to MIN_DOWNSCALE_FACTOR/MAX_DOWNSCALE_FACTOR. scale = self._scale + amount self._scale = ( @@ -619,59 +939,47 @@ def _adjust_downscale(self, amount: int): if scale < MAX_DOWNSCALE_FACTOR else MAX_DOWNSCALE_FACTOR ) - logger.info(f"Downscale factor: {self._scale}") - self._rescale() + self._scale_widget.set(self._scale) + self._rescale(allow_resize=allow_resize) def _prompt_save(self): """Save region data, prompting the user if a save path wasn't specified by command line.""" if self._save(): return - if not HAS_TKINTER: - logger.debug("Cannot show file dialog.") - return - save_path = None - with temp_tk_window() as _: - save_path = tkinter.filedialog.asksaveasfilename( - title=SAVE_TITLE, - filetypes=[("Region File", "*.txt")], - defaultextension=".txt", - confirmoverwrite=True, - ) + save_path = tkinter.filedialog.asksaveasfilename( + title=SAVE_TITLE, + filetypes=[("Region File", "*.txt")], + defaultextension=".txt", + confirmoverwrite=True, + ) if save_path: self._save(save_path) def _prompt_save_on_quit(self): """Saves any changes that weren't persisted, prompting the user if a path wasn't specified. Returns True if we should quit the program, False if we should not quit.""" - if not HAS_TKINTER: - logger.debug("Cannot show dialog.") - self._save() - return True # Don't prompt user if changes are already saved. if self._persisted: return True - with temp_tk_window() as _: - should_save = tkinter.messagebox.askyesnocancel( - title=PROMPT_TITLE, - message=PROMPT_MESSAGE, - icon=tkinter.messagebox.WARNING, + should_save = tkinter.messagebox.askyesnocancel( + title=PROMPT_TITLE, + message=PROMPT_MESSAGE, + icon=tkinter.messagebox.WARNING, + ) + if should_save is None: + return False + if should_save and not self._save(): + save_path = tkinter.filedialog.asksaveasfilename( + title=SAVE_TITLE, + filetypes=[("Region File", "*.txt")], + defaultextension=".txt", + confirmoverwrite=True, ) - if should_save is None: + if not save_path: return False - if should_save and not self._save(): - save_path = None - with temp_tk_window() as _: - save_path = tkinter.filedialog.asksaveasfilename( - title=SAVE_TITLE, - filetypes=[("Region File", "*.txt")], - defaultextension=".txt", - confirmoverwrite=True, - ) - if not save_path: - return False - self._save(save_path) - else: - logger.debug("Quitting with unsaved changes.") + self._save(save_path) + else: + logger.debug("Quitting with unsaved changes.") return True def _save(self, path=None): @@ -688,16 +996,14 @@ def _save(self, path=None): return True def _prompt_load(self): - if not HAS_TKINTER: - logger.debug("Cannot show file dialog.") + # TODO: Rename this function. + if not self._prompt_save_on_quit(): return - load_path = None - with temp_tk_window() as _: - load_path = tkinter.filedialog.askopenfilename( - title=LOAD_TITLE, - filetypes=[("Region File", "*.txt")], - defaultextension=".txt", - ) + load_path = tkinter.filedialog.askopenfilename( + title=LOAD_TITLE, + filetypes=[("Region File", "*.txt")], + defaultextension=".txt", + ) if not load_path: return if not os.path.exists(load_path): @@ -728,7 +1034,10 @@ def _prompt_load(self): self._active_shape = 0 if len(self._regions) > 0 else None def _delete_point(self): - if self._hover_point is not None and not self._dragging: + if self._dragging or self._pan_enabled: + logger.debug("Cannot remove point while dragging or panning.") + return + if self._hover_point is not None: if len(self.active_region) > MIN_NUM_POINTS: hover = self._hover_point x, y = self.active_region[hover] @@ -738,7 +1047,6 @@ def _delete_point(self): self._commit() else: logger.error("Cannot remove point, shape must have at least 3 points.") - self._dragging = False def _toggle_antialiasing(self): self._settings.use_aa = not self._settings.use_aa @@ -746,23 +1054,13 @@ def _toggle_antialiasing(self): logger.debug("AA: %s", "ON" if self._settings.use_aa else "OFF") if self._scale >= MAX_DOWNSCALE_AA_LEVEL: logger.warning("AA is disabled due to current scale factor.") + self._draw() def _toggle_mask(self): self._settings.mask_source = not self._settings.mask_source logger.debug("Masking: %s", "ON" if self._settings.mask_source else "OFF") self._redraw = True - - def _toggle_window_mode(self): - cv2.destroyWindow(WINDOW_TITLE) - if self._settings.window_mode == cv2.WINDOW_KEEPRATIO: - self._settings.window_mode = cv2.WINDOW_AUTOSIZE - else: - self._settings.window_mode = cv2.WINDOW_KEEPRATIO - logger.debug( - "Window Mode: %s", - "KEEPRATIO" if self._settings.window_mode == cv2.WINDOW_KEEPRATIO else "AUTOSIZE", - ) - self._init_window() + self._draw() def _add_point(self) -> bool: if self._nearest_points is not None: @@ -778,6 +1076,7 @@ def _add_point(self) -> bool: self._dragging = True self._drag_start = self._curr_mouse_pos self._redraw = True + self._draw() return True return False @@ -810,13 +1109,31 @@ def _recalculate_data(self): self._redraw = True self._recalculate = False - def _handle_mouse_input(self, event, x, y, flags, param): - # TODO: Map mouse events to callbacks rather than handling each event conditionally. - drag_started = False + def _to_canvas_coordinates(self, point: Point) -> ty.Tuple[Point, bool]: + """Adjust mouse coordinates to be relative to the editor canvas. + + Returns bounded point as well as a boolean if the cursor is in or outside of the canvas.""" + # TODO: We should disallow adding new points when the mouse is outside + # of the canvas. + x = int(self._editor_canvas.canvasx(point.x)) + y = int(self._editor_canvas.canvasy(point.y)) + inside_canvas = x >= 0 and y >= 0 and x <= self._frame_size.w and y <= self._frame_size.h bounded = bound_point(point=Point(x, y), size=self._frame_size) - self._curr_mouse_pos = Point(bounded.x * self._scale, bounded.y * self._scale) + return Point(bounded.x * self._scale, bounded.y * self._scale), inside_canvas + def _handle_mouse_input(self, event, point: Point): + # TODO: Map mouse events to callbacks rather than handling each event conditionally. + # TODO: Store `inside_canvas` so we can avoid highlighting/calculating when not necessary. + self._curr_mouse_pos, inside_canvas = self._to_canvas_coordinates(point) if event == cv2.EVENT_LBUTTONDOWN: + if self._pan_enabled: + self._editor_canvas.scan_mark(point.x, point.y) + self._panning = True + # We can just return without redrawing, we only have to draw once the canvas moves. + return + if not inside_canvas: + return + if not self._regions: logger.info( f"No regions to edit, add a new one by pressing {KEYBIND_REGION_ADD.upper()}." @@ -824,16 +1141,18 @@ def _handle_mouse_input(self, event, x, y, flags, param): if self._hover_point is not None: self._dragging = True self._drag_start = self._curr_mouse_pos - self._redraw = True - drag_started = True else: - drag_started = self._add_point() + self._add_point() elif event == cv2.EVENT_MOUSEMOVE: if self._dragging: self.active_region[self._hover_point] = self._curr_mouse_pos self._redraw = True - else: + elif self._panning: + self._editor_canvas.scan_dragto(point.x, point.y, gain=1) + self._redraw = True + elif not self._pan_enabled: + # Need to recalculate to see what points are closest to current mouse pos. self._recalculate = True elif event == cv2.EVENT_LBUTTONUP: @@ -849,14 +1168,48 @@ def _handle_mouse_input(self, event, x, y, flags, param): self._commit() self._redraw = True self._dragging = False + self._panning = False - elif event == cv2.EVENT_MBUTTONDOWN or IS_WINDOWS and event == cv2.EVENT_RBUTTONDOWN: + elif event == cv2.EVENT_RBUTTONDOWN: self._delete_point() - # Only draw again if we aren't dragging (too many events to draw on each one), or if - # we just started dragging a point (so it changes colour quicker). - if not self._dragging or drag_started: - self._draw() + self._draw() + + def _bind_mouse(self): + def on_mouse_move(e: tk.Event): + self._handle_mouse_input(cv2.EVENT_MOUSEMOVE, Point(e.x, e.y)) + + def on_left_mouse_down(e: tk.Event): + self._handle_mouse_input(cv2.EVENT_LBUTTONDOWN, Point(e.x, e.y)) + + def on_left_mouse_up(e: tk.Event): + self._handle_mouse_input(cv2.EVENT_LBUTTONUP, Point(e.x, e.y)) + + def on_right_mouse_down(e: tk.Event): + self._handle_mouse_input(cv2.EVENT_RBUTTONDOWN, Point(e.x, e.y)) + + def on_middle_mouse_down(e: tk.Event): + self._handle_mouse_input(cv2.EVENT_MBUTTONDOWN, Point(e.x, e.y)) + + def on_zoom(e: tk.Event): + increment = -1 if (e.num == 5 or e.delta > 0) else 1 + self._adjust_downscale(increment, allow_resize=False) + + # TODO: Allow configuring mouse buttons. + self._editor_canvas.bind("", on_left_mouse_down) + self._editor_canvas.bind("", on_left_mouse_up) + self._editor_canvas.bind("", on_middle_mouse_down) + self._editor_canvas.bind("", on_right_mouse_down) + self._editor_canvas.bind("", on_mouse_move) + self._editor_canvas.bind("", on_zoom) + + def _on_pan(self): + shift_x, shift_y = ( + (self._curr_mouse_pos.x - self._drag_start.x) / float(self._frame.shape[1]), + (self._curr_mouse_pos.y - self._drag_start.y) / float(self._frame.shape[0]), + ) + self._editor_canvas.xview_moveto(shift_x / self._scale) + self._editor_canvas.yview_moveto(shift_y / self._scale) def _add_region(self): if self._dragging: @@ -877,25 +1230,25 @@ def _add_region(self): ] self._regions.append([bound_point(point, self._source_size) for point in points]) - self._commit() self._active_shape = len(self._regions) - 1 + self._commit() self._recalculate = True self._redraw = True + self._draw() def _delete_region(self): if self._dragging: return if self._regions: del self._regions[self._active_shape] + self._active_shape = max(0, self._active_shape - 1) self._commit() - if not self._regions: - self._active_shape = None - else: - self._active_shape = (self._active_shape - 1) % len(self._regions) self._recalculate = True self._redraw = True + self._draw() def _select_region(self, index: int): + logger.debug(f"selecting region {index}") if self._dragging: return assert index >= 0 @@ -903,6 +1256,7 @@ def _select_region(self, index: int): self._active_shape = index self._recalculate = True self._redraw = True + self._draw() def _next_region(self): if self._dragging: @@ -911,6 +1265,7 @@ def _next_region(self): self._active_shape = (self._active_shape + 1) % len(self._regions) self._recalculate = True self._redraw = True + self._draw() def _prev_region(self): if self._dragging: @@ -919,3 +1274,4 @@ def _prev_region(self): self._active_shape = (self._active_shape - 1) % len(self._regions) self._recalculate = True self._redraw = True + self._draw() diff --git a/dvr_scan/scanner.py b/dvr_scan/scanner.py index 27e2c47..8f03483 100644 --- a/dvr_scan/scanner.py +++ b/dvr_scan/scanner.py @@ -32,7 +32,7 @@ from dvr_scan.detector import MotionDetector from dvr_scan.overlays import BoundingBoxOverlay, TextOverlay -from dvr_scan.platform import get_filename, get_min_screen_bounds, is_ffmpeg_available +from dvr_scan.platform import HAS_TKINTER, get_filename, get_min_screen_bounds, is_ffmpeg_available from dvr_scan.region import Point, RegionEditor, Size, bound_point, load_regions from dvr_scan.subtractor import SubtractorCNT, SubtractorCudaMOG2, SubtractorMOG2 from dvr_scan.video_joiner import VideoJoiner @@ -518,6 +518,10 @@ def _handle_regions(self) -> bool: for shape in self._regions ] if self._region_editor: + if not HAS_TKINTER: + logger.error("Error: Region editor requires Tcl/Tk support to run.") + raise SystemExit(1) + logger.info("Selecting area of interest:") # TODO(v1.7): Ensure ROI window respects start time if set. # TODO(v1.7): We should process this frame (right now it gets skipped).