A library that supports all blend modes in Photoshop, brightness, contrast, hue, saturation, lightness adjustments, and curve adjustments on separate RGB channels.
- 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
- Fast on photos: per-channel adjustments are combined into one lookup table that Pillow applies in a single pass
- 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 to 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. For complete looks (color grades, film emulation, black & white, double exposures and more), see the examples on the docs site.
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. This is 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 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 to 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, which is 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]Photos loaded with from_file() or from_url() stay as 8-bit images while
you apply per-channel operations: brightness, contrast, lightness,
curve and every blend mode with a hex color. Each of these only depends on
a pixel's own channel value, and an 8-bit channel has just 256 possible
values, so instead of processing every pixel, layeris updates a 256-entry
lookup table. Pillow applies the table in one pass when you call resize()
or save().
get_image_as_array(), grayscale(), hue(), saturation() and blending
with a per-pixel NumPy array need float pixels, so they convert the image to
float64 once and later operations run in float64. Either way, the results are
identical to processing every pixel in float64.
On a 12-megapixel JPEG, loading it, applying a curve, brightness, contrast and an overlay, then saving takes about 0.2 s instead of 4.1 s, and peak memory drops from about 1.2 GB to under 200 MB.
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 docs/ folder is a static site with five pages:
| Page | Contents |
|---|---|
index.html |
Overview: a live "look switcher", installation, quick start and performance notes |
examples.html |
24 photo recipes (fixes, color grading, film, B&W, creative effects) with before/after sliders, copy-paste code and a filter by method |
blend-modes.html |
All 14 blend modes on three photos, plus opacity and blend-color comparisons |
operations.html |
The operation gallery above |
api.html |
API reference: signatures, parameters and return values for every method, and the JSON operation types |
Rebuild every page and image with:
python scripts/build_docs.pyRecipes live in scripts/docs_examples.py. Each
snippet is executed verbatim to render its image, so the code on the page is
always exactly what produced the result. The API reference lives in
scripts/docs_api.py; the build fails if a public
LayerImage method is missing from it. The sample photos in
docs/photos/ were generated with OpenAI's
gpt-image-2.5-flare model at low quality; the prompts are kept in
scripts/generate_photos.py, which can
regenerate them (requires OPENAI_API_KEY).
To publish, enable Settings > Pages > Deploy from a branch and select the
main branch with the /docs folder. No build step is needed on GitHub's
side.
- Imitate Photoshop's auto brightness & auto contrast features
- Add filter presets
Layer.is won 2nd place (the Gold Prize and NIPA President's Award) in the general division of the 2019 Open Source Software Developer Competition, the competition's 13th edition, in which over 100 teams took part. The competition was hosted by Korea's Ministry of Science and ICT and organized by NIPA (National IT Industry Promotion Agency).























