Added proximity feature, area feature, depth from shape

This commit is contained in:
RyanOnTheInside
2024-09-09 19:56:24 -04:00
parent edbca6c086
commit 5ce1fa5200
13 changed files with 901 additions and 35 deletions
+32 -7
View File
@@ -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",
+105 -8
View File
@@ -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": """
+107
View File
@@ -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
+68 -4
View File
@@ -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)
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)
+103 -4
View File
@@ -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))
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),)
+75 -5
View File
@@ -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)}")
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
+92
View File
@@ -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)
+279
View File
@@ -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(",")))
+3 -3
View File
@@ -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)
+30
View File
@@ -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):
+2 -2
View File
@@ -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"
+3 -1
View File
@@ -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
+2 -1
View File
@@ -5,4 +5,5 @@ librosa
pymunk==6.8.1
matplotlib
openunmix
mido
mido
torchaudio