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."""
36 import json
as simplejson
42 from PIL
import _imaging
46 # Try importing the 'Image' module directly. If that fails, try
47 # importing it from the 'PIL' package (this is necessary to also
48 # cover "pillow" package installations).
54 from google
.appengine
.api
import apiproxy_stub
55 from google
.appengine
.api
import apiproxy_stub_map
56 from google
.appengine
.api
import datastore
57 from google
.appengine
.api
import datastore_errors
58 from google
.appengine
.api
import datastore_types
59 from google
.appengine
.api
import images
60 from google
.appengine
.api
.blobstore
import blobstore_stub
61 from google
.appengine
.api
.images
import images_blob_stub
62 from google
.appengine
.api
.images
import images_service_pb
63 from google
.appengine
.runtime
import apiproxy_errors
69 GS_INFO_KIND
= "__GsFileInfo__"
71 BLOB_SERVING_URL_KIND
= images_blob_stub
.BLOB_SERVING_URL_KIND
73 MAX_REQUEST_SIZE
= 32 << 20
76 _EXIF_ORIENTATION_TAG
= 274
79 _EXIF_DATETIMEORIGINAL_TAG
= 36867
87 _EXIF_ORIENTATION_TAG
: "Orientation",
90 34855: "ISOSpeedRatings",
91 _EXIF_DATETIMEORIGINAL_TAG
: "DateTimeOriginal",
92 36868: "DateTimeDigitized",
93 37383: "MeteringMode",
95 41987: "WhiteBallance"}
98 def _ArgbToRgbaTuple(argb
):
99 """Convert from a single ARGB value to a tuple containing RGBA.
102 argb: Signed 32 bit integer containing an ARGB value.
108 unsigned_argb
= argb
% 0x100000000
109 return ((unsigned_argb
>> 16) & 0xFF,
110 (unsigned_argb
>> 8) & 0xFF,
111 unsigned_argb
& 0xFF,
112 (unsigned_argb
>> 24) & 0xFF)
115 def _BackendPremultiplication(color
):
116 """Apply premultiplication and unpremultiplication to match production.
119 color: color tuple as returned by _ArgbToRgbaTuple.
130 multiplied
= [(x
* (alpha
+ 1)) >> 8 for x
in rgb
]
132 alpha_inverse
= 0xffffff / alpha
133 unmultiplied
= [(x
* alpha_inverse
) >> 16 for x
in multiplied
]
135 unmultiplied
= [0] * 3
137 return tuple(unmultiplied
+ [alpha
])
140 class ImagesServiceStub(apiproxy_stub
.APIProxyStub
):
141 """Stub version of images API to be used with the dev_appserver."""
143 def __init__(self
, service_name
="images", host_prefix
=""):
144 """Preloads PIL to load all modules in the unhardened environment.
147 service_name: Service name expected for all calls.
148 host_prefix: the URL prefix (protocol://host:port) to preprend to
149 image urls on a call to GetUrlBase.
151 super(ImagesServiceStub
, self
).__init
__(service_name
,
152 max_request_size
=MAX_REQUEST_SIZE
)
153 self
._blob
_stub
= images_blob_stub
.ImagesBlobStub(host_prefix
)
156 def _Dynamic_Composite(self
, request
, response
):
157 """Implementation of ImagesService::Composite.
159 Based off documentation of the PIL library at
160 http://www.pythonware.com/library/pil/handbook/index.htm
163 request: ImagesCompositeRequest, contains image request info.
164 response: ImagesCompositeResponse, contains transformed image.
166 width
= request
.canvas().width()
167 height
= request
.canvas().height()
168 color
= _ArgbToRgbaTuple(request
.canvas().color())
171 color
= _BackendPremultiplication(color
)
172 canvas
= Image
.new("RGBA", (width
, height
), color
)
174 if (not request
.canvas().width() or request
.canvas().width() > 4000 or
175 not request
.canvas().height() or request
.canvas().height() > 4000):
176 raise apiproxy_errors
.ApplicationError(
177 images_service_pb
.ImagesServiceError
.BAD_TRANSFORM_DATA
)
178 if not request
.image_size():
179 raise apiproxy_errors
.ApplicationError(
180 images_service_pb
.ImagesServiceError
.BAD_TRANSFORM_DATA
)
181 if not request
.options_size():
182 raise apiproxy_errors
.ApplicationError(
183 images_service_pb
.ImagesServiceError
.BAD_TRANSFORM_DATA
)
184 if request
.options_size() > images
.MAX_COMPOSITES_PER_REQUEST
:
185 raise apiproxy_errors
.ApplicationError(
186 images_service_pb
.ImagesServiceError
.BAD_TRANSFORM_DATA
)
187 for image
in request
.image_list():
188 sources
.append(self
._OpenImageData
(image
))
190 for options
in request
.options_list():
191 if (options
.anchor() < images
.TOP_LEFT
or
192 options
.anchor() > images
.BOTTOM_RIGHT
):
193 raise apiproxy_errors
.ApplicationError(
194 images_service_pb
.ImagesServiceError
.BAD_TRANSFORM_DATA
)
195 if options
.source_index() >= len(sources
) or options
.source_index() < 0:
196 raise apiproxy_errors
.ApplicationError(
197 images_service_pb
.ImagesServiceError
.BAD_TRANSFORM_DATA
)
198 if options
.opacity() < 0 or options
.opacity() > 1:
199 raise apiproxy_errors
.ApplicationError(
200 images_service_pb
.ImagesServiceError
.BAD_TRANSFORM_DATA
)
201 source
= sources
[options
.source_index()]
202 x_anchor
= (options
.anchor() % 3) * 0.5
203 y_anchor
= (options
.anchor() / 3) * 0.5
204 x_offset
= int(options
.x_offset() + x_anchor
* (width
- source
.size
[0]))
205 y_offset
= int(options
.y_offset() + y_anchor
* (height
- source
.size
[1]))
206 if source
.mode
== "RGBA":
207 canvas
.paste(source
, (x_offset
, y_offset
), source
)
209 alpha
= options
.opacity() * 255
210 mask
= Image
.new("L", source
.size
, alpha
)
211 canvas
.paste(source
, (x_offset
, y_offset
), mask
)
212 response_value
= self
._EncodeImage
(canvas
, request
.canvas().output())
213 response
.mutable_image().set_content(response_value
)
215 def _Dynamic_Histogram(self
, request
, response
):
216 """Trivial implementation of ImagesService::Histogram.
218 Based off documentation of the PIL library at
219 http://www.pythonware.com/library/pil/handbook/index.htm
222 request: ImagesHistogramRequest, contains the image.
223 response: ImagesHistogramResponse, contains histogram of the image.
225 image
= self
._OpenImageData
(request
.image())
227 img_format
= image
.format
228 if img_format
not in ("BMP", "GIF", "ICO", "JPEG", "PNG", "TIFF", "WEBP"):
229 raise apiproxy_errors
.ApplicationError(
230 images_service_pb
.ImagesServiceError
.NOT_IMAGE
)
231 image
= image
.convert("RGBA")
239 for pixel
in image
.getdata():
240 red
[int((pixel
[0] * pixel
[3]) / 255)] += 1
241 green
[int((pixel
[1] * pixel
[3]) / 255)] += 1
242 blue
[int((pixel
[2] * pixel
[3]) / 255)] += 1
243 histogram
= response
.mutable_histogram()
245 histogram
.add_red(value
)
247 histogram
.add_green(value
)
249 histogram
.add_blue(value
)
251 def _Dynamic_Transform(self
, request
, response
):
252 """Trivial implementation of ImagesService::Transform.
254 Based off documentation of the PIL library at
255 http://www.pythonware.com/library/pil/handbook/index.htm
258 request: ImagesTransformRequest, contains image request info.
259 response: ImagesTransformResponse, contains transformed image.
261 original_image
= self
._OpenImageData
(request
.image())
263 input_settings
= request
.input()
264 correct_orientation
= (
265 input_settings
.has_correct_exif_orientation() and
266 input_settings
.correct_exif_orientation() ==
267 images_service_pb
.InputSettings
.CORRECT_ORIENTATION
)
271 source_metadata
= self
._ExtractMetadata
(
272 original_image
, input_settings
.parse_metadata())
273 if input_settings
.parse_metadata():
275 "Once the application is deployed, a more powerful metadata "
276 "extraction will be performed which might return many more fields.")
278 new_image
= self
._ProcessTransforms
(original_image
,
279 request
.transform_list(),
282 substitution_rgb
= None
283 if input_settings
.has_transparent_substitution_rgb():
284 substitution_rgb
= input_settings
.transparent_substitution_rgb()
285 response_value
= self
._EncodeImage
(new_image
,
288 response
.mutable_image().set_content(response_value
)
289 response
.set_source_metadata(source_metadata
)
291 def _Dynamic_GetUrlBase(self
, request
, response
):
292 self
._blob
_stub
.GetUrlBase(request
, response
)
294 def _Dynamic_DeleteUrlBase(self
, request
, response
):
295 self
._blob
_stub
.DeleteUrlBase(request
, response
)
297 def _EncodeImage(self
, image
, output_encoding
, substitution_rgb
=None):
298 """Encode the given image and return it in string form.
301 image: PIL Image object, image to encode.
302 output_encoding: ImagesTransformRequest.OutputSettings object.
303 substitution_rgb: The color to use for transparent pixels if the output
304 format does not support transparency.
307 str with encoded image information in given encoding format.
309 image_string
= StringIO
.StringIO()
311 image_encoding
= "PNG"
313 if (output_encoding
.mime_type() == images_service_pb
.OutputSettings
.WEBP
):
314 image_encoding
= "WEBP"
316 if (output_encoding
.mime_type() == images_service_pb
.OutputSettings
.JPEG
):
317 image_encoding
= "JPEG"
328 blue
= substitution_rgb
& 0xFF
329 green
= (substitution_rgb
>> 8) & 0xFF
330 red
= (substitution_rgb
>> 16) & 0xFF
331 background
= Image
.new("RGB", image
.size
, (red
, green
, blue
))
332 background
.paste(image
, mask
=image
.split()[3])
335 image
= image
.convert("RGB")
337 image
.save(image_string
, image_encoding
)
338 return image_string
.getvalue()
340 def _OpenImageData(self
, image_data
):
341 """Open image data from ImageData protocol buffer.
344 image_data: ImageData protocol buffer containing image data or blob
348 Image containing the image data passed in or reference by blob-key.
351 ApplicationError if both content and blob-key are provided.
352 NOTE: 'content' must always be set because it is a required field,
353 however, it must be the empty string when a blob-key is provided.
355 if image_data
.content() and image_data
.has_blob_key():
356 raise apiproxy_errors
.ApplicationError(
357 images_service_pb
.ImagesServiceError
.INVALID_BLOB_KEY
)
359 if image_data
.has_blob_key():
360 image
= self
._OpenBlob
(image_data
.blob_key())
362 image
= self
._OpenImage
(image_data
.content())
365 img_format
= image
.format
366 if img_format
not in ("BMP", "GIF", "ICO", "JPEG", "PNG", "TIFF", "WEBP"):
367 raise apiproxy_errors
.ApplicationError(
368 images_service_pb
.ImagesServiceError
.NOT_IMAGE
)
371 def _OpenImage(self
, image
):
372 """Opens an image provided as a string.
375 image: image data to be opened
378 apiproxy_errors.ApplicationError if the image cannot be opened or if it
379 is an unsupported format.
382 Image containing the image data passed in.
385 raise apiproxy_errors
.ApplicationError(
386 images_service_pb
.ImagesServiceError
.NOT_IMAGE
)
388 image
= StringIO
.StringIO(image
)
390 return Image
.open(image
)
393 raise apiproxy_errors
.ApplicationError(
394 images_service_pb
.ImagesServiceError
.BAD_IMAGE_DATA
)
396 def _OpenBlob(self
, blob_key
):
397 """Create an Image from the blob data read from blob_key."""
401 blobstore_stub
.BlobstoreServiceStub
.ToDatastoreBlobKey(blob_key
))
402 except datastore_errors
.Error
:
405 logging
.exception("Blob with key %r does not exist", blob_key
)
406 raise apiproxy_errors
.ApplicationError(
407 images_service_pb
.ImagesServiceError
.UNSPECIFIED_ERROR
)
409 blobstore_storage
= apiproxy_stub_map
.apiproxy
.GetStub("blobstore")
413 blob_file
= blobstore_storage
.storage
.OpenBlob(blob_key
)
415 logging
.exception("Could not get file for blob_key %r", blob_key
)
417 raise apiproxy_errors
.ApplicationError(
418 images_service_pb
.ImagesServiceError
.BAD_IMAGE_DATA
)
421 return Image
.open(blob_file
)
423 logging
.exception("Could not open image %r for blob_key %r",
426 raise apiproxy_errors
.ApplicationError(
427 images_service_pb
.ImagesServiceError
.BAD_IMAGE_DATA
)
429 def _ValidateCropArg(self
, arg
):
430 """Check an argument for the Crop transform.
433 arg: float, argument to Crop transform to check.
436 apiproxy_errors.ApplicationError on problem with argument.
438 if not isinstance(arg
, float):
439 raise apiproxy_errors
.ApplicationError(
440 images_service_pb
.ImagesServiceError
.BAD_TRANSFORM_DATA
)
442 if not (0 <= arg
<= 1.0):
443 raise apiproxy_errors
.ApplicationError(
444 images_service_pb
.ImagesServiceError
.BAD_TRANSFORM_DATA
)
446 def _CalculateNewDimensions(self
,
453 """Get new resize dimensions keeping the current aspect ratio.
455 This uses the more restricting of the two requested values to determine
456 the new ratio. See also crop_to_fit.
459 current_width: int, current width of the image.
460 current_height: int, current height of the image.
461 req_width: int, requested new width of the image, 0 if unspecified.
462 req_height: int, requested new height of the image, 0 if unspecified.
463 crop_to_fit: bool, True if the less restricting dimension should be used.
464 allow_stretch: bool, True is aspect ratio should be ignored.
467 apiproxy_errors.ApplicationError: if crop_to_fit is True either req_width
471 tuple (width, height) which are both ints of the new ratio.
475 width_ratio
= float(req_width
) / current_width
476 height_ratio
= float(req_height
) / current_height
480 if not req_width
or not req_height
:
481 raise apiproxy_errors
.ApplicationError(
482 images_service_pb
.ImagesServiceError
.BAD_TRANSFORM_DATA
)
483 return req_width
, req_height
486 if not req_width
or not req_height
:
487 raise apiproxy_errors
.ApplicationError(
488 images_service_pb
.ImagesServiceError
.BAD_TRANSFORM_DATA
)
489 if width_ratio
> height_ratio
:
490 return req_width
, int(width_ratio
* current_height
)
492 return int(height_ratio
* current_width
), req_height
496 if req_width
== 0 or (width_ratio
> height_ratio
and req_height
!= 0):
498 return int(height_ratio
* current_width
), req_height
501 return req_width
, int(width_ratio
* current_height
)
503 def _Resize(self
, image
, transform
):
504 """Use PIL to resize the given image with the given transform.
507 image: PIL.Image.Image object to resize.
508 transform: images_service_pb.Transform to use when resizing.
511 PIL.Image.Image with transforms performed on it.
514 BadRequestError if the resize data given is bad.
519 if transform
.has_width():
520 width
= transform
.width()
521 if width
< 0 or 4000 < width
:
522 raise apiproxy_errors
.ApplicationError(
523 images_service_pb
.ImagesServiceError
.BAD_TRANSFORM_DATA
)
525 if transform
.has_height():
526 height
= transform
.height()
527 if height
< 0 or 4000 < height
:
528 raise apiproxy_errors
.ApplicationError(
529 images_service_pb
.ImagesServiceError
.BAD_TRANSFORM_DATA
)
531 crop_to_fit
= transform
.crop_to_fit()
532 allow_stretch
= transform
.allow_stretch()
534 current_width
, current_height
= image
.size
535 new_width
, new_height
= self
._CalculateNewDimensions
(current_width
,
541 new_image
= image
.resize((new_width
, new_height
), Image
.ANTIALIAS
)
542 if crop_to_fit
and (new_width
> width
or new_height
> height
):
544 left
= int((new_width
- width
) * transform
.crop_offset_x())
545 top
= int((new_height
- height
) * transform
.crop_offset_y())
547 bottom
= top
+ height
548 new_image
= new_image
.crop((left
, top
, right
, bottom
))
552 def _Rotate(self
, image
, transform
):
553 """Use PIL to rotate the given image with the given transform.
556 image: PIL.Image.Image object to rotate.
557 transform: images_service_pb.Transform to use when rotating.
560 PIL.Image.Image with transforms performed on it.
563 BadRequestError if the rotate data given is bad.
565 degrees
= transform
.rotate()
566 if degrees
< 0 or degrees
% 90 != 0:
567 raise apiproxy_errors
.ApplicationError(
568 images_service_pb
.ImagesServiceError
.BAD_TRANSFORM_DATA
)
572 degrees
= 360 - degrees
573 return image
.rotate(degrees
)
575 def _Crop(self
, image
, transform
):
576 """Use PIL to crop the given image with the given transform.
579 image: PIL.Image.Image object to crop.
580 transform: images_service_pb.Transform to use when cropping.
583 PIL.Image.Image with transforms performed on it.
586 BadRequestError if the crop data given is bad.
593 if transform
.has_crop_left_x():
594 left_x
= transform
.crop_left_x()
595 self
._ValidateCropArg
(left_x
)
597 if transform
.has_crop_top_y():
598 top_y
= transform
.crop_top_y()
599 self
._ValidateCropArg
(top_y
)
601 if transform
.has_crop_right_x():
602 right_x
= transform
.crop_right_x()
603 self
._ValidateCropArg
(right_x
)
605 if transform
.has_crop_bottom_y():
606 bottom_y
= transform
.crop_bottom_y()
607 self
._ValidateCropArg
(bottom_y
)
610 width
, height
= image
.size
612 box
= (int(round(left_x
* width
)),
613 int(round(top_y
* height
)),
614 int(round(right_x
* width
)),
615 int(round(bottom_y
* height
)))
617 return image
.crop(box
)
620 def _GetExifFromImage(image
):
621 if hasattr(image
, "_getexif"):
628 from PIL
import TiffImagePlugin
629 return image
._getexif
()
631 # We have not managed to get this to work in the SDK with Python
632 # 2.5, so just catch the ImportError and pretend there is no
633 # EXIF information of interest.
634 logging
.info('Sorry, TiffImagePlugin does not work in this environment')
638 def _ExtractMetadata(image
, parse_metadata
):
639 """Extract EXIF metadata from the image.
641 Note that this is a much simplified version of metadata extraction. After
642 deployment applications have access to a more powerful parser that can
643 parse hundreds of fields from images.
646 image: PIL Image object.
647 parse_metadata: bool, True if metadata parsing has been requested. If
648 False the result will contain image dimensions.
650 str, JSON encoded values with various metadata fields.
653 def ExifTimeToUnixtime(exif_time
):
654 """Convert time in EXIF to unix time.
657 exif_time: str, the time from the EXIF block formated by EXIF standard.
658 E.g., "2011:02:20 10:23:12", seconds are optional.
661 Integer, the time in unix fromat: seconds since the epoch.
663 regexp
= re
.compile(r
"^([0-9]{4}):([0-9]{1,2}):([0-9]{1,2})"
664 " ([0-9]{1,2}):([0-9]{1,2})(?::([0-9]{1,2}))?")
665 match
= regexp
.match(exif_time
)
666 if match
is None: return None
668 date
= datetime
.datetime(*map(int, filter(None, match
.groups())))
670 logging
.info("Invalid date in EXIF: %s", exif_time
)
672 return int(time
.mktime(date
.timetuple()))
675 parse_metadata
and ImagesServiceStub
._GetExifFromImage
(image
) or {})
677 metadata_dict
[256], metadata_dict
[257] = image
.size
681 if _EXIF_DATETIMEORIGINAL_TAG
in metadata_dict
:
682 date_ms
= ExifTimeToUnixtime(metadata_dict
[_EXIF_DATETIMEORIGINAL_TAG
])
684 metadata_dict
[_EXIF_DATETIMEORIGINAL_TAG
] = date_ms
686 del metadata_dict
[_EXIF_DATETIMEORIGINAL_TAG
]
687 metadata
= dict([(_EXIF_TAGS
[k
], v
)
688 for k
, v
in metadata_dict
.iteritems()
690 return simplejson
.dumps(metadata
)
692 def _CorrectOrientation(self
, image
, orientation
):
693 """Use PIL to correct the image orientation based on its EXIF.
695 See JEITA CP-3451 at http://www.exif.org/specifications.html,
699 image: source PIL.Image.Image object.
700 orientation: integer in range (1,8) inclusive, corresponding the image
701 orientation from EXIF.
704 PIL.Image.Image with transforms performed on it. If no correction was
705 done, it returns the input image.
710 image
= image
.transpose(Image
.FLIP_LEFT_RIGHT
)
711 elif orientation
== 3:
712 image
= image
.rotate(180)
713 elif orientation
== 4:
714 image
= image
.transpose(Image
.FLIP_TOP_BOTTOM
)
715 elif orientation
== 5:
716 image
= image
.transpose(Image
.FLIP_TOP_BOTTOM
)
717 image
= image
.rotate(270)
718 elif orientation
== 6:
719 image
= image
.rotate(270)
720 elif orientation
== 7:
721 image
= image
.transpose(Image
.FLIP_LEFT_RIGHT
)
722 image
= image
.rotate(270)
723 elif orientation
== 8:
724 image
= image
.rotate(90)
728 def _ProcessTransforms(self
, image
, transforms
, correct_orientation
):
729 """Execute PIL operations based on transform values.
732 image: PIL.Image.Image instance, image to manipulate.
733 transforms: list of ImagesTransformRequest.Transform objects.
734 correct_orientation: True to indicate that image orientation should be
735 corrected based on its EXIF.
737 PIL.Image.Image with transforms performed on it.
740 BadRequestError if we are passed more than one of the same type of
744 if len(transforms
) > images
.MAX_TRANSFORMS_PER_REQUEST
:
745 raise apiproxy_errors
.ApplicationError(
746 images_service_pb
.ImagesServiceError
.BAD_TRANSFORM_DATA
)
749 if correct_orientation
:
752 exif
= self
._GetExifFromImage
(image
)
753 if not exif
or _EXIF_ORIENTATION_TAG
not in exif
:
754 correct_orientation
= False
756 orientation
= exif
[_EXIF_ORIENTATION_TAG
]
758 width
, height
= new_image
.size
762 for transform
in transforms
:
770 if (correct_orientation
and
771 not (transform
.has_crop_left_x() or
772 transform
.has_crop_top_y() or
773 transform
.has_crop_right_x() or
774 transform
.has_crop_bottom_y()) and
775 not transform
.has_horizontal_flip() and
776 not transform
.has_vertical_flip()):
777 new_image
= self
._CorrectOrientation
(new_image
, orientation
)
778 correct_orientation
= False
780 if transform
.has_width() or transform
.has_height():
782 new_image
= self
._Resize
(new_image
, transform
)
784 elif transform
.has_rotate():
786 new_image
= self
._Rotate
(new_image
, transform
)
788 elif transform
.has_horizontal_flip():
790 new_image
= new_image
.transpose(Image
.FLIP_LEFT_RIGHT
)
792 elif transform
.has_vertical_flip():
794 new_image
= new_image
.transpose(Image
.FLIP_TOP_BOTTOM
)
796 elif (transform
.has_crop_left_x() or
797 transform
.has_crop_top_y() or
798 transform
.has_crop_right_x() or
799 transform
.has_crop_bottom_y()):
801 new_image
= self
._Crop
(new_image
, transform
)
803 elif transform
.has_autolevels():
806 logging
.info("I'm Feeling Lucky autolevels will be visible once this "
807 "application is deployed.")
809 logging
.warn("Found no transformations found to perform.")
811 if correct_orientation
:
814 new_image
= self
._CorrectOrientation
(new_image
, orientation
)
815 correct_orientation
= False