Skip to content

Commit 234e913

Browse files
robotraptaAuto-format Bot
andauthored
submit_image_query can accept numpy images (#23)
* Adding section on edge to User Guide. * Accepts numpy arrays as images into imagequery * Improving interface and docs based on feedback. * Adding some tests on the optional_import functionality * Writing some unit tests for numpy/jpeg functionality * fixing bytesio / bytes messup * Marking unpassable tests as skip * Bumping version to 0.6.1 Co-authored-by: Auto-format Bot <runner@fv-az180-333.aibneqh1mxpuhl3tnnuy2ifbce.bx.internal.cloudapp.net>
1 parent 497acf6 commit 234e913

File tree

8 files changed

+147
-5
lines changed

8 files changed

+147
-5
lines changed

.github/workflows/test-integ.yaml

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,14 @@ jobs:
55
run-tests:
66
runs-on: ubuntu-20.04
77
strategy:
8-
fail-fast: true
8+
# It's totally debatable which is better here: fail-fast or not.
9+
# Failing fast will use fewer cloud resources, in theory.
10+
# But if the tests are slightly flaky (fail to pip install something)
11+
# Then one flaky install kills lots of jobs that need to be redone.
12+
# So the efficiency argument has its limits
13+
# Failing slow is clearer about what's going on.
14+
# This is pretty unambiguous, so we're going with it for now.
15+
fail-fast: false
916
matrix:
1017
python-version: [
1118
#"3.6", # Default on Ubuntu18.04 but openapi-generator fails
@@ -15,6 +22,8 @@ jobs:
1522
"3.10",
1623
"3.11",
1724
]
25+
install_numpy: [ true, false ]
26+
install_pillow: [ true, false ]
1827
env:
1928
# This is associated with the "sdk-integ-test" user, credentials on 1password
2029
GROUNDLIGHT_API_TOKEN: ${{ secrets.GROUNDLIGHT_API_TOKEN }}
@@ -32,5 +41,15 @@ jobs:
3241
pip install -U pip
3342
pip install poetry
3443
poetry install
44+
- name: setup environment
45+
run: make install
46+
- name: install numpy
47+
if: matrix.install_numpy
48+
run: |
49+
poetry run pip install numpy
50+
- name: install pillow
51+
if: matrix.install_pillow
52+
run: |
53+
poetry run pip install pillow
3554
- name: run tests
3655
run: make test-integ

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.6.0"
3+
version = "0.6.1"
44
license = "MIT"
55
readme = "UserGuide.md"
66
homepage = "https://groundlight.ai"

src/groundlight/client.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
from openapi_client.api.image_queries_api import ImageQueriesApi
1111
from openapi_client.model.detector_creation_input import DetectorCreationInput
1212

13-
from groundlight.images import buffer_from_jpeg_file
13+
from groundlight.images import buffer_from_jpeg_file, jpeg_from_numpy
14+
from groundlight.optional_imports import np
1415

1516
API_TOKEN_WEB_URL = "https://app.groundlight.ai/reef/my-account/api-tokens"
1617
API_TOKEN_VARIABLE_NAME = "GROUNDLIGHT_API_TOKEN"
@@ -113,7 +114,7 @@ def list_image_queries(self, page: int = 1, page_size: int = 10) -> PaginatedIma
113114
def submit_image_query(
114115
self,
115116
detector: Union[Detector, str],
116-
image: Union[str, bytes, BytesIO, BufferedReader],
117+
image: Union[str, bytes, BytesIO, BufferedReader, np.ndarray],
117118
wait: float = 0,
118119
) -> ImageQuery:
119120
"""Evaluates an image with Groundlight.
@@ -139,9 +140,11 @@ def submit_image_query(
139140
elif isinstance(image, BytesIO) or isinstance(image, BufferedReader):
140141
# Already in the right format
141142
image_bytesio = image
143+
elif isinstance(image, np.ndarray):
144+
image_bytesio = BytesIO(jpeg_from_numpy(image))
142145
else:
143146
raise TypeError(
144-
"Unsupported type for image. We only support JPEG images specified through a filename, bytes, BytesIO, or BufferedReader object."
147+
"Unsupported type for image. We only support numpy arrays (3,W,H) or JPEG images specified through a filename, bytes, BytesIO, or BufferedReader object."
145148
)
146149

147150
raw_img_query = self.image_queries_api.submit_image_query(detector_id=detector_id, body=image_bytesio)

src/groundlight/images.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import imghdr
22
import io
33

4+
from groundlight.optional_imports import np, Image
5+
46

57
def buffer_from_jpeg_file(image_filename: str) -> io.BufferedReader:
68
"""
@@ -14,3 +16,13 @@ def buffer_from_jpeg_file(image_filename: str) -> io.BufferedReader:
1416
return open(image_filename, "rb")
1517
else:
1618
raise ValueError("We only support JPEG files, for now.")
19+
20+
21+
def jpeg_from_numpy(img: np.ndarray, jpeg_quality: int = 95) -> bytes:
22+
"""Converts a numpy array to BytesIO"""
23+
pilim = Image.fromarray(img.astype("uint8"), "RGB")
24+
with io.BytesIO() as buf:
25+
buf = io.BytesIO()
26+
pilim.save(buf, "jpeg", quality=jpeg_quality)
27+
out = buf.getvalue()
28+
return out
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""We use a trick to check if libraries like numpy are installed or not.
2+
If they are, we make it available as normal.
3+
If not, we set it up as a shim object which still lets type-hinting work properly,
4+
but will fail at runtime if you try to use it.
5+
6+
This can be confusing, but hopefully the errors are explicit enough to be
7+
clear about what's happening, and it makes the code which hopes numpy is installed
8+
look readable.
9+
"""
10+
11+
12+
class UnavailableModule(type):
13+
"""Represents a module that is not installed or otherwise unavailable at runtime.
14+
Attempting to access anything in this object raises the original exception
15+
(ImportError or similar) which happened when the optional library failed to import.
16+
17+
Needs to subclass type so that it works for type-hinting.
18+
"""
19+
20+
def __new__(cls, exc):
21+
out = type("UnavailableModule", (), {})
22+
out.exc = exc
23+
return out
24+
25+
def __getattr__(self, key):
26+
# TODO: This isn't getting called for some reason.
27+
raise RuntimeError("attempt to use module that failed to load") from self.exc
28+
29+
30+
try:
31+
import numpy as np
32+
33+
MISSING_NUMPY = False
34+
except ImportError as e:
35+
np = UnavailableModule(e)
36+
# Expose np.ndarray so type-hinting looks normal
37+
np.ndarray = np
38+
MISSING_NUMPY = True
39+
40+
try:
41+
import PIL
42+
from PIL import Image
43+
44+
MISSING_PIL = False
45+
except ImportError as e:
46+
PIL = UnavailableModule(e)
47+
Image = PIL
48+
MISSING_PIL = True
49+
50+
51+
__all__ = ["np", "PIL", "Image", "MISSING_NUMPY", "MISSING_PIL"]

test/integration/test_groundlight.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import pytest
66

77
from groundlight import Groundlight
8+
from groundlight.optional_imports import *
89
from model import Detector, ImageQuery, PaginatedDetectorList, PaginatedImageQueryList
910

1011

@@ -109,3 +110,11 @@ def test_get_image_query(gl: Groundlight, image_query: ImageQuery):
109110
_image_query = gl.get_image_query(id=image_query.id)
110111
assert str(_image_query)
111112
assert isinstance(_image_query, ImageQuery)
113+
114+
115+
@pytest.mark.skipif(MISSING_NUMPY or MISSING_PIL, reason="Needs numpy and pillow")
116+
def test_submit_numpy_image(gl: Groundlight, detector: Detector):
117+
np_img = np.random.uniform(0, 255, (600, 800, 3))
118+
_image_query = gl.submit_image_query(detector=detector.id, image=np_img)
119+
assert str(_image_query)
120+
assert isinstance(_image_query, ImageQuery)

test/unit/test_imagefuncs.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import pytest
2+
3+
from groundlight.images import *
4+
from groundlight.optional_imports import *
5+
6+
7+
@pytest.mark.skipif(MISSING_NUMPY or MISSING_PIL, reason="Needs numpy and pillow")
8+
def test_jpeg_from_numpy():
9+
np_img = np.random.uniform(0, 255, (480, 640, 3))
10+
jpeg1 = jpeg_from_numpy(np_img)
11+
assert len(jpeg1) > 500
12+
13+
np_img = np.random.uniform(0, 255, (768, 1024, 3))
14+
jpeg2 = jpeg_from_numpy(np_img)
15+
assert len(jpeg2) > len(jpeg1)
16+
17+
np_img = np.random.uniform(0, 255, (768, 1024, 3))
18+
jpeg3 = jpeg_from_numpy(np_img, jpeg_quality=50)
19+
assert len(jpeg2) > len(jpeg3)

test/unit/test_optional_imports.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from typing import Union
2+
3+
import pytest
4+
5+
from groundlight.optional_imports import UnavailableModule
6+
7+
8+
@pytest.fixture
9+
def failed_import() -> type:
10+
e = ModuleNotFoundError("perfect_perception module does not exist")
11+
return UnavailableModule(e)
12+
13+
14+
def test_type_hints(failed_import):
15+
# Check that the UnavailableModule class can be used in type hints.
16+
def typed_method(foo: Union[failed_import, str]):
17+
print(foo)
18+
19+
assert True, "Yay UnavailableModule can be used in a type hint"
20+
21+
22+
@pytest.mark.skip("Would be nice if this works, but it doesn't")
23+
def test_raises_exception(failed_import):
24+
# We'd like the UnavailableModule object to raise an exception
25+
# anytime you access it, where the exception is a RuntimeError
26+
# but builds on the original ImportError so you can see what went wrong.
27+
# The old version had this, but didn't work with modern type-hinting.
28+
with pytest.raises(RuntimeError):
29+
failed_import.foo

0 commit comments

Comments
 (0)