From a18b3d78dfb64141a3283ec1c408bd551b069e94 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Fri, 22 Mar 2024 13:45:16 -0700 Subject: [PATCH 1/9] returning frames as PIL images --- src/framegrab/grabber.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/framegrab/grabber.py b/src/framegrab/grabber.py index 355c97a..604fe6d 100644 --- a/src/framegrab/grabber.py +++ b/src/framegrab/grabber.py @@ -11,6 +11,7 @@ import cv2 import numpy as np +from PIL import Image import yaml from .unavailable_module import UnavailableModule @@ -316,13 +317,19 @@ def autodiscover(warmup_delay: float = 1.0) -> dict: return grabbers @abstractmethod - def grab(self) -> np.ndarray: + def _grab_impl(self) -> np.ndarray: """Read a frame from the camera, zoom and crop if called for, and then perform any camera-specific postprocessing operations. - Returns a frame. + Returns a numpy array. """ pass + def grab(self) -> Image: + """Executes the camera-specific grab implementation and return the frame as a PIL Image. + """ + frame = self._grab_impl() + return Image.fromarray(frame) + def _autogenerate_name(self) -> None: """For generating and assigning unique names for unnamed FrameGrabber objects. @@ -558,7 +565,7 @@ def __init__(self, config: dict): self.idx = idx GenericUSBFrameGrabber.indices_in_use.add(idx) - def grab(self) -> np.ndarray: + def _grab_impl(self) -> np.ndarray: # OpenCV VideoCapture buffers frames by default. It's usually not possible to turn buffering off. # Buffer can be set as low as 1, but even still, if we simply read once, we will get the buffered (stale) frame. # Assuming buffer size of 1, we need to read twice to get the current frame. @@ -708,7 +715,7 @@ def _close_connection(self): if self.capture is not None: self.capture.release() - def grab(self) -> np.ndarray: + def _grab_impl(self) -> np.ndarray: if not self.keep_connection_open: self._open_connection() try: @@ -793,7 +800,7 @@ def __init__(self, config: dict): self.camera = camera BaslerFrameGrabber.serial_numbers_in_use.add(self.config["id"]["serial_number"]) - def grab(self) -> np.ndarray: + def _grab_impl(self) -> np.ndarray: with self.camera.GrabOne(2000) as result: if result.GrabSucceeded(): # Convert the image to BGR for OpenCV @@ -884,7 +891,7 @@ def __init__(self, config: dict): # In case the serial_number wasn't provided by the user, add it to the config self.config["id"] = {"serial_number": curr_serial_number} - def grab(self) -> np.ndarray: + def _grab_impl(self) -> np.ndarray: frames = self.pipeline.wait_for_frames() # Convert color images to numpy arrays and convert from RGB to BGR @@ -977,7 +984,7 @@ def __init__(self, config: dict): # In case the serial_number wasn't provided by the user, add it to the config self.config["id"] = {"serial_number": curr_serial_number} - def grab(self) -> np.ndarray: + def _grab_impl(self) -> np.ndarray: width = self.config.get("options", {}).get("resolution", {}).get("width", 640) height = self.config.get("options", {}).get("resolution", {}).get("height", 480) From de062a0f421b17f6de07508410d2b8080fad71a1 Mon Sep 17 00:00:00 2001 From: Auto-format Bot Date: Fri, 22 Mar 2024 20:58:23 +0000 Subject: [PATCH 2/9] Automatically reformatting code with black and isort --- src/framegrab/grabber.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/framegrab/grabber.py b/src/framegrab/grabber.py index 604fe6d..d72703e 100644 --- a/src/framegrab/grabber.py +++ b/src/framegrab/grabber.py @@ -11,8 +11,8 @@ import cv2 import numpy as np -from PIL import Image import yaml +from PIL import Image from .unavailable_module import UnavailableModule @@ -325,8 +325,7 @@ def _grab_impl(self) -> np.ndarray: pass def grab(self) -> Image: - """Executes the camera-specific grab implementation and return the frame as a PIL Image. - """ + """Executes the camera-specific grab implementation and return the frame as a PIL Image.""" frame = self._grab_impl() return Image.fromarray(frame) From 13a7b197553e42020eacd35d82c6f803321882a4 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Fri, 22 Mar 2024 14:11:25 -0700 Subject: [PATCH 3/9] updating tests to use PIL images --- test/test_framegrab_with_mock_camera.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/test/test_framegrab_with_mock_camera.py b/test/test_framegrab_with_mock_camera.py index 73a9001..aa5a9d3 100644 --- a/test/test_framegrab_with_mock_camera.py +++ b/test/test_framegrab_with_mock_camera.py @@ -34,7 +34,8 @@ def test_crop_pixels(self): grabber.release() - assert frame.shape == (400, 400, 3) + assert frame.size == (400, 400) + assert frame.mode == 'RGB' def test_crop_relative(self): """Grab a frame, crop a frame in an relative manner (0 to 1), and make sure the shape is correct. @@ -63,7 +64,8 @@ def test_crop_relative(self): grabber.release() - assert frame.shape == (384, 512, 3) + assert frame.size == (512, 384) + assert frame.mode == 'RGB' def test_zoom(self): """Grab a frame, zoom a frame, and make sure the shape is correct. @@ -87,7 +89,8 @@ def test_zoom(self): grabber.release() - assert frame.shape == (240, 320, 3) + assert frame.size == (320, 240) + assert frame.mode == 'RGB' def test_attempt_create_grabber_with_invalid_input_type(self): config = { @@ -157,11 +160,10 @@ def test_attempt_create_more_grabbers_than_exist(self): # Try to connect to another grabber, this should raise an exception because there are only 3 mock cameras available try: FrameGrabber.create_grabber({'input_type': 'mock'}) - self.fail() + self.fail() # we shouldn't get here except ValueError: pass finally: - # release all the grabbers for grabber in grabbers.values(): grabber.release() From 81a5116bd3b085ef2a3b6fcd195e7a518f3ab0cb Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Fri, 22 Mar 2024 14:15:17 -0700 Subject: [PATCH 4/9] fixing a typo --- src/framegrab/grabber.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/framegrab/grabber.py b/src/framegrab/grabber.py index d72703e..eef143f 100644 --- a/src/framegrab/grabber.py +++ b/src/framegrab/grabber.py @@ -325,7 +325,7 @@ def _grab_impl(self) -> np.ndarray: pass def grab(self) -> Image: - """Executes the camera-specific grab implementation and return the frame as a PIL Image.""" + """Executes the camera-specific grab implementation and returns the frame as a PIL Image.""" frame = self._grab_impl() return Image.fromarray(frame) From 726dbf8ef67f88211154e6d321963f9b0c3f568c Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Fri, 22 Mar 2024 14:43:55 -0700 Subject: [PATCH 5/9] responding to PR feedback --- src/framegrab/grabber.py | 16 ++++----- test/test_framegrab_with_mock_camera.py | 46 +++++++++++++++++++++---- 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/src/framegrab/grabber.py b/src/framegrab/grabber.py index eef143f..45d4eb6 100644 --- a/src/framegrab/grabber.py +++ b/src/framegrab/grabber.py @@ -317,16 +317,16 @@ def autodiscover(warmup_delay: float = 1.0) -> dict: return grabbers @abstractmethod - def _grab_impl(self) -> np.ndarray: + def grab(self) -> np.ndarray: """Read a frame from the camera, zoom and crop if called for, and then perform any camera-specific postprocessing operations. Returns a numpy array. """ pass - def grab(self) -> Image: + def grabimg(self) -> Image: """Executes the camera-specific grab implementation and returns the frame as a PIL Image.""" - frame = self._grab_impl() + frame = self.grab()[:, :, ::-1] # convert from BGR to RGB, which PIL expects return Image.fromarray(frame) def _autogenerate_name(self) -> None: @@ -564,7 +564,7 @@ def __init__(self, config: dict): self.idx = idx GenericUSBFrameGrabber.indices_in_use.add(idx) - def _grab_impl(self) -> np.ndarray: + def grab(self) -> np.ndarray: # OpenCV VideoCapture buffers frames by default. It's usually not possible to turn buffering off. # Buffer can be set as low as 1, but even still, if we simply read once, we will get the buffered (stale) frame. # Assuming buffer size of 1, we need to read twice to get the current frame. @@ -714,7 +714,7 @@ def _close_connection(self): if self.capture is not None: self.capture.release() - def _grab_impl(self) -> np.ndarray: + def grab(self) -> np.ndarray: if not self.keep_connection_open: self._open_connection() try: @@ -799,7 +799,7 @@ def __init__(self, config: dict): self.camera = camera BaslerFrameGrabber.serial_numbers_in_use.add(self.config["id"]["serial_number"]) - def _grab_impl(self) -> np.ndarray: + def grab(self) -> np.ndarray: with self.camera.GrabOne(2000) as result: if result.GrabSucceeded(): # Convert the image to BGR for OpenCV @@ -890,7 +890,7 @@ def __init__(self, config: dict): # In case the serial_number wasn't provided by the user, add it to the config self.config["id"] = {"serial_number": curr_serial_number} - def _grab_impl(self) -> np.ndarray: + def grab(self) -> np.ndarray: frames = self.pipeline.wait_for_frames() # Convert color images to numpy arrays and convert from RGB to BGR @@ -983,7 +983,7 @@ def __init__(self, config: dict): # In case the serial_number wasn't provided by the user, add it to the config self.config["id"] = {"serial_number": curr_serial_number} - def _grab_impl(self) -> np.ndarray: + def grab(self) -> np.ndarray: width = self.config.get("options", {}).get("resolution", {}).get("width", 640) height = self.config.get("options", {}).get("resolution", {}).get("height", 480) diff --git a/test/test_framegrab_with_mock_camera.py b/test/test_framegrab_with_mock_camera.py index aa5a9d3..e1bd6ec 100644 --- a/test/test_framegrab_with_mock_camera.py +++ b/test/test_framegrab_with_mock_camera.py @@ -5,6 +5,8 @@ import os import unittest from framegrab.grabber import FrameGrabber, RTSPFrameGrabber +import numpy as np +from PIL import Image class TestFrameGrabWithMockCamera(unittest.TestCase): def test_crop_pixels(self): @@ -34,8 +36,7 @@ def test_crop_pixels(self): grabber.release() - assert frame.size == (400, 400) - assert frame.mode == 'RGB' + assert frame.shape == (400, 400, 3) def test_crop_relative(self): """Grab a frame, crop a frame in an relative manner (0 to 1), and make sure the shape is correct. @@ -64,8 +65,7 @@ def test_crop_relative(self): grabber.release() - assert frame.size == (512, 384) - assert frame.mode == 'RGB' + assert frame.shape == (384, 512, 3) def test_zoom(self): """Grab a frame, zoom a frame, and make sure the shape is correct. @@ -88,9 +88,8 @@ def test_zoom(self): frame = grabber.grab() grabber.release() - - assert frame.size == (320, 240) - assert frame.mode == 'RGB' + + assert frame.shape == (240, 320, 3) def test_attempt_create_grabber_with_invalid_input_type(self): config = { @@ -215,3 +214,36 @@ def test_substitute_rtsp_url_without_placeholder(self): new_config = RTSPFrameGrabber._substitute_rtsp_password(config) assert new_config == config + + def test_grab_returns_np_array(self): + """Make sure that the grab method returns a numpy array. + """ + config = { + 'input_type': 'mock', + } + + grabber = FrameGrabber.create_grabber(config) + + frame = grabber.grab() + + assert isinstance(frame, np.ndarray) + + grabber.release() + + + def test_grabimg_returns_pil_image(self): + """Make sure that the grabimg method returns a PIL Image + and that the mode is 'RGB'. + """ + config = { + 'input_type': 'mock', + } + + grabber = FrameGrabber.create_grabber(config) + + frame = grabber.grabimg() + + assert isinstance(frame, Image.Image) + assert frame.mode == 'RGB' + + grabber.release() From 6b6a62969e749dde85045e75e1d837efb495c857 Mon Sep 17 00:00:00 2001 From: Auto-format Bot Date: Fri, 22 Mar 2024 21:44:27 +0000 Subject: [PATCH 6/9] Automatically reformatting code with black and isort --- src/framegrab/grabber.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/framegrab/grabber.py b/src/framegrab/grabber.py index 45d4eb6..222da4e 100644 --- a/src/framegrab/grabber.py +++ b/src/framegrab/grabber.py @@ -326,7 +326,7 @@ def grab(self) -> np.ndarray: def grabimg(self) -> Image: """Executes the camera-specific grab implementation and returns the frame as a PIL Image.""" - frame = self.grab()[:, :, ::-1] # convert from BGR to RGB, which PIL expects + frame = self.grab()[:, :, ::-1] # convert from BGR to RGB, which PIL expects return Image.fromarray(frame) def _autogenerate_name(self) -> None: From 9e3f6edad9ae61e16d8e123633436a8276a0d76b Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Fri, 22 Mar 2024 15:49:13 -0700 Subject: [PATCH 7/9] updating docstrings --- src/framegrab/grabber.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/framegrab/grabber.py b/src/framegrab/grabber.py index 45d4eb6..56e7e1e 100644 --- a/src/framegrab/grabber.py +++ b/src/framegrab/grabber.py @@ -318,14 +318,21 @@ def autodiscover(warmup_delay: float = 1.0) -> dict: @abstractmethod def grab(self) -> np.ndarray: - """Read a frame from the camera, zoom and crop if called for, and then perform any camera-specific - postprocessing operations. + """Grabs single frame from the configured camera device, + then applies post-processing operations such as cropping or zooming based + on the grabber's configuration. + Returns a numpy array. """ pass def grabimg(self) -> Image: - """Executes the camera-specific grab implementation and returns the frame as a PIL Image.""" + """Grabs single frame from the configured camera device, + then applies post-processing operations such as cropping or zooming based + on the grabber's configuration. + + Returns a PIL image. + """ frame = self.grab()[:, :, ::-1] # convert from BGR to RGB, which PIL expects return Image.fromarray(frame) From 2d4edeee593cb217c184c7463b5e31fbad6eeb1e Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Fri, 22 Mar 2024 15:54:03 -0700 Subject: [PATCH 8/9] updating more docstrings --- src/framegrab/grabber.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/framegrab/grabber.py b/src/framegrab/grabber.py index 59ba24c..9ec33b5 100644 --- a/src/framegrab/grabber.py +++ b/src/framegrab/grabber.py @@ -318,8 +318,8 @@ def autodiscover(warmup_delay: float = 1.0) -> dict: @abstractmethod def grab(self) -> np.ndarray: - """Grabs single frame from the configured camera device, - then applies post-processing operations such as cropping or zooming based + """Grabs a single frame from the configured camera device, + then performs post-processing operations such as cropping and zooming based on the grabber's configuration. Returns a numpy array. @@ -327,7 +327,12 @@ def grab(self) -> np.ndarray: pass def grabimg(self) -> Image: - """Executes the camera-specific grab implementation and returns the frame as a PIL Image.""" + """Grabs a single frame from the configured camera device, + then performs post-processing operations such as cropping and zooming based + on the grabber's configuration. + + Returns a PIL image. + """ frame = self.grab()[:, :, ::-1] # convert from BGR to RGB, which PIL expects return Image.fromarray(frame) From 0dece0392096fac22322d6984173f6e135e59fd1 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Fri, 22 Mar 2024 16:52:34 -0700 Subject: [PATCH 9/9] some adjustments to motion detection so that it can handle PIL images --- sample_scripts/multicamera_demo.py | 24 +++++++----------------- sample_scripts/single_camera_demo.py | 15 +++------------ src/framegrab/motion.py | 7 ++++++- test/test_motdet.py | 10 ++++++++++ 4 files changed, 26 insertions(+), 30 deletions(-) diff --git a/sample_scripts/multicamera_demo.py b/sample_scripts/multicamera_demo.py index 72c22b8..80568ad 100755 --- a/sample_scripts/multicamera_demo.py +++ b/sample_scripts/multicamera_demo.py @@ -1,10 +1,11 @@ #!/usr/bin/env python3 +"""Reads framegrab configuration from a yaml file, creates FrameGrabber objects, and grabs images from each camera. +Remember to adjust sample_config.yaml according to your needs. +""" from framegrab import FrameGrabber import yaml -import cv2 -# load the configurations from yaml config_path = 'sample_config.yaml' with open(config_path, 'r') as f: configs = yaml.safe_load(f)['image_sources'] @@ -12,23 +13,12 @@ print('Loaded the following configurations from yaml:') print(configs) -# Create the grabbers grabbers = FrameGrabber.create_grabbers(configs) -while True: - # Get a frame from each camera - for camera_name, grabber in grabbers.items(): - frame = grabber.grab() - - cv2.imshow(camera_name, frame) - - key = cv2.waitKey(30) - - if key == ord('q'): - break - -cv2.destroyAllWindows() +for camera_name, grabber in grabbers.items(): + frame = grabber.grabimg() + frame.show() for grabber in grabbers.values(): grabber.release() - + \ No newline at end of file diff --git a/sample_scripts/single_camera_demo.py b/sample_scripts/single_camera_demo.py index 0f97763..82874db 100755 --- a/sample_scripts/single_camera_demo.py +++ b/sample_scripts/single_camera_demo.py @@ -1,9 +1,7 @@ #!/usr/bin/env python3 -"""Finds a single USB camera (or built-in webcam) and displays its feed in a window. -Press 'q' to quit. +"""Finds a single USB camera (or built-in webcam), grabs an image and displays the image in a window. """ -import cv2 from framegrab import FrameGrabber config = { @@ -13,15 +11,8 @@ grabber = FrameGrabber.create_grabber(config) -while True: - frame = grabber.grab() +frame = grabber.grabimg() - cv2.imshow('FrameGrab Single-Camera Demo', frame) - - key = cv2.waitKey(30) - if key == ord('q'): - break - -cv2.destroyAllWindows() +frame.show() grabber.release() diff --git a/src/framegrab/motion.py b/src/framegrab/motion.py index 9d1822a..2c06800 100644 --- a/src/framegrab/motion.py +++ b/src/framegrab/motion.py @@ -1,6 +1,8 @@ import logging +from typing import Union import numpy as np +from PIL import Image logger = logging.getLogger(__name__) @@ -36,7 +38,10 @@ def pixel_threshold(self, img: np.ndarray, threshold_val: float = None) -> bool: logger.debug(f"No motion detected: {pct_hi:.3f}% < {self.pixel_pct_threshold}%") return False - def motion_detected(self, new_img: np.ndarray) -> bool: + def motion_detected(self, new_img: Union[np.ndarray, Image.Image]) -> bool: + if isinstance(new_img, Image.Image): + new_img = np.array(new_img) + if self.unused: self.base_img = new_img self.base2 = self.base_img diff --git a/test/test_motdet.py b/test/test_motdet.py index 8085695..f10abfa 100644 --- a/test/test_motdet.py +++ b/test/test_motdet.py @@ -1,5 +1,6 @@ import unittest import numpy as np +from PIL import Image from framegrab.motion import MotionDetector class TestMotionDetector(unittest.TestCase): @@ -67,3 +68,12 @@ def test_detect_motion_with_configured_threshold(self): self.motion_detector.motion_detected(img1) # Initialize base image self.motion_detector.motion_detected(img1) # again to really reset self.assertTrue(self.motion_detector.motion_detected(img3)) + + def test_that_motet_can_take_pil_image_or_numpy_image(self): + pil_img = Image.new('RGB', (100, 100), color='red') + for _ in range(10): + self.motion_detector.motion_detected(pil_img) + + numpy_img = np.full((100, 100, 3), 255, dtype=np.uint8) + for _ in range(10): + self.motion_detector.motion_detected(numpy_img) \ No newline at end of file