diff --git a/backend/src/nodes/impl/image_utils.py b/backend/src/nodes/impl/image_utils.py index ec191778b..fec4713ab 100644 --- a/backend/src/nodes/impl/image_utils.py +++ b/backend/src/nodes/impl/image_utils.py @@ -1,11 +1,12 @@ from __future__ import annotations import itertools +import math import os import random import string from enum import Enum -from typing import List +from typing import List, Tuple import cv2 import numpy as np @@ -332,3 +333,95 @@ def cartesian_product(arrays: List[np.ndarray]) -> np.ndarray: for i, a in enumerate(arrays): arr[i, ...] = a[idx[: la - i]] return arr.reshape(la, -1).T + + +def fast_gaussian_blur( + img: np.ndarray, + sigma_x: float, + sigma_y: float | None = None, +) -> np.ndarray: + """ + Computes a channel-wise gaussian blur of the given image using a fast approximation. + + The maximum error of the approximation is guaranteed to be less than 0.1%. + In addition to that, the error is guaranteed to be smoothly distributed across the image. + There are no sudden spikes in error anywhere. + + Specifically, the method is implemented by downsampling the image, blurring the downsampled + image, and then upsampling the blurred image. This is much faster than blurring the full image. + Unfortunately, OpenCV's `resize` method has unfortunate artifacts when upscaling, so we + apply a small gaussian blur to the image after upscaling to smooth out the artifacts. This + single step almost doubles the runtime of the method, but it is still much faster than + blurring the full image. + """ + if sigma_y is None: + sigma_y = sigma_x + if sigma_x == 0 or sigma_y == 0: + return img.copy() + + h, w, _ = get_h_w_c(img) + + def get_scale_factor(sigma: float) -> float: + if sigma < 11: + return 1 + if sigma < 15: + return 1.25 + if sigma < 20: + return 1.5 + if sigma < 25: + return 2 + if sigma < 30: + return 2.5 + if sigma < 50: + return 3 + if sigma < 100: + return 4 + if sigma < 200: + return 6 + return 8 + + def get_sizing(size: int, sigma: float, f: float) -> Tuple[int, float, float]: + """ + Return the size of the downsampled image, the sigma of the downsampled gaussian blur, + and the sigma of the upscaled gaussian blur. + """ + if f <= 1: + # just use simple gaussian, the error is too large otherwise + return size, 0, sigma + + size_down = math.ceil(size / f) + f = size / size_down + sigma_up = f + sigma_down = math.sqrt(sigma**2 - sigma_up**2) / f + return size_down, sigma_down, sigma_up + + # Handling different sigma values for x and y is difficult, so we take the easy way out + # and just use the smaller one. There are potentially better ways of combining them, but + # this is good enough for now. + scale_factor = min(get_scale_factor(sigma_x), get_scale_factor(sigma_y)) + h_down, y_down_sigma, y_up_sigma = get_sizing(h, sigma_y, scale_factor) + w_down, x_down_sigma, x_up_sigma = get_sizing(w, sigma_x, scale_factor) + + if h != h_down or w != w_down: + # downsampled gaussian blur + img = cv2.resize(img, (w_down, h_down), interpolation=cv2.INTER_AREA) + img = cv2.GaussianBlur( + img, + (0, 0), + sigmaX=x_down_sigma, + sigmaY=y_down_sigma, + borderType=cv2.BORDER_REFLECT, + ) + img = cv2.resize(img, (w, h), interpolation=cv2.INTER_LINEAR) + + if x_up_sigma != 0 or y_up_sigma != 0: + # post blur to smooth out artifacts + img = cv2.GaussianBlur( + img, + (0, 0), + sigmaX=x_up_sigma, + sigmaY=y_up_sigma, + borderType=cv2.BORDER_REFLECT, + ) + + return img diff --git a/backend/src/packages/chaiNNer_standard/image_filter/blur/gaussian_blur.py b/backend/src/packages/chaiNNer_standard/image_filter/blur/gaussian_blur.py index c61dbc4f8..3b5ad2b9d 100644 --- a/backend/src/packages/chaiNNer_standard/image_filter/blur/gaussian_blur.py +++ b/backend/src/packages/chaiNNer_standard/image_filter/blur/gaussian_blur.py @@ -1,8 +1,8 @@ from __future__ import annotations -import cv2 import numpy as np +from nodes.impl.image_utils import fast_gaussian_blur from nodes.properties.inputs import ImageInput, SliderInput from nodes.properties.outputs import ImageOutput @@ -46,5 +46,5 @@ def gaussian_blur_node( ) -> np.ndarray: if sigma_x == 0 and sigma_y == 0: return img - else: - return cv2.GaussianBlur(img, (0, 0), sigmaX=sigma_x, sigmaY=sigma_y) + + return fast_gaussian_blur(img, sigma_x, sigma_y) diff --git a/backend/src/packages/chaiNNer_standard/image_filter/miscellaneous/high_pass.py b/backend/src/packages/chaiNNer_standard/image_filter/miscellaneous/high_pass.py index be286b538..5985b690d 100644 --- a/backend/src/packages/chaiNNer_standard/image_filter/miscellaneous/high_pass.py +++ b/backend/src/packages/chaiNNer_standard/image_filter/miscellaneous/high_pass.py @@ -1,8 +1,8 @@ from __future__ import annotations -import cv2 import numpy as np +from nodes.impl.image_utils import fast_gaussian_blur from nodes.properties.inputs import ImageInput, SliderInput from nodes.properties.outputs import ImageOutput @@ -55,7 +55,7 @@ def high_pass_node( if radius == 0 or contrast == 0: img = img * 0 + 0.5 else: - img = contrast * (img - cv2.GaussianBlur(img, (0, 0), radius)) + 0.5 + img = contrast * (img - fast_gaussian_blur(img, radius)) + 0.5 # type: ignore if alpha is not None: img = np.dstack((img, alpha)) diff --git a/backend/src/packages/chaiNNer_standard/image_filter/sharpen/unsharp_mask.py b/backend/src/packages/chaiNNer_standard/image_filter/sharpen/unsharp_mask.py index 5e6f17d95..37a6ecb0e 100644 --- a/backend/src/packages/chaiNNer_standard/image_filter/sharpen/unsharp_mask.py +++ b/backend/src/packages/chaiNNer_standard/image_filter/sharpen/unsharp_mask.py @@ -3,6 +3,7 @@ import cv2 import numpy as np +from nodes.impl.image_utils import fast_gaussian_blur from nodes.properties.inputs import ImageInput, SliderInput from nodes.properties.outputs import ImageOutput @@ -57,13 +58,13 @@ def unsharp_mask_node( if radius == 0 or amount == 0: return img - blurred = cv2.GaussianBlur(img, (0, 0), radius) + blurred = fast_gaussian_blur(img, radius) threshold /= 100 if threshold == 0: img = cv2.addWeighted(img, amount + 1, blurred, -amount, 0) else: - diff = img - blurred + diff = img - blurred # type: ignore diff = np.sign(diff) * np.maximum(0, np.abs(diff) - threshold) img = img + diff * amount diff --git a/backend/src/packages/chaiNNer_standard/material_textures/normal_map/normal_map_generator.py b/backend/src/packages/chaiNNer_standard/material_textures/normal_map/normal_map_generator.py index fec7c1ebf..47be1277b 100644 --- a/backend/src/packages/chaiNNer_standard/material_textures/normal_map/normal_map_generator.py +++ b/backend/src/packages/chaiNNer_standard/material_textures/normal_map/normal_map_generator.py @@ -6,6 +6,7 @@ import numpy as np import navi +from nodes.impl.image_utils import fast_gaussian_blur from nodes.impl.normals.edge_filter import EdgeFilter, get_filter_kernels from nodes.impl.normals.height import HeightSource, get_height_map from nodes.properties.inputs import ( @@ -128,12 +129,10 @@ def normal_map_generator_node( if blur_sharp < 0: # blur - height = cv2.GaussianBlur( - height, (0, 0), sigmaX=-blur_sharp, sigmaY=-blur_sharp - ) + height = fast_gaussian_blur(height, -blur_sharp) elif blur_sharp > 0: # sharpen - blurred = cv2.GaussianBlur(height, (0, 0), sigmaX=blur_sharp, sigmaY=blur_sharp) + blurred = fast_gaussian_blur(height, blur_sharp) height = cv2.addWeighted(height, 2.0, blurred, -1.0, 0) if min_z > 0: