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:
parent
5cbd0b019c
commit
ecf25b40a0
@ -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)
|
||||||
|
@ -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:
|
||||||
|
@ -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}")
|
||||||
|
@ -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
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):
|
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
|
||||||
|
@ -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))
|
||||||
|
Loading…
Reference in New Issue
Block a user