diff --git a/README.MD b/README.MD index cf80b89..e55ecc4 100644 --- a/README.MD +++ b/README.MD @@ -17,6 +17,8 @@ Nodes are divided into 5 groups according to their functions: LayerStyle, LayerC ## Update **If the dependency package error after updating, please reinstall the relevant dependency packages. for details, please refer to [here](https://github.com/chflame163/ComfyUI_LayerStyle/issues/5). + +* Commit [Film](#Film) node, this filter Simulate the grain, dark edge, and blurred edge of the film, support input depth map to simulate defocus. it is reorganize and encapsulate of [digitaljohn/comfyui-propost](https://github.com/digitaljohn/comfyui-propost). * Commit [ImageAutoCrop](#ImageAutoCrop) node, which is designed to generate image materials for training models. * Commit [ImageScaleByAspectRatio](#ImageScaleByAspectRatio) node, it can be scaled image or mask according to frame ratio. * Fix the bug of color gradation in [LUT Apply](#LUT) node rendering, and this node now support for log color space. *Please load the dedicated log lut file for the log color space image. @@ -884,6 +886,27 @@ Node options: * angle: Angle of channel separation. * mode: Channel shift arrangement order. +### Film +Simulate the grain, dark edge, and blurred edge of the film, support input depth map to simulate defocus. +This node is reorganize and encapsulate of [digitaljohn/comfyui-propost](https://github.com/digitaljohn/comfyui-propost). +![image](image/film_example.png) + +Node options: +![image](image/film_node.png) +* image: The input image. +* depth_map: Input depth map to simulate defocus effect. it is an optional input. if there is no input, will simulates radial blur at the edges of the image. +* center_x: The horizontal axis of the center point position of the dark edge and radial blur, where 0 represents the leftmost side, 1 represents the rightmost side, and 0.5 represents at the center. +* center_y: The vertical axis of the center point position of the dark edge and radial blur, where 0 represents the leftmost side, 1 represents the rightmost side, and 0.5 represents at the center. +* saturation: Color saturation, 1 is the original value. +* grain_power: Grain intensity. larger value means more pronounced the noise. +* grain_scale: Grain size. +* grain_sat: The color saturation of grain. 0 represents mono noise, and the larger the value, the more prominent the color. +* grain_shadows: Grain intensity of dark part. +* grain_highs: Grain intensity of light part. +* blur_strength: The strength of blur. larger value means more blurry it becomes. +* blur_focus_spread: Focus diffusion range. larger value means larger clear range. +* focal_depth: Simulate the focal distance of defucus. 0 indicates that focus is farthest, and 1 indicates that is closest. this setting only valid when input the depth_map. + ### ColorMap Pseudo color heat map effect. ![image](image/colormap_result.png) diff --git a/README_CN.MD b/README_CN.MD index cd1ce49..2c1106c 100644 --- a/README_CN.MD +++ b/README_CN.MD @@ -13,6 +13,7 @@ ## 更新说明 **如果本插件更新后出现依赖包错误,请重新安装相关依赖包。详情见[这里](https://github.com/chflame163/ComfyUI_LayerStyle/issues/5)。 +* 添加 [Film](#Film) 节点, 这个滤镜模拟胶片的颗粒、暗边和边缘模糊,支持输入深度图模拟虚焦,是[digitaljohn/comfyui-propost](https://github.com/digitaljohn/comfyui-propost)的重新封装。 * 添加 [ImageAutoCrop](#ImageAutoCrop) 节点, 这个节点是为生成训练模型的图片素材而设计的。 * 添加 [ImageScaleByAspectRatio](#ImageScaleByAspectRatio) 节点, 可按画幅比例缩放图像。 * 改正 [LUT Apply](#LUT) 节点渲染出现色阶的bug, 并增加log色彩空间支持。*log色彩空间图片请加载专门的log lut。 @@ -873,6 +874,28 @@ mask * angle: 通道分离的角度。 * mode: 通道错位排列顺序。 +### Film +模拟胶片的颗粒、暗边和边缘模糊,支持输入深度图模拟虚焦。 +这个节点是[digitaljohn/comfyui-propost](https://github.com/digitaljohn/comfyui-propost)的重新封装。 +![image](image/film_example.png) + +节点选项说明: +![image](image/film_node.png) +* image: 输入的图片。 +* depth_map: 深度图输入,由此模拟虚焦效果。此项是可选输入,如果没有输入则模拟为图片边缘的径向模糊。 +* center_x: 暗边和径向模糊的中心点位置横坐标,0表示最左侧,1表示最右侧,0.5表示在中心。 +* center_y: 暗边和径向模糊的中心点位置纵坐标,0表示最上方,1表示最下方,0.5表示在中心。 +* saturation: 颜色饱和度,1为原始值。 +* grain_power: 噪点强度。数值越大,噪点越明显。 +* grain_scale: 噪点颗粒大小。数值越大,颗粒越大。 +* grain_sat: 噪点的色彩饱和度。0表示黑白噪点,数值越大,彩色越明显。 +* grain_shadows: 暗部噪点强度。 +* grain_highs: 亮部噪点强度。 +* blur_strength: 模糊强度。数值越大越模糊。 +* blur_focus_spread: 焦点扩散范围。数值越大,清晰的范围越大。 +* focal_depth: 模拟虚焦的焦点距离。0表示焦点在最远,1表示焦点在最近。此项设置只在depth_map有输入时才生效。 + + ### ColorMap 伪彩色热力图效果。 ![image](image/colormap_result.png) diff --git a/image/film_example.png b/image/film_example.png new file mode 100644 index 0000000..50e706b Binary files /dev/null and b/image/film_example.png differ diff --git a/image/film_node.png b/image/film_node.png new file mode 100644 index 0000000..e386990 Binary files /dev/null and b/image/film_node.png differ diff --git a/py/film_post.py b/py/film_post.py new file mode 100644 index 0000000..fd688ee --- /dev/null +++ b/py/film_post.py @@ -0,0 +1,89 @@ +from .imagefunc import * + + +NODE_NAME = 'Film' + +class Film: + + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(self): + + return { + "required": { + "image": ("IMAGE", ), # + "center_x": ("FLOAT", {"default": 0.5, "min": 0, "max": 1, "step": 0.01}), + "center_y": ("FLOAT", {"default": 0.5, "min": 0, "max": 1, "step": 0.01}), + "saturation": ("FLOAT", {"default": 1, "min": 0.01, "max": 3, "step": 0.01}), + "vignette_intensity": ("FLOAT", {"default": 0.5, "min": 0, "max": 1, "step": 0.01}), + "grain_power": ("FLOAT", {"default": 0.15, "min": 0, "max": 1, "step": 0.01}), + "grain_scale": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 10, "step": 0.1}), + "grain_sat": ("FLOAT", {"default": 0.5, "min": 0, "max": 1, "step": 0.01}), + "grain_shadows": ("FLOAT", {"default": 0.6, "min": 0, "max": 1, "step": 0.01}), + "grain_highs": ("FLOAT", {"default": 0.2, "min": 0, "max": 1, "step": 0.01}), + "blur_strength": ("INT", {"default": 90, "min": 0, "max": 256, "step": 1}), + "blur_focus_spread": ("FLOAT", {"default": 2.2, "min": 0.1, "max": 8, "step": 0.1}), + "focal_depth": ("FLOAT", {"default": 0.9, "min": 0.0, "max": 1, "step": 0.01}), + }, + "optional": { + "depth_map": ("IMAGE",), # + } + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("image",) + FUNCTION = 'film' + CATEGORY = '馃樅dzNodes/LayerFilter' + OUTPUT_NODE = True + + def film(self, image, center_x, center_y, saturation, vignette_intensity, + grain_power, grain_scale, grain_sat, grain_shadows, grain_highs, + blur_strength, blur_focus_spread, focal_depth, + depth_map=None + ): + + ret_images = [] + + for i in image: + i = torch.unsqueeze(i, 0) + _canvas = tensor2pil(i).convert('RGB') + + if saturation != 1: + color_image = ImageEnhance.Color(_canvas) + _canvas = color_image.enhance(factor= saturation) + + if blur_strength: + if depth_map is not None: + depth_map = tensor2pil(depth_map).convert('L').convert('RGB') + if depth_map.size != _canvas.size: + depth_map.resize((_canvas.size), Image.BILINEAR) + _canvas = depthblur_image(_canvas, depth_map, blur_strength, focal_depth, blur_focus_spread) + else: + _canvas = radialblur_image(_canvas, blur_strength, center_x, center_y, blur_focus_spread * 2) + + if vignette_intensity: + # adjust image gamma and saturation + _canvas = gamma_trans(_canvas, 1 - vignette_intensity / 3) + color_image = ImageEnhance.Color(_canvas) + _canvas = color_image.enhance(factor= 1+ vignette_intensity / 3) + # add vignette + _canvas = vignette_image(_canvas, vignette_intensity, center_x, center_y) + + if grain_power: + _canvas = filmgrain_image(_canvas, grain_scale, grain_power, grain_shadows, grain_highs, grain_sat) + + ret_image = _canvas + ret_images.append(pil2tensor(ret_image)) + + log(f"{NODE_NAME} Processed {len(ret_images)} image(s).", message_type='finish') + return (torch.cat(ret_images, dim=0),) + +NODE_CLASS_MAPPINGS = { + "LayerFilter: Film": Film +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "LayerFilter: Film": "LayerFilter: Film" +} \ No newline at end of file diff --git a/py/filmgrainer/__init__.py b/py/filmgrainer/__init__.py new file mode 100644 index 0000000..bb35ee1 --- /dev/null +++ b/py/filmgrainer/__init__.py @@ -0,0 +1 @@ +__version__ = "1.0.2" \ No newline at end of file diff --git a/py/filmgrainer/filmgrainer.py b/py/filmgrainer/filmgrainer.py new file mode 100644 index 0000000..1df5108 --- /dev/null +++ b/py/filmgrainer/filmgrainer.py @@ -0,0 +1,116 @@ +# Filmgrainer - by Lars Ole Pontoppidan - MIT License + +from PIL import Image, ImageFilter +import os +import tempfile +import numpy as np + +import filmgrainer.graingamma as graingamma +import filmgrainer.graingen as graingen + + +def _grainTypes(typ): + # After rescaling to make different grain sizes, the standard deviation + # of the pixel values change. The following values of grain size and power + # have been imperically chosen to end up with approx the same standard + # deviation in the result: + if typ == 1: + return (0.8, 63) # more interesting fine grain + elif typ == 2: + return (1, 45) # basic fine grain + elif typ == 3: + return (1.5, 50) # coarse grain + elif typ == 4: + return (1.6666, 50) # coarser grain + else: + raise ValueError("Unknown grain type: " + str(typ)) + +# Grain mask cache +MASK_CACHE_PATH = os.path.join(tempfile.gettempdir(), "mask-cache") + +def _getGrainMask(img_width:int, img_height:int, saturation:float, grayscale:bool, grain_size:float, grain_gauss:float, seed): + if grayscale: + str_sat = "BW" + sat = -1.0 # Graingen makes a grayscale image if sat is negative + else: + str_sat = str(saturation) + sat = saturation + + filename = MASK_CACHE_PATH + "grain-%d-%d-%s-%s-%s-%d.png" % ( + img_width, img_height, str_sat, str(grain_size), str(grain_gauss), seed) + if os.path.isfile(filename): + # print("Reusing: %s" % filename) + mask = Image.open(filename) + else: + mask = graingen.grainGen(img_width, img_height, grain_size, grain_gauss, sat, seed) + # print("Saving: %s" % filename) + if not os.path.isdir(MASK_CACHE_PATH): + os.mkdir(MASK_CACHE_PATH) + mask.save(filename, format="png", compress_level=1) + return mask + + +def process(image:Image, scale:float, src_gamma:float, grain_power:float, shadows:float, + highs:float, grain_type:int, grain_sat:float, gray_scale:bool, sharpen:int, seed:int): + + # image = np.clip(image, 0, 1) # Ensure the values are within [0, 1] + # image = (image * 255).astype(np.uint8) + # img = Image.fromarray(image).convert("RGB") + img = image + org_width = img.size[0] + org_height = img.size[1] + + if scale != 1.0: + # print("Scaling source image ...") + img = img.resize((int(org_width / scale), int(org_height / scale)), + resample = Image.LANCZOS) + + img_width = img.size[0] + img_height = img.size[1] + # print("Size: %d x %d" % (img_width, img_height)) + + # print("Calculating map ...") + map = graingamma.Map.calculate(src_gamma, grain_power, shadows, highs) + # map.saveToFile("map.png") + + # print("Calculating grain stock ...") + (grain_size, grain_gauss) = _grainTypes(grain_type) + mask = _getGrainMask(img_width, img_height, grain_sat, gray_scale, grain_size, grain_gauss, seed) + + mask_pixels = mask.load() + img_pixels = img.load() + + # Instead of calling map.lookup(a, b) for each pixel, use the map directly: + lookup = map.map + + if gray_scale: + # print("Film graining image ... (grayscale)") + for y in range(0, img_height): + for x in range(0, img_width): + m = mask_pixels[x, y] + (r, g, b) = img_pixels[x, y] + gray = int(0.21*r + 0.72*g + 0.07*b) + #gray_lookup = map.lookup(gray, m) + gray_lookup = lookup[gray, m] + img_pixels[x, y] = (gray_lookup, gray_lookup, gray_lookup) + else: + # print("Film graining image ...") + for y in range(0, img_height): + for x in range(0, img_width): + (mr, mg, mb) = mask_pixels[x, y] + (r, g, b) = img_pixels[x, y] + r = lookup[r, mr] + g = lookup[g, mg] + b = lookup[b, mb] + img_pixels[x, y] = (r, g, b) + + if scale != 1.0: + # print("Scaling image back to original size ...") + img = img.resize((org_width, org_height), resample = Image.LANCZOS) + + if sharpen > 0: + # print("Sharpening image: %d pass ..." % sharpen) + for x in range(sharpen): + img = img.filter(ImageFilter.SHARPEN) + + return np.array(img).astype('float32') / 255.0 \ No newline at end of file diff --git a/py/filmgrainer/graingamma.py b/py/filmgrainer/graingamma.py new file mode 100644 index 0000000..0f3cc6e --- /dev/null +++ b/py/filmgrainer/graingamma.py @@ -0,0 +1,113 @@ +import numpy as np + +_ShadowEnd = 160 +_HighlightStart = 200 + + +def _gammaCurve(gamma, x): + """ Returns from 0.0 to 1.0""" + return pow((x / 255.0), (1.0 / gamma)) + + +def _calcDevelopment(shadow_level, high_level, x): + """ +This function returns a development like this: + + (return) + ^ + | +0.5 | o - o <-- mids level, always 0.5 + | - - + | - - + | - o <-- high_level eg. 0.25 + | - + | o <-- shadow_level eg. 0.15 + | + 0 -+-----------------|-------|------------|-----> x (input) + 0 160 200 255 + """ + if x < _ShadowEnd: + power = 0.5 - (_ShadowEnd - x) * (0.5 - shadow_level) / _ShadowEnd + elif x < _HighlightStart: + power = 0.5 + else: + power = 0.5 - (x - _HighlightStart) * (0.5 - high_level) / (255 - _HighlightStart) + + return power + +class Map: + def __init__(self, map): + self.map = map + + @staticmethod + def calculate(src_gamma, noise_power, shadow_level, high_level) -> 'Map': + map = np.zeros([256, 256], dtype=np.uint8) + + # We need to level off top end and low end to leave room for the noise to breathe + crop_top = noise_power * high_level / 12 + crop_low = noise_power * shadow_level / 20 + + pic_scale = 1 - (crop_top + crop_low) + pic_offs = 255 * crop_low + + for src_value in range(0, 256): + # Gamma compensate picture source value itself + pic_value = _gammaCurve(src_gamma, src_value) * 255.0 + + # In the shadows we want noise gamma to be 0.5, in the highs, 2.0: + gamma = pic_value * (1.5 / 256) + 0.5 + gamma_offset = _gammaCurve(gamma, 128) + + # Power is determined by the development + power = _calcDevelopment(shadow_level, high_level, pic_value) + + for noise_value in range(0, 256): + gamma_compensated = _gammaCurve(gamma, noise_value) - gamma_offset + value = pic_value * pic_scale + pic_offs + 255.0 * power * noise_power * gamma_compensated + if value < 0: + value = 0 + elif value < 255.0: + value = int(value) + else: + value = 255 + map[src_value, noise_value] = value + + return Map(map) + + def lookup(self, pic_value, noise_value): + return self.map[pic_value, noise_value] + + def saveToFile(self, filename): + from PIL import Image + img = Image.fromarray(self.map) + img.save(filename) + +if __name__ == "__main__": + import matplotlib.pyplot as plt + import numpy as np + + def plotfunc(x_min, x_max, step, func): + x_all = np.arange(x_min, x_max, step) + y = [] + for x in x_all: + y.append(func(x)) + + plt.figure() + plt.plot(x_all, y) + plt.grid() + + def development1(x): + return _calcDevelopment(0.2, 0.3, x) + + def gamma05(x): + return _gammaCurve(0.5, x) + def gamma1(x): + return _gammaCurve(1, x) + def gamma2(x): + return _gammaCurve(2, x) + + plotfunc(0.0, 255.0, 1.0, development1) + plotfunc(0.0, 255.0, 1.0, gamma05) + plotfunc(0.0, 255.0, 1.0, gamma1) + plotfunc(0.0, 255.0, 1.0, gamma2) + plt.show() \ No newline at end of file diff --git a/py/filmgrainer/graingen.py b/py/filmgrainer/graingen.py new file mode 100644 index 0000000..c3ea62e --- /dev/null +++ b/py/filmgrainer/graingen.py @@ -0,0 +1,61 @@ +from PIL import Image +import random +import numpy as np + +def _makeGrayNoise(width, height, power): + buffer = np.zeros([height, width], dtype=int) + + for y in range(0, height): + for x in range(0, width): + buffer[y, x] = random.gauss(128, power) + buffer = buffer.clip(0, 255) + return Image.fromarray(buffer.astype(dtype=np.uint8)) + +def _makeRgbNoise(width, height, power, saturation): + buffer = np.zeros([height, width, 3], dtype=int) + intens_power = power * (1.0 - saturation) + for y in range(0, height): + for x in range(0, width): + intens = random.gauss(128, intens_power) + buffer[y, x, 0] = random.gauss(0, power) * saturation + intens + buffer[y, x, 1] = random.gauss(0, power) * saturation + intens + buffer[y, x, 2] = random.gauss(0, power) * saturation + intens + + buffer = buffer.clip(0, 255) + return Image.fromarray(buffer.astype(dtype=np.uint8)) + + +def grainGen(width, height, grain_size, power, saturation, seed = 1): + # A grain_size of 1 means the noise buffer will be made 1:1 + # A grain_size of 2 means the noise buffer will be resampled 1:2 + noise_width = int(width / grain_size) + noise_height = int(height / grain_size) + random.seed(seed) + + if saturation < 0.0: + print("Making B/W grain, width: %d, height: %d, grain-size: %s, power: %s, seed: %d" % ( + noise_width, noise_height, str(grain_size), str(power), seed)) + img = _makeGrayNoise(noise_width, noise_height, power) + else: + print("Making RGB grain, width: %d, height: %d, saturation: %s, grain-size: %s, power: %s, seed: %d" % ( + noise_width, noise_height, str(saturation), str(grain_size), str(power), seed)) + img = _makeRgbNoise(noise_width, noise_height, power, saturation) + + # Resample + if grain_size != 1.0: + img = img.resize((width, height), resample = Image.LANCZOS) + + return img + + +if __name__ == "__main__": + import sys + if len(sys.argv) == 8: + width = int(sys.argv[2]) + height = int(sys.argv[3]) + grain_size = float(sys.argv[4]) + power = float(sys.argv[5]) + sat = float(sys.argv[6]) + seed = int(sys.argv[7]) + out = grainGen(width, height, grain_size, power, sat, seed) + out.save(sys.argv[1]) \ No newline at end of file diff --git a/py/filmgrainer/processing.py b/py/filmgrainer/processing.py new file mode 100644 index 0000000..4c48e8e --- /dev/null +++ b/py/filmgrainer/processing.py @@ -0,0 +1,32 @@ +import cv2 +import numpy as np + +def generate_blurred_images(image, blur_strength, steps, focus_spread=1): + blurred_images = [] + for step in range(1, steps + 1): + # Adjust the curve based on the curve_weight + blur_factor = (step / steps) ** focus_spread * blur_strength + blur_size = max(1, int(blur_factor)) + blur_size = blur_size if blur_size % 2 == 1 else blur_size + 1 # Ensure blur_size is odd + + # Apply Gaussian Blur + blurred_image = cv2.GaussianBlur(image, (blur_size, blur_size), 0) + blurred_images.append(blurred_image) + return blurred_images + +def apply_blurred_images(image, blurred_images, mask): + steps = len(blurred_images) # Calculate the number of steps based on the blurred images provided + final_image = np.zeros_like(image) + step_size = 1.0 / steps + for i, blurred_image in enumerate(blurred_images): + # Calculate the mask for the current step + current_mask = np.clip((mask - i * step_size) * steps, 0, 1) + next_mask = np.clip((mask - (i + 1) * step_size) * steps, 0, 1) + blend_mask = current_mask - next_mask + + # Apply the blend mask + final_image += blend_mask[:, :, np.newaxis] * blurred_image + + # Ensure no division by zero; add the original image for areas without blurring + final_image += (1 - np.clip(mask * steps, 0, 1))[:, :, np.newaxis] * image + return final_image \ No newline at end of file diff --git a/py/imagefunc.py b/py/imagefunc.py index d509a8c..10b1c41 100644 --- a/py/imagefunc.py +++ b/py/imagefunc.py @@ -1,8 +1,11 @@ '''Image process functions for ComfyUI nodes by chflame https://github.com/chflame163 ''' -import copy import os +import sys +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +import copy import re import json import math @@ -24,7 +27,8 @@ from colour.io.luts.iridas_cube import read_LUT_IridasCube, LUT3D, LUT3x1D from typing import Union import folder_paths as COMFY_FOLDER_PATH from .briarmbg import BriaRMBG - +from .filmgrainer import processing as processing_utils +from .filmgrainer import filmgrainer as filmgrainer def log(message:str, message_type:str='info'): name = 'LayerStyle' @@ -342,6 +346,126 @@ def motion_blur(image:Image, angle:int, blur:int) -> Image: ret_image = cv22pil(blurred) return ret_image +def __apply_vignette(image, vignette): + # If image needs to be normalized (0-1 range) + needs_normalization = image.max() > 1 + if needs_normalization: + image = image.astype(np.float32) / 255 + final_image = np.clip(image * vignette[..., np.newaxis], 0, 1) + if needs_normalization: + final_image = (final_image * 255).astype(np.uint8) + return final_image +def vignette_image(image:Image, intensity: float, center_x: float, center_y: float) -> Image: + image = pil2tensor(image) + _, height, width, _ = image.shape + # Generate the vignette for each image in the batch + # Create linear space but centered around the provided center point ratios + x = np.linspace(-1, 1, width) + y = np.linspace(-1, 1, height) + X, Y = np.meshgrid(x - (2 * center_x - 1), y - (2 * center_y - 1)) + # Calculate distances to the furthest corner + distances_to_corners = [ + np.sqrt((0 - center_x) ** 2 + (0 - center_y) ** 2), + np.sqrt((1 - center_x) ** 2 + (0 - center_y) ** 2), + np.sqrt((0 - center_x) ** 2 + (1 - center_y) ** 2), + np.sqrt((1 - center_x) ** 2 + (1 - center_y) ** 2) + ] + max_distance_to_corner = np.max(distances_to_corners) + radius = np.sqrt(X ** 2 + Y ** 2) + radius = radius / (max_distance_to_corner * np.sqrt(2)) # Normalize radius + opacity = np.clip(intensity, 0, 1) + vignette = 1 - radius * opacity + tensor_image = image.numpy() + # Apply vignette + vignette_image = __apply_vignette(tensor_image, vignette) + return tensor2pil(torch.from_numpy(vignette_image).unsqueeze(0)) + +def filmgrain_image(image:Image, scale:float, grain_power:float, + shadows:float, highs:float, grain_sat:float, + sharpen:int=1, grain_type:int=4, src_gamma:float=1.0, + gray_scale:bool=False, seed:int=0) -> Image: + # image = pil2tensor(image) + # grain_type, 1=fine, 2=fine simple, 3=coarse, 4=coarser + grain_type_index = 3 + + # Apply grain + grain_image = filmgrainer.process(image, scale=scale, src_gamma=src_gamma, grain_power=grain_power, + shadows=shadows, highs=highs, grain_type=grain_type_index, + grain_sat=grain_sat, gray_scale=gray_scale, sharpen=sharpen, seed=seed) + return tensor2pil(torch.from_numpy(grain_image).unsqueeze(0)) + +def __apply_radialblur(image, blur_strength, radial_mask, focus_spread, steps): + needs_normalization = image.max() > 1 + if needs_normalization: + image = image.astype(np.float32) / 255 + blurred_images = processing_utils.generate_blurred_images(image, blur_strength, steps, focus_spread) + final_image = processing_utils.apply_blurred_images(image, blurred_images, radial_mask) + if needs_normalization: + final_image = np.clip(final_image * 255, 0, 255).astype(np.uint8) + return final_image + +def radialblur_image(image:Image, blur_strength:float, center_x:float, center_y:float, focus_spread:float, steps:int=5) -> Image: + width, height = image.size + image = pil2tensor(image) + if image.dim() == 4: + image = image[0] + + # _, height, width, = image.shape + # Generate the vignette for each image in the batch + c_x, c_y = int(width * center_x), int(height * center_y) + # Calculate distances to all corners from the center + distances_to_corners = [ + np.sqrt((c_x - 0)**2 + (c_y - 0)**2), + np.sqrt((c_x - width)**2 + (c_y - 0)**2), + np.sqrt((c_x - 0)**2 + (c_y - height)**2), + np.sqrt((c_x - width)**2 + (c_y - height)**2) + ] + max_distance_to_corner = max(distances_to_corners) + # Create and adjust radial mask + X, Y = np.meshgrid(np.arange(width) - c_x, np.arange(height) - c_y) + radial_mask = np.sqrt(X**2 + Y**2) / max_distance_to_corner + tensor_image = image.numpy() + # Apply blur + blur_image = __apply_radialblur(tensor_image, blur_strength, radial_mask, focus_spread, steps) + return tensor2pil(torch.from_numpy(blur_image).unsqueeze(0)) + +def __apply_depthblur(image, depth_map, blur_strength, focal_depth, focus_spread, steps): + # Normalize the input image if needed + needs_normalization = image.max() > 1 + if needs_normalization: + image = image.astype(np.float32) / 255 + # Normalize the depth map if needed + depth_map = depth_map.astype(np.float32) / 255 if depth_map.max() > 1 else depth_map + # Resize depth map to match the image dimensions + depth_map_resized = cv2.resize(depth_map, (image.shape[1], image.shape[0]), interpolation=cv2.INTER_LINEAR) + if len(depth_map_resized.shape) > 2: + depth_map_resized = cv2.cvtColor(depth_map_resized, cv2.COLOR_BGR2GRAY) + # Adjust the depth map based on the focal plane + depth_mask = np.abs(depth_map_resized - focal_depth) + depth_mask = np.clip(depth_mask / np.max(depth_mask), 0, 1) + # Generate blurred versions of the image + blurred_images = processing_utils.generate_blurred_images(image, blur_strength, steps, focus_spread) + # Use the adjusted depth map as a mask for applying blurred images + final_image = processing_utils.apply_blurred_images(image, blurred_images, depth_mask) + # Convert back to original range if the image was normalized + if needs_normalization: + final_image = np.clip(final_image * 255, 0, 255).astype(np.uint8) + return final_image + +def depthblur_image(image:Image, depth_map:Image, blur_strength:float, focal_depth:float, focus_spread:float, steps:int=5) -> Image: + width, height = image.size + image = pil2tensor(image) + depth_map = pil2tensor(depth_map) + if image.dim() == 4: + image = image[0] + if depth_map.dim() == 4: + depth_map = depth_map[0] + tensor_image = image.numpy() + tensor_image_depth = depth_map.numpy() + # Apply blur + blur_image = __apply_depthblur(tensor_image, tensor_image_depth, blur_strength, focal_depth, focus_spread, steps) + return tensor2pil(torch.from_numpy(blur_image).unsqueeze(0)) + def fit_resize_image(image:Image, target_width:int, target_height:int, fit:str, resize_sampler:str, background_color:str = '#000000') -> Image: image = image.convert('RGB') orig_width, orig_height = image.size