import io import mimetypes from copy import deepcopy from uuid import uuid4 import cv2 import numpy as np from PIL import UnidentifiedImageError from PIL.Image import open as pillow_open from app.exceptions import NonImageResponseException class Image: THUMBNAIL_SIZE: tuple[int, int] = (64, 64) def __init__(self, image_bytes: bytes) -> None: try: self._pillow_image = pillow_open(io.BytesIO(image_bytes)) except UnidentifiedImageError as exc: raise NonImageResponseException from exc self.name = f"{uuid4()}.{self.format}" self._thumbnail: Image | None = None def crop_to_square(self) -> None: if self.size[0] == self.size[1]: return target_quality = 100 np_arr = np.frombuffer(self.image_bytes, np.uint8) # Read the image using OpenCV img = cv2.imdecode(np_arr, cv2.IMREAD_COLOR) if img is None: return width, height = img.shape[1], img.shape[0] dim = (min(width, height), min(width, height)) crop_width = dim[0] if dim[0] < img.shape[1] else img.shape[1] crop_height = dim[1] if dim[1] < img.shape[0] else img.shape[0] mid_x, mid_y = int(width / 2), int(height / 2) cw2, ch2 = int(crop_width / 2), int(crop_height / 2) crop_img = img[mid_y - ch2 : mid_y + ch2, mid_x - cw2 : mid_x + cw2] compression = ( [cv2.IMWRITE_PNG_COMPRESSION, target_quality] if self.format == "png" else [cv2.IMWRITE_JPEG_QUALITY, target_quality] ) _, encoded_image = cv2.imencode("." + self.format, crop_img, compression) self.image_bytes = bytes(encoded_image) def _resize( self, target_size: tuple[int, int], quality: int = 100, ) -> None: # Convert the input image data to an array np_arr = np.frombuffer(self.image_bytes, np.uint8) # Read the image using OpenCV img = cv2.imdecode(np_arr, cv2.IMREAD_COLOR) if img is None: return # Calculate downscale factors for width and height downscale_factor_width = target_size[0] / self.size[0] downscale_factor_height = target_size[1] / self.size[1] # Choose Gaussian blur kernel size based on downscale factors if necessary kernel_size = None if min(downscale_factor_width, downscale_factor_height) < 1.0: kernel_size = (3, 3) # Apply Gaussian blur when dealing with downscaling cases if kernel_size is not None: # Apply anti-aliasing using Gaussian blur img = cv2.GaussianBlur(img, kernel_size, 0) # Define interpolation method based on downscale factors if max(downscale_factor_width, downscale_factor_height) > 0.5: # Use cubic interpolation for moderate downscaling interpolation = cv2.INTER_CUBIC else: # Use Lanczos interpolation for significant downscaling interpolation = cv2.INTER_LANCZOS4 # Resize the image with specified dimensions and chosen interpolation method resized_img = cv2.resize(img, target_size, interpolation=interpolation) # Encode the resized image to bytes compression = ( [cv2.IMWRITE_PNG_COMPRESSION, quality] if self.format == "png" else [cv2.IMWRITE_JPEG_QUALITY, quality] ) _, encoded_image = cv2.imencode("." + self.format, resized_img, compression) self.image_bytes = bytes(encoded_image) def convert_gif_to_png(self) -> None: with io.BytesIO() as first_frame_fo: self._pillow_image.save(first_frame_fo, format="png") self._pillow_image = pillow_open(io.BytesIO(first_frame_fo.getvalue())) @property def thumbnail(self) -> "Image": if not self._thumbnail: thumbnail = deepcopy(self) if thumbnail.format == "gif": thumbnail.convert_gif_to_png() thumbnail.crop_to_square() thumbnail._resize(self.THUMBNAIL_SIZE) self._thumbnail = thumbnail return self._thumbnail @property def image_bytes(self) -> bytes: with io.BytesIO() as fo: self._pillow_image.save(fo, format=self.format) image_bytes = fo.getvalue() return image_bytes @image_bytes.setter def image_bytes(self, value: bytes) -> None: self._pillow_image = pillow_open(io.BytesIO(value)) self._thumbnail = None @property def format(self) -> str: return (self._pillow_image.format or "").lower() @property def mime_type(self) -> str: return mimetypes.types_map.get(f".{self.format}", "") @property def size(self) -> tuple[int, int]: return self._pillow_image.size