From 1b2ea1009e7d88fbf1dd14580caa7d7b1143c6d9 Mon Sep 17 00:00:00 2001 From: williamjhyland Date: Fri, 12 Dec 2025 12:08:57 -0500 Subject: [PATCH 1/4] Add optional cached-frame motion detection via cache_image flag --- src/motion_detector.py | 70 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/src/motion_detector.py b/src/motion_detector.py index f90ecf8..5e3c1f5 100644 --- a/src/motion_detector.py +++ b/src/motion_detector.py @@ -40,6 +40,10 @@ class MotionDetector(Vision, Reconfigurable): def __init__(self, name: str): super().__init__(name=name) + # Cached grayscale frame from the previous call. + # This starts as None to explicitly represent "NO IMAGE YET". + self._last_gray = None + # Constructor @classmethod def new_service( @@ -160,6 +164,19 @@ def reconfigure( else: self.crop_region = None + # If cache_image is true, this service will perform temporal differencing + # against the previously-seen frame instead of grabbing two frames per call. + # + # If the attribute is missing or false, we fall back to the original behavior. + cache_image_flag = config.attributes.fields.get("cache_image") + self.cache_image = bool(cache_image_flag.bool_value) if cache_image_flag else False + + # Reconfiguration invalidates the cached frame: + # - camera may have changed + # - crop region may have changed + # - sensitivity may have changed + self._last_gray = None + # This will be the main method implemented in this module. # Given a camera. Perform frame differencing and return how much of the image is moving async def get_classifications( @@ -171,6 +188,28 @@ async def get_classifications( timeout: Optional[float] = None, **kwargs, ) -> List[Classification]: + # If caching is enabled, only grab a single image and compare it + # to the previously-cached frame. + if self.cache_image: + input_img = await self.camera.get_image(mime_type=CameraMimeType.JPEG) + img = pil.viam_to_pil_image(input_img) + img, _, _ = self.crop_image(img) + gray = cv2.cvtColor(np.array(img), cv2.COLOR_BGR2GRAY) + + # If this is the first frame we have ever seen, there is no + # valid comparison to make yet. + if self._last_gray is None: + LOGGER.debug("No previous frame available; skipping motion detection classification") + self._last_gray = gray + return [] + + # Perform motion detection by differencing the previous frame + # against the current frame. + result = self.classification_from_gray_imgs(self._last_gray, gray) + # Update the cache so the next call compares against this frame. + self._last_gray = gray + return result + # Grab and grayscale 2 images images, _ = await self.camera.get_images() if images is None or len(images) == 0: @@ -227,6 +266,37 @@ async def get_detections( timeout: Optional[float] = None, **kwargs, ) -> List[Detection]: + # If caching is enabled, only grab a single image and compare it + # to the previously-cached frame. + if self.cache_image: + input_img = await self.camera.get_image(mime_type=CameraMimeType.JPEG) + if input_img.mime_type not in [CameraMimeType.JPEG, CameraMimeType.PNG]: + raise ValueError( + "image mime type must be PNG or JPEG, not ", input_img.mime_type + ) + + img = pil.viam_to_pil_image(input_img) + img, width, height = self.crop_image(img) + gray = cv2.cvtColor(np.array(img), cv2.COLOR_BGR2GRAY) + + # If this is the first frame we have ever seen, there is no + # valid comparison to make yet. + if self._last_gray is None: + LOGGER.debug("No previous frame available; skipping motion detection detections") + self._last_gray = gray + return [] + + # Perform detection using frame differencing against + # the cached previous frame. + detections = self.detections_from_gray_imgs( + self._last_gray, gray, width, height + ) + + # Update the cache so the next call compares against this frame. + self._last_gray = gray + return detections + + # Grab and grayscale 2 images images, _ = await self.camera.get_images() if images is None or len(images) == 0: From 4279952cedd631be334aab60b1fb79b150c0a670 Mon Sep 17 00:00:00 2001 From: Bill Hyland Date: Tue, 30 Dec 2025 15:44:51 -0500 Subject: [PATCH 2/4] logging for detections --- src/motion_detector.py | 50 +++++++++++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/src/motion_detector.py b/src/motion_detector.py index 5e3c1f5..9656a74 100644 --- a/src/motion_detector.py +++ b/src/motion_detector.py @@ -18,8 +18,6 @@ from viam.services.vision import CaptureAllResult, Vision from viam.utils import ValueTypes -LOGGER = getLogger("MotionDetectorLogger") - class MotionDetector(Vision, Reconfigurable): """ @@ -39,6 +37,7 @@ class MotionDetector(Vision, Reconfigurable): def __init__(self, name: str): super().__init__(name=name) + self.logger = getLogger(__name__) # Cached grayscale frame from the previous call. # This starts as None to explicitly represent "NO IMAGE YET". @@ -176,6 +175,20 @@ def reconfigure( # - crop region may have changed # - sensitivity may have changed self._last_gray = None + self.logger.debug( + "MotionDetector reconfigured", + extra={ + "camera_name": self.cam_name, + "sensitivity": self.sensitivity, + "cache_image": self.cache_image, + "min_box_size": self.min_box_size, + "min_box_percent": self.min_box_percent, + "max_box_size": self.max_box_size, + "max_box_percent": self.max_box_percent, + "crop_region": self.crop_region, + }, + ) + # This will be the main method implemented in this module. # Given a camera. Perform frame differencing and return how much of the image is moving @@ -191,7 +204,12 @@ async def get_classifications( # If caching is enabled, only grab a single image and compare it # to the previously-cached frame. if self.cache_image: - input_img = await self.camera.get_image(mime_type=CameraMimeType.JPEG) + self.logger.debug("cache_image=True → grabbing SINGLE frame (Classification)") + images, meta = await self.camera.get_images() + if not images: + raise ValueError("No images returned by get_images") + input_img = images[0] + img = pil.viam_to_pil_image(input_img) img, _, _ = self.crop_image(img) gray = cv2.cvtColor(np.array(img), cv2.COLOR_BGR2GRAY) @@ -199,7 +217,7 @@ async def get_classifications( # If this is the first frame we have ever seen, there is no # valid comparison to make yet. if self._last_gray is None: - LOGGER.debug("No previous frame available; skipping motion detection classification") + self.logger.debug("No previous frame available; skipping motion detection classification") self._last_gray = gray return [] @@ -211,10 +229,11 @@ async def get_classifications( return result # Grab and grayscale 2 images - images, _ = await self.camera.get_images() - if images is None or len(images) == 0: + self.logger.debug("cache_image=False → grabbing FIRST frame") + images1, meta1 = await self.camera.get_images() + if not images1: raise ValueError("No images returned by get_images") - input1 = images[0] + input1 = images1[0] if input1.mime_type not in [CameraMimeType.JPEG, CameraMimeType.PNG]: raise ValueError( "image mime type must be PNG or JPEG, not ", input1.mime_type @@ -223,10 +242,11 @@ async def get_classifications( img1, _, _ = self.crop_image(img1) gray1 = cv2.cvtColor(np.array(img1), cv2.COLOR_BGR2GRAY) - camera_images, _ = await self.camera.get_images() - if camera_images is None or len(camera_images) == 0: - raise ValueError("No images were returned by get_images") - input2 = camera_images[0] + self.logger.debug("cache_image=False → grabbing SECOND frame") + images2, meta2 = await self.camera.get_images() + if not images2: + raise ValueError("No images returned by get_images") + input2 = images2[0] if input2.mime_type not in [CameraMimeType.JPEG, CameraMimeType.PNG]: raise ValueError( "image mime type must be PNG or JPEG, not ", input2.mime_type @@ -269,7 +289,11 @@ async def get_detections( # If caching is enabled, only grab a single image and compare it # to the previously-cached frame. if self.cache_image: - input_img = await self.camera.get_image(mime_type=CameraMimeType.JPEG) + self.logger.debug("cache_image=True → grabbing SINGLE frame (Detection)") + images, meta = await self.camera.get_images() + if not images: + raise ValueError("No images returned by get_images") + input_img = images[0] if input_img.mime_type not in [CameraMimeType.JPEG, CameraMimeType.PNG]: raise ValueError( "image mime type must be PNG or JPEG, not ", input_img.mime_type @@ -282,7 +306,7 @@ async def get_detections( # If this is the first frame we have ever seen, there is no # valid comparison to make yet. if self._last_gray is None: - LOGGER.debug("No previous frame available; skipping motion detection detections") + self.logger.debug("No previous frame available; skipping motion detection detections") self._last_gray = gray return [] From 90b0ebc7927c6242d363c8540f2336c4db1b21ec Mon Sep 17 00:00:00 2001 From: Bill Hyland <62662886+williamjhyland@users.noreply.github.com> Date: Mon, 5 Jan 2026 12:20:05 -0500 Subject: [PATCH 3/4] Fix lint issues after get_images migration --- src/motion_detector.py | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/motion_detector.py b/src/motion_detector.py index 9656a74..2f0e9ef 100644 --- a/src/motion_detector.py +++ b/src/motion_detector.py @@ -204,8 +204,10 @@ async def get_classifications( # If caching is enabled, only grab a single image and compare it # to the previously-cached frame. if self.cache_image: - self.logger.debug("cache_image=True → grabbing SINGLE frame (Classification)") - images, meta = await self.camera.get_images() + self.logger.debug( + "cache_image=True → grabbing SINGLE frame (Classification)" + ) + images, _ = await self.camera.get_images() if not images: raise ValueError("No images returned by get_images") input_img = images[0] @@ -217,20 +219,22 @@ async def get_classifications( # If this is the first frame we have ever seen, there is no # valid comparison to make yet. if self._last_gray is None: - self.logger.debug("No previous frame available; skipping motion detection classification") + self.logger.debug( + "No previous frame available; skipping motion detection classification" + ) self._last_gray = gray return [] - + # Perform motion detection by differencing the previous frame # against the current frame. result = self.classification_from_gray_imgs(self._last_gray, gray) # Update the cache so the next call compares against this frame. self._last_gray = gray return result - + # Grab and grayscale 2 images self.logger.debug("cache_image=False → grabbing FIRST frame") - images1, meta1 = await self.camera.get_images() + images1, _ = await self.camera.get_images() if not images1: raise ValueError("No images returned by get_images") input1 = images1[0] @@ -243,7 +247,7 @@ async def get_classifications( gray1 = cv2.cvtColor(np.array(img1), cv2.COLOR_BGR2GRAY) self.logger.debug("cache_image=False → grabbing SECOND frame") - images2, meta2 = await self.camera.get_images() + images2, _ = await self.camera.get_images() if not images2: raise ValueError("No images returned by get_images") input2 = images2[0] @@ -289,16 +293,19 @@ async def get_detections( # If caching is enabled, only grab a single image and compare it # to the previously-cached frame. if self.cache_image: - self.logger.debug("cache_image=True → grabbing SINGLE frame (Detection)") - images, meta = await self.camera.get_images() + self.logger.debug( + "cache_image=True → grabbing SINGLE frame (Detection)" + ) + images, _ = await self.camera.get_images() if not images: raise ValueError("No images returned by get_images") input_img = images[0] if input_img.mime_type not in [CameraMimeType.JPEG, CameraMimeType.PNG]: raise ValueError( - "image mime type must be PNG or JPEG, not ", input_img.mime_type + f"image mime type must be PNG or JPEG, not {input_img.mime_type}" ) + img = pil.viam_to_pil_image(input_img) img, width, height = self.crop_image(img) gray = cv2.cvtColor(np.array(img), cv2.COLOR_BGR2GRAY) @@ -306,7 +313,9 @@ async def get_detections( # If this is the first frame we have ever seen, there is no # valid comparison to make yet. if self._last_gray is None: - self.logger.debug("No previous frame available; skipping motion detection detections") + self.logger.debug( + "No previous frame available; skipping motion detection detections" + ) self._last_gray = gray return [] From 24e89eb3f6f63a970f991e3f612f7031e1c52811 Mon Sep 17 00:00:00 2001 From: Bill Hyland <62662886+williamjhyland@users.noreply.github.com> Date: Mon, 5 Jan 2026 12:23:17 -0500 Subject: [PATCH 4/4] Initialize cache_image default in constructor --- src/motion_detector.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/motion_detector.py b/src/motion_detector.py index 2f0e9ef..df64664 100644 --- a/src/motion_detector.py +++ b/src/motion_detector.py @@ -40,8 +40,9 @@ def __init__(self, name: str): self.logger = getLogger(__name__) # Cached grayscale frame from the previous call. - # This starts as None to explicitly represent "NO IMAGE YET". self._last_gray = None + # Default behavior: preserve legacy behavior unless configured otherwise + self.cache_image = False # Constructor @classmethod