1501 lines
58 KiB
Python
1501 lines
58 KiB
Python
# By ManglerFTW (Discord: ManglerFTW)
|
|
#
|
|
# Copyright 2023 Peter Mango (ManglerFTW)
|
|
#
|
|
# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to
|
|
# deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
|
# and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
#
|
|
# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
#
|
|
# THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
# THE SOFTWARE.
|
|
|
|
import numpy as np
|
|
from collections import namedtuple
|
|
import cv2
|
|
import torch
|
|
import sys
|
|
import os
|
|
import folder_paths as comfy_paths
|
|
from torchvision.ops import masks_to_boxes
|
|
import torchvision.transforms.functional as TF
|
|
import torch.nn.functional as F
|
|
from PIL import Image, ImageFilter, ImageOps
|
|
import subprocess
|
|
import math
|
|
|
|
# Check for CUDA availability
|
|
device = 'cuda' if torch.cuda.is_available() else 'cpu'
|
|
|
|
ARRAY_DATATYPE = torch.int32 # Corresponding to 'l'
|
|
|
|
Rgb = namedtuple('Rgb', ('r', 'g', 'b'))
|
|
Hsl = namedtuple('Hsl', ('h', 's', 'l'))
|
|
|
|
VERY_BIG_SIZE = 1024 * 1024
|
|
MAX_RESOLUTION=8192
|
|
|
|
MODELS_DIR = comfy_paths.models_dir
|
|
|
|
class cstr(str):
|
|
class color:
|
|
END = '\33[0m'
|
|
BOLD = '\33[1m'
|
|
ITALIC = '\33[3m'
|
|
UNDERLINE = '\33[4m'
|
|
BLINK = '\33[5m'
|
|
BLINK2 = '\33[6m'
|
|
SELECTED = '\33[7m'
|
|
|
|
BLACK = '\33[30m'
|
|
RED = '\33[31m'
|
|
GREEN = '\33[32m'
|
|
YELLOW = '\33[33m'
|
|
BLUE = '\33[34m'
|
|
VIOLET = '\33[35m'
|
|
BEIGE = '\33[36m'
|
|
WHITE = '\33[37m'
|
|
|
|
BLACKBG = '\33[40m'
|
|
REDBG = '\33[41m'
|
|
GREENBG = '\33[42m'
|
|
YELLOWBG = '\33[43m'
|
|
BLUEBG = '\33[44m'
|
|
VIOLETBG = '\33[45m'
|
|
BEIGEBG = '\33[46m'
|
|
WHITEBG = '\33[47m'
|
|
|
|
GREY = '\33[90m'
|
|
LIGHTRED = '\33[91m'
|
|
LIGHTGREEN = '\33[92m'
|
|
LIGHTYELLOW = '\33[93m'
|
|
LIGHTBLUE = '\33[94m'
|
|
LIGHTVIOLET = '\33[95m'
|
|
LIGHTBEIGE = '\33[96m'
|
|
LIGHTWHITE = '\33[97m'
|
|
|
|
GREYBG = '\33[100m'
|
|
LIGHTREDBG = '\33[101m'
|
|
LIGHTGREENBG = '\33[102m'
|
|
LIGHTYELLOWBG = '\33[103m'
|
|
LIGHTBLUEBG = '\33[104m'
|
|
LIGHTVIOLETBG = '\33[105m'
|
|
LIGHTBEIGEBG = '\33[106m'
|
|
LIGHTWHITEBG = '\33[107m'
|
|
|
|
@staticmethod
|
|
def add_code(name, code):
|
|
if not hasattr(cstr.color, name.upper()):
|
|
setattr(cstr.color, name.upper(), code)
|
|
else:
|
|
raise ValueError(f"'cstr' object already contains a code with the name '{name}'.")
|
|
|
|
def __new__(cls, text):
|
|
return super().__new__(cls, text)
|
|
|
|
def __getattr__(self, attr):
|
|
if attr.lower().startswith("_cstr"):
|
|
code = getattr(self.color, attr.upper().lstrip("_cstr"))
|
|
modified_text = self.replace(f"__{attr[1:]}__", f"{code}")
|
|
return cstr(modified_text)
|
|
elif attr.upper() in dir(self.color):
|
|
code = getattr(self.color, attr.upper())
|
|
modified_text = f"{code}{self}{self.color.END}"
|
|
return cstr(modified_text)
|
|
elif attr.lower() in dir(cstr):
|
|
return getattr(cstr, attr.lower())
|
|
else:
|
|
raise AttributeError(f"'cstr' object has no attribute '{attr}'")
|
|
|
|
def print(self, **kwargs):
|
|
print(self, **kwargs)
|
|
|
|
def tensor2rgb(t: torch.Tensor) -> torch.Tensor:
|
|
size = t.size()
|
|
if (len(size) < 4):
|
|
return t.unsqueeze(3).repeat(1, 1, 1, 3)
|
|
if size[3] == 1:
|
|
return t.repeat(1, 1, 1, 3)
|
|
elif size[3] == 4:
|
|
return t[:, :, :, :3]
|
|
else:
|
|
return t
|
|
|
|
def tensor2rgba(t: torch.Tensor) -> torch.Tensor:
|
|
size = t.size()
|
|
if (len(size) < 4):
|
|
return t.unsqueeze(3).repeat(1, 1, 1, 4)
|
|
elif size[3] == 1:
|
|
return t.repeat(1, 1, 1, 4)
|
|
elif size[3] == 3:
|
|
alpha_tensor = torch.ones((size[0], size[1], size[2], 1))
|
|
return torch.cat((t, alpha_tensor), dim=3)
|
|
else:
|
|
return t
|
|
|
|
def tensor2mask(t: torch.Tensor) -> torch.Tensor:
|
|
size = t.size()
|
|
if (len(size) < 4):
|
|
return t
|
|
if size[3] == 1:
|
|
return t[:,:,:,0]
|
|
elif size[3] == 4:
|
|
# Not sure what the right thing to do here is. Going to try to be a little smart and use alpha unless all alpha is 1 in case we'll fallback to RGB behavior
|
|
if torch.min(t[:, :, :, 3]).item() != 1.:
|
|
return t[:,:,:,3]
|
|
|
|
return TF.rgb_to_grayscale(tensor2rgb(t).permute(0,3,1,2), num_output_channels=1)[:,0,:,:]
|
|
|
|
def tensor2batch(t: torch.Tensor, bs: torch.Size) -> torch.Tensor:
|
|
if len(t.size()) < len(bs):
|
|
t = t.unsqueeze(3)
|
|
if t.size()[0] < bs[0]:
|
|
t.repeat(bs[0], 1, 1, 1)
|
|
dim = bs[3]
|
|
if dim == 1:
|
|
return tensor2mask(t)
|
|
elif dim == 3:
|
|
return tensor2rgb(t)
|
|
elif dim == 4:
|
|
return tensor2rgba(t)
|
|
|
|
def tensors2common(t1: torch.Tensor, t2: torch.Tensor) -> (torch.Tensor, torch.Tensor):
|
|
t1s = t1.size()
|
|
t2s = t2.size()
|
|
if len(t1s) < len(t2s):
|
|
t1 = t1.unsqueeze(3)
|
|
elif len(t1s) > len(t2s):
|
|
t2 = t2.unsqueeze(3)
|
|
|
|
if len(t1.size()) == 3:
|
|
if t1s[0] < t2s[0]:
|
|
t1 = t1.repeat(t2s[0], 1, 1)
|
|
elif t1s[0] > t2s[0]:
|
|
t2 = t2.repeat(t1s[0], 1, 1)
|
|
else:
|
|
if t1s[0] < t2s[0]:
|
|
t1 = t1.repeat(t2s[0], 1, 1, 1)
|
|
elif t1s[0] > t2s[0]:
|
|
t2 = t2.repeat(t1s[0], 1, 1, 1)
|
|
|
|
t1s = t1.size()
|
|
t2s = t2.size()
|
|
if len(t1s) > 3 and t1s[3] < t2s[3]:
|
|
return tensor2batch(t1, t2s), t2
|
|
elif len(t1s) > 3 and t1s[3] > t2s[3]:
|
|
return t1, tensor2batch(t2, t1s)
|
|
else:
|
|
return t1, t2
|
|
|
|
# Tensor to PIL
|
|
def tensor2pil(image):
|
|
return Image.fromarray(np.clip(255. * image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8))
|
|
|
|
# PIL to Tensor
|
|
def pil2tensor(image):
|
|
return torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0)
|
|
|
|
# PIL to Tensor
|
|
def pil2tensor_stacked(image):
|
|
if isinstance(image, Image.Image):
|
|
return torch.from_numpy(np.array(image).astype(np.float32) / 255.0)
|
|
elif isinstance(image, torch.Tensor):
|
|
return image
|
|
else:
|
|
raise ValueError(f"Unexpected datatype for input to 'pil2tensor_stacked'. Expected a PIL Image or tensor, but received type: {type(image)}")
|
|
|
|
|
|
|
|
class Color(object):
|
|
def __init__(self, r, g, b, proportion):
|
|
self.rgb = Rgb(r, g, b)
|
|
self.proportion = proportion
|
|
|
|
def __repr__(self):
|
|
return "<colorgram.py Color: {}, {}%>".format(
|
|
str(self.rgb), str(self.proportion * 100))
|
|
|
|
@property
|
|
def hsl(self):
|
|
try:
|
|
return self._hsl
|
|
except AttributeError:
|
|
self._hsl = Hsl(*hsl(*self.rgb))
|
|
return self._hsl
|
|
|
|
def extract(image_np, number_of_colors, mask_np=None):
|
|
# Check and convert the image if needed
|
|
if len(image_np.shape) == 2 or image_np.shape[2] != 3: # If grayscale or not RGB
|
|
image_np = cv2.cvtColor(image_np, cv2.COLOR_GRAY2RGB)
|
|
|
|
samples = sample(image_np, mask_np)
|
|
used = pick_used(samples)
|
|
used.sort(key=lambda x: x[0], reverse=True)
|
|
return get_colors(samples, used, number_of_colors)
|
|
|
|
def sample(image, mask=None):
|
|
top_two_bits = 0b11000000
|
|
|
|
sides = 1 << 2
|
|
cubes = sides ** 7
|
|
|
|
samples = torch.zeros((cubes,), dtype=torch.float32, device=device) # Make sure samples is of float32 type
|
|
|
|
# Handle mask
|
|
if mask is not None:
|
|
mask_values = (torch.rand_like(mask, dtype=torch.float32) * 255).int()
|
|
active_pixels = mask_values > mask
|
|
else:
|
|
active_pixels = torch.ones_like(image[:, :, 0], dtype=torch.bool)
|
|
|
|
# Calculate RGB, HSL, and Y
|
|
r, g, b = image[:, :, 0], image[:, :, 1], image[:, :, 2]
|
|
h, s, l = hsl(r, g, b) # We need to convert the hsl function to use PyTorch
|
|
Y = (r * 0.2126 + g * 0.7152 + b * 0.0722).int()
|
|
|
|
# Packing
|
|
packed = ((Y & top_two_bits) << 4) | ((h & top_two_bits) << 2) | (l & top_two_bits)
|
|
packed *= 4
|
|
|
|
# Accumulate samples
|
|
packed_active = packed[active_pixels]
|
|
r_active, g_active, b_active = r[active_pixels], g[active_pixels], b[active_pixels]
|
|
|
|
samples.index_add_(0, packed_active, r_active)
|
|
samples.index_add_(0, packed_active + 1, g_active)
|
|
samples.index_add_(0, packed_active + 2, b_active)
|
|
samples.index_add_(0, packed_active + 3, torch.ones_like(packed_active, dtype=torch.float32))
|
|
|
|
return samples
|
|
|
|
def pick_used(samples):
|
|
# Find indices where count (every 4th value) is non-zero
|
|
non_zero_indices = torch.arange(0, samples.size(0), 4, device=samples.device)[samples[3::4] > 0]
|
|
|
|
# Get counts for non-zero indices
|
|
counts = samples[non_zero_indices + 3]
|
|
|
|
# Combine counts and indices
|
|
used = torch.stack((counts, non_zero_indices), dim=-1)
|
|
|
|
# Convert torch tensors to list of tuples on CPU
|
|
used_tuples = [(int(count.item()), int(idx.item())) for count, idx in zip(used[:, 0], used[:, 1])]
|
|
|
|
return used_tuples
|
|
|
|
def get_colors(samples, used, number_of_colors):
|
|
number_of_colors = min(number_of_colors, len(used))
|
|
used = used[:number_of_colors]
|
|
|
|
# Extract counts and indices
|
|
counts, indices = zip(*used)
|
|
counts = torch.tensor(counts, dtype=torch.long, device=device)
|
|
indices = torch.tensor(indices, dtype=torch.long, device=device)
|
|
|
|
# Calculate total pixels
|
|
total_pixels = torch.sum(counts)
|
|
|
|
# Get RGB values
|
|
r_vals = samples[indices] // counts
|
|
g_vals = samples[indices + 1] // counts
|
|
b_vals = samples[indices + 2] // counts
|
|
|
|
# Convert Torch tensors to lists
|
|
r_vals_list = r_vals.tolist()
|
|
g_vals_list = g_vals.tolist()
|
|
b_vals_list = b_vals.tolist()
|
|
counts_list = counts.tolist()
|
|
|
|
# Create Color objects
|
|
colors = [Color(r, g, b, count) for r, g, b, count in zip(r_vals_list, g_vals_list, b_vals_list, counts_list)]
|
|
|
|
# Update proportions
|
|
for color in colors:
|
|
color.proportion /= total_pixels.item()
|
|
|
|
return colors
|
|
|
|
def hsl(r, g, b):
|
|
r, g, b = r / 255.0, g / 255.0, b / 255.0
|
|
|
|
max_val, _ = torch.max(torch.stack([r, g, b]), dim=0)
|
|
min_val, _ = torch.min(torch.stack([r, g, b]), dim=0)
|
|
diff = max_val - min_val
|
|
|
|
# Luminance
|
|
l = (max_val + min_val) / 2.0
|
|
|
|
# Saturation
|
|
s = torch.where(
|
|
(max_val == min_val) | (l == 0),
|
|
torch.zeros_like(l),
|
|
torch.where(l < 0.5, diff / (max_val + min_val), diff / (2.0 - max_val - min_val))
|
|
)
|
|
|
|
# Hue
|
|
conditions = [
|
|
max_val == r,
|
|
max_val == g,
|
|
max_val == b
|
|
]
|
|
|
|
values = [
|
|
((g - b) / diff) % 6,
|
|
((b - r) / diff) + 2,
|
|
((r - g) / diff) + 4
|
|
]
|
|
|
|
h = torch.zeros_like(r)
|
|
for condition, value in zip(conditions, values):
|
|
h = torch.where(condition, value, h)
|
|
h /= 6.0
|
|
|
|
return (h * 255).int(), (s * 255).int(), (l * 255).int()
|
|
|
|
def color_distance(pixel_color, palette_color):
|
|
return torch.norm(pixel_color - palette_color)
|
|
|
|
def segment_image(image_torch, palette_colors, mask_torch=None, threshold=128):
|
|
"""
|
|
Segment the image based on the color similarity of each color in the palette using PyTorch.
|
|
"""
|
|
if mask_torch is None:
|
|
mask_torch = torch.ones(image_torch.shape[:2], device='cuda') * 255
|
|
|
|
output_image_torch = torch.zeros_like(image_torch)
|
|
|
|
# Convert palette colors to PyTorch tensor
|
|
palette_torch = torch.tensor([list(color.rgb) for color in palette_colors], device='cuda').float()
|
|
|
|
distances = torch.norm(image_torch.unsqueeze(-2) - palette_torch, dim=-1)
|
|
closest_color_indices = torch.argmin(distances, dim=-1)
|
|
|
|
for idx, palette_color in enumerate(palette_torch):
|
|
output_image_torch[closest_color_indices == idx] = palette_color
|
|
|
|
output_image_torch[mask_torch < threshold] = image_torch[mask_torch < threshold]
|
|
|
|
# Convert the PyTorch tensor back to a numpy array for saving or further operations
|
|
output_image_np = output_image_torch.cpu().numpy().astype('uint8')
|
|
return output_image_np
|
|
|
|
def calculate_luminance_vectorized(colors):
|
|
"""Calculate the luminance of an array of RGB colors using PyTorch."""
|
|
R, G, B = colors[:, 0], colors[:, 1], colors[:, 2]
|
|
return 0.299 * R + 0.587 * G + 0.114 * B
|
|
|
|
def luminance_match(palette1, palette2):
|
|
# Convert palettes to PyTorch tensors
|
|
palette1_rgb = torch.tensor([color.rgb for color in palette1], device='cuda').float()
|
|
palette2_rgb = torch.tensor([color.rgb for color in palette2], device='cuda').float()
|
|
|
|
luminance1 = calculate_luminance_vectorized(palette1_rgb)
|
|
luminance2 = calculate_luminance_vectorized(palette2_rgb)
|
|
|
|
# Sort luminances and get the sorted indices
|
|
sorted_indices1 = torch.argsort(luminance1)
|
|
sorted_indices2 = torch.argsort(luminance2)
|
|
|
|
reordered_palette2 = [None] * len(palette2)
|
|
|
|
# Match colors based on sorted luminance order
|
|
for idx1, idx2 in zip(sorted_indices1.cpu().numpy(), sorted_indices2.cpu().numpy()):
|
|
print(f"idx1: {idx1}, idx2: {idx2}") # Add this to debug
|
|
reordered_palette2[idx1] = palette2[idx2]
|
|
|
|
return reordered_palette2
|
|
|
|
def apply_blur(image_torch, blur_radius, blur_amount):
|
|
image_torch = image_torch.float().div(255.0)
|
|
channels = image_torch.shape[2]
|
|
|
|
kernel_size = int(6 * blur_radius + 1)
|
|
kernel_size += 1 if kernel_size % 2 == 0 else 0
|
|
|
|
# Calculate the padding required to keep the output size the same
|
|
padding = kernel_size // 2
|
|
|
|
# Create a Gaussian kernel
|
|
x = torch.linspace(-blur_amount, blur_amount, kernel_size).to(image_torch.device)
|
|
x = torch.exp(-x**2 / (2 * blur_radius**2))
|
|
x /= x.sum()
|
|
kernel = x[:, None] * x[None, :]
|
|
|
|
# Apply the kernel using depthwise convolution
|
|
channels = image_torch.shape[-1]
|
|
kernel = kernel[None, None, ...].repeat(channels, 1, 1, 1)
|
|
blurred = F.conv2d(image_torch.permute(2, 0, 1)[None, ...], kernel, groups=channels, padding=padding)
|
|
|
|
# Convert the tensor back to byte and de-normalize
|
|
blurred = (blurred * 255.0).byte().squeeze(0).permute(1, 2, 0)
|
|
return blurred
|
|
|
|
def refined_replace_and_blend_colors(Source_np, img_np, palette1, modified_palette2, blur_radius=0, blur_amount=0, mask_torch=None):
|
|
# Convert numpy arrays to torch tensors on GPU
|
|
device = 'cuda' if torch.cuda.is_available() else 'cpu'
|
|
Source = torch.from_numpy(Source_np).float().to(device)
|
|
img_torch = torch.tensor(img_np, device=device).float()
|
|
|
|
palette1_rgb = torch.stack([torch.tensor(color.rgb, device=device).float() if hasattr(color, 'rgb') else torch.tensor(color, device=device).float() for color in palette1])
|
|
modified_palette2_rgb = torch.stack([torch.tensor(color.rgb, device=device).float() if hasattr(color, 'rgb') else torch.tensor(color, device=device).float() for color in modified_palette2])
|
|
|
|
# Direct color replacement using broadcasting
|
|
distances = torch.norm(img_torch[:, :, None] - palette1_rgb, dim=-1)
|
|
closest_indices = torch.argmin(distances, dim=-1)
|
|
intermediate_output = modified_palette2_rgb[closest_indices]
|
|
|
|
# Convert to uint8 if not already
|
|
intermediate_output = torch.clamp(intermediate_output, 0, 255).byte()
|
|
|
|
# Apply blur if needed
|
|
if blur_radius > 0 and blur_amount > 0:
|
|
blurred_output = apply_blur(intermediate_output, blur_radius, blur_amount)
|
|
else:
|
|
blurred_output = intermediate_output
|
|
|
|
# Blend based on the mask's intensity values if provided
|
|
if mask_torch is not None:
|
|
three_channel_mask = mask_torch[:, :, None].expand_as(Source)
|
|
output_torch = Source * (1 - three_channel_mask) + blurred_output.float() * three_channel_mask
|
|
else:
|
|
output_torch = blurred_output
|
|
|
|
output_np = output_torch.cpu().numpy().astype(np.uint8)
|
|
|
|
return output_np
|
|
|
|
def torch_rgb_to_hsv(rgb):
|
|
"""
|
|
Convert an RGB image to HSV.
|
|
Assumes rgb is a PyTorch tensor with values in [0, 1].
|
|
"""
|
|
|
|
# Get R, G, B values
|
|
r = rgb[..., 0]
|
|
g = rgb[..., 1]
|
|
b = rgb[..., 2]
|
|
|
|
max_val, _ = torch.max(rgb, dim=-1)
|
|
min_val, _ = torch.min(rgb, dim=-1)
|
|
diff = max_val - min_val
|
|
|
|
# Calculate Hue
|
|
h = torch.zeros_like(r)
|
|
h[diff == 0] = 0
|
|
mask = (max_val == r) & (diff != 0)
|
|
h[mask] = (60 * ((g[mask] - b[mask]) / diff[mask]) + 360) % 360
|
|
mask = max_val == g
|
|
h[mask] = (60 * ((b[mask] - r[mask]) / diff[mask]) + 120) % 360
|
|
mask = max_val == b
|
|
h[mask] = (60 * ((r[mask] - g[mask]) / diff[mask]) + 240) % 360
|
|
h = h / 360. # Normalize to [0, 1]
|
|
|
|
# Calculate Saturation
|
|
s = torch.zeros_like(r)
|
|
s[max_val != 0] = diff[max_val != 0] / max_val[max_val != 0]
|
|
|
|
# Value
|
|
v = max_val
|
|
|
|
hsv = torch.stack([h, s, v], dim=-1)
|
|
return hsv
|
|
|
|
def torch_hsv_to_rgb(hsv):
|
|
"""
|
|
Convert an HSV image to RGB.
|
|
Assumes hsv is a PyTorch tensor with values in [0, 1] for hue and [0, 1] for saturation/value.
|
|
"""
|
|
|
|
h = hsv[..., 0] * 360.
|
|
s = hsv[..., 1]
|
|
v = hsv[..., 2]
|
|
|
|
c = v * s
|
|
hh = h / 60.
|
|
x = c * (1 - torch.abs(hh % 2 - 1))
|
|
m = v - c
|
|
|
|
r, g, b = v, v, v # Initialize with value
|
|
|
|
mask = (hh >= 0) & (hh < 1)
|
|
r[mask] = c[mask]
|
|
g[mask] = x[mask]
|
|
|
|
mask = (hh >= 1) & (hh < 2)
|
|
r[mask] = x[mask]
|
|
g[mask] = c[mask]
|
|
|
|
mask = (hh >= 2) & (hh < 3)
|
|
g[mask] = c[mask]
|
|
b[mask] = x[mask]
|
|
|
|
mask = (hh >= 3) & (hh < 4)
|
|
g[mask] = x[mask]
|
|
b[mask] = c[mask]
|
|
|
|
mask = (hh >= 4) & (hh < 5)
|
|
r[mask] = x[mask]
|
|
b[mask] = c[mask]
|
|
|
|
mask = (hh >= 5) & (hh < 6)
|
|
r[mask] = c[mask]
|
|
b[mask] = x[mask]
|
|
|
|
r += m
|
|
g += m
|
|
b += m
|
|
|
|
rgb = torch.stack([r, g, b], dim=-1)
|
|
return rgb
|
|
|
|
def retain_luminance_hsv_swap(img1_np, img2_np, strength):
|
|
"""
|
|
Blend two images while retaining the luminance of the first.
|
|
The blending is controlled by the strength parameter.
|
|
Assumes img1_np and img2_np are numpy arrays in BGR format.
|
|
"""
|
|
|
|
# Convert BGR to RGB
|
|
img1_rgb_np = cv2.cvtColor(img1_np, cv2.COLOR_BGR2RGB).astype(float) / 255.0
|
|
img2_rgb_np = cv2.cvtColor(img2_np, cv2.COLOR_BGR2RGB).astype(float) / 255.0
|
|
|
|
# Blend the two RGB images linearly based on the strength
|
|
blended_rgb_np = (1 - strength) * img1_rgb_np + strength * img2_rgb_np
|
|
|
|
# Convert the blended RGB image and the original RGB image to YUV
|
|
blended_yuv_np = cv2.cvtColor((blended_rgb_np * 255).astype(np.uint8), cv2.COLOR_RGB2YUV)
|
|
img1_yuv_np = cv2.cvtColor(img1_np, cv2.COLOR_BGR2YUV)
|
|
|
|
# Replace the Y channel (luminance) of the blended image with the original image's luminance
|
|
blended_yuv_np[:,:,0] = img1_yuv_np[:,:,0]
|
|
|
|
# Convert back to BGR
|
|
result_bgr_np = cv2.cvtColor(blended_yuv_np, cv2.COLOR_YUV2BGR)
|
|
|
|
return result_bgr_np
|
|
|
|
def adjust_gamma_contrast(image_np, gamma, contrast, brightness, mask_np=None):
|
|
# Ensure CUDA is available
|
|
device = 'cuda' if torch.cuda.is_available() else 'cpu'
|
|
|
|
# Transfer data to PyTorch tensors and move to the appropriate device
|
|
image_torch = torch.tensor(image_np, dtype=torch.float32).to(device)
|
|
|
|
# Gamma correction using a lookup table
|
|
inv_gamma = 1.0 / gamma
|
|
table = torch.tensor([(i / 255.0) ** inv_gamma * 255 for i in range(256)], device=device).float()
|
|
gamma_corrected = torch.index_select(table, 0, image_torch.long().flatten()).reshape_as(image_torch)
|
|
|
|
# Contrast and brightness adjustment
|
|
contrast_adjusted = contrast * gamma_corrected + brightness
|
|
contrast_adjusted = torch.clamp(contrast_adjusted, 0, 255).byte()
|
|
|
|
# If mask is provided, blend the original and adjusted images
|
|
if mask_np is not None:
|
|
mask_torch = torch.tensor(mask_np, device=device).float() / 255.0
|
|
three_channel_mask = mask_torch.unsqueeze(-1).expand_as(image_torch)
|
|
contrast_adjusted = image_torch * (1 - three_channel_mask) + contrast_adjusted.float() * three_channel_mask
|
|
|
|
# Transfer data back to numpy array
|
|
result_np = contrast_adjusted.cpu().numpy()
|
|
|
|
return result_np
|
|
|
|
|
|
|
|
|
|
def CutByMask(image, mask, force_resize_width, force_resize_height, mask_mapping_optional):
|
|
|
|
if len(image.shape) < 4:
|
|
C = 1
|
|
else:
|
|
C = image.shape[3]
|
|
|
|
# We operate on RGBA to keep the code clean and then convert back after
|
|
image = tensor2rgba(image)
|
|
mask = tensor2mask(mask)
|
|
|
|
if mask_mapping_optional is not None:
|
|
mask_mapping_optional = mask_mapping_optional.long()
|
|
image = image[mask_mapping_optional]
|
|
|
|
# Scale the mask to match the image size if it isn't
|
|
B, H, W, _ = image.shape
|
|
mask = F.interpolate(mask.unsqueeze(1), size=(H, W), mode='nearest')[:,0,:,:]
|
|
|
|
MB, _, _ = mask.shape
|
|
|
|
if MB < B:
|
|
assert(B % MB == 0)
|
|
mask = mask.repeat(B // MB, 1, 1)
|
|
|
|
# Masks to boxes
|
|
is_empty = ~torch.gt(torch.max(torch.reshape(mask, [B, H * W]), dim=1).values, 0.)
|
|
mask[is_empty,0,0] = 1.
|
|
boxes = masks_to_boxes(mask)
|
|
mask[is_empty,0,0] = 0.
|
|
|
|
min_x = boxes[:,0]
|
|
min_y = boxes[:,1]
|
|
max_x = boxes[:,2]
|
|
max_y = boxes[:,3]
|
|
|
|
width = max_x - min_x + 1
|
|
height = max_y - min_y + 1
|
|
|
|
use_width = int(torch.max(width).item())
|
|
use_height = int(torch.max(height).item())
|
|
|
|
if force_resize_width > 0:
|
|
use_width = force_resize_width
|
|
|
|
if force_resize_height > 0:
|
|
use_height = force_resize_height
|
|
|
|
print("use_width: ", use_width)
|
|
print("use_height: ", use_height)
|
|
|
|
alpha_mask = torch.ones((B, H, W, 4))
|
|
alpha_mask[:,:,:,3] = mask
|
|
|
|
image = image * alpha_mask
|
|
|
|
result = torch.zeros((B, use_height, use_width, 4))
|
|
for i in range(0, B):
|
|
if not is_empty[i]:
|
|
ymin = int(min_y[i].item())
|
|
ymax = int(max_y[i].item())
|
|
xmin = int(min_x[i].item())
|
|
xmax = int(max_x[i].item())
|
|
single = (image[i, ymin:ymax+1, xmin:xmax+1,:]).unsqueeze(0)
|
|
resized = F.interpolate(single.permute(0, 3, 1, 2), size=(use_height, use_width), mode='bicubic').permute(0, 2, 3, 1)
|
|
result[i] = resized[0]
|
|
|
|
# Preserve our type unless we were previously RGB and added non-opaque alpha due to the mask size
|
|
if C == 1:
|
|
print("C == 1 output image shape: ", tensor2mask(result).shape)
|
|
return tensor2mask(result)
|
|
elif C == 3 and torch.min(result[:,:,:,3]) == 1:
|
|
print("C == 3 output image shape: ", tensor2rgb(result).shape)
|
|
return tensor2rgb(result)
|
|
else:
|
|
print("else result shape: ", result.shape)
|
|
return result
|
|
|
|
def combine(image1, image2, op, clamp_result, round_result):
|
|
image1, image2 = tensors2common(image1, image2)
|
|
|
|
if op == "union (max)":
|
|
result = torch.max(image1, image2)
|
|
elif op == "intersection (min)":
|
|
result = torch.min(image1, image2)
|
|
elif op == "difference":
|
|
result = image1 - image2
|
|
elif op == "multiply":
|
|
result = image1 * image2
|
|
elif op == "multiply_alpha":
|
|
image1 = tensor2rgba(image1)
|
|
image2 = tensor2mask(image2)
|
|
result = torch.cat((image1[:, :, :, :3], (image1[:, :, :, 3] * image2).unsqueeze(3)), dim=3)
|
|
elif op == "add":
|
|
result = image1 + image2
|
|
elif op == "greater_or_equal":
|
|
result = torch.where(image1 >= image2, 1., 0.)
|
|
elif op == "greater":
|
|
result = torch.where(image1 > image2, 1., 0.)
|
|
|
|
if clamp_result == "yes":
|
|
result = torch.min(torch.max(result, torch.tensor(0.)), torch.tensor(1.))
|
|
if round_result == "yes":
|
|
result = torch.round(result)
|
|
|
|
return result
|
|
|
|
def apply_color_correction(target_image, source_image, factor=1):
|
|
|
|
if not isinstance(source_image, (torch.Tensor, Image.Image)):
|
|
raise ValueError(f"Unexpected datatype for 'source_image' at method start. Expected a tensor or PIL Image, but received type: {type(source_image)}")
|
|
|
|
# Ensure source_image is a tensor
|
|
if isinstance(source_image, Image.Image): # Check if it's a PIL Image
|
|
source_image = pil2tensor_stacked(source_image) # Convert it to tensor
|
|
|
|
if not isinstance(source_image, (torch.Tensor, Image.Image)):
|
|
raise ValueError(f"Unexpected datatype for 'source_image'. Expected a tensor or PIL Image, but received type: {type(source_image)}")
|
|
|
|
# Get the batch size
|
|
batch_size = source_image.shape[0]
|
|
output_images = []
|
|
|
|
for i in range(batch_size):
|
|
# Convert the source and target images to NumPy arrays for the i-th image in the batch
|
|
source_numpy = source_image[i, ...].numpy()
|
|
target_numpy = target_image[i, ...].numpy()
|
|
|
|
# Convert to float32
|
|
source_numpy = source_numpy.astype(np.float32)
|
|
target_numpy = target_numpy.astype(np.float32)
|
|
|
|
# If the images have an alpha channel, remove it for the color transformations
|
|
if source_numpy.shape[-1] == 4:
|
|
source_numpy = source_numpy[..., :3]
|
|
if target_numpy.shape[-1] == 4:
|
|
target_numpy = target_numpy[..., :3]
|
|
|
|
# Compute the mean and standard deviation of the color channels for both images
|
|
target_mean, target_std = np.mean(source_numpy, axis=(0, 1)), np.std(source_numpy, axis=(0, 1))
|
|
source_mean, source_std = np.mean(target_numpy, axis=(0, 1)), np.std(target_numpy, axis=(0, 1))
|
|
|
|
adjusted_source_mean = target_mean + factor * (target_mean - source_mean)
|
|
adjusted_source_std = target_std + factor * (target_std - source_std)
|
|
|
|
# Normalize the target image (zero mean and unit variance)
|
|
target_norm = (target_numpy - target_mean) / target_std
|
|
|
|
# Scale and shift the normalized target image to match the exaggerated source image statistics
|
|
matched_rgb = target_norm * adjusted_source_std + adjusted_source_mean
|
|
|
|
# Clip values to [0, 1] and convert to PIL Image
|
|
img = Image.fromarray(np.clip(matched_rgb * 255, 0, 255).astype('uint8'), 'RGB')
|
|
|
|
# Convert the PIL Image to a tensor and append to the list
|
|
img_tensor = pil2tensor_stacked(img)
|
|
output_images.append(img_tensor)
|
|
|
|
# Stack the list of tensors to get the batch of corrected images
|
|
stacked_images = torch.stack(output_images)
|
|
|
|
return stacked_images
|
|
|
|
def PasteByMask(image_base, image_to_paste, mask, resize_behavior, mask_mapping_optional):
|
|
image_base = tensor2rgba(image_base)
|
|
image_to_paste = tensor2rgba(image_to_paste)
|
|
mask = tensor2mask(mask)
|
|
|
|
# Scale the mask to be a matching size if it isn't
|
|
B, H, W, C = image_base.shape
|
|
MB = mask.shape[0]
|
|
PB = image_to_paste.shape[0]
|
|
if mask_mapping_optional is None:
|
|
if B < PB:
|
|
assert(PB % B == 0)
|
|
image_base = image_base.repeat(PB // B, 1, 1, 1)
|
|
B, H, W, C = image_base.shape
|
|
if MB < B:
|
|
assert(B % MB == 0)
|
|
mask = mask.repeat(B // MB, 1, 1)
|
|
elif B < MB:
|
|
assert(MB % B == 0)
|
|
image_base = image_base.repeat(MB // B, 1, 1, 1)
|
|
if PB < B:
|
|
assert(B % PB == 0)
|
|
image_to_paste = image_to_paste.repeat(B // PB, 1, 1, 1)
|
|
mask = F.interpolate(mask.unsqueeze(1), size=(H, W), mode='nearest')[:,0,:,:]
|
|
MB, MH, MW = mask.shape
|
|
|
|
# masks_to_boxes errors if the tensor is all zeros, so we'll add a single pixel and zero it out at the end
|
|
is_empty = ~torch.gt(torch.max(torch.reshape(mask,[MB, MH * MW]), dim=1).values, 0.)
|
|
mask[is_empty,0,0] = 1.
|
|
boxes = masks_to_boxes(mask)
|
|
mask[is_empty,0,0] = 0.
|
|
|
|
min_x = boxes[:,0]
|
|
min_y = boxes[:,1]
|
|
max_x = boxes[:,2]
|
|
max_y = boxes[:,3]
|
|
mid_x = (min_x + max_x) / 2
|
|
mid_y = (min_y + max_y) / 2
|
|
|
|
target_width = max_x - min_x + 1
|
|
target_height = max_y - min_y + 1
|
|
|
|
result = image_base.detach().clone()
|
|
|
|
for i in range(0, MB):
|
|
if i >= len(image_to_paste):
|
|
raise ValueError(f"image_to_paste does not have an entry for mask index {i}")
|
|
if is_empty[i]:
|
|
continue
|
|
else:
|
|
image_index = i
|
|
if mask_mapping_optional is not None:
|
|
image_index = mask_mapping_optional[i].item()
|
|
source_size = image_to_paste.size()
|
|
SB, SH, SW, _ = image_to_paste.shape
|
|
|
|
# Figure out the desired size
|
|
width = int(target_width[i].item())
|
|
height = int(target_height[i].item())
|
|
if resize_behavior == "keep_ratio_fill":
|
|
target_ratio = width / height
|
|
actual_ratio = SW / SH
|
|
if actual_ratio > target_ratio:
|
|
width = int(height * actual_ratio)
|
|
elif actual_ratio < target_ratio:
|
|
height = int(width / actual_ratio)
|
|
elif resize_behavior == "keep_ratio_fit":
|
|
target_ratio = width / height
|
|
actual_ratio = SW / SH
|
|
if actual_ratio > target_ratio:
|
|
height = int(width / actual_ratio)
|
|
elif actual_ratio < target_ratio:
|
|
width = int(height * actual_ratio)
|
|
elif resize_behavior == "source_size" or resize_behavior == "source_size_unmasked":
|
|
width = SW
|
|
height = SH
|
|
|
|
# Resize the image we're pasting if needed
|
|
resized_image = image_to_paste[i].unsqueeze(0)
|
|
if SH != height or SW != width:
|
|
resized_image = F.interpolate(resized_image.permute(0, 3, 1, 2), size=(height,width), mode='bicubic').permute(0, 2, 3, 1)
|
|
|
|
pasting = torch.ones([H, W, C])
|
|
ymid = float(mid_y[i].item())
|
|
ymin = int(math.floor(ymid - height / 2)) + 1
|
|
ymax = int(math.floor(ymid + height / 2)) + 1
|
|
xmid = float(mid_x[i].item())
|
|
xmin = int(math.floor(xmid - width / 2)) + 1
|
|
xmax = int(math.floor(xmid + width / 2)) + 1
|
|
|
|
_, source_ymax, source_xmax, _ = resized_image.shape
|
|
source_ymin, source_xmin = 0, 0
|
|
|
|
if xmin < 0:
|
|
source_xmin = abs(xmin)
|
|
xmin = 0
|
|
if ymin < 0:
|
|
source_ymin = abs(ymin)
|
|
ymin = 0
|
|
if xmax > W:
|
|
source_xmax -= (xmax - W)
|
|
xmax = W
|
|
if ymax > H:
|
|
source_ymax -= (ymax - H)
|
|
ymax = H
|
|
|
|
pasting[ymin:ymax, xmin:xmax, :] = resized_image[0, source_ymin:source_ymax, source_xmin:source_xmax, :]
|
|
pasting[:, :, 3] = 1.
|
|
|
|
pasting_alpha = torch.zeros([H, W])
|
|
pasting_alpha[ymin:ymax, xmin:xmax] = resized_image[0, source_ymin:source_ymax, source_xmin:source_xmax, 3]
|
|
|
|
if resize_behavior == "keep_ratio_fill" or resize_behavior == "source_size_unmasked":
|
|
# If we explicitly want to fill the area, we are ok with extending outside
|
|
paste_mask = pasting_alpha.unsqueeze(2).repeat(1, 1, 4)
|
|
else:
|
|
paste_mask = torch.min(pasting_alpha, mask[i]).unsqueeze(2).repeat(1, 1, 4)
|
|
result[image_index] = pasting * paste_mask + result[image_index] * (1. - paste_mask)
|
|
return result
|
|
|
|
class Mask_Ops:
|
|
def __init__(self):
|
|
pass
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",),
|
|
"text": ("STRING", {"default":"", "multiline": False}),
|
|
"separate_mask": ("INT", {"default":0, "min":0, "max":1, "step":1}),
|
|
"text_sigma": ("INT", {"default":30, "min":0, "max":150, "step":1}),
|
|
"use_text": ("INT", {"default":0, "min":0, "max":1, "step":1}),
|
|
"blend_percentage": ("FLOAT", {"default": 0, "min": 0.0, "max": 1.0, "step": 0.01}),
|
|
"black_level": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 255.0, "step": 0.1}),
|
|
"mid_level": ("FLOAT", {"default": 127.5, "min": 0.0, "max": 255.0, "step": 0.1}),
|
|
"white_level": ("FLOAT", {"default": 255, "min": 0.0, "max": 255.0, "step": 0.1}),
|
|
"channel": (["red", "green", "blue"],),
|
|
"shrink_grow": ("INT", {"default": 0, "min": -128, "max": 128, "step": 1}),
|
|
"invert": ("INT", {"default":0, "min":0, "max":1, "step":1}),
|
|
"blur_radius": ("FLOAT", {"default": 5.0, "min": 0.0, "max": 1024, "step": 0.1}),
|
|
},
|
|
"optional": {
|
|
"mask": ("MASK",),
|
|
},
|
|
}
|
|
|
|
CATEGORY = "I2I"
|
|
|
|
RETURN_TYPES = ("IMAGE", "MASK", "MASK_MAPPING",)
|
|
RETURN_NAMES = ("mask_image", "mask", "mask mapping")
|
|
FUNCTION = "Mask_Ops"
|
|
|
|
def Mask_Ops(self, image, text, separate_mask, text_sigma, use_text, blend_percentage, black_level, mid_level, white_level, channel, shrink_grow, invert=0, blur_radius=5.0, mask=None):
|
|
channels = ["red", "green", "blue"]
|
|
|
|
# Freeze PIP modules
|
|
def packages(versions=False):
|
|
import sys
|
|
import subprocess
|
|
return [( r.decode().split('==')[0] if not versions else r.decode() ) for r in subprocess.check_output([sys.executable, '-s', '-m', 'pip', 'freeze']).split()]
|
|
|
|
# PIL to Mask
|
|
def pil2mask(image):
|
|
image_np = np.array(image.convert("L")).astype(np.float32) / 255.0
|
|
mask = torch.from_numpy(image_np)
|
|
return 1.0 - mask
|
|
|
|
def gaussian_region(image, radius=5.0):
|
|
image = ImageOps.invert(image.convert("L"))
|
|
image = image.filter(ImageFilter.GaussianBlur(radius=int(radius)))
|
|
return image.convert("RGB")
|
|
|
|
# scipy handling
|
|
if 'scipy' not in packages():
|
|
cstr("Installing `scipy` ...").msg.print()
|
|
subprocess.check_call([sys.executable, '-s', '-m', 'pip', 'install', 'scipy'])
|
|
try:
|
|
import scipy
|
|
except ImportError as e:
|
|
cstr("Unable to import tools for certain masking procedures.").msg.print()
|
|
print(e)
|
|
|
|
def smooth_region(image, tolerance):
|
|
from scipy.ndimage import gaussian_filter
|
|
image = image.convert("L")
|
|
mask_array = np.array(image)
|
|
smoothed_array = gaussian_filter(mask_array, sigma=tolerance)
|
|
threshold = np.max(smoothed_array) / 2
|
|
smoothed_mask = np.where(smoothed_array >= threshold, 255, 0).astype(np.uint8)
|
|
smoothed_image = Image.fromarray(smoothed_mask, mode="L")
|
|
return ImageOps.invert(smoothed_image.convert("RGB"))
|
|
|
|
def erode_region(image, iterations):
|
|
from scipy.ndimage import binary_erosion
|
|
image = image.convert("L")
|
|
binary_mask = np.array(image) > 0
|
|
eroded_mask = binary_erosion(binary_mask, iterations=iterations)
|
|
eroded_image = Image.fromarray(eroded_mask.astype(np.uint8) * 255, mode="L")
|
|
return ImageOps.invert(eroded_image.convert("RGB"))
|
|
|
|
def dilate_region(image, iterations):
|
|
from scipy.ndimage import binary_dilation
|
|
image = image.convert("L")
|
|
binary_mask = np.array(image) > 0
|
|
dilated_mask = binary_dilation(binary_mask, iterations=iterations)
|
|
dilated_image = Image.fromarray(dilated_mask.astype(np.uint8) * 255, mode="L")
|
|
return ImageOps.invert(dilated_image.convert("RGB"))
|
|
|
|
def erode(masks, iterations):
|
|
iterations = iterations * -1
|
|
if masks.ndim > 3:
|
|
regions = []
|
|
for mask in masks:
|
|
mask_np = np.clip(255. * mask.cpu().numpy().squeeze(), 0, 255).astype(np.uint8)
|
|
pil_image = Image.fromarray(mask_np, mode="L")
|
|
region_mask = erode_region(pil_image, iterations)
|
|
region_tensor = pil2mask(region_mask).unsqueeze(0).unsqueeze(1)
|
|
regions.append(region_tensor)
|
|
regions_tensor = torch.cat(regions, dim=0)
|
|
return regions_tensor
|
|
else:
|
|
mask_np = np.clip(255. * masks.cpu().numpy().squeeze(), 0, 255).astype(np.uint8)
|
|
pil_image = Image.fromarray(mask_np, mode="L")
|
|
region_mask = erode_region(pil_image, iterations)
|
|
region_tensor = pil2mask(region_mask).unsqueeze(0).unsqueeze(1)
|
|
return region_tensor
|
|
|
|
def dilate(masks, iterations):
|
|
if masks.ndim > 3:
|
|
regions = []
|
|
for mask in masks:
|
|
mask_np = np.clip(255. * mask.cpu().numpy().squeeze(), 0, 255).astype(np.uint8)
|
|
pil_image = Image.fromarray(mask_np, mode="L")
|
|
region_mask = dilate_region(pil_image, iterations)
|
|
region_tensor = pil2mask(region_mask).unsqueeze(0).unsqueeze(1)
|
|
regions.append(region_tensor)
|
|
regions_tensor = torch.cat(regions, dim=0)
|
|
return regions_tensor
|
|
else:
|
|
mask_np = np.clip(255. * masks.cpu().numpy().squeeze(), 0, 255).astype(np.uint8)
|
|
pil_image = Image.fromarray(mask_np, mode="L")
|
|
region_mask = dilate_region(pil_image, iterations)
|
|
region_tensor = pil2mask(region_mask).unsqueeze(0).unsqueeze(1)
|
|
return region_tensor
|
|
|
|
def separate(mask, separate_flag=1):
|
|
if separate_flag == 0:
|
|
# Create an unseparated mapping tensor of the same length as the batch dimension
|
|
mapping = torch.arange(mask.shape[0], device=mask.device, dtype=torch.int)
|
|
return mask, mapping
|
|
|
|
mask = tensor2mask(mask)
|
|
|
|
thresholded = torch.gt(mask, 0).unsqueeze(1)
|
|
B, H, W = mask.shape
|
|
components = torch.arange(B * H * W, device=mask.device, dtype=mask.dtype).reshape(B, 1, H, W) + 1
|
|
components[~thresholded] = 0
|
|
|
|
while True:
|
|
previous_components = components
|
|
components = F.max_pool2d(components, kernel_size=3, stride=1, padding=1)
|
|
components[~thresholded] = 0
|
|
if torch.equal(previous_components, components):
|
|
break
|
|
|
|
components = components.reshape(B, H, W)
|
|
segments = torch.unique(components)
|
|
result = torch.zeros([len(segments) - 1, H, W])
|
|
index = 0
|
|
mapping = torch.zeros([len(segments) - 1], device=mask.device, dtype=torch.int)
|
|
for i in range(len(segments)):
|
|
segment = segments[i].item()
|
|
if segment == 0:
|
|
continue
|
|
image_index = int((segment - 1) // (H * W))
|
|
segment_mask = (components[image_index,:,:] == segment)
|
|
result[index][segment_mask] = mask[image_index][segment_mask]
|
|
mapping[index] = image_index
|
|
index += 1
|
|
|
|
return result, mapping
|
|
|
|
image = tensor2pil(image)
|
|
|
|
use_text = int(round(use_text))
|
|
|
|
if use_text == 1:
|
|
|
|
# CLIPSeg Model Loader
|
|
from transformers import CLIPSegProcessor, CLIPSegForImageSegmentation
|
|
model = "CIDAS/clipseg-rd64-refined"
|
|
cache = os.path.join(MODELS_DIR, 'clipseg')
|
|
|
|
inputs = CLIPSegProcessor.from_pretrained(model, cache_dir=cache)
|
|
model = CLIPSegForImageSegmentation.from_pretrained(model, cache_dir=cache)
|
|
|
|
image = image.convert('RGB')
|
|
cache = os.path.join(MODELS_DIR, 'clipseg')
|
|
|
|
with torch.no_grad():
|
|
result = model(**inputs(text=text, images=image, padding=True, return_tensors="pt"))
|
|
|
|
tensor = torch.sigmoid(result[0])
|
|
mask = 1. - (tensor - tensor.min()) / tensor.max()
|
|
mask = mask.unsqueeze(0)
|
|
mask = tensor2pil(mask).convert("L")
|
|
mask = mask.resize(image.size)
|
|
|
|
sigma = text_sigma
|
|
mask = pil2mask(mask)
|
|
|
|
if mask.ndim > 3:
|
|
regions = []
|
|
for mk in mask:
|
|
mask_np = np.clip(255. * mk.cpu().numpy().squeeze(), 0, 255).astype(np.uint8)
|
|
pil_image = Image.fromarray(mask_np, mode="L")
|
|
region_mask = smooth_region(pil_image, sigma)
|
|
region_tensor = pil2mask(region_mask).unsqueeze(0).unsqueeze(1)
|
|
regions.append(region_tensor)
|
|
mask = torch.cat(regions, dim=0)
|
|
|
|
else:
|
|
mask_np = np.clip(255. * mask.cpu().numpy().squeeze(), 0, 255).astype(np.uint8)
|
|
pil_image = Image.fromarray(mask_np, mode="L")
|
|
region_mask = smooth_region(pil_image, sigma)
|
|
mask = pil2mask(region_mask).unsqueeze(0).unsqueeze(1)
|
|
|
|
else:
|
|
if mask is None:
|
|
# Create a full mask for the entire image
|
|
mask_shape = (image.size[1], image.size[0]) # Assuming image is in (H, W, C) format
|
|
mask = torch.ones(mask_shape, dtype=torch.float32)
|
|
else:
|
|
mask = mask
|
|
|
|
if shrink_grow < 0:
|
|
mask = erode(mask, shrink_grow)
|
|
elif shrink_grow > 0:
|
|
mask = dilate(mask, shrink_grow)
|
|
|
|
invert = int(round(invert))
|
|
if invert == 1:
|
|
mask = 1.0 - mask
|
|
|
|
#Invert Mask
|
|
Mask_Inv = mask
|
|
#Convert Inverted Mask to Image
|
|
Inv_Mask_2_Img = Mask_Inv.reshape((-1, 1, Mask_Inv.shape[-2], Mask_Inv.shape[-1])).movedim(1, -1).expand(-1, -1, -1, 3)
|
|
|
|
#Convert Mask to Image
|
|
Mask_2_Img = mask.reshape((-1, 1, mask.shape[-2], mask.shape[-1])).movedim(1, -1).expand(-1, -1, -1, 3)
|
|
|
|
#Image Blend by Mask
|
|
# Convert images to PIL
|
|
img_a = tensor2pil(Inv_Mask_2_Img)
|
|
|
|
|
|
img_b = image
|
|
|
|
|
|
mask = ImageOps.invert(tensor2pil(Mask_2_Img).convert('L'))
|
|
|
|
# Mask image
|
|
masked_img = Image.composite(img_a, img_b, mask.resize(img_a.size))
|
|
|
|
# Blend image
|
|
blend_mask = Image.new(mode="L", size=img_a.size,
|
|
color=(round(blend_percentage * 255)))
|
|
blend_mask = ImageOps.invert(blend_mask)
|
|
Blended_Image = Image.composite(img_a, masked_img, blend_mask)
|
|
|
|
Blended_Image = pil2tensor(Blended_Image)
|
|
|
|
del img_a, img_b, blend_mask, mask
|
|
|
|
#Image Levels Adjustment
|
|
# Convert image to PIL
|
|
tensor_images = []
|
|
for img in Blended_Image:
|
|
img = tensor2pil(img)
|
|
img = img.convert("RGB")
|
|
levels = self.AdjustLevels(black_level, mid_level, white_level)
|
|
tensor_images.append(pil2tensor(levels.adjust(img)))
|
|
tensor_images = torch.cat(tensor_images, dim=0)
|
|
|
|
#Convert Image to Mask
|
|
masks = tensor_images[0, :, :, channels.index(channel)]
|
|
|
|
if masks.ndim > 3:
|
|
regions = []
|
|
for mask in masks:
|
|
mask_np = np.clip(255. * mask.cpu().numpy().squeeze(), 0, 255).astype(np.uint8)
|
|
pil_image = Image.fromarray(mask_np, mode="L")
|
|
region_mask = gaussian_region(pil_image, blur_radius)
|
|
region_tensor = pil2mask(region_mask).unsqueeze(0).unsqueeze(1)
|
|
regions.append(region_tensor)
|
|
result = torch.cat(regions, dim=0)
|
|
|
|
else:
|
|
mask_np = np.clip(255. * masks.cpu().numpy().squeeze(), 0, 255).astype(np.uint8)
|
|
pil_image = Image.fromarray(mask_np, mode="L")
|
|
region_mask = gaussian_region(pil_image, blur_radius)
|
|
result = pil2mask(region_mask).unsqueeze(0).unsqueeze(1)
|
|
|
|
result = result.reshape((-1, 1, result.shape[-2], result.shape[-1])).movedim(1, -1).expand(-1, -1, -1, 3)
|
|
|
|
if invert == 1:
|
|
result = 1.0 - result
|
|
|
|
result, mapping = separate(result, separate_mask)
|
|
|
|
if invert == 1:
|
|
result = 1.0 - result
|
|
|
|
return (result, result, mapping,)
|
|
|
|
class AdjustLevels:
|
|
def __init__(self, min_level, mid_level, max_level):
|
|
self.min_level = min_level
|
|
self.mid_level = mid_level
|
|
self.max_level = max_level
|
|
|
|
def adjust(self, im):
|
|
|
|
im_arr = np.array(im)
|
|
im_arr[im_arr < self.min_level] = self.min_level
|
|
im_arr = (im_arr - self.min_level) * \
|
|
(255 / (self.max_level - self.min_level))
|
|
im_arr[im_arr < 0] = 0
|
|
im_arr[im_arr > 255] = 255
|
|
im_arr = im_arr.astype(np.uint8)
|
|
|
|
im = Image.fromarray(im_arr)
|
|
im = ImageOps.autocontrast(im, cutoff=self.max_level)
|
|
|
|
return im
|
|
|
|
class Color_Correction:
|
|
def __init__(self):
|
|
pass
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"source_image": ("IMAGE",),
|
|
"target_image": ("IMAGE",),
|
|
"no_of_colors": ("INT", {"default": 6, "min": 0, "max": 256, "step": 1}),
|
|
"blur_radius": ("INT", {"default": 2, "min": 0, "max": 100, "step": 1}),
|
|
"blur_amount": ("INT", {"default": 2, "min": 0, "max": 100, "step": 1}),
|
|
"strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.1}),
|
|
"gamma": ("FLOAT", {"default": 1.0, "min": 0.10, "max": 2.0, "step": 0.1}),
|
|
"contrast": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 3.0, "step": 0.1}),
|
|
"brightness": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 100.0, "step": 0.1}),
|
|
},
|
|
"optional": {
|
|
"mask": ("MASK",),
|
|
},
|
|
}
|
|
|
|
CATEGORY = "I2I"
|
|
|
|
RETURN_TYPES = ("IMAGE", )
|
|
RETURN_NAMES = ("image", )
|
|
FUNCTION = "ColorXfer2"
|
|
|
|
def ColorXfer2(cls, source_image, target_image, no_of_colors, blur_radius, blur_amount, strength, gamma, contrast, brightness, mask=None):
|
|
if mask is not None:
|
|
if torch.is_tensor(mask):
|
|
# Convert to grayscale if it's a 3-channel image
|
|
if mask.shape[-1] == 3:
|
|
mask = torch.mean(mask, dim=-1)
|
|
|
|
# Remove batch dimension if present
|
|
if mask.dim() == 3:
|
|
mask = mask.squeeze(0)
|
|
|
|
mask_np1 = (mask.cpu().numpy() * 255).astype(np.uint8)
|
|
else:
|
|
mask_np1 = (mask * 255).astype(np.uint8)
|
|
|
|
mask_np = mask_np1 / 255.0
|
|
mask_torch = torch.tensor(mask_np).to(device)
|
|
|
|
# If the source_image is a tensor, convert it to a numpy array
|
|
if torch.is_tensor(source_image):
|
|
Source_np = (source_image[0].cpu().numpy() * 255).astype(np.uint8)
|
|
else:
|
|
Source_np = (source_image * 255).astype(np.uint8)
|
|
|
|
# If the source_image is a tensor, convert it to a numpy array
|
|
if torch.is_tensor(target_image):
|
|
Target_np = (target_image[0].cpu().numpy() * 255).astype(np.uint8)
|
|
else:
|
|
Target_np = (target_image * 255).astype(np.uint8)
|
|
|
|
# Load the source image and convert to torch tensor
|
|
Source_np = cv2.cvtColor(Source_np, cv2.COLOR_BGR2RGB)
|
|
Source = torch.from_numpy(Source_np).float().to(device)
|
|
|
|
# Extract colors from the source image
|
|
colors1 = extract(Source, no_of_colors, mask_np=mask_torch)
|
|
|
|
# Load the target image
|
|
Target_np = cv2.cvtColor(Target_np, cv2.COLOR_BGR2RGB)
|
|
Target = torch.from_numpy(Target_np).float().to(device=device)
|
|
|
|
# Extract colors from the target image
|
|
colors2 = extract(Target, no_of_colors)
|
|
|
|
min_length = min(len(colors1), len(colors2))
|
|
colors1 = colors1[:min_length]
|
|
colors2 = colors2[:min_length]
|
|
|
|
# Segment the image
|
|
segmented_np = segment_image(Source, colors1, mask_torch=mask_torch, threshold=1)
|
|
|
|
matched_pairs = luminance_match(colors1, colors2)
|
|
|
|
result_rgb = refined_replace_and_blend_colors(Source.cpu().numpy(), segmented_np, colors1, matched_pairs, blur_radius, blur_amount, mask_torch=mask_torch)
|
|
|
|
luminance_np = retain_luminance_hsv_swap(Source.cpu().numpy(), result_rgb, strength)
|
|
|
|
gamma_contrast_np = adjust_gamma_contrast(luminance_np, gamma, contrast, brightness, mask_np=mask_np1)
|
|
|
|
final_img_np_rgb = cv2.cvtColor(gamma_contrast_np, cv2.COLOR_BGR2RGB)
|
|
|
|
# Convert the numpy array back to a PyTorch tensor
|
|
final_img_tensor = torch.tensor(final_img_np_rgb).float().to(device)
|
|
|
|
final_img_tensor = final_img_tensor.unsqueeze(0)
|
|
|
|
if final_img_tensor.max() > 1.0:
|
|
final_img_tensor /= 255.0
|
|
|
|
return (final_img_tensor, )
|
|
|
|
class MaskToRegion:
|
|
def __init__(self):
|
|
pass
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",),
|
|
"mask": ("IMAGE",),
|
|
"force_resize_width": ("INT", {"default": 1024, "min": 0, "max": VERY_BIG_SIZE, "step": 1}),
|
|
"force_resize_height": ("INT", {"default": 1024, "min": 0, "max": VERY_BIG_SIZE, "step": 1}),
|
|
"kind": (["mask", "RGB", "RGBA"],),
|
|
"padding": ("INT", {"default": 3, "min": 0, "max": VERY_BIG_SIZE, "step": 1}),
|
|
"constraints": (["keep_ratio", "keep_ratio_divisible", "multiple_of", "ignore"],),
|
|
"constraint_x": ("INT", {"default": 64, "min": 2, "max": VERY_BIG_SIZE, "step": 1}),
|
|
"constraint_y": ("INT", {"default": 64, "min": 2, "max": VERY_BIG_SIZE, "step": 1}),
|
|
"min_width": ("INT", {"default": 0, "min": 0, "max": VERY_BIG_SIZE, "step": 1}),
|
|
"min_height": ("INT", {"default": 0, "min": 0, "max": VERY_BIG_SIZE, "step": 1}),
|
|
"batch_behavior": (["match_ratio", "match_size"],),
|
|
},
|
|
"optional": {
|
|
"mask_mapping_optional": ("MASK_MAPPING",),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "IMAGE", "IMAGE", )
|
|
RETURN_NAMES = ("cut image", "cut mask", "region")
|
|
FUNCTION = "get_region"
|
|
|
|
CATEGORY = "I2I"
|
|
|
|
def get_region(self, image, mask, force_resize_width, force_resize_height, kind, padding, constraints, constraint_x, constraint_y, min_width, min_height, batch_behavior, mask_mapping_optional = None):
|
|
mask2 = tensor2mask(mask)
|
|
mask_size = mask2.size()
|
|
mask_width = int(mask_size[2])
|
|
mask_height = int(mask_size[1])
|
|
|
|
# masks_to_boxes errors if the tensor is all zeros, so we'll add a single pixel and zero it out at the end
|
|
is_empty = ~torch.gt(torch.max(torch.reshape(mask2,[mask_size[0], mask_width * mask_height]), dim=1).values, 0.)
|
|
mask2[is_empty,0,0] = 1.
|
|
boxes = masks_to_boxes(mask2)
|
|
mask2[is_empty,0,0] = 0.
|
|
|
|
# Account for padding
|
|
min_x = torch.max(boxes[:,0] - padding, torch.tensor(0.))
|
|
min_y = torch.max(boxes[:,1] - padding, torch.tensor(0.))
|
|
max_x = torch.min(boxes[:,2] + padding, torch.tensor(mask_width))
|
|
max_y = torch.min(boxes[:,3] + padding, torch.tensor(mask_height))
|
|
|
|
width = max_x - min_x
|
|
height = max_y - min_y
|
|
|
|
# Make sure the width and height are big enough
|
|
target_width = torch.max(width, torch.tensor(min_width))
|
|
target_height = torch.max(height, torch.tensor(min_height))
|
|
|
|
if constraints == "keep_ratio":
|
|
target_width = torch.max(target_width, target_height * constraint_x // constraint_y)
|
|
target_height = torch.max(target_height, target_width * constraint_y // constraint_x)
|
|
elif constraints == "keep_ratio_divisible":
|
|
# Probably a more efficient way to do this, but given the bounds it's not too bad
|
|
max_factors = torch.min(constraint_x // target_width, constraint_y // target_height)
|
|
max_factor = int(torch.max(max_factors).item())
|
|
for i in range(1, max_factor+1):
|
|
divisible = constraint_x % i == 0 and constraint_y % i == 0
|
|
if divisible:
|
|
big_enough = ~torch.lt(target_width, constraint_x // i) * ~torch.lt(target_height, constraint_y // i)
|
|
target_width[big_enough] = constraint_x // i
|
|
target_height[big_enough] = constraint_y // i
|
|
elif constraints == "multiple_of":
|
|
target_width[torch.gt(target_width % constraint_x, 0)] = (target_width // constraint_x + 1) * constraint_x
|
|
target_height[torch.gt(target_height % constraint_y, 0)] = (target_height // constraint_y + 1) * constraint_y
|
|
|
|
if batch_behavior == "match_size":
|
|
target_width[:] = torch.max(target_width)
|
|
target_height[:] = torch.max(target_height)
|
|
elif batch_behavior == "match_ratio":
|
|
# We'll target the ratio that's closest to 1:1, but don't want to take into account empty masks
|
|
ratios = torch.abs(target_width / target_height - 1)
|
|
ratios[is_empty] = 10000
|
|
match_ratio = torch.min(ratios,dim=0).indices.item()
|
|
target_width = torch.max(target_width, target_height * target_width[match_ratio] // target_height[match_ratio])
|
|
target_height = torch.max(target_height, target_width * target_height[match_ratio] // target_width[match_ratio])
|
|
|
|
missing = target_width - width
|
|
min_x = min_x - missing // 2
|
|
max_x = max_x + (missing - missing // 2)
|
|
|
|
missing = target_height - height
|
|
min_y = min_y - missing // 2
|
|
max_y = max_y + (missing - missing // 2)
|
|
|
|
# Move the region into range if needed
|
|
bad = torch.lt(min_x,0)
|
|
max_x[bad] -= min_x[bad]
|
|
min_x[bad] = 0
|
|
|
|
bad = torch.lt(min_y,0)
|
|
max_y[bad] -= min_y[bad]
|
|
min_y[bad] = 0
|
|
|
|
bad = torch.gt(max_x, mask_width)
|
|
min_x[bad] -= (max_x[bad] - mask_width)
|
|
max_x[bad] = mask_width
|
|
|
|
bad = torch.gt(max_y, mask_height)
|
|
min_y[bad] -= (max_y[bad] - mask_height)
|
|
max_y[bad] = mask_height
|
|
|
|
region = torch.zeros((mask_size[0], mask_height, mask_width))
|
|
for i in range(0, mask_size[0]):
|
|
if not is_empty[i]:
|
|
ymin = int(min_y[i].item())
|
|
ymax = int(max_y[i].item())
|
|
xmin = int(min_x[i].item())
|
|
xmax = int(max_x[i].item())
|
|
region[i, ymin:ymax+1, xmin:xmax+1] = 1
|
|
|
|
Cut_Image = CutByMask(image, region, force_resize_width, force_resize_height, mask_mapping_optional)
|
|
|
|
#Change Channels >>>> OUTPUT TO VAE ENCODE
|
|
if kind == "mask":
|
|
Cut_Image = tensor2mask(Cut_Image)
|
|
elif kind == "RGBA":
|
|
Cut_Image = tensor2rgba(Cut_Image)
|
|
else: # RGB
|
|
Cut_Image = tensor2rgb(Cut_Image)
|
|
|
|
Cut_Mask = CutByMask(mask, region, force_resize_width, force_resize_height, mask_mapping_optional = None)
|
|
|
|
return (Cut_Image, Cut_Mask, region, )
|
|
|
|
class Combine_And_Paste_Op:
|
|
def __init__(self):
|
|
pass
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"decoded_vae": ("IMAGE",),
|
|
"Original_Image": ("IMAGE",),
|
|
"Cut_Image": ("IMAGE",),
|
|
"Cut_Mask": ("IMAGE",),
|
|
"region": ("IMAGE",),
|
|
"color_xfer_factor": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.1}),
|
|
"op": (["union (max)", "intersection (min)", "difference", "multiply", "multiply_alpha", "add", "greater_or_equal", "greater"],),
|
|
"clamp_result": (["yes", "no"],),
|
|
"round_result": (["no", "yes"],),
|
|
"resize_behavior": (["resize", "keep_ratio_fill", "keep_ratio_fit", "source_size", "source_size_unmasked"],),
|
|
},
|
|
"optional": {
|
|
"mask_mapping_optional": ("MASK_MAPPING",),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", )
|
|
RETURN_NAMES = ("FinalOut", )
|
|
FUNCTION = "com_paste_op"
|
|
|
|
CATEGORY = "I2I"
|
|
|
|
def com_paste_op(self, decoded_vae, Original_Image, Cut_Image, Cut_Mask, region, color_xfer_factor, op, clamp_result, round_result, resize_behavior, mask_mapping_optional = None):
|
|
|
|
Combined_Decoded = combine(decoded_vae, Cut_Mask, op, clamp_result, round_result)
|
|
|
|
Combined_Originals = combine(Cut_Image, Cut_Mask, op, clamp_result, round_result)
|
|
|
|
Cx_Decoded = apply_color_correction(Combined_Decoded, Combined_Originals, color_xfer_factor)
|
|
|
|
Cx_Decode_Mask = combine(Cx_Decoded, Cut_Mask, op, clamp_result, round_result)
|
|
|
|
FinalOut = PasteByMask(Original_Image, Cx_Decode_Mask, region, resize_behavior, mask_mapping_optional)
|
|
|
|
return (FinalOut, )
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"Color Transfer": Color_Correction,
|
|
"Mask Ops": Mask_Ops,
|
|
"Inpaint Segments": MaskToRegion,
|
|
"Combine and Paste": Combine_And_Paste_Op,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"Color Transfer": "Color Transfer",
|
|
"Mask Ops": "Mask Ops",
|
|
"Inpaint Segments": "Inpaint Segments",
|
|
"Combine and Paste": "Combine and Paste",
|
|
}
|