Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/source/contributing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.".
Expand Down
2 changes: 2 additions & 0 deletions newsfragments/decompression-bomb-image.change
Original file line number Diff line number Diff line 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.
1 change: 1 addition & 0 deletions newsfragments/single-color-image-rating.change
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Rate an image of a single color as ``0`` rather than raising an uncaught ``ZeroDivisionError``.
1 change: 1 addition & 0 deletions spelling_private_dict.txt
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ exc
filename
foo
formdata
fp
github
greyscale
gzip
Expand Down
47 changes: 47 additions & 0 deletions src/mock_vws/_image_opening.py
Original file line number Diff line number Diff line change
@@ -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
8 changes: 4 additions & 4 deletions src/mock_vws/_query_validators/image_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.")
Expand Down
2 changes: 2 additions & 0 deletions src/mock_vws/_services_validators/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
49 changes: 44 additions & 5 deletions src/mock_vws/_services_validators/image_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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


Expand Down
7 changes: 4 additions & 3 deletions src/mock_vws/image_matchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions src/mock_vws/target.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down
12 changes: 9 additions & 3 deletions src/mock_vws/target_raters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
76 changes: 75 additions & 1 deletion tests/mock_vws/test_add_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
"""
Expand Down
Loading
Loading