A library that supports all blend modes in Photoshop, brightness, contrast, hue, saturation, lightness adjustments, and curve adjustments on separate RGB channels.
This project was a part of the 2019 Open Source Software Competition.
- Supports all frequently used blend modes in Photoshop
- Chain operations for concise, readable image pipelines
- 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
Python 3.10 or higher.
pip install layerisgit clone https://github.com/subwaymatch/layer-is-python.git
cd layer-is-python
pip install -e .To install with development dependencies (includes pytest):
pip install -e ".[dev]"
# or, using hatch:
hatch env createContributor note: The package uses a
src/layout — the importable package lives undersrc/layeris/. Runpip install -e .from the repo root for an editable install that picks up changes automatically. Tests live intests/and are discovered by pytest automatically.
from layeris import LayerImage
# From a local file
image = LayerImage.from_file('/path/to/image.jpg')
# From a URL
image = LayerImage.from_url('https://example.com/photo.jpg')
# 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)# Default quality is 95 for JPEG
image.save('output.jpg')
# Specify JPEG quality (0–100)
image.save('output.jpg', quality=85)
# Save as PNG (lossless)
image.save('output.png')All operations mutate the image in place and return self, so calls can be chained:
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:
copy = image.clone()
copy.darken('#000000') # original is unaffectedEvery available operation is shown with a before/after preview in the operation gallery below.
Every image below is generated by 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:
python scripts/generate_previews.pyBlend mode descriptions are adapted from Adobe's blending modes documentation.
Convert to grayscale using the ITU-R 601 luminance weights.
image.grayscale()Scale pixel values: a factor above 0 brightens, below 0 darkens.
image.brightness(0.2)Expand or compress the range around mid-gray: a factor above 1 increases contrast, between 0 and 1 decreases it.
image.contrast(1.5)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)Scale color intensity: a factor above 0 boosts saturation, below 0 mutes it (-1 fully desaturates).
image.saturation(-0.5)Blend toward white (factor above 0) or black (factor below 0).
image.lightness(0.4)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])The same curve machinery applied to the red channel only.
image.curve('r', [0, 0.2, 0.8, 1])Resize with high-quality Lanczos resampling.
image.resize(width=240, height=160)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.
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')Multiplies the base color by the blend color. The result is always darker; multiplying with white leaves the color unchanged.
grayscale_image.multiply('#3fe28f')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')Darkens the base color to reflect the blend color by decreasing the brightness. Blending with white produces no change.
grayscale_image.linear_burn('#e1a8ff')Selects the base or blend color — whichever is lighter — as the result color.
image.lighten('#ff3ce1')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')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')Brightens the base color to reflect the blend color by increasing the brightness. Blending with black produces no change.
image.linear_dodge('#490cc7')Multiplies or screens the colors depending on the base color, preserving its highlights and shadows.
image.overlay('#ffb956')Darkens or lightens depending on the blend color — like shining a diffused spotlight on the image.
image.soft_light('#ff3cbc')Multiplies or screens depending on the blend color — like shining a harsh spotlight on the image.
image.hard_light('#df5dff')Burns or dodges by increasing or decreasing the contrast, depending on the blend color.
image.vivid_light('#ac5b7f')Burns or dodges by decreasing or increasing the brightness, depending on the blend color.
image.linear_light('#9fa500')Replaces colors depending on the blend color — useful for adding special effects to an image.
image.pin_light('#005546')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]))arr = image.get_image_as_array()
# arr.shape → (height, width, 3) for RGB
# → (height, width, 4) for RGBA
# arr.dtype → float64
# values in [0.0, 1.0]Operations can be described as a Python dict (or a JSON file) and applied in sequence:
ops = {
"operations": [
{"type": "grayscale"},
{"type": "brightness", "factor": 0.15},
{"type": "multiply", "hex": "#3fe28f", "opacity": 0.6},
{"type": "curve", "channels": "rgb", "curve_points": [0, 0.1, 0.9, 1]},
{"type": "resize", "width": 800, "height": 600},
]
}
image.apply_from_dict(ops)
# Or load directly from a JSON file
image.apply_from_json('pipeline.json')Supported operation types: grayscale, resize, brightness, contrast, hue,
saturation, lightness, curve, darken, multiply, color_burn,
linear_burn, lighten, screen, color_dodge, linear_dodge, overlay,
soft_light, hard_light, vivid_light, linear_light, pin_light.
Install development dependencies and run the test suite with pytest:
# Using pip (editable install with dev extras)
pip install -e ".[dev]"
pytest
# Using hatch
hatch run test
# With coverage report
hatch run test-covRun a specific test file or test:
pytest tests/test_layer_image.py
pytest tests/test_utils.py
pytest tests/test_layer_image.py::TestBlendModes::test_multiply_by_white_no_change -vTest images used by the suite are stored in tests/images/.
| Group | Method | Formula (A = base, B = blend) |
|---|---|---|
| Darken | darken |
min(A, B) |
| Darken | multiply |
A × B |
| Darken | color_burn |
1 − (1 − A) / B |
| Darken | linear_burn |
A + B − 1 |
| Lighten | lighten |
max(A, B) |
| Lighten | screen |
1 − (1 − A)(1 − B) |
| Lighten | color_dodge |
A / (1 − B) |
| Lighten | linear_dodge |
A + B |
| Contrast | overlay |
2AB if A ≤ 0.5 else 1 − 2(1−A)(1−B) |
| Contrast | soft_light |
Pegtop formula |
| Contrast | hard_light |
2AB if B ≤ 0.5 else 1 − 2(1−A)(1−B) |
| Contrast | vivid_light |
Color burn / dodge based on B |
| Contrast | linear_light |
A + 2B − 1 |
| Contrast | pin_light |
Conditional replace |
All results are clamped to [0, 1].
The preview generator also produces a standalone gallery page at
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 and can be
customised freely.
- Imitate Photoshop's auto brightness & auto contrast features
- Add filter presets
























