Skip to content

Commit cf052d4

Browse files
committed
add cli and more tests
1 parent b74ea1f commit cf052d4

17 files changed

+330
-98
lines changed

blendmodes/__main.py

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from blendmodes.cli import main
2+
3+
if __name__ == "__main__":
4+
main()

blendmodes/blend.py

+37-37
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626

2727
from blendmodes.blendtype import BlendType
2828

29+
HALF_THRESHOLD = 0.5
30+
2931

3032
def normal(background: np.ndarray, foreground: np.ndarray) -> np.ndarray:
3133
"""BlendType.NORMAL."""
@@ -76,7 +78,7 @@ def glow(background: np.ndarray, foreground: np.ndarray) -> np.ndarray:
7678
def overlay(background: np.ndarray, foreground: np.ndarray) -> np.ndarray:
7779
"""BlendType.OVERLAY."""
7880
return np.where(
79-
background < 0.5,
81+
background < HALF_THRESHOLD,
8082
2 * background * foreground,
8183
1.0 - (2 * (1.0 - background) * (1.0 - foreground)),
8284
)
@@ -125,7 +127,7 @@ def softlight(background: np.ndarray, foreground: np.ndarray) -> np.ndarray:
125127
def hardlight(background: np.ndarray, foreground: np.ndarray) -> np.ndarray:
126128
"""BlendType.HARDLIGHT."""
127129
return np.where(
128-
foreground < 0.5,
130+
foreground < HALF_THRESHOLD,
129131
np.minimum(background * 2 * foreground, 1.0),
130132
np.minimum(1.0 - ((1.0 - background) * (1.0 - (foreground - 0.5) * 2.0)), 1.0),
131133
)
@@ -148,16 +150,16 @@ def divide(background: np.ndarray, foreground: np.ndarray) -> np.ndarray:
148150

149151
def pinlight(background: np.ndarray, foreground: np.ndarray) -> np.ndarray:
150152
"""BlendType.PINLIGHT."""
151-
return np.minimum(background, 2 * foreground) * (foreground < 0.5) + np.maximum(
153+
return np.minimum(background, 2 * foreground) * (foreground < HALF_THRESHOLD) + np.maximum(
152154
background, 2 * (foreground - 0.5)
153-
) * (foreground >= 0.5)
155+
) * (foreground >= HALF_THRESHOLD)
154156

155157

156158
def vividlight(background: np.ndarray, foreground: np.ndarray) -> np.ndarray:
157159
"""BlendType.VIVIDLIGHT."""
158-
return colourburn(background, foreground * 2) * (foreground < 0.5) + colourdodge(
160+
return colourburn(background, foreground * 2) * (foreground < HALF_THRESHOLD) + colourdodge(
159161
background, 2 * (foreground - 0.5)
160-
) * (foreground >= 0.5)
162+
) * (foreground >= HALF_THRESHOLD)
161163

162164

163165
def exclusion(background: np.ndarray, foreground: np.ndarray) -> np.ndarray:
@@ -543,60 +545,54 @@ def blendLayersArray(
543545
"""
544546

545547
# Convert the Image.Image to a numpy array if required
546-
if isinstance(background, Image.Image):
547-
background = np.array(background.convert("RGBA"))
548-
if isinstance(foreground, Image.Image):
549-
foreground = np.array(foreground.convert("RGBA"))
548+
bg = np.array(background.convert("RGBA")) if isinstance(background, Image.Image) else background
549+
fg = np.array(foreground.convert("RGBA")) if isinstance(foreground, Image.Image) else foreground
550550

551551
# do any offset shifting first
552552
if offsets[0] > 0:
553-
foreground = np.hstack(
554-
(np.zeros((foreground.shape[0], offsets[0], 4), dtype=np.float64), foreground)
555-
)
553+
fg = np.hstack((np.zeros((bg.shape[0], offsets[0], 4), dtype=np.float64), fg))
556554
elif offsets[0] < 0:
557-
if offsets[0] > -1 * foreground.shape[1]:
558-
foreground = foreground[:, -1 * offsets[0] :, :]
555+
if offsets[0] > -1 * fg.shape[1]:
556+
fg = fg[:, -1 * offsets[0] :, :]
559557
else:
560558
# offset offscreen completely, there is nothing left
561-
return np.zeros(background.shape, dtype=np.float64)
559+
return np.zeros(bg.shape, dtype=np.float64)
562560
if offsets[1] > 0:
563-
foreground = np.vstack(
564-
(np.zeros((offsets[1], foreground.shape[1], 4), dtype=np.float64), foreground)
565-
)
561+
fg = np.vstack((np.zeros((offsets[1], fg.shape[1], 4), dtype=np.float64), fg))
566562
elif offsets[1] < 0:
567-
if offsets[1] > -1 * foreground.shape[0]:
568-
foreground = foreground[-1 * offsets[1] :, :, :]
563+
if offsets[1] > -1 * fg.shape[0]:
564+
fg = fg[-1 * offsets[1] :, :, :]
569565
else:
570566
# offset offscreen completely, there is nothing left
571-
return np.zeros(background.shape, dtype=np.float64)
567+
return np.zeros(bg.shape, dtype=np.float64)
572568

573569
# resize array to fill small images with zeros
574-
if foreground.shape[0] < background.shape[0]:
575-
foreground = np.vstack(
570+
if fg.shape[0] < bg.shape[0]:
571+
fg = np.vstack(
576572
(
577-
foreground,
573+
fg,
578574
np.zeros(
579-
(background.shape[0] - foreground.shape[0], foreground.shape[1], 4),
575+
(bg.shape[0] - fg.shape[0], fg.shape[1], 4),
580576
dtype=np.float64,
581577
),
582578
)
583579
)
584-
if foreground.shape[1] < background.shape[1]:
585-
foreground = np.hstack(
580+
if fg.shape[1] < bg.shape[1]:
581+
fg = np.hstack(
586582
(
587-
foreground,
583+
fg,
588584
np.zeros(
589-
(foreground.shape[0], background.shape[1] - foreground.shape[1], 4),
585+
(fg.shape[0], bg.shape[1] - fg.shape[1], 4),
590586
dtype=np.float64,
591587
),
592588
)
593589
)
594590

595591
# crop the source if the backdrop is smaller
596-
foreground = foreground[: background.shape[0], : background.shape[1], :]
592+
fg = fg[: bg.shape[0], : bg.shape[1], :]
597593

598-
lower_norm = background / 255.0
599-
upper_norm = foreground / 255.0
594+
lower_norm = bg / 255.0
595+
upper_norm = fg / 255.0
600596

601597
upper_alpha = upper_norm[:, :, 3] * opacity
602598
lower_alpha = lower_norm[:, :, 3]
@@ -639,10 +635,14 @@ def alpha_comp_shell(
639635

640636
blend_rgb = blend(lower_rgb, upper_rgb, blendType)
641637

642-
lower_rgb_part = np.multiply(((1.0 - upper_alpha) * lower_alpha)[:, :, None], lower_rgb)
643-
upper_rgb_part = np.multiply(((1.0 - lower_alpha) * upper_alpha)[:, :, None], upper_rgb)
644-
blended_rgb_part = np.multiply((lower_alpha * upper_alpha)[:, :, None], blend_rgb)
638+
lower_rgb_factor = (1.0 - upper_alpha) * lower_alpha
639+
upper_rgb_factor = (1.0 - lower_alpha) * upper_alpha
640+
blended_rgb_factor = lower_alpha * upper_alpha
645641

646-
out_rgb = np.divide((lower_rgb_part + upper_rgb_part + blended_rgb_part), out_alpha[:, :, None])
642+
out_rgb = (
643+
lower_rgb_factor[:, :, None] * lower_rgb
644+
+ upper_rgb_factor[:, :, None] * upper_rgb
645+
+ blended_rgb_factor[:, :, None] * blend_rgb
646+
) / out_alpha[:, :, None]
647647

648648
return out_rgb, out_alpha

blendmodes/cli.py

+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import os
5+
import sys
6+
from pathlib import Path
7+
8+
import numpy as np
9+
from loguru import logger
10+
from PIL import Image
11+
12+
from blendmodes.blend import blendLayersArray
13+
from blendmodes.blendtype import BlendType
14+
from blendmodes.imgdiff import image_diff_array
15+
16+
logger.remove()
17+
logger.add(sys.stdout, level="INFO", format="<level>{level: <8}</level> | {message}")
18+
19+
20+
def get_im_np(image: str | bytes | os.PathLike[str] | os.PathLike[bytes]) -> np.ndarray:
21+
"""Convert image at path to RGBA NumPy array."""
22+
img = image if isinstance(image, Image.Image) else Image.open(image).convert("RGBA")
23+
return np.array(img)
24+
25+
26+
def main() -> None:
27+
# Parent parser with shared arguments
28+
common_parser = argparse.ArgumentParser(add_help=False)
29+
common_parser.add_argument("image1", type=Path, help="Path to the first image (background)")
30+
common_parser.add_argument("image2", type=Path, help="Path to the second image (foreground)")
31+
32+
# Main parser
33+
parser = argparse.ArgumentParser(description="Blend or compare two images")
34+
subparsers = parser.add_subparsers(dest="command", required=True)
35+
36+
# Blend Subcommand
37+
blend_parser = subparsers.add_parser("blend", parents=[common_parser], help="Blend two images")
38+
blend_parser.add_argument(
39+
"--blend",
40+
"-b",
41+
choices=[alias.lower() for mode in BlendType for alias in mode.values],
42+
help="Blend mode to use (e.g. normal, multiply, screen)",
43+
)
44+
blend_parser.add_argument("--output", "-o", type=Path, required=True, help="Output image path")
45+
46+
# Diff Subcommand
47+
subparsers.add_parser("diff", parents=[common_parser], help="Compare two images")
48+
49+
# Parse arguments
50+
args = parser.parse_args()
51+
52+
img1 = get_im_np(args.image1)
53+
img2 = get_im_np(args.image2)
54+
55+
if args.command == "diff":
56+
diff = image_diff_array(img1, img2)
57+
logger.info(
58+
f"Comparing {args.image1.name} and {args.image2.name} are {diff * 100:.2f}% different"
59+
)
60+
61+
else:
62+
result = blendLayersArray(img1, img2, args.blend)
63+
result_im = Image.fromarray(np.uint8(np.around(result, 0)))
64+
result_im.save(args.output)
65+
logger.info(
66+
f"Blending {args.image1.name} and {args.image2.name} and "
67+
f"writing the result to {args.output}"
68+
)
69+
70+
71+
if __name__ == "__main__":
72+
main()

blendmodes/imgdiff.py

+22-7
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,21 @@
1+
"""
2+
Image Difference for Blend Modes Library.
3+
4+
This module provides functions to compare two images.
5+
It calculates the pixel-wise difference and the percentage of differing pixels between
6+
two images, which is useful for testing and validating blend mode implementations.
7+
8+
Functions:
9+
- image_diff: Compute the difference between two images as a float between 0-1 or
10+
0-100
11+
- image_diff_array: Compute the difference between two images as numpy arrays as a
12+
float between 0-1 or 0-100
13+
- is_equal: Check if two images are identical, optionally allowing for a tolerance
14+
in pixel values.
15+
- is_x_diff: Compare two images and return True/False if the image is within `tolerance` of
16+
`cmp_diff`.
17+
"""
18+
119
from __future__ import annotations
220

321
import numpy as np
@@ -65,11 +83,7 @@ def is_equal(
6583
) -> bool:
6684
"""
6785
Compare two images and return True/False if the image is within `tolerance` of
68-
`cmp_diff`.
69-
70-
For example, a black and white image compared in 'RGB' mode would
71-
return a value of 100, which would then be checked if its between
72-
`cmp_diff - tolerance` and `cmp_diff + tolerance`
86+
the second image.
7387
7488
:param Image.Image img1in: image 1 to compare
7589
:param Image.Image img2in: image 2 to compare
@@ -174,14 +188,15 @@ def image_diff_array(img1in: Image.Image | np.ndarray, img2in: Image.Image | np.
174188
175189
"""
176190
# Convert PIL images to NumPy arrays if needed
177-
img1 = np.array(img1in) if isinstance(img1in, Image.Image) else img1in
178-
img2 = np.array(img2in) if isinstance(img2in, Image.Image) else img2in
191+
img1 = np.array(img1in, dtype=np.int16) if isinstance(img1in, Image.Image) else img1in
192+
img2 = np.array(img2in, dtype=np.int16) if isinstance(img2in, Image.Image) else img2in
179193
# Ensure images have the same dimensions
180194
if img1.shape != img2.shape:
181195
msg = "Images must have the same dimensions for comparison."
182196
raise ValueError(msg)
183197

184198
# Compute absolute difference
199+
185200
difference = np.abs(img1 - img2)
186201

187202
# Sum the differences and normalize to get a percentage

documentation/reference/README.md

+2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
A full list of `Blendmodes` project modules.
66

77
- [Blendmodes](blendmodes/index.md#blendmodes)
8+
- [Main](blendmodes/__main.md#main)
89
- [Blend](blendmodes/blend.md#blend)
910
- [BlendType](blendmodes/blendtype.md#blendtype)
11+
- [Cli](blendmodes/cli.md#cli)
1012
- [Imgdiff](blendmodes/imgdiff.md#imgdiff)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Main
2+
3+
[Blendmodes Index](../README.md#blendmodes-index) / [Blendmodes](./index.md#blendmodes) / Main
4+
5+
> Auto-generated documentation for [blendmodes.__main](../../../blendmodes/__main.py) module.
6+
- [Main](#main)

0 commit comments

Comments
 (0)