Selaa lähdekoodia

WIP: revamp to incorporate motion

Blake Blackshear 5 vuotta sitten
vanhempi
commit
4c618daa90
2 muutettua tiedostoa jossa 654 lisäystä ja 32 poistoa
  1. 35 32
      Dockerfile
  2. 619 0
      start_no_thread.py

+ 35 - 32
Dockerfile

@@ -4,50 +4,53 @@ LABEL maintainer "blakeb@blakeshome.com"
 ENV DEBIAN_FRONTEND=noninteractive
 # Install packages for apt repo
 RUN apt -qq update && apt -qq install --no-install-recommends -y \
-    apt-transport-https ca-certificates \
+    software-properties-common \
+    # apt-transport-https ca-certificates \
+    build-essential \
     gnupg wget \
-    ffmpeg \
-    python3 \
-    python3-pip \
-    python3-dev \
-    python3-numpy \
-    # python-prctl
-    build-essential libcap-dev \
-    # pillow-simd
-    # zlib1g-dev libjpeg-dev \
-    # VAAPI drivers for Intel hardware accel
-    libva-drm2 libva2 i965-va-driver vainfo \
+    # libcap-dev \
+    && add-apt-repository ppa:deadsnakes/ppa -y \
+    && apt -qq install --no-install-recommends -y \
+        python3.7 \
+        python3.7-dev \
+        python3-pip \
+        ffmpeg \
+        # VAAPI drivers for Intel hardware accel
+        libva-drm2 libva2 i965-va-driver vainfo \
+    && python3.7 -m pip install -U wheel setuptools \
+    && python3.7 -m pip install -U \
+        opencv-python-headless \
+        # python-prctl \
+        numpy \
+        imutils \
+        # Flask \
+        # paho-mqtt \
+        # PyYAML \
+        # matplotlib \
+        scipy \
     && echo "deb https://packages.cloud.google.com/apt coral-edgetpu-stable main" > /etc/apt/sources.list.d/coral-edgetpu.list \
     && wget -q -O - https://packages.cloud.google.com/apt/doc/apt-key.gpg | apt-key add - \
     && apt -qq update \
     && echo "libedgetpu1-max libedgetpu/accepted-eula boolean true" | debconf-set-selections \
     && apt -qq install --no-install-recommends -y \
-    libedgetpu1-max \
-    python3-edgetpu \
+        libedgetpu1-max \
+    ## Tensorflow lite (python 3.7 only)
+    && wget -q https://dl.google.com/coral/python/tflite_runtime-1.14.0-cp37-cp37m-linux_x86_64.whl \
+    && python3.7 -m pip install tflite_runtime-1.14.0-cp37-cp37m-linux_x86_64.whl \
+    && rm tflite_runtime-1.14.0-cp37-cp37m-linux_x86_64.whl \
     && rm -rf /var/lib/apt/lists/* \
     && (apt-get autoremove -y; apt-get autoclean -y)
 
-# needs to be installed before others
-RUN pip3 install -U wheel setuptools
-
-RUN pip3 install -U \
-    opencv-python-headless \
-    python-prctl \
-    Flask \
-    paho-mqtt \
-    PyYAML \
-    matplotlib \
-    scipy
-
-# symlink the model and labels
-RUN wget -q https://github.com/google-coral/edgetpu/raw/master/test_data/mobilenet_ssd_v2_coco_quant_postprocess_edgetpu.tflite -O mobilenet_ssd_v2_coco_quant_postprocess_edgetpu.tflite --trust-server-names
-RUN wget -q https://dl.google.com/coral/canned_models/coco_labels.txt -O coco_labels.txt --trust-server-names
-RUN ln -s mobilenet_ssd_v2_coco_quant_postprocess_edgetpu.tflite /frozen_inference_graph.pb
-RUN ln -s /coco_labels.txt /label_map.pbtext
+# # symlink the model and labels
+# RUN wget -q https://github.com/google-coral/edgetpu/raw/master/test_data/mobilenet_ssd_v2_coco_quant_postprocess_edgetpu.tflite -O mobilenet_ssd_v2_coco_quant_postprocess_edgetpu.tflite --trust-server-names
+# RUN wget -q https://dl.google.com/coral/canned_models/coco_labels.txt -O coco_labels.txt --trust-server-names
+# RUN ln -s mobilenet_ssd_v2_coco_quant_postprocess_edgetpu.tflite /frozen_inference_graph.pb
+# RUN ln -s /coco_labels.txt /label_map.pbtext
 
 WORKDIR /opt/frigate/
 ADD frigate frigate/
 COPY detect_objects.py .
+COPY start.py .
 COPY benchmark.py .
 
-CMD ["python3", "-u", "detect_objects.py"]
+CMD ["python3", "-u", "start.py"]

+ 619 - 0
start_no_thread.py

@@ -0,0 +1,619 @@
+import datetime
+import time
+import threading
+import queue
+import itertools
+from collections import defaultdict
+import cv2
+import imutils
+import numpy as np
+from scipy.spatial import distance as dist
+import tflite_runtime.interpreter as tflite
+from tflite_runtime.interpreter import load_delegate
+
+def load_labels(path, encoding='utf-8'):
+  """Loads labels from file (with or without index numbers).
+  Args:
+    path: path to label file.
+    encoding: label file encoding.
+  Returns:
+    Dictionary mapping indices to labels.
+  """
+  with open(path, 'r', encoding=encoding) as f:
+    lines = f.readlines()
+    if not lines:
+      return {}
+
+    if lines[0].split(' ', maxsplit=1)[0].isdigit():
+      pairs = [line.split(' ', maxsplit=1) for line in lines]
+      return {int(index): label.strip() for index, label in pairs}
+    else:
+      return {index: line.strip() for index, line in enumerate(lines)}
+
+def draw_box_with_label(frame, x_min, y_min, x_max, y_max, label, info, thickness=2, color=None, position='ul'):
+    if color is None:
+        color = (0,0,255)
+    display_text = "{}: {}".format(label, info)
+    cv2.rectangle(frame, (x_min, y_min), (x_max, y_max), color, thickness)
+    font_scale = 0.5
+    font = cv2.FONT_HERSHEY_SIMPLEX
+    # get the width and height of the text box
+    size = cv2.getTextSize(display_text, font, fontScale=font_scale, thickness=2)
+    text_width = size[0][0]
+    text_height = size[0][1]
+    line_height = text_height + size[1]
+    # set the text start position
+    if position == 'ul':
+        text_offset_x = x_min
+        text_offset_y = 0 if y_min < line_height else y_min - (line_height+8)
+    elif position == 'ur':
+        text_offset_x = x_max - (text_width+8)
+        text_offset_y = 0 if y_min < line_height else y_min - (line_height+8)
+    elif position == 'bl':
+        text_offset_x = x_min
+        text_offset_y = y_max
+    elif position == 'br':
+        text_offset_x = x_max - (text_width+8)
+        text_offset_y = y_max
+    # make the coords of the box with a small padding of two pixels
+    textbox_coords = ((text_offset_x, text_offset_y), (text_offset_x + text_width + 2, text_offset_y + line_height))
+    cv2.rectangle(frame, textbox_coords[0], textbox_coords[1], color, cv2.FILLED)
+    cv2.putText(frame, display_text, (text_offset_x, text_offset_y + line_height - 3), font, fontScale=font_scale, color=(0, 0, 0), thickness=2)
+
+def calculate_region(frame_shape, xmin, ymin, xmax, ymax, multiplier=2):    
+    # size is larger than longest edge
+    size = int(max(xmax-xmin, ymax-ymin)*multiplier)
+    # if the size is too big to fit in the frame
+    if size > min(frame_shape[0], frame_shape[1]):
+        size = min(frame_shape[0], frame_shape[1])
+
+    # x_offset is midpoint of bounding box minus half the size
+    x_offset = int((xmax-xmin)/2.0+xmin-size/2.0)
+    # if outside the image
+    if x_offset < 0:
+        x_offset = 0
+    elif x_offset > (frame_shape[1]-size):
+        x_offset = (frame_shape[1]-size)
+
+    # y_offset is midpoint of bounding box minus half the size
+    y_offset = int((ymax-ymin)/2.0+ymin-size/2.0)
+    # if outside the image
+    if y_offset < 0:
+        y_offset = 0
+    elif y_offset > (frame_shape[0]-size):
+        y_offset = (frame_shape[0]-size)
+
+    return (x_offset, y_offset, x_offset+size, y_offset+size)
+
+def intersection(box_a, box_b):
+    return (
+        max(box_a[0], box_b[0]),
+        max(box_a[1], box_b[1]),
+        min(box_a[2], box_b[2]),
+        min(box_a[3], box_b[3])
+    )
+
+def area(box):
+    return (box[2]-box[0] + 1)*(box[3]-box[1] + 1)
+    
+def intersection_over_union(box_a, box_b):
+    # determine the (x, y)-coordinates of the intersection rectangle
+    intersect = intersection(box_a, box_b)
+
+    # compute the area of intersection rectangle
+    inter_area = max(0, intersect[2] - intersect[0] + 1) * max(0, intersect[3] - intersect[1] + 1)
+
+    if inter_area == 0:
+        return 0.0
+    
+    # compute the area of both the prediction and ground-truth
+    # rectangles
+    box_a_area = (box_a[2] - box_a[0] + 1) * (box_a[3] - box_a[1] + 1)
+    box_b_area = (box_b[2] - box_b[0] + 1) * (box_b[3] - box_b[1] + 1)
+
+    # compute the intersection over union by taking the intersection
+    # area and dividing it by the sum of prediction + ground-truth
+    # areas - the interesection area
+    iou = inter_area / float(box_a_area + box_b_area - inter_area)
+
+    # return the intersection over union value
+    return iou
+
+def clipped(obj, frame_shape):
+    # if the object is within 5 pixels of the region border, and the region is not on the edge
+    # consider the object to be clipped
+    box = obj[2]
+    region = obj[3]
+    if ((region[0] > 5 and box[0]-region[0] <= 5) or 
+        (region[1] > 5 and box[1]-region[1] <= 5) or
+        (frame_shape[1]-region[2] > 5 and region[2]-box[2] <= 5) or
+        (frame_shape[0]-region[3] > 5 and region[3]-box[3] <= 5)):
+        return True
+    else:
+        return False
+
+def filtered(obj):
+    if obj[0] != 'person':
+        return True
+    return False
+
+def create_tensor_input(frame, region):
+    cropped_frame = frame[region[1]:region[3], region[0]:region[2]]
+
+    # Resize to 300x300 if needed
+    if cropped_frame.shape != (300, 300, 3):
+        # TODO: use Pillow-SIMD?
+        cropped_frame = cv2.resize(cropped_frame, dsize=(300, 300), interpolation=cv2.INTER_LINEAR)
+    
+    # Expand dimensions since the model expects images to have shape: [1, 300, 300, 3]
+    return np.expand_dims(cropped_frame, axis=0)
+
+
+class MotionDetector():
+    # TODO: add motion masking
+    def __init__(self, frame_shape, resize_factor=4):
+        self.resize_factor = resize_factor
+        self.motion_frame_size = (int(frame_shape[0]/resize_factor), int(frame_shape[1]/resize_factor))
+        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
+        self.frame_counter = 0
+
+    def detect(self, frame):
+        motion_boxes = []
+
+        # resize frame
+        resized_frame = cv2.resize(frame, dsize=(self.motion_frame_size[1], self.motion_frame_size[0]), interpolation=cv2.INTER_LINEAR)
+
+        # convert to grayscale
+        gray = cv2.cvtColor(resized_frame, cv2.COLOR_BGR2GRAY)
+
+        # it takes ~30 frames to establish a baseline
+        # dont bother looking for motion
+        if self.frame_counter < 30:
+            self.frame_counter += 1
+        else:
+            # compare to average
+            frameDelta = cv2.absdiff(gray, 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)
+
+            # compute the threshold image for the current frame
+            current_thresh = cv2.threshold(frameDelta, 25, 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)
+            avg_delta_image[np.where(current_thresh==[0])] = [0]
+
+            # 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]
+
+            # dilate the thresholded image to fill in holes, then find contours
+            # on thresholded image
+            thresh = cv2.dilate(thresh, None, iterations=2)
+            cnts = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
+            cnts = imutils.grab_contours(cnts)
+
+            # loop over the contours
+            for c in cnts:
+                # if the contour is big enough, count it as motion
+                contour_area = cv2.contourArea(c)
+                if contour_area > 100:
+                    # cv2.drawContours(resized_frame, [c], -1, (255,255,255), 2)
+                    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(gray, self.avg_frame, 0.2)
+        else:
+            # when no motion, just keep averaging the frames together
+            cv2.accumulateWeighted(gray, self.avg_frame, 0.2)
+            self.motion_frame_count = 0
+
+        return motion_boxes
+
+class ObjectDetector():
+    def __init__(self, model_file, label_file):
+        self.labels = load_labels(label_file)
+        edge_tpu_delegate = None
+        try:
+            edge_tpu_delegate = load_delegate('libedgetpu.so.1.0')
+        except ValueError:
+            print("No EdgeTPU detected. Falling back to CPU.")
+        
+        if edge_tpu_delegate is None:
+            self.interpreter = tflite.Interpreter(
+                model_path=model_file)
+        else:
+            self.interpreter = tflite.Interpreter(
+                model_path=model_file,
+                experimental_delegates=[edge_tpu_delegate])
+        
+        self.interpreter.allocate_tensors()
+
+        self.tensor_input_details = self.interpreter.get_input_details()
+        self.tensor_output_details = self.interpreter.get_output_details()
+
+    def detect(self, tensor_input, threshold=.4):
+        self.interpreter.set_tensor(self.tensor_input_details[0]['index'], tensor_input)
+        self.interpreter.invoke()
+        boxes = np.squeeze(self.interpreter.get_tensor(self.tensor_output_details[0]['index']))
+        label_codes = np.squeeze(self.interpreter.get_tensor(self.tensor_output_details[1]['index']))
+        scores = np.squeeze(self.interpreter.get_tensor(self.tensor_output_details[2]['index']))
+
+        detections = []
+        for i, score in enumerate(scores):
+            label = self.labels[int(label_codes[i])]
+            
+            if score < threshold:
+                break
+
+            detections.append((
+                label,
+                float(score),
+                boxes[i]
+            ))
+        
+        return detections
+
+class ObjectTracker():
+    def __init__(self, max_disappeared):
+        self.tracked_objects = {}
+        self.disappeared = {}
+        self.max_disappeared = max_disappeared
+
+    def register(self, index, frame_time, obj):
+        id = f"{frame_time}-{index}"
+        obj['id'] = id
+        obj['frame_time'] = frame_time
+        obj['top_score'] = obj['score']
+        self.add_history(obj)
+        self.tracked_objects[id] = obj
+        self.disappeared[id] = 0
+
+    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'],
+            'box': obj['box'],
+            'region': obj['region'],
+            'centroid': obj['centroid'],
+            'frame_time': obj['frame_time']
+        }
+        if 'history' in obj:
+            obj['history'].append(entry)
+        else:
+            obj['history'] = [entry]
+
+    def match_and_update(self, frame_time, new_objects):
+        if len(new_objects) == 0:
+            for id in list(self.tracked_objects.keys()):
+                if self.disappeared[id] >= self.max_disappeared:
+                    self.deregister(id)
+                else:
+                    self.disappeared[id] += 1
+            return
+            
+        # 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],
+                'region': obj[3]
+            })
+        
+        # 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, frame_time, 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
+            else:
+                for col in unusedCols:
+                    self.register(col, frame_time, group[col])
+
+def main():
+    frames = 0
+    # frame_queue = queue.Queue(maxsize=5)
+    # frame_cache = {}
+    # frame_shape = (1080,1920,3)
+    frame_shape = (720,1280,3)
+    frame_size = frame_shape[0]*frame_shape[1]*frame_shape[2]
+    frame = np.zeros(frame_shape, np.uint8)
+    motion_detector = MotionDetector(frame_shape, resize_factor=4)
+    object_detector = ObjectDetector('/lab/mobilenet_ssd_v2_coco_quant_postprocess_edgetpu.tflite', '/lab/labelmap.txt')
+    # object_detector = ObjectDetector('/lab/detect.tflite', '/lab/labelmap.txt')
+    object_tracker = ObjectTracker(10)
+
+    # f = open('/debug/input/back.rgb24', 'rb')
+    f = open('/debug/back.raw_video', 'rb')
+    # f = open('/debug/ali-jake.raw_video', 'rb')
+
+    total_detections = 0
+    start = datetime.datetime.now().timestamp()
+    while True:
+        frame_detections = 0
+        frame_bytes = f.read(frame_size)
+        if not frame_bytes:
+            break
+        frame_time = datetime.datetime.now().timestamp()
+        
+        # Store frame in numpy array
+        frame[:] = (np
+                    .frombuffer(frame_bytes, np.uint8)
+                    .reshape(frame_shape))
+        frames += 1
+        
+        # look for motion
+        motion_boxes = motion_detector.detect(frame)
+
+        tracked_objects = object_tracker.tracked_objects.values()
+
+        # merge areas of motion that intersect with a known tracked object into a single area to look at
+        areas_of_interest = []
+        used_motion_boxes = []
+        for obj in tracked_objects:
+            x_min, y_min, x_max, y_max = obj['box']
+            for m_index, motion_box in enumerate(motion_boxes):
+                if area(intersection(obj['box'], motion_box))/area(motion_box) > .5:
+                    used_motion_boxes.append(m_index)
+                    x_min = min(obj['box'][0], motion_box[0])
+                    y_min = min(obj['box'][1], motion_box[1])
+                    x_max = max(obj['box'][2], motion_box[2])
+                    y_max = max(obj['box'][3], motion_box[3])
+            areas_of_interest.append((x_min, y_min, x_max, y_max))
+        unused_motion_boxes = set(range(0, len(motion_boxes))).difference(used_motion_boxes)
+        
+        # compute motion regions
+        motion_regions = [calculate_region(frame_shape, motion_boxes[i][0], motion_boxes[i][1], motion_boxes[i][2], motion_boxes[i][3], 1.2)
+            for i in unused_motion_boxes]
+        
+        # compute tracked object regions
+        object_regions = [calculate_region(frame_shape, a[0], a[1], a[2], a[3], 1.2)
+            for a in areas_of_interest]
+        
+        # merge regions with high IOU
+        merged_regions = motion_regions+object_regions
+        while True:
+            max_iou = 0.0
+            max_indices = None
+            region_indices = range(len(merged_regions))
+            for a, b in itertools.combinations(region_indices, 2):
+                iou = intersection_over_union(merged_regions[a], merged_regions[b])
+                if iou > max_iou:
+                    max_iou = iou
+                    max_indices = (a, b)
+            if max_iou > 0.1:
+                a = merged_regions[max_indices[0]]
+                b = merged_regions[max_indices[1]]
+                merged_regions.append(calculate_region(frame_shape,
+                    min(a[0], b[0]),
+                    min(a[1], b[1]),
+                    max(a[2], b[2]),
+                    max(a[3], b[3]),
+                    1
+                ))
+                del merged_regions[max(max_indices[0], max_indices[1])]
+                del merged_regions[min(max_indices[0], max_indices[1])]
+            else:
+                break
+
+        # resize regions and detect
+        detections = []
+        for region in merged_regions:
+
+            tensor_input = create_tensor_input(frame, region)
+
+            region_detections = object_detector.detect(tensor_input)
+            frame_detections += 1
+
+            for d in region_detections:
+                if filtered(d):
+                    continue
+                box = d[2]
+                size = region[2]-region[0]
+                x_min = int((box[1] * size) + region[0])
+                y_min = int((box[0] * size) + region[1])
+                x_max = int((box[3] * size) + region[0])
+                y_max = int((box[2] * size) + region[1])
+                detections.append((
+                    d[0],
+                    d[1],
+                    (x_min, y_min, x_max, y_max),
+                    region))
+
+        #########
+        # merge objects, check for clipped objects and look again up to N times
+        #########
+        refining = True
+        refine_count = 0
+        while refining and refine_count < 4:
+            refining = False
+
+            # group by name
+            detected_object_groups = defaultdict(lambda: [])
+            for detection in detections:
+                detected_object_groups[detection[0]].append(detection)
+
+            selected_objects = []
+            for group in detected_object_groups.values():
+
+                # apply non-maxima suppression to suppress weak, overlapping bounding boxes
+                boxes = [(o[2][0], o[2][1], o[2][2]-o[2][0], o[2][3]-o[2][1])
+                    for o in group]
+                confidences = [o[1] for o in group]
+                idxs = cv2.dnn.NMSBoxes(boxes, confidences, 0.5, 0.4)
+
+                for index in idxs:
+                    obj = group[index[0]]
+                    if clipped(obj, frame_shape): #obj['clipped']:
+                        box = obj[2]
+                        # calculate a new region that will hopefully get the entire object
+                        region = calculate_region(frame_shape, 
+                            box[0], box[1],
+                            box[2], box[3])
+                        
+                        tensor_input = create_tensor_input(frame, region)
+                        # run detection on new region
+                        refined_detections = object_detector.detect(tensor_input)
+                        frame_detections += 1
+                        for d in refined_detections:
+                            if filtered(d):
+                                continue
+                            box = d[2]
+                            size = region[2]-region[0]
+                            x_min = int((box[1] * size) + region[0])
+                            y_min = int((box[0] * size) + region[1])
+                            x_max = int((box[3] * size) + region[0])
+                            y_max = int((box[2] * size) + region[1])
+                            selected_objects.append((
+                                d[0],
+                                d[1],
+                                (x_min, y_min, x_max, y_max),
+                                region))
+
+                        refining = True
+                    else:
+                        selected_objects.append(obj)
+                
+            # set the detections list to only include top, complete objects
+            # and new detections
+            detections = selected_objects
+
+            if refining:
+                refine_count += 1
+        
+        # now that we have refined our detections, we need to track objects
+        object_tracker.match_and_update(frame_time, detections)
+
+        total_detections += frame_detections
+
+        # if (frames >= 700 and frames <= 1635) or (frames >= 2500):
+        # if (frames >= 700 and frames <= 1000):
+        # if (frames >= 0):
+        #     # row1 = cv2.hconcat([gray, cv2.convertScaleAbs(avg_frame)])
+        #     # row2 = cv2.hconcat([frameDelta, thresh])
+        #     # cv2.imwrite(f"/lab/debug/output/{frames}.jpg", cv2.vconcat([row1, row2]))
+        #     # # cv2.imwrite(f"/lab/debug/output/resized-frame-{frames}.jpg", resized_frame)
+        #     # for region in motion_regions:
+        #     #     cv2.rectangle(frame, (region[0], region[1]), (region[2], region[3]), (255,128,0), 2)
+        #     # for region in object_regions:
+        #     #     cv2.rectangle(frame, (region[0], region[1]), (region[2], region[3]), (0,128,255), 2)
+        #     for region in merged_regions:
+        #         cv2.rectangle(frame, (region[0], region[1]), (region[2], region[3]), (0,255,0), 2)
+        #     for box in motion_boxes:
+        #         cv2.rectangle(frame, (box[0], box[1]), (box[2], box[3]), (255,0,0), 2)
+        #     for detection in detections:
+        #         box = detection[2]
+        #         draw_box_with_label(frame, box[0], box[1], box[2], box[3], detection[0], f"{detection[1]*100}%")
+        #     for obj in object_tracker.tracked_objects.values():
+        #         box = obj['box']
+        #         draw_box_with_label(frame, box[0], box[1], box[2], box[3], obj['label'], obj['id'], thickness=1, color=(0,0,255), position='bl')
+        #     cv2.putText(frame, str(total_detections), (10, 10), cv2.FONT_HERSHEY_SIMPLEX, fontScale=0.5, color=(0, 0, 0), thickness=2)
+        #     cv2.putText(frame, str(frame_detections), (10, 30), cv2.FONT_HERSHEY_SIMPLEX, fontScale=0.5, color=(0, 0, 0), thickness=2)
+        #     cv2.imwrite(f"/lab/debug/output/frame-{frames}.jpg", frame)
+            # break
+
+    duration = datetime.datetime.now().timestamp()-start
+    print(f"Processed {frames} frames for {duration:.2f} seconds and {(frames/duration):.2f} FPS.")
+    print(f"Total detections: {total_detections}")
+
+if __name__ == '__main__':
+    main()