Skip to content

Commit

Permalink
[gui] Finish region editor
Browse files Browse the repository at this point in the history
Issue: #39
Issue: #58
  • Loading branch information
Breakthrough committed Oct 10, 2023
1 parent b4e830c commit 2265f3b
Show file tree
Hide file tree
Showing 8 changed files with 120 additions and 702 deletions.
6 changes: 3 additions & 3 deletions dvr_scan/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""------------------------------------------------
Expand Down Expand Up @@ -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', [])
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion dvr_scan/cli/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
39 changes: 23 additions & 16 deletions dvr_scan/detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'])

Expand Down Expand Up @@ -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, :]
Expand All @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions dvr_scan/platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
104 changes: 60 additions & 44 deletions dvr_scan/scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -240,20 +240,21 @@ 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
self._pre_event_len = None # -tb/--time-before-event
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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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):
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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())
Expand All @@ -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
Expand Down
Loading

0 comments on commit 2265f3b

Please sign in to comment.