diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..9b3bfe2
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,25 @@
+name: tests
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Install package with dev dependencies
+ run: pip install -e ".[dev]"
+ - name: Run test suite
+ run: pytest
+ - name: Smoke-test preview generation
+ run: python scripts/generate_previews.py --output-dir /tmp/preview-smoke
diff --git a/README.md b/README.md
index fef99cc..d2cfa17 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,7 @@ This project was a part of the 2019 Open Source Software Competition.
- Straightforward API to manipulate images
- Load a sequence of operations from JSON
- Apply curve adjustments selectively to individual RGB channels
+- Lightweight: depends only on NumPy, Pillow, and Requests
## Requirements
@@ -62,7 +63,7 @@ image = LayerImage.from_file('/path/to/image.jpg')
# From a URL
image = LayerImage.from_url('https://example.com/photo.jpg')
-# From a NumPy array (float64, values in [0, 1], shape HxWx3 or HxWx4)
+# From a NumPy array (float, values in [0, 1], shape HxWx3 or HxWx4)
import numpy as np
data = np.random.random((100, 100, 3))
image = LayerImage.from_array(data)
@@ -81,96 +82,301 @@ image.save('output.jpg', quality=85)
image.save('output.png')
```
+### Apply operations
+
+All operations mutate the image in place and return `self`, so calls can be chained:
+
+```python
+result = (
+ LayerImage.from_file('photo.jpg')
+ .grayscale()
+ .brightness(0.1)
+ .multiply('#3fe28f', opacity=0.7)
+ .curve('rgb', [0, 0.2, 0.8, 1])
+ .save('output.jpg')
+)
+```
+
+Use `clone()` to branch off an independent copy first:
+
+```python
+copy = image.clone()
+copy.darken('#000000') # original is unaffected
+```
+
+Every available operation is shown with a before/after preview in the
+[operation gallery](#operation-gallery) below.
+
+## Operation gallery
+
+Every image below is generated by [`scripts/generate_previews.py`](scripts/generate_previews.py) using layeris itself — the left half is the input, the right half is the result of the snippet above it. Regenerate them all with:
+
+```sh
+python scripts/generate_previews.py
+```
+
+Blend mode descriptions are adapted from [Adobe's blending modes documentation](https://helpx.adobe.com/photoshop/using/blending-modes.html).
+
### Basic adjustments
+#### Grayscale
+
+Convert to grayscale using the ITU-R 601 luminance weights.
+
```python
-# Grayscale
image.grayscale()
+```
+
+
-# Brightness (factor > 0 brightens, factor < 0 darkens)
+#### Brightness
+
+Scale pixel values: a factor above 0 brightens, below 0 darkens.
+
+```python
image.brightness(0.2)
+```
+
+
-# Contrast (factor > 1 increases, 0 < factor < 1 decreases)
+#### Contrast
+
+Expand or compress the range around mid-gray: a factor above 1 increases contrast, between 0 and 1 decreases it.
+
+```python
image.contrast(1.5)
+```
+
+
-# Hue (value in [0, 1])
+#### Hue
+
+Set every pixel's hue to the target value in [0, 1] while keeping saturation and value — a colorize-style adjustment.
+
+```python
image.hue(0.3)
+```
+
+
-# Saturation (factor > 0 increases, factor < 0 decreases; -1 fully desaturates)
+#### Saturation
+
+Scale color intensity: a factor above 0 boosts saturation, below 0 mutes it (-1 fully desaturates).
+
+```python
image.saturation(-0.5)
+```
+
+
-# Lightness (factor > 0 lightens toward white, factor < 0 darkens toward black)
+#### Lightness
+
+Blend toward white (factor above 0) or black (factor below 0).
+
+```python
image.lightness(0.4)
+```
+
+
-# Curve adjustment (apply an S-curve to the RGB channels)
+#### Curve (all channels)
+
+Remap tones through control points spaced evenly across [0, 1] — here an S-curve that deepens shadows and lifts highlights.
+
+```python
image.curve('rgb', [0, 0.1, 0.4, 0.65, 0.9, 1])
+```
+
+
-# Curve on a single channel
+#### Curve (single channel)
+
+The same curve machinery applied to the red channel only.
+
+```python
image.curve('r', [0, 0.2, 0.8, 1])
```
-### Resize
+
+
+#### Resize
+
+Resize with high-quality Lanczos resampling.
+
+```python
+image.resize(width=240, height=160)
+```
+
+
+
+### Blend modes — darken
+
+Blend modes accept a hex color string or a NumPy array, plus an optional `opacity` (0.0–1.0). The darken-group examples are applied to a grayscale base (`grayscale_image = image.clone().grayscale()`), as in the original demo.
+
+#### Darken
+
+Looks at the color information in each channel and selects the base or blend color — whichever is darker — as the result color.
+
+```python
+grayscale_image.darken('#3fe28f')
+```
+
+
+
+#### Multiply
+
+Multiplies the base color by the blend color. The result is always darker; multiplying with white leaves the color unchanged.
+
+```python
+grayscale_image.multiply('#3fe28f')
+```
+
+
+
+#### Color Burn
+
+Darkens the base color to reflect the blend color by increasing the contrast between the two. Blending with white produces no change.
```python
-image.resize(width=800, height=600)
+grayscale_image.color_burn('#7fe3f8')
```
-### Blend modes
+
-All blend mode methods accept a hex colour string **or** a NumPy array, plus an optional `opacity` parameter (0.0–1.0).
+#### Linear Burn
-#### Darken group
+Darkens the base color to reflect the blend color by decreasing the brightness. Blending with white produces no change.
```python
-image.darken('#3fe28f')
-image.multiply('#3fe28f', opacity=0.8)
-image.color_burn('#7fe3f8')
-image.linear_burn('#e1a8ff')
+grayscale_image.linear_burn('#e1a8ff')
```
-#### Lighten group
+
+
+### Blend modes — lighten
+
+#### Lighten
+
+Selects the base or blend color — whichever is lighter — as the result color.
```python
image.lighten('#ff3ce1')
+```
+
+
+
+#### Screen
+
+Multiplies the inverse of the blend and base colors. The result is always lighter — like projecting multiple slides on top of each other.
+
+```python
image.screen('#e633ba')
+```
+
+
+
+#### Color Dodge
+
+Brightens the base color to reflect the blend color by decreasing contrast between the two. Blending with black produces no change.
+
+```python
image.color_dodge('#490cc7')
+```
+
+
+
+#### Linear Dodge
+
+Brightens the base color to reflect the blend color by increasing the brightness. Blending with black produces no change.
+
+```python
image.linear_dodge('#490cc7')
```
-#### Contrast group
+
+
+### Blend modes — contrast
+
+#### Overlay
+
+Multiplies or screens the colors depending on the base color, preserving its highlights and shadows.
```python
image.overlay('#ffb956')
+```
+
+
+
+#### Soft Light
+
+Darkens or lightens depending on the blend color — like shining a diffused spotlight on the image.
+
+```python
image.soft_light('#ff3cbc')
+```
+
+
+
+#### Hard Light
+
+Multiplies or screens depending on the blend color — like shining a harsh spotlight on the image.
+
+```python
image.hard_light('#df5dff')
+```
+
+
+
+#### Vivid Light
+
+Burns or dodges by increasing or decreasing the contrast, depending on the blend color.
+
+```python
image.vivid_light('#ac5b7f')
+```
+
+
+
+#### Linear Light
+
+Burns or dodges by decreasing or increasing the brightness, depending on the blend color.
+
+```python
image.linear_light('#9fa500')
-image.pin_light('#005546')
```
-### Method chaining
+
+
+#### Pin Light
-All operations return `self`, so calls can be chained:
+Replaces colors depending on the blend color — useful for adding special effects to an image.
```python
-result = (
- LayerImage.from_file('photo.jpg')
- .grayscale()
- .brightness(0.1)
- .multiply('#3fe28f', opacity=0.7)
- .curve('rgb', [0, 0.2, 0.8, 1])
- .save('output.jpg')
-)
+image.pin_light('#005546')
```
-### Clone
+
+
+### Putting it together
+
+#### Method chaining
+
+Every operation returns the LayerImage itself, so a whole look can be built as one chained pipeline.
```python
-# Create an independent copy before branching
-copy = image.clone()
-copy.darken('#000000') # original is unaffected
+(image.grayscale()
+ .brightness(0.1)
+ .multiply('#3fe28f', opacity=0.7)
+ .curve('rgb', [0, 0.1, 0.4, 0.65, 0.9, 1]))
```
+
+
+
+---
+
+## More usage
+
### Get image data as a NumPy array
```python
@@ -214,12 +420,11 @@ Supported operation types: `grayscale`, `resize`, `brightness`, `contrast`, `hue
Install development dependencies and run the test suite with pytest:
```sh
-# Using pip (editable install)
-pip install -e .
-pip install pytest pytest-cov
+# Using pip (editable install with dev extras)
+pip install -e ".[dev]"
pytest
-# Using hatch (recommended)
+# Using hatch
hatch run test
# With coverage report
@@ -261,6 +466,17 @@ All results are clamped to `[0, 1]`.
---
+## Docs site (GitHub Pages)
+
+The preview generator also produces a standalone gallery page at
+[`docs/index.html`](docs/index.html). To publish it with GitHub Pages, enable
+**Settings → Pages → Deploy from a branch** and select the `main` branch with
+the `/docs` folder — no build step required. The page layout lives in
+[`scripts/generate_previews.py`](scripts/generate_previews.py) and can be
+customised freely.
+
+---
+
## Roadmap
- Imitate Photoshop's auto brightness & auto contrast features
diff --git a/docs/index.html b/docs/index.html
new file mode 100644
index 0000000..aabbd4f
--- /dev/null
+++ b/docs/index.html
@@ -0,0 +1,259 @@
+
+
+
+
+
+Layer.is — operation gallery
+
+
+
+
+
Layer.is
+
Photoshop-style blend modes, adjustments and curves for Python.
+ Each preview shows the input on the left and the result of the snippet
+ on the right — all generated with
+ layeris itself.
+
+
+
+
Basic adjustments
+
+
+
Grayscale
+
Convert to grayscale using the ITU-R 601 luminance weights.
Photoshop-style blend modes, adjustments and curves for Python.
+ Each preview shows the input on the left and the result of the snippet
+ on the right — all generated with
+ layeris itself.
+
+
+{body}
+
+
+
+
+"""
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--source", type=Path, default=DEFAULT_SOURCE,
+ help="base photo used for all previews",
+ )
+ parser.add_argument(
+ "--output-dir", type=Path, default=REPO_ROOT / "docs",
+ help="directory receiving previews/ and index.html",
+ )
+ parser.add_argument(
+ "--markdown", action="store_true",
+ help="print the README gallery markdown to stdout",
+ )
+ args = parser.parse_args()
+
+ print(f"Generating previews from {args.source} into {args.output_dir}")
+ generate_previews(args.source, args.output_dir)
+
+ index_path = args.output_dir / "index.html"
+ index_path.write_text(render_html())
+ print(f" wrote {index_path}")
+
+ if args.markdown:
+ print("\n" + render_markdown())
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/layeris/__about__.py b/src/layeris/__about__.py
index f9aa3e1..6a9beea 100644
--- a/src/layeris/__about__.py
+++ b/src/layeris/__about__.py
@@ -1 +1 @@
-__version__ = "0.3.2"
+__version__ = "0.4.0"
diff --git a/src/layeris/blend/mix.py b/src/layeris/blend/mix.py
index f63b63d..fb7d3cf 100644
--- a/src/layeris/blend/mix.py
+++ b/src/layeris/blend/mix.py
@@ -7,6 +7,9 @@ def mix(
blend_data: NDArray[np.float64],
blend_opacity: float,
) -> NDArray[np.float64]:
+ """Linearly interpolate between base and blended data by *blend_opacity*."""
+ if not 0.0 <= blend_opacity <= 1.0:
+ raise ValueError(f"opacity must be between 0.0 and 1.0, got {blend_opacity}")
if blend_opacity < 1.0:
return base_image_data * (1.0 - blend_opacity) + blend_data * blend_opacity
return blend_data
diff --git a/src/layeris/channels/operations.py b/src/layeris/channels/operations.py
index 90ddf4b..33663b2 100644
--- a/src/layeris/channels/operations.py
+++ b/src/layeris/channels/operations.py
@@ -30,8 +30,13 @@ def merge_channels(
def channel_adjust(
channel: NDArray[np.float64], values: list[float]
) -> NDArray[np.float64]:
- """Apply a curve adjustment to a single channel using linear interpolation."""
- orig_size = channel.shape
- flat_channel = channel.flatten()
- adjusted = np.interp(flat_channel, np.linspace(0, 1, len(values)), values)
- return adjusted.reshape(orig_size)
+ """Apply a curve adjustment to a single channel using linear interpolation.
+
+ *values* are the curve's control points, spaced evenly across the [0, 1]
+ input range; at least two points are required.
+ """
+ if len(values) < 2:
+ raise ValueError(
+ f"curve requires at least two control points, got {len(values)}"
+ )
+ return np.interp(channel, np.linspace(0, 1, len(values)), values)
diff --git a/src/layeris/color/__init__.py b/src/layeris/color/__init__.py
index 82037d2..dadbf97 100644
--- a/src/layeris/color/__init__.py
+++ b/src/layeris/color/__init__.py
@@ -1,5 +1,5 @@
"""
-Color conversion utilities (RGB↔float, hex parsing, HSL).
+Color conversion utilities (RGB↔float, hex parsing, HSV, HSL).
"""
from .conversions import (
@@ -12,6 +12,7 @@
round_to_uint,
)
from .hsl import hsl_to_rgb, hsl_to_rgb_arr, rgb_to_hsl, rgb_to_hsl_arr
+from .hsv import hsv_to_rgb, rgb_to_hsv
__all__ = [
"convert_float_to_uint",
@@ -25,4 +26,6 @@
"hsl_to_rgb_arr",
"rgb_to_hsl",
"rgb_to_hsl_arr",
+ "hsv_to_rgb",
+ "rgb_to_hsv",
]
diff --git a/src/layeris/color/conversions.py b/src/layeris/color/conversions.py
index d8cd6ae..9584dbb 100644
--- a/src/layeris/color/conversions.py
+++ b/src/layeris/color/conversions.py
@@ -1,6 +1,11 @@
+import re
+
import numpy as np
from numpy.typing import NDArray
+# Matches '#rgb', 'rgb', '#rrggbb' or 'rrggbb'.
+_HEX_COLOR_RE = re.compile(r"\A#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})\Z")
+
def convert_uint_to_float(img_data: NDArray[np.uint8]) -> NDArray[np.float64]:
return img_data / 255
@@ -14,14 +19,27 @@ def round_to_uint(img_data: NDArray[np.float64]) -> NDArray[np.uint8]:
return np.round(img_data).astype("uint8")
+def _parse_hex(hex_string: str) -> list[int]:
+ match = _HEX_COLOR_RE.match(hex_string)
+ if match is None:
+ raise ValueError(
+ f"Invalid hex colour {hex_string!r}: expected '#rgb' or '#rrggbb' "
+ "(with or without the leading '#')"
+ )
+ digits = match.group(1)
+ if len(digits) == 3:
+ digits = "".join(ch * 2 for ch in digits)
+ return [int(digits[i : i + 2], 16) for i in (0, 2, 4)]
+
+
def hex_to_rgb(hex_string: str) -> NDArray[np.int_]:
- return np.array(list(int(hex_string.lstrip("#")[i : i + 2], 16) for i in (0, 2, 4)))
+ """Parse a hex colour string into an integer RGB array in [0, 255]."""
+ return np.array(_parse_hex(hex_string))
def hex_to_rgb_float(hex_string: str) -> NDArray[np.float64]:
- return np.array(
- list((int(hex_string.lstrip("#")[i : i + 2], 16) / 255) for i in (0, 2, 4))
- )
+ """Parse a hex colour string into a float RGB array in [0, 1]."""
+ return hex_to_rgb(hex_string) / 255
def get_rgb_float_if_hex(blend_data: str | NDArray[np.float64]) -> NDArray[np.float64]:
diff --git a/src/layeris/color/hsv.py b/src/layeris/color/hsv.py
new file mode 100644
index 0000000..6f7ae74
--- /dev/null
+++ b/src/layeris/color/hsv.py
@@ -0,0 +1,69 @@
+"""
+Vectorised RGB ↔ HSV conversion.
+
+These functions implement the same algorithm as matplotlib.colors.rgb_to_hsv
+and matplotlib.colors.hsv_to_rgb (matplotlib was previously a dependency of
+this package for these two functions alone) and produce identical results
+for float64 inputs in [0, 1].
+"""
+
+import numpy as np
+from numpy.typing import NDArray
+
+
+def rgb_to_hsv(rgb: NDArray[np.float64]) -> NDArray[np.float64]:
+ """Convert an (..., 3) RGB array with values in [0, 1] to HSV."""
+ rgb = np.asarray(rgb, dtype=np.float64)
+ if rgb.shape[-1] != 3:
+ raise ValueError(f"last dimension must be 3 (RGB), got shape {rgb.shape}")
+
+ out = np.zeros_like(rgb)
+ value = rgb.max(-1)
+ delta = value - rgb.min(-1)
+
+ saturation = np.zeros_like(value)
+ positive = value > 0
+ saturation[positive] = delta[positive] / value[positive]
+
+ chromatic = delta > 0
+ red, green, blue = rgb[..., 0], rgb[..., 1], rgb[..., 2]
+
+ idx = (red == value) & chromatic
+ out[idx, 0] = (green[idx] - blue[idx]) / delta[idx]
+ idx = (green == value) & chromatic
+ out[idx, 0] = 2.0 + (blue[idx] - red[idx]) / delta[idx]
+ idx = (blue == value) & chromatic
+ out[idx, 0] = 4.0 + (red[idx] - green[idx]) / delta[idx]
+
+ out[..., 0] = (out[..., 0] / 6.0) % 1.0
+ out[..., 1] = saturation
+ out[..., 2] = value
+ return out
+
+
+def hsv_to_rgb(hsv: NDArray[np.float64]) -> NDArray[np.float64]:
+ """Convert an (..., 3) HSV array with values in [0, 1] to RGB."""
+ hsv = np.asarray(hsv, dtype=np.float64)
+ if hsv.shape[-1] != 3:
+ raise ValueError(f"last dimension must be 3 (HSV), got shape {hsv.shape}")
+
+ h, s, v = hsv[..., 0], hsv[..., 1], hsv[..., 2]
+
+ sector = (h * 6.0).astype(int)
+ fraction = (h * 6.0) - sector
+ sector %= 6
+
+ p = v * (1.0 - s)
+ q = v * (1.0 - s * fraction)
+ t = v * (1.0 - s * (1.0 - fraction))
+
+ r = np.choose(sector, [v, q, p, p, t, v])
+ g = np.choose(sector, [t, v, v, q, p, p])
+ b = np.choose(sector, [p, p, t, v, v, q])
+
+ gray = s == 0
+ r = np.where(gray, v, r)
+ g = np.where(gray, v, g)
+ b = np.where(gray, v, b)
+
+ return np.stack([r, g, b], axis=-1)
diff --git a/src/layeris/layer_image.py b/src/layeris/layer_image.py
index dac8d79..06686b5 100644
--- a/src/layeris/layer_image.py
+++ b/src/layeris/layer_image.py
@@ -5,11 +5,10 @@
from pathlib import Path
from typing import Any
-import matplotlib.colors
import numpy as np
import requests
from numpy.typing import NDArray
-from PIL import Image
+from PIL import Image, ImageOps
from .channels import channel_adjust, merge_channels, split_image_into_channels
from .color.conversions import (
@@ -17,6 +16,7 @@
convert_uint_to_float,
get_rgb_float_if_hex,
)
+from .color.hsv import hsv_to_rgb, rgb_to_hsv
from .blend import mix
# Blend mode operation names that accept a hex colour and opacity parameter.
@@ -46,8 +46,14 @@ class LayerImage:
# ------------------------------------------------------------------
@staticmethod
- def from_url(url: str) -> LayerImage:
- response = requests.get(url)
+ def from_url(url: str, timeout: float = 30.0) -> LayerImage:
+ """Download an image over HTTP(S) and wrap it in a LayerImage.
+
+ Raises requests.HTTPError for non-2xx responses instead of failing
+ later with a cryptic image-decoding error.
+ """
+ response = requests.get(url, timeout=timeout)
+ response.raise_for_status()
image = Image.open(BytesIO(response.content))
image = LayerImage._normalize_pil_image(image)
return LayerImage(convert_uint_to_float(np.asarray(image)))
@@ -60,6 +66,7 @@ def from_file(file_path: str | Path) -> LayerImage:
@staticmethod
def from_array(image_data: NDArray[np.float64]) -> LayerImage:
+ """Wrap a float array of shape (H, W, 3|4) with values in [0, 1]."""
return LayerImage(image_data)
# ------------------------------------------------------------------
@@ -69,6 +76,8 @@ def from_array(image_data: NDArray[np.float64]) -> LayerImage:
@staticmethod
def _normalize_pil_image(image: Image.Image) -> Image.Image:
"""Convert any PIL image mode to RGB or RGBA for consistent processing."""
+ # Apply EXIF orientation so photos from cameras/phones load upright.
+ image = ImageOps.exif_transpose(image)
if image.mode == "RGBA":
return image
if image.mode == "RGB":
@@ -86,8 +95,20 @@ def _normalize_pil_image(image: Image.Image) -> Image.Image:
# ------------------------------------------------------------------
def __init__(self, image_data: NDArray[np.float64]) -> None:
- # image_data is always a float32/float64 array in [0, 1]
- # shape is (H, W, 3) for RGB or (H, W, 4) for RGBA
+ # image_data is always a float array in [0, 1],
+ # shape (H, W, 3) for RGB or (H, W, 4) for RGBA
+ image_data = np.asarray(image_data)
+ if not np.issubdtype(image_data.dtype, np.floating):
+ raise TypeError(
+ f"image_data must be a float array with values in [0, 1], "
+ f"got dtype {image_data.dtype}. Use LayerImage.from_file() / "
+ "from_url() to load 8-bit images, or divide the array by 255."
+ )
+ if image_data.ndim != 3 or image_data.shape[2] not in (3, 4):
+ raise ValueError(
+ "image_data must have shape (height, width, 3) for RGB or "
+ f"(height, width, 4) for RGBA, got {image_data.shape}"
+ )
self.image_data: NDArray[np.float64] = image_data.astype(np.float64)
# ------------------------------------------------------------------
@@ -120,10 +141,12 @@ def _reattach_alpha(self, rgb_data: NDArray[np.float64]) -> NDArray[np.float64]:
def resize(self, width: int, height: int) -> LayerImage:
"""Resize the image to (width, height) using high-quality Lanczos resampling."""
+ if width < 1 or height < 1:
+ raise ValueError(f"width and height must be >= 1, got ({width}, {height})")
uint_data = convert_float_to_uint(self.image_data)
mode = "RGBA" if self._has_alpha() else "RGB"
pil_image = Image.fromarray(uint_data, mode=mode)
- pil_image = pil_image.resize((width, height), Image.LANCZOS)
+ pil_image = pil_image.resize((width, height), Image.Resampling.LANCZOS)
self.image_data = convert_uint_to_float(np.asarray(pil_image)).astype(
np.float64
)
@@ -150,15 +173,17 @@ def contrast(self, factor: float) -> LayerImage:
return self
def hue(self, target_hue: float) -> LayerImage:
- hsv = matplotlib.colors.rgb_to_hsv(self._rgb())
+ """Set every pixel's hue to *target_hue* (in [0, 1]), keeping
+ saturation and value — a colorize-style adjustment."""
+ hsv = rgb_to_hsv(self._rgb())
hsv[:, :, 0] = target_hue
- self.image_data = self._reattach_alpha(matplotlib.colors.hsv_to_rgb(hsv))
+ self.image_data = self._reattach_alpha(hsv_to_rgb(hsv))
return self
def saturation(self, factor: float) -> LayerImage:
- hsv = matplotlib.colors.rgb_to_hsv(self._rgb())
- hsv = np.clip(hsv + hsv * [0, factor, 0], 0, 1)
- self.image_data = self._reattach_alpha(matplotlib.colors.hsv_to_rgb(hsv))
+ hsv = rgb_to_hsv(self._rgb())
+ hsv[:, :, 1] = np.clip(hsv[:, :, 1] * (1 + factor), 0, 1)
+ self.image_data = self._reattach_alpha(hsv_to_rgb(hsv))
return self
def lightness(self, factor: float) -> LayerImage:
@@ -175,6 +200,11 @@ def lightness(self, factor: float) -> LayerImage:
def curve(
self, channels: str = "rgb", curve_points: list[float] | None = None
) -> LayerImage:
+ if not channels or set(channels) - set("rgb"):
+ raise ValueError(
+ f"channels must be a non-empty combination of 'r', 'g' and 'b' "
+ f"(e.g. 'rgb' or 'rg'), got {channels!r}"
+ )
if curve_points is None:
curve_points = [0, 1]
r, g, b = split_image_into_channels(self._rgb())
@@ -352,8 +382,14 @@ def clone(self) -> LayerImage:
return LayerImage.from_array(self.image_data.copy())
def get_image_as_array(self) -> NDArray[np.float64]:
+ """Return the underlying float64 array (not a copy)."""
return self.image_data
+ def __repr__(self) -> str:
+ height, width = self.image_data.shape[:2]
+ mode = "RGBA" if self._has_alpha() else "RGB"
+ return f""
+
def _to_pil_image(self) -> Image.Image:
"""Build a PIL image from current float data for rendering/saving paths."""
uint_data = convert_float_to_uint(self.image_data)
diff --git a/tests/test_layer_image.py b/tests/test_layer_image.py
index b69014d..64ebedc 100644
--- a/tests/test_layer_image.py
+++ b/tests/test_layer_image.py
@@ -5,6 +5,7 @@
from io import BytesIO
import numpy as np
+import pytest
from PIL import Image
from layeris import LayerImage
@@ -58,6 +59,16 @@ def test_from_file_string_path(self) -> None:
img = LayerImage.from_file(str(TEST_IMAGES_DIR / "office.jpg"))
assert img.get_image_as_array().ndim == 3
+ def test_from_array_integer_dtype_raises(self) -> None:
+ data = np.zeros((4, 4, 3), dtype=np.uint8)
+ with pytest.raises(TypeError, match="float"):
+ LayerImage.from_array(data)
+
+ @pytest.mark.parametrize("shape", [(4, 4), (4, 4, 2), (4, 4, 5), (2, 4, 4, 3)])
+ def test_from_array_bad_shape_raises(self, shape: tuple[int, ...]) -> None:
+ with pytest.raises(ValueError, match="shape"):
+ LayerImage.from_array(np.zeros(shape, dtype=np.float64))
+
# ---------------------------------------------------------------------------
# Alpha helpers
@@ -111,6 +122,17 @@ def test_resize_returns_self(self, gray_image: LayerImage) -> None:
result = gray_image.resize(2, 2)
assert result is gray_image
+ def test_resize_rgba_preserves_alpha_channel(self, rgba_image: LayerImage) -> None:
+ rgba_image.resize(2, 2)
+ assert rgba_image.get_image_as_array().shape == (2, 2, 4)
+
+ @pytest.mark.parametrize("size", [(0, 10), (10, 0), (-1, 10)])
+ def test_resize_invalid_size_raises(
+ self, gray_image: LayerImage, size: tuple[int, int]
+ ) -> None:
+ with pytest.raises(ValueError, match="width and height"):
+ gray_image.resize(*size)
+
class TestNotebookDisplay:
def test_repr_png_returns_png_bytes(self) -> None:
@@ -288,6 +310,13 @@ def test_single_channel(self) -> None:
np.testing.assert_allclose(arr[0, 0, 1], 0.5) # green unchanged
np.testing.assert_allclose(arr[0, 0, 2], 0.5) # blue unchanged
+ @pytest.mark.parametrize("channels", ["", "x", "rgba", "rx"])
+ def test_invalid_channels_raises(
+ self, sample_image: LayerImage, channels: str
+ ) -> None:
+ with pytest.raises(ValueError, match="channels"):
+ sample_image.curve(channels, [0.0, 1.0])
+
# ---------------------------------------------------------------------------
# Blend modes — darken group
@@ -315,6 +344,15 @@ def test_opacity_blends(self) -> None:
# blend result = min(0.6, 0) = 0; mixed at 50%: 0.6*0.5 + 0*0.5 = 0.3
np.testing.assert_allclose(img.get_image_as_array(), 0.3)
+ def test_shorthand_hex_accepted(self) -> None:
+ img = LayerImage.from_array(np.full((1, 1, 3), 0.6))
+ img.darken("#000")
+ np.testing.assert_allclose(img.get_image_as_array(), 0.0)
+
+ def test_invalid_opacity_raises(self, sample_image: LayerImage) -> None:
+ with pytest.raises(ValueError, match="opacity"):
+ sample_image.darken("#000000", opacity=1.5)
+
class TestMultiply:
def test_multiply_by_white_no_change(self, sample_image: LayerImage) -> None:
@@ -564,6 +602,14 @@ def test_shape_rgb(self, sample_image: LayerImage) -> None:
assert arr.shape[2] == 3
+class TestRepr:
+ def test_repr_rgb(self, sample_image: LayerImage) -> None:
+ assert repr(sample_image) == ""
+
+ def test_repr_rgba(self, rgba_image: LayerImage) -> None:
+ assert repr(rgba_image) == ""
+
+
# ---------------------------------------------------------------------------
# Method chaining
# ---------------------------------------------------------------------------
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 63c856b..c0cf05f 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -14,6 +14,7 @@
round_to_uint,
)
from layeris.color.hsl import hsl_to_rgb, hsl_to_rgb_arr, rgb_to_hsl, rgb_to_hsl_arr
+from layeris.color.hsv import hsv_to_rgb, rgb_to_hsv
from layeris.blend import mix
@@ -105,6 +106,20 @@ def test_get_array_from_hex(self) -> None:
assert result.shape == (3, 4, 3)
np.testing.assert_allclose(result[0, 0], [1.0, 0.0, 0.0])
+ def test_hex_shorthand_three_digits(self) -> None:
+ np.testing.assert_array_equal(hex_to_rgb("#f80"), [255, 136, 0])
+ np.testing.assert_array_equal(hex_to_rgb("fff"), [255, 255, 255])
+
+ def test_hex_uppercase(self) -> None:
+ np.testing.assert_array_equal(hex_to_rgb("#FF00Aa"), [255, 0, 170])
+
+ @pytest.mark.parametrize(
+ "bad", ["", "#", "#ff", "#ffff", "#fffff", "#fffffff", "#ggg", "ff 000", "red"]
+ )
+ def test_invalid_hex_raises(self, bad: str) -> None:
+ with pytest.raises(ValueError, match="hex colour"):
+ hex_to_rgb(bad)
+
# ---------------------------------------------------------------------------
# layeris.utils.channels
@@ -152,6 +167,15 @@ def test_channel_adjust_clamp_bright(self) -> None:
result = channel_adjust(channel, [0.0, 2.0])
np.testing.assert_allclose(result[0, 0], 1.0)
+ def test_channel_adjust_preserves_shape(self) -> None:
+ channel = np.random.default_rng(1).random((5, 7))
+ result = channel_adjust(channel, [0.0, 0.3, 1.0])
+ assert result.shape == (5, 7)
+
+ def test_channel_adjust_too_few_points_raises(self) -> None:
+ with pytest.raises(ValueError, match="at least two"):
+ channel_adjust(np.array([[0.5]]), [1.0])
+
# ---------------------------------------------------------------------------
# layeris.utils.layers
@@ -177,6 +201,12 @@ def test_half_opacity(self) -> None:
result = mix(base, blend, 0.5)
np.testing.assert_allclose(result, [0.5, 0.5, 0.5])
+ @pytest.mark.parametrize("bad", [-0.1, 1.1, 2.0])
+ def test_out_of_range_opacity_raises(self, bad: float) -> None:
+ base = np.array([0.5, 0.5, 0.5])
+ with pytest.raises(ValueError, match="opacity"):
+ mix(base, base, bad)
+
# ---------------------------------------------------------------------------
# layeris.utils.hsl
@@ -230,3 +260,48 @@ def test_hue_to_rgb_branch_coverage(self) -> None:
assert rgb[0] < 0.1
assert rgb[1] > 0.9
assert rgb[2] > 0.9
+
+
+# ---------------------------------------------------------------------------
+# layeris.color.hsv
+# ---------------------------------------------------------------------------
+
+
+class TestHsvConversions:
+ @pytest.mark.parametrize(
+ "rgb,hsv",
+ [
+ ([1.0, 0.0, 0.0], [0.0, 1.0, 1.0]), # red
+ ([0.0, 1.0, 0.0], [1 / 3, 1.0, 1.0]), # green
+ ([0.0, 0.0, 1.0], [2 / 3, 1.0, 1.0]), # blue
+ ([1.0, 1.0, 0.0], [1 / 6, 1.0, 1.0]), # yellow
+ ([0.0, 1.0, 1.0], [1 / 2, 1.0, 1.0]), # cyan
+ ([1.0, 0.0, 1.0], [5 / 6, 1.0, 1.0]), # magenta
+ ([0.0, 0.0, 0.0], [0.0, 0.0, 0.0]), # black
+ ([1.0, 1.0, 1.0], [0.0, 0.0, 1.0]), # white
+ ([0.5, 0.5, 0.5], [0.0, 0.0, 0.5]), # gray
+ ],
+ )
+ def test_known_values(self, rgb: list[float], hsv: list[float]) -> None:
+ np.testing.assert_allclose(rgb_to_hsv(np.array([rgb]))[0], hsv, atol=1e-12)
+ np.testing.assert_allclose(hsv_to_rgb(np.array([hsv]))[0], rgb, atol=1e-12)
+
+ def test_roundtrip_random(self) -> None:
+ rgb = np.random.default_rng(7).random((16, 16, 3))
+ np.testing.assert_allclose(hsv_to_rgb(rgb_to_hsv(rgb)), rgb, atol=1e-12)
+
+ def test_hue_wraps_at_one(self) -> None:
+ # h=1.0 is the same colour as h=0.0 (red)
+ red_at_one = hsv_to_rgb(np.array([[1.0, 1.0, 1.0]]))
+ np.testing.assert_allclose(red_at_one[0], [1.0, 0.0, 0.0], atol=1e-12)
+
+ def test_output_shape_matches_input(self) -> None:
+ data = np.random.default_rng(3).random((5, 9, 3))
+ assert rgb_to_hsv(data).shape == (5, 9, 3)
+ assert hsv_to_rgb(data).shape == (5, 9, 3)
+
+ def test_wrong_last_dimension_raises(self) -> None:
+ with pytest.raises(ValueError, match="last dimension"):
+ rgb_to_hsv(np.zeros((4, 4, 4)))
+ with pytest.raises(ValueError, match="last dimension"):
+ hsv_to_rgb(np.zeros((4, 4, 2)))