Skip to content

Commit 74872b7

Browse files
robotraptaAuto-format Bot
andauthored
Pil image support. Configurable default wait. (#31)
* Adding PIL image support. * Fixup import error. Version bump. * Fixing PIL image support. Making DEFAULT_WAIT configurable in groundlight client. * Adding test on invalid image format, and fixing a PIL import bug there. * Adding Image.Image to supported input types in type hint. * Fixing import. Documenting RGB expectation. * Automatically reformatting code with black * Fixing incorrect documentation on our expected tensor dimensions for numpy image inputs. --------- Co-authored-by: Auto-format Bot <runner@fv-az365-486.rrd34hpwizlenh5trlchfhotwb.cx.internal.cloudapp.net>
1 parent 0e46619 commit 74872b7

File tree

6 files changed

+115
-30
lines changed

6 files changed

+115
-30
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "groundlight"
3-
version = "0.7.1"
3+
version = "0.7.2"
44
license = "MIT"
55
readme = "README.md"
66
homepage = "https://www.groundlight.ai"

src/groundlight/client.py

Lines changed: 16 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from io import BufferedReader, BytesIO
55
from typing import Optional, Union
66

7+
from groundlight.optional_imports import Image
78
from model import Detector, ImageQuery, PaginatedDetectorList, PaginatedImageQueryList
89
from openapi_client import ApiClient, Configuration
910
from openapi_client.api.detectors_api import DetectorsApi
@@ -12,7 +13,7 @@
1213

1314
from groundlight.binary_labels import convert_display_label_to_internal, convert_internal_label_to_display
1415
from groundlight.config import API_TOKEN_VARIABLE_NAME, API_TOKEN_WEB_URL, DEFAULT_ENDPOINT
15-
from groundlight.images import buffer_from_jpeg_file, jpeg_from_numpy
16+
from groundlight.images import buffer_from_jpeg_file, jpeg_from_numpy, parse_supported_image_types
1617
from groundlight.internalapi import GroundlightApiClient, sanitize_endpoint_url
1718
from groundlight.optional_imports import np
1819

@@ -38,6 +39,8 @@ class Groundlight:
3839
```
3940
"""
4041

42+
DEFAULT_WAIT = 30
43+
4144
BEFORE_POLLING_DELAY = 3.0 # Expected minimum time for a label to post
4245
POLLING_INITIAL_DELAY = 0.5
4346
POLLING_EXPONENTIAL_BACKOFF = 1.3 # This still has the nice backoff property that the max number of requests
@@ -122,38 +125,28 @@ def list_image_queries(self, page: int = 1, page_size: int = 10) -> PaginatedIma
122125
def submit_image_query(
123126
self,
124127
detector: Union[Detector, str],
125-
image: Union[str, bytes, BytesIO, BufferedReader, np.ndarray],
126-
wait: float = 30,
128+
image: Union[str, bytes, Image.Image, BytesIO, BufferedReader, np.ndarray],
129+
wait: Optional[float] = None,
127130
) -> ImageQuery:
128131
"""Evaluates an image with Groundlight.
129132
:param detector: the Detector object, or string id of a detector like `det_12345`
130133
:param image: The image, in several possible formats:
131-
- a filename (string) of a jpeg file
132-
- a byte array or BytesIO with jpeg bytes
133-
- a numpy array in the 0-255 range (gets converted to jpeg)
134+
- filename (string) of a jpeg file
135+
- byte array or BytesIO or BufferedReader with jpeg bytes
136+
- numpy array with values 0-255 and dimensions (H,W,3) in RGB order
137+
(Note OpenCV uses BGR not RGB. `img[:, :, ::-1]` will reverse the channels)
138+
- PIL Image
139+
Any binary format must be JPEG-encoded already. Any pixel format will get
140+
converted to JPEG at high quality before sending to service.
134141
:param wait: How long to wait (in seconds) for a confident answer
135142
"""
143+
if wait is None:
144+
wait = self.DEFAULT_WAIT
136145
if isinstance(detector, Detector):
137146
detector_id = detector.id
138147
else:
139148
detector_id = detector
140-
image_bytesio: Union[BytesIO, BufferedReader]
141-
# TODO: support PIL Images
142-
if isinstance(image, str):
143-
# Assume it is a filename
144-
image_bytesio = buffer_from_jpeg_file(image)
145-
elif isinstance(image, bytes):
146-
# Create a BytesIO object
147-
image_bytesio = BytesIO(image)
148-
elif isinstance(image, BytesIO) or isinstance(image, BufferedReader):
149-
# Already in the right format
150-
image_bytesio = image
151-
elif isinstance(image, np.ndarray):
152-
image_bytesio = BytesIO(jpeg_from_numpy(image))
153-
else:
154-
raise TypeError(
155-
"Unsupported type for image. We only support numpy arrays (3,W,H) or JPEG images specified through a filename, bytes, BytesIO, or BufferedReader object."
156-
)
149+
image_bytesio: Union[BytesIO, BufferedReader] = parse_supported_image_types(image)
157150

158151
raw_image_query = self.image_queries_api.submit_image_query(detector_id=detector_id, body=image_bytesio)
159152
image_query = ImageQuery.parse_obj(raw_image_query.to_dict())

src/groundlight/images.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import imghdr
2-
import io
2+
from io import BufferedReader, BytesIO
3+
from typing import Union
34

4-
from groundlight.optional_imports import np, Image
5+
from groundlight.optional_imports import Image, np
56

67

7-
def buffer_from_jpeg_file(image_filename: str) -> io.BufferedReader:
8+
def buffer_from_jpeg_file(image_filename: str) -> BufferedReader:
89
"""
910
Get a buffer from an jpeg image file.
1011
@@ -21,8 +22,36 @@ def buffer_from_jpeg_file(image_filename: str) -> io.BufferedReader:
2122
def jpeg_from_numpy(img: np.ndarray, jpeg_quality: int = 95) -> bytes:
2223
"""Converts a numpy array to BytesIO"""
2324
pilim = Image.fromarray(img.astype("uint8"), "RGB")
24-
with io.BytesIO() as buf:
25-
buf = io.BytesIO()
25+
with BytesIO() as buf:
26+
buf = BytesIO()
2627
pilim.save(buf, "jpeg", quality=jpeg_quality)
2728
out = buf.getvalue()
2829
return out
30+
31+
32+
def parse_supported_image_types(
33+
image: Union[str, bytes, Image.Image, BytesIO, BufferedReader, np.ndarray], jpeg_quality: int = 95
34+
) -> Union[BytesIO, BufferedReader]:
35+
"""Parse the many supported image types into a bytes-stream objects.
36+
In some cases we have to JPEG compress."""
37+
if isinstance(image, str):
38+
# Assume it is a filename
39+
return buffer_from_jpeg_file(image)
40+
elif isinstance(image, bytes):
41+
# Create a BytesIO object
42+
return BytesIO(image)
43+
elif isinstance(image, Image.Image):
44+
# Save PIL image as jpeg in BytesIO
45+
bytesio = BytesIO()
46+
image.save(bytesio, "jpeg", quality=jpeg_quality)
47+
bytesio.seek(0)
48+
return bytesio
49+
elif isinstance(image, BytesIO) or isinstance(image, BufferedReader):
50+
# Already in the right format
51+
return image
52+
elif isinstance(image, np.ndarray):
53+
return BytesIO(jpeg_from_numpy(image, jpeg_quality=jpeg_quality))
54+
else:
55+
raise TypeError(
56+
"Unsupported type for image. Must be PIL, numpy (H,W,3) RGB, or a JPEG as a filename (str), bytes, BytesIO, or BufferedReader."
57+
)

src/groundlight/optional_imports.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ def __getattr__(self, key):
4545
except ImportError as e:
4646
PIL = UnavailableModule(e)
4747
Image = PIL
48+
Image.Image = PIL # for type-hinting
4849
MISSING_PIL = True
4950

5051

test/integration/test_groundlight.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
@pytest.fixture
1313
def gl() -> Groundlight:
1414
"""Creates a Groundlight client object for testing."""
15-
return Groundlight()
15+
gl = Groundlight()
16+
gl.DEFAULT_WAIT = 0.1
17+
return gl
1618

1719

1820
@pytest.fixture
@@ -102,6 +104,18 @@ def test_submit_image_query_bad_jpeg_file(gl: Groundlight, detector: Detector):
102104
assert "jpeg" in str(exc_info).lower()
103105

104106

107+
@pytest.mark.skipif(MISSING_PIL, reason="Needs pillow")
108+
def test_submit_image_query_pil(gl: Groundlight, detector: Detector):
109+
# generates a pil image and submits it
110+
from PIL import Image
111+
112+
dog = Image.open("test/assets/dog.jpeg")
113+
_image_query = gl.submit_image_query(detector=detector.id, image=dog)
114+
115+
black = Image.new("RGB", (640, 480))
116+
_image_query = gl.submit_image_query(detector=detector.id, image=black)
117+
118+
105119
def test_list_image_queries(gl: Groundlight):
106120
image_queries = gl.list_image_queries()
107121
assert str(image_queries)

test/unit/test_imagefuncs.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import tempfile
2+
13
import pytest
24

35
from groundlight.images import *
@@ -17,3 +19,49 @@ def test_jpeg_from_numpy():
1719
np_img = np.random.uniform(0, 255, (768, 1024, 3))
1820
jpeg3 = jpeg_from_numpy(np_img, jpeg_quality=50)
1921
assert len(jpeg2) > len(jpeg3)
22+
23+
24+
def test_unsupported_imagetype():
25+
with pytest.raises(TypeError):
26+
parse_supported_image_types(1)
27+
28+
with pytest.raises(TypeError):
29+
parse_supported_image_types(None)
30+
31+
with pytest.raises(TypeError):
32+
parse_supported_image_types(pytest)
33+
34+
35+
@pytest.mark.skipif(MISSING_PIL, reason="Needs pillow")
36+
def test_pil_support():
37+
from PIL import Image
38+
39+
img = Image.new("RGB", (640, 480))
40+
jpeg = parse_supported_image_types(img)
41+
assert isinstance(jpeg, BytesIO)
42+
43+
# Now try to parse the BytesIO object as an image
44+
jpeg_bytes = jpeg.getvalue()
45+
# save the image to a tempfile
46+
with tempfile.TemporaryFile() as f:
47+
f.write(jpeg_bytes)
48+
f.seek(0)
49+
img2 = Image.open(f)
50+
assert img2.size == (640, 480)
51+
52+
53+
@pytest.mark.skipif(MISSING_PIL, reason="Needs pillow")
54+
def test_pil_support_ref():
55+
# Similar to test_pil_support, but uses a known-good file
56+
from PIL import Image
57+
58+
fn = "test/assets/dog.jpeg"
59+
parsed = parse_supported_image_types(fn)
60+
# Now try to parse the BytesIO object as an image
61+
jpeg_bytes = parsed.read()
62+
# save the image to a tempfile
63+
with tempfile.TemporaryFile() as f:
64+
f.write(jpeg_bytes)
65+
f.seek(0)
66+
img2 = Image.open(f)
67+
assert img2.size == (509, 339)

0 commit comments

Comments
 (0)