diff --git a/Dockerfile b/Dockerfile index b06a29085..3c2362e04 100755 --- a/Dockerfile +++ b/Dockerfile @@ -25,6 +25,8 @@ RUN apt -qq update && apt -qq install --no-install-recommends -y \ imutils \ scipy \ psutil \ + # SORT deps + filterpy scikit-image lap \ && python3.7 -m pip install -U \ Flask \ paho-mqtt \ @@ -47,7 +49,7 @@ RUN apt -qq update && apt -qq install --no-install-recommends -y \ # get model and labels RUN wget -q https://github.com/google-coral/edgetpu/raw/master/test_data/ssd_mobilenet_v2_coco_quant_postprocess_edgetpu.tflite -O /edgetpu_model.tflite --trust-server-names RUN wget -q https://dl.google.com/coral/canned_models/coco_labels.txt -O /labelmap.txt --trust-server-names -RUN wget -q https://github.com/google-coral/edgetpu/raw/master/test_data/ssd_mobilenet_v2_coco_quant_postprocess.tflite -O /cpu_model.tflite +RUN wget -q https://github.com/google-coral/edgetpu/raw/master/test_data/ssd_mobilenet_v2_coco_quant_postprocess.tflite -O /cpu_model.tflite RUN mkdir /cache /clips diff --git a/config/config.example.yml b/config/config.example.yml index 90d53ace9..357392d8a 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -83,7 +83,7 @@ cameras: # hwaccel_args: [] # input_args: [] # output_args: [] - + ################ ## Optionally specify the resolution of the video feed. Frigate will try to auto detect if not specified ################ @@ -92,20 +92,20 @@ cameras: ################ ## Optional mask. Must be the same aspect ratio as your video feed. - ## + ## ## The mask works by looking at the bottom center of the bounding box for the detected ## person in the image. If that pixel in the mask is a black pixel, it ignores it as a - ## false positive. In my mask, the grass and driveway visible from my backdoor camera - ## are white. The garage doors, sky, and trees (anywhere it would be impossible for a + ## false positive. In my mask, the grass and driveway visible from my backdoor camera + ## are white. The garage doors, sky, and trees (anywhere it would be impossible for a ## person to stand) are black. - ## + ## ## Masked areas are also ignored for motion detection. ################ # mask: back-mask.bmp ################ # Allows you to limit the framerate within frigate for cameras that do not support - # custom framerates. A value of 1 tells frigate to look at every frame, 2 every 2nd frame, + # custom framerates. A value of 1 tells frigate to look at every frame, 2 every 2nd frame, # 3 every 3rd frame, etc. ################ take_frame: 1 @@ -149,3 +149,19 @@ cameras: min_area: 5000 max_area: 100000 threshold: 0.5 + + ################ + ## Tracker configuration + ## + ## If you have problems with keeping track of objects try changing these constants. + ## See SORT project for details: + ## https://github.com/abewley/sort/blob/master/sort.py + ## + ## min_hits: Minimum number of hits to start tracking an object. + ## max_age: Number of missed frames before discarding track. + ## iou_threshold: Intersection over union threshold. + ################ + tracker: + min_hits: 1 + max_age: 5 + iou_threshold: 0.2 diff --git a/frigate/object_processing.py b/frigate/object_processing.py index 4166f91e8..6b15ef4dd 100644 --- a/frigate/object_processing.py +++ b/frigate/object_processing.py @@ -22,10 +22,6 @@ COLOR_MAP = {} for key, val in LABELS.items(): COLOR_MAP[val] = tuple(int(round(255 * c)) for c in cmap(key)[:3]) -def filter_false_positives(event): - if len(event['history']) < 2: - return True - return False class TrackedObjectProcessor(threading.Thread): def __init__(self, config, client, topic_prefix, tracked_objects_queue, event_queue): @@ -70,13 +66,11 @@ class TrackedObjectProcessor(threading.Thread): updated_ids = list(set(current_ids).intersection(previous_ids)) for id in new_ids: - # only register the object here if we are sure it isnt a false positive - if not filter_false_positives(current_tracked_objects[id]): - tracked_objects[id] = current_tracked_objects[id] - # publish events to mqtt - self.client.publish(f"{self.topic_prefix}/{camera}/events/start", json.dumps(tracked_objects[id]), retain=False) - self.event_queue.put(('start', camera, tracked_objects[id])) - + tracked_objects[id] = current_tracked_objects[id] + # publish events to mqtt + self.client.publish(f"{self.topic_prefix}/{camera}/events/start", json.dumps(tracked_objects[id]), retain=False) + self.event_queue.put(('start', camera, tracked_objects[id])) + for id in updated_ids: tracked_objects[id] = current_tracked_objects[id] diff --git a/frigate/objects.py b/frigate/objects.py index b52edcd86..aceb597a8 100644 --- a/frigate/objects.py +++ b/frigate/objects.py @@ -1,26 +1,15 @@ -import time -import datetime -import threading -import cv2 -import itertools -import copy import numpy as np -import random -import string -import multiprocessing as mp -from collections import defaultdict -from scipy.spatial import distance as dist -from frigate.util import draw_box_with_label, calculate_region +from frigate.sort import Sort + class ObjectTracker(): - def __init__(self, max_disappeared): + def __init__(self, min_hits, max_age, iou_threshold): self.tracked_objects = {} self.disappeared = {} - self.max_disappeared = max_disappeared + self.max_age = max_age + self.mot_tracker = Sort(min_hits=min_hits, max_age=self.max_age, iou_threshold=iou_threshold) - def register(self, index, obj): - rand_id = ''.join(random.choices(string.ascii_lowercase + string.digits, k=6)) - id = f"{obj['frame_time']}-{rand_id}" + def register(self, id, obj): obj['id'] = id obj['start_time'] = obj['frame_time'] obj['top_score'] = obj['score'] @@ -31,14 +20,14 @@ class ObjectTracker(): def deregister(self, id): del self.tracked_objects[id] del self.disappeared[id] - + def update(self, id, new_obj): self.disappeared[id] = 0 self.tracked_objects[id].update(new_obj) self.add_history(self.tracked_objects[id]) if self.tracked_objects[id]['score'] > self.tracked_objects[id]['top_score']: self.tracked_objects[id]['top_score'] = self.tracked_objects[id]['score'] - + def add_history(self, obj): entry = { 'score': obj['score'], @@ -56,111 +45,47 @@ class ObjectTracker(): obj['history'] = [entry] def match_and_update(self, frame_time, new_objects): - # group by name - new_object_groups = defaultdict(lambda: []) - for obj in new_objects: - new_object_groups[obj[0]].append({ - 'label': obj[0], - 'score': obj[1], - 'box': obj[2], - 'area': obj[3], - 'region': obj[4], - 'frame_time': frame_time - }) - - # update any tracked objects with labels that are not - # seen in the current objects and deregister if needed + a = [] + for i, obj in enumerate(new_objects): + a.append(list(obj[2]) + [obj[1], i]) + + new_tracked_ids = {} + if len(a) == 0: + self.mot_tracker.update() + else: + new_tracked = self.mot_tracker.update(np.array(a)) + for t in new_tracked: + # Convert from numpy float array to list of ints. + int_arr = t.astype(np.int).tolist() + new_tracked_ids[str(int_arr[-2])] = int_arr + + # Remove lost tracks for obj in list(self.tracked_objects.values()): - if not obj['label'] in new_object_groups: - if self.disappeared[obj['id']] >= self.max_disappeared: + if obj['id'] not in new_tracked_ids: + # SORT will not return "missed" objects even though it still tracks them. + # Therefore, we need to count in the max_age here as well. + if self.disappeared[obj['id']] > self.max_age: self.deregister(obj['id']) else: self.disappeared[obj['id']] += 1 - - if len(new_objects) == 0: - return - - # track objects for each label type - for label, group in new_object_groups.items(): - current_objects = [o for o in self.tracked_objects.values() if o['label'] == label] - current_ids = [o['id'] for o in current_objects] - current_centroids = np.array([o['centroid'] for o in current_objects]) - # compute centroids of new objects - for obj in group: - centroid_x = int((obj['box'][0]+obj['box'][2]) / 2.0) - centroid_y = int((obj['box'][1]+obj['box'][3]) / 2.0) - obj['centroid'] = (centroid_x, centroid_y) - - if len(current_objects) == 0: - for index, obj in enumerate(group): - self.register(index, obj) - return - - new_centroids = np.array([o['centroid'] for o in group]) - - # compute the distance between each pair of tracked - # centroids and new centroids, respectively -- our - # goal will be to match each new centroid to an existing - # object centroid - D = dist.cdist(current_centroids, new_centroids) - - # in order to perform this matching we must (1) find the - # smallest value in each row and then (2) sort the row - # indexes based on their minimum values so that the row - # with the smallest value is at the *front* of the index - # list - rows = D.min(axis=1).argsort() - - # next, we perform a similar process on the columns by - # finding the smallest value in each column and then - # sorting using the previously computed row index list - cols = D.argmin(axis=1)[rows] - - # in order to determine if we need to update, register, - # or deregister an object we need to keep track of which - # of the rows and column indexes we have already examined - usedRows = set() - usedCols = set() - - # loop over the combination of the (row, column) index - # tuples - for (row, col) in zip(rows, cols): - # if we have already examined either the row or - # column value before, ignore it - if row in usedRows or col in usedCols: - continue - - # otherwise, grab the object ID for the current row, - # set its new centroid, and reset the disappeared - # counter - objectID = current_ids[row] - self.update(objectID, group[col]) - - # indicate that we have examined each of the row and - # column indexes, respectively - usedRows.add(row) - usedCols.add(col) - - # compute the column index we have NOT yet examined - unusedRows = set(range(0, D.shape[0])).difference(usedRows) - unusedCols = set(range(0, D.shape[1])).difference(usedCols) - - # in the event that the number of object centroids is - # equal or greater than the number of input centroids - # we need to check and see if some of these objects have - # potentially disappeared - if D.shape[0] >= D.shape[1]: - for row in unusedRows: - id = current_ids[row] - - if self.disappeared[id] >= self.max_disappeared: - self.deregister(id) - else: - self.disappeared[id] += 1 - # if the number of input centroids is greater - # than the number of existing object centroids we need to - # register each new input centroid as a trackable object + # Add/update new trackings + for tracker_id, track in new_tracked_ids.items(): + new_object_index = int(track[-1]) + enhanced_box = track[0:4] + new_obj = new_objects[new_object_index] + obj = { + 'label': new_obj[0], + 'score': new_obj[1], + 'box': enhanced_box, + 'area': new_obj[3], + 'region': new_obj[4], + 'frame_time': frame_time + } + centroid_x = int((obj['box'][0]+obj['box'][2]) / 2.0) + centroid_y = int((obj['box'][1]+obj['box'][3]) / 2.0) + obj['centroid'] = (centroid_x, centroid_y) + if tracker_id not in self.tracked_objects: + self.register(tracker_id, obj) else: - for col in unusedCols: - self.register(col, group[col]) + self.update(tracker_id, obj) diff --git a/frigate/sort.py b/frigate/sort.py new file mode 100644 index 000000000..5d4780fb7 --- /dev/null +++ b/frigate/sort.py @@ -0,0 +1,252 @@ +""" + SORT: A Simple, Online and Realtime Tracker + Copyright (C) 2016-2020 Alex Bewley alex@bewley.ai + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +""" + +from __future__ import print_function + +import os +import numpy as np +from skimage import io + +import glob +import time +import argparse +from filterpy.kalman import KalmanFilter + +np.random.seed(0) + + +def linear_assignment(cost_matrix): + try: + import lap + _, x, y = lap.lapjv(cost_matrix, extend_cost=True) + return np.array([[y[i],i] for i in x if i >= 0]) # + except ImportError: + from scipy.optimize import linear_sum_assignment + x, y = linear_sum_assignment(cost_matrix) + return np.array(list(zip(x, y))) + + +def iou_batch(bb_test, bb_gt): + """ + From SORT: Computes IUO between two bboxes in the form [l,t,w,h] + """ + bb_gt = np.expand_dims(bb_gt, 0) + bb_test = np.expand_dims(bb_test, 1) + + xx1 = np.maximum(bb_test[..., 0], bb_gt[..., 0]) + yy1 = np.maximum(bb_test[..., 1], bb_gt[..., 1]) + xx2 = np.minimum(bb_test[..., 2], bb_gt[..., 2]) + yy2 = np.minimum(bb_test[..., 3], bb_gt[..., 3]) + w = np.maximum(0., xx2 - xx1) + h = np.maximum(0., yy2 - yy1) + wh = w * h + o = wh / ((bb_test[..., 2] - bb_test[..., 0]) * (bb_test[..., 3] - bb_test[..., 1]) + + (bb_gt[..., 2] - bb_gt[..., 0]) * (bb_gt[..., 3] - bb_gt[..., 1]) - wh) + return(o) + + +def convert_bbox_to_z(bbox): + """ + Takes a bounding box in the form [x1,y1,x2,y2] and returns z in the form + [x,y,s,r] where x,y is the centre of the box and s is the scale/area and r is + the aspect ratio + """ + w = bbox[2] - bbox[0] + h = bbox[3] - bbox[1] + x = bbox[0] + w/2. + y = bbox[1] + h/2. + s = w * h #scale is just area + r = w / float(h) + return np.array([x, y, s, r]).reshape((4, 1)) + + +def convert_x_to_bbox(x,score=None): + """ + Takes a bounding box in the centre form [x,y,s,r] and returns it in the form + [x1,y1,x2,y2] where x1,y1 is the top left and x2,y2 is the bottom right + """ + w = np.sqrt(x[2] * x[3]) + h = x[2] / w + if(score==None): + return np.array([x[0]-w/2.,x[1]-h/2.,x[0]+w/2.,x[1]+h/2.]).reshape((1,4)) + else: + return np.array([x[0]-w/2.,x[1]-h/2.,x[0]+w/2.,x[1]+h/2.,score]).reshape((1,5)) + + +class KalmanBoxTracker(object): + """ + This class represents the internal state of individual tracked objects observed as bbox. + """ + count = 0 + def __init__(self,bbox): + """ + Initialises a tracker using initial bounding box. + """ + #define constant velocity model + self.kf = KalmanFilter(dim_x=7, dim_z=4) + self.kf.F = np.array([[1,0,0,0,1,0,0],[0,1,0,0,0,1,0],[0,0,1,0,0,0,1],[0,0,0,1,0,0,0], [0,0,0,0,1,0,0],[0,0,0,0,0,1,0],[0,0,0,0,0,0,1]]) + self.kf.H = np.array([[1,0,0,0,0,0,0],[0,1,0,0,0,0,0],[0,0,1,0,0,0,0],[0,0,0,1,0,0,0]]) + + self.kf.R[2:,2:] *= 10. + self.kf.P[4:,4:] *= 1000. #give high uncertainty to the unobservable initial velocities + self.kf.P *= 10. + self.kf.Q[-1,-1] *= 0.01 + self.kf.Q[4:,4:] *= 0.01 + + self.kf.x[:4] = convert_bbox_to_z(bbox) + self.time_since_update = 0 + self.id = KalmanBoxTracker.count + KalmanBoxTracker.count += 1 + self.history = [] + self.hits = 0 + self.hit_streak = 0 + self.age = 0 + self.original_id = bbox[5] + + def update(self,bbox): + """ + Updates the state vector with observed bbox. + """ + self.time_since_update = 0 + self.history = [] + self.hits += 1 + self.hit_streak += 1 + self.kf.update(convert_bbox_to_z(bbox)) + self.original_id = bbox[5] + + def predict(self): + """ + Advances the state vector and returns the predicted bounding box estimate. + """ + if((self.kf.x[6]+self.kf.x[2])<=0): + self.kf.x[6] *= 0.0 + self.kf.predict() + self.age += 1 + if(self.time_since_update>0): + self.hit_streak = 0 + self.time_since_update += 1 + self.history.append(convert_x_to_bbox(self.kf.x)) + return self.history[-1] + + def get_state(self): + """ + Returns the current bounding box estimate. + """ + return convert_x_to_bbox(self.kf.x) + + +def associate_detections_to_trackers(detections,trackers,iou_threshold = 0.3): + """ + Assigns detections to tracked object (both represented as bounding boxes) + + Returns 3 lists of matches, unmatched_detections and unmatched_trackers + """ + if(len(trackers)==0): + return np.empty((0,2),dtype=int), np.arange(len(detections)), np.empty((0,5),dtype=int) + + iou_matrix = iou_batch(detections, trackers) + + if min(iou_matrix.shape) > 0: + a = (iou_matrix > iou_threshold).astype(np.int32) + if a.sum(1).max() == 1 and a.sum(0).max() == 1: + matched_indices = np.stack(np.where(a), axis=1) + else: + matched_indices = linear_assignment(-iou_matrix) + else: + matched_indices = np.empty(shape=(0,2)) + + unmatched_detections = [] + for d, det in enumerate(detections): + if(d not in matched_indices[:,0]): + unmatched_detections.append(d) + unmatched_trackers = [] + for t, trk in enumerate(trackers): + if(t not in matched_indices[:,1]): + unmatched_trackers.append(t) + + #filter out matched with low IOU + matches = [] + for m in matched_indices: + if(iou_matrix[m[0], m[1]]= self.min_hits or self.frame_count <= self.min_hits): + ret.append(np.concatenate((d,[trk.id+1], [trk.original_id])).reshape(1,-1)) # +1 as MOT benchmark requires positive + i -= 1 + # remove dead tracklet + if(trk.time_since_update > self.max_age): + self.trackers.pop(i) + if(len(ret)>0): + return np.concatenate(ret) + return np.empty((0,5)) diff --git a/frigate/video.py b/frigate/video.py index a9e0e0e47..72ee8cdc8 100755 --- a/frigate/video.py +++ b/frigate/video.py @@ -200,7 +200,10 @@ def track_camera(name, config, global_objects_config, frame_queue, frame_shape, motion_detector = MotionDetector(frame_shape, mask, resize_factor=6) object_detector = RemoteObjectDetector(name, '/labelmap.txt', detection_queue) - object_tracker = ObjectTracker(10) + camera_tracker_config = config.get('tracker', {"min_hits": 1, "max_age": 5, "iou_threshold": 0.2}) + object_tracker = ObjectTracker(camera_tracker_config["min_hits"], + camera_tracker_config["max_age"], + camera_tracker_config["iou_threshold"]) plasma_client = PlasmaManager() avg_wait = 0.0