diff --git a/README.md b/README.md index 70ba53e..27db366 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ This project was a part of the 2019 Open Source Software Competition. - Can apply curve adjustment selectively to RGB channels ## Requirements -Layer.is requires Python 3.6 or higher. +Layer.is requires Python 3.9 or higher. ## Quick Start @@ -33,14 +33,14 @@ $ pip install layeris #### Loading an image from file ```python -from layeris.layer_image import LayerImage +from layeris import LayerImage image = LayerImage.from_file('/path/to/your/image.jpg') ``` #### Loading an image from URL ```python -from layeris.layer_image import LayerImage +from layeris import LayerImage image = LayerImage.from_url('https://your-image-url') ``` diff --git a/layeris/__init__.py b/layeris/__init__.py index e69de29..5420249 100644 --- a/layeris/__init__.py +++ b/layeris/__init__.py @@ -0,0 +1,6 @@ +"""Layeris image processing library.""" + +from .layer_image import LayerImage + +__all__ = ["LayerImage"] +__version__ = "0.2.0" diff --git a/layeris/layer_image.py b/layeris/layer_image.py index ab8f620..531ff40 100644 --- a/layeris/layer_image.py +++ b/layeris/layer_image.py @@ -1,39 +1,103 @@ -from PIL import Image +"""High level image manipulation helpers.""" + +from __future__ import annotations + +import json from io import BytesIO -import urllib -import requests +from pathlib import Path +from typing import Sequence, Union + import numpy as np -import matplotlib -import json -from .utils.conversions import convert_uint_to_float, convert_float_to_uint, round_to_uint, get_rgb_float_if_hex, get_array_from_hex +import requests +from PIL import Image +from matplotlib import colors as mpl_colors + +from .utils.channels import channel_adjust, merge_channels, split_image_into_channels +from .utils.conversions import convert_float_to_uint, convert_uint_to_float, get_rgb_float_if_hex from .utils.layers import mix -from .utils.channels import split_image_into_channels, merge_channels, channel_adjust -class LayerImage(): - @staticmethod - def from_url(url): - response = requests.get(url) +class LayerImage: + """A convenience wrapper around a float RGB numpy array.""" + + _HEX_METHODS = { + "darken": "darken", + "multiply": "multiply", + "color_burn": "color_burn", + "linear_burn": "linear_burn", + "lighten": "lighten", + "screen": "screen", + "color_dodge": "color_dodge", + "linear_dodge": "linear_dodge", + "overlay": "overlay", + "soft_light": "soft_light", + "hard_light": "hard_light", + "vivid_light": "vivid_light", + "linear_light": "linear_light", + "pin_light": "pin_light", + } + + _FACTOR_METHODS = { + "brightness": "brightness", + "contrast": "contrast", + "saturation": "saturation", + "lightness": "lightness", + } + + def __init__(self, image_data: np.ndarray): + self.image_data = self._coerce_image_data(image_data) + + @classmethod + def from_url(cls, url: str, *, timeout: int = 30) -> "LayerImage": + """Create a :class:`LayerImage` from an image URL.""" + + response = requests.get(url, timeout=timeout) + response.raise_for_status() image = Image.open(BytesIO(response.content)) - image_data = convert_uint_to_float(np.asarray(image)) + try: + return cls.from_pillow_image(image) + finally: + image.close() - return LayerImage(image_data) + @classmethod + def from_file(cls, file_path: Union[str, Path]) -> "LayerImage": + """Create a :class:`LayerImage` from a local file.""" - @staticmethod - def from_file(file_path): - image = Image.open(file_path) + with Image.open(file_path) as image: + return cls.from_pillow_image(image) + + @classmethod + def from_pillow_image(cls, image: Image.Image) -> "LayerImage": + """Create a :class:`LayerImage` from a :mod:`PIL` image instance.""" + + if image.mode != "RGB": + image = image.convert("RGB") image_data = convert_uint_to_float(np.asarray(image)) + return cls(image_data) + + @classmethod + def from_array(cls, image_data: np.ndarray) -> "LayerImage": + """Create a :class:`LayerImage` from a numpy array.""" - return LayerImage(image_data) + return cls(image_data) @staticmethod - def from_array(image_data): - return LayerImage(image_data) + def _coerce_image_data(image_data: np.ndarray) -> np.ndarray: + array = np.asarray(image_data) + if array.ndim != 3 or array.shape[2] < 3: + raise ValueError("LayerImage expects an array with shape (H, W, 3+)") - def __init__(self, image_data): - self.image_data = image_data + if array.shape[2] > 3: + array = array[:, :, :3] - def grayscale(self): + if np.issubdtype(array.dtype, np.integer): + array = convert_uint_to_float(array) + else: + array = np.array(array, dtype=np.float32, copy=True) + + return np.clip(array, 0.0, 1.0) + + def grayscale(self) -> "LayerImage": self.image_data = np.dot(self.image_data[..., :3], [ 0.2989, 0.5870, 0.1140]) @@ -42,7 +106,7 @@ def grayscale(self): return self - def darken(self, blend_data, opacity=1.0): + def darken(self, blend_data: Union[str, Sequence[float]], opacity: float = 1.0) -> "LayerImage": blend_data = get_rgb_float_if_hex(blend_data) result = np.minimum(self.image_data, blend_data) @@ -51,7 +115,7 @@ def darken(self, blend_data, opacity=1.0): return self - def multiply(self, blend_data, opacity=1.0): + def multiply(self, blend_data: Union[str, Sequence[float]], opacity: float = 1.0) -> "LayerImage": blend_data = get_rgb_float_if_hex(blend_data) result = self.image_data * blend_data @@ -60,20 +124,20 @@ def multiply(self, blend_data, opacity=1.0): return self - def color_burn(self, blend_data, opacity=1.0): + def color_burn(self, blend_data: Union[str, Sequence[float]], opacity: float = 1.0) -> "LayerImage": blend_data = get_rgb_float_if_hex(blend_data) A = self.image_data B = blend_data - with np.errstate(divide='ignore', invalid='ignore'): + with np.errstate(divide="ignore", invalid="ignore"): result = np.clip(np.where(B > 0, 1 - (1 - A) / B, 0), 0, 1) self.image_data = mix(self.image_data, result, opacity) return self - def linear_burn(self, blend_data, opacity=1.0): + def linear_burn(self, blend_data: Union[str, Sequence[float]], opacity: float = 1.0) -> "LayerImage": blend_data = get_rgb_float_if_hex(blend_data) A = self.image_data @@ -85,7 +149,7 @@ def linear_burn(self, blend_data, opacity=1.0): return self - def lighten(self, blend_data, opacity=1.0): + def lighten(self, blend_data: Union[str, Sequence[float]], opacity: float = 1.0) -> "LayerImage": blend_data = get_rgb_float_if_hex(blend_data) result = np.maximum(self.image_data, blend_data) @@ -94,7 +158,7 @@ def lighten(self, blend_data, opacity=1.0): return self - def screen(self, blend_data, opacity=1.0): + def screen(self, blend_data: Union[str, Sequence[float]], opacity: float = 1.0) -> "LayerImage": blend_data = get_rgb_float_if_hex(blend_data) A = self.image_data @@ -106,20 +170,20 @@ def screen(self, blend_data, opacity=1.0): return self - def color_dodge(self, blend_data, opacity=1.0): + def color_dodge(self, blend_data: Union[str, Sequence[float]], opacity: float = 1.0) -> "LayerImage": blend_data = get_rgb_float_if_hex(blend_data) A = self.image_data B = blend_data - with np.errstate(divide='ignore', invalid='ignore'): + with np.errstate(divide="ignore", invalid="ignore"): result = np.where(B == 1, B, np.clip(A / (1 - B), 0, 1)) self.image_data = mix(self.image_data, result, opacity) return self - def linear_dodge(self, blend_data, opacity=1.0): + def linear_dodge(self, blend_data: Union[str, Sequence[float]], opacity: float = 1.0) -> "LayerImage": blend_data = get_rgb_float_if_hex(blend_data) A = self.image_data @@ -131,7 +195,7 @@ def linear_dodge(self, blend_data, opacity=1.0): return self - def overlay(self, blend_data, opacity=1.0): + def overlay(self, blend_data: Union[str, Sequence[float]], opacity: float = 1.0) -> "LayerImage": blend_data = get_rgb_float_if_hex(blend_data) A = self.image_data @@ -146,7 +210,7 @@ def overlay(self, blend_data, opacity=1.0): return self - def soft_light(self, blend_data, opacity=1.0): + def soft_light(self, blend_data: Union[str, Sequence[float]], opacity: float = 1.0) -> "LayerImage": blend_data = get_rgb_float_if_hex(blend_data) A = self.image_data @@ -161,7 +225,7 @@ def soft_light(self, blend_data, opacity=1.0): return self - def hard_light(self, blend_data, opacity=1.0): + def hard_light(self, blend_data: Union[str, Sequence[float]], opacity: float = 1.0) -> "LayerImage": blend_data = get_rgb_float_if_hex(blend_data) A = self.image_data @@ -176,13 +240,13 @@ def hard_light(self, blend_data, opacity=1.0): return self - def vivid_light(self, blend_data, opacity=1.0): + def vivid_light(self, blend_data: Union[str, Sequence[float]], opacity: float = 1.0) -> "LayerImage": blend_data = get_rgb_float_if_hex(blend_data) A = self.image_data B = blend_data - with np.errstate(divide='ignore', invalid='ignore'): + with np.errstate(divide="ignore", invalid="ignore"): image_color_burn = np.where(B > 0, 1 - (1 - A) / (2 * B), 0) image_color_dodge = np.where(B < 1, A / (2 * (1 - B)), 1) @@ -193,26 +257,26 @@ def vivid_light(self, blend_data, opacity=1.0): return self - def linear_light(self, blend_data, opacity=1.0): + def linear_light(self, blend_data: Union[str, Sequence[float]], opacity: float = 1.0) -> "LayerImage": blend_data = get_rgb_float_if_hex(blend_data) A = self.image_data B = blend_data - with np.errstate(divide='ignore', invalid='ignore'): + with np.errstate(divide="ignore", invalid="ignore"): result = np.clip(A + 2 * B - 1, 0, 1) self.image_data = mix(self.image_data, result, opacity) return self - def pin_light(self, blend_data, opacity=1.0): + def pin_light(self, blend_data: Union[str, Sequence[float]], opacity: float = 1.0) -> "LayerImage": blend_data = get_rgb_float_if_hex(blend_data) A = self.image_data B = blend_data - with np.errstate(divide='ignore', invalid='ignore'): + with np.errstate(divide="ignore", invalid="ignore"): result = np.clip(np.where(A < 2 * B - 1, 2 * B - 1, np.where(A > 2 * B, 2 * B, A)), 0, 1) @@ -220,44 +284,45 @@ def pin_light(self, blend_data, opacity=1.0): return self - def brightness(self, factor): + def brightness(self, factor: float) -> "LayerImage": self.image_data = np.clip(self.image_data * (1 + factor), 0, 1) return self - # Legacy contrast mode - def contrast(self, factor): + def contrast(self, factor: float) -> "LayerImage": self.image_data = np.clip(factor * (self.image_data - 0.5) + 0.5, 0, 1) return self - def hue(self, target_hue): - image_hsv_data = matplotlib.colors.rgb_to_hsv(self.image_data) + def hue(self, target_hue: float) -> "LayerImage": + image_hsv_data = mpl_colors.rgb_to_hsv(self.image_data) image_hsv_data[:, :, 0] = target_hue - self.image_data = matplotlib.colors.hsv_to_rgb(image_hsv_data) + self.image_data = mpl_colors.hsv_to_rgb(image_hsv_data) return self - def saturation(self, factor): - image_hsv_data = matplotlib.colors.rgb_to_hsv(self.image_data) + def saturation(self, factor: float) -> "LayerImage": + image_hsv_data = mpl_colors.rgb_to_hsv(self.image_data) image_hsv_data = np.clip( image_hsv_data + image_hsv_data * [0, factor, 0], 0, 1) - self.image_data = matplotlib.colors.hsv_to_rgb(image_hsv_data) + self.image_data = mpl_colors.hsv_to_rgb(image_hsv_data) return self - def lightness(self, factor): + def lightness(self, factor: float) -> "LayerImage": if factor > 0: self.image_data = self.image_data + \ ((1 - self.image_data) * factor) elif factor < 0: self.image_data = self.image_data + (self.image_data * factor) + self.image_data = np.clip(self.image_data, 0, 1) + return self - def curve(self, channels='rgb', curve_points=[0, 1]): + def curve(self, channels: str = 'rgb', curve_points: Sequence[float] = (0, 1)) -> "LayerImage": r, g, b = split_image_into_channels(self.image_data) if 'r' in channels: @@ -271,93 +336,46 @@ def curve(self, channels='rgb', curve_points=[0, 1]): return self - def clone(self): - return LayerImage.from_array(self.image_data) + def clone(self) -> "LayerImage": + return LayerImage.from_array(self.image_data.copy()) - def get_image_as_array(self): - return self.image_data + def get_image_as_array(self, *, copy: bool = True) -> np.ndarray: + return self.image_data.copy() if copy else self.image_data - def apply_from_json(self, filepath): - with open(filepath, 'r') as content_file: - content = content_file.read() - - json_obj = json.loads(content) - operations = json_obj['operations'] + def apply_from_json(self, filepath: Union[str, Path]) -> "LayerImage": + with open(filepath, 'r', encoding='utf-8') as content_file: + json_obj = json.load(content_file) + operations = json_obj.get('operations', []) for op in operations: - hex_string = op['hex'] if 'hex' in op else None - opacity = op['opacity'] if 'opacity' in op else 1.0 - factor = op['factor'] if 'factor' in op else None - - if op['type'] == 'grayscale': - print('grayscale') + op_type = op.get('type') + if op_type == 'grayscale': self.grayscale() - elif op['type'] == 'darken': - if hex_string is not None: - self.darken(hex_string, opacity) - elif op['type'] == 'multiply': - if hex_string is not None: - self.multiply(hex_string, opacity) - elif op['type'] == 'color_burn': - if hex_string is not None: - self.color_burn(hex_string, opacity) - elif op['type'] == 'linear_burn': - if hex_string is not None: - self.linear_burn(hex_string, opacity) - elif op['type'] == 'lighten': - if hex_string is not None: - self.lighten(hex_string, opacity) - elif op['type'] == 'screen': - if hex_string is not None: - self.screen(hex_string, opacity) - elif op['type'] == 'color_dodge': - if hex_string is not None: - self.color_dodge(hex_string, opacity) - elif op['type'] == 'linear_dodge': - if hex_string is not None: - self.linear_dodge(hex_string, opacity) - elif op['type'] == 'overlay': - if hex_string is not None: - self.overlay(hex_string, opacity) - elif op['type'] == 'soft_light': - if hex_string is not None: - self.soft_light(hex_string, opacity) - elif op['type'] == 'hard_light': - if hex_string is not None: - self.hard_light(hex_string, opacity) - elif op['type'] == 'vivid_light': - if hex_string is not None: - self.vivid_light(hex_string, opacity) - elif op['type'] == 'linear_light': - if hex_string is not None: - self.linear_light(hex_string, opacity) - elif op['type'] == 'pin_light': - if hex_string is not None: - self.pin_light(hex_string, opacity) - elif op['type'] == 'brightness': - if factor is not None: - self.brightness(factor) - elif op['type'] == 'contrast': - if factor is not None: - self.contrast(factor) - elif op['type'] == 'hue': - if 'hue' in op: - self.hue(op['hue']) - elif op['type'] == 'saturation': - if factor is not None: - self.saturation(factor) - elif op['type'] == 'lightness': - if factor is not None: - self.lightness(factor) - elif op['type'] == 'curve': - if 'channels' in op and 'curve_points' in op: - self.curve(op['channels'], op['curve_points']) - else: - pass + elif op_type in self._HEX_METHODS: + hex_string = op.get('hex') + if hex_string is None: + raise ValueError(f"Operation '{op_type}' requires a 'hex' value") + opacity = float(op.get('opacity', 1.0)) + getattr(self, self._HEX_METHODS[op_type])(hex_string, opacity) + elif op_type in self._FACTOR_METHODS: + factor = op.get('factor') + if factor is None: + raise ValueError(f"Operation '{op_type}' requires a 'factor' value") + getattr(self, self._FACTOR_METHODS[op_type])(float(factor)) + elif op_type == 'hue': + if 'hue' not in op: + raise ValueError("Operation 'hue' requires a 'hue' value") + self.hue(float(op['hue'])) + elif op_type == 'curve': + channels = op.get('channels') + curve_points = op.get('curve_points') + if channels is None or curve_points is None: + raise ValueError("Operation 'curve' requires 'channels' and 'curve_points'") + self.curve(str(channels), curve_points) return self - def save(self, filename, quality=75): + def save(self, filename: Union[str, Path], quality: int = 75) -> "LayerImage": pillow_image = Image.fromarray(convert_float_to_uint(self.image_data)) pillow_image.save(filename, quality=quality) diff --git a/layeris/test/test.py b/layeris/test/test.py index 01f4649..1b8b8be 100644 --- a/layeris/test/test.py +++ b/layeris/test/test.py @@ -3,7 +3,7 @@ import numpy as np from pathlib import Path -from layeris.layer_image import LayerImage +from layeris import LayerImage dirname = os.path.dirname(__file__) diff --git a/layeris/utils/channels.py b/layeris/utils/channels.py index 396ffee..241f3bf 100644 --- a/layeris/utils/channels.py +++ b/layeris/utils/channels.py @@ -1,35 +1,35 @@ -""" -Code from https://github.com/lukexyz/CV-Instagram-Filters (MIT License) +"""Channel manipulation utilities. -Because the repository didn't have a packaged version, I've copied the relevant functions +The code is based on https://github.com/lukexyz/CV-Instagram-Filters +and has been lightly adapted for this project. """ +from __future__ import annotations + +from typing import Iterable, Tuple + import numpy as np -def split_image_into_channels(image): - """Look at each image separately""" +def split_image_into_channels(image: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + """Split an RGB image into its three channels.""" + red_channel = image[:, :, 0] green_channel = image[:, :, 1] blue_channel = image[:, :, 2] return red_channel, green_channel, blue_channel -def merge_channels(red, green, blue): - """Merge channels back into an image""" +def merge_channels(red: np.ndarray, green: np.ndarray, blue: np.ndarray) -> np.ndarray: + """Reconstruct an image from its RGB channels.""" + return np.stack([red, green, blue], axis=2) -def channel_adjust(channel, values): - # preserve the original size, so we can reconstruct at the end +def channel_adjust(channel: np.ndarray, values: Iterable[float]) -> np.ndarray: + """Apply a curve defined by ``values`` to a single colour channel.""" + orig_size = channel.shape - # flatten the image into a single array flat_channel = channel.flatten() - - # this magical numpy function takes the values in flat_channel - # and maps it from its range in [0, 1] to its new squeezed and - # stretched range adjusted = np.interp(flat_channel, np.linspace(0, 1, len(values)), values) - - # put back into the original image shape return adjusted.reshape(orig_size) diff --git a/layeris/utils/conversions.py b/layeris/utils/conversions.py index 8ac0c70..c08463d 100644 --- a/layeris/utils/conversions.py +++ b/layeris/utils/conversions.py @@ -1,34 +1,60 @@ -from PIL import Image +"""Conversion helpers for image manipulation.""" + +from __future__ import annotations + +from typing import Iterable, Union + import numpy as np -def convert_uint_to_float(img_data): - return img_data / 255 +ArrayLike = Union[np.ndarray, Iterable[float]] + + +def convert_uint_to_float(img_data: np.ndarray) -> np.ndarray: + """Normalise an unsigned integer RGB array to the ``0.0`` – ``1.0`` range.""" + + return img_data.astype(np.float32) / 255 -def convert_float_to_uint(img_data): - return round_to_uint(img_data * 255) +def convert_float_to_uint(img_data: np.ndarray) -> np.ndarray: + """Convert a normalised float RGB array back to ``uint8``.""" + return round_to_uint(np.clip(img_data, 0.0, 1.0) * 255) -def round_to_uint(img_data): - return np.round(img_data).astype('uint8') +def round_to_uint(img_data: np.ndarray) -> np.ndarray: + """Round a floating point array and cast it to ``uint8``.""" -def hex_to_rgb(hex_string): - return np.array(list(int(hex_string.lstrip('#')[i:i + 2], 16) for i in (0, 2, 4))) + return np.round(img_data).astype("uint8") -def hex_to_rgb_float(hex_string): - return np.array(list((int(hex_string.lstrip('#')[i:i + 2], 16) / 255) for i in (0, 2, 4))) +def hex_to_rgb(hex_string: str) -> np.ndarray: + """Convert a hexadecimal colour string (``#RRGGBB``) to ``uint8`` RGB values.""" + return np.array( + [int(hex_string.lstrip("#")[i : i + 2], 16) for i in (0, 2, 4)], + dtype="uint8", + ) + + +def hex_to_rgb_float(hex_string: str) -> np.ndarray: + """Convert a hexadecimal colour string to normalised float RGB values.""" + + return convert_uint_to_float(hex_to_rgb(hex_string)) + + +def get_rgb_float_if_hex(blend_data: Union[str, ArrayLike]) -> np.ndarray: + """Ensure blend data is represented as a float RGB array.""" -def get_rgb_float_if_hex(blend_data): if isinstance(blend_data, str): return hex_to_rgb_float(blend_data) - return blend_data + array = np.asarray(blend_data, dtype=np.float32) + return np.clip(array, 0.0, 1.0) + +def get_array_from_hex(hex_string: str, height: int, width: int) -> np.ndarray: + """Create a float RGB array of the given size filled with ``hex_string`` colour.""" -def get_array_from_hex(hex_string, height, width): rgb_as_float = hex_to_rgb_float(hex_string) - return np.full((height, width, 3), rgb_as_float) + return np.full((height, width, 3), rgb_as_float, dtype=np.float32) diff --git a/layeris/utils/hsl.py b/layeris/utils/hsl.py index 1ae7373..f54705b 100644 --- a/layeris/utils/hsl.py +++ b/layeris/utils/hsl.py @@ -1,27 +1,31 @@ -import numpy as np +"""RGB and HSL conversion helpers.""" + +from __future__ import annotations -""" -RGB to HSL color conversion +from typing import Sequence -Note that using rgb_to_hsl_arr() and hsl_to_rgb_arr() is very slow compared to matplotlib's colors.rgb_to_hsv() and colors.hsv_to_rgb(). +import numpy as np -You should only use the methods below if you absolutely need HSL color space -""" +def rgb_to_hsl_arr(image_data: np.ndarray) -> np.ndarray: + """Convert an RGB image array to HSL using :func:`rgb_to_hsl`.""" -def rgb_to_hsl_arr(image_data): return np.apply_along_axis(rgb_to_hsl, -1, image_data) -def hsl_to_rgb_arr(image_data): +def hsl_to_rgb_arr(image_data: np.ndarray) -> np.ndarray: + """Convert an HSL image array to RGB using :func:`hsl_to_rgb`.""" + return np.apply_along_axis(hsl_to_rgb, -1, image_data) -def rgb_to_hsl(rgb_as_float): +def rgb_to_hsl(rgb_as_float: Sequence[float]) -> np.ndarray: + """Convert a single RGB colour (``0.0`` – ``1.0``) to HSL.""" + r, g, b = rgb_as_float high = max(r, g, b) low = min(r, g, b) - h, s, v = ((high + low) / 2,) * 3 + h, s, l = ((high + low) / 2,) * 3 if high == low: h = 0.0 @@ -37,13 +41,15 @@ def rgb_to_hsl(rgb_as_float): }[high] h /= 6 - return [h, s, v] + return np.array([h, s, l], dtype=np.float32) + +def hsl_to_rgb(hsl_as_float: Sequence[float]) -> np.ndarray: + """Convert a single HSL colour to RGB (``0.0`` – ``1.0``).""" -def hsl_to_rgb(hsl_as_float): h, s, l = hsl_as_float - def hue_to_rgb(p, q, t): + def hue_to_rgb(p: float, q: float, t: float) -> float: t += 1 if t < 0 else 0 t -= 1 if t > 1 else 0 if t < 1 / 6: @@ -51,11 +57,11 @@ def hue_to_rgb(p, q, t): if t < 1 / 2: return q if t < 2 / 3: - p + (q - p) * (2 / 3 - t) * 6 + return p + (q - p) * (2 / 3 - t) * 6 return p if s == 0: - r, g, b = l, l, l + r = g = b = l else: q = l * (1 + s) if l < 0.5 else l + s - l * s p = 2 * l - q @@ -63,4 +69,4 @@ def hue_to_rgb(p, q, t): g = hue_to_rgb(p, q, h) b = hue_to_rgb(p, q, h - 1 / 3) - return [r, g, b] + return np.array([r, g, b], dtype=np.float32) diff --git a/layeris/utils/layers.py b/layeris/utils/layers.py index 7f06d8c..fae0e64 100644 --- a/layeris/utils/layers.py +++ b/layeris/utils/layers.py @@ -1,4 +1,14 @@ -def mix(base_image_data, blend_data, blend_opacity): +"""Blend mode helpers.""" + +from __future__ import annotations + +import numpy as np + + +def mix(base_image_data: np.ndarray, blend_data: np.ndarray, blend_opacity: float) -> np.ndarray: + """Return a linear blend between ``base_image_data`` and ``blend_data``.""" + + blend_opacity = float(np.clip(blend_opacity, 0.0, 1.0)) if blend_opacity < 1.0: return base_image_data * (1.0 - blend_opacity) + blend_data * blend_opacity diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ecd2201 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,42 @@ +[build-system] +requires = ["hatchling>=1.21.0"] +build-backend = "hatchling.build" + +[project] +name = "layeris" +version = "0.2.0" +description = "An open source image processing library that supports blend modes, curve adjustment, and other adjustments used by graphic designers or photographers." +readme = "README.md" +authors = [{ name = "Ye Joo Park", email = "subwaymatch@gmail.com" }] +license = { file = "LICENSE" } +requires-python = ">=3.9" +dependencies = [ + "numpy>=1.24", + "matplotlib>=3.7", + "pillow>=10.0", + "requests>=2.31", +] +classifiers = [ + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", +] + +[project.urls] +Homepage = "https://github.com/subwaymatch/layer-is-python" + +[project.optional-dependencies] +dev = [ + "pytest>=7.4", +] + +[tool.hatch.build.targets.wheel] +packages = ["layeris"] + +[tool.pytest.ini_options] +addopts = "-ra" +testpaths = ["tests"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 15e0aca..0000000 --- a/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -numpy==1.17.2 -matplotlib==3.1.1 -Pillow==6.2.0 -requests==2.22.0 \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index b1cbeba..0000000 --- a/setup.py +++ /dev/null @@ -1,22 +0,0 @@ -from setuptools import setup, find_packages - -with open("README.md", "r") as fh: - long_description = fh.read() - -setup( - name="layeris", - version="0.1.3", - author="Ye Joo Park", - author_email="subwaymatch@gmail.com", - description="An open source image processing library that supports blend modes, curve adjustment, and other adjustments that graphic designers or photographers frequently use", - long_description=long_description, - long_description_content_type="text/markdown", - url="https://github.com/subwaymatch/layer-is-python", - packages=find_packages(), - classifiers=[ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - ], - python_requires='>=3.6', -) diff --git a/tests/test_conversions.py b/tests/test_conversions.py new file mode 100644 index 0000000..fcb0196 --- /dev/null +++ b/tests/test_conversions.py @@ -0,0 +1,37 @@ +import numpy as np + +from layeris.utils.conversions import ( + convert_float_to_uint, + convert_uint_to_float, + get_rgb_float_if_hex, + hex_to_rgb, + hex_to_rgb_float, +) + + +def test_uint_float_roundtrip(): + uint_image = np.array([[0, 128, 255]], dtype=np.uint8) + float_image = convert_uint_to_float(uint_image) + assert float_image.dtype == np.float32 + np.testing.assert_allclose(float_image, [[0.0, 128 / 255, 1.0]]) + + roundtrip = convert_float_to_uint(float_image) + assert roundtrip.dtype == np.uint8 + np.testing.assert_array_equal(roundtrip, uint_image) + + +def test_hex_conversions(): + rgb_uint = hex_to_rgb("#112233") + np.testing.assert_array_equal(rgb_uint, np.array([17, 34, 51], dtype=np.uint8)) + + rgb_float = hex_to_rgb_float("#112233") + np.testing.assert_allclose(rgb_float, rgb_uint.astype(np.float32) / 255) + + +def test_get_rgb_float_if_hex_accepts_arrays(): + array = np.array([0.2, 0.4, 0.6]) + result = get_rgb_float_if_hex(array) + np.testing.assert_allclose(result, array) + + result_from_hex = get_rgb_float_if_hex("#ff0000") + np.testing.assert_allclose(result_from_hex, np.array([1.0, 0.0, 0.0], dtype=np.float32)) diff --git a/tests/test_layer_image.py b/tests/test_layer_image.py new file mode 100644 index 0000000..1261ff3 --- /dev/null +++ b/tests/test_layer_image.py @@ -0,0 +1,40 @@ +import json + +import numpy as np +import pytest + +from layeris import LayerImage + + +@pytest.fixture +def sample_image(): + return np.zeros((2, 2, 3), dtype=np.uint8) + + +def test_clone_produces_independent_copy(sample_image): + image = LayerImage.from_array(sample_image) + clone = image.clone() + + clone.lightness(0.5) + original = image.get_image_as_array() + modified = clone.get_image_as_array() + + np.testing.assert_array_equal(original, np.zeros_like(original)) + assert not np.allclose(original, modified) + + +def test_apply_from_json(tmp_path, sample_image): + image = LayerImage.from_array(sample_image) + json_data = { + "operations": [ + {"type": "brightness", "factor": 0.2}, + {"type": "saturation", "factor": 0.0}, + ] + } + json_path = tmp_path / "ops.json" + json_path.write_text(json.dumps(json_data), encoding="utf-8") + + image.apply_from_json(json_path) + result = image.get_image_as_array() + + np.testing.assert_allclose(result, np.full_like(result, 0.2))