diff --git a/dvr-scan.cfg b/dvr-scan.cfg index 3422fb2..cf6789f 100644 --- a/dvr-scan.cfg +++ b/dvr-scan.cfg @@ -88,11 +88,6 @@ # from 3, 0 to disable, or -1 to auto-set using video resolution. #kernel-size = -1 -# Region of interest of the form (x, y) / (w, h), where x, y is the top left -# corner, and w, h is the width/height in pixels. Brackets, commas, and slahes -# are optional. -# region-of-interest = 100, 110 / 50, 50 - # Integer factor to shrink video before processing. Values <= 1 have no effect. #downscale-factor = 0 diff --git a/dvr_scan/cli/__init__.py b/dvr_scan/cli/__init__.py index bc39517..8aa31b6 100644 --- a/dvr_scan/cli/__init__.py +++ b/dvr_scan/cli/__init__.py @@ -20,16 +20,18 @@ """ import argparse +import os from typing import List, Optional import dvr_scan -from dvr_scan.cli.config import ConfigRegistry, CHOICE_MAP, USER_CONFIG_FILE_PATH, ROIValue +from dvr_scan.cli.config import ConfigRegistry, CHOICE_MAP, USER_CONFIG_FILE_PATH +from dvr_scan.selection_window import Point, RegionValue # Version string shown for the -v/--version CLI argument. VERSION_STRING = f"""------------------------------------------------ DVR-Scan {dvr_scan.__version__} ------------------------------------------------ -Copyright (C) 2016-2022 Brandon Castellano +Copyright (C) 2016-2023 Brandon Castellano < https://github.com/Breakthrough/DVR-Scan > """ @@ -272,6 +274,8 @@ def __call__(self, parser, namespace, values, option_string=None): class RoiAction(argparse.Action): + DEFAULT_ERROR_MESSAGE = "Region must be 3 or more points of the form X0 Y0 X1 Y1 X2 Y2 ..." + def __init__(self, option_strings, dest, @@ -296,34 +300,50 @@ def __init__(self, required=required, help=help, metavar=metavar) + self.file_list = [] def _is_deprecated(self) -> bool: - return '-roi' in self.option_strings + return '-roi' in self.option_strings or '--region-of-interest' in self.option_strings def __call__(self, parser, namespace, values: List[str], option_string=None): - # Used to show warning to user if they use -roi instead of --roi. + # Used to show warning to user if they use -roi/--region-of-interest instead of -r/--roi. if self._is_deprecated(): setattr(namespace, 'used_deprecated_roi_option', True) - # --roi/--region-of-interest specified without coordinates + # -r/--roi specified without coordinates if not values: if not hasattr(namespace, 'show_roi_window'): setattr(namespace, 'show_roi_window', 1) else: namespace.show_roi_window += 1 return - # TODO(v1.6): Figure out how to represent multiple ROIs in config file, - # e.g.: region-of-interest = [10, 10 / 20, 20], [..] - # TODO(v1.6): Add backwards compatibility for --roi MAX_WIDTH MAX_HEIGHT syntax *only* for - # the deprecated -roi flag, not the new one. - try: - roi = ROIValue(' '.join(values)) - except ValueError as ex: - raise argparse.ArgumentError(self, - 'ROI must be rectangle of the form --roi X Y W H') from ex + regions = [] + save_regions_to = None + if len(values) == 1: + try: + if os.path.exists(values[0]): + region_path = values[0] + with open(region_path, "rt") as region_file: + regions = list( + RegionValue(region) for region in filter(None, ( + region.strip() for region in region_file.readlines()))) + else: + save_regions_to = values[0] + except ValueError as ex: + prefix = f"Error loading region from {region_path}: " + message = " ".join( + str(arg) for arg in ex.args) if ex.args else RoiAction.DEFAULT_ERROR_MESSAGE + raise (argparse.ArgumentError(self, f"{prefix}{message}")) from ex + else: + try: + regions.append(RegionValue(" ".join(values))) + except ValueError as ex: + message = " ".join(str(arg) for arg in ex.args) + raise (argparse.ArgumentError( + self, message if message else RoiAction.DEFAULT_ERROR_MESSAGE)) from ex # Append this ROI to any existing ones, if any. items = getattr(namespace, 'region_of_interest', []) - items += [roi.value] + items += regions setattr(namespace, 'region_of_interest', items) @@ -514,21 +534,30 @@ def get_cli_parser(user_config: ConfigRegistry): help=('Timecode to stop processing the input (see -st for valid timecode formats).'), ) + # Too much logic crammed in one flag, split it up: + # + # -r/--region [X0 Y0 X1 Y1 X2 Y2 ...] + # -R/--load-region [FILE] + # -s/--save-region [FILE] + # parser.add_argument( - '--roi', - '--region-of-interest', - metavar='x0 y0 w h', + '-r', + '--region', + metavar='x0 y0 x1 y1 x2 y2', dest='region_of_interest', nargs='*', action=RoiAction, - help=('Limit detection to specified region. Can specify as --roi to show popup window,' - ' or specify the region in the form --roi x,y w,h (e.g. --roi 100 200 50 50)%s' % + help=('TODO. %s' % (user_config.get_help_string('region-of-interest', show_default=False))), ) - # TODO(v1.7): Remove -roi (replaced by --roi/--region-of-interest). + # TODO(v1.7): Remove -roi (split up into other options). parser.add_argument( + # TODO(v1.6): Re-add support for -roi MAX_WIDTH MAX_HEIGHT syntax until this is removed, + # replaced with config file options. + # TODO(v1.6): Restore this argument to the exact way it was except for the pop-up window. '-roi', + '-region-of-interest', metavar='x0 y0 w h', dest='region_of_interest', nargs='*', diff --git a/dvr_scan/cli/config.py b/dvr_scan/cli/config.py index b096855..792411d 100644 --- a/dvr_scan/cli/config.py +++ b/dvr_scan/cli/config.py @@ -33,6 +33,8 @@ from dvr_scan.detector import Rectangle # Backwards compatibility for config options that were renamed/replaced. +# TODO(v1.6): Ensure 'region-of-interest' is deprecated and a warning to use -r/--roi with a file +# will be required in a subsequent release. DEPRECATED_CONFIG_OPTIONS: Dict[str, str] = { 'timecode': 'time-code', 'timecode-margin': 'text-margin', @@ -183,39 +185,43 @@ def from_config(config_value: str, default: 'KernelSizeValue') -> 'KernelSizeVal 'Size must be odd number starting from 3, 0 to disable, or -1 for auto.') from ex -class ROIValue(ValidatedValue): - """Validator for a set of regions of interest.""" +class RegionValueDeprecated(ValidatedValue): + """Validator for deprecated region-of-interest values.""" _IGNORE_CHARS = [',', '/', '(', ')'] """Characters to ignore.""" - def __init__(self, value: Optional[str] = None): - if value is None: - self._value = Rectangle(0, 0, 0, 0) - else: - translation_table = str.maketrans({char: ' ' for char in ROIValue._IGNORE_CHARS}) + def __init__(self, value: Optional[str] = None, allow_size: bool = False): + if value is not None: + translation_table = str.maketrans({char: ' ' for char in RegionValue._IGNORE_CHARS}) values = value.translate(translation_table).split() - if not len(values) == 4 and all([val.isdigit() for val in values]): + valid_lengths = (2, 4) if allow_size else (4,) + if not (len(values) in valid_lengths and all([val.isdigit() for val in values]) + and all([int(val) >= 0 for val in values])): raise ValueError() - self._value = Rectangle(*[int(val) for val in values]) + self._value = [int(val) for val in values] + else: + self._value = None @property - def value(self) -> Rectangle: + def value(self) -> Optional[List[int]]: return self._value def __repr__(self) -> str: return str(self.value) def __str__(self) -> str: + if self.value is not None: + return '(%d,%d)/(%d,%d)' % (self.value[0], self.value[1], self.value[2], self.value[3]) return str(self.value) @staticmethod - def from_config(config_value: str, default: 'ROIValue') -> 'ROIValue': + def from_config(config_value: str, default: 'RegionValue') -> 'RegionValue': try: - return ROIValue(config_value) + return RegionValue(config_value) except ValueError as ex: - raise OptionParseFailure( - 'ROI must be four positive integers of the form X, Y, W, H.') from ex + raise OptionParseFailure('ROI must be four positive integers of the form (x,y)/(w,h).' + ' Brackets, commas, slashes, and spaces are optional.') from ex class RGBValue(ValidatedValue): @@ -311,7 +317,8 @@ def from_config(config_value: str, default: 'RGBValue') -> 'RGBValue': 'threshold': 0.15, 'kernel-size': KernelSizeValue(), 'downscale-factor': 0, - 'region-of-interest': ROIValue(), + # TODO(v1.7): Remove, replaced with region files. + 'region-of-interest': RegionValueDeprecated(), 'max-window-width': 0, 'max-window-height': 0, 'frame-skip': 0, diff --git a/dvr_scan/cli/controller.py b/dvr_scan/cli/controller.py index d98aacd..55488b0 100644 --- a/dvr_scan/cli/controller.py +++ b/dvr_scan/cli/controller.py @@ -188,6 +188,7 @@ def run_dvr_scan(settings: ProgramSettings) -> ty.List[ty.Tuple[FrameTimecode, F input_videos=settings.get_arg('input'), frame_skip=settings.get('frame-skip'), show_progress=not settings.get('quiet-mode'), + debug_mode=settings.get('debug'), ) output_mode = ( diff --git a/dvr_scan/scanner.py b/dvr_scan/scanner.py index 0d0ad85..166e761 100644 --- a/dvr_scan/scanner.py +++ b/dvr_scan/scanner.py @@ -222,7 +222,8 @@ class MotionScanner: def __init__(self, input_videos: List[AnyStr], frame_skip: int = 0, - show_progress: bool = False): + show_progress: bool = False, + debug_mode: bool = False): """ Initializes the MotionScanner with the supplied arguments. Arguments: @@ -236,6 +237,7 @@ def __init__(self, self._in_motion_event: bool = False self._show_progress: bool = show_progress + self._debug_mode: bool = debug_mode # Output Parameters (set_output) self._comp_file: Optional[AnyStr] = None # -o/--output @@ -411,12 +413,7 @@ def set_detection_params(self, self._downscale_factor = max(downscale_factor, 1) assert kernel_size == -1 or kernel_size == 0 or kernel_size >= 3 self._kernel_size = kernel_size - if roi_list is not None: - if isinstance(roi_list, Rectangle): - self._roi_list = [roi_list] - else: - assert all(isinstance(roi, Rectangle) for roi in roi_list) - self._roi_list = list(roi_list) + # TODO(v1.6): Redo ROI list. self._show_roi_window = show_roi_window self._max_window_size = tuple(None if x is None else max(x, 0) for x in max_window_size) @@ -514,7 +511,10 @@ def _select_roi(self) -> bool: factor_h = frame_h / float(max_h) if max_h > 0 and frame_h > max_h else 1 factor_w = frame_w / float(max_w) if max_w > 0 and frame_w > max_w else 1 scale_factor = round(max(factor_h, factor_w)) - roi_list = SelectionWindow(frame_for_crop, scale_factor).run() + # TODO(v1.6): Allow previewing a pre-defined set of polygons if they were loaded from + # command line or config file. + roi_list = SelectionWindow( + frame_for_crop, scale_factor, debug_mode=self._debug_mode).run() logging.info(str(roi_list)) # TODO: Actually use ROI list now. sys.exit(0) diff --git a/dvr_scan/selection_window.py b/dvr_scan/selection_window.py index 8d68cd2..04c1c42 100644 --- a/dvr_scan/selection_window.py +++ b/dvr_scan/selection_window.py @@ -11,46 +11,150 @@ # from collections import namedtuple +from contextlib import contextmanager +from dataclasses import dataclass from logging import getLogger from copy import deepcopy import math +import tkinter +import tkinter.filedialog +import tkinter.messagebox + import typing as ty +import os import cv2 import numpy as np from dvr_scan.detector import Rectangle +_IS_WINDOWS = (os.name == 'nt') + WINDOW_NAME = "DVR-Scan: Select ROI" """Title given to the ROI selection window.""" +DEFAULT_WINDOW_MODE = (cv2.WINDOW_AUTOSIZE if _IS_WINDOWS else cv2.WINDOW_KEEPRATIO) + MIN_SIZE = 16 """Minimum height/width for a ROI created using the mouse.""" logger = getLogger('dvr_scan') Point = namedtuple("Point", ['x', 'y']) + Size = namedtuple("Size", ['w', 'h']) + InputRectangle = ty.Tuple[Point, Point] -MIN_NUM_POINTS = 3 -"""Minimum number of points that can be in a polygon.""" -MIN_POINT_SPACING = 16 -"""Minimum spacing between points in pixels.""" +class RegionValue: + """Validator for a set of points representing a closed polygon.""" -POINT_HANDLE_RADIUS = 8 -"""Radius of the point control handle.""" + _IGNORE_CHARS = [',', '/', '(', ')', '[', ']'] + """Characters to ignore.""" -POINT_CONTROL_RADIUS = 16 -"""Radius of action for hovering/interacting with a point handle.""" + def __init__(self, value: str): + translation_table = str.maketrans({char: ' ' for char in RegionValue._IGNORE_CHARS}) + values = value.translate(translation_table).split() + if not all([val.isdigit() for val in values]): + raise ValueError("Regions can only contain numbers and the following characters:" + f" , / ( )\n Input: {value}") + if not len(values) % 2 == 0: + raise ValueError(f"Could not parse region, missing X or Y component.\n Input: {value}") + if not len(values) >= 6: + raise ValueError(f"Regions must have at least 3 points.\n Input: {value}") -MAX_HISTORY_SIZE = 1024 + self._value = [Point(int(x), int(y)) for x, y in zip(values[0::2], values[1::2])] -MAX_DOWNSCALE_FACTOR = 1024 + @property + def value(self) -> ty.List[Point]: + return self._value + def __repr__(self) -> str: + return repr(self.value) + + def __str__(self) -> str: + return ", ".join(f'P({x},{y})' for x, y in self._value) + + +# TODO(v1.6): Allow controlling some of these settings in the config file. +@dataclass +class SelectionWindowSettings: + 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(v1.6): Move more of these to SelectionWindowSettings. +MIN_NUM_POINTS = 3 +MIN_POINT_SPACING = 16 +MAX_HISTORY_SIZE = 1024 +MAX_DOWNSCALE_FACTOR = 100 MAX_UPDATE_RATE_NORMAL = 20 MAX_UPDATE_RATE_DRAGGING = 5 +HOVER_DISPLAY_DISTANCE = 200000 +MAX_DOWNSCALE_AA_LEVEL = 4 + +KEYBIND_POINT_DELETE = 'x' +KEYBIND_POINT_ADD = 'q' +KEYBIND_UNDO = 'z' +KEYBIND_REDO = 'y' +KEYBIND_MASK = 'm' +KEYBIND_TOGGLE_AA = 'a' +KEYBIND_WINDOW_MODE = 'r' +KEYBIND_DOWNSCALE_INC = 'w' +KEYBIND_DOWNSCALE_DEC = 'e' +KEYBIND_OUTPUT_LIST = 'c' +KEYBIND_HELP = 'h' +KEYBIND_BREAKPOINT = 'b' +KEYBIND_LOAD = 'l' +KEYBIND_SAVE = 's' + + +@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: + # TODO(v1.6): Figure out how to get the correct path and gracefully handle case when + # icon cannot be found. + root.withdraw() + root.iconbitmap(os.path.abspath('dist/dvr-scan.ico')) + yield root + finally: + root.destroy() + + +def show_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: + +Add Point Key: {KEYBIND_POINT_ADD} Mouse: Left +Delete Point Key: {KEYBIND_POINT_DELETE} Mouse: {_WINDOWS_ONLY}Middle +Print Points Key: {KEYBIND_OUTPUT_LIST} +Start Scan Key: Space, Enter +Quit Key: Escape + +Save Key: {KEYBIND_SAVE} +Load Key: {KEYBIND_LOAD} +Undo Key: {KEYBIND_UNDO} +Redo Key: {KEYBIND_REDO} + +Toggle Mask Key: {KEYBIND_MASK} +Window Mode Key: {KEYBIND_WINDOW_MODE} +Antialiasing Key: {KEYBIND_TOGGLE_AA} +Downscale Set Key: 1 - 9 +Downscale +/- Key: {KEYBIND_DOWNSCALE_INC}(+), {KEYBIND_DOWNSCALE_DEC} (-) +""") def initial_point_list(frame_size: Size) -> ty.List[Point]: @@ -73,11 +177,37 @@ def bound_point(point: Point, size: Size): return Point(min(max(0, point.x), size.w), min(max(0, point.y), size.h)) +def control_handle_radius(scale: int): + if scale == 1: + return 16 + elif scale == 2: + return 8 + elif scale == 3: + return 5 + elif scale <= 4: + return 4 + elif scale <= 7: + return 3 + elif scale <= 30: + return 2 + return 1 + + +def edge_thickness(scale: int): + if scale < 2: + return 4 + elif scale < 5: + return 3 + elif scale < 7: + return 2 + return 1 + + # TODO(v1.7): Allow multiple polygons by adding new ones using keyboard. # TODO(v1.7): Allow shifting polygons by using middle mouse button. class SelectionWindow: - def __init__(self, frame: np.ndarray, initial_scale: ty.Optional[int]): + def __init__(self, frame: np.ndarray, initial_scale: ty.Optional[int], debug_mode: bool): self._source_frame = frame.copy() # Frame before downscaling self._source_size = Size(w=frame.shape[1], h=frame.shape[0]) self._scale: int = 1 if initial_scale is None else initial_scale @@ -87,7 +217,6 @@ def __init__(self, frame: np.ndarray, initial_scale: ty.Optional[int]): self._point_list = initial_point_list(self._frame_size) self._history = [] self._history_pos = 0 - self._commit() self._curr_mouse_pos = None self._redraw = True self._recalculate = True @@ -95,27 +224,23 @@ def __init__(self, frame: np.ndarray, initial_scale: ty.Optional[int]): self._nearest_points = None self._dragging = False self._drag_start = None - self._use_aa = True - - self._line_color = (255, 0, 0) - self._hover_color = (0, 127, 255) - self._hover_color_alt = (0, 0, 255) - self._interact_color = (0, 255, 255) - - # TODO: press 's' to "save" the current ROI by printing it to terminal (or saving to file?) - - self._segment_distances = [] # Squared distance of segment from point i to i+1 - self._mouse_distances = [] # Squared distance of mouse to point i - + self._debug_mode = debug_mode + self._saved = True + 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._settings = SelectionWindowSettings() + self._commit() + self._saved = True def _rescale(self): + assert self._scale > 0 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 - logger.debug("Resizing: scale = 1/%d%s, resolution = %d x %d", self._scale, + logger.debug("Resize: scale = 1/%d%s, res = %d x %d", self._scale, ' (off)' if self._scale == 1 else '', self._frame_size.w, self._frame_size.h) def _undo(self): @@ -141,31 +266,39 @@ def _commit(self): self._history_pos = 0 self._recalculate = True self._redraw = True - logger.debug("Commit: [%d] = %s", - len(self._history) - 1, - ', '.join(f'P({x},{y})' for x, y in [point for point in self._point_list])) + self._saved = False def _emit_points(self): logger.info("ROI:\n--roi %s", " ".join("%d %d" % (point.x, point.y) for point in self._point_list)) def _draw(self): - # TODO: Can cache a lot of the calculations below. Need to keep drawing as fast as possible. if self._recalculate: self._recalculate_data() if not self._redraw: return - line_type = cv2.LINE_AA if self._use_aa else cv2.LINE_4 - self._frame = self._original_frame.copy() + + frame = self._original_frame.copy() points = np.array([self._point_list], np.int32) + thickness = edge_thickness(self._scale) if self._scale > 1: points = points // self._scale - self._frame = cv2.polylines( - self._frame, + + if self._settings.mask_source: + mask = cv2.fillPoly( + np.zeros_like(frame, dtype=np.uint8), + points, + color=(255, 255, 255), + lineType=cv2.LINE_4) + frame = np.bitwise_and(frame, mask).astype(np.uint8) + + line_type = cv2.LINE_AA if self._settings.use_aa and self._scale <= MAX_DOWNSCALE_AA_LEVEL else cv2.LINE_4 + frame = cv2.polylines( + frame, points, isClosed=True, - color=self._line_color, - thickness=2, + color=self._settings.line_color, + thickness=thickness, lineType=line_type) if not self._hover_point is None: first, mid, last = ((self._hover_point - 1) % len(self._point_list), self._hover_point, @@ -175,48 +308,49 @@ def _draw(self): np.int32) if self._scale > 1: points = points // self._scale - self._frame = cv2.polylines( - self._frame, + frame = cv2.polylines( + frame, points, isClosed=False, - color=self._hover_color if not self._dragging else self._hover_color_alt, - thickness=3, + color=self._settings.hover_color + if not self._dragging else self._settings.hover_color_alt, + thickness=thickness + 1, lineType=line_type) - elif not self._nearest_points is None: + elif not self._nearest_points is None and self._settings.highlight_insert: + points = np.array([[ self._point_list[self._nearest_points[0]], self._point_list[self._nearest_points[1]] ]], np.int32) if self._scale > 1: points = points // self._scale - self._frame = cv2.polylines( - self._frame, + frame = cv2.polylines( + frame, points, isClosed=False, - color=self._hover_color, - thickness=3, + color=self._settings.hover_color, + thickness=thickness + 1, lineType=line_type) + radius = control_handle_radius(self._scale) for i, point in enumerate(self._point_list): - color = self._line_color - + color = self._settings.line_color_alt if not self._hover_point is None: if self._hover_point == i: - color = self._hover_color_alt if not self._dragging else self._interact_color + color = self._settings.hover_color_alt if not self._dragging else self._settings.interact_color elif not self._nearest_points is None and i in self._nearest_points: - color = self._hover_color if self._dragging else self._interact_color + color = self._settings.hover_color if self._dragging else self._settings.interact_color start, end = ( - Point((point.x // self._scale) - POINT_HANDLE_RADIUS, - (point.y // self._scale) - POINT_HANDLE_RADIUS), - Point((point.x // self._scale) + POINT_HANDLE_RADIUS, - (point.y // self._scale) + POINT_HANDLE_RADIUS), + Point((point.x // self._scale) - radius, (point.y // self._scale) - radius), + Point((point.x // self._scale) + radius, (point.y // self._scale) + radius), ) cv2.rectangle( - self._frame, + frame, start, end, color, thickness=cv2.FILLED, ) + self._frame = frame cv2.imshow(WINDOW_NAME, self._frame) self._redraw = False @@ -226,9 +360,9 @@ def _find_nearest(self) -> ty.Tuple[int, int]: # Create a triangle where side a's length is the mouse to closest point on the line, # side c is the length to the furthest point, and side b is the line segment length. next = (i + 1) % len(self._point_list) - a_sq = min(self._mouse_distances[i], self._mouse_distances[next]) - c_sq = max(self._mouse_distances[i], self._mouse_distances[next]) - b_sq = self._segment_distances[i] + a_sq = min(self._mouse_dist[i], self._mouse_dist[next]) + c_sq = max(self._mouse_dist[i], self._mouse_dist[next]) + b_sq = self._segment_dist[i] # Calculate "angle" C (angle between line segment and closest line to mouse) a = math.sqrt(a_sq) b = math.sqrt(b_sq) @@ -238,52 +372,180 @@ def _find_nearest(self) -> ty.Tuple[int, int]: dist = int(a_sq - (int(a * cos_C)**2)) if cos_C > 0 else a_sq if dist < nearest_dist or (dist == nearest_dist and cos_C > largest_cosine): nearest_seg, nearest_dist, largest_cosine = i, dist, cos_C - return (nearest_seg, (nearest_seg + 1) % len(self._point_list)) + last = self._settings.highlight_insert + self._settings.highlight_insert = nearest_dist <= HOVER_DISPLAY_DISTANCE + if last != self._settings.highlight_insert: + self._redraw = True + self._nearest_points = (nearest_seg, (nearest_seg + 1) % len(self._point_list)) def _hovering_over(self) -> ty.Optional[int]: min_i = 0 - for i in range(1, len(self._mouse_distances)): - if self._mouse_distances[i] < self._mouse_distances[min_i]: + for i in range(1, len(self._mouse_dist)): + if self._mouse_dist[i] < self._mouse_dist[min_i]: min_i = i # If we've shrunk the image, we need to compensate for the size difference in the image. # The control handles remain the same size but the image is smaller - return min_i if self._mouse_distances[min_i] <= (POINT_CONTROL_RADIUS * - self._scale)**2 else None + return min_i if self._mouse_dist[min_i] <= (4 * control_handle_radius(self._scale) * + self._scale)**2 else None - def run(self): - logger.debug('Creating window for frame (scale = %d)', self._scale) - cv2.namedWindow(WINDOW_NAME, cv2.WINDOW_GUI_NORMAL) - cv2.resizeWindow(WINDOW_NAME, width=self._frame_size.w, height=self._frame_size.h) + def _init_window(self): + cv2.namedWindow(WINDOW_NAME, self._settings.window_mode) + if self._settings.window_mode == cv2.WINDOW_AUTOSIZE: + cv2.resizeWindow(WINDOW_NAME, width=self._frame_size.w, height=self._frame_size.h) cv2.imshow(WINDOW_NAME, mat=self._frame) cv2.setMouseCallback(WINDOW_NAME, on_mouse=self._handle_mouse_input) + + def run(self): + logger.debug("Creating window for frame (scale = %d)", self._scale) + self._init_window() while True: self._draw() key = cv2.waitKey( MAX_UPDATE_RATE_NORMAL if not self._dragging else MAX_UPDATE_RATE_DRAGGING) & 0xFF if key in (ord(' '), 27): break - elif key == ord('b'): + elif key == ord(KEYBIND_BREAKPOINT) and self._debug_mode: breakpoint() - self._find_nearest() - elif key == ord('a'): - self._use_aa = not self._use_aa + elif key == ord(KEYBIND_TOGGLE_AA): + self._settings.use_aa = not self._settings.use_aa self._redraw = True - logger.debug("Antialiasing: %s", 'ON' if self._use_aa else 'OFF') - elif key == ord('w'): + 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.") + elif key == ord(KEYBIND_DOWNSCALE_INC): if self._scale < MAX_DOWNSCALE_FACTOR: self._scale += 1 self._rescale() - elif key == ord('s'): + elif key == ord(KEYBIND_DOWNSCALE_DEC): if self._scale > 1: self._scale = max(1, self._scale - 1) self._rescale() - elif key == ord('z'): + elif key == ord(KEYBIND_UNDO): self._undo() - elif key == ord('y'): + elif key == ord(KEYBIND_REDO): self._redo() - elif key == ord('c'): + elif key == ord(KEYBIND_OUTPUT_LIST): self._emit_points() + elif key == ord(KEYBIND_MASK): + self._toggle_mask() + elif key == ord(KEYBIND_WINDOW_MODE): + cv2.destroyWindow(WINDOW_NAME) + 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() + elif key == ord(KEYBIND_POINT_DELETE): + self._delete_point() + elif key == ord(KEYBIND_POINT_ADD): + self._add_point() + elif key == ord(KEYBIND_HELP): + show_controls() + elif key == ord('l'): + self._load() + elif key == ord('s'): + self._save() + elif key >= ord('1') and key <= ord('9'): + scale = 1 + key - ord('1') + if scale != self._scale: + self._scale = scale + self._rescale() + + def _save(self): + save_path = None + with temp_tk_window() as _: + save_path = tkinter.filedialog.asksaveasfilename( + title="DVR-Scan: Save Region", + filetypes=[("Region File", "*.txt")], + defaultextension=".txt", + confirmoverwrite=True, + ) + if save_path: + with open(save_path, "wt") as region_file: + regions = " ".join(f"{x} {y}" for x, y in self._point_list) + region_file.write(f"{regions}\n") + logger.info('Saved region to: %s', save_path) + + def _load(self): + load_path = None + with temp_tk_window() as _: + if not self._saved: + result = tkinter.messagebox.askyesno( + title="Unsaved Changes", + message="Warning: unsaved changes will be discarded. Do you want to continue?", + ) + if not result: + return + load_path = tkinter.filedialog.askopenfilename( + title="DVR-Scan: Load Region", + filetypes=[("Region File", "*.txt")], + defaultextension=".txt", + ) + if not load_path: + return + if not os.path.exists(load_path): + logger.error(f"File does not exist: {load_path}") + return + region_data = None + with open(load_path, 'rt') as region_file: + region_data = region_file.readlines() + regions = None + if region_data: + try: + regions = list( + RegionValue(region) + for region in filter(None, (region.strip() for region in region_data))) + except ValueError as ex: + reason = " ".join(str(arg) for arg in ex.args) + if not reason: + reason = "Could not parse region file!" + logger.error(f"Error loading region from {load_path}: {reason}") + if regions: + logger.debug("Loaded %d polygon%s from region file:\n%s", len(region_data), + 's' if len(region_data) > 1 else '', + "\n".join(f"[{i}] = {points}" for i, points in enumerate(regions))) + if len(regions) > 1: + logger.error("Error: GUI does not support multiple regions.") + return + self._point_list = [bound_point(point, self._source_size) for point in regions[0].value] + self._saved = True + self._recalculate = True + + def _delete_point(self): + if not self._hover_point is None: + if len(self._point_list) > MIN_NUM_POINTS: + hover = self._hover_point + x, y = self._point_list[hover] + del self._point_list[hover] + self._hover_point = None + logger.debug("Del: [%d] = %s", hover, f'P({x},{y})') + self._commit() + else: + logger.error("Cannot remove point, shape must have at least 3 points.") + self._dragging = False + + 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 _add_point(self) -> bool: + if not self._nearest_points is None: + insert_pos = (1 + self._nearest_points[0] if self._nearest_points[0] + < self._nearest_points[1] else self._nearest_points[1]) + insert_pos = insert_pos % len(self._point_list) + self._point_list.insert(insert_pos, self._curr_mouse_pos) + self._nearest_points = None + self._hover_point = insert_pos + self._dragging = True + self._drag_start = self._curr_mouse_pos + self._redraw = True + return True + return False def _recalculate_data(self): # TODO: Optimize further, only need to do a lot of work below if certain things changed @@ -292,8 +554,8 @@ def _recalculate_data(self): return last_hover = self._hover_point last_nearest = self._nearest_points - # Calculate distance from mouse cursor to each point. - self._mouse_distances = [ + # Calculate distance from mouse cursor to each point. + self._mouse_dist = [ squared_distance(self._curr_mouse_pos, point) for point in self._point_list ] # Check if we're hovering over a point. @@ -301,11 +563,11 @@ def _recalculate_data(self): # Optimization: Only recalculate segment distances if we aren't hovering over a point. if self._hover_point is None: num_points = len(self._point_list) - self._segment_distances = [ + self._segment_dist = [ squared_distance(self._point_list[i], self._point_list[(i + 1) % num_points]) for i in range(num_points) ] - self._nearest_points = self._find_nearest() + self._find_nearest() if last_hover != self._hover_point or last_nearest != self._nearest_points: self._redraw = True self._recalculate = False @@ -317,20 +579,11 @@ def _handle_mouse_input(self, event, x, y, flags, param): if event == cv2.EVENT_LBUTTONDOWN: if not self._hover_point is None: self._dragging = True - drag_started = True self._drag_start = self._curr_mouse_pos self._redraw = True - elif not self._nearest_points is None: - insert_pos = (1 + self._nearest_points[0] if self._nearest_points[0] - < self._nearest_points[1] else self._nearest_points[1]) - insert_pos = insert_pos % len(self._point_list) - self._point_list.insert(insert_pos, self._curr_mouse_pos) - self._nearest_points = None - self._hover_point = insert_pos - self._dragging = True # Wait for dragging to stop to commit to history. drag_started = True - self._drag_start = self._curr_mouse_pos - self._redraw = True + else: + drag_started = self._add_point() elif event == cv2.EVENT_MOUSEMOVE: if self._dragging: @@ -345,19 +598,16 @@ def _handle_mouse_input(self, event, x, y, flags, param): if (len(self._point_list) != len(self._history[self._history_pos]) or self._curr_mouse_pos != self._drag_start): self._point_list[self._hover_point] = self._curr_mouse_pos + x, y = self._point_list[self._hover_point] + logger.debug("Add: [%d] = %s", self._hover_point, f'P({x},{y})') self._commit() self._redraw = True self._dragging = False - elif event == cv2.EVENT_RBUTTONDOWN: - if not self._hover_point is None: - if len(self._point_list) > MIN_NUM_POINTS: - del self._point_list[self._hover_point] - self._hover_point = None - self._commit() - else: - logger.error("Cannot remove point, shape must have at least 3 points.") - self._dragging = False + elif event == cv2.EVENT_MBUTTONDOWN or _IS_WINDOWS and 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()