Merge branch 'feature/crop-closeness' of https://github.com/kaedroho/wagtail into kaedroho-feature/crop-closeness

pull/677/head
Matt Westcott 2014-10-07 11:41:11 +01:00
commit fb543589b7
11 zmienionych plików z 214 dodań i 228 usunięć

Wyświetl plik

@ -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<percentage>`` 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)

Wyświetl plik

@ -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"""

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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)

Wyświetl plik

@ -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))

Wyświetl plik

@ -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

Wyświetl plik

@ -17,6 +17,7 @@
<ul class="field-row">
{% include "wagtailadmin/shared/field_as_li.html" with field=form.width li_classes="field-col col4" %}
{% include "wagtailadmin/shared/field_as_li.html" with field=form.height li_classes="field-col col4" %}
{% include "wagtailadmin/shared/field_as_li.html" with field=form.closeness li_classes="field-col col4" %}
</ul>
</li>
</ul>

Wyświetl plik

@ -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

Wyświetl plik

@ -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)

Wyświetl plik

@ -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
)