diff --git a/README.md b/README.md index 2938badae..5bfbdbfac 100644 --- a/README.md +++ b/README.md @@ -281,6 +281,38 @@ objects: # Optional: minimum decimal percentage for tracked object's computed score to be considered a true positive (default: shown below) threshold: 0.7 +# Optional: Global motion detection config. These may also be defined at the camera level. +# ADVANCED: Most users will not need to set these values in their config +motion: + # Optional: The threshold passed to cv2.threshold to determine if a pixel is different enough to be counted as motion. (default: shown below) + # Increasing this value will make motion detection less sensitive and decreasing it will make motion detection more sensitive. + # The value should be between 1 and 255. + threshold: 25 + # Optional: Minimum size in pixels in the resized motion image that counts as motion + # Increasing this value will prevent smaller areas of motion from being detected. Decreasing will make motion detection more sensitive to smaller + # moving objects. + contour_area: 100 + # Optional: Alpha value passed to cv2.accumulateWeighted when averaging the motion delta across multiple frames (default: shown below) + # Higher values mean the current frame impacts the delta a lot, and a single raindrop may register as motion. + # Too low and a fast moving person wont be detected as motion. + delta_alpha: 0.2 + # Optional: Alpha value passed to cv2.accumulateWeighted when averaging frames to determine the background (default: shown below) + # Higher values mean the current frame impacts the average a lot, and a new object will be averaged into the background faster. + # Low values will cause things like moving shadows to be detected as motion for longer. + # https://www.geeksforgeeks.org/background-subtraction-in-an-image-using-concept-of-running-average/ + frame_alpha: 0.2 + # Optional: Height of the resized motion frame (default: 1/6th of the original frame height) + # This operates as an efficient blur alternative. Higher values will result in more granular motion detection at the expense of higher CPU usage. + # Lower values result in less CPU, but small changes may not register as motion. + frame_height: 180 + +# Optional: Global detecttion settings. These may also be defined at the camera level. +# ADVANCED: Most users will not need to set these values in their config +detect: + # Optional: Number of frames without a detection before frigate considers an object to be gone. (default: double the frame rate) + max_disappeared: 10 + + # Required: configuration section for cameras cameras: # Required: name of the camera diff --git a/frigate/config.py b/frigate/config.py index cc34d6803..07246f032 100644 --- a/frigate/config.py +++ b/frigate/config.py @@ -84,6 +84,22 @@ GLOBAL_FFMPEG_SCHEMA = vol.Schema( } ) +MOTION_SCHEMA = vol.Schema( + { + 'threshold': vol.Range(min=1, max=255), + 'contour_area': int, + 'delta_alpha': float, + 'frame_alpha': float, + 'frame_height': int + } +) + +DETECT_SCHEMA = vol.Schema( + { + 'max_disappeared': int + } +) + FILTER_SCHEMA = vol.Schema( { str: { @@ -109,16 +125,6 @@ OBJECTS_SCHEMA = vol.Schema(vol.All(filters_for_all_tracked_objects, } )) -DEFAULT_CAMERA_SAVE_CLIPS = { - 'enabled': False -} -DEFAULT_CAMERA_SNAPSHOTS = { - 'show_timestamp': True, - 'draw_zones': False, - 'draw_bounding_boxes': True, - 'crop_to_region': True -} - def each_role_used_once(inputs): roles = [role for i in inputs for role in i['roles']] roles_set = set(roles) @@ -166,7 +172,7 @@ CAMERAS_SCHEMA = vol.Schema(vol.All( vol.Optional('filters', default={}): FILTER_SCHEMA } }, - vol.Optional('save_clips', default=DEFAULT_CAMERA_SAVE_CLIPS): { + vol.Optional('save_clips', default={}): { vol.Optional('enabled', default=False): bool, vol.Optional('pre_capture', default=30): int, 'objects': [str], @@ -179,14 +185,16 @@ CAMERAS_SCHEMA = vol.Schema(vol.All( vol.Optional('rtmp', default={}): { vol.Required('enabled', default=True): bool, }, - vol.Optional('snapshots', default=DEFAULT_CAMERA_SNAPSHOTS): { + vol.Optional('snapshots', default={}): { vol.Optional('show_timestamp', default=True): bool, vol.Optional('draw_zones', default=False): bool, vol.Optional('draw_bounding_boxes', default=True): bool, vol.Optional('crop_to_region', default=True): bool, vol.Optional('height', default=175): int }, - 'objects': OBJECTS_SCHEMA + 'objects': OBJECTS_SCHEMA, + vol.Optional('motion', default={}): MOTION_SCHEMA, + vol.Optional('detect', default={}): DETECT_SCHEMA } }, vol.Msg(ensure_zones_and_cameras_have_different_names, msg='Zones cannot share names with cameras')) ) @@ -213,6 +221,8 @@ FRIGATE_CONFIG_SCHEMA = vol.Schema( }, vol.Optional('ffmpeg', default={}): GLOBAL_FFMPEG_SCHEMA, vol.Optional('objects', default={}): OBJECTS_SCHEMA, + vol.Optional('motion', default={}): MOTION_SCHEMA, + vol.Optional('detect', default={}): DETECT_SCHEMA, vol.Required('cameras', default={}): CAMERAS_SCHEMA } ) @@ -561,6 +571,58 @@ class CameraRtmpConfig(): 'enabled': self.enabled, } +class MotionConfig(): + def __init__(self, global_config, config, camera_height: int): + self._threshold = config.get('threshold', global_config.get('threshold', 25)) + self._contour_area = config.get('contour_area', global_config.get('contour_area', 100)) + self._delta_alpha = config.get('delta_alpha', global_config.get('delta_alpha', 0.2)) + self._frame_alpha = config.get('frame_alpha', global_config.get('frame_alpha', 0.2)) + self._frame_height = config.get('frame_height', global_config.get('frame_height', camera_height//6)) + + @property + def threshold(self): + return self._threshold + + @property + def contour_area(self): + return self._contour_area + + @property + def delta_alpha(self): + return self._delta_alpha + + @property + def frame_alpha(self): + return self._frame_alpha + + @property + def frame_height(self): + return self._frame_height + + def to_dict(self): + return { + 'threshold': self.threshold, + 'contour_area': self.contour_area, + 'delta_alpha': self.delta_alpha, + 'frame_alpha': self.frame_alpha, + 'frame_height': self.frame_height, + } + + + +class DetectConfig(): + def __init__(self, global_config, config, camera_fps): + self._max_disappeared = config.get('max_disappeared', global_config.get('max_disappeared', camera_fps*2)) + + @property + def max_disappeared(self): + return self._max_disappeared + + def to_dict(self): + return { + 'max_disappeared': self._max_disappeared, + } + class ZoneConfig(): def __init__(self, name, config): self._coordinates = config['coordinates'] @@ -623,6 +685,8 @@ class CameraConfig(): self._rtmp = CameraRtmpConfig(global_config, config['rtmp']) self._snapshots = CameraSnapshotsConfig(config['snapshots']) self._objects = ObjectConfig(global_config['objects'], config.get('objects', {})) + self._motion = MotionConfig(global_config['motion'], config['motion'], self._height) + self._detect = DetectConfig(global_config['detect'], config['detect'], config.get('fps', 5)) self._ffmpeg_cmds = [] for ffmpeg_input in self._ffmpeg.inputs: @@ -755,6 +819,14 @@ class CameraConfig(): @property def objects(self): return self._objects + + @property + def motion(self): + return self._motion + + @property + def detect(self): + return self._detect @property def frame_shape(self): @@ -781,6 +853,8 @@ class CameraConfig(): 'rtmp': self.rtmp.to_dict(), 'snapshots': self.snapshots.to_dict(), 'objects': self.objects.to_dict(), + 'motion': self.motion.to_dict(), + 'detect': self.detect.to_dict(), 'frame_shape': self.frame_shape, 'ffmpeg_cmds': [{'roles': c['roles'], 'cmd': ' '.join(c['cmd'])} for c in self.ffmpeg_cmds], } diff --git a/frigate/motion.py b/frigate/motion.py index 13765bac2..723bc2b84 100644 --- a/frigate/motion.py +++ b/frigate/motion.py @@ -1,13 +1,15 @@ import cv2 import imutils import numpy as np +from frigate.config import MotionConfig class MotionDetector(): - def __init__(self, frame_shape, mask, resize_factor=4): + def __init__(self, frame_shape, mask, config: MotionConfig): + self.config = config self.frame_shape = frame_shape - self.resize_factor = resize_factor - self.motion_frame_size = (int(frame_shape[0]/resize_factor), int(frame_shape[1]/resize_factor)) + self.resize_factor = frame_shape[0]/config.frame_height + self.motion_frame_size = (config.frame_height, config.frame_height*frame_shape[1]//frame_shape[0]) self.avg_frame = np.zeros(self.motion_frame_size, np.float) self.avg_delta = np.zeros(self.motion_frame_size, np.float) self.motion_frame_count = 0 @@ -23,6 +25,8 @@ class MotionDetector(): # resize frame resized_frame = cv2.resize(gray, dsize=(self.motion_frame_size[1], self.motion_frame_size[0]), interpolation=cv2.INTER_LINEAR) + # TODO: can I improve the contrast of the grayscale image here? + # convert to grayscale # resized_frame = cv2.cvtColor(resized_frame, cv2.COLOR_BGR2GRAY) @@ -38,14 +42,13 @@ class MotionDetector(): frameDelta = cv2.absdiff(resized_frame, cv2.convertScaleAbs(self.avg_frame)) # compute the average delta over the past few frames - # the alpha value can be modified to configure how sensitive the motion detection is. # higher values mean the current frame impacts the delta a lot, and a single raindrop may # register as motion, too low and a fast moving person wont be detected as motion - # this also assumes that a person is in the same location across more than a single frame - cv2.accumulateWeighted(frameDelta, self.avg_delta, 0.2) + cv2.accumulateWeighted(frameDelta, self.avg_delta, self.config.delta_alpha) # compute the threshold image for the current frame - current_thresh = cv2.threshold(frameDelta, 25, 255, cv2.THRESH_BINARY)[1] + # TODO: threshold + current_thresh = cv2.threshold(frameDelta, self.config.threshold, 255, cv2.THRESH_BINARY)[1] # black out everything in the avg_delta where there isnt motion in the current frame avg_delta_image = cv2.convertScaleAbs(self.avg_delta) @@ -53,7 +56,7 @@ class MotionDetector(): # then look for deltas above the threshold, but only in areas where there is a delta # in the current frame. this prevents deltas from previous frames from being included - thresh = cv2.threshold(avg_delta_image, 25, 255, cv2.THRESH_BINARY)[1] + thresh = cv2.threshold(avg_delta_image, self.config.threshold, 255, cv2.THRESH_BINARY)[1] # dilate the thresholded image to fill in holes, then find contours # on thresholded image @@ -65,19 +68,18 @@ class MotionDetector(): for c in cnts: # if the contour is big enough, count it as motion contour_area = cv2.contourArea(c) - if contour_area > 100: + if contour_area > self.config.contour_area: x, y, w, h = cv2.boundingRect(c) motion_boxes.append((x*self.resize_factor, y*self.resize_factor, (x+w)*self.resize_factor, (y+h)*self.resize_factor)) if len(motion_boxes) > 0: self.motion_frame_count += 1 - # TODO: this really depends on FPS if self.motion_frame_count >= 10: - # only average in the current frame if the difference persists for at least 3 frames - cv2.accumulateWeighted(resized_frame, self.avg_frame, 0.2) + # only average in the current frame if the difference persists for a bit + cv2.accumulateWeighted(resized_frame, self.avg_frame, self.config.frame_alpha) else: # when no motion, just keep averaging the frames together - cv2.accumulateWeighted(resized_frame, self.avg_frame, 0.2) + cv2.accumulateWeighted(resized_frame, self.avg_frame, self.config.frame_alpha) self.motion_frame_count = 0 return motion_boxes diff --git a/frigate/objects.py b/frigate/objects.py index f4cb6644e..0d992f103 100644 --- a/frigate/objects.py +++ b/frigate/objects.py @@ -12,14 +12,15 @@ import cv2 import numpy as np from scipy.spatial import distance as dist +from frigate.config import DetectConfig from frigate.util import draw_box_with_label class ObjectTracker(): - def __init__(self, max_disappeared): + def __init__(self, config: DetectConfig): self.tracked_objects = {} self.disappeared = {} - self.max_disappeared = max_disappeared + self.max_disappeared = config.max_disappeared def register(self, index, obj): rand_id = ''.join(random.choices(string.ascii_lowercase + string.digits, k=6)) diff --git a/frigate/process_clip.py b/frigate/process_clip.py index c81d65828..09135ae00 100644 --- a/frigate/process_clip.py +++ b/frigate/process_clip.py @@ -81,10 +81,10 @@ class ProcessClip(): def process_frames(self, objects_to_track=['person'], object_filters={}): mask = np.zeros((self.frame_shape[0], self.frame_shape[1], 1), np.uint8) mask[:] = 255 - motion_detector = MotionDetector(self.frame_shape, mask) + motion_detector = MotionDetector(self.frame_shape, mask, self.camera_config.motion) object_detector = LocalObjectDetector(labels='/labelmap.txt') - object_tracker = ObjectTracker(10) + object_tracker = ObjectTracker(self.camera_config.detect) process_info = { 'process_fps': mp.Value('d', 0.0), 'detection_fps': mp.Value('d', 0.0), diff --git a/frigate/video.py b/frigate/video.py index c43f9dd79..c0d344251 100755 --- a/frigate/video.py +++ b/frigate/video.py @@ -258,10 +258,10 @@ def track_camera(name, config: CameraConfig, model_shape, detection_queue, resul object_filters = config.objects.filters mask = config.mask - motion_detector = MotionDetector(frame_shape, mask, resize_factor=6) + motion_detector = MotionDetector(frame_shape, mask, config.motion) object_detector = RemoteObjectDetector(name, '/labelmap.txt', detection_queue, result_connection, model_shape) - object_tracker = ObjectTracker(10) + object_tracker = ObjectTracker(config.detect) frame_manager = SharedMemoryFrameManager()