From e78b95fb439712b418ec863d4e9a9398ce62b980 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 9 Oct 2023 23:50:22 -0400 Subject: [PATCH] [gui] Finish region editor Issue: #39 Issue: #58 --- dvr_scan/cli/__init__.py | 6 +- dvr_scan/cli/controller.py | 2 +- dvr_scan/detector.py | 39 +- dvr_scan/platform.py | 4 + dvr_scan/{selection_window.py => region.py} | 438 +++++++++++++------- dvr_scan/scanner.py | 104 +++-- tests/test_cli.py | 7 +- tests/test_scan_context.py | 38 +- 8 files changed, 404 insertions(+), 234 deletions(-) rename dvr_scan/{selection_window.py => region.py} (60%) diff --git a/dvr_scan/cli/__init__.py b/dvr_scan/cli/__init__.py index c7b1279..e8b385b 100644 --- a/dvr_scan/cli/__init__.py +++ b/dvr_scan/cli/__init__.py @@ -25,7 +25,7 @@ import dvr_scan from dvr_scan.cli.config import ConfigRegistry, CHOICE_MAP, USER_CONFIG_FILE_PATH -from dvr_scan.selection_window import Point, RegionValue +from dvr_scan.region import Point, RegionValidator # Version string shown for the -v/--version CLI argument. VERSION_STRING = f"""------------------------------------------------ @@ -306,7 +306,7 @@ def __call__(self, parser, namespace, values: List[str], option_string=None): if not values: setattr(namespace, 'region_editor', True) return - # TODO(v1.6): Re-add backwards compat. for X Y W H rectangles. + # TODO(v1.6): Re-add backwards compatibility for X Y W H rectangles. raise NotImplementedError() # Append this ROI to any existing ones, if any. items = getattr(namespace, 'roi_deprecated', []) @@ -346,7 +346,7 @@ def __init__(self, def __call__(self, parser, namespace, values: List[str], option_string=None): try: - region = RegionValue(" ".join(values)) + region = RegionValidator(" ".join(values)) except ValueError as ex: message = " ".join(str(arg) for arg in ex.args) raise (argparse.ArgumentError( diff --git a/dvr_scan/cli/controller.py b/dvr_scan/cli/controller.py index 61beb59..82353ec 100644 --- a/dvr_scan/cli/controller.py +++ b/dvr_scan/cli/controller.py @@ -269,7 +269,7 @@ def run_dvr_scan(settings: ProgramSettings) -> ty.List[ty.Tuple[FrameTimecode, F duration=settings.get_arg('duration'), ) - # TODO(v1.6): Ensure ROI window respects start time if set. + # TODO(v1.7): Ensure ROI window respects start time if set. scanner.set_regions( region_editor=settings.get('region-editor'), regions=settings.get_arg('regions'), diff --git a/dvr_scan/detector.py b/dvr_scan/detector.py index a4631e3..0ed0ae7 100644 --- a/dvr_scan/detector.py +++ b/dvr_scan/detector.py @@ -24,7 +24,7 @@ import numpy as np from dvr_scan.subtractor import Subtractor -from dvr_scan.selection_window import Point +from dvr_scan.region import Point Rectangle = namedtuple("Rectangle", ['x', 'y', 'w', 'h']) @@ -53,41 +53,50 @@ def __init__(self, subtractor: Subtractor, frame_size: ty.Tuple[int, int], downs self._downscale = downscale self._regions = list(regions) if not regions is None else [] self._mask: np.ndarray = np.ones((0, 0)) - self._area: Rectangle = Rectangle(x=0, y=0, w=self._frame_size[0], h=self._frame_size[1]) + self._area: ty.Tuple[Point, Point] = (Point(0, 0), + Point(self._frame_size[0] - 1, + self._frame_size[1] - 1)) if self._regions: + # TODO: See if this can be done using a single color channel or in a bitmap mask = np.zeros((frame_size[1], frame_size[0], 3), dtype=np.uint8) for shape in self._regions: points = np.array([shape], np.int32) mask = cv2.fillPoly(mask, points, color=(255, 255, 255), lineType=cv2.LINE_4) - # True denotes ignored (masked) elements, and False represents unmasked elements (those - # inside any region). - self._mask = np.logical_not(mask.astype(bool)) + mask = mask[:, :, 0].astype(bool) + active_pixels = mask.sum() + # False marks unmasked elements (those inside the active region), so we invert the mask. + mask = np.logical_not(mask) # Calculate subset of frame to use to speed up calculations. min_x, min_y, max_x, max_y = self._frame_size[0], self._frame_size[1], 0, 0 for shape in self._regions: for point in shape: min_x, min_y = min(min_x, point.x), min(min_y, point.y) max_x, max_y = max(max_x, point.x), max(max_y, point.y) - self._area: Rectangle = Rectangle(x=min_x, y=min_y, w=max_x - min_x, h=max_y - min_y) - logger.debug("Cropping detection area: %s", str(self._area)) + self._area = (Point(min_x, min_y), Point(max_x, max_y)) + coverage = 100.0 * (active_pixels / float(frame_size[0] * frame_size[1])) + mask = mask[self._area[0].y:self._area[1].y, self._area[0].x:self._area[1].x] + logger.debug( + "Region Mask: area = (" + f"{self._area[0].x},{self._area[0].y}),({self._area[1].x},{self._area[1].y}" + f"), coverage = {coverage:.2f}%") + if self._downscale > 1: + mask = mask[::self._downscale, ::self._downscale] + logger.debug(f"Mask Downscaled: size = {mask.shape[0]}, {mask.shape[1]}") + self._mask = mask @property def area(self) -> Rectangle: """Area the region of interest covers in the original frame.""" return self._area - @property - def background_mask(self) -> np.ndarray: - raise NotImplementedError() - def _preprocess(self, frame: np.ndarray) -> np.ndarray: cropped = None if not self._regions: cropped = frame else: cropped = frame[ - self._area.y:self._area.y + self._area.h, - self._area.x:self._area.x + self._area.w, + self._area[0].y:self._area[1].y, + self._area[0].x:self._area[1].x, ] if self._downscale > 1: return cropped[::self._downscale, ::self._downscale, :] @@ -97,11 +106,9 @@ def _preprocess(self, frame: np.ndarray) -> np.ndarray: def update(self, frame: np.ndarray) -> ProcessedFrame: frame = self._preprocess(frame) subtracted = self._subtractor.apply(frame) - - if len(self._regions) <= 1: + if not self._regions: return ProcessedFrame( subtracted=subtracted, masked=subtracted, score=np.average(subtracted)) - motion_mask = np.ma.array(subtracted, mask=self._mask) return ProcessedFrame( subtracted=subtracted, diff --git a/dvr_scan/platform.py b/dvr_scan/platform.py index d462443..cb4e674 100644 --- a/dvr_scan/platform.py +++ b/dvr_scan/platform.py @@ -130,12 +130,15 @@ def get_filename(path: AnyStr, include_extension: bool) -> AnyStr: def set_icon(window_name: str, icon_path: str): + 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 @@ -146,6 +149,7 @@ def set_icon(window_name: str, icon_path: str): 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 diff --git a/dvr_scan/selection_window.py b/dvr_scan/region.py similarity index 60% rename from dvr_scan/selection_window.py rename to dvr_scan/region.py index 8acb012..e58bf94 100644 --- a/dvr_scan/selection_window.py +++ b/dvr_scan/region.py @@ -9,6 +9,7 @@ # DVR-Scan is licensed under the BSD 2-Clause License; see the included # LICENSE file, or visit one of the above pages for details. # +"""Handles detection region processing.""" from collections import namedtuple from contextlib import contextmanager @@ -28,11 +29,22 @@ import tkinter import tkinter.filedialog -WINDOW_NAME = "DVR-Scan: Select ROI" +_WINDOW_NAME = "DVR-Scan: Select ROI" """Title given to the ROI selection window.""" -# TODO(v1.6): Figure out how to properly get icon path in the package. -ICON_PATH = "dist/dvr-scan.ico" + +# TODO(v1.6): 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. +# TODO(v1.7): Figure out how to make icon work on Linux. Might need a PNG version. +def _get_icon_path() -> str: + for path in ("dvr-scan.ico", "dist/dvr-scan.ico"): + if os.path.exists(path): + return path + return '' + + +_ICON_PATH = _get_icon_path() DEFAULT_WINDOW_MODE = (cv2.WINDOW_AUTOSIZE if IS_WINDOWS else cv2.WINDOW_KEEPRATIO) """Minimum height/width for a ROI created using the mouse.""" @@ -46,22 +58,27 @@ InputRectangle = ty.Tuple[Point, Point] -def check_tkinter_support(): - # TODO(v1.6): Only show the warning if the user hasn't specified a file to save the ROI to. - if not HAS_TKINTER: +@dataclass +class Snapshot: + regions: ty.List[ty.List[Point]] + active_shape: ty.Optional[int] + + +def check_tkinter_support(warn_if_notkinter: bool): + if warn_if_notkinter and not HAS_TKINTER: logger.warning( "Warning: Tkinter is not installed. To save the region to disk, use " "-s/--save-region [FILE], or install python3-tk (e.g. `sudo apt install python3-tk`).") -class RegionValue: +class RegionValidator: """Validator for a set of points representing a closed polygon.""" _IGNORE_CHARS = [',', '/', '(', ')', '[', ']'] """Characters to ignore.""" def __init__(self, value: str): - translation_table = str.maketrans({char: ' ' for char in RegionValue._IGNORE_CHARS}) + translation_table = str.maketrans({char: ' ' for char in RegionValidator._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:" @@ -84,7 +101,7 @@ 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. +# TODO(v1.7): Allow controlling some of these settings in the config file. @dataclass class SelectionWindowSettings: use_aa: bool = True @@ -98,28 +115,30 @@ class SelectionWindowSettings: highlight_insert: bool = False -# TODO(v1.6): Move more of these to SelectionWindowSettings. +# TODO(v1.7): Move more of these to SelectionWindowSettings. MIN_NUM_POINTS = 3 MAX_HISTORY_SIZE = 1024 MAX_DOWNSCALE_FACTOR = 100 MAX_UPDATE_RATE_NORMAL = 20 MAX_UPDATE_RATE_DRAGGING = 5 -HOVER_DISPLAY_DISTANCE = 200000 +HOVER_DISPLAY_DISTANCE = 260**2 MAX_DOWNSCALE_AA_LEVEL = 4 -KEYBIND_POINT_DELETE = 'x' -KEYBIND_POINT_ADD = 'q' +KEYBIND_REGION_ADD = 'a' +KEYBIND_REGION_DELETE = 'x' +KEYBIND_REGION_NEXT = 'k' +KEYBIND_REGION_PREV = 'l' KEYBIND_UNDO = 'z' KEYBIND_REDO = 'y' KEYBIND_MASK = 'm' -KEYBIND_TOGGLE_AA = 'a' +KEYBIND_TOGGLE_AA = 'q' 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_LOAD = 'o' KEYBIND_SAVE = 's' @@ -160,22 +179,29 @@ def show_controls(): _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} -Downscale Set Key: 1 - 9 -Downscale +/- Key: {KEYBIND_DOWNSCALE_INC}(+), {KEYBIND_DOWNSCALE_DEC} (-) -Antialiasing Key: {KEYBIND_TOGGLE_AA} -Window Mode Key: {KEYBIND_WINDOW_MODE} +Editor: + Preview Key: {KEYBIND_MASK} + Start Scan Key: Space, Enter + Quit Key: Escape + Save Key: {KEYBIND_SAVE} + Load Key: {KEYBIND_LOAD} + Undo Key: {KEYBIND_UNDO} + Redo Key: {KEYBIND_REDO} + Print Points Key: {KEYBIND_OUTPUT_LIST} + +Regions: + Add Point Mouse: Left + Delete Point Mouse: {_WINDOWS_ONLY}Middle + Add Region Key: {KEYBIND_REGION_ADD} + Delete Region Key: {KEYBIND_REGION_DELETE} + Select Region Key: 1 - 9 + Next Region Key: {KEYBIND_REGION_NEXT} + Previous Region Key: {KEYBIND_REGION_PREV} + +Display: + Downscale +/- Key: {KEYBIND_DOWNSCALE_INC}(+), {KEYBIND_DOWNSCALE_DEC} (-) + Antialiasing Key: {KEYBIND_TOGGLE_AA} + Window Mode Key: {KEYBIND_WINDOW_MODE} """) @@ -199,18 +225,34 @@ def bound_point(point: Point, size: Size): return Point(min(max(0, point.x), size.w), min(max(0, point.y), size.h)) +def load_regions(path: ty.AnyStr) -> ty.Iterable[RegionValidator]: + region_data = None + with open(path, 'rt') as file: + region_data = file.readlines() + if region_data: + return list( + RegionValidator(region).value + for region in filter(None, (region.strip() for region in region_data))) + return [] + + # 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], debug_mode: bool): + def __init__(self, frame: np.ndarray, initial_shapes: ty.Optional[ty.List[ty.List[Point]]], + 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 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 - self._shapes = [initial_point_list(self._frame_size)] + 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 @@ -230,7 +272,12 @@ def __init__(self, frame: np.ndarray, initial_scale: ty.Optional[int], debug_mod @property def shapes(self) -> ty.Iterable[ty.Iterable[Point]]: - return self._shapes + return self._regions + + @property + def active_region(self) -> ty.Optional[ty.List[Point]]: + return self._regions[self._active_shape] if (not self._active_shape is None + and bool(self._regions)) else None def _rescale(self): assert self._scale > 0 @@ -244,22 +291,27 @@ def _rescale(self): def _undo(self): if self._history_pos < (len(self._history) - 1): self._history_pos += 1 - self._shapes = deepcopy(self._history[self._history_pos]) + snapshot = deepcopy(self._history[self._history_pos]) + self._regions = snapshot.regions + self._active_shape = snapshot.active_shape self._recalculate = True self._redraw = True - logger.debug("Undo: [%d/%d]", self._history_pos, len(self._history)) + logger.debug("Undo: [%d/%d]", self._history_pos, len(self._history) - 1) def _redo(self): if self._history_pos > 0: self._history_pos -= 1 - self._shapes = deepcopy(self._history[self._history_pos]) + snapshot = deepcopy(self._history[self._history_pos]) + self._regions = snapshot.regions + self._active_shape = snapshot.active_shape self._recalculate = True self._redraw = True - logger.debug("Redo: [%d/%d]", self._history_pos, len(self._history)) + logger.debug("Redo: [%d/%d]", self._history_pos, len(self._history) - 1) def _commit(self): + snapshot = deepcopy(Snapshot(regions=self._regions, active_shape=self._active_shape)) self._history = self._history[self._history_pos:] - self._history.insert(0, deepcopy(self._shapes)) + self._history.insert(0, snapshot) self._history = self._history[:MAX_HISTORY_SIZE] self._history_pos = 0 self._recalculate = True @@ -267,7 +319,7 @@ def _commit(self): def _emit_points(self): region_info = [] - for shape in self._shapes: + for shape in self._regions: region_info.append("--region %s" % " ".join(f"{x} {y}" for x, y in shape)) logger.info("Region data for CLI:\n%s", " ".join(region_info)) @@ -278,34 +330,40 @@ def _draw(self): return frame = self._original_frame.copy() - for shape in self._shapes: + # 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) + for shape in self._regions: + 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) + # 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) + + thickness = edge_thickness(self._scale) + thickness_active = edge_thickness(self._scale, 1) + for shape in self._regions: points = np.array([shape], np.int32) - thickness = edge_thickness(self._scale) - thickness_active = edge_thickness(self._scale, 1) if self._scale > 1: points = points // self._scale - - 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._settings.line_color, - thickness=thickness, - lineType=line_type) - if not self._hover_point is None: - first, mid, last = ((self._hover_point - 1) % len(self._shapes[0]), self._hover_point, - (self._hover_point + 1) % len(self._shapes[0])) + # + if not self._settings.mask_source: + frame = cv2.polylines( + frame, + points, + isClosed=True, + color=self._settings.line_color, + thickness=thickness, + lineType=line_type) + if not self._hover_point is None and not self._settings.mask_source: + first, mid, last = ((self._hover_point - 1) % len(self.active_region), + self._hover_point, + (self._hover_point + 1) % len(self.active_region)) points = np.array( - [[self._shapes[0][first], self._shapes[0][mid], self._shapes[0][last]]], np.int32) + [[self.active_region[first], self.active_region[mid], self.active_region[last]]], + np.int32) if self._scale > 1: points = points // self._scale frame = cv2.polylines( @@ -316,10 +374,11 @@ def _draw(self): if not self._dragging else self._settings.hover_color_alt, thickness=thickness_active, lineType=line_type) - elif not self._nearest_points is None and self._settings.highlight_insert: + elif not self._nearest_points is None and self._settings.highlight_insert and not self._settings.mask_source: points = np.array([[ - self._shapes[0][self._nearest_points[0]], self._shapes[0][self._nearest_points[1]] + self.active_region[self._nearest_points[0]], + self.active_region[self._nearest_points[1]] ]], np.int32) if self._scale > 1: points = points // self._scale @@ -331,44 +390,47 @@ def _draw(self): thickness=thickness_active, lineType=line_type) - radius = control_handle_radius(self._scale) - for i, point in enumerate(self._shapes[0]): - color = self._settings.line_color_alt - if not self._hover_point is None: - if self._hover_point == i: - 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._settings.hover_color if self._dragging else self._settings.interact_color - start, end = ( - Point((point.x // self._scale) - radius, (point.y // self._scale) - radius), - Point((point.x // self._scale) + radius, (point.y // self._scale) + radius), - ) - cv2.rectangle( - frame, - start, - end, - color, - thickness=cv2.FILLED, - ) + if not self.active_region is None: + radius = control_handle_radius(self._scale) + for i, point in enumerate(self.active_region): + color = self._settings.line_color_alt + if not self._hover_point is None: + if self._hover_point == i: + 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._settings.hover_color if self._dragging else self._settings.interact_color + start, end = ( + Point((point.x // self._scale) - radius, (point.y // self._scale) - radius), + Point((point.x // self._scale) + radius, (point.y // self._scale) + radius), + ) + cv2.rectangle( + frame, + start, + end, + color, + thickness=cv2.FILLED, + ) self._frame = frame - cv2.imshow(WINDOW_NAME, self._frame) + cv2.imshow(_WINDOW_NAME, self._frame) self._redraw = False def _find_nearest(self) -> ty.Tuple[int, int]: nearest_seg, nearest_dist, largest_cosine = 0, 2**31, math.pi - for i in range(len(self._shapes[0])): + for i in range(len(self.active_region)): # 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._shapes[0]) + next = (i + 1) % len(self.active_region) 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) + assert a_sq > 0 # Should never hit this since we check _hovering_over first. + if b_sq == 0: + # Two adjacent points are overlapping, just skip this one. + continue + a, b = math.sqrt(a_sq), math.sqrt(b_sq) cos_C = ((a_sq + b_sq) - c_sq) / (2.0 * a * b) - # If cos_C is between [0,1] the triangle is acute. If it's not, just take the distance - # of the closest point. + # If cos_C is between [0,1] the triangle is acute. If it's not, just take the distance + # of the closest point. 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 @@ -376,7 +438,7 @@ def _find_nearest(self) -> ty.Tuple[int, int]: 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._shapes[0])) + self._nearest_points = (nearest_seg, (nearest_seg + 1) % len(self.active_region)) def _hovering_over(self) -> ty.Optional[int]: min_i = 0 @@ -389,20 +451,21 @@ def _hovering_over(self) -> ty.Optional[int]: self._scale)**2 else None def _init_window(self): - cv2.namedWindow(WINDOW_NAME, self._settings.window_mode) + 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) + 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) - # TODO(v1.6): Need to return status if ROI selection succeeded or user hit escape/closed window. - def run(self) -> bool: + def run(self, warn_if_notkinter: bool) -> bool: logger.debug("Creating window for frame (scale = %d)", self._scale) self._init_window() - check_tkinter_support() - set_icon(WINDOW_NAME, ICON_PATH) + check_tkinter_support(warn_if_notkinter) + set_icon(_WINDOW_NAME, _ICON_PATH) + regions_valid = False + logger.info(f"Region editor active. Press {KEYBIND_HELP} to show controls.") while True: - if not cv2.getWindowProperty(WINDOW_NAME, cv2.WND_PROP_VISIBLE): + if not cv2.getWindowProperty(_WINDOW_NAME, cv2.WND_PROP_VISIBLE): logger.debug("Main window closed.") break self._draw() @@ -412,7 +475,8 @@ def run(self) -> bool: if key == 27: break elif key in (ord(' '), 13): - return True + regions_valid = True + break elif key == ord(KEYBIND_BREAKPOINT) and self._debug_mode: breakpoint() elif key == ord(KEYBIND_TOGGLE_AA): @@ -438,7 +502,7 @@ def run(self) -> bool: elif key == ord(KEYBIND_MASK): self._toggle_mask() elif key == ord(KEYBIND_WINDOW_MODE): - cv2.destroyWindow(WINDOW_NAME) + cv2.destroyWindow(_WINDOW_NAME) if self._settings.window_mode == cv2.WINDOW_KEEPRATIO: self._settings.window_mode = cv2.WINDOW_AUTOSIZE else: @@ -447,29 +511,34 @@ def run(self) -> bool: "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_REGION_ADD): + self._add_region() + elif key == ord(KEYBIND_REGION_DELETE): + self._delete_region() + elif key == ord(KEYBIND_REGION_NEXT): + self._next_region() + elif key == ord(KEYBIND_REGION_PREV): + self._prev_region() + elif key == ord(KEYBIND_HELP): show_controls() - elif key == ord('l'): + elif key == ord(KEYBIND_LOAD): self._load() - elif key == ord('s'): + elif key == ord(KEYBIND_SAVE): self._save() - elif key >= ord('1') and key <= ord('9'): - scale = 1 + key - ord('1') - if scale != self._scale: - self._scale = scale - self._rescale() - return False + elif key >= ord('0') and key <= ord('9'): + self._select_region((key - ord('1')) % 10) + + cv2.destroyAllWindows() + return regions_valid def _save(self): if not HAS_TKINTER: logger.debug("Cannot show file dialog.") return save_path = None - with temp_tk_window(ICON_PATH) as _: + with temp_tk_window(_ICON_PATH) as _: save_path = tkinter.filedialog.asksaveasfilename( title="DVR-Scan: Save Region", filetypes=[("Region File", "*.txt")], @@ -478,8 +547,9 @@ def _save(self): ) if save_path: with open(save_path, "wt") as region_file: - regions = " ".join(f"{x} {y}" for x, y in self._shapes[0]) - region_file.write(f"{regions}\n") + for shape in self._regions: + region_file.write(" ".join(f"{x} {y}" for x, y in shape)) + region_file.write("\n") logger.info('Saved region to: %s', save_path) def _load(self): @@ -487,7 +557,7 @@ def _load(self): logger.debug("Cannot show file dialog.") return load_path = None - with temp_tk_window(ICON_PATH) as _: + with temp_tk_window(_ICON_PATH) as _: load_path = tkinter.filedialog.askopenfilename( title="DVR-Scan: Load Region", filetypes=[("Region File", "*.txt")], @@ -498,36 +568,31 @@ def _load(self): 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 '', + regions = [] + try: + regions = load_regions(load_path) + 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}") + else: + logger.debug("Loaded %d polygon%s from region file:\n%s", len(regions), + 's' if len(regions) > 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._shapes[0] = [bound_point(point, self._source_size) for point in regions[0].value] + + self._regions = [ + [bound_point(point, self._source_size) for point in shape] for shape in regions + ] self._commit() + self._active_shape = 0 if len(self._regions) > 0 else None def _delete_point(self): if not self._hover_point is None: - if len(self._shapes[0]) > MIN_NUM_POINTS: + if len(self.active_region) > MIN_NUM_POINTS: hover = self._hover_point - x, y = self._shapes[0][hover] - del self._shapes[0][hover] + x, y = self.active_region[hover] + del self.active_region[hover] self._hover_point = None logger.debug("Del: [%d] = %s", hover, f'P({x},{y})') self._commit() @@ -544,8 +609,8 @@ 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._shapes[0]) - self._shapes[0].insert(insert_pos, self._curr_mouse_pos) + insert_pos = insert_pos % len(self.active_region) + self.active_region.insert(insert_pos, self._curr_mouse_pos) self._nearest_points = None self._hover_point = insert_pos self._dragging = True @@ -559,19 +624,23 @@ def _recalculate_data(self): # from the last calculation point. if self._curr_mouse_pos is None: return + if not self._regions or self.active_region is None: + self._hover_point = None + self._nearest_points = None + return last_hover = self._hover_point last_nearest = self._nearest_points - # Calculate distance from mouse cursor to each point. + # Calculate distance from mouse cursor to each point. self._mouse_dist = [ - squared_distance(self._curr_mouse_pos, point) for point in self._shapes[0] + squared_distance(self._curr_mouse_pos, point) for point in self.active_region ] # Check if we're hovering over a point. self._hover_point = self._hovering_over() # Optimization: Only recalculate segment distances if we aren't hovering over a point. if self._hover_point is None: - num_points = len(self._shapes[0]) + num_points = len(self.active_region) self._segment_dist = [ - squared_distance(self._shapes[0][i], self._shapes[0][(i + 1) % num_points]) + squared_distance(self.active_region[i], self.active_region[(i + 1) % num_points]) for i in range(num_points) ] self._find_nearest() @@ -586,6 +655,8 @@ def _handle_mouse_input(self, event, x, y, flags, param): self._curr_mouse_pos = Point(bounded.x * self._scale, bounded.y * self._scale) if event == cv2.EVENT_LBUTTONDOWN: + if not self._regions: + logger.info(f"No regions to edit, add a new one by pressing {KEYBIND_REGION_ADD}.") if not self._hover_point is None: self._dragging = True self._drag_start = self._curr_mouse_pos @@ -596,7 +667,7 @@ def _handle_mouse_input(self, event, x, y, flags, param): elif event == cv2.EVENT_MOUSEMOVE: if self._dragging: - self._shapes[0][self._hover_point] = self._curr_mouse_pos + self.active_region[self._hover_point] = self._curr_mouse_pos self._redraw = True else: self._recalculate = True @@ -604,10 +675,10 @@ def _handle_mouse_input(self, event, x, y, flags, param): elif event == cv2.EVENT_LBUTTONUP: if self._dragging: assert not self._hover_point is None - if (len(self._shapes[0]) != len(self._history[self._history_pos]) + if (len(self.active_region) != len(self._history[self._history_pos].regions) or self._curr_mouse_pos != self._drag_start): - self._shapes[0][self._hover_point] = self._curr_mouse_pos - x, y = self._shapes[0][self._hover_point] + self.active_region[self._hover_point] = self._curr_mouse_pos + x, y = self.active_region[self._hover_point] logger.debug("Add: [%d] = %s", self._hover_point, f'P({x},{y})') self._commit() self._redraw = True @@ -620,3 +691,62 @@ def _handle_mouse_input(self, event, x, y, flags, param): # we just started dragging a point (so it changes colour quicker). if not self._dragging or drag_started: self._draw() + + def _add_region(self): + if self._dragging: + return + + # Add a box around the current mouse position that's roughly 20% of the frame. + width, height = max(1, self._source_size.w // 10), max(1, self._source_size.h // 10) + + top_left = Point(x=self._curr_mouse_pos.x - width, y=self._curr_mouse_pos.y - height) + points = [ + top_left, + Point(x=top_left.x + 2 * width, y=top_left.y), + Point(x=top_left.x + 2 * width, y=top_left.y + 2 * height), + Point(x=top_left.x, y=top_left.y + 2 * height), + ] + + self._regions.append([bound_point(point, self._source_size) for point in points]) + self._commit() + self._active_shape = len(self._regions) - 1 + self._recalculate = True + self._redraw = True + + def _delete_region(self): + if self._dragging: + return + if self._regions: + del self._regions[self._active_shape] + 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 + + def _select_region(self, index: int): + if self._dragging: + return + assert index >= 0 + if self._regions and index < len(self._regions): + self._active_shape = index + self._recalculate = True + self._redraw = True + + def _next_region(self): + if self._dragging: + return + if self._regions: + self._active_shape = (self._active_shape + 1) % len(self._regions) + self._recalculate = True + self._redraw = True + + def _prev_region(self): + if self._dragging: + return + if self._regions: + self._active_shape = (self._active_shape - 1) % len(self._regions) + self._recalculate = True + self._redraw = True diff --git a/dvr_scan/scanner.py b/dvr_scan/scanner.py index 11535ee..2454eb1 100644 --- a/dvr_scan/scanner.py +++ b/dvr_scan/scanner.py @@ -35,7 +35,7 @@ from dvr_scan.detector import MotionDetector, Rectangle from dvr_scan.overlays import BoundingBoxOverlay, TextOverlay from dvr_scan.platform import get_filename, get_min_screen_bounds, is_ffmpeg_available -from dvr_scan.selection_window import SelectionWindow, Point, Size, bound_point +from dvr_scan.region import SelectionWindow, Point, Size, bound_point, load_regions from dvr_scan.subtractor import ( SubtractorMOG2, SubtractorCNT, @@ -240,9 +240,10 @@ def __init__(self, self._threshold = 0.15 # -t/--threshold self._kernel_size = None # -k/--kernel-size self._downscale_factor = 1 # -df/--downscale-factor - # TODO(v1.6): Add ability to configure this filter by adding a threshold + amount option - # (e.g. ignore up to 2 frames in a row that are over score 100). - self._max_score = 255.0 # scores greater than this are ignored + + # TODO(v1.6): Add ability to configure the rejection filter (_max_score_) by adding a + # threshold + amount option (e.g. ignore up to 2 frames in a row that are over score 100). + self._max_score = 255.0 # Motion Event Parameters (set_event_params) self._min_event_len = None # -l/--min-event-length @@ -250,10 +251,10 @@ def __init__(self, self._post_event_len = None # -tp/--time-post-event # Region Parameters (set_region) - self._region_editor = False # -w/--region-window - self._regions: Optional[List[List[Point]]] = None # -a/--add-region, -w/--region-window - self._load_region: Optional[str] = None # -R/--load-region - self._save_region: Optional[str] = None # -s/--save-region + self._region_editor = False # -w/--region-window + self._regions: List[List[Point]] = [] # -a/--add-region, -w/--region-window + self._load_region: Optional[str] = None # -R/--load-region + self._save_region: Optional[str] = None # -s/--save-region # Input Video Parameters (set_video_time) self._input: VideoJoiner = VideoJoiner(input_videos) # -i/--input @@ -377,11 +378,14 @@ def set_detection_params( assert kernel_size == -1 or kernel_size == 0 or kernel_size >= 3 self._kernel_size = kernel_size - def set_regions(self, region_editor: bool, regions: Optional[List[List[Point]]], - load_region: Optional[str], save_region: Optional[str]): + def set_regions(self, + region_editor: bool = False, + regions: Optional[List[List[Point]]] = None, + load_region: Optional[str] = None, + save_region: Optional[str] = None): """Set options for limiting detection regions.""" self._region_editor = region_editor - self._regions = regions + self._regions = regions if regions else [] self._load_region = load_region self._save_region = save_region @@ -456,18 +460,24 @@ def __exit__(self, type, value, traceback): return (NullProgressBar(), NullContextManager()) - def _process_regions(self) -> bool: - - if self._save_region: - raise NotImplementedError() + def _handle_regions(self) -> bool: if self._load_region: - raise NotImplementedError() - if self._region_editor and self._regions: - raise NotImplementedError() - - # TODO(v1.6): Handle self._load_region. Right now they are overwritten by the region editor. - # TODO(v1.6): Allow adding multiple ROIs by re-displaying the window in a loop. - # TODO(v1.6): Display ROIs added via command line/config file as overlay on frame. + try: + regions = load_regions(self._load_region) + 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 {self._load_region}: {reason}") + else: + logger.debug("Loaded %d region%s from file:\n%s", len(regions), + 's' if len(regions) > 1 else '', + "\n".join(f"[{i}] = {points}" for i, points in enumerate(regions))) + self._regions += regions + if self._regions: + self._regions = [[bound_point(point, Size(*self._input.resolution)) + for point in shape] + for shape in self._regions] if self._region_editor: self._logger.info("Selecting area of interest:") # TODO: We should process this frame. @@ -483,22 +493,33 @@ def _process_regions(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)) - # TODO(v1.6): Allow previewing a pre-defined set of polygons if they were loaded from - # command line or config file. - regions = SelectionWindow(frame_for_crop, scale_factor, debug_mode=self._debug_mode) - if not regions.run(): + regions = SelectionWindow( + frame=frame_for_crop, + initial_shapes=self._regions, + initial_scale=scale_factor, + debug_mode=self._debug_mode) + save_was_specified = bool(self._save_region) + if not regions.run(warn_if_notkinter=not save_was_specified): return False - # TODO(v1.6): Handle self._save_region. - self._regions = regions.shapes - # Ensure all regions are bounded to the frame. + self._regions = list(regions.shapes) if self._regions: - print(self._regions) - self._regions = [[bound_point(point, Size(*self._input.resolution)) - for point in shape] - for shape in self._regions] logger.info(f"Limiting detection to {len(self._regions)} " f"region{'s' if len(self._regions) > 1 else ''}.") + else: + logger.info("No regions selected, using entire frame.") + if self._save_region: + regions = self._regions if self._regions else [[ + Point(0, 0), + Point(frame_w - 1, 0), + Point(frame_w - 1, frame_h - 1), + Point(0, frame_h - 1) + ]] + with open(self._save_region, 'wt') as region_file: + for shape in self._regions: + region_file.write(" ".join(f"{x} {y}" for x, y in shape)) + region_file.write("\n") + logger.info(f"Saved region data to: {self._save_region}") return True def stop(self): @@ -524,7 +545,7 @@ def scan(self) -> Optional[DetectionResult]: start_frame = self._input.position.frame_num # Show ROI selection window if required. - if not self._process_regions(): + if not self._handle_regions(): self._logger.info("Exiting...") return None @@ -549,14 +570,15 @@ def scan(self) -> Optional[DetectionResult]: str(kernel_size) if kernel_size else 'off', ' (auto)' if self._kernel_size == -1 else '', ) - # TODO(v1.6): Dump point info only if in debug mode and other stats. - # Correct pre/post and minimum event lengths to account for frame skip factor. + # Correct event length parameters to account frame skip. post_event_len: int = self._post_event_len.frame_num // (self._frame_skip + 1) pre_event_len: int = self._pre_event_len.frame_num // (self._frame_skip + 1) min_event_len: int = max(self._min_event_len.frame_num // (self._frame_skip + 1), 1) + # Calculations below rely on min_event_len always being >= 1 (cannot be zero) assert min_event_len >= 1, "min_event_len must be at least 1 frame" + # Ensure that we include the exact amount of time specified in `-tb`/`--time-before` when # shifting the event start time. Instead of using `-l`/`--min-event-len` directly, we # need to compensate for rounding errors when we corrected it for frame skip. This is @@ -569,11 +591,10 @@ def scan(self) -> Optional[DetectionResult]: event_end = FrameTimecode(timecode=0, fps=self._input.framerate) last_frame_above_threshold = 0 - # TODO(v1.6): This doesn't display correctly when there is a single ROI. if self._bounding_box: self._bounding_box.set_corrections( downscale_factor=self._downscale_factor, - shift=(detector.area.x, detector.area.y), + shift=(detector.area[0].x, detector.area[0].y), frame_skip=self._frame_skip) # Motion event scanning/detection loop. Need to avoid CLI output/logging until end of the @@ -584,7 +605,6 @@ def scan(self) -> Optional[DetectionResult]: # Don't use the first result from the background subtractor. processed_first_frame = False - progress_bar, context_manager = self._create_progress_bar(show_progress=self._show_progress) # TODO: The main scanning loop should be refactored into a state machine. @@ -607,7 +627,7 @@ def scan(self) -> Optional[DetectionResult]: if frame is None: break assert frame.frame_rgb is not None - # TODO(v1.6): Is this copy necessary? + # TODO(v1.7): Is this copy necessary? frame_copy = ( frame.frame_rgb if not self._output_mode == OutputMode.OPENCV else frame.frame_rgb.copy()) @@ -626,10 +646,6 @@ def scan(self) -> Optional[DetectionResult]: processed_first_frame = True event_window = event_window[-min_event_len:] - # TODO(v1.6): Figure out the best way to make the overlay work with multiple ROIs. - # It might be good enough to simply ignore the ROI and just draw the bounds over - # the unmasked `result.subtracted` and leave this as a follow-up, but verify that - # the result still works as intended. bounding_box = None # TODO: Only call clear() when we exit the current motion event. # TODO: Include frames below the threshold for smoothing, or push a sentinel diff --git a/tests/test_cli.py b/tests/test_cli.py index 42faa69..672d223 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -34,8 +34,8 @@ BASE_COMMAND = [ '--input', 'tests/resources/traffic_camera.mp4', - '--region-of-interest', - '631,532, 210,127', + '--add-region', + '631 532 841 532 841 659 631 659', '--min-event-length', '4', '--time-before-event', @@ -44,7 +44,6 @@ BASE_COMMAND_NUM_EVENTS = 3 TEST_CONFIG_FILE = """ -region-of-interest = 631,532 210,127 min-event-length = 4 time-before-event = 0 """ @@ -218,7 +217,7 @@ def test_config_file(tmp_path): file.write(TEST_CONFIG_FILE) output = subprocess.check_output( - args=DVR_SCAN_COMMAND + BASE_COMMAND[0:2] + [ # Only use the input from BASE_COMMAND. + args=DVR_SCAN_COMMAND + BASE_COMMAND[0:4] + [ # Only use the input from BASE_COMMAND. '--output-dir', tmp_path, '--config', diff --git a/tests/test_scan_context.py b/tests/test_scan_context.py index 7ca13eb..9657bf0 100644 --- a/tests/test_scan_context.py +++ b/tests/test_scan_context.py @@ -19,9 +19,15 @@ from dvr_scan.detector import Rectangle from dvr_scan.scanner import DetectorType, MotionScanner from dvr_scan.subtractor import SubtractorCNT, SubtractorCudaMOG2 +from dvr_scan.region import Point # ROI within the frame used for the test case (see traffic_camera.txt for details). -TRAFFIC_CAMERA_ROI = Rectangle(631, 532, 210, 127) +TRAFFIC_CAMERA_ROI = [ + Point(631, 532), + Point(841, 532), + Point(841, 659), + Point(631, 659), +] TRAFFIC_CAMERA_EVENTS = [ (9, 149), @@ -52,7 +58,12 @@ ] # Small ROI for faster test execution. -CORRUPT_VIDEO_ROI = Rectangle(0, 0, 32, 32) +CORRUPT_VIDEO_ROI = [ + Point(0, 0), + Point(32, 0), + Point(32, 32), + Point(0, 32), +] CORRUPT_VIDEO_EVENTS = [ (152, 366), ] @@ -61,7 +72,8 @@ def test_scan_context(traffic_camera_video): """Test functionality of MotionScanner with default parameters (DetectorType.MOG2).""" scanner = MotionScanner([traffic_camera_video]) - scanner.set_detection_params(roi_list=TRAFFIC_CAMERA_ROI) + scanner.set_detection_params() + scanner.set_regions(regions=[TRAFFIC_CAMERA_ROI]) scanner.set_event_params(min_event_len=4, time_pre_event=0) event_list = scanner.scan().event_list event_list = [(event.start.frame_num, event.end.frame_num) for event in event_list] @@ -72,7 +84,8 @@ def test_scan_context(traffic_camera_video): def test_scan_context_cuda(traffic_camera_video): """ Test functionality of MotionScanner with the DetectorType.MOG2_CUDA. """ scanner = MotionScanner([traffic_camera_video]) - scanner.set_detection_params(detector_type=DetectorType.MOG2_CUDA, roi_list=TRAFFIC_CAMERA_ROI) + scanner.set_detection_params(detector_type=DetectorType.MOG2_CUDA) + scanner.set_regions(regions=[TRAFFIC_CAMERA_ROI]) scanner.set_event_params(min_event_len=4, time_pre_event=0) event_list = scanner.scan().event_list assert len(event_list) == len(TRAFFIC_CAMERA_EVENTS) @@ -89,7 +102,8 @@ def test_scan_context_cuda(traffic_camera_video): def test_scan_context_cnt(traffic_camera_video): """ Test basic functionality of MotionScanner using the CNT algorithm. """ scanner = MotionScanner([traffic_camera_video]) - scanner.set_detection_params(detector_type=DetectorType.CNT, roi_list=TRAFFIC_CAMERA_ROI) + scanner.set_detection_params(detector_type=DetectorType.CNT) + scanner.set_regions(regions=[TRAFFIC_CAMERA_ROI]) scanner.set_event_params(min_event_len=3, time_pre_event=0) event_list = scanner.scan().event_list event_list = [(event.start.frame_num, event.end.frame_num) for event in event_list] @@ -99,7 +113,7 @@ def test_scan_context_cnt(traffic_camera_video): def test_pre_event_shift(traffic_camera_video): """ Test setting time_pre_event. """ scanner = MotionScanner([traffic_camera_video]) - scanner.set_detection_params(roi_list=TRAFFIC_CAMERA_ROI) + scanner.set_regions(regions=[TRAFFIC_CAMERA_ROI]) scanner.set_event_params(min_event_len=4, time_pre_event=6) event_list = scanner.scan().event_list event_list = [(event.start.frame_num, event.end.frame_num) for event in event_list] @@ -110,7 +124,7 @@ def test_pre_event_shift_with_frame_skip(traffic_camera_video): """ Test setting time_pre_event when using frame_skip. """ for frame_skip in range(1, 6): scanner = MotionScanner([traffic_camera_video], frame_skip=frame_skip) - scanner.set_detection_params(roi_list=TRAFFIC_CAMERA_ROI) + scanner.set_regions(regions=[TRAFFIC_CAMERA_ROI]) scanner.set_event_params(min_event_len=4, time_pre_event=6) event_list = scanner.scan().event_list event_list = [(event.start.frame_num, event.end.frame_num) for event in event_list] @@ -128,7 +142,7 @@ def test_post_event_shift(traffic_camera_video): """ Test setting time_post_event. """ scanner = MotionScanner([traffic_camera_video]) - scanner.set_detection_params(roi_list=TRAFFIC_CAMERA_ROI) + scanner.set_regions(regions=[TRAFFIC_CAMERA_ROI]) scanner.set_event_params(min_event_len=4, time_pre_event=0, time_post_event=40) event_list = scanner.scan().event_list @@ -141,7 +155,7 @@ def test_post_event_shift_with_frame_skip(traffic_camera_video): """ Test setting time_post_event. """ for frame_skip in range(1, 6): scanner = MotionScanner([traffic_camera_video], frame_skip=frame_skip) - scanner.set_detection_params(roi_list=TRAFFIC_CAMERA_ROI) + scanner.set_regions(regions=[TRAFFIC_CAMERA_ROI]) scanner.set_event_params(min_event_len=4, time_post_event=40) event_list = scanner.scan().event_list assert len(event_list) == len(TRAFFIC_CAMERA_EVENTS_TIME_POST_40) @@ -163,7 +177,7 @@ def test_decode_corrupt_video(corrupt_video): """Ensure we can process a video with a single bad frame.""" scanner = MotionScanner([corrupt_video]) scanner.set_event_params(min_event_len=2) - scanner.set_detection_params(roi_list=CORRUPT_VIDEO_ROI) + scanner.set_regions(regions=[CORRUPT_VIDEO_ROI]) event_list = scanner.scan().event_list event_list = [(event.start.frame_num, event.end.frame_num) for event in event_list] assert event_list == CORRUPT_VIDEO_EVENTS @@ -172,7 +186,7 @@ def test_decode_corrupt_video(corrupt_video): def test_start_end_time(traffic_camera_video): """ Test basic functionality of MotionScanner with start and stop times defined. """ scanner = MotionScanner([traffic_camera_video]) - scanner.set_detection_params(roi_list=TRAFFIC_CAMERA_ROI) + scanner.set_regions(regions=[TRAFFIC_CAMERA_ROI]) scanner.set_event_params(min_event_len=4, time_pre_event=0) scanner.set_video_time(start_time=200, end_time=500) event_list = scanner.scan().event_list @@ -184,7 +198,7 @@ def test_start_end_time(traffic_camera_video): def test_start_duration(traffic_camera_video): """ Test basic functionality of MotionScanner with start and duration defined. """ scanner = MotionScanner([traffic_camera_video]) - scanner.set_detection_params(roi_list=TRAFFIC_CAMERA_ROI) + scanner.set_regions(regions=[TRAFFIC_CAMERA_ROI]) scanner.set_event_params(min_event_len=4, time_pre_event=0) scanner.set_video_time(start_time=200, duration=300) event_list = scanner.scan().event_list