diff --git a/docs/index.rst b/docs/index.rst index 5d3433892da8..3913c55b7ea1 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -155,6 +155,7 @@ vision-usage vision-annotations + vision-batch vision-client vision-color vision-entity diff --git a/docs/vision-batch.rst b/docs/vision-batch.rst new file mode 100644 index 000000000000..38d4ec340c47 --- /dev/null +++ b/docs/vision-batch.rst @@ -0,0 +1,10 @@ +Vision Batch +============ + +Batch +~~~~~ + +.. automodule:: google.cloud.vision.batch + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/vision-usage.rst b/docs/vision-usage.rst index c1ca134b8f22..242cb86903a7 100644 --- a/docs/vision-usage.rst +++ b/docs/vision-usage.rst @@ -283,6 +283,40 @@ image and determine the dominant colors in the image. 0.758658 +********************* +Batch image detection +********************* + +Multiple images can be processed with a single request by passing +:class:`~google.cloud.vision.image.Image` to +:meth:`~google.cloud.vision.client.Client.batch()`. + +.. code-block:: python + + >>> from google.cloud import vision + >>> from google.cloud.vision.feature import Feature + >>> from google.cloud.vision.feature import FeatureTypes + >>> + >>> client = vision.Client() + >>> batch = client.batch() + >>> + >>> image_one = client.image(source_uri='gs://my-test-bucket/image1.jpg') + >>> image_two = client.image(source_uri='gs://my-test-bucket/image2.jpg') + >>> face_feature = Feature(FeatureTypes.FACE_DETECTION, 2) + >>> logo_feature = Feature(FeatureTypes.LOGO_DETECTION, 2) + >>> batch.add_image(image_one, [face_feature, logo_feature]) + >>> batch.add_image(image_two, [logo_feature]) + >>> results = batch.detect() + >>> for image in results: + ... for face in image.faces: + ... print('=' * 40) + ... print(face.joy) + ======================================== + + ======================================== + + + **************** No results found **************** diff --git a/system_tests/vision.py b/system_tests/vision.py index f95d5458c3eb..5d5d903c6793 100644 --- a/system_tests/vision.py +++ b/system_tests/vision.py @@ -21,6 +21,8 @@ from google.cloud import storage from google.cloud import vision from google.cloud.vision.entity import EntityAnnotation +from google.cloud.vision.feature import Feature +from google.cloud.vision.feature import FeatureTypes from system_test_utils import unique_resource_id from retry import RetryErrors @@ -507,3 +509,53 @@ def test_detect_properties_filename(self): image = client.image(filename=FACE_FILE) properties = image.detect_properties() self._assert_properties(properties) + + +class TestVisionBatchProcessing(BaseVisionTestCase): + def setUp(self): + self.to_delete_by_case = [] + + def tearDown(self): + for value in self.to_delete_by_case: + value.delete() + + def test_batch_detect_gcs(self): + client = Config.CLIENT + bucket_name = Config.TEST_BUCKET.name + + # Logo GCS image. + blob_name = 'logos.jpg' + blob = Config.TEST_BUCKET.blob(blob_name) + self.to_delete_by_case.append(blob) # Clean-up. + with open(LOGO_FILE, 'rb') as file_obj: + blob.upload_from_file(file_obj) + + logo_source_uri = 'gs://%s/%s' % (bucket_name, blob_name) + + image_one = client.image(source_uri=logo_source_uri) + logo_feature = Feature(FeatureTypes.LOGO_DETECTION, 2) + + # Faces GCS image. + blob_name = 'faces.jpg' + blob = Config.TEST_BUCKET.blob(blob_name) + self.to_delete_by_case.append(blob) # Clean-up. + with open(FACE_FILE, 'rb') as file_obj: + blob.upload_from_file(file_obj) + + face_source_uri = 'gs://%s/%s' % (bucket_name, blob_name) + + image_two = client.image(source_uri=face_source_uri) + face_feature = Feature(FeatureTypes.FACE_DETECTION, 2) + + batch = client.batch() + batch.add_image(image_one, [logo_feature]) + batch.add_image(image_two, [face_feature, logo_feature]) + results = batch.detect() + self.assertEqual(len(results), 2) + self.assertIsInstance(results[0], vision.annotations.Annotations) + self.assertIsInstance(results[1], vision.annotations.Annotations) + self.assertEqual(len(results[0].logos), 1) + self.assertEqual(len(results[0].faces), 0) + + self.assertEqual(len(results[1].logos), 0) + self.assertEqual(len(results[1].faces), 2) diff --git a/vision/google/cloud/vision/_gax.py b/vision/google/cloud/vision/_gax.py index e9eeaf33ab10..55f3dfd4adf2 100644 --- a/vision/google/cloud/vision/_gax.py +++ b/vision/google/cloud/vision/_gax.py @@ -30,24 +30,28 @@ def __init__(self, client=None): self._client = client self._annotator_client = image_annotator_client.ImageAnnotatorClient() - def annotate(self, image, features): + def annotate(self, images): """Annotate images through GAX. - :type image: :class:`~google.cloud.vision.image.Image` - :param image: Instance of ``Image``. - - :type features: list - :param features: List of :class:`~google.cloud.vision.feature.Feature`. + :type images: list + :param images: List containing pairs of + :class:`~google.cloud.vision.image.Image` and + :class:`~google.cloud.vision.feature.Feature`. + e.g. [(image, [feature_one, feature_two]),] :rtype: list :returns: List of :class:`~google.cloud.vision.annotations.Annotations`. """ - gapic_features = [_to_gapic_feature(feature) for feature in features] - gapic_image = _to_gapic_image(image) - request = image_annotator_pb2.AnnotateImageRequest( - image=gapic_image, features=gapic_features) - requests = [request] + requests = [] + for image, features in images: + gapic_features = [_to_gapic_feature(feature) + for feature in features] + gapic_image = _to_gapic_image(image) + request = image_annotator_pb2.AnnotateImageRequest( + image=gapic_image, features=gapic_features) + requests.append(request) + annotator_client = self._annotator_client responses = annotator_client.batch_annotate_images(requests).responses return [Annotations.from_pb(response) for response in responses] diff --git a/vision/google/cloud/vision/_http.py b/vision/google/cloud/vision/_http.py index 5846a2817519..35d9c76ef8c6 100644 --- a/vision/google/cloud/vision/_http.py +++ b/vision/google/cloud/vision/_http.py @@ -29,24 +29,19 @@ def __init__(self, client): self._client = client self._connection = client._connection - def annotate(self, image, features): + def annotate(self, images): """Annotate an image to discover it's attributes. - :type image: :class:`~google.cloud.vision.image.Image` - :param image: A instance of ``Image``. + :type images: list of :class:`~google.cloud.vision.image.Image` + :param images: A list of ``Image``. - :type features: list of :class:`~google.cloud.vision.feature.Feature` - :param features: The type of detection that the Vision API should - use to determine image attributes. Pricing is - based on the number of Feature Types. - - See: https://cloud.google.com/vision/docs/pricing :rtype: list :returns: List of :class:`~googe.cloud.vision.annotations.Annotations`. """ - request = _make_request(image, features) - - data = {'requests': [request]} + requests = [] + for image, features in images: + requests.append(_make_request(image, features)) + data = {'requests': requests} api_response = self._connection.api_request( method='POST', path='/images:annotate', data=data) responses = api_response.get('responses') diff --git a/vision/google/cloud/vision/batch.py b/vision/google/cloud/vision/batch.py new file mode 100644 index 000000000000..1bc0119aeb3a --- /dev/null +++ b/vision/google/cloud/vision/batch.py @@ -0,0 +1,57 @@ +# Copyright 2017 Google Inc. +# +# 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. + +"""Batch multiple images into one request.""" + + +class Batch(object): + """Batch of images to process. + + :type client: :class:`~google.cloud.vision.client.Client` + :param client: Vision client. + """ + def __init__(self, client): + self._client = client + self._images = [] + + def add_image(self, image, features): + """Add image to batch request. + + :type image: :class:`~google.cloud.vision.image.Image` + :param image: Istance of ``Image``. + + :type features: list + :param features: List of :class:`~google.cloud.vision.feature.Feature`. + """ + self._images.append((image, features)) + + @property + def images(self): + """List of images to process. + + :rtype: list + :returns: List of :class:`~google.cloud.vision.image.Image`. + """ + return self._images + + def detect(self): + """Perform batch detection of images. + + :rtype: list + :returns: List of + :class:`~google.cloud.vision.annotations.Annotations`. + """ + results = self._client._vision_api.annotate(self.images) + self._images = [] + return results diff --git a/vision/google/cloud/vision/client.py b/vision/google/cloud/vision/client.py index 518522963197..1fdc00ad81a7 100644 --- a/vision/google/cloud/vision/client.py +++ b/vision/google/cloud/vision/client.py @@ -20,6 +20,7 @@ from google.cloud.environment_vars import DISABLE_GRPC from google.cloud.vision._gax import _GAPICVisionAPI +from google.cloud.vision.batch import Batch from google.cloud.vision.connection import Connection from google.cloud.vision.image import Image from google.cloud.vision._http import _HTTPVisionAPI @@ -71,6 +72,14 @@ def __init__(self, project=None, credentials=None, http=None, else: self._use_gax = use_gax + def batch(self): + """Batch multiple images into a single API request. + + :rtype: :class:`google.cloud.vision.batch.Batch` + :returns: Instance of ``Batch``. + """ + return Batch(self) + def image(self, content=None, filename=None, source_uri=None): """Get instance of Image using current client. diff --git a/vision/google/cloud/vision/image.py b/vision/google/cloud/vision/image.py index 87bf86e2f7e4..561339dce26a 100644 --- a/vision/google/cloud/vision/image.py +++ b/vision/google/cloud/vision/image.py @@ -94,21 +94,17 @@ def source(self): """ return self._source - def _detect_annotation(self, features): + def _detect_annotation(self, images): """Generic method for detecting annotations. - :type features: list - :param features: List of :class:`~google.cloud.vision.feature.Feature` - indicating the type of annotations to perform. + :type images: list + :param images: List of :class:`~google.cloud.vision.image.Image`. :rtype: list :returns: List of - :class:`~google.cloud.vision.entity.EntityAnnotation`, - :class:`~google.cloud.vision.face.Face`, - :class:`~google.cloud.vision.color.ImagePropertiesAnnotation`, - :class:`~google.cloud.vision.sage.SafeSearchAnnotation`, + :class:`~google.cloud.vision.annotations.Annotations`. """ - return self.client._vision_api.annotate(self, features) + return self.client._vision_api.annotate(images) def detect(self, features): """Detect multiple feature types. @@ -121,7 +117,8 @@ def detect(self, features): :returns: List of :class:`~google.cloud.vision.entity.EntityAnnotation`. """ - return self._detect_annotation(features) + images = ((self, features),) + return self._detect_annotation(images) def detect_faces(self, limit=10): """Detect faces in image. @@ -133,7 +130,7 @@ def detect_faces(self, limit=10): :returns: List of :class:`~google.cloud.vision.face.Face`. """ features = [Feature(FeatureTypes.FACE_DETECTION, limit)] - annotations = self._detect_annotation(features) + annotations = self.detect(features) return annotations[0].faces def detect_labels(self, limit=10): @@ -146,7 +143,7 @@ def detect_labels(self, limit=10): :returns: List of :class:`~google.cloud.vision.entity.EntityAnnotation` """ features = [Feature(FeatureTypes.LABEL_DETECTION, limit)] - annotations = self._detect_annotation(features) + annotations = self.detect(features) return annotations[0].labels def detect_landmarks(self, limit=10): @@ -160,7 +157,7 @@ def detect_landmarks(self, limit=10): :class:`~google.cloud.vision.entity.EntityAnnotation`. """ features = [Feature(FeatureTypes.LANDMARK_DETECTION, limit)] - annotations = self._detect_annotation(features) + annotations = self.detect(features) return annotations[0].landmarks def detect_logos(self, limit=10): @@ -174,7 +171,7 @@ def detect_logos(self, limit=10): :class:`~google.cloud.vision.entity.EntityAnnotation`. """ features = [Feature(FeatureTypes.LOGO_DETECTION, limit)] - annotations = self._detect_annotation(features) + annotations = self.detect(features) return annotations[0].logos def detect_properties(self, limit=10): @@ -188,7 +185,7 @@ def detect_properties(self, limit=10): :class:`~google.cloud.vision.color.ImagePropertiesAnnotation`. """ features = [Feature(FeatureTypes.IMAGE_PROPERTIES, limit)] - annotations = self._detect_annotation(features) + annotations = self.detect(features) return annotations[0].properties def detect_safe_search(self, limit=10): @@ -202,7 +199,7 @@ def detect_safe_search(self, limit=10): :class:`~google.cloud.vision.sage.SafeSearchAnnotation`. """ features = [Feature(FeatureTypes.SAFE_SEARCH_DETECTION, limit)] - annotations = self._detect_annotation(features) + annotations = self.detect(features) return annotations[0].safe_searches def detect_text(self, limit=10): @@ -216,5 +213,5 @@ def detect_text(self, limit=10): :class:`~google.cloud.vision.entity.EntityAnnotation`. """ features = [Feature(FeatureTypes.TEXT_DETECTION, limit)] - annotations = self._detect_annotation(features) + annotations = self.detect(features) return annotations[0].texts diff --git a/vision/unit_tests/test__gax.py b/vision/unit_tests/test__gax.py index 8e52b166e394..31383936d0df 100644 --- a/vision/unit_tests/test__gax.py +++ b/vision/unit_tests/test__gax.py @@ -54,7 +54,8 @@ def test_annotation(self): spec_set=['batch_annotate_images'], **mock_response) with mock.patch('google.cloud.vision._gax.Annotations') as mock_anno: - gax_api.annotate(image, [feature]) + images = ((image, [feature]),) + gax_api.annotate(images) mock_anno.from_pb.assert_called_with('mock response data') gax_api._annotator_client.batch_annotate_images.assert_called() @@ -78,7 +79,8 @@ def test_annotate_no_results(self): gax_api._annotator_client = mock.Mock( spec_set=['batch_annotate_images'], **mock_response) with mock.patch('google.cloud.vision._gax.Annotations'): - response = gax_api.annotate(image, [feature]) + images = ((image, [feature]),) + response = gax_api.annotate(images) self.assertEqual(len(response), 0) self.assertIsInstance(response, list) @@ -109,7 +111,8 @@ def test_annotate_multiple_results(self): gax_api._annotator_client = mock.Mock( spec_set=['batch_annotate_images']) gax_api._annotator_client.batch_annotate_images.return_value = response - responses = gax_api.annotate(image, [feature]) + images = ((image, [feature]),) + responses = gax_api.annotate(images) self.assertEqual(len(responses), 2) self.assertIsInstance(responses[0], Annotations) diff --git a/vision/unit_tests/test__http.py b/vision/unit_tests/test__http.py index 9293820915e6..e0eb690352b0 100644 --- a/vision/unit_tests/test__http.py +++ b/vision/unit_tests/test__http.py @@ -44,7 +44,8 @@ def test_call_annotate_with_no_results(self): http_api = self._make_one(client) http_api._connection = mock.Mock(spec_set=['api_request']) http_api._connection.api_request.return_value = {'responses': []} - response = http_api.annotate(image, [feature]) + images = ((image, [feature]),) + response = http_api.annotate(images) self.assertEqual(len(response), 0) self.assertIsInstance(response, list) @@ -63,7 +64,8 @@ def test_call_annotate_with_more_than_one_result(self): http_api = self._make_one(client) http_api._connection = mock.Mock(spec_set=['api_request']) http_api._connection.api_request.return_value = MULTIPLE_RESPONSE - responses = http_api.annotate(image, [feature]) + images = ((image, [feature]),) + responses = http_api.annotate(images) self.assertEqual(len(responses), 2) image_one = responses[0] diff --git a/vision/unit_tests/test_batch.py b/vision/unit_tests/test_batch.py new file mode 100644 index 000000000000..c9c59c1baf45 --- /dev/null +++ b/vision/unit_tests/test_batch.py @@ -0,0 +1,77 @@ +# Copyright 2017 Google Inc. +# +# 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 unittest + +import mock + +PROJECT = 'PROJECT' + + +def _make_credentials(): + import google.auth.credentials + return mock.Mock(spec=google.auth.credentials.Credentials) + + +class TestBatch(unittest.TestCase): + @staticmethod + def _get_target_class(): + from google.cloud.vision.batch import Batch + + return Batch + + def _make_one(self, *args, **kw): + return self._get_target_class()(*args, **kw) + + def test_ctor(self): + from google.cloud.vision.feature import Feature + from google.cloud.vision.feature import FeatureTypes + from google.cloud.vision.image import Image + + client = mock.Mock() + image = Image(client, source_uri='gs://images/imageone.jpg') + face_feature = Feature(FeatureTypes.FACE_DETECTION, 5) + logo_feature = Feature(FeatureTypes.LOGO_DETECTION, 3) + + batch = self._make_one(client) + batch.add_image(image, [logo_feature, face_feature]) + self.assertEqual(len(batch.images), 1) + self.assertEqual(len(batch.images[0]), 2) + self.assertIsInstance(batch.images[0][0], Image) + self.assertEqual(len(batch.images[0][1]), 2) + self.assertIsInstance(batch.images[0][1][0], Feature) + self.assertIsInstance(batch.images[0][1][1], Feature) + + def test_batch_from_client(self): + from google.cloud.vision.client import Client + from google.cloud.vision.feature import Feature + from google.cloud.vision.feature import FeatureTypes + + creds = _make_credentials() + client = Client(project=PROJECT, credentials=creds) + + image_one = client.image(source_uri='gs://images/imageone.jpg') + image_two = client.image(source_uri='gs://images/imagtwo.jpg') + face_feature = Feature(FeatureTypes.FACE_DETECTION, 5) + logo_feature = Feature(FeatureTypes.LOGO_DETECTION, 3) + client._vision_api_internal = mock.Mock() + client._vision_api_internal.annotate.return_value = True + batch = client.batch() + batch.add_image(image_one, [face_feature]) + batch.add_image(image_two, [logo_feature, face_feature]) + images = batch.images + self.assertEqual(len(images), 2) + self.assertTrue(batch.detect()) + self.assertEqual(len(batch.images), 0) + client._vision_api_internal.annotate.assert_called_with(images) diff --git a/vision/unit_tests/test_client.py b/vision/unit_tests/test_client.py index 1224dabd0dea..e1f23f6d4be9 100644 --- a/vision/unit_tests/test_client.py +++ b/vision/unit_tests/test_client.py @@ -33,6 +33,7 @@ class TestClient(unittest.TestCase): @staticmethod def _get_target_class(): from google.cloud.vision.client import Client + return Client def _make_one(self, *args, **kw): @@ -104,7 +105,8 @@ def test_face_annotation(self): features = [Feature(feature_type=FeatureTypes.FACE_DETECTION, max_results=3)] image = client.image(content=IMAGE_CONTENT) - api_response = client._vision_api.annotate(image, features) + images = ((image, features),) + api_response = client._vision_api.annotate(images) self.assertEqual(len(api_response), 1) response = api_response[0]