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() +``` + +![grayscale](docs/previews/grayscale.jpg) -# Brightness (factor > 0 brightens, factor < 0 darkens) +#### Brightness + +Scale pixel values: a factor above 0 brightens, below 0 darkens. + +```python image.brightness(0.2) +``` + +![brightness](docs/previews/brightness.jpg) -# 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) +``` + +![contrast](docs/previews/contrast.jpg) -# 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) +``` + +![hue](docs/previews/hue.jpg) -# 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) +``` + +![saturation](docs/previews/saturation.jpg) -# 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) +``` + +![lightness](docs/previews/lightness.jpg) -# 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_rgb](docs/previews/curve_rgb.jpg) -# 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 +![curve_red](docs/previews/curve_red.jpg) + +#### Resize + +Resize with high-quality Lanczos resampling. + +```python +image.resize(width=240, height=160) +``` + +![resize](docs/previews/resize.jpg) + +### 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') +``` + +![darken](docs/previews/darken.jpg) + +#### 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') +``` + +![multiply](docs/previews/multiply.jpg) + +#### 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 +![color_burn](docs/previews/color_burn.jpg) -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 +![linear_burn](docs/previews/linear_burn.jpg) + +### Blend modes — lighten + +#### Lighten + +Selects the base or blend color — whichever is lighter — as the result color. ```python image.lighten('#ff3ce1') +``` + +![lighten](docs/previews/lighten.jpg) + +#### 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') +``` + +![screen](docs/previews/screen.jpg) + +#### 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') +``` + +![color_dodge](docs/previews/color_dodge.jpg) + +#### 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 +![linear_dodge](docs/previews/linear_dodge.jpg) + +### Blend modes — contrast + +#### Overlay + +Multiplies or screens the colors depending on the base color, preserving its highlights and shadows. ```python image.overlay('#ffb956') +``` + +![overlay](docs/previews/overlay.jpg) + +#### Soft Light + +Darkens or lightens depending on the blend color — like shining a diffused spotlight on the image. + +```python image.soft_light('#ff3cbc') +``` + +![soft_light](docs/previews/soft_light.jpg) + +#### Hard Light + +Multiplies or screens depending on the blend color — like shining a harsh spotlight on the image. + +```python image.hard_light('#df5dff') +``` + +![hard_light](docs/previews/hard_light.jpg) + +#### Vivid Light + +Burns or dodges by increasing or decreasing the contrast, depending on the blend color. + +```python image.vivid_light('#ac5b7f') +``` + +![vivid_light](docs/previews/vivid_light.jpg) + +#### 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 +![linear_light](docs/previews/linear_light.jpg) + +#### 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 +![pin_light](docs/previews/pin_light.jpg) + +### 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])) ``` +![chaining](docs/previews/chaining.jpg) + + +--- + +## 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.

+
image.grayscale()
+
+ Before/after preview of Grayscale +
+
+
+

Brightness

+

Scale pixel values: a factor above 0 brightens, below 0 darkens.

+
image.brightness(0.2)
+
+ Before/after preview of Brightness +
+
+
+

Contrast

+

Expand or compress the range around mid-gray: a factor above 1 increases contrast, between 0 and 1 decreases it.

+
image.contrast(1.5)
+
+ Before/after preview of Contrast +
+
+
+

Hue

+

Set every pixel's hue to the target value in [0, 1] while keeping saturation and value — a colorize-style adjustment.

+
image.hue(0.3)
+
+ Before/after preview of Hue +
+
+
+

Saturation

+

Scale color intensity: a factor above 0 boosts saturation, below 0 mutes it (-1 fully desaturates).

+
image.saturation(-0.5)
+
+ Before/after preview of Saturation +
+
+
+

Lightness

+

Blend toward white (factor above 0) or black (factor below 0).

+
image.lightness(0.4)
+
+ Before/after preview of Lightness +
+
+
+

Curve (all channels)

+

Remap tones through control points spaced evenly across [0, 1] — here an S-curve that deepens shadows and lifts highlights.

+
image.curve('rgb', [0, 0.1, 0.4, 0.65, 0.9, 1])
+
+ Before/after preview of Curve (all channels) +
+
+
+

Curve (single channel)

+

The same curve machinery applied to the red channel only.

+
image.curve('r', [0, 0.2, 0.8, 1])
+
+ Before/after preview of Curve (single channel) +
+
+
+

Resize

+

Resize with high-quality Lanczos resampling.

+
image.resize(width=240, height=160)
+
+ Before/after preview of Resize +
+
+
+

Blend modes — darken

+
+
+

Darken

+

Looks at the color information in each channel and selects the base or blend color — whichever is darker — as the result color.

+
grayscale_image.darken('#3fe28f')
+
+ Before/after preview of Darken +
+
+
+

Multiply

+

Multiplies the base color by the blend color. The result is always darker; multiplying with white leaves the color unchanged.

+
grayscale_image.multiply('#3fe28f')
+
+ Before/after preview of Multiply +
+
+
+

Color Burn

+

Darkens the base color to reflect the blend color by increasing the contrast between the two. Blending with white produces no change.

+
grayscale_image.color_burn('#7fe3f8')
+
+ Before/after preview of Color Burn +
+
+
+

Linear Burn

+

Darkens the base color to reflect the blend color by decreasing the brightness. Blending with white produces no change.

+
grayscale_image.linear_burn('#e1a8ff')
+
+ Before/after preview of Linear Burn +
+
+
+

Blend modes — lighten

+
+
+

Lighten

+

Selects the base or blend color — whichever is lighter — as the result color.

+
image.lighten('#ff3ce1')
+
+ Before/after preview of Lighten +
+
+
+

Screen

+

Multiplies the inverse of the blend and base colors. The result is always lighter — like projecting multiple slides on top of each other.

+
image.screen('#e633ba')
+
+ Before/after preview of Screen +
+
+
+

Color Dodge

+

Brightens the base color to reflect the blend color by decreasing contrast between the two. Blending with black produces no change.

+
image.color_dodge('#490cc7')
+
+ Before/after preview of Color Dodge +
+
+
+

Linear Dodge

+

Brightens the base color to reflect the blend color by increasing the brightness. Blending with black produces no change.

+
image.linear_dodge('#490cc7')
+
+ Before/after preview of Linear Dodge +
+
+
+

Blend modes — contrast

+
+
+

Overlay

+

Multiplies or screens the colors depending on the base color, preserving its highlights and shadows.

+
image.overlay('#ffb956')
+
+ Before/after preview of Overlay +
+
+
+

Soft Light

+

Darkens or lightens depending on the blend color — like shining a diffused spotlight on the image.

+
image.soft_light('#ff3cbc')
+
+ Before/after preview of Soft Light +
+
+
+

Hard Light

+

Multiplies or screens depending on the blend color — like shining a harsh spotlight on the image.

+
image.hard_light('#df5dff')
+
+ Before/after preview of Hard Light +
+
+
+

Vivid Light

+

Burns or dodges by increasing or decreasing the contrast, depending on the blend color.

+
image.vivid_light('#ac5b7f')
+
+ Before/after preview of Vivid Light +
+
+
+

Linear Light

+

Burns or dodges by decreasing or increasing the brightness, depending on the blend color.

+
image.linear_light('#9fa500')
+
+ Before/after preview of Linear Light +
+
+
+

Pin Light

+

Replaces colors depending on the blend color — useful for adding special effects to an image.

+
image.pin_light('#005546')
+
+ Before/after preview of Pin Light +
+
+
+

Putting it together

+
+
+

Method chaining

+

Every operation returns the LayerImage itself, so a whole look can be built as one chained pipeline.

+
(image.grayscale()
+      .brightness(0.1)
+      .multiply('#3fe28f', opacity=0.7)
+      .curve('rgb', [0, 0.1, 0.4, 0.65, 0.9, 1]))
+
+ Before/after preview of Method chaining +
+
+
+ + + diff --git a/docs/previews/brightness.jpg b/docs/previews/brightness.jpg new file mode 100644 index 0000000..8744033 Binary files /dev/null and b/docs/previews/brightness.jpg differ diff --git a/docs/previews/chaining.jpg b/docs/previews/chaining.jpg new file mode 100644 index 0000000..dd03f30 Binary files /dev/null and b/docs/previews/chaining.jpg differ diff --git a/docs/previews/color_burn.jpg b/docs/previews/color_burn.jpg new file mode 100644 index 0000000..2fcb724 Binary files /dev/null and b/docs/previews/color_burn.jpg differ diff --git a/docs/previews/color_dodge.jpg b/docs/previews/color_dodge.jpg new file mode 100644 index 0000000..4339599 Binary files /dev/null and b/docs/previews/color_dodge.jpg differ diff --git a/docs/previews/contrast.jpg b/docs/previews/contrast.jpg new file mode 100644 index 0000000..9bd6452 Binary files /dev/null and b/docs/previews/contrast.jpg differ diff --git a/docs/previews/curve_red.jpg b/docs/previews/curve_red.jpg new file mode 100644 index 0000000..53b91bc Binary files /dev/null and b/docs/previews/curve_red.jpg differ diff --git a/docs/previews/curve_rgb.jpg b/docs/previews/curve_rgb.jpg new file mode 100644 index 0000000..6bbd7c6 Binary files /dev/null and b/docs/previews/curve_rgb.jpg differ diff --git a/docs/previews/darken.jpg b/docs/previews/darken.jpg new file mode 100644 index 0000000..9123bff Binary files /dev/null and b/docs/previews/darken.jpg differ diff --git a/docs/previews/grayscale.jpg b/docs/previews/grayscale.jpg new file mode 100644 index 0000000..c3d465c Binary files /dev/null and b/docs/previews/grayscale.jpg differ diff --git a/docs/previews/hard_light.jpg b/docs/previews/hard_light.jpg new file mode 100644 index 0000000..3abd566 Binary files /dev/null and b/docs/previews/hard_light.jpg differ diff --git a/docs/previews/hue.jpg b/docs/previews/hue.jpg new file mode 100644 index 0000000..88d32fe Binary files /dev/null and b/docs/previews/hue.jpg differ diff --git a/docs/previews/lighten.jpg b/docs/previews/lighten.jpg new file mode 100644 index 0000000..817186e Binary files /dev/null and b/docs/previews/lighten.jpg differ diff --git a/docs/previews/lightness.jpg b/docs/previews/lightness.jpg new file mode 100644 index 0000000..7698e9b Binary files /dev/null and b/docs/previews/lightness.jpg differ diff --git a/docs/previews/linear_burn.jpg b/docs/previews/linear_burn.jpg new file mode 100644 index 0000000..175d6fd Binary files /dev/null and b/docs/previews/linear_burn.jpg differ diff --git a/docs/previews/linear_dodge.jpg b/docs/previews/linear_dodge.jpg new file mode 100644 index 0000000..00f9c7a Binary files /dev/null and b/docs/previews/linear_dodge.jpg differ diff --git a/docs/previews/linear_light.jpg b/docs/previews/linear_light.jpg new file mode 100644 index 0000000..64f40f4 Binary files /dev/null and b/docs/previews/linear_light.jpg differ diff --git a/docs/previews/multiply.jpg b/docs/previews/multiply.jpg new file mode 100644 index 0000000..6352b67 Binary files /dev/null and b/docs/previews/multiply.jpg differ diff --git a/docs/previews/overlay.jpg b/docs/previews/overlay.jpg new file mode 100644 index 0000000..29b5094 Binary files /dev/null and b/docs/previews/overlay.jpg differ diff --git a/docs/previews/pin_light.jpg b/docs/previews/pin_light.jpg new file mode 100644 index 0000000..2512c68 Binary files /dev/null and b/docs/previews/pin_light.jpg differ diff --git a/docs/previews/resize.jpg b/docs/previews/resize.jpg new file mode 100644 index 0000000..404963a Binary files /dev/null and b/docs/previews/resize.jpg differ diff --git a/docs/previews/saturation.jpg b/docs/previews/saturation.jpg new file mode 100644 index 0000000..463a668 Binary files /dev/null and b/docs/previews/saturation.jpg differ diff --git a/docs/previews/screen.jpg b/docs/previews/screen.jpg new file mode 100644 index 0000000..5f86cdd Binary files /dev/null and b/docs/previews/screen.jpg differ diff --git a/docs/previews/soft_light.jpg b/docs/previews/soft_light.jpg new file mode 100644 index 0000000..6cf9cf9 Binary files /dev/null and b/docs/previews/soft_light.jpg differ diff --git a/docs/previews/vivid_light.jpg b/docs/previews/vivid_light.jpg new file mode 100644 index 0000000..b068cd6 Binary files /dev/null and b/docs/previews/vivid_light.jpg differ diff --git a/pyproject.toml b/pyproject.toml index 9c990f7..133eeaa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,11 +23,16 @@ classifiers = [ ] dependencies = [ "numpy>=1.20", - "matplotlib>=3.3", - "Pillow>=9.0", + "Pillow>=9.1", "requests>=2.22", ] +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "pytest-cov>=4.0", +] + [tool.hatch.version] path = "src/layeris/__about__.py" diff --git a/requirements.txt b/requirements.txt index 15e0aca..af6208a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ -numpy==1.17.2 -matplotlib==3.1.1 -Pillow==6.2.0 -requests==2.22.0 \ No newline at end of file +# Runtime dependencies. pyproject.toml is the authoritative list — +# prefer `pip install -e .` (or `pip install -e ".[dev]"` for development). +numpy>=1.20 +Pillow>=9.1 +requests>=2.22 diff --git a/scripts/generate_previews.py b/scripts/generate_previews.py new file mode 100644 index 0000000..77b3eec --- /dev/null +++ b/scripts/generate_previews.py @@ -0,0 +1,542 @@ +#!/usr/bin/env python3 +"""Generate before/after preview images for every layeris operation. + +This script is the single source of truth for the operation gallery. It +produces, for each operation: + + * a side-by-side before/after JPEG in ``/previews/`` + * a static HTML gallery at ``/index.html`` (GitHub Pages ready — + enable Pages with "Deploy from a branch" and the ``/docs`` folder) + +and can print the matching README markdown section with ``--markdown``. + +Usage:: + + python scripts/generate_previews.py # writes into docs/ + python scripts/generate_previews.py --markdown # also print README markdown + python scripts/generate_previews.py --output-dir /tmp/smoke +""" + +from __future__ import annotations + +import argparse +import html +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +import numpy as np +from PIL import Image + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT / "src")) + +from layeris import LayerImage, __version__ # noqa: E402 + +DEFAULT_SOURCE = REPO_ROOT / "test-images" / "piano-man-01.jpg" +PANEL_WIDTH = 480 # width of each before/after panel +GUTTER = 4 # white gap between the two panels +JPEG_QUALITY = 82 + +ADOBE_NOTE = ( + "Blend mode descriptions are adapted from " + "[Adobe's blending modes documentation]" + "(https://helpx.adobe.com/photoshop/using/blending-modes.html)." +) + + +@dataclass(frozen=True) +class Operation: + slug: str # file name and anchor + title: str + group: str # gallery section + code: str # snippet shown in README / gallery + apply: Callable[[LayerImage], LayerImage] + description: str + base: str = "color" # "color" or "gray" — which before-image to use + + +OPERATIONS: list[Operation] = [ + # ------------------------------------------------------------------ + # Basic adjustments + # ------------------------------------------------------------------ + Operation( + slug="grayscale", + title="Grayscale", + group="Basic adjustments", + code="image.grayscale()", + apply=lambda img: img.grayscale(), + description="Convert to grayscale using the ITU-R 601 luminance weights.", + ), + Operation( + slug="brightness", + title="Brightness", + group="Basic adjustments", + code="image.brightness(0.2)", + apply=lambda img: img.brightness(0.2), + description="Scale pixel values: a factor above 0 brightens, below 0 darkens.", + ), + Operation( + slug="contrast", + title="Contrast", + group="Basic adjustments", + code="image.contrast(1.5)", + apply=lambda img: img.contrast(1.5), + description=( + "Expand or compress the range around mid-gray: a factor above 1 " + "increases contrast, between 0 and 1 decreases it." + ), + ), + Operation( + slug="hue", + title="Hue", + group="Basic adjustments", + code="image.hue(0.3)", + apply=lambda img: img.hue(0.3), + description=( + "Set every pixel's hue to the target value in [0, 1] while keeping " + "saturation and value — a colorize-style adjustment." + ), + ), + Operation( + slug="saturation", + title="Saturation", + group="Basic adjustments", + code="image.saturation(-0.5)", + apply=lambda img: img.saturation(-0.5), + description=( + "Scale color intensity: a factor above 0 boosts saturation, below 0 " + "mutes it (-1 fully desaturates)." + ), + ), + Operation( + slug="lightness", + title="Lightness", + group="Basic adjustments", + code="image.lightness(0.4)", + apply=lambda img: img.lightness(0.4), + description=( + "Blend toward white (factor above 0) or black (factor below 0)." + ), + ), + Operation( + slug="curve_rgb", + title="Curve (all channels)", + group="Basic adjustments", + code="image.curve('rgb', [0, 0.1, 0.4, 0.65, 0.9, 1])", + apply=lambda img: img.curve("rgb", [0, 0.1, 0.4, 0.65, 0.9, 1]), + description=( + "Remap tones through control points spaced evenly across [0, 1] — " + "here an S-curve that deepens shadows and lifts highlights." + ), + ), + Operation( + slug="curve_red", + title="Curve (single channel)", + group="Basic adjustments", + code="image.curve('r', [0, 0.2, 0.8, 1])", + apply=lambda img: img.curve("r", [0, 0.2, 0.8, 1]), + description="The same curve machinery applied to the red channel only.", + ), + Operation( + slug="resize", + title="Resize", + group="Basic adjustments", + code="image.resize(width=240, height=160)", + apply=lambda img: img.resize(240, 160), + description="Resize with high-quality Lanczos resampling.", + ), + # ------------------------------------------------------------------ + # Blend modes — darken group (applied to a grayscale base, as in the + # original demo). + # ------------------------------------------------------------------ + Operation( + slug="darken", + title="Darken", + group="Blend modes — darken", + code="grayscale_image.darken('#3fe28f')", + apply=lambda img: img.darken("#3fe28f"), + base="gray", + description=( + "Looks at the color information in each channel and selects the base " + "or blend color — whichever is darker — as the result color." + ), + ), + Operation( + slug="multiply", + title="Multiply", + group="Blend modes — darken", + code="grayscale_image.multiply('#3fe28f')", + apply=lambda img: img.multiply("#3fe28f"), + base="gray", + description=( + "Multiplies the base color by the blend color. The result is always " + "darker; multiplying with white leaves the color unchanged." + ), + ), + Operation( + slug="color_burn", + title="Color Burn", + group="Blend modes — darken", + code="grayscale_image.color_burn('#7fe3f8')", + apply=lambda img: img.color_burn("#7fe3f8"), + base="gray", + description=( + "Darkens the base color to reflect the blend color by increasing the " + "contrast between the two. Blending with white produces no change." + ), + ), + Operation( + slug="linear_burn", + title="Linear Burn", + group="Blend modes — darken", + code="grayscale_image.linear_burn('#e1a8ff')", + apply=lambda img: img.linear_burn("#e1a8ff"), + base="gray", + description=( + "Darkens the base color to reflect the blend color by decreasing the " + "brightness. Blending with white produces no change." + ), + ), + # ------------------------------------------------------------------ + # Blend modes — lighten group + # ------------------------------------------------------------------ + Operation( + slug="lighten", + title="Lighten", + group="Blend modes — lighten", + code="image.lighten('#ff3ce1')", + apply=lambda img: img.lighten("#ff3ce1"), + description=( + "Selects the base or blend color — whichever is lighter — as the " + "result color." + ), + ), + Operation( + slug="screen", + title="Screen", + group="Blend modes — lighten", + code="image.screen('#e633ba')", + apply=lambda img: img.screen("#e633ba"), + description=( + "Multiplies the inverse of the blend and base colors. The result is " + "always lighter — like projecting multiple slides on top of each other." + ), + ), + Operation( + slug="color_dodge", + title="Color Dodge", + group="Blend modes — lighten", + code="image.color_dodge('#490cc7')", + apply=lambda img: img.color_dodge("#490cc7"), + description=( + "Brightens the base color to reflect the blend color by decreasing " + "contrast between the two. Blending with black produces no change." + ), + ), + Operation( + slug="linear_dodge", + title="Linear Dodge", + group="Blend modes — lighten", + code="image.linear_dodge('#490cc7')", + apply=lambda img: img.linear_dodge("#490cc7"), + description=( + "Brightens the base color to reflect the blend color by increasing " + "the brightness. Blending with black produces no change." + ), + ), + # ------------------------------------------------------------------ + # Blend modes — contrast group + # ------------------------------------------------------------------ + Operation( + slug="overlay", + title="Overlay", + group="Blend modes — contrast", + code="image.overlay('#ffb956')", + apply=lambda img: img.overlay("#ffb956"), + description=( + "Multiplies or screens the colors depending on the base color, " + "preserving its highlights and shadows." + ), + ), + Operation( + slug="soft_light", + title="Soft Light", + group="Blend modes — contrast", + code="image.soft_light('#ff3cbc')", + apply=lambda img: img.soft_light("#ff3cbc"), + description=( + "Darkens or lightens depending on the blend color — like shining a " + "diffused spotlight on the image." + ), + ), + Operation( + slug="hard_light", + title="Hard Light", + group="Blend modes — contrast", + code="image.hard_light('#df5dff')", + apply=lambda img: img.hard_light("#df5dff"), + description=( + "Multiplies or screens depending on the blend color — like shining a " + "harsh spotlight on the image." + ), + ), + Operation( + slug="vivid_light", + title="Vivid Light", + group="Blend modes — contrast", + code="image.vivid_light('#ac5b7f')", + apply=lambda img: img.vivid_light("#ac5b7f"), + description=( + "Burns or dodges by increasing or decreasing the contrast, depending " + "on the blend color." + ), + ), + Operation( + slug="linear_light", + title="Linear Light", + group="Blend modes — contrast", + code="image.linear_light('#9fa500')", + apply=lambda img: img.linear_light("#9fa500"), + description=( + "Burns or dodges by decreasing or increasing the brightness, " + "depending on the blend color." + ), + ), + Operation( + slug="pin_light", + title="Pin Light", + group="Blend modes — contrast", + code="image.pin_light('#005546')", + apply=lambda img: img.pin_light("#005546"), + description=( + "Replaces colors depending on the blend color — useful for adding " + "special effects to an image." + ), + ), + # ------------------------------------------------------------------ + # Composition + # ------------------------------------------------------------------ + Operation( + slug="chaining", + title="Method chaining", + group="Putting it together", + code=( + "(image.grayscale()\n" + " .brightness(0.1)\n" + " .multiply('#3fe28f', opacity=0.7)\n" + " .curve('rgb', [0, 0.1, 0.4, 0.65, 0.9, 1]))" + ), + apply=lambda img: ( + img.grayscale() + .brightness(0.1) + .multiply("#3fe28f", opacity=0.7) + .curve("rgb", [0, 0.1, 0.4, 0.65, 0.9, 1]) + ), + description=( + "Every operation returns the LayerImage itself, so a whole look can " + "be built as one chained pipeline." + ), + ), +] + + +def to_pil(image: LayerImage) -> Image.Image: + """Convert a LayerImage to an 8-bit RGB PIL image via the public API.""" + arr = np.clip(image.get_image_as_array(), 0.0, 1.0)[:, :, :3] + return Image.fromarray(np.round(arr * 255).astype(np.uint8), "RGB") + + +def side_by_side(before: Image.Image, after: Image.Image) -> Image.Image: + """Compose the before/after panels with a thin white gutter.""" + # Smaller results (e.g. resize) sit top-left on a neutral canvas so the + # size difference stays visible. + if after.size != before.size: + panel = Image.new("RGB", before.size, "#ececec") + panel.paste(after, (0, 0)) + after = panel + canvas = Image.new( + "RGB", + (before.width + GUTTER + after.width, before.height), + "#ffffff", + ) + canvas.paste(before, (0, 0)) + canvas.paste(after, (before.width + GUTTER, 0)) + return canvas + + +def generate_previews(source: Path, output_dir: Path) -> list[tuple[Operation, Path]]: + previews_dir = output_dir / "previews" + previews_dir.mkdir(parents=True, exist_ok=True) + + base = LayerImage.from_file(source) + height = round(base.get_image_as_array().shape[0] * PANEL_WIDTH + / base.get_image_as_array().shape[1]) + base.resize(PANEL_WIDTH, height) + bases = {"color": base, "gray": base.clone().grayscale()} + before_panels = {kind: to_pil(img) for kind, img in bases.items()} + + written: list[tuple[Operation, Path]] = [] + for op in OPERATIONS: + result = op.apply(bases[op.base].clone()) + composite = side_by_side(before_panels[op.base], to_pil(result)) + path = previews_dir / f"{op.slug}.jpg" + composite.save(path, quality=JPEG_QUALITY, optimize=True) + written.append((op, path)) + print(f" wrote {path.relative_to(output_dir.parent) if output_dir.parent in path.parents else path}") + return written + + +def render_markdown() -> str: + """README-ready markdown for the operation gallery.""" + lines = [ + "## 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", + "```", + "", + ADOBE_NOTE, + "", + ] + current_group = None + for op in OPERATIONS: + if op.group != current_group: + current_group = op.group + lines += [f"### {current_group}", ""] + if current_group == "Blend modes — darken": + lines += [ + "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.", + "", + ] + lines += [ + f"#### {op.title}", + "", + op.description, + "", + "```python", + op.code, + "```", + "", + f"![{op.slug}](docs/previews/{op.slug}.jpg)", + "", + ] + return "\n".join(lines) + + +def render_html() -> str: + """Static gallery page for GitHub Pages (docs/index.html).""" + groups: dict[str, list[Operation]] = {} + for op in OPERATIONS: + groups.setdefault(op.group, []).append(op) + + sections = [] + for group, ops in groups.items(): + cards = [] + for op in ops: + cards.append(f""" +
+
+

{html.escape(op.title)}

+

{html.escape(op.description)}

+
{html.escape(op.code)}
+
+ Before/after preview of {html.escape(op.title)} +
""") + sections.append( + f'
\n

{html.escape(group)}

{"".join(cards)}\n
' + ) + + body = "\n".join(sections) + return f""" + + + + +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.

+
+
+{body} +
+
Generated by scripts/generate_previews.py · layeris v{__version__}
+ + +""" + + +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)))