From ecf25b40a0935726aed18f6ed57ee804d94d2134 Mon Sep 17 00:00:00 2001 From: Ethan Paul <24588726+enpaul@users.noreply.github.com> Date: Tue, 23 Nov 2021 00:21:17 -0500 Subject: [PATCH] Add initial MVP for b&w and crop manips --- kodak/application.py | 4 +- kodak/database/_shared.py | 5 +++ kodak/database/image.py | 23 +++++++++-- kodak/database/manip.py | 56 +++++++++++++++++++++++++- kodak/manipulations.py | 82 ++++++++++++++++++++++++++++++++++++++ kodak/resources/_shared.py | 2 +- kodak/resources/manip.py | 51 +++++++++++++++++++++--- 7 files changed, 210 insertions(+), 13 deletions(-) create mode 100644 kodak/manipulations.py diff --git a/kodak/application.py b/kodak/application.py index c3cb7c0..db347b6 100644 --- a/kodak/application.py +++ b/kodak/application.py @@ -1,7 +1,7 @@ import flask_restful +from kodak import index from kodak import resources -from kodak import tools from kodak._server import initialize_database from kodak._server import KodakFlask from kodak._server import make_the_tea @@ -13,7 +13,7 @@ API = flask_restful.Api(APPLICATION, catch_all_404s=True) APPLICATION.before_request(make_the_tea) APPLICATION.before_first_request(initialize_database) -APPLICATION.before_first_request(tools.index.build) +APPLICATION.before_first_request(index.build) for resource in resources.RESOURCES: API.add_resource(resource, *resource.routes) diff --git a/kodak/database/_shared.py b/kodak/database/_shared.py index 036abfc..9dbd3e6 100644 --- a/kodak/database/_shared.py +++ b/kodak/database/_shared.py @@ -1,6 +1,7 @@ import datetime import enum import hashlib +import logging import typing import uuid from pathlib import Path @@ -50,6 +51,10 @@ class Checksum(NamedTuple): for chunk in iter(lambda: infile.readinto(view), 0): # type: ignore hasher.update(view[:chunk]) + logging.getLogger(__name__).debug( + f"Checksum of file {path}: {hasher.name}:{hasher.hexdigest()}" + ) + return cls.from_hash(hasher) def as_header(self) -> str: diff --git a/kodak/database/image.py b/kodak/database/image.py index 0c3c512..7f8d0b5 100644 --- a/kodak/database/image.py +++ b/kodak/database/image.py @@ -1,3 +1,4 @@ +import logging import os from pathlib import Path @@ -29,11 +30,17 @@ class ImageRecord(KodakModel): :param path: Full path to the image file to process. The file path provided is expected to already be absolute, with all symlinks and aliases resolved. """ - extension = path.suffix + logger = logging.getLogger(__name__) + logger.debug(f"Creating image record from path {path}") + + extension = path.suffix for item in constants.ImageFormat: - if extension.replace(".", "") in item.value: + if extension.replace(".", "").lower() in item.value: format_ = item + logger.debug( + f"Identified format of file {path} as '{format_.name}' based on file extension" + ) break else: raise RuntimeError @@ -42,6 +49,8 @@ class ImageRecord(KodakModel): os.sep, constants.IMAGE_PATH_NAME_SEPARATOR )[: -len(extension)] + logger.debug(f"Determined image name of file {path} to be '{name}'") + return cls( name=name, source=path.relative_to(config.source_dir), @@ -55,10 +64,15 @@ class ImageRecord(KodakModel): :param config: Populated application configuration object :returns: Path to the created symbolic link back to the source file """ + logger = logging.getLogger(__name__) + Path(config.content_dir, self.name).mkdir(exist_ok=True) link = Path(config.content_dir, self.name, "original") try: link.symlink_to(config.source_dir / self.source) + logger.debug( + f"Created link from {config.source_dir / self.source} to {link}" + ) except FileExistsError: pass return link @@ -68,4 +82,7 @@ class ImageRecord(KodakModel): :param config: Populated application configuration object """ - Path(config.content_dir, self.name, "original").unlink(missing_ok=True) + logger = logging.getLogger(__name__) + link = Path(config.content_dir, self.name, "original") + link.unlink(missing_ok=True) + logger.debug(f"Removed link from {config.source_dir / self.source} to {link}") diff --git a/kodak/database/manip.py b/kodak/database/manip.py index 8d6fad7..b7ad958 100644 --- a/kodak/database/manip.py +++ b/kodak/database/manip.py @@ -1,6 +1,12 @@ -import peewee +import logging +import peewee +from PIL import Image + +from kodak import configuration from kodak import constants +from kodak import manipulations +from kodak.database._shared import Checksum from kodak.database._shared import ChecksumField from kodak.database._shared import EnumField from kodak.database._shared import KodakModel @@ -12,7 +18,53 @@ class ManipRecord(KodakModel): """Model for manipulated image records""" parent = peewee.ForeignKeyField(ImageRecord, null=False) - manip = peewee.CharField(null=False) + name = peewee.CharField(null=False) file = PathField(null=False) format_ = EnumField(constants.ImageFormat, null=False) checksum = ChecksumField(null=False) + + @classmethod + def from_parent( + cls, + parent: ImageRecord, + config: configuration.KodakConfig, + manip: configuration.ManipConfig, + format_: constants.ImageFormat, + ): + """Construct an image manip record + + :param parent: Parent image record that should be manipulated + :param config: Populated manipulation configuration object + :param format_: Image format that the manipulation should be saved in + :returns: Saved image manipulation record + """ + logger = logging.getLogger(__name__) + + logger.info( + f"Constructing manip '{manip.name}' from source file {config.source_dir / parent.source}" + ) + + filepath = ( + config.content_dir / parent.name / f"{manip.name}.{format_.name.lower()}" + ) + + with Image.open(config.source_dir / parent.source) as image: + if manip.scale.horizontal is not None or manip.scale.vertical is not None: + image = manipulations.scale(image, manip) + + if manip.crop.horizontal is not None or manip.crop.vertical is not None: + image = manipulations.crop(image, manip) + + if manip.black_and_white: + image = manipulations.black_and_white(image, manip) + + image.save(filepath, format_.name) + logger.debug(f"Saved manipulated image at {filepath} in {format_.name} format") + + return cls( + parent=parent, + name=manip.name, + file=filepath.relative_to(config.content_dir), + checksum=Checksum.from_path(filepath), + format_=format_, + ) diff --git a/kodak/manipulations.py b/kodak/manipulations.py new file mode 100644 index 0000000..e0b7777 --- /dev/null +++ b/kodak/manipulations.py @@ -0,0 +1,82 @@ +import logging + +from PIL import Image + +from kodak import configuration +from kodak import constants + + +def scale(image: Image.Image, config: configuration.ManipConfig) -> Image.Image: + pass + + +def crop(image: Image.Image, config: configuration.ManipConfig) -> Image.Image: + """Crop an image to new dimensions""" + + # TODO: add safeguards for when config values are out of bounds for the image + + width = config.crop.horizontal or image.width + height = config.crop.vertical or image.height + + top = 0 + left = 0 + bottom = image.height + right = image.width + + if config.crop.anchor == constants.CropAnchor.TL: + x_1 = left + y_1 = top + x_2 = width + y_2 = height + elif config.crop.anchor == constants.CropAnchor.TC: + x_1 = (right / 2) - (width / 2) + y_1 = top + x_2 = (right / 2) + (width / 2) + y_2 = height + elif config.crop.anchor == constants.CropAnchor.TR: + x_1 = right - width + y_1 = top + x_2 = right + y_2 = height + elif config.crop.anchor == constants.CropAnchor.CL: + x_1 = left + y_1 = (bottom / 2) - (height / 2) + x_2 = width + y_2 = (bottom / 2) + (height / 2) + elif config.crop.anchor == constants.CropAnchor.C: + x_1 = (right / 2) - (width / 2) + y_1 = (bottom / 2) - (height / 2) + x_2 = (right / 2) + (width / 2) + y_2 = (bottom / 2) + (height / 2) + elif config.crop.anchor == constants.CropAnchor.BL: + x_1 = left + y_1 = bottom - height + x_2 = width + y_2 = bottom + elif config.crop.anchor == constants.CropAnchor.BC: + x_1 = (right / 2) - (width / 2) + y_1 = bottom - height + x_2 = (right / 2) + (width / 2) + y_2 = bottom + elif config.crop.anchor == constants.CropAnchor.BR: + x_1 = right - width + y_1 = bottom - height + x_2 = right + y_2 = bottom + else: + raise ValueError("Ye gadds! This codepath is impossible!") + + logging.getLogger(__name__).debug( + f"Cropping image {image.filename}: old {right}x{bottom}; new {width}x{height}; upper-left anchor ({x_1}, {y_1}); lower-right anchor ({x_2}, {y_2})" + ) + + return image.crop((x_1, y_1, x_2, y_2)) + + +def black_and_white( + image: Image.Image, config: configuration.ManipConfig +) -> Image.Image: + """Convert an image to full-depth black and white""" + logger = logging.getLogger(__name__) + logger.debug(f"Converting image {image.filename} to black and white") + return image.convert("L") diff --git a/kodak/resources/_shared.py b/kodak/resources/_shared.py index 00068c8..1ed53df 100644 --- a/kodak/resources/_shared.py +++ b/kodak/resources/_shared.py @@ -69,7 +69,7 @@ class KodakResource(flask_restful.Resource): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.logger = logging.getLogger() + self.logger = logging.getLogger(__name__) def options( self, *args, **kwargs # pylint: disable=unused-argument diff --git a/kodak/resources/manip.py b/kodak/resources/manip.py index 3bee4c4..5295317 100644 --- a/kodak/resources/manip.py +++ b/kodak/resources/manip.py @@ -1,3 +1,10 @@ +import datetime + +import flask +import peewee + +from kodak import constants +from kodak import database from kodak.resources._shared import authenticated from kodak.resources._shared import KodakResource from kodak.resources._shared import ResponseTuple @@ -6,13 +13,47 @@ from kodak.resources._shared import ResponseTuple class ImageManip(KodakResource): """Handle generating and returning a processed image manip""" - routes = ("/image//",) + routes = ("/image//.",) @authenticated - def get(self, image_name: str, manip: str) -> ResponseTuple: + def get( # pylint: disable=no-self-use + self, image_name: str, manip_name: str, format_name: str + ) -> flask.Response: """Retrieve an image variation""" - raise NotImplementedError + try: + manip_config = flask.current_app.appconfig.manips[manip_name] + format_ = constants.ImageFormat[format_name.upper()] + except KeyError: + raise - def head(self, image_name: str, manip: str) -> ResponseTuple: + with database.interface.atomic(): + parent = database.ImageRecord.get(database.ImageRecord.name == image_name) + + try: + manip = ( + database.ManipRecord.select() + .where( + database.ManipRecord.parent == parent, + database.ManipRecord.name == manip_config.name, + database.ManipRecord.format_ == format_, + ) + .get() + ) + except peewee.DoesNotExist: + manip = database.ManipRecord.from_parent( + parent, flask.current_app.appconfig, manip_config, format_ + ) + + resp = flask.send_file( + (flask.current_app.appconfig.content_dir / manip.file), + cache_timeout=int(datetime.timedelta(days=365).total_seconds()), + add_etags=False, + ) + + resp.headers["Content-Digest"] = manip.checksum.as_header() + + return resp + + def head(self, image_name: str, manip_name: str, format_name: str) -> ResponseTuple: """Alias HEAD to GET""" - return self._head(self.get(image_name, manip)) + return self._head(self.get(image_name, manip_name, format_name))