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
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
7 changes: 6 additions & 1 deletion src/groundlight/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
31 changes: 31 additions & 0 deletions src/groundlight/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're going to break our optional pillow dependency with this

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, would need to check the rest of the code, but I think we should be able to scale the image before it becomes a bytestream?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see that PIL has ever been optional. I think if it becomes optional, we could just turn off image pre-processing on the client side if PIL is not installed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As for scaling the image before it becomes a bytestream, it seems that would be possible, but only for inputs that are already PIL objects. Claude points out that for string/bytes inputs (e.g. reading a file off disk), there's no PIL object in flight, so we'd still need the post-conversion step as a fallback anyway. Two code paths for marginal gain didn't seem worth it.

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,
Expand Down
21 changes: 21 additions & 0 deletions test/integration/test_groundlight.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions test/unit/test_image_submission.py
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions test/unit/test_imagefuncs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions test/utils.py
Original file line number Diff line number Diff line change
@@ -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()
Loading