diff --git a/pyproject.toml b/pyproject.toml index 669ed0f3..2623d6a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ packages = [ {include = "**/*.py", from = "src"}, ] readme = "README.md" -version = "0.28.0" +version = "0.29.0" [tool.poetry.dependencies] # For certifi, use ">=" instead of "^" since it upgrades its "major version" every year, not really following semver @@ -76,6 +76,7 @@ spaces_indent_inline_array = 4 trailing_comma_inline_array = true [tool.pytest.ini_options] +pythonpath = ["test"] markers = [ "skip_for_edge_endpoint", "run_only_for_edge_endpoint", diff --git a/src/groundlight/client.py b/src/groundlight/client.py index 2318cab2..edcb8771 100644 --- a/src/groundlight/client.py +++ b/src/groundlight/client.py @@ -43,7 +43,7 @@ from groundlight.binary_labels import Label, convert_internal_label_to_display from groundlight.config import API_TOKEN_MISSING_HELP_MESSAGE, API_TOKEN_VARIABLE_NAME, DISABLE_TLS_VARIABLE_NAME from groundlight.encodings import url_encode_dict -from groundlight.images import ByteStreamWrapper, parse_supported_image_types +from groundlight.images import ByteStreamWrapper, parse_supported_image_types, shrink_image_if_needed from groundlight.internalapi import ( GroundlightApiClient, NotFoundError, @@ -800,6 +800,11 @@ def submit_image_query( # noqa: PLR0913 # pylint: disable=too-many-arguments, t image_bytesio: ByteStreamWrapper = parse_supported_image_types(image) + # Match the Groundlight cloud service's ingest pipeline locally. Saves bandwidth + # and ensures Edge Endpoints, which do not run this step, see the same input + # distribution cloud-trained models were trained on. + image_bytesio = ByteStreamWrapper(data=shrink_image_if_needed(image_bytesio.read())) + params = { "detector_id": detector_id, "body": image_bytesio, diff --git a/src/groundlight/images.py b/src/groundlight/images.py index a8d2714f..d618144c 100644 --- a/src/groundlight/images.py +++ b/src/groundlight/images.py @@ -7,6 +7,15 @@ DEFAULT_JPEG_QUALITY = 95 +# The Groundlight cloud service applies the same shrink-and-encode step on +# ingest. Doing the same work client-side saves bandwidth and ensures Edge +# Endpoints, which do not run this step, see the same input distribution that +# cloud-trained models expect. Keep these constants in sync with the cloud +# service if it ever changes its defaults. +MAX_BYTES_IMAGE_SIZE = 256_000 +MAX_IMAGE_RESOLUTION_LONGSIDE = 1024 +SHRINK_JPEG_QUALITY = 85 + class ByteStreamWrapper(IOBase): """This class acts as a thin wrapper around bytes in order to @@ -78,6 +87,28 @@ def bytestream_from_pil(pil_image: Image.Image, jpeg_quality: int = DEFAULT_JPEG return ByteStreamWrapper(data=bytesio) +def shrink_image_if_needed(jpeg: bytes) -> bytes: + """Shrink an oversized JPEG to match the Groundlight cloud service's ingest pipeline. + + If the input is already at or below MAX_BYTES_IMAGE_SIZE, returns it unchanged. + Otherwise, decodes the image, scales it (BICUBIC, aspect-ratio preserved) so the + longest side is at most MAX_IMAGE_RESOLUTION_LONGSIDE, and re-encodes as JPEG. + + Already-lossy JPEGs are decoded and re-encoded, which is the same lossy step the + cloud has been doing for years; net quality reaching the ML pipeline is unchanged. + """ + if len(jpeg) <= MAX_BYTES_IMAGE_SIZE: + return jpeg + img = Image.open(BytesIO(jpeg)).convert("RGB") + if max(img.size) > MAX_IMAGE_RESOLUTION_LONGSIDE: + ratio = MAX_IMAGE_RESOLUTION_LONGSIDE / max(img.size) + new_size = (int(img.width * ratio), int(img.height * ratio)) + img = img.resize(new_size, resample=Image.Resampling.BICUBIC) + buf = BytesIO() + img.save(buf, "jpeg", quality=SHRINK_JPEG_QUALITY) + return buf.getvalue() + + def parse_supported_image_types( image: Union[str, bytes, Image.Image, BytesIO, BufferedReader, np.ndarray], jpeg_quality: int = 95, diff --git a/test/integration/test_groundlight.py b/test/integration/test_groundlight.py index c96f4ae9..63ddc84c 100644 --- a/test/integration/test_groundlight.py +++ b/test/integration/test_groundlight.py @@ -10,6 +10,7 @@ import pytest from groundlight import Groundlight from groundlight.binary_labels import VALID_DISPLAY_LABELS, Label, convert_internal_label_to_display +from groundlight.images import MAX_IMAGE_RESOLUTION_LONGSIDE from groundlight.internalapi import ApiException, NotFoundError from groundlight.optional_imports import * from groundlight.status_codes import is_user_error @@ -29,6 +30,7 @@ ) from urllib3.exceptions import ConnectTimeoutError, MaxRetryError, ReadTimeoutError from urllib3.util.retry import Retry +from utils import make_random_jpeg from test.retry_decorator import retry_on_failure @@ -368,6 +370,25 @@ def test_submit_image_query_png(gl: Groundlight, detector: Detector): assert is_valid_display_result(_image_query.result) +@retry_on_failure() +def test_submit_image_query_shrinks_oversized_image(gl: Groundlight, detector: Detector): + """Verifies the SDK shrinks oversized images client-side and the cloud stores the shrunken version. + + Detects drift between the SDK and the cloud service: if either side changes its + algorithm such that the cloud-stored dimensions differ from what the SDK produces + locally, this test fails. Does not catch the cloud service becoming more permissive + (the SDK would still shrink to a smaller image that the cloud accepts as-is); that + direction is benign and intentionally not covered. + """ + # Random noise compresses poorly, so 4000x3000 is well above the 256 KB threshold. + big = make_random_jpeg(4000, 3000) + + iq = gl.submit_image_query(detector=detector.id, image=big, human_review="NEVER") + stored = Image.open(gl.get_image(iq.id)) + # 4000x3000 scaled so longest side == 1024 preserves the 4:3 aspect ratio. + assert stored.size == (MAX_IMAGE_RESOLUTION_LONGSIDE, 768) + + @retry_on_failure() def test_submit_image_query_with_confidence_threshold(gl: Groundlight, detector: Detector): confidence_threshold = 0.5234 # Arbitrary specific value diff --git a/test/unit/test_image_submission.py b/test/unit/test_image_submission.py new file mode 100644 index 00000000..0003dba1 --- /dev/null +++ b/test/unit/test_image_submission.py @@ -0,0 +1,30 @@ +"""Tests for image handling behavior in Groundlight.submit_image_query.""" + +from io import BytesIO +from unittest import mock + +import pytest +from groundlight import Groundlight +from groundlight.images import MAX_BYTES_IMAGE_SIZE, MAX_IMAGE_RESOLUTION_LONGSIDE +from groundlight.internalapi import InternalApiError +from PIL import Image +from utils import make_random_jpeg + + +def test_submit_image_query_sends_shrunken_image(gl: Groundlight): + """Verifies that image shrinking runs in the submission path by inspecting the bytes at the HTTP layer. + + Submits an oversized image to a mocked urllib3 transport, then checks that the body + that actually went on the wire was already resized to the expected dimensions. + """ + big = make_random_jpeg(4000, 3000) + assert len(big) > MAX_BYTES_IMAGE_SIZE + + with mock.patch("urllib3.PoolManager.request") as mock_request: + mock_request.return_value.status = 500 + with pytest.raises(InternalApiError): + gl.submit_image_query(detector="det_test", image=big, wait=0) + + body = mock_request.call_args_list[0].kwargs["body"] + sent_img = Image.open(BytesIO(body)) + assert max(sent_img.size) == MAX_IMAGE_RESOLUTION_LONGSIDE diff --git a/test/unit/test_imagefuncs.py b/test/unit/test_imagefuncs.py index 835aac1a..587b2fae 100644 --- a/test/unit/test_imagefuncs.py +++ b/test/unit/test_imagefuncs.py @@ -7,6 +7,7 @@ import pytest from groundlight.images import * from groundlight.optional_imports import * +from utils import make_random_jpeg JPEG_MIN_SIZE = 500 @@ -90,6 +91,36 @@ def test_pil_support_ref(): assert img2.size == (509, 339) +def test_shrink_image_if_needed_small_returns_unchanged(): + """Images at or below the byte threshold are passed through untouched.""" + small = make_random_jpeg(200, 200) + assert len(small) <= MAX_BYTES_IMAGE_SIZE + assert shrink_image_if_needed(small) is small + + +def test_shrink_image_if_needed_oversized_dimensions_get_resized(): + """Images above the byte threshold with longest side > 1024 are downscaled.""" + # Random noise compresses poorly, so 4000x3000 easily exceeds the 256 KB threshold. + big = make_random_jpeg(4000, 3000) + assert len(big) > MAX_BYTES_IMAGE_SIZE + out = shrink_image_if_needed(big) + out_img = Image.open(BytesIO(out)) + # 4000x3000 scaled so longest side == 1024 preserves the 4:3 aspect ratio. + assert out_img.size == (1024, 768) + + +def test_shrink_image_if_needed_oversized_bytes_only_gets_reencoded(): + """Images above the byte threshold but with longest side <= 1024 are re-encoded only.""" + high_q = make_random_jpeg(1024, 768, quality=99) + assert len(high_q) > MAX_BYTES_IMAGE_SIZE + out = shrink_image_if_needed(high_q) + out_img = Image.open(BytesIO(out)) + assert out_img.size == (1024, 768) + # Bytes changed (proves re-encode happened) and got smaller (Q85 vs Q99). + assert out != high_q + assert len(out) < len(high_q) + + def test_byte_stream_wrapper(): """ Test that we can call `open` and `close` repeatedly many times on a diff --git a/test/utils.py b/test/utils.py new file mode 100644 index 00000000..4178052d --- /dev/null +++ b/test/utils.py @@ -0,0 +1,14 @@ +"""Shared utility functions for tests.""" + +import os +from io import BytesIO + +from PIL import Image + + +def make_random_jpeg(width: int, height: int, quality: int = 95) -> bytes: + """Generate a JPEG with random pixel data.""" + img = Image.frombytes("RGB", (width, height), os.urandom(width * height * 3)) + buf = BytesIO() + img.save(buf, "jpeg", quality=quality) + return buf.getvalue()