diff --git a/__init__.py b/__init__.py index 5c868e4..2e41f9b 100644 --- a/__init__.py +++ b/__init__.py @@ -9,6 +9,7 @@ class RyanOnTheInside(metaclass=CombinedMeta): display_name = NODE_DISPLAY_NAME_MAPPINGS.get(cls.__name__, cls.__name__) footer = "For more information, visit [RyanOnTheInside GitHub](https://github.com/ryanontheinside).\n\n" footer += "For tutorials and example workflows visit [RyanOnTheInside Civitai](https://civitai.com/user/ryanontheinside).\n\n" + footer += "For video tutorials and more visit [RyanOnTheInside YouTube](https://www.youtube.com/@ryanontheinside).\n\n" display_name = display_name.replace(" | RyanOnTheInside", "") desc = f"# {display_name}\n\n" @@ -65,12 +66,20 @@ from .nodes.flex.feature_extractors import( ColorFeatureNode, BrightnessFeatureNode, MotionFeatureNode, + AreaFeatureNode, ) from .nodes.flex.midi_feature_extractor import( MIDILoadAndExtract, ) +from .nodes.flex.proximity_feature_extractor import( + LocationFromMask, + ProximityFeatureNode, + ProximityVisualizer, + LocationFromPoint, + LocationTransform, +) @@ -112,6 +121,7 @@ from .nodes.utility_nodes import ( ImageDifference, ImageShuffle, SwapDevice, + ImageIntervalSelectPercentage, ) ###images @@ -147,19 +157,24 @@ from .nodes.images.flex_images import ( FlexImageColorGrade, FlexImageTiltShift, FlexImageParallax, + ) from .nodes.flex.feature_externals import ( FeatureToWeightsStrategy, + DepthShapeModifier, ) from .nodes.flex.feature_modulation import ( FeatureMixer, - FeatureMath, + FeatureCombine, FeatureOscillator, FeatureScaler, FeatureSmoothing, FeatureFade, + PreviewFeature, + FeatureMath, + FeatureRebase, ) @@ -235,16 +250,26 @@ NODE_CLASS_MAPPINGS = { "ColorFeatureNode": ColorFeatureNode, "BrightnessFeatureNode": BrightnessFeatureNode, "MotionFeatureNode": MotionFeatureNode, + "LocationFromMask": LocationFromMask, + "ProximityFeatureNode": ProximityFeatureNode, + "ProximityVisualizer": ProximityVisualizer, + "LocationFromPoint": LocationFromPoint, + "LocationTransform": LocationTransform, + "AreaFeatureNode": AreaFeatureNode, + "FeatureToWeightsStrategy": FeatureToWeightsStrategy, + "DepthShapeModifier": DepthShapeModifier, #feature modulation "FeatureMixer": FeatureMixer, - "FeatureMath": FeatureMath, + "FeatureCombine": FeatureCombine, "FeatureOscillator": FeatureOscillator, "FeatureScaler": FeatureScaler, "FeatureSmoothing": FeatureSmoothing, "FeatureFade": FeatureFade, - + "FeatureMath": FeatureMath, + "PreviewFeature": PreviewFeature, + "FeatureRebase": FeatureRebase, #images 'FlexImageEdgeDetect': FlexImageEdgeDetect, "FlexImagePosterize": FlexImagePosterize, @@ -256,7 +281,6 @@ NODE_CLASS_MAPPINGS = { "FlexImageColorGrade": FlexImageColorGrade, "FlexImageTiltShift": FlexImageTiltShift, "FlexImageParallax": FlexImageParallax, - #opacity xp # "FlexDepthBasedMaskOpacity": FlexDepthBasedMaskOpacity, # "DepthBasedMaskOpacity": DepthBasedMaskOpacity, @@ -275,6 +299,7 @@ NODE_CLASS_MAPPINGS = { "ImageDifference": ImageDifference, "ImageShuffle": ImageShuffle, "SwapDevice": SwapDevice, + "ImageIntervalSelectPercentage":ImageIntervalSelectPercentage, } @@ -322,17 +347,17 @@ NODE_DISPLAY_NAME_MAPPINGS = { "MotionFeatureNode":"Motion Feature", "FeatureMixer": "FeatureMod Mixer", - "FeatureMath": "FeatureMod Math", + "FeatureCombine": "FeatureMod Combine", "FeatureOscillator": "FeatureMod Oscillator", "FeatureScaler": "FeatureMod Scaler", "FeatureSmoothing": "FeatureMod Smoothing", - - + "FeatureMath": "FeatureMod Math", "MovingShape": "Moving Shape", "TextMaskNode":"Text Mask Node", "DyeImage" : "Dye Image", + "ImageIntervalSelectPercentage": "Image Interval Select %" # "FlexImageAdjustment":"Flex Image Adjustment", # "FlexImageFilter":"Flex Image Filter", # "FlexImageBlend":"Flex Image Blend", diff --git a/node_configs.py b/node_configs.py index 9881603..9a7df1a 100644 --- a/node_configs.py +++ b/node_configs.py @@ -325,7 +325,7 @@ add_node_config("FlexMaskDepthChamber", { - `z_front`: Front depth value for the mask (0.0 to 1.0). Default is 1.0. - `z_back`: Back depth value for the mask (0.0 to 1.0). Default is 0.0. - `feature_param`: Parameter to modulate based on the feature. Options are "none", "z_front", "z_back", "both". -- `feature_mode`: Mode of feature modulation. Options are "squeeze" and "expand". +- `feature_mode`: Mode of feature modulation. This node creates a mask based on the depth values in the input depth map. The mask is modulated by the specified front and back depth values, and can be further adjusted using a feature input to dynamically change the depth range. """ @@ -593,6 +593,69 @@ Analyzes the input depth maps to extract the specified depth-related feature. Th """ }) +add_node_config("AreaFeatureNode", { + "TOP_DESCRIPTION": "Extracts area-related features from mask sequences for mask modulation.", + "ADDITIONAL_INFO": """ +- `masks`: Input mask sequence to analyze (MASK type) +- `feature_type`: Type of area feature to extract + - Options: + - "total_area" (sum of pixels above threshold) + - "largest_contour" (area of the largest contiguous region) + - "bounding_box" (area of the bounding box containing the largest region) +- `threshold`: Threshold value for considering pixels as part of the area (0.0 to 1.0) + +This node analyzes the input mask sequence to extract the specified area-related feature. The resulting feature can be used to modulate masks based on changes in area over time, allowing for effects that respond to the size or extent of masked regions in the scene. +""" +}) + +add_node_config("ProximityFeatureNode", { + "TOP_DESCRIPTION": "Calculates a proximity feature based on the distance between anchor and query locations in video frames.", + "ADDITIONAL_INFO": """ +- `video_frames`: Input video frames (IMAGE type) +- `frame_rate`: Frame rate of the video (FLOAT type, default: 30.0, min: 1.0, max: 120.0, step: 0.1) +- `anchor_locations`: Locations of anchor points (LOCATION type) +- `query_locations`: Locations of query points (LOCATION type) +- `distance_metric`: Distance metric to use for calculation (Options: "euclidean", "manhattan", "chebyshev") + +This node calculates a proximity feature based on the specified distance metric between anchor and query locations in the input video frames. The resulting feature can be used to modulate other effects based on spatial relationships. +""" +}) + +add_node_config("LocationFromMask", { + "TOP_DESCRIPTION": "This is for use with proximity features. This generates locations from mask inputs using various methods.", + "ADDITIONAL_INFO": """ +- `masks`: Input masks (MASK type) +- `method`: Method to use for location extraction (Options: "mask_center", "mask_boundary", "mask_top_left", "mask_bottom_right") +- `depth_maps`: (Optional) Input depth maps. The depth map provides a value for the z coordinate of every location. If no depth map is provided, the value defaults to .5. The z coordinate is far less granular than x and y, as all we have are relative normalized depth per frame (0 to 1). + +This node generates locations from the input masks using the specified method. The locations can be used as anchor or query points for proximity calculations or other spatially dependent effects. +""" +}) + +add_node_config("LocationFromPoint", { + "TOP_DESCRIPTION": "Generates locations from specified x, y, and z coordinates.", + "ADDITIONAL_INFO": """ +- `x`: X-coordinate of the location (FLOAT, default: 0.0, min: 0.0, step: 0.01) +- `y`: Y-coordinate of the location (FLOAT, default: 0.0, min: 0.0, step: 0.01) +- `batch_count`: Number of locations to generate (INT, default: 1, min: 1) +- `z`: Z-coordinate of the location (FLOAT, default: 0.0, min: 0.0, max: 1.0, step: 0.01) + +This node generates a batch of locations based on the specified x, y, and z coordinates. +""" +}) + +add_node_config("LocationTransform", { + "TOP_DESCRIPTION": "Transforms locations based on a feature and specified transformation type.", + "ADDITIONAL_INFO": """ +- `locations`: Input locations to be transformed (LOCATION type) +- `feature`: Feature used to modulate the transformation (FEATURE type) +- `transformation_type`: Type of transformation to apply ("translate" or "scale") +- `transformation_value`: Value of the transformation (FLOAT, default: 1.0) + +This node transforms the input locations based on the specified transformation type and value, modulated by the input feature. +""" +}) + add_node_config("EmitterMovement", { "TOP_DESCRIPTION": """These parameters work together to create complex, periodic movements for particle emitters. By adjusting frequencies and amplitudes, you can achieve various patterns like circles, @@ -725,6 +788,30 @@ Outputs: """ }) +add_node_config("FeatureRebase", { + "TOP_DESCRIPTION": "Rebases feature values within specified thresholds.", + "ADDITIONAL_INFO": """ +- `feature`: Input feature to be rebased (FEATURE type) +- `lower_threshold`: Lower threshold for feature values (FLOAT, default: 0.0, min: 0.0, max: 1.0, step: 0.01) +- `upper_threshold`: Upper threshold for feature values (FLOAT, default: 1.0, min: 0.0, max: 1.0, step: 0.01) +- `invert_output`: Whether to invert the output feature values (BOOLEAN, default: False) + +This node rebases the input feature values within the specified thresholds and normalizes them. +""" +}) + +add_node_config("FeatureMath", { + "TOP_DESCRIPTION": "Performs mathematical operations between a feature's values and a float value.", + "ADDITIONAL_INFO": """ +- `feature`: Input feature (FEATURE type) +- `y`: Input value (FLOAT type) +- `operation`: Mathematical operation to perform ("add", "subtract", "multiply", "divide", "max", "min"). Determines how the feature's values are combined with y. + +This node takes a feature and performs the specified operation between its values and the float value y, returning the processed feature and its visualization. +""" +}) + + add_node_config("FeatureScaler", { "TOP_DESCRIPTION": "Scales and transforms feature values using various mathematical functions.", "ADDITIONAL_INFO": """ @@ -736,14 +823,14 @@ add_node_config("FeatureScaler", { """ }) -add_node_config("FeatureMath", { +add_node_config("FeatureCombine", { "TOP_DESCRIPTION": "Performs mathematical operations between two features.", "ADDITIONAL_INFO": """ - `feature1`: First input feature (FEATURE type) - `feature2`: Second input feature (FEATURE type) - - `operation`: Mathematical operation to perform ("add", "subtract", "multiply", "divide", "max", "min") - - `weight1`: Weight applied to feature1 (0.0 to 1.0) - - `weight2`: Weight applied to feature2 (0.0 to 1.0) + - `operation`: Mathematical operation to perform ("add", "subtract", "multiply", "divide", "max", "min"). Determines how the two features are combined. + - `weight1`: Weight applied to feature1 (0.0 to 1.0). Higher values give more importance to feature1 in the operation. + - `weight2`: Weight applied to feature2 (0.0 to 1.0). Higher values give more importance to feature2 in the operation. """ }) @@ -752,9 +839,9 @@ add_node_config("FeatureSmoothing", { "ADDITIONAL_INFO": """ - `feature`: Input feature to be smoothed (FEATURE type) - `smoothing_type`: Type of smoothing to apply ("moving_average", "exponential", "gaussian") - - `window_size`: Size of the smoothing window for moving average and gaussian (3 to 21, odd numbers only) - - `alpha`: Smoothing factor for exponential smoothing (0.0 to 1.0) - - `sigma`: Standard deviation for gaussian smoothing (0.1 to 5.0) + - `window_size`: Size of the smoothing window for moving average and gaussian (3 to 21, odd numbers only). Larger values create smoother transitions but may reduce responsiveness. + - `alpha`: Smoothing factor for exponential smoothing (0.0 to 1.0). Higher values make the feature respond more quickly to changes, while lower values create a more gradual, smoothed effect. + - `sigma`: Standard deviation for gaussian smoothing (0.1 to 5.0). Higher values create a more pronounced smoothing effect. """ }) @@ -942,6 +1029,16 @@ add_node_config("ImageIntervalSelect", { """ }) +add_node_config("ImageIntervalSelectPercentage", { + "TOP_DESCRIPTION": "Selects images from a sequence at specified percentage intervals.", + "ADDITIONAL_INFO": """ +- `image`: Input image sequence (IMAGE type) +- `interval_percentage`: Interval at which to select images as a percentage of the total sequence length (1 to 100) +- `start_percentage`: Starting percentage for selection (0 to 100) +- `end_percentage`: Ending percentage for selection (0 to 100) +""" +}) + add_node_config("ImageChunks", { "TOP_DESCRIPTION": "Concatenates images into a grid.", "ADDITIONAL_INFO": """ diff --git a/nodes/flex/feature_externals.py b/nodes/flex/feature_externals.py index 93df7ca..c9b9cd3 100644 --- a/nodes/flex/feature_externals.py +++ b/nodes/flex/feature_externals.py @@ -1,3 +1,7 @@ +import torch +import torch.nn.functional as F +import numpy as np +from ... import RyanOnTheInside from ... import RyanOnTheInside class FlexExternalModulator(RyanOnTheInside): @@ -36,3 +40,106 @@ class FeatureToWeightsStrategy(FlexExternalModulator): return (weights_strategy,) + + + +import torch +import torch.nn.functional as F +import cv2 + +class DepthShapeModifier(FlexExternalModulator): + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "depth_map": ("IMAGE",), + "mask": ("MASK",), + # "feature": ("FEATURE",), + # "feature_param": (["gradient_steepness", "depth_min", "depth_max", "strength"],), + "gradient_steepness": ("FLOAT", {"default": 2.0, "min": 0.1, "max": 10.0, "step": 0.1}), + "depth_min": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}), + "depth_max": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), + "strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "modify_depth" + CATEGORY = "RyanOnTheInside/DepthModifiers" + + def modify_depth(self, depth_map, mask, gradient_steepness, depth_min, depth_max, strength): + device = depth_map.device + mask = mask.to(device).bool() + + b, h, w, c = depth_map.shape + + modified_depths = [] + for i in range(b): + # Modify feature parameters based on the feature value + # gradient_steepness, depth_min, depth_max, strength = self.modify_feature_param( + # feature, feature_param, gradient_steepness, depth_min, depth_max, strength + # ) #TODO + + mask_i = mask[i].cpu().numpy().astype(np.uint8) + + # Use cv2 to find contours (separate shapes) + contours, _ = cv2.findContours(mask_i, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + sphere_gradients = torch.zeros((h, w), device=device) + + for contour in contours: + # Create a mask for this contour + component_mask = np.zeros_like(mask_i) + cv2.drawContours(component_mask, [contour], 0, 1, -1) + component_mask = torch.from_numpy(component_mask).to(device).bool() + + # Find center and radius of the circular component + M = cv2.moments(contour) + if M["m00"] != 0: + center_x = int(M["m10"] / M["m00"]) + center_y = int(M["m01"] / M["m00"]) + else: + continue # Skip this contour if we can't find its center + + # Calculate radius (approximate) + radius = np.sqrt(cv2.contourArea(contour) / np.pi) + + # Generate spherical gradient + y, x = torch.meshgrid(torch.arange(h, device=device), torch.arange(w, device=device), indexing='ij') + distances = torch.sqrt((y - center_y)**2 + (x - center_x)**2) + sphere_gradient = torch.clamp(1 - (distances / radius) ** gradient_steepness, 0, 1) + + # Apply gradient only to this component + sphere_gradients += sphere_gradient * component_mask.float() + + # Scale gradient to depth range + depth_gradient = depth_min + sphere_gradients * (depth_max - depth_min) + + # Apply gradient to depth map + modified_depth = depth_map[i].clone() + depth_gradient = depth_gradient.unsqueeze(-1).repeat(1, 1, c) + modified_depth = torch.where(mask[i].unsqueeze(-1).repeat(1, 1, c), + depth_gradient, + modified_depth) + + # Blend modified depth with original depth + blend_mask = (mask[i].unsqueeze(-1) * strength).repeat(1, 1, c) + modified_depth = depth_map[i] * (1 - blend_mask) + modified_depth * blend_mask + modified_depths.append(modified_depth) + + result = torch.stack(modified_depths, dim=0) + return (result,) + + def modify_feature_param(self, feature, feature_param, gradient_steepness, depth_min, depth_max, strength): + frames = feature.frame_count + for i in range(frames): + value = feature.get_value_at_frame(i) + if feature_param == "gradient_steepness": + gradient_steepness *= value + elif feature_param == "depth_min": + depth_min += value + elif feature_param == "depth_max": + depth_max -= value + elif feature_param == "strength": + strength *= value + return gradient_steepness, depth_min, depth_max, strength \ No newline at end of file diff --git a/nodes/flex/feature_extractors.py b/nodes/flex/feature_extractors.py index 54c6294..fa46237 100644 --- a/nodes/flex/feature_extractors.py +++ b/nodes/flex/feature_extractors.py @@ -1,8 +1,37 @@ from .feature_pipe import FeaturePipe from ... import RyanOnTheInside -from .features import AudioFeature, TimeFeature, DepthFeature, ColorFeature, BrightnessFeature, MotionFeature +from .features import AudioFeature, TimeFeature, DepthFeature, ColorFeature, BrightnessFeature, MotionFeature, AreaFeature +from .proximity_feature import Location +from tqdm import tqdm +from comfy.utils import ProgressBar class FeatureExtractorBase(RyanOnTheInside): + def __init__(self): + self.progress_bar = None + self.tqdm_bar = None + self.current_progress = 0 + self.total_steps = 0 + + def start_progress(self, total_steps, desc="Processing"): + self.progress_bar = ProgressBar(total_steps) + self.tqdm_bar = tqdm(total=total_steps, desc=desc, leave=False) + self.current_progress = 0 + self.total_steps = total_steps + + def update_progress(self, step=1): + self.current_progress += step + if self.progress_bar: + self.progress_bar.update(step) + if self.tqdm_bar: + self.tqdm_bar.update(step) + + def end_progress(self): + if self.tqdm_bar: + self.tqdm_bar.close() + self.progress_bar = None + self.tqdm_bar = None + self.current_progress = 0 + self.total_steps = 0 CATEGORY="RyanOnTheInside/FlexFeatures" class AudioFeatureExtractor(FeatureExtractorBase): @@ -119,7 +148,7 @@ class MotionFeatureNode(FirstFeature): **super().INPUT_TYPES(), "required": { **super().INPUT_TYPES()["required"], - "feature_type": (["mean_motion", "max_motion", "motion_direction", "horizontal_motion", "vertical_motion", "motion_complexity"],), + "feature_type": (["mean_motion", "max_motion", "motion_direction", "horizontal_motion", "vertical_motion", "motion_complexity","motion_speed"],), "flow_method": (["Farneback", "LucasKanade", "PyramidalLK"],), "flow_threshold": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 10.0, "step": 0.1}), "magnitude_threshold": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}), @@ -131,6 +160,13 @@ class MotionFeatureNode(FirstFeature): def create_feature(self, video_frames, frame_rate, feature_type, flow_method, flow_threshold, magnitude_threshold): feature_pipe = FeaturePipe(frame_rate, video_frames) + num_frames = feature_pipe.frame_count + + self.start_progress(num_frames, desc="Extracting motion features") + + def progress_callback(current_step, total_steps): + self.update_progress(current_step - self.current_progress) + motion_feature = MotionFeature( "motion_feature", feature_pipe.frame_rate, @@ -139,7 +175,35 @@ class MotionFeatureNode(FirstFeature): feature_type, flow_method, flow_threshold, - magnitude_threshold + magnitude_threshold, + progress_callback=progress_callback ) + motion_feature.extract() - return (motion_feature, feature_pipe) \ No newline at end of file + self.end_progress() + + return (motion_feature, feature_pipe) + + +class AreaFeatureNode(FirstFeature): + @classmethod + def INPUT_TYPES(cls): + return { + **super().INPUT_TYPES(), + "required": { + **super().INPUT_TYPES()["required"], + "masks": ("MASK",), + "feature_type": (["total_area", "largest_contour", "bounding_box"],), + "threshold": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + } + } + + RETURN_TYPES = ("FEATURE", "FEATURE_PIPE") + FUNCTION = "create_feature" + + def create_feature(self, masks, video_frames, frame_rate, feature_type, threshold): + feature_pipe = FeaturePipe(frame_rate, video_frames) + area_feature = AreaFeature("area_feature", feature_pipe.frame_rate, feature_pipe.frame_count, + masks, feature_type=feature_type, threshold=threshold) + area_feature.extract() + return (area_feature, feature_pipe) \ No newline at end of file diff --git a/nodes/flex/feature_modulation.py b/nodes/flex/feature_modulation.py index 0a2c796..ab55b78 100644 --- a/nodes/flex/feature_modulation.py +++ b/nodes/flex/feature_modulation.py @@ -6,7 +6,7 @@ from io import BytesIO from PIL import Image class FeatureModulationBase(RyanOnTheInside): - CATEGORY = "RyanOnTheInside/FlexFeatures" + CATEGORY = "RyanOnTheInside/FlexFeatures/FeatureModulators" FUNCTION = "modulate" @classmethod @@ -223,7 +223,7 @@ class FeatureScaler(FeatureModulationBase): processed_feature = self.create_processed_feature(feature, final_values, "Scaled", invert_output) return (processed_feature, self.visualize(processed_feature)) -class FeatureMath(FeatureModulationBase): +class FeatureCombine(FeatureModulationBase): @classmethod def INPUT_TYPES(cls): return { @@ -265,6 +265,41 @@ class FeatureMath(FeatureModulationBase): processed_feature = self.create_processed_feature(feature1, combined, "Combined", invert_output) return (processed_feature, self.visualize(processed_feature)) +class FeatureMath(FeatureModulationBase): + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "feature": ("FEATURE",), + "y": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 10000000.0, "step": 0.005}), + "operation": (["add", "subtract", "multiply", "divide", "max", "min"],), + **super().INPUT_TYPES()["required"], + } + } + + RETURN_TYPES = ("FEATURE", "IMAGE") + RETURN_NAMES = ("FEATURE", "FEATURE_VISUALIZATION") + FUNCTION = "modulate" + + def modulate(self, feature, y, operation, invert_output): + values = [feature.get_value_at_frame(i) for i in range(feature.frame_count)] + + if operation == "add": + result = [v + y for v in values] + elif operation == "subtract": + result = [v - y for v in values] + elif operation == "multiply": + result = [v * y for v in values] + elif operation == "divide": + result = [v / y if y != 0 else 0 for v in values] + elif operation == "max": + result = [max(v, y) for v in values] + elif operation == "min": + result = [min(v, y) for v in values] + + processed_feature = self.create_processed_feature(feature, result, "MathResult", invert_output) + return (processed_feature, self.visualize(processed_feature)) + class FeatureSmoothing(FeatureModulationBase): @classmethod def INPUT_TYPES(cls): @@ -284,6 +319,7 @@ class FeatureSmoothing(FeatureModulationBase): def modulate(self, feature, smoothing_type, window_size, alpha, sigma, invert_output): values = [feature.get_value_at_frame(i) for i in range(feature.frame_count)] + original_min = min(values) if smoothing_type == "moving_average": smoothed = np.convolve(values, np.ones(window_size), 'valid') / window_size @@ -300,7 +336,12 @@ class FeatureSmoothing(FeatureModulationBase): kernel = kernel / np.sum(kernel) smoothed = np.convolve(values, kernel, mode='same') - processed_feature = self.create_processed_feature(feature, smoothed, "Smoothed", invert_output) + # Adjust the smoothed values to ensure the minimum value remains unchanged + smoothed_min = min(smoothed) + adjustment = original_min - smoothed_min + adjusted_smoothed = [v + adjustment for v in smoothed] + + processed_feature = self.create_processed_feature(feature, adjusted_smoothed, "Smoothed", invert_output) return (processed_feature, self.visualize(processed_feature)) class FeatureOscillator(FeatureModulationBase): @@ -378,4 +419,62 @@ class FeatureFade(FeatureModulationBase): combined = [(1 - f) * v1 + f * v2 for v1, v2, f in zip(values1, values2, fader_values)] processed_feature = self.create_processed_feature(feature1, combined, "Faded", invert_output) - return (processed_feature, self.visualize(processed_feature)) \ No newline at end of file + return (processed_feature, self.visualize(processed_feature)) + +class FeatureRebase(FeatureModulationBase): + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "feature": ("FEATURE",), + "lower_threshold": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}), + "upper_threshold": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), + **super().INPUT_TYPES()["required"], + } + } + + RETURN_TYPES = ("FEATURE", "IMAGE") + RETURN_NAMES = ("FEATURE", "FEATURE_VISUALIZATION") + FUNCTION = "rebase" + + def rebase(self, feature, lower_threshold, upper_threshold, invert_output): + values = [feature.get_value_at_frame(i) for i in range(feature.frame_count)] + + # Apply thresholds + rebased_values = [v if lower_threshold <= v <= upper_threshold else 0 for v in values] + + # Re-normalize the values + min_val, max_val = min(rebased_values), max(rebased_values) + if min_val == max_val: + normalized = [0 for _ in rebased_values] # All values are the same, normalize to 0 + else: + normalized = [(v - min_val) / (max_val - min_val) for v in rebased_values] + + processed_feature = self.create_processed_feature(feature, normalized, "Rebased", invert_output) + return (processed_feature, self.visualize(processed_feature)) + +class PreviewFeature(FeatureModulationBase): + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "feature": ("FEATURE",), + **super().INPUT_TYPES()["required"], + } + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("FEATURE_PREVIEW",) + FUNCTION = "preview" + + def preview(self, feature, invert_output): + values = [feature.get_value_at_frame(i) for i in range(feature.frame_count)] + + if invert_output: + min_val, max_val = min(values), max(values) + inverted_values = [max_val - v + min_val for v in values] + processed_feature = self.create_processed_feature(feature, inverted_values, "Inverted", invert_output) + else: + processed_feature = feature + + return (self.visualize(processed_feature),) \ No newline at end of file diff --git a/nodes/flex/features.py b/nodes/flex/features.py index e7d8fe9..28eedab 100644 --- a/nodes/flex/features.py +++ b/nodes/flex/features.py @@ -336,7 +336,7 @@ class BrightnessFeature(BaseFeature): raise ValueError(f"Invalid feature name. Available features are: {', '.join(self.available_features)}") class MotionFeature(BaseFeature): - def __init__(self, name, frame_rate, frame_count, images, feature_name='mean_motion', flow_method='Farneback', flow_threshold=0.0, magnitude_threshold=0.0): + def __init__(self, name, frame_rate, frame_count, images, feature_name='mean_motion', flow_method='Farneback', flow_threshold=0.0, magnitude_threshold=0.0, progress_callback=None): self.images = images self.feature_name = feature_name self.flow_method = flow_method @@ -344,8 +344,10 @@ class MotionFeature(BaseFeature): self.magnitude_threshold = magnitude_threshold self.available_features = [ "mean_motion", "max_motion", "motion_direction", - "horizontal_motion", "vertical_motion", "motion_complexity" + "horizontal_motion", "vertical_motion", "motion_complexity", + "motion_speed" ] + self.progress_callback = progress_callback super().__init__(name, "motion", frame_rate, frame_count) def extract(self): @@ -353,14 +355,18 @@ class MotionFeature(BaseFeature): self.features = {self.feature_name: []} images_np = (self.images.cpu().numpy() * 255).astype(np.uint8) - - for i in range(len(images_np) - 1): + num_frames = len(images_np) - 1 + + for i in range(num_frames): print(f"Processing frames {i+1} and {i+2}") frame1 = images_np[i] frame2 = images_np[i+1] flow = calculate_optical_flow(frame1, frame2, self.flow_method) self._extract_features(flow) + + if self.progress_callback: + self.progress_callback(i + 1, num_frames) self._pad_last_frame() self._normalize_features() @@ -387,6 +393,8 @@ class MotionFeature(BaseFeature): self.features[self.feature_name].append(np.mean(np.abs(flow[..., 1]))) elif self.feature_name == 'motion_complexity': self.features[self.feature_name].append(np.std(flow_magnitude)) + elif self.feature_name == 'motion_speed': + self.features[self.feature_name].append(np.mean(flow_magnitude) * self.frame_rate) def _pad_last_frame(self): for feature in self.features: @@ -419,4 +427,66 @@ class MotionFeature(BaseFeature): if feature_name in self.available_features: self.feature_name = feature_name else: - raise ValueError(f"Invalid feature name. Available features are: {', '.join(self.available_features)}") \ No newline at end of file + raise ValueError(f"Invalid feature name. Available features are: {', '.join(self.available_features)}") + +class AreaFeature(BaseFeature): + def __init__(self, name, frame_rate, frame_count, masks, feature_type='total_area', threshold=0.5): + self.masks = masks + self.feature_type = feature_type + self.threshold = threshold + self.available_features = ["total_area", "largest_contour", "bounding_box"] + super().__init__(name, "area", frame_rate, frame_count) + + def extract(self): + self.data = [] + + for mask in self.masks: + mask_np = mask.cpu().numpy() + binary_mask = (mask_np > self.threshold).astype(np.uint8) + + if self.feature_type == 'total_area': + area = np.sum(binary_mask) + elif self.feature_type == 'largest_contour': + contours, _ = cv2.findContours(binary_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + if contours: + largest_contour = max(contours, key=cv2.contourArea) + area = cv2.contourArea(largest_contour) + else: + area = 0 + elif self.feature_type == 'bounding_box': + contours, _ = cv2.findContours(binary_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + if contours: + x, y, w, h = cv2.boundingRect(max(contours, key=cv2.contourArea)) + area = w * h + else: + area = 0 + else: + raise ValueError(f"Unsupported feature type: {self.feature_type}") + + self.data.append(area) + + return self.normalize() + + def normalize(self): + if self.data: + min_val = min(self.data) + max_val = max(self.data) + if max_val > min_val: + self.data = [(v - min_val) / (max_val - min_val) for v in self.data] + else: + self.data = [0] * len(self.data) + return self + + def get_value_at_frame(self, frame_index): + if self.data is not None and 0 <= frame_index < len(self.data): + return self.data[frame_index] + else: + raise ValueError("Invalid frame index or no data available") + + def set_active_feature(self, feature_name): + if feature_name in self.available_features: + self.feature_type = feature_name + self.extract() # Re-extract the data with the new feature type + else: + raise ValueError(f"Invalid feature name. Available features are: {', '.join(self.available_features)}") +#TODO volume feature diff --git a/nodes/flex/proximity_feature.py b/nodes/flex/proximity_feature.py new file mode 100644 index 0000000..281646c --- /dev/null +++ b/nodes/flex/proximity_feature.py @@ -0,0 +1,92 @@ +from .features import BaseFeature +import torch +import numpy as np + +import numpy as np +from .features import BaseFeature + +class ProximityFeature(BaseFeature): + def __init__(self, name, anchor_locations, query_locations, frame_rate, frame_count, frame_dimensions, normalization_method='frame'): + super().__init__(name, "proximity", frame_rate, frame_count) + self.anchor_locations = anchor_locations + self.query_locations = query_locations + self.frame_diagonal = np.sqrt(frame_dimensions[0]**2 + frame_dimensions[1]**2) + self.proximity_values = None + self.normalization_method = normalization_method + + def extract(self): + from scipy.spatial.distance import cdist + + proximities = [] + for anchor, query in zip(self.anchor_locations, self.query_locations): + if len(anchor) == 0 or len(query) == 0: + proximities.append(self.frame_diagonal if self.normalization_method == 'frame' else float('inf')) + else: + distances = cdist(anchor.points, query.points) + min_distance = np.min(distances) + proximities.append(min_distance) + + proximities = np.array(proximities) + + if self.normalization_method == 'frame': + self.proximity_values = 1 - np.clip(proximities / self.frame_diagonal, 0, 1) + elif self.normalization_method == 'minmax': + min_proximity = np.min(proximities) + max_proximity = np.max(proximities) + if max_proximity > min_proximity: + self.proximity_values = 1 - (proximities - min_proximity) / (max_proximity - min_proximity) + else: + self.proximity_values = np.ones_like(proximities) + else: + raise ValueError(f"Unknown normalization method: {self.normalization_method}") + + self.smooth_proximities() + return self + + def smooth_proximities(self, window_size=5): + kernel = np.ones(window_size) / window_size + self.proximity_values = np.convolve(self.proximity_values, kernel, mode='same') + + def get_value_at_frame(self, frame_index): + return self.proximity_values[frame_index] + +class Location: + def __init__(self, x, y, z=None): + x = np.asarray(x).reshape(-1) + y = np.asarray(y).reshape(-1) + + if z is not None: + z = np.asarray(z).reshape(-1) + # Ensure z has the same length as x and y, with a small tolerance for floating-point imprecision + if abs(len(z) - len(x)) <= 1: # Allow for off-by-one errors + z = z[:len(x)] if len(z) > len(x) else np.pad(z, (0, len(x) - len(z)), 'constant', constant_values=np.nan) + elif len(z) != len(x): + raise ValueError(f"Mismatch in dimensions: x,y have length {len(x)}, but z has length {len(z)}") + self.points = np.column_stack((x, y, z)) + else: + self.points = np.column_stack((x, y)) + + def __len__(self): + return len(self.points) + + def __getitem__(self, idx): + return self.points[idx] + + @property + def x(self): + return self.points[:, 0] + + @property + def y(self): + return self.points[:, 1] + + @property + def z(self): + return self.points[:, 2] if self.points.shape[1] > 2 else None + + @classmethod + def from_tensor(cls, tensor): + return cls(tensor[:, 0], tensor[:, 1], tensor[:, 2] if tensor.shape[1] > 2 else None) + + def to_tensor(self): + return torch.from_numpy(self.points) \ No newline at end of file diff --git a/nodes/flex/proximity_feature_extractor.py b/nodes/flex/proximity_feature_extractor.py new file mode 100644 index 0000000..e8d75b6 --- /dev/null +++ b/nodes/flex/proximity_feature_extractor.py @@ -0,0 +1,279 @@ +from .proximity_feature import Location, ProximityFeature +from .feature_extractors import FirstFeature +from ... import RyanOnTheInside +from .feature_pipe import FeaturePipe +import numpy as np +import cv2 +import torch +from scipy.spatial.distance import cdist # Add this import + +class ProximityFeatureNode(FirstFeature): + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "video_frames": ("IMAGE",), + "frame_rate": ("FLOAT", {"default": 30.0, "min": 1.0, "max": 120.0, "step": 0.1}), + "anchor_locations": ("LOCATION",), + "query_locations": ("LOCATION",), + "normalization_method": (["frame", "minmax"],), + } + } + + RETURN_TYPES = ("FEATURE", "FEATURE_PIPE") + RETURN_NAMES = ("proximity_feature", "feature_pipe") + FUNCTION = "create_feature" + + def create_feature(self, video_frames, frame_rate, anchor_locations, query_locations, normalization_method): + feature_pipe = FeaturePipe(frame_rate, video_frames) + frame_dimensions = (video_frames.shape[2], video_frames.shape[1]) # width, height + + proximity_feature = ProximityFeature( + name="proximity_feature", + anchor_locations=anchor_locations, + query_locations=query_locations, + frame_rate=frame_rate, + frame_count=len(video_frames), + frame_dimensions=frame_dimensions, + normalization_method=normalization_method + ) + + # Ensure anchor and query points have the same number of columns + anchor_dim = anchor_locations[0].points.shape[1] + query_dim = query_locations[0].points.shape[1] + + if anchor_dim != query_dim: + if anchor_dim == 3: + for location in anchor_locations: + location.points = location.points[:, :2] + elif query_dim == 3: + for location in query_locations: + location.points = location.points[:, :2] + print("Depth ignored. Depth must be included for all points or no points.") + + # self.start_progress(len(video_frames), desc="Calculating proximity") + proximity_feature.extract() + # self.end_progress() + + return (proximity_feature, feature_pipe) + +class ProximityFeatureInput(RyanOnTheInside): + CATEGORY="RyanOnTheInside/Proximity" + +class LocationFromMask(ProximityFeatureInput): + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "masks": ("MASK",), + "method": (["mask_boundary", "mask_center"],), + }, + "optional": { + "depth_maps": ("IMAGE",), + } + } + + RETURN_TYPES = ("LOCATION",) + FUNCTION = "generate_locations" + + def generate_locations(self, masks, method, depth_maps=None): + locations = [] + for i in range(masks.shape[0]): + mask = masks[i].cpu().numpy() + if np.sum(mask) == 0: # Check if the mask is empty + print(f"No mask found in frame {i}.") + locations.append(Location(np.array([-1]), np.array([-1]), np.array([-1]) if depth_maps is not None else None)) + continue + + if method == "mask_boundary": + contours, _ = cv2.findContours(mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + if contours: + boundary = np.vstack(contours).squeeze() + else: + boundary = np.array([[0, 0]]) # Fallback to a single point + elif method == "mask_center": + y, x = np.where(mask > 0.5) + if len(x) > 0 and len(y) > 0: + center_x, center_y = np.mean(x), np.mean(y) + boundary = np.array([[center_x, center_y]]) + else: + boundary = np.array([[mask.shape[1] / 2, mask.shape[0] / 2]]) + + if depth_maps is not None: + depth_map = depth_maps[i].cpu().numpy() + # Ensure boundary points are within the depth map dimensions + valid_points = (boundary[:, 0] < depth_map.shape[1]) & (boundary[:, 1] < depth_map.shape[0]) + boundary = boundary[valid_points] + + if boundary.size == 0: # Check if boundary is empty after filtering + print(f"No valid boundary points in frame {i}.") + locations.append(Location(np.array([-1]), np.array([-1]), np.array([-1]))) + continue + + # Extract depth values for valid boundary points + z = np.mean(depth_map[boundary[:, 1].astype(int), boundary[:, 0].astype(int)], axis=-1) + + location = Location(boundary[:, 0], boundary[:, 1], z) + else: + location = Location(boundary[:, 0], boundary[:, 1]) + + locations.append(location) + + return (locations,) + +class LocationFromPoint(ProximityFeatureInput): + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "x": ("FLOAT", {"default": 0.0,"min":0.0,"step":0.01}), + "y": ("FLOAT", {"default": 0.0,"min":0.0,"step":0.01}), + "z": ("FLOAT", {"default": 0.0, "max":1.0, "min":0.0,"step":0.01}), + "batch_count": ("INT", {"default": 1, "min": 1}), + } + } + + RETURN_TYPES = ("LOCATION",) + FUNCTION = "generate_locations" + + def generate_locations(self, x, y, batch_count, z): + locations = [] + for _ in range(batch_count): + location = Location(np.array([x]), np.array([y]), np.array([z])) + locations.append(location) + + return (locations,) + +class LocationTransform(ProximityFeatureInput): + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "locations": ("LOCATION",), + "feature": ("FEATURE",), + "transformation_type": (["translate", "scale"],), + "transformation_value": ("FLOAT", {"default": 1.0}), + } + } + + RETURN_TYPES = ("LOCATION",) + FUNCTION = "transform_locations" + + def transform_locations(self, locations, feature, transformation_type, transformation_value): + transformed_locations = [] + for frame_index, location in enumerate(locations): + feature_value = feature.get_value_at_frame(frame_index) + if transformation_type == "translate": + new_x = location.x + feature_value * transformation_value + new_y = location.y + feature_value * transformation_value + if location.z is not None: + new_z = location.z + feature_value * transformation_value + transformed_location = Location(new_x, new_y, new_z) + else: + transformed_location = Location(new_x, new_y) + elif transformation_type == "scale": + new_x = location.x * feature_value * transformation_value + new_y = location.y * feature_value * transformation_value + if location.z is not None: + new_z = location.z * feature_value * transformation_value + transformed_location = Location(new_x, new_y, new_z) + else: + transformed_location = Location(new_x, new_y) + transformed_locations.append(transformed_location) + + return (transformed_locations,) + + + +class ProximityVisualizer(RyanOnTheInside): + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "video_frames": ("IMAGE",), + "anchor_locations": ("LOCATION",), + "query_locations": ("LOCATION",), + "proximity_feature": ("FEATURE",), + "anchor_color": ("STRING", {"default": "(255,0,0)"}), + "query_color": ("STRING", {"default": "(0,255,0)"}), + "line_color": ("STRING", {"default": "(0,0,255)"}), + "text_color": ("STRING", {"default": "(255,255,255)"}), + "font_scale": ("FLOAT", {"default": 0.5, "min": 0.1, "max": 2.0, "step": 0.1}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "visualize_proximity" + + def visualize_proximity(self, video_frames, anchor_locations, query_locations, proximity_feature, + anchor_color, query_color, line_color, text_color, font_scale): + anchor_color = self.parse_color(anchor_color) + query_color = self.parse_color(query_color) + line_color = self.parse_color(line_color) + text_color = self.parse_color(text_color) + + output_frames = [] + height, width = video_frames.shape[1:3] # Extract height and width from video_frames + + # Calculate the frame diagonal from the video frames + frame_diagonal = np.sqrt(width**2 + height**2) + scale_factor = frame_diagonal / proximity_feature.frame_diagonal + for frame_index in range(len(video_frames)): + frame = video_frames[frame_index].cpu().numpy() + frame = (frame * 255).astype(np.uint8) + frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR) + + anchor = anchor_locations[frame_index] + query = query_locations[frame_index] + + # Calculate scaling factors based on the frame diagonal + + + # Draw all anchor and query points (using only x and y coordinates) + for point in anchor: + scaled_point = (int(point[0] * scale_factor), int(point[1] * scale_factor)) + cv2.circle(frame, scaled_point, 2, anchor_color, -1) + for point in query: + scaled_point = (int(point[0] * scale_factor), int(point[1] * scale_factor)) + cv2.circle(frame, scaled_point, 2, query_color, -1) + + # Find the closest pair of points + if len(anchor) > 0 and len(query) > 0: + distances = cdist(anchor.points[:, :2], query.points[:, :2]) # Use only x and y for distance calculation + min_idx = np.unravel_index(distances.argmin(), distances.shape) + closest_anchor = anchor[min_idx[0]] + closest_query = query[min_idx[1]] + + # Draw line between closest points (using only x and y coordinates) + cv2.line(frame, + (int(closest_anchor[0] * scale_factor), int(closest_anchor[1] * scale_factor)), + (int(closest_query[0] * scale_factor), int(closest_query[1] * scale_factor)), + line_color, 2) + + # Display coordinates of closest points + anchor_text = f"Anchor: ({int(closest_anchor[0] * scale_factor)}, {int(closest_anchor[1] * scale_factor)}" + query_text = f"Query: ({int(closest_query[0] * scale_factor)}, {int(closest_query[1] * scale_factor)}" + + if anchor.z is not None and query.z is not None: + anchor_text += f", {closest_anchor[2]:.2f})" + query_text += f", {closest_query[2]:.2f})" + else: + anchor_text += ") [2D]" + query_text += ") [2D]" + + cv2.putText(frame, anchor_text, (10, 60), cv2.FONT_HERSHEY_SIMPLEX, font_scale, text_color, 1) + cv2.putText(frame, query_text, (10, 90), cv2.FONT_HERSHEY_SIMPLEX, font_scale, text_color, 1) + + # Add proximity value to the frame + proximity_value = proximity_feature.get_value_at_frame(frame_index) + cv2.putText(frame, f"Proximity: {proximity_value:.2f}", (10, 30), + cv2.FONT_HERSHEY_SIMPLEX, 1, text_color, 2) + + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + output_frames.append(frame) + + output_tensor = torch.from_numpy(np.stack(output_frames)).float() / 255.0 + return (output_tensor,) + + def parse_color(self, color_string): + return tuple(map(int, color_string.strip("()").split(","))) \ No newline at end of file diff --git a/nodes/masks/mask_utility_nodes.py b/nodes/masks/mask_utility_nodes.py index 5b4830b..b946bbf 100644 --- a/nodes/masks/mask_utility_nodes.py +++ b/nodes/masks/mask_utility_nodes.py @@ -34,7 +34,7 @@ class MovingShape: RETURN_TYPES = ("MASK",) FUNCTION = "generate" - CATEGORY = "/RyanOnTheInside/masks/" + CATEGORY = "RyanOnTheInside/masks/" def generate(self, frame_width, frame_height, num_frames, rgb, shape, shape_width_percent, shape_height_percent, shape_start_position_x, shape_start_position_y, shape_end_position_x, shape_end_position_y, movement_type, grow, palindrome, delay): rgb = self.parse_rgb_string(rgb) @@ -171,7 +171,7 @@ class TextMaskNode: RETURN_TYPES = ("MASK", "IMAGE") FUNCTION = "create_text_mask" - CATEGORY = "/RyanOnTheInside/masks/" + CATEGORY = "RyanOnTheInside/masks/" def parse_rgb(self, rgb_string): try: @@ -250,7 +250,7 @@ class _mfc: RETURN_TYPES = ("MASK", "IMAGE") FUNCTION = "execute" - CATEGORY = "/RyanOnTheInside/masks/" + CATEGORY = "RyanOnTheInside/masks/" def execute(self, image, red, green, blue, threshold): temp = (torch.clamp(image, 0, 1.0) * 255.0).round().to(torch.int) diff --git a/nodes/utility_nodes.py b/nodes/utility_nodes.py index 430a3a2..7c06880 100644 --- a/nodes/utility_nodes.py +++ b/nodes/utility_nodes.py @@ -39,6 +39,36 @@ class ImageIntervalSelect(UtilityNode): RETURN_TYPES = ("IMAGE",) FUNCTION = "select_interval" +#NOTE eh +class ImageIntervalSelectPercentage(UtilityNode): + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "interval_percentage": ("FLOAT", {"default": 10, "min": 1, "max": 100, "step": 1}), + "start_percentage": ("FLOAT", {"default": 0, "min": 0, "max": 100, "step": 1}), + "end_percentage": ("FLOAT", {"default": 100, "min": 0, "max": 100, "step": 1}), + }, + } + + def select_percentage_interval(self, image, interval_percentage=10, start_percentage=0, end_percentage=100): + total_images = len(image) + interval = max(1, int(total_images * (interval_percentage / 100))) + start_at = int(total_images * (start_percentage / 100)) + end_at = int(total_images * (end_percentage / 100)) + + # Ensure start_at and end_at are within the bounds of the image list + start_at = max(0, min(start_at, total_images - 1)) + end_at = max(start_at + 1, min(end_at, total_images)) + + # Slice the image list from start_at to end_at with the specified interval + images = image[start_at:end_at:interval] + return (images,) + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "select_percentage_interval" + class ImageChunks(UtilityNode): @classmethod def INPUT_TYPES(cls): diff --git a/pyproject.toml b/pyproject.toml index d4494b1..ad3cc4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,9 @@ [project] name = "comfyui_ryanonyheinside" description = "Custom nodes introducing particle simulations, optical flow, audio manipulation & reactivity, and temporal masks" -version = "1.5.6" +version = "1.6.0" license = {file = "LICENSE"} -dependencies = ["opencv-python==4.10.0","scipy","pillow","librosa","pymunk==6.8.1","matplotlib","openunmix","mido"] +dependencies = ["opencv-python==4.10.0","scipy","torchaudio", "pillow","librosa","pymunk==6.8.1","matplotlib","openunmix","mido"] [project.urls] Repository = "https://github.com/ryanontheinside/ComfyUI_RyanOnTheInside" diff --git a/readme.md b/readme.md index df50997..96979e7 100644 --- a/readme.md +++ b/readme.md @@ -33,6 +33,8 @@ *Examples showcasing various effects using particle emitters, vortices, and other node features* ##### 🆕 Recent Updates +- 9/8/24 - **Area Feature**: Adds area as a driving reactivity feature! +- 9/7/24 - **Proximity Feature**: Adds proximity as a driving reactivity feature! Allows for the distince of objects from one another to control other nodes. - 9/5/24 - **FlexImageParallax**: Add illusory depth. - 9/3/24 - **FlexMaskDepthChamber**: Mask anything within a given depth range - 9/3/24 - **FeatureFade**: another feature modifier to allow mixing of features, controled by features. Think mixing depth and motion features according to a kick drum or somthing. Ridiculous. @@ -67,7 +69,7 @@ Particles are now reactive! ### 🎛️ Flex Features Dynamic control over various aspects of your workflow: - Modulate IPAdapters, Masks, Images, and Particles based on extracted features -- Features include: Audio, MIDI, Motion, Depth, Color, Time, and more +- Features include: Audio, MIDI, Motion, Proximity Depth, Color, Time, and more - Create adaptive, responsive effects that evolve with your input data ### 🎆 Particle Systems diff --git a/requirements.txt b/requirements.txt index d5e8409..4f9d321 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,5 @@ librosa pymunk==6.8.1 matplotlib openunmix -mido \ No newline at end of file +mido +torchaudio \ No newline at end of file