diff --git a/.gitignore b/.gitignore index 4ad6467..3be13a2 100644 --- a/.gitignore +++ b/.gitignore @@ -103,4 +103,10 @@ venv.bak/ # mypy .mypy_cache/ -.idea/ \ No newline at end of file +# local test files +test_image_utils/ +.idea/ + +# other +*.DS_Store +.vscode/ \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index b7ccd06..71913d8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,7 +15,7 @@ install: before_script: - flake8 . --max-line-length=127 script: - - true # add other tests here + - python setup.py test # add other tests here notifications: on_success: change on_failure: change # `always` will be the setting once code changes slow down diff --git a/maxfw/__init__.py b/maxfw/__init__.py new file mode 100644 index 0000000..487277e --- /dev/null +++ b/maxfw/__init__.py @@ -0,0 +1,15 @@ +# +# Copyright 2018-2019 IBM Corp. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# diff --git a/maxfw/core/__init__.py b/maxfw/core/__init__.py index 81cf59c..db0f077 100644 --- a/maxfw/core/__init__.py +++ b/maxfw/core/__init__.py @@ -1,2 +1,18 @@ +# +# Copyright 2018-2019 IBM Corp. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# from .app import MAXApp, MAX_API # noqa from .api import * # noqa +from .utils import * # noqa diff --git a/maxfw/core/api.py b/maxfw/core/api.py index 8d03d82..2b7505c 100644 --- a/maxfw/core/api.py +++ b/maxfw/core/api.py @@ -1,3 +1,18 @@ +# +# Copyright 2018-2019 IBM Corp. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# from .app import MAX_API from flask_restplus import Resource, fields diff --git a/maxfw/core/app.py b/maxfw/core/app.py index 5df0ec9..8060d3f 100644 --- a/maxfw/core/app.py +++ b/maxfw/core/app.py @@ -1,3 +1,18 @@ +# +# Copyright 2018-2019 IBM Corp. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# import os from flask import Flask from flask_restplus import Api, Namespace diff --git a/maxfw/core/default_config.py b/maxfw/core/default_config.py index 5e93617..d85a3e1 100644 --- a/maxfw/core/default_config.py +++ b/maxfw/core/default_config.py @@ -1,3 +1,18 @@ +# +# Copyright 2018-2019 IBM Corp. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# # API metadata API_TITLE = 'Model Asset Exchange Microservice' API_DESC = 'An API for serving models' diff --git a/maxfw/core/utils.py b/maxfw/core/utils.py new file mode 100644 index 0000000..7c110b5 --- /dev/null +++ b/maxfw/core/utils.py @@ -0,0 +1,61 @@ +# +# Copyright 2018-2019 IBM Corp. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from flask import abort +from maxfw.utils.image_utils import ImageProcessor + + +def redirect_errors_to_flask(func): + """ + This decorator function will capture all Pythonic errors and return them as flask errors. + + If you are looking to disable this functionality, please remove this decorator from the `apply_transforms()` module + under the ImageProcessor class. + """ + + def inner(*args, **kwargs): + try: + # run the function + return func(*args, **kwargs) + except ValueError as ve: + if 'pic should be 2 or 3 dimensional' in str(ve): + abort(400, "Invalid input, please ensure the input is either " + "a grayscale or a colour image.") + except TypeError as te: + if 'bytes or ndarray' in str(te): + abort(400, "Invalid input format, please make sure the input file format " + " is a common image format such as JPG or PNG.") + return inner + + +class MAXImageProcessor(ImageProcessor): + """Composes several transforms together. + + Args: + transforms (list of ``Transform`` objects): list of transforms to compose. + + Example: + >>> pipeline = ImageProcessor([ + >>> Rotate(150), + >>> Resize([100,100]) + >>> ]) + >>> pipeline.apply_transforms(img) + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + @redirect_errors_to_flask + def apply_transforms(self, img): + return super().apply_transforms(img) diff --git a/maxfw/model/__init__.py b/maxfw/model/__init__.py index 3d20c4b..fce5430 100644 --- a/maxfw/model/__init__.py +++ b/maxfw/model/__init__.py @@ -1 +1,16 @@ +# +# Copyright 2018-2019 IBM Corp. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# from .model import MAXModelWrapper # noqa diff --git a/maxfw/model/model.py b/maxfw/model/model.py index d7f6a65..f4f04b0 100644 --- a/maxfw/model/model.py +++ b/maxfw/model/model.py @@ -1,3 +1,19 @@ +# +# Copyright 2018-2019 IBM Corp. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + from abc import ABC, abstractmethod diff --git a/maxfw/tests/__init__.py b/maxfw/tests/__init__.py new file mode 100644 index 0000000..487277e --- /dev/null +++ b/maxfw/tests/__init__.py @@ -0,0 +1,15 @@ +# +# Copyright 2018-2019 IBM Corp. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# diff --git a/maxfw/tests/test_image.jpg b/maxfw/tests/test_image.jpg new file mode 100644 index 0000000..6311aa8 Binary files /dev/null and b/maxfw/tests/test_image.jpg differ diff --git a/maxfw/tests/test_image_utils.py b/maxfw/tests/test_image_utils.py new file mode 100644 index 0000000..9e37f86 --- /dev/null +++ b/maxfw/tests/test_image_utils.py @@ -0,0 +1,382 @@ +# +# Copyright 2018-2019 IBM Corp. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Standard libs +import io + +# Dependencies +import nose +import numpy as np +from PIL import Image + +# The module to test +from maxfw.utils.image_utils import ImageProcessor, ToPILImage, Resize, Grayscale, Normalize, Standardize, Rotate, \ + PILtoarray +from maxfw.core.utils import MAXImageProcessor + +# Initialize a test input file +stream = io.BytesIO() +Image.open('maxfw/tests/test_image.jpg').convert('RGBA').save(stream, 'PNG') +test_input = stream.getvalue() + + +def test_imageprocessor_read(): + """Test the Imageprocessor.""" + + # Test with 4 channels + transform_sequence = [ToPILImage('RGBA')] + p = ImageProcessor(transform_sequence) + img_out = p.apply_transforms(test_input) + assert np.array(img_out).shape == (678, 1024, 4) + + # Test with 3 channels + transform_sequence = [ToPILImage('RGB')] + p = ImageProcessor(transform_sequence) + img_out = p.apply_transforms(test_input) + assert np.array(img_out).shape == (678, 1024, 3) + + # Test the values of the image + transform_sequence = [ToPILImage('RGBA'), PILtoarray()] + p = ImageProcessor(transform_sequence) + img_out = p.apply_transforms(test_input) + assert np.min(img_out) >= 0 + assert np.max(img_out) <= 255 + + # Test the values of the image + transform_sequence = [ToPILImage('L'), PILtoarray()] + p = ImageProcessor(transform_sequence) + img_out = p.apply_transforms(test_input) + assert np.min(img_out) >= 0 + assert np.max(img_out) <= 255 + + +def test_imageprocessor_resize(): + """Test the Imageprocessor's resize function.""" + + # Resize to 200x200 + transform_sequence = [ToPILImage('RGBA'), Resize(size=(200, 200))] + p = ImageProcessor(transform_sequence) + img_out = p.apply_transforms(test_input) + assert np.array(img_out).shape == (200, 200, 4) + + # Resize to 2000x2000 + transform_sequence = [ToPILImage('RGBA'), Resize(size=(2000, 2000))] + p = ImageProcessor(transform_sequence) + img_out = p.apply_transforms(test_input) + assert np.array(img_out).shape == (2000, 2000, 4) + + +def test_imageprocessor_grayscale(): + """Test the Imageprocessor's grayscale function.""" + + # Using the standard 1 output channel + transform_sequence = [ToPILImage('RGBA'), Resize(size=(200, 200)), Grayscale()] + p = ImageProcessor(transform_sequence) + img_out = p.apply_transforms(test_input) + assert np.array(img_out).shape == (200, 200) + + # Using 3 output channels + transform_sequence = [ToPILImage('RGBA'), Resize(size=(200, 200)), Grayscale(num_output_channels=3)] + p = ImageProcessor(transform_sequence) + img_out = p.apply_transforms(test_input) + assert np.array(img_out).shape == (200, 200, 3) + + # Using 4 output channels + transform_sequence = [ToPILImage('RGBA'), Resize(size=(200, 200)), Grayscale(num_output_channels=4), PILtoarray()] + p = ImageProcessor(transform_sequence) + img_out = p.apply_transforms(test_input) + assert img_out.shape == (200, 200, 4) + + # Test that the values in all 4 output channels are identical + assert img_out[..., 0].all() == img_out[..., 1].all() == img_out[..., 2].all() == img_out[..., 3].all() + + +def test_imageprocessor_normalize(): + """Test the Imageprocessor's normalize function.""" + + # Test normalize + transform_sequence = [ToPILImage('RGBA'), Normalize()] + p = ImageProcessor(transform_sequence) + img_out = p.apply_transforms(test_input) + assert np.max(img_out) <= 1 and np.min(img_out) >= 0 + + # Test normalize + transform_sequence = [ToPILImage('RGB'), Normalize()] + p = ImageProcessor(transform_sequence) + img_out = p.apply_transforms(test_input) + assert np.max(img_out) <= 1 and np.min(img_out) >= 0 + + # Test normalize + transform_sequence = [ToPILImage('L'), Normalize()] + p = ImageProcessor(transform_sequence) + img_out = p.apply_transforms(test_input) + assert np.max(img_out) <= 1 and np.min(img_out) >= 0 + + # Test for wrong use + transform_sequence = [ToPILImage('L'), Normalize(), Resize(size=(200, 200))] + p = ImageProcessor(transform_sequence) + with nose.tools.assert_raises(Exception): + p.apply_transforms(test_input) + + # Test for wrong use + transform_sequence = [ToPILImage('RGBA'), Normalize(), Normalize()] + p = ImageProcessor(transform_sequence) + with nose.tools.assert_raises(Exception): + p.apply_transforms(test_input) + + +def test_imageprocessor_standardize(): + """Test the Imageprocessor's standardize function.""" + + # Test standardize (RGBA) + # The `A` channel cannot be standardized, and will therefore prompt an error message when attempted. + transform_sequence = [ToPILImage('RGBA'), Standardize()] + with nose.tools.assert_raises_regexp(AssertionError, r".*must be converted to an image with 3 or fewer channels.*"): + ImageProcessor(transform_sequence).apply_transforms(test_input) + + # Test standardize (RGB) + transform_sequence = [ToPILImage('RGB'), Standardize()] + p = ImageProcessor(transform_sequence) + img_out = np.array(p.apply_transforms(test_input)) + nose.tools.assert_almost_equal(np.std(img_out), 1) + nose.tools.assert_almost_equal(np.mean(img_out), 0) + + # Test standardize (RGB) - check whether the mean centering works + transform_sequence = [ToPILImage('RGB'), Standardize(std=0)] + p = ImageProcessor(transform_sequence) + img_out = np.array(p.apply_transforms(test_input)) + nose.tools.assert_almost_equal(np.mean(img_out), 0) + + # Test standardize (RGB) - check whether the std division works + transform_sequence = [ToPILImage('RGB'), Standardize(mean=0)] + p = ImageProcessor(transform_sequence) + img_out = np.array(p.apply_transforms(test_input)) + nose.tools.assert_almost_equal(np.std(img_out[..., 0]), 1) + nose.tools.assert_almost_equal(np.std(img_out[..., 1]), 1) + nose.tools.assert_almost_equal(np.std(img_out[..., 2]), 1) + + # generate an image array + pil_img = ImageProcessor([ToPILImage('RGB'), PILtoarray()]).apply_transforms(test_input) + + # Test standardize (RGB) with 3 channel-wise values + transform_sequence = [ToPILImage('RGB'), Standardize(mean=[x for x in np.mean(pil_img, axis=(0, 1))], + std=[x for x in np.std(pil_img, axis=(0, 1))])] + ImageProcessor(transform_sequence).apply_transforms(test_input) + + # Test standardize (RGB) with 3 channel-wise values for the mean + transform_sequence = [ToPILImage('RGB'), Standardize(mean=[x for x in np.mean(pil_img, axis=(0, 1))])] + ImageProcessor(transform_sequence).apply_transforms(test_input) + + # Test standardize (RGB) with 3 channel-wise values for the std + transform_sequence = [ToPILImage('RGB'), Standardize(std=[x for x in np.std(pil_img, axis=(0, 1))])] + ImageProcessor(transform_sequence).apply_transforms(test_input) + + # Test standardize (RGB) + transform_sequence = [ToPILImage('RGB'), Standardize(mean=np.mean(pil_img))] + ImageProcessor(transform_sequence).apply_transforms(test_input) + + # Test standardize (RGB) + transform_sequence = [ToPILImage('RGB'), Standardize(mean=np.mean(pil_img), std=np.std(pil_img))] + ImageProcessor(transform_sequence).apply_transforms(test_input) + + # Test standardize (RGB) + transform_sequence = [ToPILImage('RGB'), Standardize(std=np.std(pil_img))] + ImageProcessor(transform_sequence).apply_transforms(test_input) + + # Test standardize (RGB) + transform_sequence = [ToPILImage('RGB'), Standardize(mean=127, std=5)] + ImageProcessor(transform_sequence).apply_transforms(test_input) + + # Test standardize error (RGB) + transform_sequence = [ToPILImage('RGB'), Standardize(mean=[127, 127], std=5)] + with nose.tools.assert_raises_regexp(AssertionError, r".*must correspond to the number of channels.*"): + ImageProcessor(transform_sequence).apply_transforms(test_input) + + # Test standardize error (RGB) + transform_sequence = [ToPILImage('RGB'), Standardize(std=[5, 5, 5, 5])] + with nose.tools.assert_raises_regexp(AssertionError, r".*must correspond to the number of channels.*"): + ImageProcessor(transform_sequence).apply_transforms(test_input) + + # Test standardize (L) + transform_sequence = [ToPILImage('L'), Standardize()] + p = ImageProcessor(transform_sequence) + img_out = p.apply_transforms(test_input) + nose.tools.assert_almost_equal(np.std(img_out), 1) + + # Test standardize (L) + transform_sequence = [ToPILImage('L'), Standardize(mean=np.mean(pil_img))] + ImageProcessor(transform_sequence).apply_transforms(test_input) + + # Test standardize (L) + transform_sequence = [ToPILImage('L'), Standardize(mean=np.mean(pil_img), std=np.std(pil_img))] + ImageProcessor(transform_sequence).apply_transforms(test_input) + + # Test standardize (L) + transform_sequence = [ToPILImage('L'), Standardize(std=np.std(pil_img))] + ImageProcessor(transform_sequence).apply_transforms(test_input) + + # Test standardize (L) + transform_sequence = [ToPILImage('L'), Standardize(mean=127, std=5)] + ImageProcessor(transform_sequence).apply_transforms(test_input) + + # Test standardize (L) - check whether the mean centering works + transform_sequence = [ToPILImage('L'), Standardize(std=0)] + p = ImageProcessor(transform_sequence) + img_out = np.array(p.apply_transforms(test_input)) + nose.tools.assert_almost_equal(np.mean(img_out), 0) + + # Test standardize (L) - check whether the std division works + transform_sequence = [ToPILImage('L'), Standardize(mean=0)] + p = ImageProcessor(transform_sequence) + img_out = np.array(p.apply_transforms(test_input)) + nose.tools.assert_almost_equal(np.std(img_out), 1) + + # Test standardize error (L) + transform_sequence = [ToPILImage('L'), Standardize(mean=[127, 127], std=5)] + with nose.tools.assert_raises(AssertionError): + ImageProcessor(transform_sequence).apply_transforms(test_input) + + # Test standardize error (L) + transform_sequence = [ToPILImage('L'), Standardize(std=[5, 5, 5, 5])] + with nose.tools.assert_raises(AssertionError): + ImageProcessor(transform_sequence).apply_transforms(test_input) + + # Test for wrong use (L) + transform_sequence = [ToPILImage('L'), Standardize(), Resize(size=(200, 200))] + p = ImageProcessor(transform_sequence) + with nose.tools.assert_raises(Exception): + p.apply_transforms(test_input) + + # Test for wrong use (RGBA) + transform_sequence = [ToPILImage('RGBA'), Standardize(), Standardize()] + p = ImageProcessor(transform_sequence) + with nose.tools.assert_raises(Exception): + p.apply_transforms(test_input) + + +def test_imageprocessor_rotate(): + """Test the Imageprocessor's rotate function.""" + + # Test rotate (int) + transform_sequence = [ToPILImage('RGBA'), Resize((200, 200)), Rotate(5)] + p = ImageProcessor(transform_sequence) + img_out = p.apply_transforms(test_input) + assert np.array(img_out).shape == (200, 200, 4) + + # Test rotate vs Pillow rotate + transform_sequence = [ToPILImage('RGBA'), Resize((200, 200))] + p = ImageProcessor(transform_sequence) + img_in = p.apply_transforms(test_input) + + transform_sequence = [Rotate(5)] + p = ImageProcessor(transform_sequence) + img_out = p.apply_transforms(img_in) + assert img_in.rotate(5) == img_out + + # Test rotate (negative int) + transform_sequence = [ToPILImage('RGBA'), Resize((200, 200)), Rotate(-5)] + p = ImageProcessor(transform_sequence) + img_out = p.apply_transforms(test_input) + assert np.array(img_out).shape == (200, 200, 4) + + # Test rotate (float) + transform_sequence = [ToPILImage('RGBA'), Resize((200, 200)), Rotate(499.99)] + p = ImageProcessor(transform_sequence) + img_out = p.apply_transforms(test_input) + assert np.array(img_out).shape == (200, 200, 4) + + +def test_imageprocessor_combinations(): + """Test various combinations of the Imageprocessor's functionality.""" + + # Combination 1 + transform_sequence = [ + ToPILImage('RGB'), + Resize((2000, 2000)), + Rotate(5), + Grayscale(num_output_channels=4), + Resize((200, 200)), + Normalize() + ] + p = ImageProcessor(transform_sequence) + img_out = p.apply_transforms(test_input) + assert np.array(img_out).shape == (200, 200, 4) + + # Combination 2 + transform_sequence = [ + ToPILImage('RGB'), + Resize((200, 200)), + Normalize() + ] + p = ImageProcessor(transform_sequence) + img_out = p.apply_transforms(test_input) + assert np.array(img_out).shape == (200, 200, 3) + + # Combination 3 + transform_sequence = [ + ToPILImage('RGB'), + Resize((200, 200)), + Standardize() + ] + p = ImageProcessor(transform_sequence) + img_out = p.apply_transforms(test_input) + assert np.array(img_out).shape == (200, 200, 3) + + # Combination 5 - including pixel value tests + transform_sequence = [ + ToPILImage('RGB'), + Resize((2000, 2000)), + Rotate(5), + Grayscale(num_output_channels=4), + Resize((200, 200)), + PILtoarray() + ] + p = ImageProcessor(transform_sequence) + img_out = p.apply_transforms(test_input) + assert img_out.shape == (200, 200, 4) + assert np.min(img_out) >= 0 + assert np.max(img_out) <= 255 + + # Combination 6 - utilize the pipeline multiple times + transform_sequence = [ + ToPILImage('RGB'), + Resize((2000, 2000)), + Rotate(5), + Grayscale(num_output_channels=4), + Resize((200, 200)), + PILtoarray() + ] + p = ImageProcessor(transform_sequence) + p.apply_transforms(test_input) + p.apply_transforms(test_input) + + +def test_flask_error(): + + # Test invalid input format + transform_sequence = [ToPILImage('RGB')] + p = MAXImageProcessor(transform_sequence) + with nose.tools.assert_raises_regexp(Exception, r".*Invalid input format*"): + p.apply_transforms("") + + # Test invalid input dimensions + transform_sequence = [ToPILImage('RGB')] + p = MAXImageProcessor(transform_sequence) + with nose.tools.assert_raises_regexp(Exception, r".*grayscale or a colour image*"): + p.apply_transforms(np.random.rand(10, 10, 10, 10)) + + +if __name__ == '__main__': + nose.main() diff --git a/maxfw/utils/__init__.py b/maxfw/utils/__init__.py new file mode 100644 index 0000000..487277e --- /dev/null +++ b/maxfw/utils/__init__.py @@ -0,0 +1,15 @@ +# +# Copyright 2018-2019 IBM Corp. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# diff --git a/maxfw/utils/image_functions.py b/maxfw/utils/image_functions.py new file mode 100644 index 0000000..b7c6151 --- /dev/null +++ b/maxfw/utils/image_functions.py @@ -0,0 +1,542 @@ +# +# Copyright 2018-2019 IBM Corp. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import division +import sys +import io +import numbers +import collections + +from PIL import Image, ImageEnhance +import numpy as np + +if sys.version_info < (3, 3): + Sequence = collections.Sequence + Iterable = collections.Iterable +else: + Sequence = collections.abc.Sequence + Iterable = collections.abc.Iterable + + +def _is_pil_image(img): + return isinstance(img, Image.Image) + + +def _is_numpy_image(img): + return isinstance(img, np.ndarray) and (img.ndim in {2, 3}) + + +def to_pil_image(pic, target_mode, mode=None): + """Convert an ndarray to PIL Image. + + Args: + pic (io.BytesIO or numpy.ndarray): Image to be converted to PIL Image. + mode (`PIL.Image mode`_): color space and pixel depth of input data (optional). + + .. _PIL.Image mode: https://pillow.readthedocs.io/en/latest/handbook/concepts.html#concept-modes + + Returns: + PIL Image: Image converted to PIL Image. + """ + if not isinstance(pic, (bytes, bytearray)) and not(isinstance(pic, np.ndarray)): + # if the object is not bytes, and it's not a ndarray + raise TypeError('pic should be bytes or ndarray. Got {}.'.format(type(pic))) + + elif isinstance(pic, np.ndarray): + if pic.ndim not in {2, 3}: + raise ValueError('pic should be 2 or 3 dimensional. Got {} dimensions.'.format(pic.ndim)) + + elif pic.ndim == 2: + # if 2D image, add channel dimension (HWC) + pic = np.expand_dims(pic, 2) + + elif isinstance(pic, (bytes, bytearray)): + try: + # verify that the object can be loaded into memory + pic = np.array(Image.open(io.BytesIO(pic))) + except Exception: + raise TypeError('The input bytes object is not suitable for the Pillow library. Check the input again.') + + npimg = pic + if not isinstance(npimg, np.ndarray): + raise TypeError('Input pic must be a bytes object or NumPy ndarray, ' + + 'not {}'.format(type(npimg))) + + if npimg.shape[2] == 1: + expected_mode = None + npimg = npimg[:, :, 0] + if npimg.dtype == np.uint8: + expected_mode = 'L' + elif npimg.dtype == np.int16: + expected_mode = 'I;16' + elif npimg.dtype == np.int32: + expected_mode = 'I' + elif npimg.dtype == np.float32: + expected_mode = 'F' + if mode is not None and mode != expected_mode: + raise ValueError("Incorrect mode ({}) supplied for input type {}. Should be {}" + .format(mode, np.dtype, expected_mode)) + mode = expected_mode + + elif npimg.shape[2] == 2: + permitted_2_channel_modes = ['LA'] + if mode is not None and mode not in permitted_2_channel_modes: + raise ValueError("Only modes {} are supported for 2D inputs".format(permitted_2_channel_modes)) + + if mode is None and npimg.dtype == np.uint8: + mode = 'LA' + + elif npimg.shape[2] == 4: + permitted_4_channel_modes = ['RGBA', 'CMYK', 'RGBX'] + if mode is not None and mode not in permitted_4_channel_modes: + raise ValueError("Only modes {} are supported for 4D inputs".format(permitted_4_channel_modes)) + + if mode is None and npimg.dtype == np.uint8: + mode = 'RGBA' + else: + permitted_3_channel_modes = ['RGB', 'YCbCr', 'HSV'] + if mode is not None and mode not in permitted_3_channel_modes: + raise ValueError("Only modes {} are supported for 3D inputs".format(permitted_3_channel_modes)) + if mode is None and npimg.dtype == np.uint8: + mode = 'RGB' + + if mode is None: + raise TypeError('Input type {} is not supported'.format(npimg.dtype)) + + # Verify that the target mode exists + assert target_mode in [1, 'L', 'P', 'RGB', 'RGBA', 'CMYK', 'YCbCr', 'LAB', 'HSV', 'I', 'F', 'RGBX', 'RGBBa'] + + return Image.fromarray(npimg, mode=mode).convert(target_mode) + + +def pil_to_array(pic): + assert _is_pil_image(pic), 'The input image for `PILtoarray` is not a PIL Image object.' + return np.array(pic) + + +def normalize(img): + if type(img) is not np.ndarray: + img = np.array(img) + return img / (np.max(img) - np.min(img)) + + +def standardize(img, mean=None, std=None): + # ensure we are working with a numpy ndarray + if type(img) is not np.ndarray: + img = np.array(img) + img = img.astype(np.float64) + + # check whether the image has channels + if img.ndim == 3: + # (this image has channels) + # calculate the number of channels + channels = img.shape[-1] + assert channels < 4, 'An image with more than 3 channels, ' \ + 'e.g. `RGBA`, must be converted to an image with 3 or fewer channels (e.g. `RGB` or `L`)' \ + 'before it can be standardized correctly.' + + if mean is None: + # calculate channel-wise mean + mean = np.mean(img, axis=(0, 1), keepdims=True) + elif isinstance(mean, (int, float)): + # convert the number to an array + mean = np.array([mean] * channels).reshape((1, 1, channels)) + elif isinstance(mean, Sequence): + # convert a sequence to the right dimensions + assert np.sum([not isinstance(x, (int, float)) for x in mean]) == 0, \ + 'The sequence `mean` can only contain numbers.' + assert len(mean) == channels, \ + 'The size of the `mean` array must correspond to the number of channels in the image.' + mean = np.array(mean).reshape((1, 1, channels)) + else: + # if the mean is not a number or a sequence + raise TypeError('`Mean` should either be a number or an n-dimensional vector of numbers ' + 'with n equal to the number of image channels.') + + if std is None: + # calculate channel-wise std + std = np.std(img, axis=(0, 1)).reshape((channels,)) + elif isinstance(std, (int, float)): + # convert the number to an array + std = np.array([std] * channels).reshape((channels,)) + elif isinstance(std, Sequence): + # convert a sequence to the right dimensions + assert np.sum([not isinstance(x, (int, float)) for x in std]) == 0, \ + 'The sequence `std` can only contain numbers.' + assert len(std) == channels, \ + 'The size of the `std` array must correspond to the number of channels in the image.' + std = np.array(std).reshape((channels,)) + else: + # if the std is not a number or a sequence + raise TypeError('`std` should either be a number or an n-dimensional vector ' + 'of numbers with n equal to the number of image channels.') + + # return the standardized array + # a. mean center + img_mean_centered = (img - mean).astype(np.float64) + + # b. channel-wise division by std + for c in range(channels): + if std[c] != 0: + img_mean_centered[..., c] = img_mean_centered[..., c] / std[c] + + return img_mean_centered + + else: + # (this image has no channels) + if mean is None: + mean = np.mean(img) + elif isinstance(mean, Sequence): + assert len(mean) == 1 + mean = mean[0] + elif not isinstance(mean, (int, float)): + raise ValueError('The value for `mean` should be a number or `None` ' + 'when working with single-channel images.') + + if std is None: + std = np.std(img) + elif isinstance(std, Sequence): + assert len(std) == 1 + std = std[0] + elif not isinstance(std, (int, float)): + raise ValueError('The value for `std` should be a number or `None` ' + 'when working with single-channel images.') + + if std != 0: + return (img-mean)/std + else: + return img-mean + + +def resize(img, size, interpolation=Image.BILINEAR): + r"""Resize the input PIL Image to the given size. + + Args: + img (PIL Image): Image to be resized. + size (sequence or int): Desired output size. If size is a sequence like + (h, w), the output size will be matched to this. If size is an int, + the smaller edge of the image will be matched to this number maintaing + the aspect ratio. i.e, if height > width, then image will be rescaled to + :math:`\left(\text{size} \times \frac{\text{height}}{\text{width}}, \text{size}\right)` + interpolation (int, optional): Desired interpolation. Default is + ``PIL.Image.BILINEAR`` + + Returns: + PIL Image: Resized image. + """ + if not _is_pil_image(img): + raise TypeError('img should be PIL Image. Got {}'.format(type(img))) + if not (isinstance(size, int) or (isinstance(size, Iterable) and len(size) == 2)): + raise TypeError('Got inappropriate size arg: {}'.format(size)) + + if isinstance(size, int): + w, h = img.size + if (w <= h and w == size) or (h <= w and h == size): + return img + if w < h: + ow = size + oh = int(size * h / w) + return img.resize((ow, oh), interpolation) + else: + oh = size + ow = int(size * w / h) + return img.resize((ow, oh), interpolation) + else: + return img.resize(size[::-1], interpolation) + + +def crop(img, i, j, h, w): + """Crop the given PIL Image. + + Args: + img (PIL Image): Image to be cropped. + i (int): i in (i,j) i.e coordinates of the upper left corner. + j (int): j in (i,j) i.e coordinates of the upper left corner. + h (int): Height of the cropped image. + w (int): Width of the cropped image. + + Returns: + PIL Image: Cropped image. + """ + if not _is_pil_image(img): + raise TypeError('img should be PIL Image. Got {}'.format(type(img))) + + return img.crop((j, i, j + w, i + h)) + + +def center_crop(img, output_size): + if isinstance(output_size, numbers.Number): + output_size = (int(output_size), int(output_size)) + w, h = img.size + th, tw = output_size + i = int(round((h - th) / 2.)) + j = int(round((w - tw) / 2.)) + return crop(img, i, j, th, tw) + + +def resized_crop(img, i, j, h, w, size, interpolation=Image.BILINEAR): + """Crop the given PIL Image and resize it to desired size. + + Args: + img (PIL Image): Image to be cropped. + i (int): i in (i,j) i.e coordinates of the upper left corner + j (int): j in (i,j) i.e coordinates of the upper left corner + h (int): Height of the cropped image. + w (int): Width of the cropped image. + size (sequence or int): Desired output size. Same semantics as ``resize``. + interpolation (int, optional): Desired interpolation. Default is + ``PIL.Image.BILINEAR``. + Returns: + PIL Image: Cropped image. + """ + assert _is_pil_image(img), 'img should be PIL Image' + img = crop(img, i, j, h, w) + img = resize(img, size, interpolation) + return img + + +def hflip(img): + """Horizontally flip the given PIL Image. + + Args: + img (PIL Image): Image to be flipped. + + Returns: + PIL Image: Horizontall flipped image. + """ + if not _is_pil_image(img): + raise TypeError('img should be PIL Image. Got {}'.format(type(img))) + + return img.transpose(Image.FLIP_LEFT_RIGHT) + + +def vflip(img): + """Vertically flip the given PIL Image. + + Args: + img (PIL Image): Image to be flipped. + + Returns: + PIL Image: Vertically flipped image. + """ + if not _is_pil_image(img): + raise TypeError('img should be PIL Image. Got {}'.format(type(img))) + + return img.transpose(Image.FLIP_TOP_BOTTOM) + + +def adjust_brightness(img, brightness_factor): + """Adjust brightness of an Image. + + Args: + img (PIL Image): PIL Image to be adjusted. + brightness_factor (float): How much to adjust the brightness. Can be + any non negative number. 0 gives a black image, 1 gives the + original image while 2 increases the brightness by a factor of 2. + + Returns: + PIL Image: Brightness adjusted image. + """ + if not _is_pil_image(img): + raise TypeError('img should be PIL Image. Got {}'.format(type(img))) + + enhancer = ImageEnhance.Brightness(img) + img = enhancer.enhance(brightness_factor) + return img + + +def adjust_contrast(img, contrast_factor): + """Adjust contrast of an Image. + + Args: + img (PIL Image): PIL Image to be adjusted. + contrast_factor (float): How much to adjust the contrast. Can be any + non negative number. 0 gives a solid gray image, 1 gives the + original image while 2 increases the contrast by a factor of 2. + + Returns: + PIL Image: Contrast adjusted image. + """ + if not _is_pil_image(img): + raise TypeError('img should be PIL Image. Got {}'.format(type(img))) + + enhancer = ImageEnhance.Contrast(img) + img = enhancer.enhance(contrast_factor) + return img + + +def adjust_saturation(img, saturation_factor): + """Adjust color saturation of an image. + + Args: + img (PIL Image): PIL Image to be adjusted. + saturation_factor (float): How much to adjust the saturation. 0 will + give a black and white image, 1 will give the original image while + 2 will enhance the saturation by a factor of 2. + + Returns: + PIL Image: Saturation adjusted image. + """ + if not _is_pil_image(img): + raise TypeError('img should be PIL Image. Got {}'.format(type(img))) + + enhancer = ImageEnhance.Color(img) + img = enhancer.enhance(saturation_factor) + return img + + +def adjust_hue(img, hue_factor): + """Adjust hue of an image. + + The image hue is adjusted by converting the image to HSV and + cyclically shifting the intensities in the hue channel (H). + The image is then converted back to original image mode. + + `hue_factor` is the amount of shift in H channel and must be in the + interval `[-0.5, 0.5]`. + + See `Hue`_ for more details. + + .. _Hue: https://en.wikipedia.org/wiki/Hue + + Args: + img (PIL Image): PIL Image to be adjusted. + hue_factor (float): How much to shift the hue channel. Should be in + [-0.5, 0.5]. 0.5 and -0.5 give complete reversal of hue channel in + HSV space in positive and negative direction respectively. + 0 means no shift. Therefore, both -0.5 and 0.5 will give an image + with complementary colors while 0 gives the original image. + + Returns: + PIL Image: Hue adjusted image. + """ + if not(-0.5 <= hue_factor <= 0.5): + raise ValueError('hue_factor - {} is not in [-0.5, 0.5].'.format(hue_factor)) + + if not _is_pil_image(img): + raise TypeError('img should be PIL Image. Got {}'.format(type(img))) + + input_mode = img.mode + if input_mode in {'L', '1', 'I', 'F'}: + return img + + h, s, v = img.convert('HSV').split() + + np_h = np.array(h, dtype=np.uint8) + # uint8 addition take cares of rotation across boundaries + with np.errstate(over='ignore'): + np_h += np.uint8(hue_factor * 255) + h = Image.fromarray(np_h, 'L') + + img = Image.merge('HSV', (h, s, v)).convert(input_mode) + return img + + +def adjust_gamma(img, gamma, gain=1): + r"""Perform gamma correction on an image. + + Also known as Power Law Transform. Intensities in RGB mode are adjusted + based on the following equation: + + .. math:: + I_{\text{out}} = 255 \times \text{gain} \times \left(\frac{I_{\text{in}}}{255}\right)^{\gamma} + + See `Gamma Correction`_ for more details. + + .. _Gamma Correction: https://en.wikipedia.org/wiki/Gamma_correction + + Args: + img (PIL Image): PIL Image to be adjusted. + gamma (float): Non negative real number, same as :math:`\gamma` in the equation. + gamma larger than 1 make the shadows darker, + while gamma smaller than 1 make dark regions lighter. + gain (float): The constant multiplier. + """ + if not _is_pil_image(img): + raise TypeError('img should be PIL Image. Got {}'.format(type(img))) + + if gamma < 0: + raise ValueError('Gamma should be a non-negative real number') + + input_mode = img.mode + img = img.convert('RGB') + + gamma_map = [255 * gain * pow(ele / 255., gamma) for ele in range(256)] * 3 + img = img.point(gamma_map) # use PIL's point-function to accelerate this part + + img = img.convert(input_mode) + return img + + +def rotate(img, angle, resample=False, expand=False, center=None): + """Rotate the image by angle. + + + Args: + img (PIL Image): PIL Image to be rotated. + angle (float or int): In degrees degrees counter clockwise order. + resample (``PIL.Image.NEAREST`` or ``PIL.Image.BILINEAR`` or ``PIL.Image.BICUBIC``, optional): + An optional resampling filter. See `filters`_ for more information. + If omitted, or if the image has mode "1" or "P", it is set to ``PIL.Image.NEAREST``. + expand (bool, optional): Optional expansion flag. + If true, expands the output image to make it large enough to hold the entire rotated image. + If false or omitted, make the output image the same size as the input image. + Note that the expand flag assumes rotation around the center and no translation. + center (2-tuple, optional): Optional center of rotation. + Origin is the upper left corner. + Default is the center of the image. + + .. _filters: https://pillow.readthedocs.io/en/latest/handbook/concepts.html#filters + + """ + assert isinstance(angle, (int, float)), "The angle must be either a float or int." + + if not _is_pil_image(img): + raise TypeError('img should be PIL Image. Got {}'.format(type(img))) + + return img.rotate(angle, resample, expand, center) + + +def to_grayscale(img, num_output_channels=1): + """Convert image to grayscale version of image. + + Args: + img (PIL Image): Image to be converted to grayscale. + + Returns: + PIL Image: Grayscale version of the image. + if num_output_channels = 1 : returned image is single channel + if num_output_channels = 3 : returned image is 3 channel with r = g = b + if num_output_channels = 4 : returned image is 4 channel with r = g = b = a + """ + if not _is_pil_image(img): + raise TypeError('img should be PIL Image. Got {}'.format(type(img))) + + if num_output_channels == 1: + img = img.convert('L') + elif num_output_channels == 3: + img = img.convert('L') + np_img = np.array(img, dtype=np.uint8) + np_img = np.dstack([np_img, np_img, np_img]) + img = Image.fromarray(np_img, 'RGB') + elif num_output_channels == 4: + img = img.convert('L') + np_img = np.array(img, dtype=np.uint8) + np_img = np.dstack([np_img, np_img, np_img, np_img]) + img = Image.fromarray(np_img, 'RGBA') + else: + raise ValueError('num_output_channels should be either 1, 3 or 4') + + return img diff --git a/maxfw/utils/image_utils.py b/maxfw/utils/image_utils.py new file mode 100644 index 0000000..28dda5a --- /dev/null +++ b/maxfw/utils/image_utils.py @@ -0,0 +1,240 @@ +# +# Copyright 2018-2019 IBM Corp. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import division +import sys +from PIL import Image +import collections + +from . import image_functions as F + +if sys.version_info < (3, 3): + Sequence = collections.Sequence + Iterable = collections.Iterable +else: + Sequence = collections.abc.Sequence + Iterable = collections.abc.Iterable + + +class ImageProcessor(object): + """Composes several transforms together. + + Args: + transforms (list of ``Transform`` objects): sequence of transforms to compose. + + Example: + >>> pipeline = ImageProcessor([ + >>> Rotate(150), + >>> Resize([100,100]) + >>> ]) + >>> pipeline.apply_transforms(img) + """ + + def __init__(self, transforms=[]): + assert isinstance(transforms, Sequence) + self.transforms = transforms + + def apply_transforms(self, img): + """ + Sequentially apply the list of transformations to the input image. + + args: + img: an image in bytes format, as a Pillow image object, or a numpy ndarray + + output: + The transformed image. + Depending on the transformation the output is either a Pillow Image object or a numpy ndarray. + """ + # verify whether the Normalize or Standardize transformations are positioned at the end + encoding = [(isinstance(t, Normalize) or isinstance(t, Standardize)) for t in self.transforms] + assert sum(encoding[:-1]) == 0, \ + 'A Standardize or Normalize transformation must be positioned at the end of the pipeline.' + + # apply the transformations + for t in self.transforms: + img = t(img) + return img + + +class ToPILImage(object): + """Convert a byte stream or an ndarray to PIL Image. + + Converts a byte stream or a numpy ndarray of shape + H x W x C to a PIL Image while preserving the value range. + + Args: + mode (`PIL.Image mode`_): color space and pixel depth of input data (optional). + If ``mode`` is ``None`` (default) there are some assumptions made about the input data: + - If the input has 4 channels, the ``mode`` is assumed to be ``RGBA``. + - If the input has 3 channels, the ``mode`` is assumed to be ``RGB``. + - If the input has 2 channels, the ``mode`` is assumed to be ``LA``. + - If the input has 1 channel, the ``mode`` is determined by the data type (i.e ``int``, ``float``, + ``short``). + + .. _PIL.Image mode: https://pillow.readthedocs.io/en/latest/handbook/concepts.html#concept-modes + """ + def __init__(self, target_mode, mode=None): + self.mode = mode + self.target_mode = target_mode + + def __call__(self, pic): + """ + Args: + pic (bytestream or numpy.ndarray): Image to be converted to PIL Image. + + Returns: + PIL Image: Image converted to PIL Image. + + """ + return F.to_pil_image(pic, self.target_mode, self.mode) + + +class PILtoarray(object): + """ + onvert a PIL Image object to a numpy ndarray. + """ + + def __call__(self, pic): + """ + Args: + pic (PIL Image): Image to be converted to a numpy ndarray. + + Returns: + numpy ndarray + + """ + return F.pil_to_array(pic) + + +class Normalize(object): + """ + Normalize the image to a range between [0, 1]. + """ + + def __call__(self, img): + """ + Args: + img (PIL image or numpy.ndarray): Image to be normalized. + + Returns: + numpy.ndarray: Normalized image. + """ + return F.normalize(img) + + +class Standardize(object): + """ + Standardize the image (mean-centering and STD of 1). + + Args: + mean (optional): a single number or an n-dimensional sequence with n equal to the number of image channels + std (optional): a single number or an n-dimensional sequence with n equal to the number of image channels + Returns: + numpy.ndarray: standardized image + + If `mean` or `std` are not provided, the channel-wise values will be calculated for the input image. + """ + def __init__(self, mean=None, std=None): + self.mean = mean + self.std = std + + def __call__(self, img): + """ + Args: + img (PIL image or numpy.ndarray): Image to be standardized. + + Returns: + numpy.ndarray: Standardized image. + """ + return F.standardize(img, self.mean, self.std) + + +class Resize(object): + """Resize the input PIL Image to the given size. + + Args: + size (sequence or int): Desired output size. If size is a sequence like + (h, w), output size will be matched to this. If size is an int, + smaller edge of the image will be matched to this number. + i.e, if height > width, then image will be rescaled to + (size * height / width, size) + interpolation (int, optional): Desired interpolation. Default is + ``PIL.Image.BILINEAR`` + """ + + def __init__(self, size, interpolation=Image.BILINEAR): + assert isinstance(size, int) or (isinstance(size, Sequence) and len(size) == 2) + self.size = size + self.interpolation = interpolation + + def __call__(self, img): + """ + Args: + img (PIL Image): Image to be scaled. + + Returns: + PIL Image: Rescaled image. + """ + return F.resize(img, self.size, self.interpolation) + + +class Rotate(object): + """ + Rotate the input PIL Image by a given angle (counter clockwise). + + Args: + angle (int or float): Counter clockwise angle to rotate the image by. + """ + + def __init__(self, angle): + self.angle = angle + + def __call__(self, img): + """ + Args: + img (PIL Image): Image to be rotated. + + Returns: + PIL Image: Rotated image. + """ + return F.rotate(img, self.angle) + + +class Grayscale(object): + """Convert image to grayscale. + + Args: + num_output_channels (int): (1, 3 or 4) number of channels desired for output image + + Returns: + PIL Image: Grayscale version of the input. + - If num_output_channels == 1 : returned image is single channel + - If num_output_channels == 3 : returned image is 3 channel with r == g == b + - If num_output_channels == 4 : returned image is 3 channel with r == g == b == a + + """ + + def __init__(self, num_output_channels=1): + self.num_output_channels = num_output_channels + + def __call__(self, img): + """ + Args: + img (PIL Image): Image to be converted to grayscale. + + Returns: + PIL Image: Randomly grayscaled image. + """ + return F.to_grayscale(img, num_output_channels=self.num_output_channels) diff --git a/setup.py b/setup.py index e60f51f..e92c627 100644 --- a/setup.py +++ b/setup.py @@ -1,3 +1,18 @@ +# +# Copyright 2018-2019 IBM Corp. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# from setuptools import setup with open('README.md') as f: @@ -12,10 +27,13 @@ author='CODAIT', author_email='djalova@us.ibm.com, nickp@za.ibm.com, brendan.dwyer@ibm.com', license='Apache', - packages=['maxfw', 'maxfw.core', 'maxfw.model'], + packages=['maxfw', 'maxfw.core', 'maxfw.model', 'maxfw.utils'], zip_safe=True, install_requires=[ 'flask-restplus==0.11.0', 'flask-cors', + 'Pillow', ], + test_suite='nose.collector', + tests_require=['nose'] )