diff --git a/docs/core_components/images/index.rst b/docs/core_components/images/index.rst index d4f103bfb..45ac14833 100644 --- a/docs/core_components/images/index.rst +++ b/docs/core_components/images/index.rst @@ -79,7 +79,7 @@ The available resizing methods are: Resize the height of the image to the dimension specified.. ``fill`` - (takes two dimensions) + (takes two dimensions and an optional ``-c`` parameter) .. code-block:: django @@ -87,9 +87,25 @@ The available resizing methods are: Resize and **crop** to fill the **exact** dimensions. - This can be particularly useful for websites requiring square thumbnails of arbitrary images. For example, a landscape image of width 2000, height 1000, treated with ``fill`` dimensions ``200x200`` would have its height reduced to 200, then its width (ordinarily 400) cropped to 200. + This can be particularly useful for websites requiring square thumbnails of arbitrary images. For example, a landscape image of width 2000, height 1000, treated with ``fill`` dimensions ``200x200`` would have its height reduced to 200, then its width (ordinarily 400) cropped to 200. - **The crop always aligns on the centre of the image.** + This filter will crop to the images focal point if it has been set. If not, it will crop to the centre of the image. + + **Cropping closer to the focal point** + + By default, Wagtail will only crop to change the aspect ratio of the image. + + In some cases (thumbnails, for example) it may be nice to crop closer to the focal point so the subject of the image is easier to see. + + You can do this by appending ``-c`` at the end of the method. For example, if you would like the image to be cropped as closely as possible to its focal point, add ``-c100`` to the end of the method. + + .. code-block:: django + + {% image self.photo fill-200x200-c100 %} + + This will crop the image as much as it an but will never crop into the focal point. + + If you find that ``-c100`` is too close, you can try ``-c75`` or ``-c50`` (any whole number from 0 to 100 is accepted). ``original`` (takes no dimensions) diff --git a/wagtail/wagtailimages/backends/base.py b/wagtail/wagtailimages/backends/base.py index a374f6ce1..7c95f2612 100644 --- a/wagtail/wagtailimages/backends/base.py +++ b/wagtail/wagtailimages/backends/base.py @@ -1,6 +1,9 @@ +from __future__ import division + from django.conf import settings -from wagtail.wagtailimages.utils import crop +from wagtail.wagtailimages.utils.rect import Rect +from wagtail.wagtailimages.utils.focal_point import FocalPoint class BaseImageBackend(object): @@ -35,27 +38,6 @@ class BaseImageBackend(object): def crop(self, image, crop_box): raise NotImplementedError('subclasses of BaseImageBackend must provide a crop() method') - def crop_to_centre(self, image, size): - crop_box = crop.crop_to_centre(image.size, size) - if crop_box.size != image.size: - return self.crop(image, crop_box) - else: - return image - - def crop_to_point(self, image, size, focal_point): - crop_box = crop.crop_to_point(image.size, size, focal_point) - - # Don't crop if we don't need to - if crop_box.size != image.size: - image = self.crop(image, crop_box) - - # If the focal points are too large, the cropping system may not - # crop it fully, resize the image if this has happened: - if crop_box.size != size: - image = self.resize_to_fill(image, size) - - return image - def resize_to_max(self, image, size, focal_point=None): """ Resize image down to fit within the given dimensions, preserving aspect ratio. @@ -68,9 +50,9 @@ class BaseImageBackend(object): return image # scale factor if we were to downsize the image to fit the target width - horz_scale = float(target_width) / original_width + horz_scale = target_width / original_width # scale factor if we were to downsize the image to fit the target height - vert_scale = float(target_height) / original_height + vert_scale = target_height / original_height # choose whichever of these gives a smaller image if horz_scale < vert_scale: @@ -92,9 +74,9 @@ class BaseImageBackend(object): return image # scale factor if we were to downsize the image to fit the target width - horz_scale = float(target_width) / original_width + horz_scale = target_width / original_width # scale factor if we were to downsize the image to fit the target height - vert_scale = float(target_height) / original_height + vert_scale = target_height / original_height # choose whichever of these gives a larger image if horz_scale > vert_scale: @@ -114,7 +96,7 @@ class BaseImageBackend(object): if original_width <= target_width: return image - scale = float(target_width) / original_width + scale = target_width / original_width final_size = (target_width, int(original_height * scale)) @@ -130,23 +112,139 @@ class BaseImageBackend(object): if original_height <= target_height: return image - scale = float(target_height) / original_height + scale = target_height / original_height final_size = (int(original_width * scale), target_height) return self.resize(image, final_size) - def resize_to_fill(self, image, size, focal_point=None): + def resize_to_fill(self, image, arg, focal_point=None): """ Resize down and crop image to fill the given dimensions. Most suitable for thumbnails. (The final image will match the requested size, unless one or the other dimension is already smaller than the target size) """ - if focal_point is not None: - return self.crop_to_point(image, size, focal_point) + size = arg[:2] + + # Get crop closeness if it's set + if len(arg) > 2 and arg[2] is not None: + crop_closeness = arg[2] / 100 + + # Clamp it + if crop_closeness > 1: + crop_closeness = 1 else: - resized_image = self.resize_to_min(image, size) - return self.crop_to_centre(resized_image, size) + crop_closeness = 0 + + # Get image width and height + (im_width, im_height) = image.size + + # Get filter width and height + fl_width = size[0] + fl_height = size[1] + + # Get crop aspect ratio + crop_aspect_ratio = fl_width / fl_height + + # Get crop max + crop_max_scale = min(im_width, im_height * crop_aspect_ratio) + crop_max_width = crop_max_scale + crop_max_height = crop_max_scale / crop_aspect_ratio + + # Initialise crop width and height to max + crop_width = crop_max_width + crop_height = crop_max_height + + # Use crop closeness to zoom in + if focal_point is not None: + fp_width = focal_point.width + fp_height = focal_point.height + + # Get crop min + crop_min_scale = max(fp_width, fp_height * crop_aspect_ratio) + crop_min_width = crop_min_scale + crop_min_height = crop_min_scale / crop_aspect_ratio + + # Sometimes, the focal point may be bigger than the image... + if not crop_min_scale > crop_max_scale: + # Calculate max crop closeness to prevent upscaling + max_crop_closeness = max( + 1 - (fl_width - crop_min_width) / (crop_max_width - crop_min_width), + 1 - (fl_height - crop_min_height) / (crop_max_height - crop_min_height) + ) + + # Apply max crop closeness + crop_closeness = min(crop_closeness, max_crop_closeness) + + if 1 >= crop_closeness >= 0: + # Get crop width and height + crop_width = crop_max_width + (crop_min_width - crop_max_width) * crop_closeness + crop_height = crop_max_height + (crop_min_height - crop_max_height) * crop_closeness + + # Find focal point UV + if focal_point is not None: + fp_x = focal_point.x + fp_y = focal_point.y + else: + # Fall back to positioning in the centre + fp_x = im_width / 2 + fp_y = im_height / 2 + + fp_u = fp_x / im_width + fp_v = fp_y / im_height + + # Position crop box based on focal point UV + crop_x = fp_x - (fp_u - 0.5) * crop_width + crop_y = fp_y - (fp_v - 0.5) * crop_height + + # Convert crop box into rect + left = crop_x - crop_width / 2 + top = crop_y - crop_height / 2 + right = crop_x + crop_width / 2 + bottom = crop_y + crop_height / 2 + + # Make sure the entire focal point is in the crop box + if focal_point is not None: + focal_point_left = focal_point.x - focal_point.width / 2 + focal_point_top = focal_point.y - focal_point.height / 2 + focal_point_right = focal_point.x + focal_point.width / 2 + focal_point_bottom = focal_point.y + focal_point.height / 2 + + if left > focal_point_left: + right -= left - focal_point_left + left = focal_point_left + + if top > focal_point_top: + bottom -= top - focal_point_top + top = focal_point_top + + if right < focal_point_right: + left += focal_point_right - right + right = focal_point_right + + if bottom < focal_point_bottom: + top += focal_point_bottom - bottom + bottom = focal_point_bottom + + # Don't allow the crop box to go over the image boundary + if left < 0: + right -= left + left = 0 + + if top < 0: + bottom -= top + top = 0 + + if right > im_width: + left -= right - im_width + right = im_width + + if bottom > im_height: + top -= bottom - im_height + bottom = im_height + + # Crop! + return self.resize_to_min(self.crop(image, Rect(left, top, right, bottom)), size) def no_operation(self, image, param, focal_point=None): """Return the image unchanged""" diff --git a/wagtail/wagtailimages/backends/pillow.py b/wagtail/wagtailimages/backends/pillow.py index a1963f931..c460c7d2c 100644 --- a/wagtail/wagtailimages/backends/pillow.py +++ b/wagtail/wagtailimages/backends/pillow.py @@ -21,8 +21,8 @@ class PillowBackend(BaseImageBackend): image = image.convert('RGB') return image.resize(size, PIL.Image.ANTIALIAS) - def crop(self, image, crop_box): - return image.crop(crop_box) + def crop(self, image, rect): + return image.crop(rect) def image_data_as_rgb(self, image): # https://github.com/thumbor/thumbor/blob/f52360dc96eedd9fc914fcf19eaf2358f7e2480c/thumbor/engines/pil.py#L206-L215 diff --git a/wagtail/wagtailimages/backends/wand.py b/wagtail/wagtailimages/backends/wand.py index 3c41f60c1..d6c9c3048 100644 --- a/wagtail/wagtailimages/backends/wand.py +++ b/wagtail/wagtailimages/backends/wand.py @@ -25,10 +25,10 @@ class WandBackend(BaseImageBackend): new_image.resize(size[0], size[1]) return new_image - def crop(self, image, crop_box): + def crop(self, image, rect): new_image = image.clone() new_image.crop( - left=crop_box[0], top=crop_box[1], right=crop_box[2], bottom=crop_box[3] + left=rect[0], top=rect[1], right=rect[2], bottom=rect[3] ) return new_image diff --git a/wagtail/wagtailimages/forms.py b/wagtail/wagtailimages/forms.py index af3b3ce83..46bdd997d 100644 --- a/wagtail/wagtailimages/forms.py +++ b/wagtail/wagtailimages/forms.py @@ -57,3 +57,4 @@ class URLGeneratorForm(forms.Form): ) width = forms.IntegerField(_("Width"), min_value=0) height = forms.IntegerField(_("Height"), min_value=0) + closeness = forms.IntegerField(_("Closeness"), min_value=0, initial=0) diff --git a/wagtail/wagtailimages/models.py b/wagtail/wagtailimages/models.py index 35d55a798..fa40e7d9a 100644 --- a/wagtail/wagtailimages/models.py +++ b/wagtail/wagtailimages/models.py @@ -278,6 +278,7 @@ class Filter(models.Model): # 'original' # 'width-200' # 'max-320x200' + # 'fill-200x200-c50' if self.spec == 'original': return Filter.OPERATION_NAMES['original'], None @@ -286,6 +287,13 @@ class Filter(models.Model): if match: return Filter.OPERATION_NAMES[match.group(1)], int(match.group(2)) + match = re.match(r'(fill)-(\d+)x(\d+)-c(\d+)$', self.spec) + if match: + width = int(match.group(2)) + height = int(match.group(3)) + crop_closeness = int(match.group(4)) + return Filter.OPERATION_NAMES[match.group(1)], (width, height, crop_closeness) + match = re.match(r'(max|min|fill)-(\d+)x(\d+)$', self.spec) if match: width = int(match.group(2)) diff --git a/wagtail/wagtailimages/static/wagtailimages/js/image-url-generator.js b/wagtail/wagtailimages/static/wagtailimages/js/image-url-generator.js index c9f8ed1e7..86bf41fcd 100644 --- a/wagtail/wagtailimages/static/wagtailimages/js/image-url-generator.js +++ b/wagtail/wagtailimages/static/wagtailimages/js/image-url-generator.js @@ -7,6 +7,7 @@ $(function() { var $filterMethodField = $form.find('select#id_filter_method'); var $widthField = $form.find('input#id_width'); var $heightField = $form.find('input#id_height'); + var $closenessField = $form.find('input#id_closeness'); var $result = $this.find('#result-url'); var $loadingMask = $this.find('.loading-mask') var $preview = $this.find('img.preview'); @@ -22,18 +23,28 @@ $(function() { if (filterSpec == 'original') { $widthField.prop('disabled', true); $heightField.prop('disabled', true); + $closenessField.prop('disabled', true); } else if (filterSpec == 'width') { $widthField.prop('disabled', false); $heightField.prop('disabled', true); + $closenessField.prop('disabled', true); filterSpec += '-' + $widthField.val(); } else if (filterSpec == 'height') { $widthField.prop('disabled', true); $heightField.prop('disabled', false); + $closenessField.prop('disabled', true); filterSpec += '-' + $heightField.val(); } else if (filterSpec == 'min' || filterSpec == 'max' || filterSpec == 'fill') { $widthField.prop('disabled', false); $heightField.prop('disabled', false); - filterSpec += '-' + $widthField.val() + 'x' + $heightField.val(); + + if (filterSpec == 'fill') { + $closenessField.prop('disabled', false); + filterSpec += '-' + $widthField.val() + 'x' + $heightField.val() + '-c' + $closenessField.val() + } else { + $closenessField.prop('disabled', true); + filterSpec += '-' + $widthField.val() + 'x' + $heightField.val(); + } } // Display note about scaled down images if image is large diff --git a/wagtail/wagtailimages/templates/wagtailimages/images/url_generator.html b/wagtail/wagtailimages/templates/wagtailimages/images/url_generator.html index aae28cd95..e725fd2f9 100644 --- a/wagtail/wagtailimages/templates/wagtailimages/images/url_generator.html +++ b/wagtail/wagtailimages/templates/wagtailimages/images/url_generator.html @@ -17,6 +17,7 @@ diff --git a/wagtail/wagtailimages/tests.py b/wagtail/wagtailimages/tests.py index 80e9932af..f38589851 100644 --- a/wagtail/wagtailimages/tests.py +++ b/wagtail/wagtailimages/tests.py @@ -26,8 +26,6 @@ from wagtail.wagtailimages.formats import ( from wagtail.wagtailimages.backends import get_image_backend from wagtail.wagtailimages.backends.pillow import PillowBackend -from wagtail.wagtailimages.utils.crop import crop_to_point, CropBox -from wagtail.wagtailimages.utils.focal_point import FocalPoint from wagtail.wagtailimages.utils.crypto import generate_signature, verify_signature from wagtail.tests.models import EventPage, EventPageCarouselItem from wagtail.wagtailcore.models import Page @@ -951,70 +949,6 @@ class TestGenerateURLView(TestCase, WagtailTestUtils): })) -class TestCropToPoint(TestCase): - def test_basic(self): - "Test basic cropping in the centre of the image" - self.assertEqual( - crop_to_point((640, 480), (100, 100), FocalPoint(x=320, y=240)), - CropBox(270, 190, 370, 290), - ) - - def test_basic_no_focal_point(self): - "If focal point is None, it should make one in the centre of the image" - self.assertEqual( - crop_to_point((640, 480), (100, 100), None), - CropBox(270, 190, 370, 290), - ) - - def test_doesnt_exit_top_left(self): - "Test that the cropbox doesn't exit the image at the top left" - self.assertEqual( - crop_to_point((640, 480), (100, 100), FocalPoint(x=0, y=0)), - CropBox(0, 0, 100, 100), - ) - - def test_doesnt_exit_bottom_right(self): - "Test that the cropbox doesn't exit the image at the bottom right" - self.assertEqual( - crop_to_point((640, 480), (100, 100), FocalPoint(x=640, y=480)), - CropBox(540, 380, 640, 480), - ) - - def test_doesnt_get_smaller_than_focal_point(self): - "Test that the cropbox doesn't get any smaller than the focal point" - self.assertEqual( - crop_to_point((640, 480), (10, 10), FocalPoint(x=320, y=240, width=100, height=100)), - CropBox(270, 190, 370, 290), - ) - - def test_keeps_composition(self): - "Test that the cropbox tries to keep the composition of the original image as much as it can" - self.assertEqual( - crop_to_point((300, 300), (150, 150), FocalPoint(x=100, y=200)), - CropBox(50, 100, 200, 250), # Focal point is 1/3 across and 2/3 down in the crop box - ) - - def test_keeps_focal_point_in_view_bottom_left(self): - """ - Even though it tries to keep the composition of the image, - it shouldn't let that get in the way of keeping the entire subject in view - """ - self.assertEqual( - crop_to_point((300, 300), (150, 150), FocalPoint(x=100, y=200, width=150, height=150)), - CropBox(25, 125, 175, 275), - ) - - def test_keeps_focal_point_in_view_top_right(self): - """ - Even though it tries to keep the composition of the image, - it shouldn't let that get in the way of keeping the entire subject in view - """ - self.assertEqual( - crop_to_point((300, 300), (150, 150), FocalPoint(x=200, y=100, width=150, height=150)), - CropBox(125, 25, 275, 175), - ) - - class TestIssue573(TestCase): """ This tests for a bug which causes filename limit on Renditions to be reached diff --git a/wagtail/wagtailimages/utils/crop.py b/wagtail/wagtailimages/utils/crop.py deleted file mode 100644 index 983f5db88..000000000 --- a/wagtail/wagtailimages/utils/crop.py +++ /dev/null @@ -1,121 +0,0 @@ -from __future__ import division - -from wagtail.wagtailimages.utils.focal_point import FocalPoint - - -class CropBox(object): - def __init__(self, left, top, right, bottom): - self.left = int(left) - self.top = int(top) - self.right = int(right) - self.bottom = int(bottom) - - def __getitem__(self, key): - return (self.left, self.top, self.right, self.bottom)[key] - - @property - def width(self): - return self.right - self.left - - @property - def height(self): - return self.bottom - self.top - - @property - def size(self): - return self.width, self.height - - def as_tuple(self): - return self.left, self.top, self.right, self.bottom - - def __eq__(self, other): - return self.as_tuple() == other.as_tuple() - - def __ne__(self, other): - return not (self == other) - - def __repr__(self): - return 'CropBox(left: %d, top: %d, right: %d, bottom: %d)' % ( - self.left, self.top, self.right, self.bottom - ) - - -def crop_to_centre(image_size, crop_size): - (original_width, original_height) = image_size - (crop_width, crop_height) = crop_size - - # final dimensions should not exceed original dimensions - final_width = min(original_width, crop_width) - final_height = min(original_height, crop_height) - - left = (original_width - final_width) / 2 - top = (original_height - final_height) / 2 - - return CropBox(left, top, left + final_width, top + final_height) - - -def crop_to_point(image_size, crop_size, focal_point): - (original_width, original_height) = image_size - (crop_width, crop_height) = crop_size - - if not focal_point: - focal_point = FocalPoint(original_width / 2, original_height / 2) - - # Make sure that the crop size is no smaller than the focal point - crop_width = max(crop_width, focal_point.width) - crop_height = max(crop_height, focal_point.height) - - # Make sure final dimensions do not exceed original dimensions - final_width = min(original_width, crop_width) - final_height = min(original_height, crop_height) - - # Get UV for focal point - focal_point_u = focal_point.x / original_width - focal_point_v = focal_point.y / original_height - - # Get crop box - left = focal_point.x - focal_point_u * final_width - top = focal_point.y - focal_point_v * final_height - right = focal_point.x - focal_point_u * final_width + final_width - bottom = focal_point.y - focal_point_v * final_height + final_height - - # Make sure the entire focal point is in the crop box - focal_point_left = focal_point.x - focal_point.width / 2 - focal_point_top = focal_point.y - focal_point.height / 2 - focal_point_right = focal_point.x + focal_point.width / 2 - focal_point_bottom = focal_point.y + focal_point.height / 2 - - if left > focal_point_left: - right -= left - focal_point_left - left = focal_point_left - - if top > focal_point_top: - bottom -= top - focal_point_top - top = focal_point_top - - if right < focal_point_right: - left += focal_point_right - right; - right = focal_point_right - - if bottom < focal_point_bottom: - top += focal_point_bottom - bottom; - bottom = focal_point_bottom - - # Don't allow the crop box to go over the image boundary - if left < 0: - right -= left - left = 0 - - if top < 0: - bottom -= top - top = 0 - - if right > original_width: - left -= right - original_width - right = original_width - - if bottom > original_height: - top -= bottom - original_height - bottom = original_height - - return CropBox(left, top, right, bottom) diff --git a/wagtail/wagtailimages/utils/rect.py b/wagtail/wagtailimages/utils/rect.py new file mode 100644 index 000000000..5140ce0bb --- /dev/null +++ b/wagtail/wagtailimages/utils/rect.py @@ -0,0 +1,38 @@ +from __future__ import division + + +class Rect(object): + def __init__(self, left, top, right, bottom): + self.left = int(left) + self.top = int(top) + self.right = int(right) + self.bottom = int(bottom) + + def __getitem__(self, key): + return (self.left, self.top, self.right, self.bottom)[key] + + @property + def width(self): + return self.right - self.left + + @property + def height(self): + return self.bottom - self.top + + @property + def size(self): + return self.width, self.height + + def as_tuple(self): + return self.left, self.top, self.right, self.bottom + + def __eq__(self, other): + return self.as_tuple() == other.as_tuple() + + def __ne__(self, other): + return not (self == other) + + def __repr__(self): + return 'Rect(left: %d, top: %d, right: %d, bottom: %d)' % ( + self.left, self.top, self.right, self.bottom + )