Commit f0908249 by Cliff Dyer

Merge pull request #11301 from edx/cdyer/exif-rotation

Preserve EXIF orientation data for profile images. (MA-1559)
parents d6b258fd 2b6ae748
"""
Exceptions related to the handling of profile images.
"""
class ImageValidationError(Exception):
"""
Exception to use when the system rejects a user-supplied source image.
"""
@property
def user_message(self):
"""
Translate the developer-facing exception message for API clients.
"""
return self.message
......@@ -3,13 +3,16 @@ Image file manipulation functions related to profile images.
"""
from cStringIO import StringIO
from collections import namedtuple
from contextlib import closing
from django.conf import settings
from django.core.files.base import ContentFile
from django.utils.translation import ugettext as _
import piexif
from PIL import Image
from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_storage
from .exceptions import ImageValidationError
ImageType = namedtuple('ImageType', ('extensions', 'mimetypes', 'magic'))
......@@ -33,42 +36,47 @@ IMAGE_TYPES = {
}
def user_friendly_size(size):
def create_profile_images(image_file, profile_image_names):
"""
Convert size in bytes to user friendly size.
Generates a set of image files based on image_file and stores them
according to the sizes and filenames specified in `profile_image_names`.
Arguments:
size (int): size in bytes
image_file (file):
The uploaded image file to be cropped and scaled to use as a
profile image. The image is cropped to the largest possible square,
and centered on this image.
profile_image_names (dict):
A dictionary that maps image sizes to file names. The image size
is an integer representing one side of the equilateral image to be
created.
Returns:
user friendly size
None
"""
units = [_('bytes'), _('KB'), _('MB')]
i = 0
while size >= 1024:
size /= 1024
i += 1
return u'{} {}'.format(size, units[i])
storage = get_profile_image_storage()
original = Image.open(image_file)
image = _set_color_mode_to_rgb(original)
image = _crop_image_to_square(image)
def get_valid_file_types():
"""
Return comma separated string of valid file types.
"""
return ', '.join([', '.join(IMAGE_TYPES[ft].extensions) for ft in IMAGE_TYPES.keys()])
for size, name in profile_image_names.items():
scaled = _scale_image(image, size)
exif = _get_corrected_exif(scaled, original)
with closing(_create_image_file(scaled, exif)) as scaled_image_file:
storage.save(name, scaled_image_file)
class ImageValidationError(Exception):
def remove_profile_images(profile_image_names):
"""
Exception to use when the system rejects a user-supplied source image.
Physically remove the image files specified in `profile_image_names`
"""
@property
def user_message(self):
"""
Translate the developer-facing exception message for API clients.
"""
# pylint: disable=translation-of-non-string
return _(self.message)
storage = get_profile_image_storage()
for name in profile_image_names.values():
storage.delete(name)
def validate_uploaded_image(uploaded_file):
......@@ -86,14 +94,14 @@ def validate_uploaded_image(uploaded_file):
file_upload_too_large = _(
u'The file must be smaller than {image_max_size} in size.'
).format(
image_max_size=user_friendly_size(settings.PROFILE_IMAGE_MAX_BYTES)
image_max_size=_user_friendly_size(settings.PROFILE_IMAGE_MAX_BYTES)
)
raise ImageValidationError(file_upload_too_large)
elif uploaded_file.size < settings.PROFILE_IMAGE_MIN_BYTES:
file_upload_too_small = _(
u'The file must be at least {image_min_size} in size.'
).format(
image_min_size=user_friendly_size(settings.PROFILE_IMAGE_MIN_BYTES)
image_min_size=_user_friendly_size(settings.PROFILE_IMAGE_MIN_BYTES)
)
raise ImageValidationError(file_upload_too_small)
......@@ -103,7 +111,7 @@ def validate_uploaded_image(uploaded_file):
if not filetype:
file_upload_bad_type = _(
u'The file must be one of the following types: {valid_file_types}.'
).format(valid_file_types=get_valid_file_types())
).format(valid_file_types=_get_valid_file_types())
raise ImageValidationError(file_upload_bad_type)
filetype = filetype[0]
......@@ -127,51 +135,109 @@ def validate_uploaded_image(uploaded_file):
uploaded_file.seek(0)
def _get_scaled_image_file(image_obj, size):
def _crop_image_to_square(image):
"""
Given a PIL.Image object, return a copy cropped to a square around the
center point with each side set to the size of the smaller dimension.
"""
width, height = image.size
if width != height:
side = width if width < height else height
left = (width - side) // 2
top = (height - side) // 2
right = (width + side) // 2
bottom = (height + side) // 2
image = image.crop((left, top, right, bottom))
return image
def _set_color_mode_to_rgb(image):
"""
Given a PIL.Image object, return a copy with the color mode set to RGB.
"""
return image.convert('RGB')
def _scale_image(image, side_length):
"""
Given a PIL.Image object, get a resized copy with each side being
`side_length` pixels long. The scaled image will always be square.
"""
return image.resize((side_length, side_length), Image.ANTIALIAS)
def _create_image_file(image, exif):
"""
Given a PIL.Image object, get a resized copy using `size` (square) and
return a file-like object containing the data saved as a JPEG.
Given a PIL.Image object, create and return a file-like object containing
the data saved as a JPEG.
Note that the file object returned is a django ContentFile which holds
data in memory (not on disk).
Note that the file object returned is a django ContentFile which holds data
in memory (not on disk).
"""
if image_obj.mode != "RGB":
image_obj = image_obj.convert("RGB")
scaled = image_obj.resize((size, size), Image.ANTIALIAS)
string_io = StringIO()
scaled.save(string_io, format='JPEG')
# The if/else dance below is required, because PIL raises an exception if
# you pass None as the value of the exif kwarg.
if exif is None:
image.save(string_io, format='JPEG')
else:
image.save(string_io, format='JPEG', exif=exif)
image_file = ContentFile(string_io.getvalue())
return image_file
def create_profile_images(image_file, profile_image_names):
def _get_corrected_exif(image, original):
"""
Generates a set of image files based on image_file and
stores them according to the sizes and filenames specified
in `profile_image_names`.
If the original image contains exif data, use that data to
preserve image orientation in the new image.
"""
image_obj = Image.open(image_file)
if 'exif' in original.info:
image_exif = image.info.get('exif', piexif.dump({}))
original_exif = original.info['exif']
image_exif = _update_exif_orientation(image_exif, _get_exif_orientation(original_exif))
return image_exif
# first center-crop the image if needed (but no scaling yet).
width, height = image_obj.size
if width != height:
side = width if width < height else height
image_obj = image_obj.crop(((width - side) / 2, (height - side) / 2, (width + side) / 2, (height + side) / 2))
storage = get_profile_image_storage()
for size, name in profile_image_names.items():
scaled_image_file = _get_scaled_image_file(image_obj, size)
# Store the file.
try:
storage.save(name, scaled_image_file)
finally:
scaled_image_file.close()
def _update_exif_orientation(exif, orientation):
"""
Given an exif value and an integer value 1-8, reflecting a valid value for
the exif orientation, return a new exif with the orientation set.
"""
exif_dict = piexif.load(exif)
exif_dict['0th'][piexif.ImageIFD.Orientation] = orientation
return piexif.dump(exif_dict)
def remove_profile_images(profile_image_names):
def _get_exif_orientation(exif):
"""
Physically remove the image files specified in `profile_image_names`
Return the orientation value for the given Image object, or None if the
value is not set.
"""
storage = get_profile_image_storage()
for name in profile_image_names.values():
storage.delete(name)
exif_dict = piexif.load(exif)
return exif_dict['0th'].get(piexif.ImageIFD.Orientation)
def _get_valid_file_types():
"""
Return comma separated string of valid file types.
"""
return ', '.join([', '.join(IMAGE_TYPES[ft].extensions) for ft in IMAGE_TYPES.keys()])
def _user_friendly_size(size):
"""
Convert size in bytes to user friendly size.
Arguments:
size (int): size in bytes
Returns:
user friendly size
"""
units = [_('bytes'), _('KB'), _('MB')]
i = 0
while size >= 1024 and i < len(units):
size /= 1024
i += 1
return u'{} {}'.format(size, units[i])
......@@ -6,11 +6,12 @@ import os
from tempfile import NamedTemporaryFile
from django.core.files.uploadedfile import UploadedFile
import piexif
from PIL import Image
@contextmanager
def make_image_file(dimensions=(320, 240), extension=".jpeg", force_size=None):
def make_image_file(dimensions=(320, 240), extension=".jpeg", force_size=None, orientation=None):
"""
Yields a named temporary file created with the specified image type and
options.
......@@ -24,7 +25,11 @@ def make_image_file(dimensions=(320, 240), extension=".jpeg", force_size=None):
image = Image.new('RGB', dimensions, "green")
image_file = NamedTemporaryFile(suffix=extension)
try:
image.save(image_file)
if orientation and orientation in xrange(1, 9):
exif_bytes = piexif.dump({'0th': {piexif.ImageIFD.Orientation: orientation}})
image.save(image_file, exif=exif_bytes)
else:
image.save(image_file)
if force_size is not None:
image_file.seek(0, os.SEEK_END)
bytes_to_pad = force_size - image_file.tell()
......
......@@ -13,14 +13,16 @@ from django.test import TestCase
from django.test.utils import override_settings
import ddt
import mock
import piexif
from PIL import Image
from ..exceptions import ImageValidationError
from ..images import (
create_profile_images,
ImageValidationError,
remove_profile_images,
validate_uploaded_image,
get_valid_file_types,
_get_exif_orientation,
_get_valid_file_types,
)
from .helpers import make_image_file, make_uploaded_file
......@@ -33,7 +35,7 @@ class TestValidateUploadedImage(TestCase):
"""
FILE_UPLOAD_BAD_TYPE = (
u'The file must be one of the following types: {valid_file_types}.'.format(
valid_file_types=get_valid_file_types()
valid_file_types=_get_valid_file_types()
)
)
......@@ -126,6 +128,18 @@ class TestGenerateProfileImages(TestCase):
"""
Test create_profile_images
"""
def check_exif_orientation(self, image, expected_orientation):
"""
Check that the created object is a JPEG and that it has the expected
"""
self.assertEqual(image.format, 'JPEG')
if expected_orientation is not None:
self.assertIn('exif', image.info)
self.assertEqual(_get_exif_orientation(image.info['exif']), expected_orientation)
else:
self.assertIsNone(_get_exif_orientation(image.info.get('exif', piexif.dump({}))))
@ddt.data(
*product(
["gif", "jpg", "png"],
......@@ -146,24 +160,49 @@ class TestGenerateProfileImages(TestCase):
100: "hundred.jpg",
1000: "thousand.jpg",
}
mock_storage = mock.Mock()
with make_uploaded_file(dimensions=dimensions, extension=extension, content_type=content_type) as uploaded_file:
with mock.patch(
"openedx.core.djangoapps.profile_images.images.get_profile_image_storage",
return_value=mock_storage,
):
create_profile_images(uploaded_file, requested_sizes)
names_and_files = [v[0] for v in mock_storage.save.call_args_list]
actual_sizes = {}
for name, file_ in names_and_files:
# get the size of the image file and ensure it's square jpeg
with closing(Image.open(file_)) as image_obj:
width, height = image_obj.size
self.assertEqual(width, height)
self.assertEqual(image_obj.format, 'JPEG')
actual_sizes[width] = name
self.assertEqual(requested_sizes, actual_sizes)
mock_storage.save.reset_mock()
names_and_images = self._create_mocked_profile_images(uploaded_file, requested_sizes)
actual_sizes = {}
for name, image_obj in names_and_images:
# get the size of the image file and ensure it's square jpeg
width, height = image_obj.size
self.assertEqual(width, height)
actual_sizes[width] = name
self.assertEqual(requested_sizes, actual_sizes)
def test_jpeg_with_exif_orientation(self):
requested_images = {10: "ten.jpg", 100: "hunnert.jpg"}
rotate_90_clockwise = 8 # Value used in EXIF Orientation field.
with make_image_file(orientation=rotate_90_clockwise, extension='.jpg') as imfile:
for _, image in self._create_mocked_profile_images(imfile, requested_images):
self.check_exif_orientation(image, rotate_90_clockwise)
def test_jpeg_without_exif_orientation(self):
requested_images = {10: "ten.jpg", 100: "hunnert.jpg"}
with make_image_file(extension='.jpg') as imfile:
for _, image in self._create_mocked_profile_images(imfile, requested_images):
self.check_exif_orientation(image, None)
def _create_mocked_profile_images(self, image_file, requested_images):
"""
Create image files with mocked-out storage.
Verifies that an image was created for each element in
requested_images, and returns an iterator of 2-tuples representing
those imageswhere each tuple consists of a filename and a PIL.Image
object.
"""
mock_storage = mock.Mock()
with mock.patch(
"openedx.core.djangoapps.profile_images.images.get_profile_image_storage",
return_value=mock_storage,
):
create_profile_images(image_file, requested_images)
names_and_files = [v[0] for v in mock_storage.save.call_args_list]
self.assertEqual(len(names_and_files), len(requested_images))
for name, file_ in names_and_files:
with closing(Image.open(file_)) as image:
yield name, image
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Profile Image API is only supported in LMS')
......
......@@ -22,8 +22,9 @@ from openedx.core.lib.api.parsers import TypedFileUploadParser
from openedx.core.lib.api.permissions import IsUserInUrl, IsUserInUrlOrStaff
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin
from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_names, set_has_profile_image
from .exceptions import ImageValidationError
from .images import (
IMAGE_TYPES, validate_uploaded_image, create_profile_images, remove_profile_images, ImageValidationError
IMAGE_TYPES, validate_uploaded_image, create_profile_images, remove_profile_images
)
log = logging.getLogger(__name__)
......
......@@ -55,6 +55,7 @@ nose-xunitmp==0.3.2
oauthlib==0.7.2
paramiko==1.9.0
path.py==7.2
piexif==1.0.2
Pillow==2.7.0
polib==1.0.3
pycrypto>=2.6
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment