1
0
mirror of https://github.com/enpaul/kodak.git synced 2024-11-23 06:56:58 +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
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)

View File

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

View File

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

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 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_,
)

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):
super().__init__(*args, **kwargs)
self.logger = logging.getLogger()
self.logger = logging.getLogger(__name__)
def options(
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 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/<string:image_name>/<string:manip>",)
routes = ("/image/<string:image_name>/<string:manip_name>.<string:format_name>",)
@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))