Source code for flask_iiif.api
# -*- coding: utf-8 -*-
#
# This file is part of Flask-IIIF
# Copyright (C) 2014, 2015, 2016, 2017 CERN.
# Copyright (C) 2020 data-futures.
#
# Flask-IIIF is free software; you can redistribute it and/or modify
# it under the terms of the Revised BSD License; see LICENSE file for
# more details.
"""Multimedia Image API."""
import itertools
import math
import os
import re
from flask import current_app
from PIL import Image
from six import BytesIO, string_types
from werkzeug.utils import import_string
from .errors import IIIFValidatorError, MultimediaImageCropError, \
MultimediaImageFormatError, MultimediaImageNotFound, \
MultimediaImageQualityError, MultimediaImageResizeError, \
MultimediaImageRotateError
from .utils import resize_gif
[docs]class MultimediaImage(MultimediaObject):
r"""Multimedia Image API.
Initializes an image api with IIIF standards. You can:
* Resize :func:`resize`.
* Crop :func:`crop`.
* Rotate :func:`rotate`.
* Change image quality :func:`quality`.
Example of editing an image and saving it to disk:
.. code-block:: python
from flask_iiif.api import MultimediaImage
image = IIIFImageAPIWrapper.from_file(path)
# Rotate the image
image.rotate(90)
# Resize the image
image.resize('300,200')
# Crop the image
image.crop('20,20,400,300')
# Make the image black and white
image.quality('grey')
# Finaly save it to /tmp
image.save('/tmp')
Example of serving the modified image over http:
.. code-block:: python
from flask import current_app, Blueprint
from flask_iiif.api import MultimediaImage
@blueprint.route('/serve/<string:uuid>/<string:size>')
def serve_thumbnail(uuid, size):
\"\"\"Serve the image thumbnail.
:param uuid: The document uuid.
:param size: The desired image size.
\"\"\"
# Initialize the image with the uuid
path = current_app.extensions['iiif'].uuid_to_path(uuid)
image = IIIFImageAPIWrapper.from_file(path)
# Resize it
image.resize(size)
# Serve it
return send_file(image.serve(), mimetype='image/jpeg')
"""
def __init__(self, image):
"""Initialize the image."""
self.image = image
[docs] @classmethod
def from_file(cls, path):
"""Return the image object from the given path.
:param str path: The absolute path of the file
:returns: a :class:`~flask_iiif.api.MultimediaImage`
instance
"""
if not os.path.exists(path):
raise MultimediaImageNotFound(
"The requested image {0} not found".format(path))
image = Image.open(path)
return cls(image)
[docs] @classmethod
def from_string(cls, source):
"""Create an :class:`~flask_iiif.api.MultimediaImage` instance.
:param str source: the image string
:type source: `BytesIO` object
:returns: a :class:`~flask_iiif.api.MultimediaImage`
instance
"""
image = Image.open(source)
return cls(image)
[docs] def resize(self, dimensions, resample=None):
"""Resize the image.
:param str dimensions: The dimensions to resize the image
:param resample: The algorithm to be used
:type resample: :py:mod:`PIL.Image` algorithm
.. note::
* `dimensions` must be one of the following:
* 'w,': The exact width, height will be calculated.
* ',h': The exact height, width will be calculated.
* 'pct:n': Image percentage scale.
* 'w,h': The exact width and height.
* '!w,h': Best fit for the given width and height.
"""
real_width, real_height = self.image.size
point_x, point_y = 0, 0
if resample is None:
if isinstance(current_app.config['IIIF_RESIZE_RESAMPLE'],
string_types):
resample = import_string(
current_app.config['IIIF_RESIZE_RESAMPLE'])
else:
resample = current_app.config['IIIF_RESIZE_RESAMPLE']
# Check if it is `pct:`
if dimensions.startswith('pct:'):
percent = float(dimensions.split(':')[1]) * 0.01
if percent < 0:
raise MultimediaImageResizeError(
("Image percentage could not be negative, {0} has been"
" given").format(percent)
)
width = max(1, int(real_width * percent))
height = max(1, int(real_height * percent))
# Check if it is `,h`
elif dimensions.startswith(','):
height = int(dimensions[1:])
# find the ratio
ratio = self.reduce_by(height, real_height)
# calculate width (minimum 1)
width = max(1, int(real_width * ratio))
# Check if it is `!w,h`
elif dimensions.startswith('!'):
point_x, point_y = map(int, dimensions[1:].split(','))
# find the ratio
ratio_x = self.reduce_by(point_x, real_width)
ratio_y = self.reduce_by(point_y, real_height)
# take the min
ratio = min(ratio_x, ratio_y)
# calculate the dimensions
width = max(1, int(real_width * ratio))
height = max(1, int(real_height * ratio))
# Check if it is `w,`
elif dimensions.endswith(','):
width = int(dimensions[:-1])
# find the ratio
ratio = self.reduce_by(width, real_width)
# calculate the height
height = max(1, int(real_height * ratio))
# Normal mode `w,h`
else:
try:
width, height = map(int, dimensions.split(','))
except ValueError:
raise MultimediaImageResizeError(
"The request must contain width,height sequence"
)
# If a dimension is missing throw error
if any((dimension <= 0 and
dimension is not None) for dimension in (width, height)):
raise MultimediaImageResizeError(
("Width and height cannot be zero or negative, {0},{1} has"
" been given").format(width, height)
)
arguments = dict(size=(width, height), resample=resample)
if self.image.format == 'GIF':
self.image = resize_gif(self.image, **arguments)
else:
self.image = self.image.resize(**arguments)
[docs] def crop(self, coordinates):
"""Crop the image.
:param str coordinates: The coordinates to crop the image
.. note::
* `coordinates` must have the following pattern:
* 'x,y,w,h': in pixels.
* 'pct:x,y,w,h': percentage.
"""
# Get image full dimensions
real_width, real_height = self.image.size
real_dimensions = itertools.cycle((real_width, real_height))
dimensions = []
percentage = False
if coordinates.startswith('pct:'):
for coordinate in coordinates.split(':')[1].split(','):
dimensions.append(float(coordinate))
percentage = True
else:
for coordinate in coordinates.split(','):
dimensions.append(int(coordinate))
# First check if it has 4 coordinates x,y,w,h
dimensions_length = len(dimensions)
if dimensions_length != 4:
raise MultimediaImageCropError(
"Must have 4 dimensions {0} has been given".
format(dimensions_length))
# Make sure that there is not any negative dimension
if any(coordinate < 0 for coordinate in dimensions):
raise MultimediaImageCropError(
"Dimensions cannot be negative {0} has been given".
format(dimensions)
)
if percentage:
if any(coordinate > 100.0 for coordinate in dimensions):
raise MultimediaImageCropError(
"Dimensions could not be grater than 100%")
# Calculate the dimensions
start_x, start_y, width, height = [
int(
math.floor(
self.percent_to_number(dimension) *
next(real_dimensions)
)
) for dimension in dimensions
]
else:
start_x, start_y, width, height = dimensions
# Check if any of the requested axis is outside of image borders
if any(axis > next(real_dimensions) for axis in (start_x, start_y)):
raise MultimediaImageCropError(
"Outside of image borders {0},{1}".
format(real_width, real_height)
)
# Calculate the final dimensions
max_x = start_x + width
max_y = start_y + height
# Check if the final width is bigger than the the real image width
if max_x > real_width:
max_x = real_width
# Check if the final height is bigger than the the real image height
if max_y > real_height:
max_y = real_height
self.image = self.image.crop((start_x, start_y, max_x, max_y))
[docs] def rotate(self, degrees, mirror=False):
"""Rotate the image clockwise by given degrees.
:param float degrees: The degrees, should be in range of [0, 360]
:param bool mirror: Flip image from left to right
"""
# PIL wants to do anti-clockwise rotation so swap these around
transforms = {
'90': Image.ROTATE_270,
'180': Image.ROTATE_180,
'270': Image.ROTATE_90,
'mirror': Image.FLIP_LEFT_RIGHT,
}
# Check if we have the right degrees
if not 0.0 <= float(degrees) <= 360.0:
raise MultimediaImageRotateError(
"Degrees must be between 0 and 360, {0} has been given".
format(degrees)
)
# mirror must be applied before rotation
if mirror:
self.image = self.image.transpose(transforms.get('mirror'))
if str(degrees) in transforms.keys():
self.image = self.image.transpose(transforms.get(str(degrees)))
else:
# transparent background if degrees not multiple of 90
self.image = self.image.convert('RGBA')
self.image = self.image.rotate(float(degrees), expand=1)
[docs] def quality(self, quality):
"""Change the image format.
:param str quality: The image quality should be in (default, grey,
bitonal, color)
.. note::
The library supports transformations between each supported
mode and the "L" and "RGB" modes. To convert between other
modes, you may have to use an intermediate image (typically
an "RGB" image).
"""
qualities = current_app.config['IIIF_QUALITIES']
if quality not in qualities:
raise MultimediaImageQualityError(
("{0} is not supported, please select one of the"
" valid qualities: {1}").format(quality, qualities)
)
qualities_by_code = zip(qualities,
current_app.config['IIIF_CONVERTERS'])
if quality not in ('default', 'color'):
# Convert image to RGB read the note
if self.image.mode != "RGBA":
self.image = self.image.convert('RGBA')
code = [quality_code[1] for quality_code in qualities_by_code
if quality_code[0] == quality][0]
self.image = self.image.convert(code)
[docs] def size(self):
"""Return the current image size.
:return: the image size
"""
return self.image.size
[docs] def save(self, path, image_format="jpeg", quality=90):
"""Store the image to the specific path.
:param str path: absolute path
:param str image_format: (gif, jpeg, pdf, png, tif)
:param int quality: The image quality; [1, 100]
.. note::
`image_format` = jpg will not be recognized by :py:mod:`PIL.Image`
and it will be changed to jpeg.
"""
# transform `image_format` is lower case and not equals to jpg
cleaned_image_format = self._prepare_for_output(image_format)
self.image.save(path, cleaned_image_format, quality=quality)
[docs] def serve(self, image_format="png", quality=90):
"""Return a BytesIO object to easily serve it thought HTTTP.
:param str image_format: (gif, jpeg, pdf, png, tif)
:param int quality: The image quality; [1, 100]
.. note::
`image_format` = jpg will not be recognized by
:py:mod:`PIL.Image` and it will be changed to jpeg.
"""
image_buffer = BytesIO()
# transform `image_format` is lower case and not equals to jpg
cleaned_image_format = self._prepare_for_output(image_format)
save_kwargs = dict(quality=quality)
if self.image.format == 'GIF':
save_kwargs.update(save_all=True)
self.image.save(image_buffer, cleaned_image_format, **save_kwargs)
image_buffer.seek(0)
return image_buffer
def _prepare_for_output(self, requested_format):
"""Help validate output format.
:param str requested_format: The image output format
.. note::
pdf and jpeg format can't be saved as `RBGA` so image needs to be
converted to `RGB` mode.
"""
pil_map = current_app.config['IIIF_FORMATS_PIL_MAP']
pil_keys = pil_map.keys()
format_keys = current_app.config['IIIF_FORMATS'].keys()
if (
requested_format not in format_keys or
requested_format not in pil_keys
):
raise MultimediaImageFormatError(
("{0} is not supported, please select one of the valid"
" formats: {1}").format(requested_format, format_keys)
)
else:
image_format = pil_map[requested_format]
# If the the `requested_format` is pdf or jpeg force mode to RGB
if image_format in ("pdf", "jpeg"):
self.image = self.image.convert('RGB')
return image_format
[docs] @staticmethod
def reduce_by(nominally, dominator):
"""Calculate the ratio."""
return float(nominally) / float(dominator)
[docs] @staticmethod
def percent_to_number(number):
"""Calculate the percentage."""
return float(number) / 100.0
[docs]class IIIFImageAPIWrapper(MultimediaImage):
"""IIIF Image API Wrapper."""
[docs] @staticmethod
def validate_api(**kwargs):
"""Validate IIIF Image API.
Example to validate the IIIF API:
.. code:: python
from flask_iiif.api import IIIFImageAPIWrapper
IIIFImageAPIWrapper.validate_api(
version=version,
region=region,
size=size,
rotation=rotation,
quality=quality,
image_format=image_format
)
.. note::
If the version is not specified it will fallback to version 2.0.
"""
# Get the api version
version = kwargs.get('version', 'v2')
# Get the validations and ignore cases
cases = current_app.config['IIIF_VALIDATIONS'].get(version)
for key in cases.keys():
# If the parameter don't match with iiif casess
if not re.search(
cases.get(key, {}).get('validate', ''), kwargs.get(key)
):
raise IIIFValidatorError(
("value: `{0}` for parameter: `{1}` is not supported").
format(kwargs.get(key), key)
)
[docs] def apply_api(self, **kwargs):
"""Apply the IIIF API to the image.
Example to apply the IIIF API:
.. code:: python
from flask_iiif.api import IIIFImageAPIWrapper
image = IIIFImageAPIWrapper.from_file(path)
image.apply_api(
version=version,
region=region,
size=size,
rotation=rotation,
quality=quality
)
.. note::
* If the version is not specified it will fallback to version 2.0.
* Please note the :func:`validate_api` should be run before
:func:`apply_api`.
"""
# Get the api version
version = kwargs.get('version', 'v2')
# Get the validations and ignore cases
cases = current_app.config['IIIF_VALIDATIONS'].get(version)
# Set the apply order
order = 'region', 'size', 'rotation', 'quality'
# Set the functions to be applied
tools = {
"region": self.apply_region,
"size": self.apply_size,
"rotation": self.apply_rotate,
"quality": self.apply_quality
}
for key in order:
# Ignore if has the ignore value for the specific key
if kwargs.get(key) != cases.get(key, {}).get('ignore'):
tools.get(key)(kwargs.get(key))
[docs] def apply_region(self, value):
"""IIIF apply crop.
Apply :func:`~flask_iiif.api.MultimediaImage.crop`.
"""
self.crop(value)
[docs] def apply_size(self, value):
"""IIIF apply resize.
Apply :func:`~flask_iiif.api.MultimediaImage.resize`.
"""
self.resize(value)
[docs] def apply_rotate(self, value):
"""IIIF apply rotate.
Apply :func:`~flask_iiif.api.MultimediaImage.rotate`.
.. note::
PIL rotates anti-clockwise, IIIF specifies clockwise
"""
mirror = False
degrees = value
if value.startswith('!'):
mirror = True
degrees = value[1:]
self.rotate(360-float(degrees), mirror=mirror)
[docs] def apply_quality(self, value):
"""IIIF apply quality.
Apply :func:`~flask_iiif.api.MultimediaImage.quality`.
"""
self.quality(value)
[docs] @classmethod
def open_image(cls, source):
"""Create an :class:`~flask_iiif.api.MultimediaImage` instance.
:param str source: The image image string
:type source: `BytesIO` object
:param str source_type: the type of ``data``
:returns: a :class:`~flask_iiif.api.MultimediaImage`
instance
"""
try:
image = Image.open(source)
except (AttributeError, IOError):
raise MultimediaImageNotFound(
"The requested image cannot be opened"
)
return cls(image)