Reputation: 520
I am currently working on conditional blending implementation like one used in Photoshop -> Blending Options -> Blend If. Anyone. who is a bit familiar with Photoshop should
be aware of such functionality. For simplicity, lets assume Blend If
implementation just for Underlying image. Anyway, this one is most important to me. Photoshop's feature works in two modes. First, simpler mode, take care for two values, shadows and highlight. Blending among layer (foreground) on top with underlying layer(background) is executed if gray-scale pixels of background are lying between interval given by values [shadow, highlight], otherwise original background pixels are taken. I implemented such behavior in function bellow.
from typing import Tuple
import numpy as np
def expand_as_rgba(image: np.ndarray) -> np.ndarray:
# Add alpha-channels, if they are not provided
if image.shape[2] == 3:
return np.dstack((image, np.ones(image.shape[:2] + (1,)) * 255)).astype(np.uint8)
return image
def normal_blend_if(
this_layer: np.ndarray,
underlying_layer: np.ndarray,
underlying_layer_shadows_range: Tuple = (0, 0),
underlying_layer_highlights_range: Tuple = (255, 255),
) -> np.ndarray:
bg_shadows, bg_highlights = underlying_layer_shadows_range, underlying_layer_highlights_range
# Expand with alpha if missing
foreground_array = expand_as_rgba(this_layer) / 255.0
background_array = expand_as_rgba(underlying_layer) / 255.0
# Extract the individual channels (R, G, B, A)
foreground_r, foreground_g, foreground_b, foreground_a = np.rollaxis(foreground_array, axis=-1)
background_r, background_g, background_b, background_a = np.rollaxis(background_array, axis=-1)
# Calculate the luminosity of the background image
background_luminosity = (0.299 * background_r + 0.587 * background_g + 0.114 * background_b) * 255.0
# Create the blend if condition based on the luminosity range
blend_if = (background_luminosity >= bg_shadows[0]) & (background_luminosity <= bg_highlights[1])
blend_if_broadcast = np.expand_dims(blend_if, 2)
foreground_rgb, background_rgb = foreground_array[:, :, :3], background_array[:, :, :3]
fga_broadast, bga_broadcast = np.expand_dims(foreground_a, 2), np.expand_dims(background_a, 2)
# Conditional blending
blended_rgb = np.where(
blend_if_broadcast,
(foreground_rgb * fga_broadast + background_rgb * bga_broadcast * (1 - fga_broadast)) / (
fga_broadast + bga_broadcast * (1 - fga_broadast)),
background_rgb
)
blended_a = np.where(
blend_if_broadcast,
fga_broadast + bga_broadcast * (1 - fga_broadast),
bga_broadcast
)
# Combine the blended channels back into a single RGBA image
blended_rgba = np.dstack((blended_rgb, blended_a))
# Scale the RGBA values back to the range [0, 255]
blended_rgba = (blended_rgba * 255).astype(np.uint8)
return blended_rgba
However, what is crucial for me,is more complex implementation, where shadow and highlight thresholds are split into two values. It eventually leads to smooth blending, but I have no idea what is going on even though, I watched several tutorial about Photoshop. It seems like additional blending is executed based on scale defined by shadow/highlight range, but have no idea how and how to add it to my algorithm. The closest implementation I was able to derive is in the second snippet,
def normal_complex_blend_if(
this_layer: np.ndarray,
underlying_layer: np.ndarray,
underlying_layer_shadows_range: Tuple = (0, 0),
underlying_layer_highlights_range: Tuple = (255, 255),
) -> np.ndarray:
bg_shadows, bg_highlights = underlying_layer_shadows_range, underlying_layer_highlights_range
# Expand with alpha if missing
foreground_array = expand_as_rgba(this_layer) / 255.0
background_array = expand_as_rgba(underlying_layer) / 255.0
# Extract the individual channels (R, G, B, A)
foreground_r, foreground_g, foreground_b, foreground_a = np.rollaxis(foreground_array, axis=-1)
background_r, background_g, background_b, background_a = np.rollaxis(background_array, axis=-1)
# Calculate the luminosity of the background image
background_luminosity = (0.299 * background_r + 0.587 * background_g + 0.114 * background_b) * 255.0
# Create the blend if condition based on the luminosity range
blend_if = (background_luminosity >= bg_shadows[0]) & (background_luminosity <= bg_highlights[1])
blend_if_broadcast = np.expand_dims(blend_if, 2)
# Calculate the blending factors for the shadow and highlight ranges
shadow_factor = np.interp(background_luminosity, [bg_shadows[0], bg_shadows[1]], [0, 1])
highlight_factor = np.interp(background_luminosity, [bg_highlights[0], bg_highlights[1]], [0, 1])
# Expand dimensions of alpha for further use
fga_broadast, bga_broadcast = np.expand_dims(foreground_a, 2), np.expand_dims(background_a, 2)
foreground_rgb, background_rgb = foreground_array[:, :, :3], background_array[:, :, :3]
shadow_factor = np.expand_dims(shadow_factor, 2)
highlight_factor = np.expand_dims(highlight_factor, 2)
blended_rgb = np.where(
blend_if_broadcast,
foreground_rgb + (background_rgb - foreground_rgb) * (1 - shadow_factor),
background_rgb + (foreground_rgb - background_rgb) * highlight_factor
)
blended_a = np.where(
blend_if_broadcast,
fga_broadast + bga_broadcast * (1 - fga_broadast),
bga_broadcast
)
blended_rgba = np.dstack((blended_rgb, blended_a))
# Scale the RGBA values back to the range [0, 255]
blended_rgba = (blended_rgba * 255).astype(np.uint8)
return blended_rgba
but it doesn't work properly if foreground image has its own mask already on the input. Here are also images I blend as expected results and my results obtained with
blended = normal_complex_blend_if(
filtered_image,
underlying_layer,
underlying_layer_shadows_range=(10, 55),
underlying_layer_highlights_range=(255, 255)
)
Inputs [two images, one of them is quite white text]:
Output:
Both functions take input images in form of 0 - 255 uint8 numpy array in shape (h, w, ch).
Can anyone help with this issue? Thank You
Upvotes: 1
Views: 221
Reputation: 520
I spent additional time to investigate the problem and finally I found a solution. I describe the main idea of the solution for the future generations. Basically, I split problem to several intervals and solve one by one.
In out-of-range case, there are used pixels from background image. In mid-range case, pixels are blended with standard method by using original RGBA channels. For shadows-range, you have to compute scaling factor based on background gray scale and distance from given interval boundaries. That scaling factor is used to adjust alpha channel of foreground image and related pixels are replaced. Same is done for highlights-range. Note that you have to invert shadows scaling factor to reflect, that highlight vs. shadows are should have maximal opacity in different part of defined intervals.
Code pasted bellow does not handle cases for overlapping intervals as allowed in Photoshop. Requires better implementation. But idea here should be clear.
import numpy as np
Float32 = np.float32
Int32 = np.int32
UInt32 = np.uint32
UInt8 = np.uint8
Int = int
def rgb_to_luminosity(rgb: np.ndarray) -> np.ndarray:
int_dtypes = (UInt8, UInt32, Int)
original_type = rgb.dtype
if original_type in int_dtypes:
rgb = rgb.astype(Float32) / 255.0
luminosity = 0.299 * rgb[:, :, 0] + 0.587 * rgb[:, :, 1] + 0.114 * rgb[:, :, 2]
return (luminosity * 255).astype(original_type) if original_type in int_dtypes else luminosity
def expand_as_rgba(image: np.ndarray) -> np.ndarray:
try:
if image.shape[2] == 3:
return np.dstack((image, np.ones(image.shape[:2] + (1,)) * 255)).astype(np.uint8)
elif image.shape[2] != 4:
raise IndexError("")
except IndexError:
raise TypeError("Invalid image type. Expecting RGB or RGBA!")
return image
def _sanitize_inputs(
underlying_layer_shadows_range: Tuple[Int, Int],
underlying_layer_highlights_range: Tuple[Int, Int]
) -> Tuple[Tuple[Int, Int], Tuple[Int, Int]]:
if underlying_layer_shadows_range[0] > underlying_layer_shadows_range[1]:
print("Invalid shadows")
underlying_layer_shadows_range = (underlying_layer_shadows_range[0], underlying_layer_shadows_range[0])
if underlying_layer_highlights_range[0] > underlying_layer_highlights_range[1]:
print("Invalid highlights")
underlying_layer_highlights_range = (underlying_layer_highlights_range[1], underlying_layer_highlights_range[1])
if underlying_layer_shadows_range[1] >= underlying_layer_highlights_range[0]:
msg = "Unsupported options: `underlying_layer_shadows_range[1]` cannot " \
"be higher than `underlying_layer_highlights_range[0]`."
raise ValueError(msg)
return underlying_layer_shadows_range, underlying_layer_highlights_range
def _normal_blend(fg_rgb: np.ndarray, fg_a: np.ndarray, bg_rgb: np.ndarray, bg_a: np.ndarray) -> np.ndarray:
fg_a = np.expand_dims(fg_a, 2) if len(fg_a.shape) == 2 else fg_a
bg_a = np.expand_dims(bg_a, 2) if len(bg_a.shape) == 2 else bg_a
return fg_rgb * fg_a + bg_rgb * bg_a * (1 - fg_a)
def normal_blend_if(
this_layer: np.ndarray,
underlying_layer: np.ndarray,
underlying_layer_shadows_range: Tuple[Int, Int] = (0, 0),
underlying_layer_highlights_range: Tuple[Int, Int] = (255, 255)
) -> np.ndarray:
# To avoid any mutation on original inputs
this_layer, underlying_layer = this_layer.copy(), underlying_layer.copy()
# Input validation.
bg_shadows, bg_highlights = _sanitize_inputs(underlying_layer_shadows_range, underlying_layer_highlights_range)
# Expand with alpha if missing
this_layer = expand_as_rgba(this_layer) / 255.0
underlying_layer = expand_as_rgba(underlying_layer) / 255.0
# Calculate the luminosity of the background image
bg_luminosity = (rgb_to_luminosity(underlying_layer) * 255.0).astype(UInt8)
# Expand dimensions for further use
fg_rgb, bg_rgb = this_layer[:, :, :3], underlying_layer[:, :, :3]
fg_a, bg_a = this_layer[:, :, 3], underlying_layer[:, :, 3]
# Define result image
blended_rgb = np.ones(bg_rgb.shape).astype(Float32)
# Determine blending conditions
# ### Out of range
bg_condition = (bg_luminosity < bg_shadows[0]) | (bg_luminosity > bg_highlights[1])
# ### Middle range
blending_condition = (bg_luminosity >= bg_shadows[1]) & (bg_luminosity <= bg_highlights[0])
# ### Shadows range
shadows_condition = (bg_luminosity >= bg_shadows[0]) & (bg_luminosity <= bg_shadows[1])
# ### Highlights range
highlights_condition = (bg_luminosity >= bg_highlights[0]) & (bg_luminosity <= bg_highlights[1])
# Calculate the blending factors for the shadow and highlight ranges
shadow_factor = np.interp(bg_luminosity, [bg_shadows[0], bg_shadows[1]], [0, 1])
highlight_factor = np.interp(bg_luminosity, [bg_highlights[0], bg_highlights[1]], [0, 1])
# Blending (replace pixels with bakground pixels) out of codnitions range
blended_rgb[bg_condition] = bg_rgb[bg_condition]
# Blending within middle range
blended_ = _normal_blend(fg_rgb, fg_a, bg_rgb, bg_a)
blended_rgb[blending_condition] = blended_[blending_condition]
# Blending within shadows range
fg_a[shadows_condition] = (fg_a * shadow_factor)[shadows_condition]
shadows_blended_ = _normal_blend(fg_rgb, fg_a, bg_rgb, bg_a)
blended_rgb[shadows_condition] = shadows_blended_[shadows_condition]
# Blending within highlights range
fg_a[highlights_condition] = (fg_a * (1 - highlight_factor))[highlights_condition]
highlights_blended_ = _normal_blend(fg_rgb, fg_a, bg_rgb, bg_a)
blended_rgb[highlights_condition] = highlights_blended_[highlights_condition]
# Transform image to Uint8 form
return (blended_rgb * 255).astype(UInt8)
Upvotes: 1