diff --git a/docs/source/contributing.rst b/docs/source/contributing.rst index a902f2af8..0ce738975 100644 --- a/docs/source/contributing.rst +++ b/docs/source/contributing.rst @@ -187,6 +187,11 @@ This is not the case. The documentation page `Vuforia Query Web API`_ states "Maximum image size: 2.1 MPixel. 512 KiB for JPEG, 2MiB for PNG". However, JPEG images up to 2MiB are accepted. +There is no documented limit on the number of pixels in an image, but ``POST /targets`` returns ``ImageTooLarge`` for an image with more than 37748736 pixels, whatever its file size, aspect ratio or color space. +An image of a single color has a tiny file size whatever its dimensions, which is how this limit is reached. +The Query API applies no such limit. +It applies only its maximum width and height of 30000 pixels. + The ``request_count`` in a database summary is always ``0``. The documentation for the target summary report says "Note: tracking_rating and ``reco_rating`` are provided only when status = success.". diff --git a/newsfragments/decompression-bomb-image.change b/newsfragments/decompression-bomb-image.change new file mode 100644 index 000000000..edb07bcd2 --- /dev/null +++ b/newsfragments/decompression-bomb-image.change @@ -0,0 +1,2 @@ +Return a response, rather than raising an uncaught ``PIL.Image.DecompressionBombError``, when an image with a small file size but a huge number of pixels is given to ``POST /targets`` or ``POST /v1/query``. +As real Vuforia does, ``POST /targets`` now returns the ``ImageTooLarge`` result code for an image with more than 37748736 pixels, and the Query API applies no pixel count limit. diff --git a/newsfragments/single-color-image-rating.change b/newsfragments/single-color-image-rating.change new file mode 100644 index 000000000..d4eff2031 --- /dev/null +++ b/newsfragments/single-color-image-rating.change @@ -0,0 +1 @@ +Rate an image of a single color as ``0`` rather than raising an uncaught ``ZeroDivisionError``. diff --git a/spelling_private_dict.txt b/spelling_private_dict.txt index f96162f5c..ef4380bc2 100644 --- a/spelling_private_dict.txt +++ b/spelling_private_dict.txt @@ -39,6 +39,7 @@ exc filename foo formdata +fp github greyscale gzip diff --git a/src/mock_vws/_image_opening.py b/src/mock_vws/_image_opening.py new file mode 100644 index 000000000..a2449b45c --- /dev/null +++ b/src/mock_vws/_image_opening.py @@ -0,0 +1,47 @@ +"""Open images without Pillow's decompression bomb protection.""" + +import contextlib +import threading +from collections.abc import Generator +from typing import IO + +from PIL import Image + +# ``Image.MAX_IMAGE_PIXELS`` is a module level setting, so it is changed for +# as short a time as possible, and only by one thread at a time. +_MAX_IMAGE_PIXELS_LOCK = threading.Lock() + + +# This is not decorated with ``@beartype`` because beartype does not accept +# an ``io.BytesIO`` for an ``IO[bytes]`` parameter, and that is what most +# callers give. +@contextlib.contextmanager +def open_image(*, fp: IO[bytes]) -> Generator[Image.Image]: + """Open an image however many pixels it has. + + Pillow raises :class:`PIL.Image.DecompressionBombError` when opening an + image with more than twice ``Image.MAX_IMAGE_PIXELS`` pixels, and a small + file can decode to many more pixels than that. + Real Vuforia returns a response for such an image rather than failing to + respond, so the mock must be able to open one. + + Pillow checks the pixel count when the image is opened, not when it is + decoded, so ``Image.MAX_IMAGE_PIXELS`` is restored before the image is + used. + + Args: + fp: A file object with the content of the image. + + Yields: + The opened image. + """ + with _MAX_IMAGE_PIXELS_LOCK: + original_max_image_pixels = Image.MAX_IMAGE_PIXELS + Image.MAX_IMAGE_PIXELS = None + try: + image = Image.open(fp=fp) + finally: + Image.MAX_IMAGE_PIXELS = original_max_image_pixels + + with image: + yield image diff --git a/src/mock_vws/_query_validators/image_validators.py b/src/mock_vws/_query_validators/image_validators.py index 8c0494c7e..aa12caa94 100644 --- a/src/mock_vws/_query_validators/image_validators.py +++ b/src/mock_vws/_query_validators/image_validators.py @@ -6,10 +6,10 @@ from email.message import EmailMessage from beartype import beartype -from PIL import Image from werkzeug.datastructures import FileStorage, MultiDict from werkzeug.formparser import MultiPartParser +from mock_vws._image_opening import open_image from mock_vws._query_validators.exceptions import ( BadImageError, ImageNotGivenError, @@ -130,7 +130,7 @@ def validate_image_dimensions( image_part = files["image"] image_value = image_part.stream.read() image_file = io.BytesIO(initial_bytes=image_value) - with Image.open(fp=image_file) as pil_image: + with open_image(fp=image_file) as pil_image: max_width = 30000 max_height = 30000 if pil_image.height <= max_height and pil_image.width <= max_width: @@ -160,7 +160,7 @@ def validate_image_format( request_body=request_body, ) image_part = files["image"] - with Image.open(fp=image_part.stream) as pil_image: + with open_image(fp=image_part.stream) as pil_image: if pil_image.format in {"PNG", "JPEG"}: return @@ -190,7 +190,7 @@ def validate_image_is_image( image_file = files["image"].stream try: - with Image.open(fp=image_file) as _: + with open_image(fp=image_file) as _: pass except OSError as exc: _LOGGER.warning(msg="The image is not an image file.") diff --git a/src/mock_vws/_services_validators/__init__.py b/src/mock_vws/_services_validators/__init__.py index d08c1d80b..bf8824e13 100644 --- a/src/mock_vws/_services_validators/__init__.py +++ b/src/mock_vws/_services_validators/__init__.py @@ -32,6 +32,7 @@ validate_image_format, validate_image_integrity, validate_image_is_image, + validate_image_pixel_count, validate_image_size, ) from .json_validators import validate_body_given, validate_json @@ -163,6 +164,7 @@ def run_services_validators( validate_image_format(request_body=request_body) validate_image_color_space(request_body=request_body) validate_image_size(request_body=request_body) + validate_image_pixel_count(request_body=request_body) validate_image_integrity(request_body=request_body) validate_name_type(request_body=request_body) diff --git a/src/mock_vws/_services_validators/image_validators.py b/src/mock_vws/_services_validators/image_validators.py index e5413b7f8..fadf0fea0 100644 --- a/src/mock_vws/_services_validators/image_validators.py +++ b/src/mock_vws/_services_validators/image_validators.py @@ -7,9 +7,9 @@ from http import HTTPStatus from beartype import beartype -from PIL import Image from mock_vws._base64_decoding import decode_base64 +from mock_vws._image_opening import open_image from mock_vws._services_validators.exceptions import ( BadImageError, FailError, @@ -40,7 +40,7 @@ def validate_image_integrity(*, request_body: bytes) -> None: decoded = decode_base64(encoded_data=image) image_file = io.BytesIO(initial_bytes=decoded) - with Image.open(fp=image_file) as pil_image: + with open_image(fp=image_file) as pil_image: try: pil_image.verify() except SyntaxError as exc: @@ -69,7 +69,7 @@ def validate_image_format(*, request_body: bytes) -> None: decoded = decode_base64(encoded_data=image) image_file = io.BytesIO(initial_bytes=decoded) - with Image.open(fp=image_file) as pil_image: + with open_image(fp=image_file) as pil_image: if pil_image.format in {"PNG", "JPEG"}: return @@ -99,7 +99,7 @@ def validate_image_color_space(*, request_body: bytes) -> None: decoded = decode_base64(encoded_data=image) image_file = io.BytesIO(initial_bytes=decoded) - with Image.open(fp=image_file) as pil_image: + with open_image(fp=image_file) as pil_image: if pil_image.mode in {"L", "RGB"}: return @@ -139,6 +139,44 @@ def validate_image_size(*, request_body: bytes) -> None: raise ImageTooLargeError +@beartype +def validate_image_pixel_count(*, request_body: bytes) -> None: + """Validate the number of pixels of the image given to a VWS endpoint. + + A small file can decode to a very large number of pixels, so this is not + covered by the file size limit. + + Args: + request_body: The body of the request. + + Raises: + ImageTooLargeError: The image is given and it has more than the + maximum number of pixels. + """ + if not request_body: + return + + request_text = request_body.decode() + image = json.loads(s=request_text).get("image") + + if image is None: + return + + decoded = decode_base64(encoded_data=image) + image_file = io.BytesIO(initial_bytes=decoded) + + # This limit is not documented. + # It was found by binary search against a real database, and it holds + # whatever the image's aspect ratio and color space are. + max_allowed_pixels = 37_748_736 + with open_image(fp=image_file) as pil_image: + if pil_image.width * pil_image.height <= max_allowed_pixels: + return + + _LOGGER.warning(msg="The image has too many pixels.") + raise ImageTooLargeError + + @beartype def validate_image_is_image(*, request_body: bytes) -> None: """Validate that the given image data is actually an image file. @@ -162,9 +200,10 @@ def validate_image_is_image(*, request_body: bytes) -> None: image_file = io.BytesIO(initial_bytes=decoded) try: - with Image.open(fp=image_file) as _: + with open_image(fp=image_file) as _: pass except OSError as exc: + _LOGGER.warning(msg="The image is not an image file.") raise BadImageError from exc diff --git a/src/mock_vws/image_matchers.py b/src/mock_vws/image_matchers.py index be6b76941..aa4b00b99 100644 --- a/src/mock_vws/image_matchers.py +++ b/src/mock_vws/image_matchers.py @@ -7,7 +7,8 @@ import cv2 import numpy as np from beartype import beartype -from PIL import Image + +from mock_vws._image_opening import open_image @runtime_checkable @@ -69,8 +70,8 @@ def __call__( first_image_file = io.BytesIO(initial_bytes=first_image_content) second_image_file = io.BytesIO(initial_bytes=second_image_content) with ( - Image.open(fp=first_image_file) as first_image, - Image.open(fp=second_image_file) as second_image, + open_image(fp=first_image_file) as first_image, + open_image(fp=second_image_file) as second_image, ): # Images must be the same size, and they must be larger than the # default SSIM window size of 11x11. diff --git a/src/mock_vws/target.py b/src/mock_vws/target.py index 069414120..556943a8a 100644 --- a/src/mock_vws/target.py +++ b/src/mock_vws/target.py @@ -10,9 +10,10 @@ from zoneinfo import ZoneInfo from beartype import BeartypeConf, beartype -from PIL import Image, ImageStat +from PIL import ImageStat from mock_vws._constants import TargetStatuses +from mock_vws._image_opening import open_image from mock_vws.target_raters import ( HardcodedTargetTrackingRater, TargetTrackingRater, @@ -96,7 +97,7 @@ def _post_processing_status(self) -> TargetStatuses: suitable the target is for detection. """ image_file = io.BytesIO(initial_bytes=self.image_value) - with Image.open(fp=image_file) as image: + with open_image(fp=image_file) as image: image_stat = ImageStat.Stat(image_or_list=image) average_std_dev = statistics.mean(data=image_stat.stddev) diff --git a/src/mock_vws/target_raters.py b/src/mock_vws/target_raters.py index 8627b4307..1d4393915 100644 --- a/src/mock_vws/target_raters.py +++ b/src/mock_vws/target_raters.py @@ -8,9 +8,10 @@ from typing import Protocol, runtime_checkable from beartype import beartype -from PIL import Image from pyteenybrisque import score +from mock_vws._image_opening import open_image + @functools.cache @beartype @@ -25,10 +26,15 @@ def _get_brisque_target_tracking_rating(*, image_content: bytes) -> int: image_content: A target's image's content. """ image_file = io.BytesIO(initial_bytes=image_content) - with Image.open(fp=image_file) as image, warnings.catch_warnings(): + with open_image(fp=image_file) as image, warnings.catch_warnings(): # Uniform images produce a zero-variance warning and non-finite score. warnings.simplefilter(action="ignore", category=RuntimeWarning) - brisque_score = score(image=image) + try: + brisque_score = score(image=image) + except ZeroDivisionError: + # An image of a single color divides by zero rather than giving a + # non-finite score. + return 0 if not math.isfinite(brisque_score): return 0 diff --git a/tests/mock_vws/test_add_target.py b/tests/mock_vws/test_add_target.py index b22ecfd56..02b848654 100644 --- a/tests/mock_vws/test_add_target.py +++ b/tests/mock_vws/test_add_target.py @@ -25,7 +25,11 @@ from vws.response import Response from mock_vws._constants import ResultCodes -from tests.mock_vws.utils import make_image_file +from tests.mock_vws.utils import ( + make_decompression_bomb_image_file, + make_image_file, + make_single_color_image_file, +) from tests.mock_vws.utils.assertions import ( assert_vws_failure, assert_vws_response, @@ -501,6 +505,76 @@ def test_corrupted( result_code=ResultCodes.BAD_IMAGE, ) + @staticmethod + def test_decompression_bomb(vws_client: VWS) -> None: + """ + An ``ImageTooLargeError`` result is returned when the given + image has a small file size but a huge number of pixels. + """ + max_bytes = 2.3 * 1024 * 1024 + image_file = make_decompression_bomb_image_file() + assert len(image_file.getvalue()) < max_bytes + + with pytest.raises(expected_exception=ImageTooLargeError) as exc: + vws_client.add_target( + name="example_name", + width=1, + image=image_file, + application_metadata=None, + active_flag=True, + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + result_code=ResultCodes.IMAGE_TOO_LARGE, + ) + + @staticmethod + def test_image_pixel_count_too_large(vws_client: VWS) -> None: + """ + An ``ImageTooLargeError`` result is returned if the image has + more than 37748736 pixels, whatever its file size. + + This limit is not documented. + """ + max_allowed_pixels = 37_748_736 + width = height = 6144 + assert width * height == max_allowed_pixels + + image_not_too_many_pixels = make_single_color_image_file( + width=width, + height=height, + ) + + vws_client.add_target( + name="example_name", + width=1, + image=image_not_too_many_pixels, + application_metadata=None, + active_flag=True, + ) + + image_too_many_pixels = make_single_color_image_file( + width=width + 1, + height=height, + ) + + with pytest.raises(expected_exception=ImageTooLargeError) as exc: + vws_client.add_target( + name="example_name_2", + width=1, + image=image_too_many_pixels, + application_metadata=None, + active_flag=True, + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + result_code=ResultCodes.IMAGE_TOO_LARGE, + ) + @staticmethod def test_image_file_size_too_large(vws_client: VWS) -> None: """ diff --git a/tests/mock_vws/test_query.py b/tests/mock_vws/test_query.py index 99d54c276..68eef4810 100644 --- a/tests/mock_vws/test_query.py +++ b/tests/mock_vws/test_query.py @@ -40,7 +40,10 @@ from mock_vws.database import CloudDatabase from tests.mock_vws.fixtures.vuforia_backends import VuforiaBackend -from tests.mock_vws.utils import make_image_file +from tests.mock_vws.utils import ( + make_decompression_bomb_image_file, + make_image_file, +) from tests.mock_vws.utils.assertions import ( assert_query_success, assert_valid_transaction_id, @@ -1776,6 +1779,24 @@ def test_max_pixels(cloud_reco_client: CloudRecoService) -> None: result = cloud_reco_client.query(image=png_not_too_wide) assert result == [] + @staticmethod + def test_small_file_many_pixels( + cloud_reco_client: CloudRecoService, + ) -> None: + """ + No error is returned for an image with a small file size and a + huge number of pixels. + + Unlike ``POST /targets``, the Query API has no limit on the + number of pixels, only on the width and the height. + """ + max_bytes = 2 * 1024 * 1024 + image_file = make_decompression_bomb_image_file() + assert len(image_file.getvalue()) < max_bytes + + result = cloud_reco_client.query(image=image_file) + assert result == [] + @pytest.mark.usefixtures("verify_mock_vuforia") class TestImageFormats: diff --git a/tests/mock_vws/utils/__init__.py b/tests/mock_vws/utils/__init__.py index 5f48d0664..296692ecd 100644 --- a/tests/mock_vws/utils/__init__.py +++ b/tests/mock_vws/utils/__init__.py @@ -120,3 +120,42 @@ def make_image_file( image.save(fp=image_buffer, format=file_format) image_buffer.seek(0) return image_buffer + + +@beartype +def make_single_color_image_file(*, width: int, height: int) -> io.BytesIO: + """Return a greyscale PNG file of one color. + + A single color image compresses to a tiny file whatever its dimensions, so + this is a way to make an image with many pixels but a small file size. + + Args: + width: The width, in pixels, of the image. + height: The height, in pixels, of the image. + + Returns: + A greyscale PNG file of one color. + """ + image_buffer = io.BytesIO() + image = Image.new(mode="L", size=(width, height)) + image.save(fp=image_buffer, format="PNG") + image_buffer.seek(0) + return image_buffer + + +@beartype +def make_decompression_bomb_image_file() -> io.BytesIO: + """Return a PNG file which is tiny on disk but huge when decoded. + + The dimensions are within the maximum width and height accepted by the + Query API, and the file is well within the maximum file size, but the + pixel count is above the point at which Pillow refuses to open an image. + + Returns: + A PNG file which is a decompression bomb. + """ + # Pillow raises ``Image.DecompressionBombError`` for images with more than + # twice ``Image.MAX_IMAGE_PIXELS`` pixels, which is 178956970 pixels by + # default. + width = height = 15_000 + return make_single_color_image_file(width=width, height=height)