diff --git a/imagekit/lib.py b/imagekit/lib.py index 092cb5c..d8b2aed 100644 --- a/imagekit/lib.py +++ b/imagekit/lib.py @@ -1,11 +1,12 @@ # Required PIL classes may or may not be available from the root namespace # depending on the installation method used. try: - from PIL import Image, ImageColor, ImageEnhance, ImageFile, ImageFilter + from PIL import Image, ImageColor, ImageChops, ImageEnhance, ImageFile, ImageFilter except ImportError: try: import Image import ImageColor + import ImageChops import ImageEnhance import ImageFile import ImageFilter diff --git a/imagekit/processors/resize.py b/imagekit/processors/resize.py index e2788d5..78e5c39 100644 --- a/imagekit/processors/resize.py +++ b/imagekit/processors/resize.py @@ -1,5 +1,6 @@ -from imagekit.lib import Image +import math +from imagekit.lib import Image class _Resize(object): width = None @@ -121,3 +122,87 @@ class Fit(_Resize): return img img = img.resize(new_dimensions, Image.ANTIALIAS) return img + + +def histogram_entropy(im): + """ + Calculate the entropy of an images' histogram. Used for "smart cropping" in easy-thumbnails; + see: https://raw.github.com/SmileyChris/easy-thumbnails/master/easy_thumbnails/utils.py + + """ + if not isinstance(im, Image.Image): + return 0 # Fall back to a constant entropy. + + histogram = im.histogram() + hist_ceil = float(sum(histogram)) + histonorm = [histocol / hist_ceil for histocol in histogram] + + return -sum([p * math.log(p, 2) for p in histonorm if p != 0]) + + +class SmartCrop(_Resize): + """ + Crop an image 'smartly' -- based on smart crop implementation from easy-thumbnails: + + https://github.com/SmileyChris/easy-thumbnails/blob/master/easy_thumbnails/processors.py#L193 + + Smart cropping whittles away the parts of the image with the least entropy. + + """ + + def __init__(self, width=None, height=None): + super(SmartCrop, self).__init__(width, height) + + + def compare_entropy(self, start_slice, end_slice, slice, difference): + """ + Calculate the entropy of two slices (from the start and end of an axis), + returning a tuple containing the amount that should be added to the start + and removed from the end of the axis. + + """ + start_entropy = histogram_entropy(start_slice) + end_entropy = histogram_entropy(end_slice) + + if end_entropy and abs(start_entropy / end_entropy - 1) < 0.01: + # Less than 1% difference, remove from both sides. + if difference >= slice * 2: + return slice, slice + half_slice = slice // 2 + return half_slice, slice - half_slice + + if start_entropy > end_entropy: + return 0, slice + else: + return slice, 0 + + def process(self, img): + source_x, source_y = img.size + diff_x = int(source_x - min(source_x, self.width)) + diff_y = int(source_y - min(source_y, self.height)) + left = top = 0 + right, bottom = source_x, source_y + + while diff_x: + slice = min(diff_x, max(diff_x // 5, 10)) + start = img.crop((left, 0, left + slice, source_y)) + end = img.crop((right - slice, 0, right, source_y)) + add, remove = self.compare_entropy(start, end, slice, diff_x) + left += add + right -= remove + diff_x = diff_x - add - remove + + while diff_y: + slice = min(diff_y, max(diff_y // 5, 10)) + start = img.crop((0, top, source_x, top + slice)) + end = img.crop((0, bottom - slice, source_x, bottom)) + add, remove = self.compare_entropy(start, end, slice, diff_y) + top += add + bottom -= remove + diff_y = diff_y - add - remove + + box = (left, top, right, bottom) + img = img.crop(box) + + return img + diff --git a/tests/core/assets/Lenna.png b/tests/core/assets/Lenna.png new file mode 100644 index 0000000..59ef68a Binary files /dev/null and b/tests/core/assets/Lenna.png differ diff --git a/tests/core/assets/lenna-800x600-white-border.jpg b/tests/core/assets/lenna-800x600-white-border.jpg new file mode 100644 index 0000000..d0b1183 Binary files /dev/null and b/tests/core/assets/lenna-800x600-white-border.jpg differ diff --git a/tests/core/assets/lenna-800x600.jpg b/tests/core/assets/lenna-800x600.jpg new file mode 100644 index 0000000..7c2ccd8 Binary files /dev/null and b/tests/core/assets/lenna-800x600.jpg differ diff --git a/tests/core/tests.py b/tests/core/tests.py index 36d7ae7..78e954a 100644 --- a/tests/core/tests.py +++ b/tests/core/tests.py @@ -9,13 +9,17 @@ from imagekit import utils from imagekit.lib import Image from imagekit.models import ImageSpec from imagekit.processors import Adjust -from imagekit.processors.resize import Crop +from imagekit.processors.resize import Crop, SmartCrop class Photo(models.Model): original_image = models.ImageField(upload_to='photos') + thumbnail = ImageSpec([Adjust(contrast=1.2, sharpness=1.1), Crop(50, 50)], image_field='original_image', format='JPEG', quality=90) + + smartcropped_thumbnail = ImageSpec([Adjust(contrast=1.2, sharpness=1.1), SmartCrop(50, 50)], + image_field='original_image', format='JPEG', quality=90) class IKTest(TestCase): @@ -24,32 +28,50 @@ class IKTest(TestCase): Image.new('RGB', (800, 600)).save(tmp, 'JPEG') tmp.seek(0) return tmp - + + def generate_lenna(self): + """ + See also: + + http://en.wikipedia.org/wiki/Lenna + http://sipi.usc.edu/database/database.php?volume=misc&image=12 + + """ + tmp = tempfile.TemporaryFile() + lennapath = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'assets', 'lenna-800x600-white-border.jpg') + with open(lennapath, "r+b") as lennafile: + Image.open(lennafile).save(tmp, 'JPEG') + tmp.seek(0) + return tmp + def setUp(self): self.photo = Photo() - img = self.generate_image() + img = self.generate_lenna() file = ContentFile(img.read()) self.photo.original_image = file self.photo.original_image.save('test.jpeg', file) self.photo.save() img.close() - + def test_save_image(self): photo = Photo.objects.get(id=self.photo.id) self.assertTrue(os.path.isfile(photo.original_image.path)) - + def test_setup(self): self.assertEqual(self.photo.original_image.width, 800) self.assertEqual(self.photo.original_image.height, 600) - + def test_thumbnail_creation(self): photo = Photo.objects.get(id=self.photo.id) self.assertTrue(os.path.isfile(photo.thumbnail.file.name)) - + def test_thumbnail_size(self): + """ Explicit and smart-cropped thumbnail size """ self.assertEqual(self.photo.thumbnail.width, 50) self.assertEqual(self.photo.thumbnail.height, 50) - + self.assertEqual(self.photo.smartcropped_thumbnail.width, 50) + self.assertEqual(self.photo.smartcropped_thumbnail.height, 50) + def test_thumbnail_source_file(self): self.assertEqual( self.photo.thumbnail.source_file, self.photo.original_image)