A Custom Cropping Engine With sorl-thumbnail

🏚 2020 Update: This is an old post. I haven't used Sorl in quite a while so this code may be out-of-date

I was recently using sorl-thumbnail and needed to create cropped thumbnails. I wanted to be able to specify exactly where I wanted to crop from as well as scale the dimensions of the resulting cropped thumbnail. For example “create crop from pixels 10,10 to 500,750“,

Unfortunately the (sensible) default behaviour of the PIL engine in sorl-thumbnailfirst scales the image, and then performs a CSS-esque crop to the resulting scaled image. Essentially the reverse of what I wanted.

The following is a custom sorl-thumbnail engine that can be hot-swapped (or used by default if you prefer), allowing you to create nicely cropped and scaled thumbnails.

from sorl.thumbnail.engines import pil_engine
from sorl.thumbnail import parsers

class CustomCroppingEngine(pil_engine.Engine):
  A custom sorl.thumbnail engine (using PIL) that first crops 
  an image according to 4 pixel/percentage values in the source 
  image, then scales that crop down to the size specified in the 
  geometry. This is in contrast to sorl.thumbnail's default engine 
  which first scales the image down to the specified geometry 
  and applies the crop afterward.
  def create(self, image, geometry, options):
    image = self.orientation(image, geometry, options)
    image = self.colorspace(image, geometry, options)
    image = self.crop(image, geometry, options)
    image = self.scale(image, geometry, options)
    return image

  def _crop_parse(self, crop, xy_image, xy_window):
    Convert the crop string passed by the user to accurate 
    cropping values (this is adapter from the default 
    crops = crop.split(' ')
    if len(crops) != 4:
      raise parsers.ThumbnailParseError(
        'Unrecognized crop option: %s' % crop)
    x1, y1, x2, y2 = crops

    def get_offset(crop, epsilon):
      m = parsers.bgpos_pat.match(crop)
      if not m:
        raise parsers.ThumbnailParseError(
          'Unrecognized crop option: %s' % crop)
      # we only take ints in the regexp
      value = int(m.group('value'))
      unit = m.group('unit')
      if unit == '%':
        value = epsilon * value / 100.0
      return int(max(0, min(value, epsilon)))
    x1 = get_offset(x1, xy_image[0])
    y1 = get_offset(y1, xy_image[0])
    x2 = get_offset(x2, xy_image[1])
    y2 = get_offset(y2, xy_image[1])
    return x1, y1, x2, y2

  def crop(self, image, geometry, options):
    crop = options['crop']
    if not crop or crop == 'noop':
      return image
    x_image, y_image = self.get_image_size(image)
    x1,y1,x2,y2 = self._crop_parse(
      crop, (x_image, y_image), geometry)
    return self._crop(image, x1, y1, x2, y2)

  def _crop(self, image, x1, y1, x2, y2):
    return image.crop((x1, y1, x2, y2))

this can now be used dynamically in your application:

from sorl.thumbnail import get_thumbnail, default
from myapp.engine import CustomCroppingEngine
def crop(img, x1, y1, x2, y2, crop_width, crop_height):
  # Swap engines
  old_engine = default.engine
  default.engine = CustomCroppingEngine()
  crop = get_thumbnail(
    "%sx%s" % (crop_width, crop_height),
    crop="%s%% %s%% %s%% %s%%" % (int(x1), int(y1), int(x2), int(y2)))
  # Replace normal engine
  default.engine = old_engine
  return crop

alternatively you can make permanent use of this engine by setting settings.THUMBNAIL_ENGINE