From 05664b1c16f6d1c555ddf0d7156c0fb704fa7ae0 Mon Sep 17 00:00:00 2001 From: robotrapta <79607467+robotrapta@users.noreply.github.com> Date: Tue, 14 Mar 2023 15:19:18 -0700 Subject: [PATCH] Cropping (#33) * Motion detection threshold accepts floating point input. * First crack at client-side cropping. * Fixing a couple bugs. Client-side crop is working now. * Automatically reformatting code with black and isort --------- Co-authored-by: Auto-format Bot --- src/stream.py | 47 +++++++++++++++++++++++++++++++++++++++++++++++ src/test_crop.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 src/test_crop.py diff --git a/src/stream.py b/src/stream.py index 7641ed6..09c4381 100644 --- a/src/stream.py +++ b/src/stream.py @@ -13,6 +13,7 @@ -v, --verbose enable debug logs -w, --width=WIDTH resize images to w pixels wide (and scale height proportionately if not set explicitly) -y, --height=HEIGHT resize images to y pixels high (and scale width proportionately if not set explicitly) + -c, --crop=[x,y,w,h] crop image to box before resizing. x,y,w,h are fractions from 0-1. [Default:"0,0,1,1"] -m, --motion enable motion detection with pixel change threshold percentage (disabled by default) -r, --threshold=THRESHOLD detection threshold for motion detection - percent of changed pixels [default: 1] -p, --postmotion=POSTMOTION minimum number of seconds to capture for every motion detection [default: 1] @@ -28,6 +29,7 @@ from operator import truediv from queue import Empty, Queue from threading import Thread +from typing import Tuple from xmlrpc.client import Boolean import cv2 @@ -101,6 +103,42 @@ def resize_if_needed(frame, width: int, height: int): frame = cv2.resize(frame, (target_width, target_height)) +def crop_frame(frame, crop_region: Tuple[float, float, float, float]): + """Returns a cropped version of the frame.""" + (img_height, img_width, _) = frame.shape + x1 = int(img_width * crop_region[0]) + y1 = int(img_height * crop_region[1]) + x2 = x1 + int(img_width * crop_region[2]) + y2 = y1 + int(img_height * crop_region[3]) + + out = frame[y1:y2, x1:x2, :] + return out + + +def parse_crop_string(crop_string: str) -> Tuple[float, float, float, float]: + """Parses a string like "0.25,0.25,0.5,0.5" to a tuple like (0.25,0.25,0.5,0.5) + Also validates that numbers are between 0-1, and that it doesn't go off the edge. + """ + parts = crop_string.split(",") + if len(parts) != 4: + raise ValueError("Expected crop to be list of four floating point numbers.") + numbers = tuple([float(n) for n in parts]) + + for n in numbers: + if (n < 0) or (n > 1): + raise ValueError("All numbers must be between 0 and 1, showing relative position in image") + + if numbers[0] + numbers[2] > 1.0: + raise ValueError("Invalid crop: x+w is greater than 1.") + if numbers[1] + numbers[3] > 1.0: + raise ValueError("Invalid crop: y+h is greater than 1.") + + if numbers[2] * numbers[3] == 0: + raise ValueError("Width and Height must both be >0") + + return numbers + + def main(): args = docopt.docopt(__doc__) if args.get("--verbose"): @@ -121,6 +159,11 @@ def main(): except ValueError as e: raise ValueError(f"invalid height parameter: {args['--height']}") + if args.get("--crop"): + crop_region = parse_crop_string(args["--crop"]) + else: + crop_region = None + ENDPOINT = args["--endpoint"] TOKEN = args["--token"] DETECTOR = args["--detector"] @@ -203,6 +246,10 @@ def main(): now = time.time() logger.debug(f"captured a new frame after {now-start:.3f}s of size {frame.shape=} ") + if crop_region: + frame = crop_frame(frame, crop_region) + logger.debug(f"Cropped to {frame.shape=}") + if motion_detect: if m.motion_detected(frame): logger.info(f"Motion detected") diff --git a/src/test_crop.py b/src/test_crop.py new file mode 100644 index 0000000..b64bd6e --- /dev/null +++ b/src/test_crop.py @@ -0,0 +1,40 @@ +import pytest + +from stream import parse_crop_string + + +def test_good_crop(): + assert parse_crop_string("0,0,1,1") == (0, 0, 1, 1) + assert parse_crop_string("0.5,0,0.5,1") == (0.5, 0, 0.5, 1) + + with pytest.raises(ValueError): + # too short + parse_crop_string("0.5,0,0.5") + + with pytest.raises(ValueError): + # not numbers + parse_crop_string("a,b,c,d") + + with pytest.raises(ValueError): + # too big + parse_crop_string("0,0,256,250") + + with pytest.raises(ValueError): + # negative + parse_crop_string("-1,0,1,1") + + with pytest.raises(ValueError): + # Off both edges + parse_crop_string("0.5,0.5,0.6,0.6") + + with pytest.raises(ValueError): + # Off right + parse_crop_string("0.3,0,0.8,1") + + with pytest.raises(ValueError): + # Off bottom + parse_crop_string("0,0.2,0.5,0.9") + + with pytest.raises(ValueError): + # zero size + parse_crop_string("0,0,1,0")