From dc851b9dd50127ae2cc8559c4313f161838151be Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Aug 2021 03:44:12 -0500 Subject: [PATCH] Ensure camera scaling always produces an image of at least the requested width and height (#55033) --- homeassistant/components/camera/img_util.py | 39 ++++++++++++----- tests/components/camera/test_img_util.py | 46 +++++++++++++++++++++ 2 files changed, 74 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/camera/img_util.py b/homeassistant/components/camera/img_util.py index 279bc57672a..3aadc5c454c 100644 --- a/homeassistant/components/camera/img_util.py +++ b/homeassistant/components/camera/img_util.py @@ -1,4 +1,5 @@ """Image processing for cameras.""" +from __future__ import annotations import logging from typing import TYPE_CHECKING, cast @@ -15,7 +16,28 @@ if TYPE_CHECKING: from . import Image -def scale_jpeg_camera_image(cam_image: "Image", width: int, height: int) -> bytes: +def find_supported_scaling_factor( + current_width: int, current_height: int, target_width: int, target_height: int +) -> tuple[int, int] | None: + """Find a supported scaling factor to scale the image. + + If there is no exact match, we use one size up to ensure + the image remains crisp. + """ + for idx, supported_sf in enumerate(SUPPORTED_SCALING_FACTORS): + ratio = supported_sf[0] / supported_sf[1] + width_after_scale = current_width * ratio + height_after_scale = current_height * ratio + if width_after_scale == target_width and height_after_scale == target_height: + return supported_sf + if width_after_scale < target_width or height_after_scale < target_height: + return None if idx == 0 else SUPPORTED_SCALING_FACTORS[idx - 1] + + # Giant image, the most we can reduce by is 1/8 + return SUPPORTED_SCALING_FACTORS[-1] + + +def scale_jpeg_camera_image(cam_image: Image, width: int, height: int) -> bytes: """Scale a camera image as close as possible to one of the supported scaling factors.""" turbo_jpeg = TurboJPEGSingleton.instance() if not turbo_jpeg: @@ -28,17 +50,12 @@ def scale_jpeg_camera_image(cam_image: "Image", width: int, height: int) -> byte except OSError: return cam_image.content - if current_width <= width or current_height <= height: + scaling_factor = find_supported_scaling_factor( + current_width, current_height, width, height + ) + if scaling_factor is None: return cam_image.content - ratio = width / current_width - - scaling_factor = SUPPORTED_SCALING_FACTORS[-1] - for supported_sf in SUPPORTED_SCALING_FACTORS: - if ratio >= (supported_sf[0] / supported_sf[1]): - scaling_factor = supported_sf - break - return cast( bytes, turbo_jpeg.scale_with_quality( @@ -61,7 +78,7 @@ class TurboJPEGSingleton: __instance = None @staticmethod - def instance() -> "TurboJPEG": + def instance() -> TurboJPEG: """Singleton for TurboJPEG.""" if TurboJPEGSingleton.__instance is None: TurboJPEGSingleton() diff --git a/tests/components/camera/test_img_util.py b/tests/components/camera/test_img_util.py index 4f32715800e..35670b8f8d6 100644 --- a/tests/components/camera/test_img_util.py +++ b/tests/components/camera/test_img_util.py @@ -1,11 +1,13 @@ """Test img_util module.""" from unittest.mock import patch +import pytest from turbojpeg import TurboJPEG from homeassistant.components.camera import Image from homeassistant.components.camera.img_util import ( TurboJPEGSingleton, + find_supported_scaling_factor, scale_jpeg_camera_image, ) @@ -59,6 +61,15 @@ def test_scale_jpeg_camera_image(): assert jpeg_bytes == EMPTY_8_6_JPEG + turbo_jpeg = mock_turbo_jpeg( + first_width=640, first_height=480, second_width=640, second_height=480 + ) + with patch("turbojpeg.TurboJPEG", return_value=turbo_jpeg): + TurboJPEGSingleton() + jpeg_bytes = scale_jpeg_camera_image(camera_image, 320, 480) + + assert jpeg_bytes == EMPTY_16_12_JPEG + def test_turbojpeg_load_failure(): """Handle libjpegturbo not being installed.""" @@ -70,3 +81,38 @@ def test_turbojpeg_load_failure(): _clear_turbojpeg_singleton() TurboJPEGSingleton() assert TurboJPEGSingleton.instance() is not None + + +SCALE_TEST_EXPECTED = [ + (5782, 3946, 640, 480, (1, 8)), # Maximum scale + (1600, 1200, 640, 480, (1, 2)), # Equal scale for width and height + (1600, 1200, 1400, 1050, (7, 8)), # Equal scale for width and height + (1600, 1200, 1200, 900, (3, 4)), # Equal scale for width and height + (1600, 1200, 1000, 750, (5, 8)), # Equal scale for width and height + (1600, 1200, 600, 450, (3, 8)), # Equal scale for width and height + (1600, 1200, 400, 300, (1, 4)), # Equal scale for width and height + (1600, 1200, 401, 300, (3, 8)), # Width is just a little to big, next size up + (640, 480, 330, 200, (5, 8)), # Preserve width clarity + (640, 480, 300, 260, (5, 8)), # Preserve height clarity + (640, 480, 1200, 480, None), # Request larger width - no scaling + (640, 480, 640, 480, None), # Request same - no scaling + (640, 480, 640, 270, None), # Request smaller height - no scaling + (640, 480, 320, 480, None), # Request smaller width - no scaling +] + + +@pytest.mark.parametrize( + "image_width, image_height, input_width, input_height, scaling_factor", + SCALE_TEST_EXPECTED, +) +def test_find_supported_scaling_factor( + image_width, image_height, input_width, input_height, scaling_factor +): + """Test we always get an image of at least the size we ask if its big enough.""" + + assert ( + find_supported_scaling_factor( + image_width, image_height, input_width, input_height + ) + == scaling_factor + )