1
0
mirror of https://github.com/enpaul/kodak.git synced 2024-11-23 15:07:13 +00:00

Add initial MVP for b&w and crop manips

This commit is contained in:
Ethan Paul 2021-11-23 00:21:17 -05:00
parent 5cbd0b019c
commit ecf25b40a0
No known key found for this signature in database
GPG Key ID: D0E2CBF1245E92BF
7 changed files with 210 additions and 13 deletions

View File

@ -1,7 +1,7 @@
import flask_restful import flask_restful
from kodak import index
from kodak import resources from kodak import resources
from kodak import tools
from kodak._server import initialize_database from kodak._server import initialize_database
from kodak._server import KodakFlask from kodak._server import KodakFlask
from kodak._server import make_the_tea 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_request(make_the_tea)
APPLICATION.before_first_request(initialize_database) APPLICATION.before_first_request(initialize_database)
APPLICATION.before_first_request(tools.index.build) APPLICATION.before_first_request(index.build)
for resource in resources.RESOURCES: for resource in resources.RESOURCES:
API.add_resource(resource, *resource.routes) API.add_resource(resource, *resource.routes)

View File

@ -1,6 +1,7 @@
import datetime import datetime
import enum import enum
import hashlib import hashlib
import logging
import typing import typing
import uuid import uuid
from pathlib import Path from pathlib import Path
@ -50,6 +51,10 @@ class Checksum(NamedTuple):
for chunk in iter(lambda: infile.readinto(view), 0): # type: ignore for chunk in iter(lambda: infile.readinto(view), 0): # type: ignore
hasher.update(view[:chunk]) hasher.update(view[:chunk])
logging.getLogger(__name__).debug(
f"Checksum of file {path}: {hasher.name}:{hasher.hexdigest()}"
)
return cls.from_hash(hasher) return cls.from_hash(hasher)
def as_header(self) -> str: def as_header(self) -> str:

View File

@ -1,3 +1,4 @@
import logging
import os import os
from pathlib import Path 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 :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. 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: for item in constants.ImageFormat:
if extension.replace(".", "") in item.value: if extension.replace(".", "").lower() in item.value:
format_ = item format_ = item
logger.debug(
f"Identified format of file {path} as '{format_.name}' based on file extension"
)
break break
else: else:
raise RuntimeError raise RuntimeError
@ -42,6 +49,8 @@ class ImageRecord(KodakModel):
os.sep, constants.IMAGE_PATH_NAME_SEPARATOR os.sep, constants.IMAGE_PATH_NAME_SEPARATOR
)[: -len(extension)] )[: -len(extension)]
logger.debug(f"Determined image name of file {path} to be '{name}'")
return cls( return cls(
name=name, name=name,
source=path.relative_to(config.source_dir), source=path.relative_to(config.source_dir),
@ -55,10 +64,15 @@ class ImageRecord(KodakModel):
:param config: Populated application configuration object :param config: Populated application configuration object
:returns: Path to the created symbolic link back to the source file :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) Path(config.content_dir, self.name).mkdir(exist_ok=True)
link = Path(config.content_dir, self.name, "original") link = Path(config.content_dir, self.name, "original")
try: try:
link.symlink_to(config.source_dir / self.source) link.symlink_to(config.source_dir / self.source)
logger.debug(
f"Created link from {config.source_dir / self.source} to {link}"
)
except FileExistsError: except FileExistsError:
pass pass
return link return link
@ -68,4 +82,7 @@ class ImageRecord(KodakModel):
:param config: Populated application configuration object :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}")

View File

@ -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 constants
from kodak import manipulations
from kodak.database._shared import Checksum
from kodak.database._shared import ChecksumField from kodak.database._shared import ChecksumField
from kodak.database._shared import EnumField from kodak.database._shared import EnumField
from kodak.database._shared import KodakModel from kodak.database._shared import KodakModel
@ -12,7 +18,53 @@ class ManipRecord(KodakModel):
"""Model for manipulated image records""" """Model for manipulated image records"""
parent = peewee.ForeignKeyField(ImageRecord, null=False) parent = peewee.ForeignKeyField(ImageRecord, null=False)
manip = peewee.CharField(null=False) name = peewee.CharField(null=False)
file = PathField(null=False) file = PathField(null=False)
format_ = EnumField(constants.ImageFormat, null=False) format_ = EnumField(constants.ImageFormat, null=False)
checksum = ChecksumField(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_,
)

82
kodak/manipulations.py Normal file
View File

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

View File

@ -69,7 +69,7 @@ class KodakResource(flask_restful.Resource):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.logger = logging.getLogger() self.logger = logging.getLogger(__name__)
def options( def options(
self, *args, **kwargs # pylint: disable=unused-argument self, *args, **kwargs # pylint: disable=unused-argument

View File

@ -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 authenticated
from kodak.resources._shared import KodakResource from kodak.resources._shared import KodakResource
from kodak.resources._shared import ResponseTuple from kodak.resources._shared import ResponseTuple
@ -6,13 +13,47 @@ from kodak.resources._shared import ResponseTuple
class ImageManip(KodakResource): class ImageManip(KodakResource):
"""Handle generating and returning a processed image manip""" """Handle generating and returning a processed image manip"""
routes = ("/image/<string:image_name>/<string:manip>",) routes = ("/image/<string:image_name>/<string:manip_name>.<string:format_name>",)
@authenticated @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""" """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""" """Alias HEAD to GET"""
return self._head(self.get(image_name, manip)) return self._head(self.get(image_name, manip_name, format_name))