diff --git a/docs/index.rst b/docs/index.rst index 0b5ab3be38df..fc363d5271d5 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -158,11 +158,14 @@ vision-batch vision-client vision-color + vision-crop-hint vision-entity vision-feature vision-face vision-image vision-safe-search + vision-text + vision-web .. toctree:: :maxdepth: 0 diff --git a/docs/vision-crop-hint.rst b/docs/vision-crop-hint.rst new file mode 100644 index 000000000000..14be33de2761 --- /dev/null +++ b/docs/vision-crop-hint.rst @@ -0,0 +1,10 @@ +Vision Crop Hint +================ + +Crop Hint +~~~~~~~~~ + +.. automodule:: google.cloud.vision.crop_hint + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/vision-text.rst b/docs/vision-text.rst new file mode 100644 index 000000000000..85f162494a42 --- /dev/null +++ b/docs/vision-text.rst @@ -0,0 +1,10 @@ +Vision Full Text +================ + +Full Text Annotation +~~~~~~~~~~~~~~~~~~~~ + +.. automodule:: google.cloud.vision.text + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/vision-usage.rst b/docs/vision-usage.rst index 242cb86903a7..7c4e52dfa57a 100644 --- a/docs/vision-usage.rst +++ b/docs/vision-usage.rst @@ -98,6 +98,30 @@ You can call the detection method manually. 'github' +********** +Crop Hints +********** + +:meth:`~google.cloud.vision.image.Image.detect_crop_hints` will attempt to find +boundaries that contain interesting data which can be used to crop an image. + +.. code-block:: python + + >>> from google.cloud import vision + >>> client = vision.Client() + >>> image = client.image(source_uri='gs://my-test-bucket/image.jpg') + >>> crop_hints = image.detect_crop_hints(aspect_ratios=[1.3333], limit=2) + >>> first_hint = crop_hints[0] + >>> first_hint.bounds.vertices[0].x_coordinate + 77 + >>> first_hint.bounds.vertices[0].y_coordinate + 102 + >>> first_hint.confidence + 0.5 + >>> first_hint.importance_fraction + 1.22000002861 + + ************** Face Detection ************** @@ -317,6 +341,43 @@ Multiple images can be processed with a single request by passing +************* +Web Detection +************* + +:meth:`~google.cloud.vision.image.Image.detect_web` search for images on the +web that are similar to the image you have. + +.. code-block:: python + + >>> from google.cloud import vision + >>> client = vision.Client() + >>> with open('./image.jpg', 'rb') as image_file: + ... image = client.image(content=image_file.read()) + >>> web_images = image.detect_web(limit=2) + >>> for full_matching_image in web_images.full_matching_images: + ... print('=' * 20) + ... print(full_matching_image.url) + ==================== + 'https://example.com/image.jpg' + >>> for partial_matching_image in web_images.partial_matching_images: + ... print('=' * 20) + ... print(partial_matching_image.url) + ==================== + >>> for page_with_matching_images in web_images.pages_with_matching_images: + ... print('=' * 20) + ... print(page_with_matching_images.url) + ==================== + 'https://example.com/portfolio/' + >>> for entity in web_images.web_entities: + ... print('=' * 20) + ... print(entity.description) + ==================== + 'Mount Rushmore National Memorial' + ==================== + 'Landmark' + + **************** No results found **************** diff --git a/docs/vision-web.rst b/docs/vision-web.rst new file mode 100644 index 000000000000..e4df464c12c7 --- /dev/null +++ b/docs/vision-web.rst @@ -0,0 +1,10 @@ +Vision Web Annotations +====================== + +Web Annotations +~~~~~~~~~~~~~~~ + +.. automodule:: google.cloud.vision.web + :members: + :undoc-members: + :show-inheritance: diff --git a/logging/google/cloud/logging/logger.py b/logging/google/cloud/logging/logger.py index d5a5b201dca0..4ea35881765c 100644 --- a/logging/google/cloud/logging/logger.py +++ b/logging/google/cloud/logging/logger.py @@ -171,7 +171,7 @@ def log_text(self, text, client=None, labels=None, insert_id=None, See: https://cloud.google.com/logging/docs/api/reference/rest/v2/entries/write - :type text: text + :type text: str :param text: the log message. :type client: :class:`~google.cloud.logging.client.Client` or diff --git a/storage/google/cloud/storage/blob.py b/storage/google/cloud/storage/blob.py index 8d30a94b600b..a7504723bec5 100644 --- a/storage/google/cloud/storage/blob.py +++ b/storage/google/cloud/storage/blob.py @@ -659,7 +659,7 @@ def upload_from_string(self, data, content_type='text/plain', client=None): `lifecycle `_ API documents for details. - :type data: bytes or text + :type data: bytes or str :param data: The data to store in this blob. If the value is text, it will be encoded as UTF-8. diff --git a/system_tests/data/full-text.jpg b/system_tests/data/full-text.jpg new file mode 100644 index 000000000000..df864330c6b6 Binary files /dev/null and b/system_tests/data/full-text.jpg differ diff --git a/system_tests/vision.py b/system_tests/vision.py index 5d5d903c6793..a317216e1d48 100644 --- a/system_tests/vision.py +++ b/system_tests/vision.py @@ -35,6 +35,7 @@ LABEL_FILE = os.path.join(_SYS_TESTS_DIR, 'data', 'car.jpg') LANDMARK_FILE = os.path.join(_SYS_TESTS_DIR, 'data', 'landmark.jpg') TEXT_FILE = os.path.join(_SYS_TESTS_DIR, 'data', 'text.jpg') +FULL_TEXT_FILE = os.path.join(_SYS_TESTS_DIR, 'data', 'full-text.jpg') class Config(object): @@ -80,6 +81,107 @@ def _pb_not_implemented_skip(self, message): self.skipTest(message) +class TestVisionFullText(unittest.TestCase): + def setUp(self): + self.to_delete_by_case = [] + + def tearDown(self): + for value in self.to_delete_by_case: + value.delete() + + def _assert_full_text(self, full_text): + from google.cloud.vision.text import TextAnnotation + + self.assertIsInstance(full_text, TextAnnotation) + self.assertIsInstance(full_text.text, six.text_type) + self.assertEqual(len(full_text.pages), 1) + self.assertIsInstance(full_text.pages[0].width, int) + self.assertIsInstance(full_text.pages[0].height, int) + + def test_detect_full_text_content(self): + client = Config.CLIENT + with open(FULL_TEXT_FILE, 'rb') as image_file: + image = client.image(content=image_file.read()) + full_text = image.detect_full_text() + self._assert_full_text(full_text) + + def test_detect_full_text_filename(self): + client = Config.CLIENT + image = client.image(filename=FULL_TEXT_FILE) + full_text = image.detect_full_text() + self._assert_full_text(full_text) + + def test_detect_full_text_gcs(self): + bucket_name = Config.TEST_BUCKET.name + blob_name = 'full-text.jpg' + blob = Config.TEST_BUCKET.blob(blob_name) + self.to_delete_by_case.append(blob) # Clean-up. + with open(FULL_TEXT_FILE, 'rb') as file_obj: + blob.upload_from_file(file_obj) + + source_uri = 'gs://%s/%s' % (bucket_name, blob_name) + + client = Config.CLIENT + image = client.image(source_uri=source_uri) + full_text = image.detect_full_text() + self._assert_full_text(full_text) + + +class TestVisionClientCropHint(BaseVisionTestCase): + def setUp(self): + self.to_delete_by_case = [] + + def tearDown(self): + for value in self.to_delete_by_case: + value.delete() + + def _assert_crop_hint(self, hint): + from google.cloud.vision.crop_hint import CropHint + from google.cloud.vision.geometry import Bounds + + self.assertIsInstance(hint, CropHint) + self.assertIsInstance(hint.bounds, Bounds) + self.assertGreater(hint.bounds.vertices, 1) + self.assertIsInstance(hint.confidence, (int, float)) + self.assertIsInstance(hint.importance_fraction, float) + + def test_detect_crop_hints_content(self): + client = Config.CLIENT + with open(FACE_FILE, 'rb') as image_file: + image = client.image(content=image_file.read()) + crop_hints = image.detect_crop_hints( + aspect_ratios=[1.3333, 1.7777], limit=2) + self.assertEqual(len(crop_hints), 2) + for hint in crop_hints: + self._assert_crop_hint(hint) + + def test_detect_crop_hints_filename(self): + client = Config.CLIENT + image = client.image(filename=FACE_FILE) + crop_hints = image.detect_crop_hints( + aspect_ratios=[1.3333, 1.7777], limit=2) + self.assertEqual(len(crop_hints), 2) + for hint in crop_hints: + self._assert_crop_hint(hint) + + def test_detect_crop_hints_gcs(self): + bucket_name = Config.TEST_BUCKET.name + 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) + + source_uri = 'gs://%s/%s' % (bucket_name, blob_name) + client = Config.CLIENT + image = client.image(source_uri=source_uri) + crop_hints = image.detect_crop_hints( + aspect_ratios=[1.3333, 1.7777], limit=2) + self.assertEqual(len(crop_hints), 2) + for hint in crop_hints: + self._assert_crop_hint(hint) + + class TestVisionClientLogo(unittest.TestCase): def setUp(self): self.to_delete_by_case = [] @@ -559,3 +661,82 @@ def test_batch_detect_gcs(self): self.assertEqual(len(results[1].logos), 0) self.assertEqual(len(results[1].faces), 2) + + +class TestVisionWebAnnotation(BaseVisionTestCase): + def setUp(self): + self.to_delete_by_case = [] + + def tearDown(self): + for value in self.to_delete_by_case: + value.delete() + + def _assert_web_entity(self, web_entity): + from google.cloud.vision.web import WebEntity + + self.assertIsInstance(web_entity, WebEntity) + self.assertIsInstance(web_entity.entity_id, six.text_type) + self.assertIsInstance(web_entity.score, float) + self.assertIsInstance(web_entity.description, six.text_type) + + def _assert_web_image(self, web_image): + from google.cloud.vision.web import WebImage + + self.assertIsInstance(web_image, WebImage) + self.assertIsInstance(web_image.url, six.text_type) + self.assertIsInstance(web_image.score, float) + + def _assert_web_page(self, web_page): + from google.cloud.vision.web import WebPage + + self.assertIsInstance(web_page, WebPage) + self.assertIsInstance(web_page.url, six.text_type) + self.assertIsInstance(web_page.score, float) + + def _assert_web_images(self, web_images, limit): + self.assertEqual(len(web_images.web_entities), limit) + for web_entity in web_images.web_entities: + self._assert_web_entity(web_entity) + + self.assertEqual(len(web_images.full_matching_images), limit) + for web_image in web_images.full_matching_images: + self._assert_web_image(web_image) + + self.assertEqual(len(web_images.partial_matching_images), limit) + for web_image in web_images.partial_matching_images: + self._assert_web_image(web_image) + + self.assertEqual(len(web_images.pages_with_matching_images), limit) + for web_page in web_images.pages_with_matching_images: + self._assert_web_page(web_page) + + def test_detect_web_images_from_content(self): + client = Config.CLIENT + with open(LANDMARK_FILE, 'rb') as image_file: + image = client.image(content=image_file.read()) + limit = 5 + web_images = image.detect_web(limit=limit) + self._assert_web_images(web_images, limit) + + def test_detect_web_images_from_gcs(self): + client = Config.CLIENT + bucket_name = Config.TEST_BUCKET.name + blob_name = 'landmark.jpg' + blob = Config.TEST_BUCKET.blob(blob_name) + self.to_delete_by_case.append(blob) # Clean-up. + with open(LANDMARK_FILE, 'rb') as file_obj: + blob.upload_from_file(file_obj) + + source_uri = 'gs://%s/%s' % (bucket_name, blob_name) + + image = client.image(source_uri=source_uri) + limit = 5 + web_images = image.detect_web(limit=limit) + self._assert_web_images(web_images, limit) + + def test_detect_web_images_from_filename(self): + client = Config.CLIENT + image = client.image(filename=LANDMARK_FILE) + limit = 5 + web_images = image.detect_web(limit=limit) + self._assert_web_images(web_images, limit) diff --git a/vision/google/cloud/vision/_gax.py b/vision/google/cloud/vision/_gax.py index d738288b5aca..44a55e0f09e5 100644 --- a/vision/google/cloud/vision/_gax.py +++ b/vision/google/cloud/vision/_gax.py @@ -33,7 +33,7 @@ def __init__(self, client=None): credentials=client._credentials, lib_name='gccl', lib_version=__version__) - def annotate(self, images): + def annotate(self, images=None, requests_pb=None): """Annotate images through GAX. :type images: list @@ -42,18 +42,28 @@ def annotate(self, images): :class:`~google.cloud.vision.feature.Feature`. e.g. [(image, [feature_one, feature_two]),] + :type requests_pb: list + :param requests_pb: List of :class:`google.cloud.proto.vision.v1.\ + image_annotator_pb2.AnnotateImageRequest` + :rtype: list :returns: List of :class:`~google.cloud.vision.annotations.Annotations`. """ - 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) + if any([images, requests_pb]) is False: + return [] + + if requests_pb is None: + 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) + else: + requests = requests_pb annotator_client = self._annotator_client responses = annotator_client.batch_annotate_images(requests).responses @@ -89,9 +99,16 @@ def _to_gapic_image(image): if image.content is not None: return image_annotator_pb2.Image(content=image.content) if image.source is not None: - return image_annotator_pb2.Image( - source=image_annotator_pb2.ImageSource( - gcs_image_uri=image.source - ), - ) + if image.source.startswith('gs://'): + return image_annotator_pb2.Image( + source=image_annotator_pb2.ImageSource( + gcs_image_uri=image.source + ), + ) + elif image.source.startswith(('http://', 'https://')): + return image_annotator_pb2.Image( + source=image_annotator_pb2.ImageSource( + image_uri=image.source + ), + ) raise ValueError('No image content or source found.') diff --git a/vision/google/cloud/vision/_http.py b/vision/google/cloud/vision/_http.py index 7ce94e218a3a..178e2c8dc0ec 100644 --- a/vision/google/cloud/vision/_http.py +++ b/vision/google/cloud/vision/_http.py @@ -14,6 +14,7 @@ """HTTP Client for interacting with the Google Cloud Vision API.""" +import json from google.cloud import _http @@ -21,6 +22,8 @@ from google.cloud.vision.annotations import Annotations from google.cloud.vision.feature import Feature +from google.protobuf import json_format + _CLIENT_INFO = _http.CLIENT_INFO_TEMPLATE.format(__version__) @@ -57,19 +60,35 @@ def __init__(self, client): self._client = client self._connection = Connection(client) - def annotate(self, images): + def annotate(self, images=None, requests_pb=None): """Annotate an image to discover it's attributes. :type images: list of :class:`~google.cloud.vision.image.Image` :param images: A list of ``Image``. + :rtype: list + :returns: List of :class:`~googe.cloud.vision.annotations.Annotations`. + + :type requests_pb: list + :param requests_pb: List of :class:`google.cloud.proto.vision.v1.\ + image_annotator_b2.AnnotateImageRequest`. + :rtype: list :returns: List of :class:`~googe.cloud.vision.annotations.Annotations`. """ + if any([images, requests_pb]) is False: + return [] + requests = [] - for image, features in images: - requests.append(_make_request(image, features)) + if requests_pb is None: + for image, features in images: + requests.append(_make_request(image, features)) + else: + requests = [json.loads(json_format.MessageToJson(request)) + for request in requests_pb] + 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/annotations.py b/vision/google/cloud/vision/annotations.py index 053bb26d6f68..eed9ebdc5d61 100644 --- a/vision/google/cloud/vision/annotations.py +++ b/vision/google/cloud/vision/annotations.py @@ -12,37 +12,55 @@ # See the License for the specific language governing permissions and # limitations under the License. +# pylint: disable=too-many-arguments """Annotations management for Vision API responses.""" import six from google.cloud.vision.color import ImagePropertiesAnnotation +from google.cloud.vision.crop_hint import CropHint from google.cloud.vision.entity import EntityAnnotation from google.cloud.vision.face import Face from google.cloud.vision.safe_search import SafeSearchAnnotation +from google.cloud.vision.text import TextAnnotation +from google.cloud.vision.web import WebDetection +_CROP_HINTS_ANNOTATION = 'cropHintsAnnotation' _FACE_ANNOTATIONS = 'faceAnnotations' +_FULL_TEXT_ANNOTATION = 'fullTextAnnotation' _IMAGE_PROPERTIES_ANNOTATION = 'imagePropertiesAnnotation' _SAFE_SEARCH_ANNOTATION = 'safeSearchAnnotation' +_WEB_ANNOTATION = 'webDetection' _KEY_MAP = { + _CROP_HINTS_ANNOTATION: 'crop_hints', _FACE_ANNOTATIONS: 'faces', + _FULL_TEXT_ANNOTATION: 'full_texts', _IMAGE_PROPERTIES_ANNOTATION: 'properties', 'labelAnnotations': 'labels', 'landmarkAnnotations': 'landmarks', 'logoAnnotations': 'logos', _SAFE_SEARCH_ANNOTATION: 'safe_searches', - 'textAnnotations': 'texts' + 'textAnnotations': 'texts', + _WEB_ANNOTATION: 'web', } class Annotations(object): """Helper class to bundle annotation responses. + :type crop_hints: list + :param crop_hints: List of + :class:`~google.cloud.vision.crop_hint.CropHintsAnnotation`. + :type faces: list :param faces: List of :class:`~google.cloud.vision.face.Face`. + :type full_texts: list + :param full_texts: List of + :class:`~google.cloud.vision.text.TextAnnotation`. + :type properties: list :param properties: List of :class:`~google.cloud.vision.color.ImagePropertiesAnnotation`. @@ -66,16 +84,23 @@ class Annotations(object): :type texts: list :param texts: List of :class:`~google.cloud.vision.entity.EntityAnnotation`. + + :type web: list + :param web: List of :class:`~google.cloud.vision.web.WebDetection`. """ - def __init__(self, faces=(), properties=(), labels=(), landmarks=(), - logos=(), safe_searches=(), texts=()): + def __init__(self, crop_hints=(), faces=(), full_texts=(), properties=(), + labels=(), landmarks=(), logos=(), safe_searches=(), + texts=(), web=()): + self.crop_hints = crop_hints self.faces = faces + self.full_texts = full_texts self.properties = properties self.labels = labels self.landmarks = landmarks self.logos = logos self.safe_searches = safe_searches self.texts = texts + self.web = web @classmethod def from_api_repr(cls, response): @@ -121,7 +146,9 @@ def _process_image_annotations(image): :returns: Dictionary populated with entities from response. """ return { + 'crop_hints': _make_crop_hints_from_pb(image.crop_hints_annotation), 'faces': _make_faces_from_pb(image.face_annotations), + 'full_texts': _make_full_text_from_pb(image.full_text_annotation), 'labels': _make_entity_from_pb(image.label_annotations), 'landmarks': _make_entity_from_pb(image.landmark_annotations), 'logos': _make_entity_from_pb(image.logo_annotations), @@ -130,9 +157,24 @@ def _process_image_annotations(image): 'safe_searches': _make_safe_search_from_pb( image.safe_search_annotation), 'texts': _make_entity_from_pb(image.text_annotations), + 'web': _make_web_detection_from_pb(image.web_detection) } +def _make_crop_hints_from_pb(crop_hints): + """Create list of ``CropHint`` objects from a protobuf response. + + :type crop_hints: list + :param crop_hints: List of + :class:`google.cloud.grpc.vision.v1.\ + image_annotator_pb2.CropHintsAnnotation` + + :rtype: list + :returns: List of ``CropHint`` objects. + """ + return [CropHint.from_pb(hint) for hint in crop_hints.crop_hints] + + def _make_entity_from_pb(annotations): """Create an entity from a protobuf response. @@ -159,6 +201,19 @@ def _make_faces_from_pb(faces): return [Face.from_pb(face) for face in faces] +def _make_full_text_from_pb(full_text): + """Create text annotation object from protobuf response. + + :type full_text: :class:`~google.cloud.proto.vision.v1.\ + text_annotation_pb2.TextAnnotation` + :param full_text: Protobuf instance of ``TextAnnotation``. + + :rtype: :class:`~google.cloud.vision.text.TextAnnotation` + :returns: Instance of ``TextAnnotation``. + """ + return TextAnnotation.from_pb(full_text) + + def _make_image_properties_from_pb(image_properties): """Create ``ImageProperties`` object from a protobuf response. @@ -186,6 +241,19 @@ def _make_safe_search_from_pb(safe_search): return SafeSearchAnnotation.from_pb(safe_search) +def _make_web_detection_from_pb(annotation): + """Create ``WebDetection`` object from a protobuf response. + + :type annotation: :class:`~google.cloud.proto.vision.v1.web_detection_pb2\ + .WebDetection` + :param annotation: Protobuf instance of ``WebDetection``. + + :rtype: :class: `~google.cloud.vision.web.WebDetection` + :returns: Instance of ``WebDetection``. + """ + return WebDetection.from_pb(annotation) + + def _entity_from_response_type(feature_type, results): """Convert a JSON result to an entity type based on the feature. @@ -207,6 +275,14 @@ def _entity_from_response_type(feature_type, results): return ImagePropertiesAnnotation.from_api_repr(results) elif feature_type == _SAFE_SEARCH_ANNOTATION: return SafeSearchAnnotation.from_api_repr(results) + elif feature_type == _WEB_ANNOTATION: + return WebDetection.from_api_repr(results) + elif feature_type == _CROP_HINTS_ANNOTATION: + crop_hints = results.get('cropHints', []) + detected_objects.extend( + CropHint.from_api_repr(result) for result in crop_hints) + elif feature_type == _FULL_TEXT_ANNOTATION: + return TextAnnotation.from_api_repr(results) else: for result in results: detected_objects.append(EntityAnnotation.from_api_repr(result)) diff --git a/vision/google/cloud/vision/client.py b/vision/google/cloud/vision/client.py index c102fd16ecc7..50907519f4c9 100644 --- a/vision/google/cloud/vision/client.py +++ b/vision/google/cloud/vision/client.py @@ -88,7 +88,7 @@ def image(self, content=None, filename=None, source_uri=None): :param filename: Filename to image. :type source_uri: str - :param source_uri: Google Cloud Storage URI of image. + :param source_uri: URL or Google Cloud Storage URI of image. :rtype: :class:`~google.cloud.vision.image.Image` :returns: Image instance with the current client attached. diff --git a/vision/google/cloud/vision/crop_hint.py b/vision/google/cloud/vision/crop_hint.py new file mode 100644 index 000000000000..4d04fbb9b075 --- /dev/null +++ b/vision/google/cloud/vision/crop_hint.py @@ -0,0 +1,92 @@ +# 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. + +"""Representation of Vision API's crop hints.""" + +from google.cloud.vision.geometry import Bounds + + +class CropHint(object): + """Representation of a crop hint returned from the Vision API. + + :type bounds: dict + :param bounds: Dictionary of boundary information of detected entity. + + :type confidence: float + :param confidence: Confidence of this being a salient region. + + :type importance_fraction: float + :param importance_fraction: Fraction of importance of this region. + """ + def __init__(self, bounds, confidence, importance_fraction): + self._bounds = bounds + self._confidence = confidence + self._importance_fraction = importance_fraction + + @classmethod + def from_api_repr(cls, response): + """Factory: construct ``CropHint`` from Vision API response. + + :type response: dict + :param response: Dictionary response from Vision API with entity data. + + :rtype: :class:`~google.cloud.vision.crop_hint.CropHint` + :returns: Instance of ``CropHint``. + """ + bounds = Bounds.from_api_repr(response.get('boundingPoly')) + confidence = response.get('confidence', 0.0) + importance_fraction = response.get('importanceFraction', 0.0) + return cls(bounds, confidence, importance_fraction) + + @classmethod + def from_pb(cls, response): + """Factory: construct ``CropHint`` from Vision gRPC response. + + :type response: :class:`google.cloud.proto.vision.v1.\ + image_annotator_pb2.CropHint` + :param response: gRPC response from Vision API with entity data. + + :rtype: :class:`~google.cloud.vision.crop_hint.CropHint` + :returns: Instance of ``CropHint``. + """ + bounds = Bounds.from_pb(response.bounding_poly) + return cls(bounds, response.confidence, response.importance_fraction) + + @property + def bounds(self): + """Bounding polygon of crop hints. + + :rtype: :class:`~google.cloud.vision.geometry.Bounds` + :returns: Instance of ``Bounds`` with populated vertices. + """ + return self._bounds + + @property + def confidence(self): + """Confidence of this being a salient region. Range [0, 1]. + + :rtype: float + :returns: float between 0 and 1, inclusive. + """ + return self._confidence + + @property + def importance_fraction(self): + """Fraction of importance of this salient region with respect to the + original image. + + :rtype: float + :returns: float + """ + return self._importance_fraction diff --git a/vision/google/cloud/vision/feature.py b/vision/google/cloud/vision/feature.py index 2870a38bf080..df7d5f5a1e7d 100644 --- a/vision/google/cloud/vision/feature.py +++ b/vision/google/cloud/vision/feature.py @@ -21,13 +21,17 @@ class FeatureTypes(object): See: https://cloud.google.com/vision/reference/rest/v1/images/annotate#Type """ + CROP_HINTS = 'CROP_HINTS' + DOCUMENT_TEXT_DETECTION = 'DOCUMENT_TEXT_DETECTION' FACE_DETECTION = 'FACE_DETECTION' + IMAGE_PROPERTIES = 'IMAGE_PROPERTIES' LANDMARK_DETECTION = 'LANDMARK_DETECTION' LOGO_DETECTION = 'LOGO_DETECTION' LABEL_DETECTION = 'LABEL_DETECTION' TEXT_DETECTION = 'TEXT_DETECTION' SAFE_SEARCH_DETECTION = 'SAFE_SEARCH_DETECTION' IMAGE_PROPERTIES = 'IMAGE_PROPERTIES' + WEB_ANNOTATION = 'WEB_ANNOTATION' class Feature(object): diff --git a/vision/google/cloud/vision/image.py b/vision/google/cloud/vision/image.py index 561339dce26a..7ff6ad87c2bb 100644 --- a/vision/google/cloud/vision/image.py +++ b/vision/google/cloud/vision/image.py @@ -17,6 +17,9 @@ from base64 import b64encode +from google.cloud.proto.vision.v1 import image_annotator_pb2 + +from google.cloud.vision._gax import _to_gapic_image from google.cloud._helpers import _to_bytes from google.cloud._helpers import _bytes_to_unicode from google.cloud.vision.feature import Feature @@ -33,7 +36,7 @@ class Image(object): :param filename: Filename to image. :type source_uri: str - :param source_uri: Google Cloud Storage URI of image. + :param source_uri: URL or Google Cloud Storage URI of image. :type client: :class:`~google.cloud.vision.client.Client` :param client: Instance of Vision client. @@ -69,12 +72,19 @@ def as_dict(self): return { 'content': _bytes_to_unicode(b64encode(self.content)) } - else: + elif self.source.startswith('gs://'): return { 'source': { 'gcs_image_uri': self.source } } + elif self.source.startswith(('http://', 'https://')): + return { + 'source': { + 'image_uri': self.source + } + } + raise ValueError('No image content or source found.') @property def content(self): @@ -106,6 +116,18 @@ def _detect_annotation(self, images): """ return self.client._vision_api.annotate(images) + def _detect_annotation_from_pb(self, requests_pb=None): + """Helper for pre-made requests. + + :type requests_pb: list + :param requests_pb: List of :class:`google.cloud.proto.vision.v1.\ + image_annotator_pb2.AnnotateImageRequest` + + :rtype: :class:`~google.cloud.vision.annotations.Annotations` + :returns: Instance of ``Annotations``. + """ + return self.client._vision_api.annotate(self, requests_pb=requests_pb) + def detect(self, features): """Detect multiple feature types. @@ -120,6 +142,33 @@ def detect(self, features): images = ((self, features),) return self._detect_annotation(images) + def detect_crop_hints(self, aspect_ratios=None, limit=10): + """Detect crop hints in image. + + :type aspect_ratios: list + :param aspect_ratios: (Optional) List of floats i.e. 4/3 == 1.33333. A + maximum of 16 aspect ratios can be given. + + :type limit: int + :param limit: (Optional) The number of crop hints to detect. + + :rtype: list + :returns: List of :class:`~google.cloud.vision.crop_hint.CropHints`. + """ + feature_type = image_annotator_pb2.Feature.CROP_HINTS + feature = image_annotator_pb2.Feature(type=feature_type, + max_results=limit) + image = _to_gapic_image(self) + crop_hints_params = image_annotator_pb2.CropHintsParams( + aspect_ratios=aspect_ratios) + image_context = image_annotator_pb2.ImageContext( + crop_hints_params=crop_hints_params) + request = image_annotator_pb2.AnnotateImageRequest( + image=image, features=[feature], image_context=image_context) + + annotations = self._detect_annotation_from_pb([request]) + return annotations[0].crop_hints + def detect_faces(self, limit=10): """Detect faces in image. @@ -133,6 +182,19 @@ def detect_faces(self, limit=10): annotations = self.detect(features) return annotations[0].faces + def detect_full_text(self, limit=10): + """Detect a full document's text. + + :type limit: int + :param limit: The number of documents to detect. + + :rtype: list + :returns: List of :class:`~google.cloud.vision.text.TextAnnotation`. + """ + features = [Feature(FeatureTypes.DOCUMENT_TEXT_DETECTION, limit)] + annotations = self.detect(features) + return annotations[0].full_texts + def detect_labels(self, limit=10): """Detect labels that describe objects in an image. @@ -215,3 +277,17 @@ def detect_text(self, limit=10): features = [Feature(FeatureTypes.TEXT_DETECTION, limit)] annotations = self.detect(features) return annotations[0].texts + + def detect_web(self, limit=10): + """Detect similar images elsewhere on the web. + + :type limit: int + :param limit: The maximum instances of text to find. + + :rtype: list + :returns: List of + :class:`~google.cloud.vision.entity.EntityAnnotation`. + """ + features = [Feature(FeatureTypes.WEB_ANNOTATION, limit)] + annotations = self.detect(features) + return annotations[0].web diff --git a/vision/google/cloud/vision/text.py b/vision/google/cloud/vision/text.py new file mode 100644 index 000000000000..b903c3547b4b --- /dev/null +++ b/vision/google/cloud/vision/text.py @@ -0,0 +1,83 @@ +# 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. + +"""Text annotations of an image.""" + +import json + +from google.cloud.proto.vision.v1 import text_annotation_pb2 +from google.protobuf import json_format + + +class TextAnnotation(object): + """Representation of a text annotation returned from the Vision API. + + :type pages: list + :param pages: List of + :class:`~google.cloud.proto.vision.v1.\ + text_annotation_pb2.Page`. + + :type text: str + :param text: String containing text detected from the image. + """ + def __init__(self, pages, text): + self._pages = pages + self._text = text + + @classmethod + def from_api_repr(cls, annotation): + """Factory: construct an instance of ``TextAnnotation`` from JSON. + + :type annotation: dict + :param annotation: Dictionary response from Vision API. + + :rtype: :class:`~google.cloud.vision.text.TextAnnotation` + :returns: Instance of ``TextAnnotation``. + """ + annotation_json = json.dumps(annotation) + text_annotation = text_annotation_pb2.TextAnnotation() + json_format.Parse(annotation_json, text_annotation) + return cls(text_annotation.pages, text_annotation.text) + + @classmethod + def from_pb(cls, annotation): + """Factory: construct an instance of ``TextAnnotation`` from protobuf. + + :type annotation: :class:`~google.cloud.proto.vision.v1.\ + text_annotation_pb2.TextAnnotation` + :param annotation: Populated instance of ``TextAnnotation``. + + :rtype: :class:`~google.cloud.vision.text.TextAnnotation` + :returns: Populated instance of ``TextAnnotation``. + """ + return cls(annotation.pages, annotation.text) + + @property + def pages(self): + """Pages found in text image. + + :rtype: list + :returns: List of :class:`~google.cloud.proto.vision.v1.\ + text_annotation_pb2.Page`. + """ + return self._pages + + @property + def text(self): + """Text detected from an image. + + :rtype: str + :returns: String of text found in an image. + """ + return self._text diff --git a/vision/google/cloud/vision/web.py b/vision/google/cloud/vision/web.py new file mode 100644 index 000000000000..01e8cb2c6899 --- /dev/null +++ b/vision/google/cloud/vision/web.py @@ -0,0 +1,335 @@ +# 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. + +"""Web image search.""" + + +class WebDetection(object): + """Representation of a web detection sent from the Vision API. + + :type web_entities: list + :param web_entities: List of + :class:`google.cloud.proto.vision.v1.\ + web_detection_pb2.WebDetection.WebEntity`. + + :type full_matching_images: list + :param full_matching_images: List of + :class:`google.cloud.proto.vision.v1.\ + web_detection_pb2.WebDetection.WebImage`. + + :type partial_matching_images: list + :param partial_matching_images: List of + :class:`google.cloud.proto.vision.v1.\ + web_detection_pb2.WebDetection.WebImage`. + + :type pages_with_matching_images: list + :param pages_with_matching_images: List of + :class:`google.cloud.proto.vision.v1.\ + web_detection_pb2.WebDetection.\ + WebPage`. + """ + def __init__(self, web_entities=(), full_matching_images=(), + partial_matching_images=(), pages_with_matching_images=()): + self._web_entities = web_entities + self._full_matching_images = full_matching_images + self._partial_matching_images = partial_matching_images + self._pages_with_matching_images = pages_with_matching_images + + @classmethod + def from_api_repr(cls, detection): + """Factory: construct ``WebDetection`` from Vision API response. + + :type detection: dict + :param detection: Dictionary representing a ``WebDetection``. + + :rtype: :class:`~google.cloud.vision.web.WebDetection` + :returns: Populated instance of ``WebDetection``. + """ + web_entities = detection.get('webEntities') + full_matching_images = detection.get('fullMatchingImages') + partial_matching_images = detection.get('partialMatchingImages') + pages_with_matching_images = detection.get('pagesWithMatchingImages') + + web_detection = { + 'web_entities': [WebEntity.from_api_repr(web_entity) + for web_entity in web_entities], + 'full_matching_images': [WebImage.from_api_repr(web_image) + for web_image in full_matching_images], + 'partial_matching_images': [WebImage.from_api_repr(web_image) + for web_image + in partial_matching_images], + 'pages_with_matching_images': [WebPage.from_api_repr(web_page) + for web_page + in pages_with_matching_images], + } + return cls(**web_detection) + + @classmethod + def from_pb(cls, detection): + """Factory: construct ``WebDetection`` from Vision API response. + + :type detection: :class:`~google.cloud.proto.vision.v1.\ + web_detection_pb2.WebDetection` + :param detection: Dictionary representing a ``WebDetection``. + + :rtype: :class:`~google.cloud.vision.web.WebDetection` + :returns: Populated instance of ``WebDetection``. + """ + web_entities = [WebEntity.from_pb(web_entity) + for web_entity in detection.web_entities] + full_image_matches = [WebImage.from_pb(web_image) + for web_image in detection.full_matching_images] + partial_image_matches = [WebImage.from_pb(web_image) + for web_image + in detection.partial_matching_images] + pages_with_images = [WebPage.from_pb(web_page) + for web_page + in detection.pages_with_matching_images] + return cls(web_entities, full_image_matches, partial_image_matches, + pages_with_images) + + @property + def web_entities(self): + """Return the web entities. + + :rtype: list + :returns: A list of ``WebEntity`` instances. + """ + return self._web_entities + + @property + def full_matching_images(self): + """Return the full matching images. + + :rtype: list + :returns: A list of ``WebImage`` instances. + """ + return self._full_matching_images + + @property + def partial_matching_images(self): + """Return the partially matching images. + + :rtype: list + :returns: A list of ``WebImage`` instances. + """ + return self._partial_matching_images + + @property + def pages_with_matching_images(self): + """Return the web pages with matching images. + + :rtype: list + :returns: A list of ``WebPage`` instances. + """ + return self._pages_with_matching_images + + +class WebEntity(object): + """Object containing a web entity sent from the Vision API. + + :type entity_id: str + :param entity_id: ID string for the entity. + + :type score: float + :param score: Overall relevancy score for the entity. + + :type description: str + :param description: Description of the entity. + """ + + def __init__(self, entity_id, score, description): + self._entity_id = entity_id + self._score = score + self._description = description + + @classmethod + def from_api_repr(cls, web_entity): + """Factory: construct ``WebImage`` from Vision API response. + + :type web_entity: dict + :param web_entity: Dictionary representing a web entity + + :rtype: :class:`~google.cloud.vision.web.WebEntity` + :returns: Populated instance of ``WebEntity``. + """ + return cls(web_entity.get('entityId'), web_entity.get('score'), + web_entity.get('description')) + + @classmethod + def from_pb(cls, web_entity): + """Factory: construct ``WebEntity`` from Vision API response. + + :type web_entity: :class:`~google.cloud.proto.vision.v1.\ + web_detection_pb2.WebDetection.WebEntity` + :param web_entity: Dictionary representing a web entity + + :rtype: :class:`~google.cloud.vision.web.WebEntity` + :returns: Populated instance of ``WebEntity``. + """ + return cls(web_entity.entity_id, web_entity.score, + web_entity.description) + + @property + def entity_id(self): + """The entity ID. + + :rtype: str + :returns: String representing the entity ID. Opaque. + """ + return self._entity_id + + @property + def score(self): + """Overall relevancy score for the image. + + .. note:: + + Not normalized nor comparable between requests. + + :rtype: float + :returns: Relevancy score as a float. + """ + return self._score + + @property + def description(self): + """Canonical description of the entity, in English. + + :rtype: str + :returns: Description of the entity. + """ + return self._description + + +class WebImage(object): + """Object containing image information elsewhere on the web. + + :type url: str + :param url: URL of the matched image. + + :type score: float + :param score: Overall relevancy score of the image. + """ + def __init__(self, url, score): + self._url = url + self._score = score + + @classmethod + def from_api_repr(cls, web_image): + """Factory: construct ``WebImage`` from Vision API response. + + :type web_image: dict + :param web_image: Dictionary representing a web image + + :rtype: :class:`~google.cloud.vision.web.WebImage` + :returns: Populated instance of ``WebImage``. + """ + return cls(web_image['url'], web_image['score']) + + @classmethod + def from_pb(cls, web_image): + """Factory: construct ``WebImage`` from Vision API response. + + :type web_image: :class:`~google.cloud.proto.vision.v1.\ + web_detection_pb2.WebDetection.WebImage` + :param web_image: Dictionary representing a web image + + :rtype: :class:`~google.cloud.vision.web.WebImage` + :returns: Populated instance of ``WebImage``. + """ + return cls(web_image.url, web_image.score) + + @property + def url(self): + """The URL of the matched image. + + :rtype: str + :returns: URL of matched image. + """ + return self._url + + @property + def score(self): + """Overall relevancy score for the image. + + .. note:: + + Not normalized nor comparable between requests. + + :rtype: float + :returns: Relevancy score as a float. + """ + return self._score + + +class WebPage(object): + """Web page that may contain this image or a similar one. + + :type url: str + :param url: URL of the matched image. + + :type score: float + :param score: Overall relevancy score of the image. + """ + def __init__(self, url, score): + self._url = url + self._score = score + + @classmethod + def from_api_repr(cls, web_page): + """Factory: construct ``WebPage`` from Vision API response. + + :type web_page: dict + :param web_page: Dictionary representing a web page + + :rtype: :class:`~google.cloud.vision.web.WebPage` + :returns: Populated instance of ``WebPage``. + """ + return cls(web_page['url'], web_page['score']) + + @classmethod + def from_pb(cls, web_page): + """Factory: construct ``WebPage`` from Vision API response. + + :type web_page: :class:`~google.cloud.proto.vision.v1.\ + web_detection_pb2.WebDetection.WebPage` + :param web_page: Dictionary representing a web image + + :rtype: :class:`~google.cloud.vision.web.WebPage` + :returns: Populated instance of ``WebPage``. + """ + return cls(web_page.url, web_page.score) + + @property + def url(self): + """The page URL. + + :rtype: str + :returns: String representing a URL. + """ + return self._url + + @property + def score(self): + """Overall relevancy score for the image. + + .. note:: + + Not normalized nor comparable between requests. + + :rtype: float + :returns: Relevancy score as a float. + """ + return self._score diff --git a/vision/setup.py b/vision/setup.py index b83bb8440973..352cad044a10 100644 --- a/vision/setup.py +++ b/vision/setup.py @@ -52,12 +52,12 @@ REQUIREMENTS = [ 'enum34', 'google-cloud-core >= 0.23.1, < 0.24dev', - 'gapic-google-cloud-vision-v1 >= 0.15.0, < 0.16dev', + 'gapic-google-cloud-vision-v1 >= 0.15.2, < 0.16dev', ] setup( name='google-cloud-vision', - version='0.23.0', + version='0.23.1', description='Python Client for Google Cloud Vision', long_description=README, namespace_packages=[ diff --git a/vision/unit_tests/_fixtures.py b/vision/unit_tests/_fixtures.py index a008c66274ce..111e0d82ed08 100644 --- a/vision/unit_tests/_fixtures.py +++ b/vision/unit_tests/_fixtures.py @@ -1,17 +1,163 @@ # Copyright 2016 Google Inc. # -# Licensed under the Apache License, Version 2.0 (the "License"); +# 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, +# 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. + +FULL_TEXT_RESPONSE = { + 'responses': [{ + 'fullTextAnnotation': { + 'pages': [{ + 'height': 1872, + 'property': { + 'detectedLanguages': [{ + 'languageCode': 'en' + }] + }, + 'blocks': [{ + 'blockType': 'TEXT', + 'property': { + 'detectedLanguages': [{ + 'languageCode': 'en' + }] + }, + 'boundingBox': { + 'vertices': [{ + 'y': 8, + 'x': 344 + }, { + 'y': 8, + 'x': 434 + }, { + 'y': 22, + 'x': 434 + }, { + 'y': 22, + 'x': 344 + }] + }, + 'paragraphs': [{ + 'property': { + 'detectedLanguages': [{ + 'languageCode': 'en' + }] + }, + 'words': [{ + 'symbols': [{ + 'property': { + 'detectedLanguages': [{ + 'languageCode': 'en' + }] + }, + 'text': 'T', + 'boundingBox': { + 'vertices': [{ + 'y': 8, + 'x': 344 + }, { + 'y': 8, + 'x': 352 + }, { + 'y': 22, + 'x': 352 + }, { + 'y': 22, + 'x': 344 + }] + } + }], + 'property': { + 'detectedLanguages': [{ + 'languageCode': 'en' + }] + }, + 'boundingBox': { + 'vertices': [{ + 'y': 8, + 'x': 377 + }, { + 'y': 8, + 'x': 434 + }, { + 'y': 22, + 'x': 434 + }, { + 'y': 22, + 'x': 377 + }] + } + }], + 'boundingBox': { + 'vertices': [{ + 'y': 8, + 'x': 344 + }, { + 'y': 8, + 'x': 434 + }, { + 'y': 22, + 'x': 434 + }, { + 'y': 22, + 'x': 344 + }] + } + }] + }], + 'width': 792 + }], + 'text': 'The Republic\nBy Plato' + } + }] +} + +CROP_HINTS_RESPONSE = { + "responses": [{ + "cropHintsAnnotation": { + "cropHints": [{ + "importanceFraction": 1.22, + "boundingPoly": { + "vertices": [{ + "x": 77 + }, { + "x": 1821 + }, { + "x": 1821, + "y": 1306 + }, { + "x": 77, + "y": 1306 + }] + }, + "confidence": 0.5 + }, { + "importanceFraction": 1.2099999, + "boundingPoly": { + "vertices": [{}, { + "x": 1959 + }, { + "x": 1959, + "y": 1096 + }, { + "y": 1096 + }] + }, + "confidence": 0.29999998 + }] + } + }] +} + + IMAGE_PROPERTIES_RESPONSE = { 'responses': [ { @@ -1813,3 +1959,43 @@ } ] } + + +WEB_ANNOTATION_RESPONSE = { + 'responses': [ + { + 'webDetection': { + 'partialMatchingImages': [{ + 'score': 0.9216, + 'url': 'https://cloud.google.com/vision' + }, { + 'score': 0.55520177, + 'url': 'https://cloud.google.com/vision' + }], + 'fullMatchingImages': [{ + 'score': 0.09591467, + 'url': 'https://cloud.google.com/vision' + }, { + 'score': 0.09591467, + 'url': 'https://cloud.google.com/vision' + }], + 'webEntities': [{ + 'entityId': '/m/019dvv', + 'score': 1470.4435, + 'description': 'Mount Rushmore National Memorial' + }, { + 'entityId': '/m/05_5t0l', + 'score': 0.9468027, + 'description': 'Landmark' + }], + 'pagesWithMatchingImages': [{ + 'score': 2.9996617, + 'url': 'https://cloud.google.com/vision' + }, { + 'score': 1.1980441, + 'url': 'https://cloud.google.com/vision' + }] + } + } + ] +} diff --git a/vision/unit_tests/test__gax.py b/vision/unit_tests/test__gax.py index 1a97778bfc1b..88877f2324ad 100644 --- a/vision/unit_tests/test__gax.py +++ b/vision/unit_tests/test__gax.py @@ -107,6 +107,16 @@ def test_annotation(self): mock_anno.from_pb.assert_called_with('mock response data') gax_api._annotator_client.batch_annotate_images.assert_called() + def test_annotate_no_requests(self): + client = mock.Mock(spec_set=['_credentials']) + with mock.patch('google.cloud.vision._gax.image_annotator_client.' + 'ImageAnnotatorClient'): + gax_api = self._make_one(client) + + response = gax_api.annotate() + self.assertEqual(response, []) + gax_api._annotator_client.batch_annotate_images.assert_not_called() + def test_annotate_no_results(self): from google.cloud.vision.feature import Feature from google.cloud.vision.feature import FeatureTypes @@ -167,6 +177,47 @@ def test_annotate_multiple_results(self): self.assertIsInstance(responses[1], Annotations) gax_api._annotator_client.batch_annotate_images.assert_called() + def test_annotate_with_pb_requests_results(self): + from google.cloud.proto.vision.v1 import image_annotator_pb2 + from google.cloud.vision.annotations import Annotations + + client = mock.Mock(spec_set=['_credentials']) + + feature_type = image_annotator_pb2.Feature.CROP_HINTS + feature = image_annotator_pb2.Feature(type=feature_type, max_results=2) + + image_content = b'abc 1 2 3' + image = image_annotator_pb2.Image(content=image_content) + + aspect_ratios = [1.3333, 1.7777] + crop_hints_params = image_annotator_pb2.CropHintsParams( + aspect_ratios=aspect_ratios) + image_context = image_annotator_pb2.ImageContext( + crop_hints_params=crop_hints_params) + request = image_annotator_pb2.AnnotateImageRequest( + image=image, features=[feature], image_context=image_context) + + with mock.patch('google.cloud.vision._gax.image_annotator_client.' + 'ImageAnnotatorClient'): + gax_api = self._make_one(client) + + responses = [ + image_annotator_pb2.AnnotateImageResponse(), + image_annotator_pb2.AnnotateImageResponse(), + ] + response = image_annotator_pb2.BatchAnnotateImagesResponse( + responses=responses) + + 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(requests_pb=[request]) + + self.assertEqual(len(responses), 2) + self.assertIsInstance(responses[0], Annotations) + self.assertIsInstance(responses[1], Annotations) + gax_api._annotator_client.batch_annotate_images.assert_called() + class Test__to_gapic_feature(unittest.TestCase): def _call_fut(self, feature): @@ -201,7 +252,7 @@ def test__to_gapic_image_content(self): self.assertIsInstance(image_pb, image_annotator_pb2.Image) self.assertEqual(image_pb.content, image_content) - def test__to_gapic_image_uri(self): + def test__to_gapic_gcs_image_uri(self): from google.cloud.vision.image import Image from google.cloud.proto.vision.v1 import image_annotator_pb2 @@ -212,6 +263,26 @@ def test__to_gapic_image_uri(self): self.assertIsInstance(image_pb, image_annotator_pb2.Image) self.assertEqual(image_pb.source.gcs_image_uri, image_uri) + def test__to_gapic_image_uri(self): + from google.cloud.vision.image import Image + from google.cloud.proto.vision.v1 import image_annotator_pb2 + + image_uri = 'http://1234/34.jpg' + client = object() + image = Image(client, source_uri=image_uri) + image_pb = self._call_fut(image) + self.assertIsInstance(image_pb, image_annotator_pb2.Image) + self.assertEqual(image_pb.source.image_uri, image_uri) + + def test__to_gapic_invalid_image_uri(self): + from google.cloud.vision.image import Image + + image_uri = 'ftp://1234/34.jpg' + client = object() + image = Image(client, source_uri=image_uri) + with self.assertRaises(ValueError): + self._call_fut(image) + def test__to_gapic_with_empty_image(self): image = mock.Mock( content=None, source=None, spec=['content', 'source']) diff --git a/vision/unit_tests/test__http.py b/vision/unit_tests/test__http.py index 92b39021fe57..eae6a6506147 100644 --- a/vision/unit_tests/test__http.py +++ b/vision/unit_tests/test__http.py @@ -95,6 +95,51 @@ def test_call_annotate_with_no_results(self): self.assertEqual(len(response), 0) self.assertIsInstance(response, list) + def test_call_annotate_with_no_parameters(self): + client = mock.Mock(spec_set=['_connection']) + http_api = self._make_one(client) + http_api._connection = mock.Mock(spec_set=['api_request']) + + results = http_api.annotate() + self.assertEqual(results, []) + http_api._connection.api_request.assert_not_called() + + def test_call_annotate_with_pb_requests_results(self): + from google.cloud.proto.vision.v1 import image_annotator_pb2 + + client = mock.Mock(spec_set=['_connection']) + + feature_type = image_annotator_pb2.Feature.CROP_HINTS + feature = image_annotator_pb2.Feature(type=feature_type, max_results=2) + + image = image_annotator_pb2.Image(content=IMAGE_CONTENT) + + aspect_ratios = [1.3333, 1.7777] + crop_hints_params = image_annotator_pb2.CropHintsParams( + aspect_ratios=aspect_ratios) + image_context = image_annotator_pb2.ImageContext( + crop_hints_params=crop_hints_params) + request = image_annotator_pb2.AnnotateImageRequest( + image=image, features=[feature], image_context=image_context) + + http_api = self._make_one(client) + http_api._connection = mock.Mock(spec_set=['api_request']) + http_api._connection.api_request.return_value = {'responses': []} + + responses = http_api.annotate(requests_pb=[request]) + + # Establish that one and exactly one api_request call was made. + self.assertEqual(http_api._connection.api_request.call_count, 1) + + # Establish that the basic keyword arguments look correct. + call = http_api._connection.api_request.mock_calls[0] + self.assertEqual(call[2]['method'], 'POST') + self.assertEqual(call[2]['path'], '/images:annotate') + + # Establish that the responses look correct. + self.assertEqual(responses, []) + self.assertEqual(len(responses), 0) + def test_call_annotate_with_more_than_one_result(self): from google.cloud.vision.feature import Feature from google.cloud.vision.feature import FeatureTypes diff --git a/vision/unit_tests/test_client.py b/vision/unit_tests/test_client.py index 7b87f0e6fc80..5fc69ec65ab1 100644 --- a/vision/unit_tests/test_client.py +++ b/vision/unit_tests/test_client.py @@ -75,8 +75,6 @@ def test_make_http_client(self): credentials = _make_credentials() client = self._make_one(project=PROJECT, credentials=credentials, use_gax=False) - vision_api = client._vision_api - vision_api._connection = _Connection() self.assertIsInstance(client._vision_api, _HTTPVisionAPI) def test_face_annotation(self): @@ -213,6 +211,27 @@ def test_multiple_detection_from_content(self): self.assertEqual(logo_request['maxResults'], 2) self.assertEqual(logo_request['type'], 'LOGO_DETECTION') + def test_detect_crop_hints_from_source(self): + from google.cloud.vision.crop_hint import CropHint + from unit_tests._fixtures import CROP_HINTS_RESPONSE + returned = CROP_HINTS_RESPONSE + credentials = _make_credentials() + client = self._make_one(project=PROJECT, credentials=credentials, + use_gax=False) + api = client._vision_api + api._connection = _Connection(returned) + image = client.image(source_uri=IMAGE_SOURCE) + crop_hints = image.detect_crop_hints(aspect_ratios=[1.3333], limit=3) + + self.assertEqual(len(crop_hints), 2) + self.assertIsInstance(crop_hints[0], CropHint) + image_request = api._connection._requested[0]['data']['requests'][0] + self.assertEqual(image_request['image']['source']['gcsImageUri'], + IMAGE_SOURCE) + ar = image_request['imageContext']['cropHintsParams']['aspectRatios'] + self.assertAlmostEqual(ar[0], 1.3333, 4) + self.assertEqual(3, image_request['features'][0]['maxResults']) + def test_face_detection_from_source(self): from google.cloud.vision.face import Face from unit_tests._fixtures import FACE_DETECTION_RESPONSE @@ -275,6 +294,37 @@ def test_face_detection_from_content_no_results(self): image_request['image']['content']) self.assertEqual(5, image_request['features'][0]['maxResults']) + def test_detect_full_text_annotation(self): + from google.cloud.vision.text import TextAnnotation + from unit_tests._fixtures import FULL_TEXT_RESPONSE + + returned = FULL_TEXT_RESPONSE + credentials = _make_credentials() + client = self._make_one(project=PROJECT, credentials=credentials, + use_gax=False) + api = client._vision_api + api._connection = _Connection(returned) + image = client.image(source_uri=IMAGE_SOURCE) + full_text = image.detect_full_text(limit=2) + + self.assertIsInstance(full_text, TextAnnotation) + self.assertEqual(full_text.text, 'The Republic\nBy Plato') + self.assertEqual(len(full_text.pages), 1) + self.assertEqual(len(full_text.pages), 1) + page = full_text.pages[0] + self.assertEqual(page.height, 1872) + self.assertEqual(page.width, 792) + self.assertEqual(len(page.blocks), 1) + self.assertEqual(len(page.blocks[0].paragraphs), 1) + self.assertEqual(len(page.blocks[0].paragraphs[0].words), 1) + + image_request = api._connection._requested[0]['data']['requests'][0] + self.assertEqual(image_request['image']['source']['gcs_image_uri'], + IMAGE_SOURCE) + self.assertEqual(image_request['features'][0]['maxResults'], 2) + self.assertEqual(image_request['features'][0]['type'], + 'DOCUMENT_TEXT_DETECTION') + def test_label_detection_from_source(self): from google.cloud.vision.entity import EntityAnnotation from unit_tests._fixtures import ( @@ -522,6 +572,40 @@ def test_image_properties_no_results(self): self.assertEqual(image_properties, ()) self.assertEqual(len(image_properties), 0) + def test_detect_web_detection(self): + from google.cloud.vision.web import WebEntity + from google.cloud.vision.web import WebImage + from google.cloud.vision.web import WebPage + from unit_tests._fixtures import WEB_ANNOTATION_RESPONSE + + returned = WEB_ANNOTATION_RESPONSE + credentials = _make_credentials() + client = self._make_one(project=PROJECT, credentials=credentials, + use_gax=False) + api = client._vision_api + api._connection = _Connection(returned) + image = client.image(source_uri=IMAGE_SOURCE) + web_images = image.detect_web(limit=2) + + self.assertEqual(len(web_images.partial_matching_images), 2) + self.assertEqual(len(web_images.full_matching_images), 2) + self.assertEqual(len(web_images.web_entities), 2) + self.assertEqual(len(web_images.pages_with_matching_images), 2) + + self.assertIsInstance(web_images.partial_matching_images[0], + WebImage) + self.assertIsInstance(web_images.full_matching_images[0], WebImage) + self.assertIsInstance(web_images.web_entities[0], WebEntity) + self.assertIsInstance(web_images.pages_with_matching_images[0], + WebPage) + + image_request = api._connection._requested[0]['data']['requests'][0] + self.assertEqual(image_request['image']['source']['gcs_image_uri'], + IMAGE_SOURCE) + self.assertEqual(image_request['features'][0]['maxResults'], 2) + self.assertEqual(image_request['features'][0]['type'], + 'WEB_ANNOTATION') + class _Connection(object): diff --git a/vision/unit_tests/test_crop_hint.py b/vision/unit_tests/test_crop_hint.py new file mode 100644 index 000000000000..21367d001564 --- /dev/null +++ b/vision/unit_tests/test_crop_hint.py @@ -0,0 +1,53 @@ +# 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 + + +class TestCropHint(unittest.TestCase): + @staticmethod + def _get_target_class(): + from google.cloud.vision.crop_hint import CropHint + return CropHint + + def test_crop_hint_annotation(self): + from google.cloud.vision.geometry import Bounds + from unit_tests._fixtures import CROP_HINTS_RESPONSE + + response = CROP_HINTS_RESPONSE['responses'][0]['cropHintsAnnotation'] + crop_hints_dict = response['cropHints'][0] + crop_hints_class = self._get_target_class() + crop_hints = crop_hints_class.from_api_repr(crop_hints_dict) + + self.assertIsInstance(crop_hints.bounds, Bounds) + self.assertEqual(len(crop_hints.bounds.vertices), 4) + self.assertEqual(crop_hints.confidence, 0.5) + self.assertEqual(crop_hints.importance_fraction, 1.22) + + def test_crop_hint_annotation_pb(self): + from google.cloud.proto.vision.v1 import geometry_pb2 + from google.cloud.proto.vision.v1 import image_annotator_pb2 + + vertex = geometry_pb2.Vertex(x=1, y=2) + bounds = geometry_pb2.BoundingPoly(vertices=[vertex]) + crop_hint_pb = image_annotator_pb2.CropHint( + bounding_poly=bounds, confidence=1.23, importance_fraction=4.56) + crop_hints_class = self._get_target_class() + crop_hint = crop_hints_class.from_pb(crop_hint_pb) + + self.assertEqual(len(crop_hint.bounds.vertices), 1) + self.assertEqual(crop_hint.bounds.vertices[0].x_coordinate, 1) + self.assertEqual(crop_hint.bounds.vertices[0].y_coordinate, 2) + self.assertAlmostEqual(crop_hint.confidence, 1.23, 4) + self.assertAlmostEqual(crop_hint.importance_fraction, 4.56, 4) diff --git a/vision/unit_tests/test_image.py b/vision/unit_tests/test_image.py index f9f00d98b3fd..5b8f3e74e166 100644 --- a/vision/unit_tests/test_image.py +++ b/vision/unit_tests/test_image.py @@ -72,6 +72,24 @@ def test_image_source_type_google_cloud_storage(self): self.assertEqual(None, image.content) self.assertEqual(image.as_dict(), as_dict) + def test_image_source_type_image_url(self): + url = 'http://www.example.com/image.jpg' + image = self._make_one(CLIENT_MOCK, source_uri=url) + as_dict = { + 'source': { + 'image_uri': url, + }, + } + + self.assertEqual(image.source, url) + self.assertIsNone(image.content) + self.assertEqual(image.as_dict(), as_dict) + + def test_image_no_valid_image_data(self): + image = self._make_one(CLIENT_MOCK, source_uri='ftp://notsupported') + with self.assertRaises(ValueError): + image.as_dict() + def test_cannot_set_both_source_and_content(self): image = self._make_one(CLIENT_MOCK, content=IMAGE_CONTENT) diff --git a/vision/unit_tests/test_text.py b/vision/unit_tests/test_text.py new file mode 100644 index 000000000000..3b5df496d299 --- /dev/null +++ b/vision/unit_tests/test_text.py @@ -0,0 +1,47 @@ +# 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 + + +class TestTextAnnotatin(unittest.TestCase): + @staticmethod + def _get_target_class(): + from google.cloud.vision.text import TextAnnotation + return TextAnnotation + + def test_text_annotation_from_api_repr(self): + annotation = { + 'pages': [], + 'text': 'some detected text', + } + text_annotation = self._get_target_class().from_api_repr(annotation) + self.assertIsInstance(text_annotation, self._get_target_class()) + self.assertEqual(len(text_annotation.pages), 0) + self.assertEqual(text_annotation.text, annotation['text']) + + def test_text_annotation_from_pb(self): + from google.cloud.proto.vision.v1 import text_annotation_pb2 + + page = text_annotation_pb2.Page(width=8, height=11) + text = 'some detected text' + text_annotation_pb = text_annotation_pb2.TextAnnotation( + pages=[page], text=text) + + text_annotation = self._get_target_class().from_pb(text_annotation_pb) + self.assertIsInstance(text_annotation, self._get_target_class()) + self.assertEqual(len(text_annotation.pages), 1) + self.assertEqual(text_annotation.pages[0].width, 8) + self.assertEqual(text_annotation.pages[0].height, 11) + self.assertEqual(text_annotation.text, text) diff --git a/vision/unit_tests/test_web.py b/vision/unit_tests/test_web.py new file mode 100644 index 000000000000..9f91d883b1b9 --- /dev/null +++ b/vision/unit_tests/test_web.py @@ -0,0 +1,227 @@ +# 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 + + +class TestWebDetection(unittest.TestCase): + @staticmethod + def _get_target_class(): + from google.cloud.vision.web import WebDetection + return WebDetection + + def _make_one(self, web_entities, full_matching_images, + partial_matching_images, pages_with_matching_images): + return self._get_target_class()(web_entities, full_matching_images, + partial_matching_images, + pages_with_matching_images) + + def test_web_detection_ctor(self): + web_detection = self._make_one(1, 2, 3, 4) + self.assertEqual(web_detection.web_entities, 1) + self.assertEqual(web_detection.full_matching_images, 2) + self.assertEqual(web_detection.partial_matching_images, 3) + self.assertEqual(web_detection.pages_with_matching_images, 4) + + def test_web_detection_from_api_repr(self): + from google.cloud.vision.web import WebEntity + from google.cloud.vision.web import WebImage + from google.cloud.vision.web import WebPage + + web_detection_dict = { + 'partialMatchingImages': [{ + 'url': 'https://cloud.google.com/vision', + 'score': 0.92234, + }], + 'fullMatchingImages': [{ + 'url': 'https://cloud.google.com/vision', + 'score': 0.92234, + }], + 'webEntities': [{ + 'entityId': '/m/05_5t0l', + 'score': 0.9468027, + 'description': 'Landmark' + }], + 'pagesWithMatchingImages': [{ + 'url': 'https://cloud.google.com/vision', + 'score': 0.92234, + }], + } + web_detection = self._get_target_class().from_api_repr( + web_detection_dict) + self.assertEqual(len(web_detection.partial_matching_images), 1) + self.assertEqual(len(web_detection.full_matching_images), 1) + self.assertEqual(len(web_detection.web_entities), 1) + self.assertEqual(len(web_detection.pages_with_matching_images), 1) + + self.assertIsInstance(web_detection.partial_matching_images[0], + WebImage) + self.assertIsInstance(web_detection.full_matching_images[0], WebImage) + self.assertIsInstance(web_detection.web_entities[0], WebEntity) + self.assertIsInstance(web_detection.pages_with_matching_images[0], + WebPage) + + def test_web_detection_from_pb(self): + from google.cloud.proto.vision.v1 import web_detection_pb2 + from google.cloud.vision.web import WebEntity + from google.cloud.vision.web import WebImage + from google.cloud.vision.web import WebPage + + description = 'Some images like the image you have.' + entity_id = '/m/019dvv' + score = 1470.4435 + url = 'http://cloud.google.com/vision' + + web_entity_pb = web_detection_pb2.WebDetection.WebEntity( + entity_id=entity_id, score=score, description=description) + + web_image_pb = web_detection_pb2.WebDetection.WebImage( + url=url, score=score) + + web_page_pb = web_detection_pb2.WebDetection.WebPage( + url=url, score=score) + + web_detection_pb = web_detection_pb2.WebDetection( + web_entities=[web_entity_pb], full_matching_images=[web_image_pb], + partial_matching_images=[web_image_pb], + pages_with_matching_images=[web_page_pb]) + web_detection = self._get_target_class().from_pb(web_detection_pb) + self.assertEqual(len(web_detection.web_entities), 1) + self.assertEqual(len(web_detection.full_matching_images), 1) + self.assertEqual(len(web_detection.partial_matching_images), 1) + self.assertEqual(len(web_detection.pages_with_matching_images), 1) + self.assertIsInstance(web_detection.web_entities[0], WebEntity) + self.assertIsInstance(web_detection.full_matching_images[0], WebImage) + self.assertIsInstance(web_detection.partial_matching_images[0], + WebImage) + self.assertIsInstance(web_detection.pages_with_matching_images[0], + WebPage) + + +class TestWebEntity(unittest.TestCase): + @staticmethod + def _get_target_class(): + from google.cloud.vision.web import WebEntity + return WebEntity + + def _make_one(self, entity_id, score, description): + return self._get_target_class()(entity_id, score, description) + + def test_web_entity_ctor(self): + entity_id = 'm/abc123' + score = 0.13245 + description = 'This is an image from the web that matches your image.' + web_entity = self._make_one(entity_id, score, description) + self.assertEqual(web_entity.entity_id, entity_id) + self.assertAlmostEqual(web_entity.score, score, 4) + self.assertEqual(web_entity.description, description) + + def test_web_entity_from_api_repr(self): + entity_dict = { + 'entityId': '/m/019dvv', + 'score': 1470.4435, + 'description': 'Mount Rushmore National Memorial', + } + web_entity = self._get_target_class().from_api_repr(entity_dict) + + self.assertEqual(web_entity.entity_id, entity_dict['entityId']) + self.assertAlmostEqual(web_entity.score, entity_dict['score'], 4) + self.assertEqual(web_entity.description, entity_dict['description']) + + def test_web_entity_from_pb(self): + from google.cloud.proto.vision.v1 import web_detection_pb2 + + entity_id = '/m/019dvv' + score = 1470.4435 + description = 'Some images like the image you have.' + web_entity_pb = web_detection_pb2.WebDetection.WebEntity( + entity_id=entity_id, score=score, description=description) + web_entity = self._get_target_class().from_pb(web_entity_pb) + self.assertEqual(web_entity.entity_id, entity_id) + self.assertAlmostEqual(web_entity.score, score, 4) + self.assertEqual(web_entity.description, description) + + +class TestWebImage(unittest.TestCase): + @staticmethod + def _get_target_class(): + from google.cloud.vision.web import WebImage + return WebImage + + def _make_one(self, url, score): + return self._get_target_class()(url, score) + + def test_web_image_ctor(self): + url = 'http://cloud.google.com/vision' + score = 1234.23 + web_image = self._make_one(url, score) + self.assertEqual(web_image.url, url) + self.assertAlmostEqual(web_image.score, score, 4) + + def test_web_image_from_api_repr(self): + web_image_dict = { + 'url': 'http://cloud.google.com/vision', + 'score': 1234.23, + } + web_image = self._get_target_class().from_api_repr(web_image_dict) + self.assertEqual(web_image.url, web_image_dict['url']) + self.assertAlmostEqual(web_image.score, web_image_dict['score']) + + def test_web_image_from_pb(self): + from google.cloud.proto.vision.v1 import web_detection_pb2 + + url = 'http://cloud.google.com/vision' + score = 1234.23 + web_image_pb = web_detection_pb2.WebDetection.WebImage( + url=url, score=score) + web_image = self._get_target_class().from_pb(web_image_pb) + self.assertEqual(web_image.url, url) + self.assertAlmostEqual(web_image.score, score, 4) + + +class TestWebPage(unittest.TestCase): + @staticmethod + def _get_target_class(): + from google.cloud.vision.web import WebPage + return WebPage + + def _make_one(self, url, score): + return self._get_target_class()(url, score) + + def test_web_page_ctor(self): + url = 'http://cloud.google.com/vision' + score = 1234.23 + web_page = self._make_one(url, score) + self.assertEqual(web_page.url, url) + self.assertAlmostEqual(web_page.score, score, 4) + + def test_web_page_from_api_repr(self): + web_page_dict = { + 'url': 'http://cloud.google.com/vision', + 'score': 1234.23, + } + web_page = self._get_target_class().from_api_repr(web_page_dict) + self.assertEqual(web_page.url, web_page_dict['url']) + self.assertAlmostEqual(web_page.score, web_page_dict['score'], 4) + + def test_web_page_from_pb(self): + from google.cloud.proto.vision.v1 import web_detection_pb2 + + url = 'http://cloud.google.com/vision' + score = 1234.23 + web_page_pb = web_detection_pb2.WebDetection.WebPage( + url=url, score=score) + web_page = self._get_target_class().from_pb(web_page_pb) + self.assertEqual(web_page.url, url) + self.assertAlmostEqual(web_page.score, score, 4)