Source code for profile_photo.models
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from functools import cached_property
from io import BytesIO
from os import PathLike
from os.path import splitext, basename
from pathlib import Path
from typing import TYPE_CHECKING
from PIL import Image
from PIL.Image import Image as PILImage
from .utils.aws.rekognition_models import DetectLabelsResp, DetectFacesResp
from .utils.img_orient import resize_ims_and_concat_h
if TYPE_CHECKING:
from typing import Protocol
class GetImFileName(Protocol):
def __call__(self, file_stem: str, file_ext: str) -> PathLike[str] | str:
...
class GetResponseFileName(Protocol):
def __call__(self, file_name: str, api: str) -> PathLike[str] | str:
...
def _get_im_filename(file_stem: str, file_ext: str) -> PathLike[str] | str:
return f'{file_stem}-out{file_ext}'
def _get_response_filename(file_name: str, api: str) -> PathLike[str] | str:
return f'{file_name}_{api}_resp.json'
[docs]@dataclass
class ProfilePhoto:
filepath: str | None
im_bytes: bytes
is_rotated: bool
orientation: int | None
faces: DetectFacesResp
labels: DetectLabelsResp
# PRIVATE
_original_im_bytes: bytes = field(repr=False)
# default filename
_DEFAULT_FILENAME = 'output.jpg'
@cached_property
def image(self) -> PILImage:
"""Returns the final photo as a PIL Image."""
return Image.open(BytesIO(self.im_bytes))
@cached_property
def side_by_side_image(self) -> PILImage:
"""
Returns a horizontally concatenated photo (before and after)
as a PIL Image.
"""
orig_im = Image.open(BytesIO(self._original_im_bytes))
return resize_ims_and_concat_h(orig_im, self.image)
[docs] def show(self, side_by_side=True, title='Profile Photo'):
"""
Show the Profile Photo (as a PIL Image) in a new window.
If `side_by_side` is True (the default), then also show
the original image on the left.
"""
im = self.side_by_side_image if side_by_side else self.image
im.show(title)
[docs] def save_all(self, folder: Path | str | None = None,
get_im_filename: GetImFileName = _get_im_filename,
get_response_filename: GetResponseFileName = _get_response_filename):
"""Save both the output image and API responses to a local folder."""
path = self._path(folder)
self.save_image(path, get_im_filename)
self.save_responses(path, get_response_filename)
[docs] def save_image(self, folder: Path | str | None = None,
get_filename: GetImFileName = _get_im_filename):
"""Save the output image to a local folder."""
folder = self._path(folder)
folder.mkdir(exist_ok=True)
filename, ext = splitext(fp if (fp := self.filepath) else self._DEFAULT_FILENAME)
out_filename = get_filename(basename(filename), ext)
self.image.save(folder / out_filename)
[docs] def save_responses(self, folder: Path | str | None = None,
get_filename: GetResponseFileName = _get_response_filename):
"""
Save (or cache) Rekognition API responses -- from the Detect Faces API
and Detect Labels API -- as separate JSON files under a folder or path.
"""
folder = self._path(folder)
fp = self.filepath
f_name = Path(fp if fp else self._DEFAULT_FILENAME).stem
for (api, resp) in (('DetectFaces', self.faces),
('DetectLabels', self.labels)):
fpath = folder / get_filename(f_name, api)
# ensure that output folder exists
if (parent := fpath.parent) != '.':
parent.mkdir(parents=True, exist_ok=True)
with open(fpath, 'w') as f:
f.write(resp.to_json())
def _path(self, folder: Path | str | None):
if isinstance(folder, Path):
return folder
if folder:
return Path(folder)
fp = self.filepath
if not fp:
from .errors import MissingOneOfParams
raise MissingOneOfParams('filepath_or_bytes', required_if_missing='folder')
return Path(fp).parent
[docs]class StrEnum(str, Enum):
[docs] @classmethod
def values(cls):
"""Returns an iterable of all values in the `Enum` subclass."""
return (str(v) for v in cls.__members__.values())
def __str__(self) -> str:
return str.__str__(self)
[docs]class Params(StrEnum):
"""Required Params -- one of FILEPATH_OR_BYTES or {FACES, LABELS}."""
FILEPATH_OR_BYTES = 'filepath_or_bytes'
FACES = 'faces'
LABELS = 'labels'