mirror of
https://github.com/enpaul/kodak.git
synced 2024-11-14 10:36:55 +00:00
Add initial MVP for b&w and crop manips
This commit is contained in:
parent
5cbd0b019c
commit
ecf25b40a0
@ -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)
|
||||
|
@ -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:
|
||||
|
@ -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}")
|
||||
|
@ -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
82
kodak/manipulations.py
Normal 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")
|
@ -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
|
||||
|
@ -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))
|
||||
|
Loading…
Reference in New Issue
Block a user