diff --git a/nodes/images/flex_images.py b/nodes/images/flex_images.py index ad7baf6..ed4d192 100644 --- a/nodes/images/flex_images.py +++ b/nodes/images/flex_images.py @@ -44,6 +44,7 @@ class FlexImagePosterize(FlexImageBase): "dither_strength": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01}), "channel_separation": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01}), "gamma": ("FLOAT", {"default": 1.2, "min": 0.1, "max": 2.2, "step": 0.1}), + "dither_method": (["ordered", "floyd", "none"], {"default": "ordered"}), }) return base_inputs @@ -52,41 +53,59 @@ class FlexImagePosterize(FlexImageBase): return ["max_levels", "dither_strength", "channel_separation", "gamma", "None"] def apply_effect_internal(self, image: np.ndarray, max_levels: int, dither_strength: float, - channel_separation: float, gamma: float, **kwargs) -> np.ndarray: + channel_separation: float, gamma: float, dither_method: str, **kwargs) -> np.ndarray: # Apply gamma correction image_gamma = np.power(image, gamma) - # Convert image to uint8 - image_uint8 = (image_gamma * 255).astype(np.uint8) + # Initialize result array + result = np.zeros_like(image_gamma) - posterized = np.zeros_like(image_uint8) + # Create ordered dither pattern if needed + if dither_method == "ordered": + bayer_pattern = np.array([[0, 8, 2, 10], + [12, 4, 14, 6], + [3, 11, 1, 9], + [15, 7, 13, 5]], dtype=np.float32) / 16.0 + h, w = image.shape[:2] + # Tile the pattern to match image size + pattern_h = (h + 3) // 4 + pattern_w = (w + 3) // 4 + dither_pattern = np.tile(bayer_pattern, (pattern_h, pattern_w))[:h, :w] + dither_pattern = dither_pattern[..., np.newaxis] * dither_strength for c in range(3): # RGB channels - # Calculate levels for each channel + # Calculate levels for each channel with separation channel_levels = int(np.clip(2 + (max_levels - 2) * (1 + channel_separation * (c - 1)), 2, max_levels)) - # Posterize - div = 256 // channel_levels - posterized[:,:,c] = (image_uint8[:,:,c] // div) * div + # Get current channel + channel = image_gamma[..., c] + + if dither_method == "ordered": + # Add ordered dither pattern + channel = np.clip(channel + dither_pattern[..., 0] - 0.5 * dither_strength, 0, 1) + + # Quantize + scale = (channel_levels - 1) + quantized = np.round(channel * scale) / scale + + if dither_method == "floyd": + # Efficient Floyd-Steinberg dithering using convolution + error = (channel - quantized) * dither_strength + + # Prepare error diffusion kernel + kernel = np.array([[0, 0, 0], + [0, 0, 7/16], + [3/16, 5/16, 1/16]]) + + # Apply error diffusion + from scipy.signal import convolve2d + error_diffused = convolve2d(error, kernel, mode='same') + quantized = np.clip(quantized + error_diffused, 0, 1) + + result[..., c] = quantized - if dither_strength > 0: - # Apply Floyd-Steinberg dithering with adjustable strength - error = (image_uint8.astype(np.float32) - posterized) * dither_strength - h, w = image.shape[:2] - for c in range(3): # RGB channels - for i in range(h - 1): - for j in range(w - 1): - old_pixel = posterized[i, j, c] - new_pixel = np.clip(old_pixel + error[i, j, c], 0, 255) - quant_error = old_pixel - new_pixel - posterized[i, j, c] = new_pixel - error[i, j+1, c] += quant_error * 7 / 16 - error[i+1, j-1, c] += quant_error * 3 / 16 - error[i+1, j, c] += quant_error * 5 / 16 - error[i+1, j+1, c] += quant_error * 1 / 16 - - # Convert back to float32 and apply gamma correction - return np.power(posterized.astype(np.float32) / 255.0, 1/gamma) + # Apply inverse gamma correction + return np.power(result, 1/gamma) @apply_tooltips class FlexImageKaleidoscope(FlexImageBase): @@ -197,35 +216,151 @@ class FlexImageGlitch(FlexImageBase): def INPUT_TYPES(cls): base_inputs = super().INPUT_TYPES() base_inputs["required"].update({ - "shift_amount": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 1.0, "step": 0.01}), - "scan_lines": ("INT", {"default": 10, "min": 0, "max": 100, "step": 1}), - "color_shift": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 1.0, "step": 0.01}), + "glitch_type": (["digital", "analog", "compression", "wave", "corrupt"], {"default": "digital"}), + "intensity": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "block_size": ("INT", {"default": 32, "min": 8, "max": 128, "step": 8}), + "wave_amplitude": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 1.0, "step": 0.01}), + "wave_frequency": ("FLOAT", {"default": 5.0, "min": 0.1, "max": 20.0, "step": 0.1}), + "corruption_amount": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 1.0, "step": 0.01}), + "time_seed": ("INT", {"default": 0, "min": 0, "max": 10000, "step": 1}), }) return base_inputs @classmethod def get_modifiable_params(cls): - return ["shift_amount", "scan_lines", "color_shift", "None"] + return ["intensity", "block_size", "wave_amplitude", "wave_frequency", "corruption_amount", "time_seed", "None"] - def apply_effect_internal(self, image: np.ndarray, shift_amount: float, scan_lines: int, color_shift: float, **kwargs) -> np.ndarray: + def apply_effect_internal(self, image: np.ndarray, glitch_type: str, intensity: float, + block_size: int, wave_amplitude: float, wave_frequency: float, + corruption_amount: float, time_seed: int, **kwargs) -> np.ndarray: h, w = image.shape[:2] - - # Apply horizontal shift - shift = int(w * shift_amount) - result = np.roll(image, shift, axis=1) - - # Add scan lines - if scan_lines > 0: - scan_line_mask = np.zeros((h, w)) - scan_line_mask[::scan_lines] = 1 - result = result * (1 - scan_line_mask)[:,:,np.newaxis] + scan_line_mask[:,:,np.newaxis] - - # Apply color channel shift - if color_shift > 0: - color_shift_amount = int(w * color_shift) - result[:,:,0] = np.roll(result[:,:,0], color_shift_amount, axis=1) - result[:,:,2] = np.roll(result[:,:,2], -color_shift_amount, axis=1) - + + # Add smart padding - using reflection for most natural look + pad_size = max(int(min(h, w) * 0.1), 32) # At least 32 pixels or 10% of size + padded = cv2.copyMakeBorder(image, pad_size, pad_size, pad_size, pad_size, + cv2.BORDER_REFLECT_101) + + ph, pw = padded.shape[:2] + result = padded.copy() + + # Set random seed for reproducibility + np.random.seed(time_seed) + + # Apply effects as before, but now working with padded image + if glitch_type == "digital": + for c in range(3): + shift_x = int(pw * intensity * np.random.uniform(-0.1, 0.1)) + shift_y = int(ph * intensity * np.random.uniform(-0.1, 0.1)) + result[..., c] = np.roll(np.roll(padded[..., c], shift_x, axis=1), shift_y, axis=0) + + num_blocks = int(intensity * 10) + for _ in range(num_blocks): + x = np.random.randint(0, pw - block_size) + y = np.random.randint(0, ph - block_size) + block = result[y:y+block_size, x:x+block_size].copy() + + shift_x = int(block_size * np.random.uniform(-1, 1)) + shift_y = int(block_size * np.random.uniform(-1, 1)) + + new_x = np.clip(x + shift_x, 0, pw - block_size) + new_y = np.clip(y + shift_y, 0, ph - block_size) + + result[new_y:new_y+block_size, new_x:new_x+block_size] = block + + elif glitch_type == "compression": + # Simulate JPEG compression artifacts + num_blocks_y = ph // block_size + num_blocks_x = pw // block_size + + for by in range(num_blocks_y): + for bx in range(num_blocks_x): + y1, y2 = by * block_size, (by + 1) * block_size + x1, x2 = bx * block_size, (bx + 1) * block_size + + # Random block corruption + if np.random.random() < corruption_amount: + # Quantization effect + block = result[y1:y2, x1:x2] + # Simulate DCT quantization + quant_level = int(8 * intensity) + block = (block * quant_level).astype(int) / quant_level + # Add blocking artifacts + block += np.random.uniform(-0.1, 0.1, block.shape) * intensity + result[y1:y2, x1:x2] = block + + elif glitch_type == "wave": + y_coords, x_coords = np.mgrid[0:ph, 0:pw] + + for i in range(3): + freq = wave_frequency * (i + 1) + amp = wave_amplitude / (i + 1) + + x_offset = amp * pw * np.sin(2 * np.pi * y_coords / ph * freq + time_seed * 0.1) + y_offset = amp * ph * np.cos(2 * np.pi * x_coords / pw * freq + time_seed * 0.1) + + x_map = (x_coords + x_offset * intensity).astype(np.float32) + y_map = (y_coords + y_offset * intensity).astype(np.float32) + + x_map = np.clip(x_map, 0, pw-1) + y_map = np.clip(y_map, 0, ph-1) + + result = cv2.remap(result, x_map, y_map, cv2.INTER_LINEAR) + + elif glitch_type == "corrupt": + # Data corruption simulation + for _ in range(int(corruption_amount * 20)): + # Random line corruption + if np.random.random() < 0.5: + y = np.random.randint(0, ph) + length = int(pw * np.random.uniform(0.1, 0.5)) + start = np.random.randint(0, pw - length) + + # Corrupt line with various effects + corrupt_type = np.random.choice(['repeat', 'shift', 'noise']) + if corrupt_type == 'repeat': + result[y, start:start+length] = result[y, start] + elif corrupt_type == 'shift': + shift = np.random.randint(-50, 50) + result[y, start:start+length] = np.roll(result[y, start:start+length], shift, axis=0) + else: # noise + result[y, start:start+length] = np.random.random((length, 3)) + + # Block corruption + else: + y = np.random.randint(0, ph - block_size) + x = np.random.randint(0, pw - block_size) + + # Different corruption patterns + pattern = np.random.choice(['solid', 'noise', 'repeat']) + if pattern == 'solid': + result[y:y+block_size, x:x+block_size] = np.random.random(3) + elif pattern == 'noise': + result[y:y+block_size, x:x+block_size] = np.random.random((block_size, block_size, 3)) + else: # repeat + result[y:y+block_size, x:x+block_size] = result[y, x] + + elif glitch_type == "analog": + # Simulate analog TV distortion + # Add scan lines + scan_lines = np.ones((ph, pw, 3)) + scan_lines[::2] *= 0.8 + result *= scan_lines + + # Add noise + noise = np.random.normal(0, 0.1 * intensity, (ph, pw, 3)) + result += noise + + # Add vertical hold distortion + hold_offset = int(ph * 0.1 * intensity * np.sin(time_seed * 0.1)) + result = np.roll(result, hold_offset, axis=0) + + # Add ghosting + ghost = np.roll(result, int(pw * 0.05 * intensity), axis=1) * 0.3 + result = result * 0.7 + ghost + + # Extract the center portion (removing padding) + result = result[pad_size:pad_size+h, pad_size:pad_size+w] + return np.clip(result, 0, 1) @apply_tooltips @@ -301,26 +436,123 @@ class FlexImageBloom(FlexImageBase): "threshold": ("FLOAT", {"default": 0.7, "min": 0.0, "max": 1.0, "step": 0.01}), "blur_amount": ("FLOAT", {"default": 10.0, "min": 0.0, "max": 50.0, "step": 0.1}), "intensity": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "num_passes": ("INT", {"default": 4, "min": 1, "max": 8, "step": 1}), + "color_bleeding": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01}), + "falloff": ("FLOAT", {"default": 1.2, "min": 0.1, "max": 3.0, "step": 0.1}), + "surface_scatter": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "normal_influence": ("FLOAT", {"default": 0.7, "min": 0.0, "max": 1.0, "step": 0.01}), + }) + base_inputs["optional"].update({ + "opt_normal_map": ("IMAGE",), }) return base_inputs @classmethod def get_modifiable_params(cls): - return ["threshold", "blur_amount", "intensity", "None"] + return ["threshold", "blur_amount", "intensity", "num_passes", "color_bleeding", "falloff", "surface_scatter", "normal_influence", "None"] def apply_effect_internal(self, image: np.ndarray, threshold: float, blur_amount: float, - intensity: float, **kwargs) -> np.ndarray: - # Extract bright areas - bright_areas = np.maximum(image - threshold, 0) / (1 - threshold) - - # Apply gaussian blur - blurred = gaussian_filter(bright_areas, sigma=blur_amount) - - # Combine with original image - result = image + blurred * intensity - + intensity: float, num_passes: int, color_bleeding: float, + falloff: float, surface_scatter: float, normal_influence: float, + opt_normal_map: np.ndarray = None, **kwargs) -> np.ndarray: + # Convert to float32 for better precision + image = image.astype(np.float32) + + # Extract bright areas with smooth threshold + brightness = np.max(image, axis=2) + bright_mask = np.clip((brightness - threshold) / (1 - threshold), 0, 1) + bright_mask = np.power(bright_mask, falloff) + + # Initialize accumulator for bloom effect + bloom_accumulator = np.zeros_like(image) + + # Process normal map if provided + if opt_normal_map is not None: + # Extract the normal map for the current frame and ensure it's numpy + if torch.is_tensor(opt_normal_map): + opt_normal_map = opt_normal_map.cpu().numpy() + + if len(opt_normal_map.shape) > 3: # If batched + frame_index = kwargs.get('frame_index', 0) + opt_normal_map = opt_normal_map[frame_index] + + # Resize normal map to match image dimensions if needed + h, w = image.shape[:2] + if opt_normal_map.shape[:2] != (h, w): + opt_normal_map = cv2.resize(opt_normal_map, (w, h), interpolation=cv2.INTER_LINEAR) + + # Convert normal map from [0,1] to [-1,1] range + normals = opt_normal_map * 2.0 - 1.0 + + # Calculate surface alignment factors using numpy operations + view_vector = np.array([0, 0, 1]) + surface_alignment = np.zeros((h, w), dtype=np.float32) + for i in range(3): # Manual dot product + surface_alignment += normals[..., i] * view_vector[i] + surface_alignment = (surface_alignment + 1) * 0.5 # Normalize to [0,1] + + # Create directional kernels based on surface normals + y_grad, x_grad = np.gradient(surface_alignment) + angles = np.arctan2(y_grad, x_grad) + strengths = np.sqrt(x_grad**2 + y_grad**2) + + # Multi-pass gaussian blur at different scales + for i in range(num_passes): + # Calculate sigma for this pass (increasing with each pass) + current_sigma = blur_amount * (1 + i) + + # Extract color information with color bleeding control + color_contribution = image * (1 - color_bleeding) + np.mean(image, axis=2, keepdims=True) * color_bleeding + + # Apply bright mask with pass-specific weighting + pass_contribution = color_contribution * bright_mask[..., np.newaxis] + + if opt_normal_map is not None: + # Apply anisotropic gaussian blur based on surface normals + for c in range(3): + channel = pass_contribution[..., c] + + # Calculate directional sigmas based on surface normals + sigma_x = current_sigma * (1 + strengths * surface_scatter * np.cos(angles)) + sigma_y = current_sigma * (1 + strengths * surface_scatter * np.sin(angles)) + + # Blend between directional and uniform blur based on normal influence + uniform_blur = gaussian_filter(channel, sigma=current_sigma) + directional_blur = np.zeros_like(channel) + + # Apply directional blur + for angle in np.linspace(0, np.pi, 4): # Sample 4 directions + rot_sigma_x = sigma_x * np.cos(angle) - sigma_y * np.sin(angle) + rot_sigma_y = sigma_x * np.sin(angle) + sigma_y * np.cos(angle) + # Take mean of sigma values for each direction + sigma_y_mean = np.mean(np.abs(rot_sigma_y)) + sigma_x_mean = np.mean(np.abs(rot_sigma_x)) + directional_blur += gaussian_filter(channel, sigma=[sigma_y_mean, sigma_x_mean]) + + directional_blur /= 4 # Average the directional samples + + # Blend between uniform and directional blur + blurred_channel = uniform_blur * (1 - normal_influence) + directional_blur * normal_influence + pass_contribution[..., c] = blurred_channel + + # Modulate blur by surface alignment + pass_contribution *= (1 - surface_alignment[..., np.newaxis] * surface_scatter) + else: + # Regular gaussian blur if no normal map + pass_contribution = gaussian_filter(pass_contribution, sigma=[current_sigma, current_sigma, 0]) + + # Weight contribution based on pass number (later passes contribute less) + pass_weight = 1.0 / (2 ** i) + bloom_accumulator += pass_contribution * pass_weight + + # Normalize accumulator + bloom_accumulator /= np.sum([1.0 / (2 ** i) for i in range(num_passes)]) + + # Combine with original image using intensity + result = image + bloom_accumulator * intensity + return np.clip(result, 0, 1) - + @apply_tooltips class FlexImageTiltShift(FlexImageBase): @classmethod diff --git a/tooltips/categories/images.py b/tooltips/categories/images.py index 2f37b3d..bd4f8dd 100644 --- a/tooltips/categories/images.py +++ b/tooltips/categories/images.py @@ -28,10 +28,14 @@ Each node type has different parameters that can be modulated: # FlexImagePosterize tooltips (inherits from: FlexImageBase) TooltipManager.register_tooltips("FlexImagePosterize", { - "max_levels": "Maximum number of color levels per channel (2 to 256)", - "dither_strength": "Intensity of dithering effect (0.0 to 1.0)", - "channel_separation": "Degree of separation between color channels (0.0 to 1.0)", - "gamma": "Gamma correction applied before posterization (0.1 to 2.2)", + "max_levels": "Maximum number of color levels per channel. Higher values preserve more color detail (2 to 256)", + "dither_strength": "Intensity of dithering effect. Higher values reduce color banding (0.0 to 1.0)", + "channel_separation": "Degree of separation between color channels. Creates color shift effects (0.0 to 1.0)", + "gamma": "Gamma correction applied before posterization. Affects brightness distribution (0.1 to 2.2)", + "dither_method": """Method used for dithering: +- ordered: Fast Bayer matrix dithering, good for retro effects +- floyd: Floyd-Steinberg dithering, better for natural gradients +- none: No dithering, creates sharp color transitions""", "feature_param": """Choose which parameter to modulate: - max_levels: Dynamically adjust color quantization @@ -74,31 +78,43 @@ Each node type has different parameters that can be modulated: # FlexImageGlitch tooltips (inherits from: FlexImageBase) TooltipManager.register_tooltips("FlexImageGlitch", { - "shift_amount": "Magnitude of horizontal shift (0.0 to 1.0)", - "scan_lines": "Number of scan lines to add (0 to 100)", - "color_shift": "Amount of color channel separation (0.0 to 1.0)", + "glitch_type": """Type of glitch effect to apply: +- digital: RGB channel shifts and block displacement +- analog: Classic TV distortion with scan lines and ghosting +- compression: JPEG-like block artifacts +- wave: Smooth wave distortions +- corrupt: Random data corruption effects""", + "intensity": "Overall strength of the glitch effect (0.0 to 1.0)", + "block_size": "Size of blocks for digital glitches and compression artifacts (8 to 128)", + "wave_amplitude": "Height of wave distortions (0.0 to 1.0)", + "wave_frequency": "Frequency of wave patterns (0.1 to 20.0)", + "corruption_amount": "Probability and intensity of corruption artifacts (0.0 to 1.0)", + "time_seed": "Seed for random glitch generation. Same seed produces same pattern (0 to 10000)", "feature_param": """Choose which parameter to modulate: -- shift_amount: Dynamically adjust glitch displacement -- scan_lines: Dynamically adjust line density -- color_shift: Dynamically adjust color separation +- intensity: Dynamically adjust effect strength +- block_size: Dynamically adjust artifact size +- wave_amplitude: Dynamically adjust wave height +- wave_frequency: Dynamically adjust wave frequency +- corruption_amount: Dynamically adjust corruption intensity +- time_seed: Dynamically change random patterns - None: No parameter modulation""" }, inherits_from='FlexImageBase') # FlexImageChromaticAberration tooltips (inherits from: FlexImageBase) TooltipManager.register_tooltips("FlexImageChromaticAberration", { - "shift_amount": "Magnitude of color channel shift (0.0 to 0.1)", - "angle": "Angle of the shift effect (0.0 to 360.0)", + "shift_amount": "Amount of RGB channel separation. Higher values create more color fringing (0.0 to 0.5)", + "angle": "Direction of the chromatic aberration effect in degrees. 0° is horizontal, 90° is vertical (0.0 to 720.0)", "feature_param": """Choose which parameter to modulate: -- shift_amount: Dynamically adjust color separation amount +- shift_amount: Dynamically adjust color separation - angle: Dynamically adjust separation direction - None: No parameter modulation""" }, inherits_from='FlexImageBase') # FlexImagePixelate tooltips (inherits from: FlexImageBase) TooltipManager.register_tooltips("FlexImagePixelate", { - "pixel_size": "Size of each pixelated block (1 to 100)", + "pixel_size": "Size of each pixelated block. Larger values create more pronounced pixelation (1 to 100)", "feature_param": """Choose which parameter to modulate: - pixel_size: Dynamically adjust pixelation size @@ -107,25 +123,36 @@ Each node type has different parameters that can be modulated: # FlexImageBloom tooltips (inherits from: FlexImageBase) TooltipManager.register_tooltips("FlexImageBloom", { - "threshold": "Brightness threshold for the bloom effect (0.0 to 1.0)", - "blur_amount": "Amount of blur applied to the bloom (0.0 to 50.0)", - "intensity": "Strength of the bloom effect (0.0 to 1.0)", + "threshold": "Brightness threshold for bloom effect. Only pixels brighter than this value will glow (0.0 to 1.0)", + "blur_amount": "Base radius of the bloom effect. Higher values create softer, more diffused glow (0.0 to 50.0)", + "intensity": "Overall strength of the bloom effect. Controls the brightness of the glow (0.0 to 1.0)", + "num_passes": "Number of bloom passes. More passes create layered, atmospheric glow effects (1 to 8)", + "color_bleeding": "Amount of color spreading between bright areas. Higher values create more color mixing (0.0 to 1.0)", + "falloff": "How quickly the bloom effect diminishes with distance. Higher values create tighter, more focused glow (0.1 to 3.0)", + "surface_scatter": "How much the bloom follows surface geometry when using normal map. Affects glow distribution (0.0 to 1.0)", + "normal_influence": "Strength of normal map's influence on bloom direction. Controls surface-aware lighting (0.0 to 1.0)", + "opt_normal_map": "Optional normal map for surface-aware bloom. Enables realistic light scattering based on surface geometry", "feature_param": """Choose which parameter to modulate: -- threshold: Dynamically adjust glow threshold -- blur_amount: Dynamically adjust glow spread -- intensity: Dynamically adjust glow strength +- threshold: Dynamically adjust bloom threshold +- blur_amount: Dynamically adjust bloom radius +- intensity: Dynamically adjust bloom strength +- num_passes: Dynamically adjust bloom quality +- color_bleeding: Dynamically adjust color spread +- falloff: Dynamically adjust bloom falloff +- surface_scatter: Dynamically adjust surface influence +- normal_influence: Dynamically adjust normal map impact - None: No parameter modulation""" }, inherits_from='FlexImageBase') # FlexImageTiltShift tooltips (inherits from: FlexImageBase) TooltipManager.register_tooltips("FlexImageTiltShift", { - "blur_amount": "Strength of the blur effect (0.0 to 50.0)", - "focus_position_x": "X-coordinate of the focus center (0.0 to 1.0)", - "focus_position_y": "Y-coordinate of the focus center (0.0 to 1.0)", - "focus_width": "Width of the focus area (0.0 to 1.0)", - "focus_height": "Height of the focus area (0.0 to 1.0)", - "focus_shape": "Shape of the focus area ('rectangle' or 'ellipse')", + "blur_amount": "Amount of blur applied to out-of-focus areas (0.0 to 50.0)", + "focus_position_x": "Horizontal position of focus center. 0 = left, 1 = right (0.0 to 1.0)", + "focus_position_y": "Vertical position of focus center. 0 = top, 1 = bottom (0.0 to 1.0)", + "focus_width": "Width of the focus area relative to image width (0.0 to 1.0)", + "focus_height": "Height of the focus area relative to image height (0.0 to 1.0)", + "focus_shape": "Shape of the focus area:\n- rectangle: Sharp-edged focus region\n- ellipse: Smooth oval focus region", "feature_param": """Choose which parameter to modulate: - blur_amount: Dynamically adjust blur intensity