Skip to content

Commit 995e6da

Browse files
timmarkhuffTim HuffAuto-format Bot
authored
Shrink oversized images client-side before submission (#435)
## Summary The Groundlight cloud service downscales and re-encodes oversized images on ingest. This PR copies that same step to the client, running it before bytes leave the machine. Two benefits: - **Edge Endpoint quality**: edge ML models were trained on cloud-processed images. Submitting large images directly to an Edge Endpoint skips that step, causing a distribution shift that can hurt confidence. - **Bandwidth**: oversized images are shrunk before transmission to either the cloud or edge. The cloud service's own shrink path remains in place as a safety net for direct API calls and other-language clients. ## Trade-offs - **Code duplication**: the algorithm now exists in both this SDK and the cloud service. We accept this; the alternative (a shared library) is premature for one small function. A note in the source points at the cloud service as the canonical owner of the defaults. - **Per-detector overrides invisible to the SDK**: the cloud service allows per-detector limits looser than the defaults. Users with such overrides will have the SDK shrink more aggressively than necessary. This is an acceptable limitation for v1; an opt-out can be added if a real need surfaces. - **Small CPU cost on the client**: negligible for images that are already small (fast path exits immediately on byte length check). For large images, the savings in transmission time outweigh it. ## Tests - **Algorithm unit tests**: lock the shrink logic itself against accidental changes, covering all three cases (below threshold, resize, re-encode only). - **Wiring unit test**: mocks the urllib3 transport layer and inspects the raw request body, asserting the image is already shrunk before it goes on the wire. Guards against the shrink step being accidentally removed from the submission path (the cloud service's own fallback means the integration test below would still pass without this guard). - **Integration test**: submits a known oversized image to a real detector, fetches it back, and asserts the stored dimensions match what the SDK produces locally. Catches the cloud service diverging from the SDK's algorithm. --------- Co-authored-by: Tim Huff <thuff@axon.com> Co-authored-by: Auto-format Bot <autoformatbot@groundlight.ai>
1 parent e6178a8 commit 995e6da

7 files changed

Lines changed: 135 additions & 2 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ packages = [
99
{include = "**/*.py", from = "src"},
1010
]
1111
readme = "README.md"
12-
version = "0.28.0"
12+
version = "0.29.0"
1313

1414
[tool.poetry.dependencies]
1515
# 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
7676
trailing_comma_inline_array = true
7777

7878
[tool.pytest.ini_options]
79+
pythonpath = ["test"]
7980
markers = [
8081
"skip_for_edge_endpoint",
8182
"run_only_for_edge_endpoint",

src/groundlight/client.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
from groundlight.binary_labels import Label, convert_internal_label_to_display
4444
from groundlight.config import API_TOKEN_MISSING_HELP_MESSAGE, API_TOKEN_VARIABLE_NAME, DISABLE_TLS_VARIABLE_NAME
4545
from groundlight.encodings import url_encode_dict
46-
from groundlight.images import ByteStreamWrapper, parse_supported_image_types
46+
from groundlight.images import ByteStreamWrapper, parse_supported_image_types, shrink_image_if_needed
4747
from groundlight.internalapi import (
4848
GroundlightApiClient,
4949
NotFoundError,
@@ -800,6 +800,11 @@ def submit_image_query( # noqa: PLR0913 # pylint: disable=too-many-arguments, t
800800

801801
image_bytesio: ByteStreamWrapper = parse_supported_image_types(image)
802802

803+
# Match the Groundlight cloud service's ingest pipeline locally. Saves bandwidth
804+
# and ensures Edge Endpoints, which do not run this step, see the same input
805+
# distribution cloud-trained models were trained on.
806+
image_bytesio = ByteStreamWrapper(data=shrink_image_if_needed(image_bytesio.read()))
807+
803808
params = {
804809
"detector_id": detector_id,
805810
"body": image_bytesio,

src/groundlight/images.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@
77

88
DEFAULT_JPEG_QUALITY = 95
99

10+
# The Groundlight cloud service applies the same shrink-and-encode step on
11+
# ingest. Doing the same work client-side saves bandwidth and ensures Edge
12+
# Endpoints, which do not run this step, see the same input distribution that
13+
# cloud-trained models expect. Keep these constants in sync with the cloud
14+
# service if it ever changes its defaults.
15+
MAX_BYTES_IMAGE_SIZE = 256_000
16+
MAX_IMAGE_RESOLUTION_LONGSIDE = 1024
17+
SHRINK_JPEG_QUALITY = 85
18+
1019

1120
class ByteStreamWrapper(IOBase):
1221
"""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
7887
return ByteStreamWrapper(data=bytesio)
7988

8089

90+
def shrink_image_if_needed(jpeg: bytes) -> bytes:
91+
"""Shrink an oversized JPEG to match the Groundlight cloud service's ingest pipeline.
92+
93+
If the input is already at or below MAX_BYTES_IMAGE_SIZE, returns it unchanged.
94+
Otherwise, decodes the image, scales it (BICUBIC, aspect-ratio preserved) so the
95+
longest side is at most MAX_IMAGE_RESOLUTION_LONGSIDE, and re-encodes as JPEG.
96+
97+
Already-lossy JPEGs are decoded and re-encoded, which is the same lossy step the
98+
cloud has been doing for years; net quality reaching the ML pipeline is unchanged.
99+
"""
100+
if len(jpeg) <= MAX_BYTES_IMAGE_SIZE:
101+
return jpeg
102+
img = Image.open(BytesIO(jpeg)).convert("RGB")
103+
if max(img.size) > MAX_IMAGE_RESOLUTION_LONGSIDE:
104+
ratio = MAX_IMAGE_RESOLUTION_LONGSIDE / max(img.size)
105+
new_size = (int(img.width * ratio), int(img.height * ratio))
106+
img = img.resize(new_size, resample=Image.Resampling.BICUBIC)
107+
buf = BytesIO()
108+
img.save(buf, "jpeg", quality=SHRINK_JPEG_QUALITY)
109+
return buf.getvalue()
110+
111+
81112
def parse_supported_image_types(
82113
image: Union[str, bytes, Image.Image, BytesIO, BufferedReader, np.ndarray],
83114
jpeg_quality: int = 95,

test/integration/test_groundlight.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import pytest
1111
from groundlight import Groundlight
1212
from groundlight.binary_labels import VALID_DISPLAY_LABELS, Label, convert_internal_label_to_display
13+
from groundlight.images import MAX_IMAGE_RESOLUTION_LONGSIDE
1314
from groundlight.internalapi import ApiException, NotFoundError
1415
from groundlight.optional_imports import *
1516
from groundlight.status_codes import is_user_error
@@ -29,6 +30,7 @@
2930
)
3031
from urllib3.exceptions import ConnectTimeoutError, MaxRetryError, ReadTimeoutError
3132
from urllib3.util.retry import Retry
33+
from utils import make_random_jpeg
3234

3335
from test.retry_decorator import retry_on_failure
3436

@@ -368,6 +370,25 @@ def test_submit_image_query_png(gl: Groundlight, detector: Detector):
368370
assert is_valid_display_result(_image_query.result)
369371

370372

373+
@retry_on_failure()
374+
def test_submit_image_query_shrinks_oversized_image(gl: Groundlight, detector: Detector):
375+
"""Verifies the SDK shrinks oversized images client-side and the cloud stores the shrunken version.
376+
377+
Detects drift between the SDK and the cloud service: if either side changes its
378+
algorithm such that the cloud-stored dimensions differ from what the SDK produces
379+
locally, this test fails. Does not catch the cloud service becoming more permissive
380+
(the SDK would still shrink to a smaller image that the cloud accepts as-is); that
381+
direction is benign and intentionally not covered.
382+
"""
383+
# Random noise compresses poorly, so 4000x3000 is well above the 256 KB threshold.
384+
big = make_random_jpeg(4000, 3000)
385+
386+
iq = gl.submit_image_query(detector=detector.id, image=big, human_review="NEVER")
387+
stored = Image.open(gl.get_image(iq.id))
388+
# 4000x3000 scaled so longest side == 1024 preserves the 4:3 aspect ratio.
389+
assert stored.size == (MAX_IMAGE_RESOLUTION_LONGSIDE, 768)
390+
391+
371392
@retry_on_failure()
372393
def test_submit_image_query_with_confidence_threshold(gl: Groundlight, detector: Detector):
373394
confidence_threshold = 0.5234 # Arbitrary specific value

test/unit/test_image_submission.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""Tests for image handling behavior in Groundlight.submit_image_query."""
2+
3+
from io import BytesIO
4+
from unittest import mock
5+
6+
import pytest
7+
from groundlight import Groundlight
8+
from groundlight.images import MAX_BYTES_IMAGE_SIZE, MAX_IMAGE_RESOLUTION_LONGSIDE
9+
from groundlight.internalapi import InternalApiError
10+
from PIL import Image
11+
from utils import make_random_jpeg
12+
13+
14+
def test_submit_image_query_sends_shrunken_image(gl: Groundlight):
15+
"""Verifies that image shrinking runs in the submission path by inspecting the bytes at the HTTP layer.
16+
17+
Submits an oversized image to a mocked urllib3 transport, then checks that the body
18+
that actually went on the wire was already resized to the expected dimensions.
19+
"""
20+
big = make_random_jpeg(4000, 3000)
21+
assert len(big) > MAX_BYTES_IMAGE_SIZE
22+
23+
with mock.patch("urllib3.PoolManager.request") as mock_request:
24+
mock_request.return_value.status = 500
25+
with pytest.raises(InternalApiError):
26+
gl.submit_image_query(detector="det_test", image=big, wait=0)
27+
28+
body = mock_request.call_args_list[0].kwargs["body"]
29+
sent_img = Image.open(BytesIO(body))
30+
assert max(sent_img.size) == MAX_IMAGE_RESOLUTION_LONGSIDE

test/unit/test_imagefuncs.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import pytest
88
from groundlight.images import *
99
from groundlight.optional_imports import *
10+
from utils import make_random_jpeg
1011

1112
JPEG_MIN_SIZE = 500
1213

@@ -90,6 +91,36 @@ def test_pil_support_ref():
9091
assert img2.size == (509, 339)
9192

9293

94+
def test_shrink_image_if_needed_small_returns_unchanged():
95+
"""Images at or below the byte threshold are passed through untouched."""
96+
small = make_random_jpeg(200, 200)
97+
assert len(small) <= MAX_BYTES_IMAGE_SIZE
98+
assert shrink_image_if_needed(small) is small
99+
100+
101+
def test_shrink_image_if_needed_oversized_dimensions_get_resized():
102+
"""Images above the byte threshold with longest side > 1024 are downscaled."""
103+
# Random noise compresses poorly, so 4000x3000 easily exceeds the 256 KB threshold.
104+
big = make_random_jpeg(4000, 3000)
105+
assert len(big) > MAX_BYTES_IMAGE_SIZE
106+
out = shrink_image_if_needed(big)
107+
out_img = Image.open(BytesIO(out))
108+
# 4000x3000 scaled so longest side == 1024 preserves the 4:3 aspect ratio.
109+
assert out_img.size == (1024, 768)
110+
111+
112+
def test_shrink_image_if_needed_oversized_bytes_only_gets_reencoded():
113+
"""Images above the byte threshold but with longest side <= 1024 are re-encoded only."""
114+
high_q = make_random_jpeg(1024, 768, quality=99)
115+
assert len(high_q) > MAX_BYTES_IMAGE_SIZE
116+
out = shrink_image_if_needed(high_q)
117+
out_img = Image.open(BytesIO(out))
118+
assert out_img.size == (1024, 768)
119+
# Bytes changed (proves re-encode happened) and got smaller (Q85 vs Q99).
120+
assert out != high_q
121+
assert len(out) < len(high_q)
122+
123+
93124
def test_byte_stream_wrapper():
94125
"""
95126
Test that we can call `open` and `close` repeatedly many times on a

test/utils.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"""Shared utility functions for tests."""
2+
3+
import os
4+
from io import BytesIO
5+
6+
from PIL import Image
7+
8+
9+
def make_random_jpeg(width: int, height: int, quality: int = 95) -> bytes:
10+
"""Generate a JPEG with random pixel data."""
11+
img = Image.frombytes("RGB", (width, height), os.urandom(width * height * 3))
12+
buf = BytesIO()
13+
img.save(buf, "jpeg", quality=quality)
14+
return buf.getvalue()

0 commit comments

Comments
 (0)