3 # Copyright 2007 Google Inc.
5 # Licensed under the Apache License, Version 2.0 (the "License");
6 # you may not use this file except in compliance with the License.
7 # You may obtain a copy of the License at
9 # http://www.apache.org/licenses/LICENSE-2.0
11 # Unless required by applicable law or agreed to in writing, software
12 # distributed under the License is distributed on an "AS IS" BASIS,
13 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 # See the License for the specific language governing permissions and
15 # limitations under the License.
21 """Stub version of the images API."""
37 import json
as simplejson
43 from PIL
import _imaging
47 # Try importing the 'Image' module directly. If that fails, try
48 # importing it from the 'PIL' package (this is necessary to also
49 # cover "pillow" package installations).
55 from google
.appengine
.api
import apiproxy_stub
56 from google
.appengine
.api
import apiproxy_stub_map
57 from google
.appengine
.api
import datastore
58 from google
.appengine
.api
import datastore_errors
59 from google
.appengine
.api
import datastore_types
60 from google
.appengine
.api
import images
61 from google
.appengine
.api
.blobstore
import blobstore_stub
62 from google
.appengine
.api
.images
import images_blob_stub
63 from google
.appengine
.api
.images
import images_service_pb
64 from google
.appengine
.runtime
import apiproxy_errors
68 BLOB_SERVING_URL_KIND
= images_blob_stub
.BLOB_SERVING_URL_KIND
71 GS_INFO_KIND
= '__GsFileInfo__'
74 MAX_REQUEST_SIZE
= 32 << 20 # 32MB
81 FORMAT_LIST
= [BMP
, GIF
, ICO
, JPEG
, PNG
, TIFF
, WEBP
]
82 EXIF_TIME_REGEX
= re
.compile(r
'^([0-9]{4}):([0-9]{1,2}):([0-9]{1,2})'
83 ' ([0-9]{1,2}):([0-9]{1,2})(?::([0-9]{1,2}))?')
86 # Orientation tag id in EXIF.
87 _EXIF_ORIENTATION_TAG
= 274
89 # DateTimeOriginal tag in EXIF.
90 _EXIF_DATETIMEORIGINAL_TAG
= 36867
92 # Subset of EXIF tags. The stub is only able to extract these fields.
98 _EXIF_ORIENTATION_TAG
: 'Orientation',
101 34855: 'ISOSpeedRatings',
102 _EXIF_DATETIMEORIGINAL_TAG
: 'DateTimeOriginal',
103 36868: 'DateTimeDigitized',
104 37383: 'MeteringMode',
106 41987: 'WhiteBalance'}
109 def _ArgbToRgbaTuple(argb
):
110 """Convert from a single ARGB value to a tuple containing RGBA.
113 argb: Signed 32 bit integer containing an ARGB value.
119 unsigned_argb
= argb
% 0x100000000
120 return ((unsigned_argb
>> 16) & 0xFF,
121 (unsigned_argb
>> 8) & 0xFF,
122 unsigned_argb
& 0xFF,
123 (unsigned_argb
>> 24) & 0xFF)
126 def _BackendPremultiplication(color
):
127 """Apply premultiplication and unpremultiplication to match production.
130 color: color tuple as returned by _ArgbToRgbaTuple.
141 multiplied
= [(x
* (alpha
+ 1)) >> 8 for x
in rgb
]
143 alpha_inverse
= 0xffffff / alpha
144 unmultiplied
= [(x
* alpha_inverse
) >> 16 for x
in multiplied
]
146 unmultiplied
= [0] * 3
148 return tuple(unmultiplied
+ [alpha
])
151 class ImagesServiceStub(apiproxy_stub
.APIProxyStub
):
152 """Stub version of images API to be used with the dev_appserver."""
154 def __init__(self
, service_name
='images', host_prefix
=''):
155 """Preloads PIL to load all modules in the unhardened environment.
158 service_name: Service name expected for all calls.
159 host_prefix: the URL prefix (protocol://host:port) to preprend to
160 image urls on a call to GetUrlBase.
162 super(ImagesServiceStub
, self
).__init
__(
163 service_name
, max_request_size
=MAX_REQUEST_SIZE
)
164 self
._blob
_stub
= images_blob_stub
.ImagesBlobStub(host_prefix
)
167 def _Dynamic_Composite(self
, request
, response
):
168 """Implementation of ImagesService::Composite.
170 Based off documentation of the PIL library at
171 http://www.pythonware.com/library/pil/handbook/index.htm
174 request: ImagesCompositeRequest - Contains image request info.
175 response: ImagesCompositeResponse - Contains transformed image.
178 ApplicationError: Bad data was provided, likely data about the dimensions.
180 if (not request
.canvas().width() or not request
.canvas().height() or
181 not request
.image_size() or not request
.options_size()):
182 raise apiproxy_errors
.ApplicationError(
183 images_service_pb
.ImagesServiceError
.BAD_TRANSFORM_DATA
)
184 if (request
.canvas().width() > 4000 or
185 request
.canvas().height() > 4000 or
186 request
.options_size() > images
.MAX_COMPOSITES_PER_REQUEST
):
187 raise apiproxy_errors
.ApplicationError(
188 images_service_pb
.ImagesServiceError
.BAD_TRANSFORM_DATA
)
190 width
= request
.canvas().width()
191 height
= request
.canvas().height()
192 color
= _ArgbToRgbaTuple(request
.canvas().color())
195 color
= _BackendPremultiplication(color
)
196 canvas
= Image
.new(RGBA
, (width
, height
), color
)
198 for image
in request
.image_list():
199 sources
.append(self
._OpenImageData
(image
))
201 for options
in request
.options_list():
202 if (options
.anchor() < images
.TOP_LEFT
or
203 options
.anchor() > images
.BOTTOM_RIGHT
):
204 raise apiproxy_errors
.ApplicationError(
205 images_service_pb
.ImagesServiceError
.BAD_TRANSFORM_DATA
)
206 if options
.source_index() >= len(sources
) or options
.source_index() < 0:
207 raise apiproxy_errors
.ApplicationError(
208 images_service_pb
.ImagesServiceError
.BAD_TRANSFORM_DATA
)
209 if options
.opacity() < 0 or options
.opacity() > 1:
210 raise apiproxy_errors
.ApplicationError(
211 images_service_pb
.ImagesServiceError
.BAD_TRANSFORM_DATA
)
212 source
= sources
[options
.source_index()]
213 x_anchor
= (options
.anchor() % 3) * 0.5
214 y_anchor
= (options
.anchor() / 3) * 0.5
215 x_offset
= int(options
.x_offset() + x_anchor
* (width
- source
.size
[0]))
216 y_offset
= int(options
.y_offset() + y_anchor
* (height
- source
.size
[1]))
217 if source
.mode
== RGBA
:
218 canvas
.paste(source
, (x_offset
, y_offset
), source
)
220 alpha
= options
.opacity() * 255
221 mask
= Image
.new('L', source
.size
, alpha
)
222 canvas
.paste(source
, (x_offset
, y_offset
), mask
)
223 response_value
= self
._EncodeImage
(canvas
, request
.canvas().output())
224 response
.mutable_image().set_content(response_value
)
226 def _Dynamic_Histogram(self
, request
, response
):
227 """Trivial implementation of an API.
229 Based off documentation of the PIL library at
230 http://www.pythonware.com/library/pil/handbook/index.htm
233 request: ImagesHistogramRequest - Contains the image.
234 response: ImagesHistogramResponse - Contains histogram of the image.
237 ApplicationError: Image was of an unsupported format.
239 image
= self
._OpenImageData
(request
.image())
241 img_format
= image
.format
242 if img_format
not in FORMAT_LIST
:
243 raise apiproxy_errors
.ApplicationError(
244 images_service_pb
.ImagesServiceError
.NOT_IMAGE
)
245 image
= image
.convert(RGBA
)
253 for pixel
in image
.getdata():
254 red
[int((pixel
[0] * pixel
[3]) / 255)] += 1
255 green
[int((pixel
[1] * pixel
[3]) / 255)] += 1
256 blue
[int((pixel
[2] * pixel
[3]) / 255)] += 1
257 histogram
= response
.mutable_histogram()
259 histogram
.add_red(value
)
261 histogram
.add_green(value
)
263 histogram
.add_blue(value
)
265 def _Dynamic_Transform(self
, request
, response
):
266 """Trivial implementation of ImagesService::Transform.
268 Based off documentation of the PIL library at
269 http://www.pythonware.com/library/pil/handbook/index.htm
272 request: ImagesTransformRequest, contains image request info.
273 response: ImagesTransformResponse, contains transformed image.
275 original_image
= self
._OpenImageData
(request
.image())
277 input_settings
= request
.input()
278 correct_orientation
= (
279 input_settings
.has_correct_exif_orientation() and
280 input_settings
.correct_exif_orientation() ==
281 images_service_pb
.InputSettings
.CORRECT_ORIENTATION
)
285 source_metadata
= self
._ExtractMetadata
(
286 original_image
, input_settings
.parse_metadata())
287 if input_settings
.parse_metadata():
289 'Once the application is deployed, a more powerful metadata '
290 'extraction will be performed which might return many more fields.')
292 new_image
= self
._ProcessTransforms
(
293 original_image
, request
.transform_list(), correct_orientation
)
295 substitution_rgb
= None
296 if input_settings
.has_transparent_substitution_rgb():
297 substitution_rgb
= input_settings
.transparent_substitution_rgb()
298 response_value
= self
._EncodeImage
(
299 new_image
, request
.output(), substitution_rgb
)
300 response
.mutable_image().set_content(response_value
)
301 response
.set_source_metadata(source_metadata
)
303 def _Dynamic_GetUrlBase(self
, request
, response
):
304 self
._blob
_stub
.GetUrlBase(request
, response
)
306 def _Dynamic_DeleteUrlBase(self
, request
, response
):
307 self
._blob
_stub
.DeleteUrlBase(request
, response
)
309 def _EncodeImage(self
, image
, output_encoding
, substitution_rgb
=None):
310 """Encode the given image and return it in string form.
313 image: PIL Image object, image to encode.
314 output_encoding: ImagesTransformRequest.OutputSettings object.
315 substitution_rgb: The color to use for transparent pixels if the output
316 format does not support transparency.
319 str - Encoded image information in given encoding format. Default is PNG.
321 image_string
= StringIO
.StringIO()
324 if output_encoding
.mime_type() == images_service_pb
.OutputSettings
.WEBP
:
325 image_encoding
= WEBP
327 if output_encoding
.mime_type() == images_service_pb
.OutputSettings
.JPEG
:
328 image_encoding
= JPEG
339 blue
= substitution_rgb
& 0xFF
340 green
= (substitution_rgb
>> 8) & 0xFF
341 red
= (substitution_rgb
>> 16) & 0xFF
342 background
= Image
.new(RGB
, image
.size
, (red
, green
, blue
))
343 background
.paste(image
, mask
=image
.split()[3])
346 image
= image
.convert(RGB
)
348 image
.save(image_string
, image_encoding
)
349 return image_string
.getvalue()
351 def _OpenImageData(self
, image_data
):
352 """Open image data from ImageData protocol buffer.
355 image_data: ImageData protocol buffer containing image data or blob
359 Image containing the image data passed in or reference by blob-key.
362 ApplicationError: Both content and blob-key are provided.
363 NOTE: 'content' must always be set because it is a required field,
364 however, it must be the empty string when a blob-key is provided.
366 if image_data
.content() and image_data
.has_blob_key():
367 raise apiproxy_errors
.ApplicationError(
368 images_service_pb
.ImagesServiceError
.INVALID_BLOB_KEY
)
370 if image_data
.has_blob_key():
371 image
= self
._OpenBlob
(image_data
.blob_key())
373 image
= self
._OpenImage
(image_data
.content())
376 img_format
= image
.format
377 if img_format
not in FORMAT_LIST
:
378 raise apiproxy_errors
.ApplicationError(
379 images_service_pb
.ImagesServiceError
.NOT_IMAGE
)
382 def _OpenImage(self
, image
):
383 """Opens an image provided as a string.
386 image: Image data to be opened.
389 ApplicationError: Image could not be opened or was an unsupported format.
392 Image containing the image data passed in.
395 raise apiproxy_errors
.ApplicationError(
396 images_service_pb
.ImagesServiceError
.NOT_IMAGE
)
398 image
= StringIO
.StringIO(image
)
400 return Image
.open(image
)
403 raise apiproxy_errors
.ApplicationError(
404 images_service_pb
.ImagesServiceError
.BAD_IMAGE_DATA
)
406 def _OpenBlob(self
, blob_key
):
407 """Create an Image from the blob data read from blob_key."""
411 blobstore_stub
.BlobstoreServiceStub
.ToDatastoreBlobKey(blob_key
))
412 except datastore_errors
.Error
:
415 logging
.exception('Blob with key %r does not exist', blob_key
)
416 raise apiproxy_errors
.ApplicationError(
417 images_service_pb
.ImagesServiceError
.UNSPECIFIED_ERROR
)
419 blobstore_storage
= apiproxy_stub_map
.apiproxy
.GetStub('blobstore')
423 blob_file
= blobstore_storage
.storage
.OpenBlob(blob_key
)
425 logging
.exception('Could not get file for blob_key %r', blob_key
)
427 raise apiproxy_errors
.ApplicationError(
428 images_service_pb
.ImagesServiceError
.BAD_IMAGE_DATA
)
431 return Image
.open(blob_file
)
433 logging
.exception('Could not open image %r for blob_key %r',
436 raise apiproxy_errors
.ApplicationError(
437 images_service_pb
.ImagesServiceError
.BAD_IMAGE_DATA
)
439 def _ValidateCropArg(self
, arg
):
440 """Check an argument for the Crop transform.
443 arg: float - Argument to Crop transform to check.
446 ApplicationError: There was a problem with the provided argument.
448 if not isinstance(arg
, float):
449 raise apiproxy_errors
.ApplicationError(
450 images_service_pb
.ImagesServiceError
.BAD_TRANSFORM_DATA
)
452 if 0 > arg
or arg
> 1.0:
453 raise apiproxy_errors
.ApplicationError(
454 images_service_pb
.ImagesServiceError
.BAD_TRANSFORM_DATA
)
456 def _CalculateNewDimensions(self
,
463 """Get new resize dimensions keeping the current aspect ratio.
465 This uses the more restricting of the two requested values to determine
466 the new ratio. See also crop_to_fit.
469 current_width: int, current width of the image.
470 current_height: int, current height of the image.
471 req_width: int, requested new width of the image, 0 if unspecified.
472 req_height: int, requested new height of the image, 0 if unspecified.
473 crop_to_fit: bool, True if the less restricting dimension should be used.
474 allow_stretch: bool, True is aspect ratio should be ignored.
477 apiproxy_errors.ApplicationError: if crop_to_fit is True either req_width
481 tuple (width, height) ints of the new dimensions.
485 width_ratio
= float(req_width
) / current_width
486 height_ratio
= float(req_height
) / current_height
490 if allow_stretch
or crop_to_fit
:
492 if not req_width
or not req_height
:
493 raise apiproxy_errors
.ApplicationError(
494 images_service_pb
.ImagesServiceError
.BAD_TRANSFORM_DATA
)
495 if not allow_stretch
:
496 if width_ratio
> height_ratio
:
497 height
= int(width_ratio
* current_height
)
499 width
= int(height_ratio
* current_width
)
504 if not req_width
or (width_ratio
> height_ratio
and req_height
):
506 width
= int(height_ratio
* current_width
)
509 height
= int(width_ratio
* current_height
)
512 def _Resize(self
, image
, transform
):
513 """Use PIL to resize the given image with the given transform.
516 image: PIL.Image.Image object to resize.
517 transform: images_service_pb.Transform to use when resizing.
520 PIL.Image.Image with transforms performed on it.
523 ApplicationError: The resize data given was bad.
528 if transform
.has_width():
529 width
= transform
.width()
530 if width
< 0 or 4000 < width
:
531 raise apiproxy_errors
.ApplicationError(
532 images_service_pb
.ImagesServiceError
.BAD_TRANSFORM_DATA
)
534 if transform
.has_height():
535 height
= transform
.height()
536 if height
< 0 or 4000 < height
:
537 raise apiproxy_errors
.ApplicationError(
538 images_service_pb
.ImagesServiceError
.BAD_TRANSFORM_DATA
)
540 crop_to_fit
= transform
.crop_to_fit()
541 allow_stretch
= transform
.allow_stretch()
543 current_width
, current_height
= image
.size
544 new_width
, new_height
= self
._CalculateNewDimensions
(
545 current_width
, current_height
, width
, height
, crop_to_fit
,
547 new_image
= image
.resize((new_width
, new_height
), Image
.ANTIALIAS
)
548 if crop_to_fit
and (new_width
> width
or new_height
> height
):
550 left
= int((new_width
- width
) * transform
.crop_offset_x())
551 top
= int((new_height
- height
) * transform
.crop_offset_y())
553 bottom
= top
+ height
554 new_image
= new_image
.crop((left
, top
, right
, bottom
))
558 def _Rotate(self
, image
, transform
):
559 """Use PIL to rotate the given image with the given transform.
562 image: PIL.Image.Image object to rotate.
563 transform: images_service_pb.Transform to use when rotating.
566 PIL.Image.Image with transforms performed on it.
569 ApplicationError: Given data for the rotate was bad.
571 degrees
= transform
.rotate()
572 if degrees
< 0 or degrees
% 90 != 0:
573 raise apiproxy_errors
.ApplicationError(
574 images_service_pb
.ImagesServiceError
.BAD_TRANSFORM_DATA
)
578 degrees
= 360 - degrees
579 return image
.rotate(degrees
)
581 def _Crop(self
, image
, transform
):
582 """Use PIL to crop the given image with the given transform.
585 image: PIL.Image.Image object to crop.
586 transform: images_service_pb.Transform to use when cropping.
589 PIL.Image.Image with transforms performed on it.
592 BadRequestError if the crop data given is bad.
599 if transform
.has_crop_left_x():
600 left_x
= transform
.crop_left_x()
601 self
._ValidateCropArg
(left_x
)
603 if transform
.has_crop_top_y():
604 top_y
= transform
.crop_top_y()
605 self
._ValidateCropArg
(top_y
)
607 if transform
.has_crop_right_x():
608 right_x
= transform
.crop_right_x()
609 self
._ValidateCropArg
(right_x
)
611 if transform
.has_crop_bottom_y():
612 bottom_y
= transform
.crop_bottom_y()
613 self
._ValidateCropArg
(bottom_y
)
616 width
, height
= image
.size
618 box
= (int(round(left_x
* width
)),
619 int(round(top_y
* height
)),
620 int(round(right_x
* width
)),
621 int(round(bottom_y
* height
)))
623 return image
.crop(box
)
626 def _GetExifFromImage(image
):
627 if hasattr(image
, '_getexif'):
635 from PIL
import TiffImagePlugin
637 return image
._getexif
()
639 # We have not managed to get this to work in the SDK with Python
640 # 2.5, so just catch the ImportError and pretend there is no
641 # EXIF information of interest.
642 logging
.info('Sorry, TiffImagePlugin does not work in this environment')
646 def _ExtractMetadata(image
, parse_metadata
):
647 """Extract EXIF metadata from the image.
649 Note that this is a much simplified version of metadata extraction. After
650 deployment applications have access to a more powerful parser that can
651 parse hundreds of fields from images.
654 image: PIL Image object.
655 parse_metadata: bool, True if metadata parsing has been requested. If
656 False the result will contain image dimensions.
658 str - JSON encoded values with various metadata fields.
661 def ExifTimeToUnixtime(exif_time
):
662 """Convert time in EXIF to unix time.
665 exif_time: str - Time from the EXIF block formated by EXIF standard.
666 Seconds are optional. (Example: '2011:02:20 10:23:12')
669 Integer, the time in unix fromat: seconds since the epoch.
671 match
= EXIF_TIME_REGEX
.match(exif_time
)
675 date
= datetime
.datetime(*map(int, filter(None, match
.groups())))
677 logging
.info('Invalid date in EXIF: %s', exif_time
)
679 return int(time
.mktime(date
.timetuple()))
682 parse_metadata
and ImagesServiceStub
._GetExifFromImage
(image
) or {})
684 metadata_dict
[256], metadata_dict
[257] = image
.size
688 if _EXIF_DATETIMEORIGINAL_TAG
in metadata_dict
:
689 date_ms
= ExifTimeToUnixtime(metadata_dict
[_EXIF_DATETIMEORIGINAL_TAG
])
691 metadata_dict
[_EXIF_DATETIMEORIGINAL_TAG
] = date_ms
693 del metadata_dict
[_EXIF_DATETIMEORIGINAL_TAG
]
695 [(_EXIF_TAGS
[k
], v
) for k
, v
in metadata_dict
.iteritems()
697 return simplejson
.dumps(metadata
)
699 def _CorrectOrientation(self
, image
, orientation
):
700 """Use PIL to correct the image orientation based on its EXIF.
702 See JEITA CP-3451 at http://www.exif.org/specifications.html,
706 image: source PIL.Image.Image object.
707 orientation: integer in range (1,8) inclusive, corresponding the image
708 orientation from EXIF.
711 PIL.Image.Image with transforms performed on it. If no correction was
712 done, it returns the input image.
717 image
= image
.transpose(Image
.FLIP_LEFT_RIGHT
)
718 elif orientation
== 3:
719 image
= image
.rotate(180)
720 elif orientation
== 4:
721 image
= image
.transpose(Image
.FLIP_TOP_BOTTOM
)
722 elif orientation
== 5:
723 image
= image
.transpose(Image
.FLIP_TOP_BOTTOM
)
724 image
= image
.rotate(270)
725 elif orientation
== 6:
726 image
= image
.rotate(270)
727 elif orientation
== 7:
728 image
= image
.transpose(Image
.FLIP_LEFT_RIGHT
)
729 image
= image
.rotate(270)
730 elif orientation
== 8:
731 image
= image
.rotate(90)
735 def _ProcessTransforms(self
, image
, transforms
, correct_orientation
):
736 """Execute PIL operations based on transform values.
739 image: PIL.Image.Image instance, image to manipulate.
740 transforms: list of ImagesTransformRequest.Transform objects.
741 correct_orientation: True to indicate that image orientation should be
742 corrected based on its EXIF.
744 PIL.Image.Image with transforms performed on it.
747 ApplicationError: More than one of the same type of transform was present.
750 if len(transforms
) > images
.MAX_TRANSFORMS_PER_REQUEST
:
751 raise apiproxy_errors
.ApplicationError(
752 images_service_pb
.ImagesServiceError
.BAD_TRANSFORM_DATA
)
755 if correct_orientation
:
758 exif
= self
._GetExifFromImage
(image
)
759 if not exif
or _EXIF_ORIENTATION_TAG
not in exif
:
760 correct_orientation
= False
762 orientation
= exif
[_EXIF_ORIENTATION_TAG
]
764 width
, height
= new_image
.size
768 for transform
in transforms
:
776 if (correct_orientation
and
777 not (transform
.has_crop_left_x() or
778 transform
.has_crop_top_y() or
779 transform
.has_crop_right_x() or
780 transform
.has_crop_bottom_y()) and
781 not transform
.has_horizontal_flip() and
782 not transform
.has_vertical_flip()):
783 new_image
= self
._CorrectOrientation
(new_image
, orientation
)
784 correct_orientation
= False
786 if transform
.has_width() or transform
.has_height():
788 new_image
= self
._Resize
(new_image
, transform
)
790 elif transform
.has_rotate():
792 new_image
= self
._Rotate
(new_image
, transform
)
794 elif transform
.has_horizontal_flip():
796 new_image
= new_image
.transpose(Image
.FLIP_LEFT_RIGHT
)
798 elif transform
.has_vertical_flip():
800 new_image
= new_image
.transpose(Image
.FLIP_TOP_BOTTOM
)
802 elif (transform
.has_crop_left_x() or
803 transform
.has_crop_top_y() or
804 transform
.has_crop_right_x() or
805 transform
.has_crop_bottom_y()):
807 new_image
= self
._Crop
(new_image
, transform
)
809 elif transform
.has_autolevels():
812 logging
.info('I\'m Feeling Lucky autolevels will be visible once this '
813 'application is deployed.')
815 logging
.warn('Found no transformations found to perform.')
817 if correct_orientation
:
820 new_image
= self
._CorrectOrientation
(new_image
, orientation
)
821 correct_orientation
= False