diff --git a/README.rst b/README.rst index d60119d..800efa4 100644 --- a/README.rst +++ b/README.rst @@ -61,12 +61,12 @@ your spec, you can expose different versions of the original image:: from django.db import models from imagekit.models.fields import ImageSpecField - from imagekit.processors import resize, Adjust + from imagekit.processors import ResizeToFill, Adjust class Photo(models.Model): original_image = models.ImageField(upload_to='photos') thumbnail = ImageSpecField([Adjust(contrast=1.2, sharpness=1.1), - resize.Fill(50, 50)], image_field='original_image', + ResizeToFill(50, 50)], image_field='original_image', format='JPEG', options={'quality': 90}) The ``thumbnail`` property will now return a cropped image:: @@ -77,7 +77,7 @@ The ``thumbnail`` property will now return a cropped image:: photo.original_image.width # > 1000 The original image is not modified; ``thumbnail`` is a new file that is the -result of running the ``imagekit.processors.resize.Fill`` processor on the +result of running the ``imagekit.processors.ResizeToFill`` processor on the original. The ``imagekit.processors`` module contains processors for many common diff --git a/imagekit/processors/__init__.py b/imagekit/processors/__init__.py index c3be66c..c2c9320 100644 --- a/imagekit/processors/__init__.py +++ b/imagekit/processors/__init__.py @@ -7,260 +7,7 @@ should be limited to image manipulations--they should be completely decoupled from both the filesystem and the ORM. """ -from imagekit.lib import Image, ImageColor, ImageEnhance -from imagekit.processors import resize, crop - -RGBA_TRANSPARENCY_FORMATS = ['PNG'] -PALETTE_TRANSPARENCY_FORMATS = ['PNG', 'GIF'] - - -class ProcessorPipeline(list): - """ - A :class:`list` of other processors. This class allows any object that - knows how to deal with a single processor to deal with a list of them. - For example:: - - processed_image = ProcessorPipeline([ProcessorA(), ProcessorB()]).process(image) - - """ - def process(self, img): - for proc in self: - img = proc.process(img) - return img - - -class Adjust(object): - """ - Performs color, brightness, contrast, and sharpness enhancements on the - image. See :mod:`PIL.ImageEnhance` for more imformation. - - """ - def __init__(self, color=1.0, brightness=1.0, contrast=1.0, sharpness=1.0): - """ - :param color: A number between 0 and 1 that specifies the saturation - of the image. 0 corresponds to a completely desaturated image - (black and white) and 1 to the original color. - See :class:`PIL.ImageEnhance.Color` - :param brightness: A number representing the brightness; 0 results in - a completely black image whereas 1 corresponds to the brightness - of the original. See :class:`PIL.ImageEnhance.Brightness` - :param contrast: A number representing the contrast; 0 results in a - completely gray image whereas 1 corresponds to the contrast of - the original. See :class:`PIL.ImageEnhance.Contrast` - :param sharpness: A number representing the sharpness; 0 results in a - blurred image; 1 corresponds to the original sharpness; 2 - results in a sharpened image. See - :class:`PIL.ImageEnhance.Sharpness` - - """ - self.color = color - self.brightness = brightness - self.contrast = contrast - self.sharpness = sharpness - - def process(self, img): - original = img = img.convert('RGBA') - for name in ['Color', 'Brightness', 'Contrast', 'Sharpness']: - factor = getattr(self, name.lower()) - if factor != 1.0: - try: - img = getattr(ImageEnhance, name)(img).enhance(factor) - except ValueError: - pass - else: - # PIL's Color and Contrast filters both convert the image - # to L mode, losing transparency info, so we put it back. - # See https://github.com/jdriscoll/django-imagekit/issues/64 - if name in ('Color', 'Contrast'): - img = Image.merge('RGBA', img.split()[:3] + - original.split()[3:4]) - return img - - -class Reflection(object): - """ - Creates an image with a reflection. - - """ - background_color = '#FFFFFF' - size = 0.0 - opacity = 0.6 - - def process(self, img): - # Convert bgcolor string to RGB value. - background_color = ImageColor.getrgb(self.background_color) - # Handle palleted images. - img = img.convert('RGB') - # Copy orignial image and flip the orientation. - reflection = img.copy().transpose(Image.FLIP_TOP_BOTTOM) - # Create a new image filled with the bgcolor the same size. - background = Image.new("RGB", img.size, background_color) - # Calculate our alpha mask. - start = int(255 - (255 * self.opacity)) # The start of our gradient. - steps = int(255 * self.size) # The number of intermedite values. - increment = (255 - start) / float(steps) - mask = Image.new('L', (1, 255)) - for y in range(255): - if y < steps: - val = int(y * increment + start) - else: - val = 255 - mask.putpixel((0, y), val) - alpha_mask = mask.resize(img.size) - # Merge the reflection onto our background color using the alpha mask. - reflection = Image.composite(background, reflection, alpha_mask) - # Crop the reflection. - reflection_height = int(img.size[1] * self.size) - reflection = reflection.crop((0, 0, img.size[0], reflection_height)) - # Create new image sized to hold both the original image and - # the reflection. - composite = Image.new("RGB", (img.size[0], img.size[1] + reflection_height), background_color) - # Paste the orignal image and the reflection into the composite image. - composite.paste(img, (0, 0)) - composite.paste(reflection, (0, img.size[1])) - # Return the image complete with reflection effect. - return composite - - -class Transpose(object): - """ - Rotates or flips the image. - - """ - AUTO = 'auto' - FLIP_HORIZONTAL = Image.FLIP_LEFT_RIGHT - FLIP_VERTICAL = Image.FLIP_TOP_BOTTOM - ROTATE_90 = Image.ROTATE_90 - ROTATE_180 = Image.ROTATE_180 - ROTATE_270 = Image.ROTATE_270 - - methods = [AUTO] - _EXIF_ORIENTATION_STEPS = { - 1: [], - 2: [FLIP_HORIZONTAL], - 3: [ROTATE_180], - 4: [FLIP_VERTICAL], - 5: [ROTATE_270, FLIP_HORIZONTAL], - 6: [ROTATE_270], - 7: [ROTATE_90, FLIP_HORIZONTAL], - 8: [ROTATE_90], - } - - def __init__(self, *args): - """ - Possible arguments: - - Transpose.AUTO - - Transpose.FLIP_HORIZONTAL - - Transpose.FLIP_VERTICAL - - Transpose.ROTATE_90 - - Transpose.ROTATE_180 - - Transpose.ROTATE_270 - - The order of the arguments dictates the order in which the - Transposition steps are taken. - - If Transpose.AUTO is present, all other arguments are ignored, and - the processor will attempt to rotate the image according to the - EXIF Orientation data. - - """ - super(Transpose, self).__init__() - if args: - self.methods = args - - def process(self, img): - if self.AUTO in self.methods: - try: - orientation = img._getexif()[0x0112] - ops = self._EXIF_ORIENTATION_STEPS[orientation] - except (KeyError, TypeError, AttributeError): - ops = [] - else: - ops = self.methods - for method in ops: - img = img.transpose(method) - return img - - -class AutoConvert(object): - """A processor that does some common-sense conversions based on the target - format. This includes things like preserving transparency and quantizing. - This processors is used automatically by ``ImageSpecField`` and - ``ProcessedImageField`` immediately before saving the image unless you - specify ``autoconvert=False``. - - """ - - def __init__(self, format): - self.format = format - - def process(self, img): - matte = False - self.save_kwargs = {} - if img.mode == 'RGBA': - if self.format in RGBA_TRANSPARENCY_FORMATS: - pass - elif self.format in PALETTE_TRANSPARENCY_FORMATS: - # If you're going from a format with alpha transparency to one - # with palette transparency, transparency values will be - # snapped: pixels that are more opaque than not will become - # fully opaque; pixels that are more transparent than not will - # become fully transparent. This will not produce a good-looking - # result if your image contains varying levels of opacity; in - # that case, you'll probably want to use a processor to matte - # the image on a solid color. The reason we don't matte by - # default is because not doing so allows processors to treat - # RGBA-format images as a super-type of P-format images: if you - # have an RGBA-format image with only a single transparent - # color, and save it as a GIF, it will retain its transparency. - # In other words, a P-format image converted to an - # RGBA-formatted image by a processor and then saved as a - # P-format image will give the expected results. - alpha = img.split()[-1] - mask = Image.eval(alpha, lambda a: 255 if a <= 128 else 0) - img = img.convert('RGB').convert('P', palette=Image.ADAPTIVE, - colors=255) - img.paste(255, mask) - self.save_kwargs['transparency'] = 255 - else: - # Simply converting an RGBA-format image to an RGB one creates a - # gross result, so we matte the image on a white background. If - # that's not what you want, that's fine: use a processor to deal - # with the transparency however you want. This is simply a - # sensible default that will always produce something that looks - # good. Or at least, it will look better than just a straight - # conversion. - matte = True - elif img.mode == 'P': - if self.format in PALETTE_TRANSPARENCY_FORMATS: - try: - self.save_kwargs['transparency'] = img.info['transparency'] - except KeyError: - pass - elif self.format in RGBA_TRANSPARENCY_FORMATS: - # Currently PIL doesn't support any RGBA-mode formats that - # aren't also P-mode formats, so this will never happen. - img = img.convert('RGBA') - else: - matte = True - else: - img = img.convert('RGB') - - # GIFs are always going to be in palette mode, so we can do a little - # optimization. Note that the RGBA sources also use adaptive - # quantization (above). Images that are already in P mode don't need - # any quantization because their colors are already limited. - if self.format == 'GIF': - img = img.convert('P', palette=Image.ADAPTIVE) - - if matte: - img = img.convert('RGBA') - bg = Image.new('RGBA', img.size, (255, 255, 255)) - bg.paste(img, img) - img = bg.convert('RGB') - - if self.format == 'JPEG': - self.save_kwargs['optimize'] = True - - return img +from .base import * +from .crop import * +from .resize import * diff --git a/imagekit/processors/base.py b/imagekit/processors/base.py new file mode 100644 index 0000000..afc0f4f --- /dev/null +++ b/imagekit/processors/base.py @@ -0,0 +1,296 @@ +from imagekit.lib import Image, ImageColor, ImageEnhance + + +RGBA_TRANSPARENCY_FORMATS = ['PNG'] +PALETTE_TRANSPARENCY_FORMATS = ['PNG', 'GIF'] + + +class ProcessorPipeline(list): + """ + A :class:`list` of other processors. This class allows any object that + knows how to deal with a single processor to deal with a list of them. + For example:: + + processed_image = ProcessorPipeline([ProcessorA(), ProcessorB()]).process(image) + + """ + def process(self, img): + for proc in self: + img = proc.process(img) + return img + + +class Adjust(object): + """ + Performs color, brightness, contrast, and sharpness enhancements on the + image. See :mod:`PIL.ImageEnhance` for more imformation. + + """ + def __init__(self, color=1.0, brightness=1.0, contrast=1.0, sharpness=1.0): + """ + :param color: A number between 0 and 1 that specifies the saturation + of the image. 0 corresponds to a completely desaturated image + (black and white) and 1 to the original color. + See :class:`PIL.ImageEnhance.Color` + :param brightness: A number representing the brightness; 0 results in + a completely black image whereas 1 corresponds to the brightness + of the original. See :class:`PIL.ImageEnhance.Brightness` + :param contrast: A number representing the contrast; 0 results in a + completely gray image whereas 1 corresponds to the contrast of + the original. See :class:`PIL.ImageEnhance.Contrast` + :param sharpness: A number representing the sharpness; 0 results in a + blurred image; 1 corresponds to the original sharpness; 2 + results in a sharpened image. See + :class:`PIL.ImageEnhance.Sharpness` + + """ + self.color = color + self.brightness = brightness + self.contrast = contrast + self.sharpness = sharpness + + def process(self, img): + original = img = img.convert('RGBA') + for name in ['Color', 'Brightness', 'Contrast', 'Sharpness']: + factor = getattr(self, name.lower()) + if factor != 1.0: + try: + img = getattr(ImageEnhance, name)(img).enhance(factor) + except ValueError: + pass + else: + # PIL's Color and Contrast filters both convert the image + # to L mode, losing transparency info, so we put it back. + # See https://github.com/jdriscoll/django-imagekit/issues/64 + if name in ('Color', 'Contrast'): + img = Image.merge('RGBA', img.split()[:3] + + original.split()[3:4]) + return img + + +class Reflection(object): + """ + Creates an image with a reflection. + + """ + background_color = '#FFFFFF' + size = 0.0 + opacity = 0.6 + + def process(self, img): + # Convert bgcolor string to RGB value. + background_color = ImageColor.getrgb(self.background_color) + # Handle palleted images. + img = img.convert('RGB') + # Copy orignial image and flip the orientation. + reflection = img.copy().transpose(Image.FLIP_TOP_BOTTOM) + # Create a new image filled with the bgcolor the same size. + background = Image.new("RGB", img.size, background_color) + # Calculate our alpha mask. + start = int(255 - (255 * self.opacity)) # The start of our gradient. + steps = int(255 * self.size) # The number of intermedite values. + increment = (255 - start) / float(steps) + mask = Image.new('L', (1, 255)) + for y in range(255): + if y < steps: + val = int(y * increment + start) + else: + val = 255 + mask.putpixel((0, y), val) + alpha_mask = mask.resize(img.size) + # Merge the reflection onto our background color using the alpha mask. + reflection = Image.composite(background, reflection, alpha_mask) + # Crop the reflection. + reflection_height = int(img.size[1] * self.size) + reflection = reflection.crop((0, 0, img.size[0], reflection_height)) + # Create new image sized to hold both the original image and + # the reflection. + composite = Image.new("RGB", (img.size[0], img.size[1] + reflection_height), background_color) + # Paste the orignal image and the reflection into the composite image. + composite.paste(img, (0, 0)) + composite.paste(reflection, (0, img.size[1])) + # Return the image complete with reflection effect. + return composite + + +class Transpose(object): + """ + Rotates or flips the image. + + """ + AUTO = 'auto' + FLIP_HORIZONTAL = Image.FLIP_LEFT_RIGHT + FLIP_VERTICAL = Image.FLIP_TOP_BOTTOM + ROTATE_90 = Image.ROTATE_90 + ROTATE_180 = Image.ROTATE_180 + ROTATE_270 = Image.ROTATE_270 + + methods = [AUTO] + _EXIF_ORIENTATION_STEPS = { + 1: [], + 2: [FLIP_HORIZONTAL], + 3: [ROTATE_180], + 4: [FLIP_VERTICAL], + 5: [ROTATE_270, FLIP_HORIZONTAL], + 6: [ROTATE_270], + 7: [ROTATE_90, FLIP_HORIZONTAL], + 8: [ROTATE_90], + } + + def __init__(self, *args): + """ + Possible arguments: + - Transpose.AUTO + - Transpose.FLIP_HORIZONTAL + - Transpose.FLIP_VERTICAL + - Transpose.ROTATE_90 + - Transpose.ROTATE_180 + - Transpose.ROTATE_270 + + The order of the arguments dictates the order in which the + Transposition steps are taken. + + If Transpose.AUTO is present, all other arguments are ignored, and + the processor will attempt to rotate the image according to the + EXIF Orientation data. + + """ + super(Transpose, self).__init__() + if args: + self.methods = args + + def process(self, img): + if self.AUTO in self.methods: + try: + orientation = img._getexif()[0x0112] + ops = self._EXIF_ORIENTATION_STEPS[orientation] + except (KeyError, TypeError, AttributeError): + ops = [] + else: + ops = self.methods + for method in ops: + img = img.transpose(method) + return img + + +class AutoConvert(object): + """A processor that does some common-sense conversions based on the target + format. This includes things like preserving transparency and quantizing. + This processors is used automatically by ``ImageSpecField`` and + ``ProcessedImageField`` immediately before saving the image unless you + specify ``autoconvert=False``. + + """ + + def __init__(self, format): + self.format = format + + def process(self, img): + matte = False + self.save_kwargs = {} + self.rgba_ = img.mode == 'RGBA' + if self.rgba_: + if self.format in RGBA_TRANSPARENCY_FORMATS: + pass + elif self.format in PALETTE_TRANSPARENCY_FORMATS: + # If you're going from a format with alpha transparency to one + # with palette transparency, transparency values will be + # snapped: pixels that are more opaque than not will become + # fully opaque; pixels that are more transparent than not will + # become fully transparent. This will not produce a good-looking + # result if your image contains varying levels of opacity; in + # that case, you'll probably want to use a processor to matte + # the image on a solid color. The reason we don't matte by + # default is because not doing so allows processors to treat + # RGBA-format images as a super-type of P-format images: if you + # have an RGBA-format image with only a single transparent + # color, and save it as a GIF, it will retain its transparency. + # In other words, a P-format image converted to an + # RGBA-formatted image by a processor and then saved as a + # P-format image will give the expected results. + alpha = img.split()[-1] + mask = Image.eval(alpha, lambda a: 255 if a <= 128 else 0) + img = img.convert('RGB').convert('P', palette=Image.ADAPTIVE, + colors=255) + img.paste(255, mask) + self.save_kwargs['transparency'] = 255 + else: + # Simply converting an RGBA-format image to an RGB one creates a + # gross result, so we matte the image on a white background. If + # that's not what you want, that's fine: use a processor to deal + # with the transparency however you want. This is simply a + # sensible default that will always produce something that looks + # good. Or at least, it will look better than just a straight + # conversion. + matte = True + elif img.mode == 'P': + if self.format in PALETTE_TRANSPARENCY_FORMATS: + try: + self.save_kwargs['transparency'] = img.info['transparency'] + except KeyError: + pass + elif self.format in RGBA_TRANSPARENCY_FORMATS: + # Currently PIL doesn't support any RGBA-mode formats that + # aren't also P-mode formats, so this will never happen. + img = img.convert('RGBA') + else: + matte = True + else: + img = img.convert('RGB') + + # GIFs are always going to be in palette mode, so we can do a little + # optimization. Note that the RGBA sources also use adaptive + # quantization (above). Images that are already in P mode don't need + # any quantization because their colors are already limited. + if self.format == 'GIF': + img = img.convert('P', palette=Image.ADAPTIVE) + + if matte: + img = img.convert('RGBA') + bg = Image.new('RGBA', img.size, (255, 255, 255)) + bg.paste(img, img) + img = bg.convert('RGB') + + if self.format == 'JPEG': + self.save_kwargs['optimize'] = True + + return img + + +class Anchor(object): + """ + Defines all the anchor points needed by the various processor classes. + + """ + TOP_LEFT = 'tl' + TOP = 't' + TOP_RIGHT = 'tr' + BOTTOM_LEFT = 'bl' + BOTTOM = 'b' + BOTTOM_RIGHT = 'br' + CENTER = 'c' + LEFT = 'l' + RIGHT = 'r' + + _ANCHOR_PTS = { + TOP_LEFT: (0, 0), + TOP: (0.5, 0), + TOP_RIGHT: (1, 0), + LEFT: (0, 0.5), + CENTER: (0.5, 0.5), + RIGHT: (1, 0.5), + BOTTOM_LEFT: (0, 1), + BOTTOM: (0.5, 1), + BOTTOM_RIGHT: (1, 1), + } + + @staticmethod + def get_tuple(anchor): + """Normalizes anchor values (strings or tuples) to tuples. + + """ + # If the user passed in one of the string values, convert it to a + # percentage tuple. + if anchor in Anchor._ANCHOR_PTS.keys(): + anchor = Anchor._ANCHOR_PTS[anchor] + return anchor diff --git a/imagekit/processors/crop.py b/imagekit/processors/crop.py index 304297d..da5c0fb 100644 --- a/imagekit/processors/crop.py +++ b/imagekit/processors/crop.py @@ -1,5 +1,6 @@ -from ..lib import Image, ImageChops, ImageDraw, ImageStat +from .base import Anchor from .utils import histogram_entropy +from ..lib import Image, ImageChops, ImageDraw, ImageStat class Side(object): @@ -71,101 +72,31 @@ class TrimBorderColor(object): return img -class BasicCrop(object): - """Crops an image to the specified rectangular region. - - """ - def __init__(self, x, y, width, height): - """ - :param x: The x position of the clipping box, in pixels. - :param y: The y position of the clipping box, in pixels. - :param width: The width position of the clipping box, in pixels. - :param height: The height position of the clipping box, in pixels. - - """ - self.x = x - self.y = y - self.width = width - self.height = height - - def process(self, img): - box = (self.x, self.y, self.x + self.width, self.y + self.height) - return img.crop(box) - - class Crop(object): """ - Crops an image , cropping it to the specified width and height - relative to the anchor. + Crops an image, cropping it to the specified width and height. You may + optionally provide either an anchor or x and y coordinates. This processor + functions exactly the same as ``ResizeCanvas`` except that it will never + enlarge the image. """ - TOP_LEFT = 'tl' - TOP = 't' - TOP_RIGHT = 'tr' - BOTTOM_LEFT = 'bl' - BOTTOM = 'b' - BOTTOM_RIGHT = 'br' - CENTER = 'c' - LEFT = 'l' - RIGHT = 'r' - _ANCHOR_PTS = { - TOP_LEFT: (0, 0), - TOP: (0.5, 0), - TOP_RIGHT: (1, 0), - LEFT: (0, 0.5), - CENTER: (0.5, 0.5), - RIGHT: (1, 0.5), - BOTTOM_LEFT: (0, 1), - BOTTOM: (0.5, 1), - BOTTOM_RIGHT: (1, 1), - } - - def __init__(self, width=None, height=None, anchor=None): - """ - :param width: The target width, in pixels. - :param height: The target height, in pixels. - :param anchor: Specifies which part of the image should be retained - when cropping. Valid values are: - - - Crop.TOP_LEFT - - Crop.TOP - - Crop.TOP_RIGHT - - Crop.LEFT - - Crop.CENTER - - Crop.RIGHT - - Crop.BOTTOM_LEFT - - Crop.BOTTOM - - Crop.BOTTOM_RIGHT - - You may also pass a tuple that indicates the percentages of excess - to be trimmed from each dimension. For example, ``(0, 0)`` - corresponds to "top left", ``(0.5, 0.5)`` to "center" and ``(1, 1)`` - to "bottom right". This is basically the same as using percentages - in CSS background positions. - - """ + def __init__(self, width=None, height=None, anchor=None, x=None, y=None): self.width = width self.height = height self.anchor = anchor + self.x = x + self.y = y def process(self, img): + from .resize import ResizeCanvas + original_width, original_height = img.size new_width, new_height = min(original_width, self.width), \ min(original_height, self.height) - trim_x, trim_y = original_width - new_width, \ - original_height - new_height - # If the user passed in one of the string values, convert it to a - # percentage tuple. - anchor = self.anchor or Crop.CENTER - if anchor in Crop._ANCHOR_PTS.keys(): - anchor = Crop._ANCHOR_PTS[anchor] - - x = int(float(trim_x) * float(anchor[0])) - y = int(float(trim_y) * float(anchor[1])) - - return BasicCrop(x, y, new_width, new_height).process(img) + return ResizeCanvas(new_width, new_height, anchor=self.anchor, + x=self.x, y=self.y).process(img) class SmartCrop(object): diff --git a/imagekit/processors/resize.py b/imagekit/processors/resize.py index 923877a..721768a 100644 --- a/imagekit/processors/resize.py +++ b/imagekit/processors/resize.py @@ -1,9 +1,9 @@ from imagekit.lib import Image -from . import crop import warnings +from .base import Anchor -class BasicResize(object): +class Resize(object): """ Resizes an image to the specified width and height. @@ -25,7 +25,7 @@ class Cover(object): """ Resizes the image to the smallest possible size that will entirely cover the provided dimensions. You probably won't be using this processor directly, - but it's used internally by ``Fill`` and ``SmartFill``. + but it's used internally by ``ResizeToFill`` and ``SmartResize``. """ def __init__(self, width, height): @@ -42,59 +42,39 @@ class Cover(object): float(self.height) / original_height) new_width, new_height = (int(original_width * ratio), int(original_height * ratio)) - return BasicResize(new_width, new_height).process(img) + return Resize(new_width, new_height).process(img) -class Fill(object): +class ResizeToFill(object): """ - Resizes an image , cropping it to the exact specified width and height. + Resizes an image, cropping it to the exact specified width and height. """ - TOP_LEFT = crop.Crop.TOP_LEFT - TOP = crop.Crop.TOP - TOP_RIGHT = crop.Crop.TOP_RIGHT - BOTTOM_LEFT = crop.Crop.BOTTOM_LEFT - BOTTOM = crop.Crop.BOTTOM - BOTTOM_RIGHT = crop.Crop.BOTTOM_RIGHT - CENTER = crop.Crop.CENTER - LEFT = crop.Crop.LEFT - RIGHT = crop.Crop.RIGHT def __init__(self, width=None, height=None, anchor=None): """ :param width: The target width, in pixels. :param height: The target height, in pixels. :param anchor: Specifies which part of the image should be retained - when cropping. Valid values are: - - - Fill.TOP_LEFT - - Fill.TOP - - Fill.TOP_RIGHT - - Fill.LEFT - - Fill.CENTER - - Fill.RIGHT - - Fill.BOTTOM_LEFT - - Fill.BOTTOM - - Fill.BOTTOM_RIGHT - + when cropping. """ self.width = width self.height = height self.anchor = anchor def process(self, img): + from .crop import Crop img = Cover(self.width, self.height).process(img) - return crop.Crop(self.width, self.height, + return Crop(self.width, self.height, anchor=self.anchor).process(img) -class SmartFill(object): +class SmartResize(object): """ - The ``SmartFill`` processor is identical to ``Fill``, except that it uses - entropy to crop the image instead of a user-specified anchor point. - Internally, it simply runs the ``resize.Cover`` and ``crop.SmartCrop`` + The ``SmartResize`` processor is identical to ``ResizeToFill``, except that + it uses entropy to crop the image instead of a user-specified anchor point. + Internally, it simply runs the ``ResizeToCover`` and ``SmartCrop`` processors in series. - """ def __init__(self, width, height): """ @@ -105,23 +85,104 @@ class SmartFill(object): self.width, self.height = width, height def process(self, img): + from .crop import SmartCrop img = Cover(self.width, self.height).process(img) - return crop.SmartCrop(self.width, self.height).process(img) + return SmartCrop(self.width, self.height).process(img) -class Crop(Fill): - def __init__(self, *args, **kwargs): - warnings.warn('`imagekit.processors.resize.Crop` has been renamed to' - '`imagekit.processors.resize.Fill`.', DeprecationWarning) - super(Crop, self).__init__(*args, **kwargs) +class ResizeCanvas(object): + """ + Resizes the canvas, using the provided background color if the new size is + larger than the current image. + + """ + def __init__(self, width, height, color=None, anchor=None, x=None, y=None): + """ + :param width: The target width, in pixels. + :param height: The target height, in pixels. + :param color: The background color to use for padding. + :param anchor: Specifies the position of the original image on the new + canvas. Valid values are: + + - Anchor.TOP_LEFT + - Anchor.TOP + - Anchor.TOP_RIGHT + - Anchor.LEFT + - Anchor.CENTER + - Anchor.RIGHT + - Anchor.BOTTOM_LEFT + - Anchor.BOTTOM + - Anchor.BOTTOM_RIGHT + + You may also pass a tuple that indicates the position in + percentages. For example, ``(0, 0)`` corresponds to "top left", + ``(0.5, 0.5)`` to "center" and ``(1, 1)`` to "bottom right". This is + basically the same as using percentages in CSS background positions. + + """ + if x is not None or y is not None: + if anchor: + raise Exception('You may provide either an anchor or x and y' + ' coordinate, but not both.') + else: + self.x, self.y = x or 0, y or 0 + self.anchor = None + else: + self.anchor = anchor or Anchor.CENTER + self.x = self.y = None + + self.width = width + self.height = height + self.color = color or (255, 255, 255, 0) + + def process(self, img): + original_width, original_height = img.size + + if self.anchor: + anchor = Anchor.get_tuple(self.anchor) + trim_x, trim_y = self.width - original_width, \ + self.height - original_height + x = int(float(trim_x) * float(anchor[0])) + y = int(float(trim_y) * float(anchor[1])) + else: + x, y = self.x, self.y + + new_img = Image.new('RGBA', (self.width, self.height), self.color) + new_img.paste(img, (x, y)) + return new_img -class Fit(object): +class AddBorder(object): + """ + Add a border of specific color and size to an image. + + """ + def __init__(self, thickness, color=None): + """ + :param color: Color to use for the border + :param thickness: Thickness of the border. Can be either an int or + a 4-tuple of ints of the form (top, right, bottom, left). + """ + self.color = color + if isinstance(thickness, int): + self.top = self.right = self.bottom = self.left = thickness + else: + self.top, self.right, self.bottom, self.left = thickness + + def process(self, img): + new_width = img.size[0] + self.left + self.right + new_height = img.size[1] + self.top + self.bottom + return ResizeCanvas(new_width, new_height, color=self.color, + x=self.left, y=self.top).process(img) + + +class ResizeToFit(object): """ Resizes an image to fit within the specified dimensions. """ - def __init__(self, width=None, height=None, upscale=None, mat_color=None): + + def __init__(self, width=None, height=None, upscale=None, mat_color=None, anchor=Anchor.CENTER): """ :param width: The maximum width of the desired image. :param height: The maximum height of the desired image. @@ -136,6 +197,7 @@ class Fit(object): self.height = height self.upscale = upscale self.mat_color = mat_color + self.anchor = anchor def process(self, img): cur_width, cur_height = img.size @@ -151,18 +213,8 @@ class Fit(object): int(round(cur_height * ratio))) if (cur_width > new_dimensions[0] or cur_height > new_dimensions[1]) or \ self.upscale: - img = BasicResize(new_dimensions[0], + img = Resize(new_dimensions[0], new_dimensions[1]).process(img) if self.mat_color: - new_img = Image.new('RGBA', (self.width, self.height), self.mat_color) - new_img.paste(img, ((self.width - img.size[0]) / 2, (self.height - img.size[1]) / 2)) - img = new_img + img = ResizeCanvas(self.width, self.height, self.mat_color, anchor=self.anchor).process(img) return img - - -class SmartCrop(crop.SmartCrop): - def __init__(self, *args, **kwargs): - warnings.warn('The SmartCrop processor has been moved to' - ' `imagekit.processors.crop.SmartCrop`, where it belongs.', - DeprecationWarning) - super(SmartCrop, self).__init__(*args, **kwargs) diff --git a/tests/core/tests.py b/tests/core/tests.py index c11d89b..396aab5 100644 --- a/tests/core/tests.py +++ b/tests/core/tests.py @@ -13,8 +13,8 @@ from imagekit import utils from imagekit.lib import Image from imagekit.models.fields import ImageSpecField from imagekit.processors import Adjust -from imagekit.processors.resize import Fill -from imagekit.processors.crop import SmartCrop +from imagekit.processors import ResizeToFill +from imagekit.processors import SmartCrop def generate_lenna(): @@ -52,7 +52,7 @@ class Photo(models.Model): original_image = models.ImageField(upload_to='photos') thumbnail = ImageSpecField([Adjust(contrast=1.2, sharpness=1.1), - Fill(50, 50)], image_field='original_image', format='JPEG', + ResizeToFill(50, 50)], image_field='original_image', format='JPEG', options={'quality': 90}) smartcropped_thumbnail = ImageSpecField([Adjust(contrast=1.2,