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).
+
+
+Node options:
+
+* 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.

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: 输入的图片。
+* 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
伪彩色热力图效果。

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