Squashed commit of the following:

commit f16db92525
Author: ryanontheinstide <ryanfosdick87@gmail.com>
Date:   Tue Sep 17 09:15:11 2024 -0400

    temporarily added back depth shape modifier

commit 6f0d93dda3
Author: ryanontheinstide <ryanfosdick87@gmail.com>
Date:   Mon Sep 16 09:52:01 2024 -0400

    depth targets

commit f9c586173f
Author: ryanontheinstide <ryanfosdick87@gmail.com>
Date:   Mon Sep 16 07:38:21 2024 -0400

    bugfix

commit bbd18f6732
Author: ryanontheinstide <ryanfosdick87@gmail.com>
Date:   Sun Sep 15 21:06:12 2024 -0400

    Depth from shape, Pitch Feature, Pitch Range specification, etc

commit c16d5f0100
Author: ryanontheinstide <ryanfosdick87@gmail.com>
Date:   Tue Sep 10 07:15:48 2024 -0400

    for inagy

commit 92b411d254
Author: ryanontheinstide <ryanfosdick87@gmail.com>
Date:   Mon Sep 9 19:56:24 2024 -0400

    Added proximity feature, area feature, depth from shape

commit 7902b6a5bf
Author: ryanontheinstide <ryanfosdick87@gmail.com>
Date:   Thu Sep 5 05:44:14 2024 -0400

    parallax, depthchamber

commit b2ec476287
Author: ryanontheinstide <ryanfosdick87@gmail.com>
Date:   Wed Sep 4 09:20:09 2024 -0400

    depth chamber improvement

commit 1312348002
Author: ryanontheinstide <ryanfosdick87@gmail.com>
Date:   Tue Sep 3 16:12:30 2024 -0400

    Squash

commit 6f0332bafd
Author: ryanontheinstide <ryanfosdick87@gmail.com>
Date:   Mon Sep 2 11:28:46 2024 -0400

    swap device

commit 5e3a59b2a1
Author: ryanontheinstide <ryanfosdick87@gmail.com>
Date:   Mon Sep 2 08:20:05 2024 -0400

    utility nodes

commit 4442ea14de
Author: ryanontheinstide <ryanfosdick87@gmail.com>
Date:   Sun Sep 1 09:35:42 2024 -0400

    improved wave propagation in Emanating Rings

commit 7fe5befaf6
Author: ryanontheinstide <ryanfosdick87@gmail.com>
Date:   Sat Aug 31 17:22:26 2024 -0400

    Added random shapes

commit c8597d35f9
Author: ryanontheinstide <ryanfosdick87@gmail.com>
Date:   Sat Aug 31 10:01:55 2024 -0400

    Rings and wave propagation, skip  features for flex images, etc

commit 40981b910f
Author: ryanontheinstide <ryanfosdick87@gmail.com>
Date:   Thu Aug 29 17:41:51 2024 -0400

    bugfix

commit 98b21aaa50
Author: ryanontheinstide <ryanfosdick87@gmail.com>
Date:   Thu Aug 29 12:24:22 2024 -0400

    feature modulation of particle systems

commit d79ac2936c
Author: ryanontheinstide <ryanfosdick87@gmail.com>
Date:   Wed Aug 28 18:47:15 2024 -0400

    Squashed commit of the following:

    commit 5c3ab23192
    Author: ryanontheinstide <ryanfosdick87@gmail.com>
    Date:   Wed Aug 28 18:45:53 2024 -0400

        direction amplitude, colorgrade bug fixes

    commit ff153b25eb
    Author: ryanontheinstide <ryanfosdick87@gmail.com>
    Date:   Wed Aug 28 17:00:40 2024 -0400

        added emission radius

    commit 618f63bc2b
    Author: ryanontheinstide <ryanfosdick87@gmail.com>
    Date:   Wed Aug 28 15:37:17 2024 -0400

        add random flag to particle modulators

    commit 67b273dcee
    Author: ryanontheinstide <ryanfosdick87@gmail.com>
    Date:   Wed Aug 28 15:26:46 2024 -0400

commit 27cee31fea
Author: ryanontheinstide <ryanfosdick87@gmail.com>
Date:   Wed Aug 28 13:36:22 2024 -0400

    damn version

commit d5883b5529
Author: ryanontheinstide <ryanfosdick87@gmail.com>
Date:   Wed Aug 28 13:32:49 2024 -0400

    Squashed commit of the following:

    more feature modulators and flex mask targets

commit e0603a88f9
Author: ryanontheinstide <ryanfosdick87@gmail.com>
Date:   Tue Aug 27 19:20:05 2024 -0400

    version update

commit ea2ca47a82
Author: ryanontheinstide <ryanfosdick87@gmail.com>
Date:   Tue Aug 27 19:18:27 2024 -0400

    get torch device

commit 0f9b32e13e
Author: ryanontheinstide <ryanfosdick87@gmail.com>
Date:   Tue Aug 27 18:41:06 2024 -0400

    bugfix

commit 8cc1a292f3
Author: ryanontheinstide <ryanfosdick87@gmail.com>
Date:   Tue Aug 27 18:37:42 2024 -0400

    bugfix

commit 7de6853a28
Author: ryanontheinstide <ryanfosdick87@gmail.com>
Date:   Tue Aug 27 17:58:23 2024 -0400

    Added flex opacity and voronoi scheduled

commit f435740018
Author: ryanontheinstide <ryanfosdick87@gmail.com>
Date:   Tue Aug 27 06:35:15 2024 -0400

    bugfix - arbitrary video audio lengths

commit 607703988a
Author: ryanontheinstide <ryanfosdick87@gmail.com>
Date:   Mon Aug 26 10:32:13 2024 -0400

    Added flex, audio, midi, help system

commit e239c5a19d
Author: ryanontheinstide <ryanfosdick87@gmail.com>
Date:   Sat Aug 17 18:00:05 2024 -0400

    version, temporal control for particle size,color,speed
This commit is contained in:
ryanontheinstide
2024-09-17 10:06:32 -04:00
parent 6e34f6ec96
commit 207327216a
9 changed files with 1947 additions and 2 deletions
+9
View File
@@ -173,6 +173,12 @@ from .nodes.images.flex_images import (
)
from .nodes.depth.depth_base import(
DepthInjection,
DepthBlender,
DepthRippleEffect,
)
from .nodes.flex.feature_externals import (
FeatureToWeightsStrategy,
DepthShapeModifier,
@@ -279,6 +285,9 @@ NODE_CLASS_MAPPINGS = {
"AreaFeatureNode": AreaFeatureNode,
"FeatureToWeightsStrategy": FeatureToWeightsStrategy,
"DepthInjection": DepthInjection,
"DepthRippleEffect": DepthRippleEffect,
"DepthBlender": DepthBlender,
"DepthShapeModifier": DepthShapeModifier,
#feature modulation
+27
View File
@@ -1148,6 +1148,33 @@ add_node_config("ImageIntervalSelectPercentage", {
"""
})
add_node_config("DepthInjection", {
"TOP_DESCRIPTION": "Modifies depth maps based on mask contours, creating spherical gradients.",
"ADDITIONAL_INFO": """
- `depth_map`: Input depth map (IMAGE type)
- `mask`: Input mask to define areas for depth modification (MASK type)
- `gradient_steepness`: Controls the steepness of the spherical gradient (0.1 to 10.0). Higher values create sharper transitions.
- `depth_min`: Minimum depth value for the modified areas (0.0 to 1.0)
- `depth_max`: Maximum depth value for the modified areas (0.0 to 1.0)
- `strength`: Overall strength of the depth modification effect (0.0 to 1.0)
This node modifies depth maps by creating spherical gradients based on the contours of the input mask. It's useful for adding depth variations to specific areas of an image, such as creating a sense of volume for masked objects.
The process involves:
1. Finding contours in the mask
2. Generating spherical gradients for each contour
3. Scaling the gradients to the specified depth range
4. Blending the modified depth with the original depth map
This node can be particularly effective for:
- Adding depth to flat objects in a scene
- Creating a sense of volume for masked areas
- Fine-tuning depth maps for more realistic 3D effects
Note: The node currently doesn't use the feature modulation capabilities, but these could be added in future versions for dynamic depth modifications.
"""
})
add_node_config("ImageChunks", {
"TOP_DESCRIPTION": "Concatenates images into a grid.",
"ADDITIONAL_INFO": """
File diff suppressed because it is too large Load Diff
+286
View File
@@ -0,0 +1,286 @@
import torch
import numpy as np
from abc import ABC, abstractmethod
from tqdm import tqdm
from comfy.utils import ProgressBar
from ... import RyanOnTheInside
import cv2
class FlexDepthBase(RyanOnTheInside, ABC):
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"depth_maps": ("IMAGE",),
"feature": ("FEATURE",),
"feature_pipe": ("FEATURE_PIPE",),
"strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"feature_threshold": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"feature_param": (cls.get_modifiable_params(), {"default": cls.get_modifiable_params()[0]}),
"feature_mode": (["relative", "absolute"], {"default": "relative"}),
}
}
CATEGORY = "RyanOnTheInside/DepthEffects"
RETURN_TYPES = ("IMAGE",)
FUNCTION = "apply_effect"
def __init__(self):
self.progress_bar = None
def start_progress(self, total_steps, desc="Processing"):
self.progress_bar = ProgressBar(total_steps)
def update_progress(self):
if self.progress_bar:
self.progress_bar.update(1)
def end_progress(self):
self.progress_bar = None
@classmethod
@abstractmethod
def get_modifiable_params(cls):
"""Return a list of parameter names that can be modulated."""
return []
def modulate_param(self, param_name, param_value, feature_value, strength, mode):
if mode == "relative":
return param_value * (1 + (feature_value - 0.5) * strength)
else: # absolute
return param_value * feature_value * strength
def apply_effect(self, depth_maps, feature, feature_pipe, strength, feature_threshold, feature_param, feature_mode, **kwargs):
num_frames = feature_pipe.frame_count
depth_maps_np = depth_maps.cpu().numpy()
self.start_progress(num_frames, desc=f"Applying {self.__class__.__name__}")
result = []
for i in range(num_frames):
depth_map = depth_maps_np[i]
feature_value = feature.get_value_at_frame(i)
kwargs['frame_index'] = i
if feature_value >= feature_threshold:
processed_depth_map = self.process_depth_map(depth_map, feature_value, strength,
feature_param=feature_param,
feature_mode=feature_mode,
**kwargs)
else:
processed_depth_map = depth_map
result.append(processed_depth_map)
self.update_progress()
self.end_progress()
# Convert the list of numpy arrays to a single numpy array
result_np = np.stack(result)
# Convert the numpy array to a PyTorch tensor
result_tensor = torch.from_numpy(result_np).float()
# Ensure the tensor is in BHWC format
if result_tensor.shape[1] != depth_maps.shape[1]: # Adjust as needed
result_tensor = result_tensor.permute(0, 2, 3, 1)
return (result_tensor,)
def process_depth_map(self, depth_map: np.ndarray, feature_value: float, strength: float,
feature_param: str, feature_mode: str, **kwargs) -> np.ndarray:
# Modulate the selected parameter
for param_name in self.get_modifiable_params():
if param_name in kwargs:
if param_name == feature_param:
kwargs[param_name] = self.modulate_param(param_name, kwargs[param_name],
feature_value, strength, feature_mode)
# Call the child class's implementation
return self.apply_effect_internal(depth_map, **kwargs)
@abstractmethod
def apply_effect_internal(self, depth_map: np.ndarray, **kwargs) -> np.ndarray:
"""Apply the effect to the depth map. To be implemented by child classes."""
pass
class DepthInjection(FlexDepthBase):
@classmethod
def INPUT_TYPES(cls):
return {
**super().INPUT_TYPES(),
"required": {
**super().INPUT_TYPES()["required"],
"mask": ("MASK",),
"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" is already included in the base class
}
}
CATEGORY = "RyanOnTheInside/DepthModifiers"
@classmethod
def get_modifiable_params(cls):
return ["gradient_steepness", "depth_min", "depth_max", "strength", "None"]
def apply_effect_internal(self, depth_map: np.ndarray, mask, frame_index, **kwargs) -> np.ndarray:
gradient_steepness = kwargs.get('gradient_steepness')
depth_min = kwargs.get('depth_min')
depth_max = kwargs.get('depth_max')
strength = kwargs.get('strength', 1.0)
h, w, c = depth_map.shape
# Get mask for this frame
mask_i = mask[frame_index].cpu().numpy().astype(np.uint8)
# Label connected components in the mask
num_labels, labels = cv2.connectedComponents(mask_i)
# Initialize the gradient map
gradient_map = np.zeros((h, w), dtype=np.float32)
for label in range(1, num_labels): # Skip background label 0
# Create a mask for this component
component_mask = (labels == label).astype(np.uint8)
# Compute distance transform inside the component
dist_transform = cv2.distanceTransform(component_mask, cv2.DIST_L2, 5)
max_dist = np.max(dist_transform)
if max_dist > 0:
# Normalize distance transform to [0,1]
normalized_dist = dist_transform / max_dist
# Apply gradient steepness
shape_gradient = np.power(normalized_dist, gradient_steepness)
# Apply gradient only to this component
gradient_map += shape_gradient * component_mask.astype(np.float32)
# Scale gradient to depth range
depth_gradient = depth_min + gradient_map * (depth_max - depth_min)
# Apply gradient to depth map
modified_depth = depth_map.copy()
depth_gradient_expanded = np.expand_dims(depth_gradient, axis=-1).repeat(c, axis=-1)
mask_i_expanded = np.expand_dims(mask_i, axis=-1).repeat(c, axis=-1).astype(bool)
modified_depth = np.where(mask_i_expanded,
depth_gradient_expanded,
modified_depth)
# Blend modified depth with original depth
blend_mask = mask_i_expanded * strength
modified_depth = depth_map * (1 - blend_mask) + modified_depth * blend_mask
return modified_depth
class DepthBlender(FlexDepthBase):
@classmethod
def INPUT_TYPES(cls):
return {
**super().INPUT_TYPES(),
"required": {
**super().INPUT_TYPES()["required"],
"other_depth_maps": ("IMAGE",),
"blend_mode": (["add", "subtract", "multiply", "average"], {"default": "average"}),
# "strength" is already included in the base class
}
}
CATEGORY = "RyanOnTheInside/DepthModifiers"
@classmethod
def get_modifiable_params(cls):
return ["strength", "None"]
def apply_effect_internal(self, depth_map: np.ndarray, other_depth_maps, frame_index, **kwargs) -> np.ndarray:
blend_mode = kwargs.get('blend_mode')
strength = kwargs.get('strength', 1.0)
# Get the other depth map for the current frame
other_depth_map = other_depth_maps[frame_index].cpu().numpy()
if blend_mode == "add":
blended_depth = depth_map + other_depth_map
elif blend_mode == "subtract":
blended_depth = depth_map - other_depth_map
elif blend_mode == "multiply":
blended_depth = depth_map * other_depth_map
elif blend_mode == "average":
blended_depth = (depth_map + other_depth_map) / 2
else:
blended_depth = depth_map # Default to original depth map
# Blend with the original depth map based on strength
modified_depth = depth_map * (1 - strength) + blended_depth * strength
# Ensure depth values are within valid range
modified_depth = np.clip(modified_depth, 0.0, 1.0)
return modified_depth
class DepthRippleEffect(FlexDepthBase):
@classmethod
def INPUT_TYPES(cls):
return {
**super().INPUT_TYPES(),
"required": {
**super().INPUT_TYPES()["required"],
"ripple_amplitude": ("FLOAT", {"default": 0.05, "min": 0.0, "max": 0.5, "step": 0.01}),
"ripple_frequency": ("FLOAT", {"default": 20.0, "min": 1.0, "max": 100.0, "step": 1.0}),
"ripple_phase": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 6.2832, "step": 0.1}), # 2π
"curvature": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}),
# "strength" is already included in the base class
}
}
CATEGORY = "RyanOnTheInside/DepthModifiers"
@classmethod
def get_modifiable_params(cls):
return ["ripple_amplitude", "ripple_frequency", "ripple_phase", "curvature", "strength", "None"]
def apply_effect_internal(self, depth_map: np.ndarray, frame_index, **kwargs) -> np.ndarray:
ripple_amplitude = kwargs.get('ripple_amplitude')
ripple_frequency = kwargs.get('ripple_frequency')
ripple_phase = kwargs.get('ripple_phase')
curvature = kwargs.get('curvature')
strength = kwargs.get('strength', 1.0)
h, w, c = depth_map.shape
# Create a coordinate grid normalized to [0,1]
y, x = np.meshgrid(np.linspace(0, 1, h), np.linspace(0, 1, w), indexing='ij')
# Center coordinates around 0
x_c = x - 0.5
y_c = y - 0.5
# Compute distance from center for circular ripples
distance = np.sqrt(x_c**2 + y_c**2)
# Compute linear ripple pattern (lines)
linear_pattern = x_c + y_c
# Interpolate between linear and circular patterns
pattern = (1 - curvature) * linear_pattern + curvature * distance
# Compute ripple effect
ripple = ripple_amplitude * np.sin(2 * np.pi * ripple_frequency * pattern + ripple_phase)
# Apply ripple to depth map
ripple = np.expand_dims(ripple, axis=-1).repeat(c, axis=-1)
modified_depth = depth_map + ripple
# Blend with the original depth map based on strength
modified_depth = depth_map * (1 - strength) + modified_depth * strength
# Ensure depth values are within valid range
modified_depth = np.clip(modified_depth, 0.0, 1.0)
return modified_depth
+1 -1
View File
@@ -41,7 +41,7 @@ class FeatureToWeightsStrategy(FlexExternalModulator):
return (weights_strategy,)
#TODO: sub somthing else
class DepthShapeModifier(FlexExternalModulator):
@classmethod
def INPUT_TYPES(cls):
+163
View File
@@ -0,0 +1,163 @@
import mido
import os
from .feature_pipe import FeaturePipe
import folder_paths
from server import PromptServer
from aiohttp import web
import shutil
from .midi_feature import MIDIFeature
from .feature_extractors import FeatureExtractorBase
class MIDILoadAndExtract(FeatureExtractorBase):
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"midi_file": (folder_paths.get_filename_list("midi_files"),),
"track_selection": (["all"],),
"attribute": (MIDIFeature.get_attribute_names(), {"default": "Note On/Off"}),
"frame_rate": ("FLOAT", {"default": 30, "min": 0.1, "max": 120, "step": 0.1}),
"video_frames": ("IMAGE",),
"chord_only": ("BOOLEAN", {"default": False}),
"notes": ("STRING", {"default": ""}),
},
}
RETURN_TYPES = ("MIDI", "FEATURE", "FEATURE_PIPE")
FUNCTION = "process_midi"
CATEGORY = "RyanOnTheInside/Audio"
def process_midi(self, midi_file, track_selection, notes, attribute, frame_rate, video_frames, chord_only=False):
try:
midi_path = folder_paths.get_full_path("midi_files", midi_file)
if not midi_path or not os.path.exists(midi_path):
raise FileNotFoundError(f"MIDI file not found: {midi_file}")
midi_data = mido.MidiFile(midi_path)
selected_notes = [int(n.strip()) for n in notes.split(',') if n.strip().isdigit()]
feature_pipe = FeaturePipe(frame_rate, video_frames)
# Convert friendly attribute name to internal attribute name
internal_attribute = MIDIFeature.get_attribute_value(attribute)
feature = MIDIFeature(f"midi_{internal_attribute}", midi_data, internal_attribute,
feature_pipe.frame_rate, feature_pipe.frame_count,
notes=selected_notes, chord_only=chord_only)
feature.extract()
return (midi_data, feature, feature_pipe)
except Exception as e:
# error_msg = f"Error in MIDILoadAndExtract.process_midi: {type(e).__name__}: {str(e)}\n"
# error_msg += traceback.format_exc()
# print(error_msg)
raise RuntimeError(f"Error processing MIDI file: {type(e).__name__}: {str(e)}")
@classmethod
def analyze_midi(cls, midi_path):
midi_data = mido.MidiFile(midi_path)
tracks = ["all"]
all_notes = set()
track_notes = {}
for i, track in enumerate(midi_data.tracks):
track_notes[str(i)] = set()
for msg in track:
if msg.type == 'note_on':
track_notes[str(i)].add(msg.note)
all_notes.add(msg.note)
if len(track_notes[str(i)]) == 0:
tracks.append(f"{i}: (Empty)")
else:
tracks.append(f"{i}: {track.name or f'Track {i}'}")
return {
"tracks": tracks,
"all_notes": ",".join(map(str, sorted(set(all_notes)))),
"track_notes": {k: ",".join(map(str, sorted(v))) for k, v in track_notes.items()}
}
@classmethod
def VALIDATE_INPUTS(cls, midi_file, track_selection, notes, attribute, frame_rate, video_frames):
if not folder_paths.exists_and_is_file(midi_file):
return "MIDI file not found: {}".format(midi_file)
if notes != "all":
try:
note_list = [int(n.strip()) for n in notes.split(',') if n.strip()]
if not all(0 <= n <= 127 for n in note_list):
return "Invalid note value. All notes must be between 0 and 127."
except ValueError:
return "Invalid notes format. Please provide comma-separated integers or 'all'."
return True
routes = PromptServer.instance.routes
@PromptServer.instance.routes.post('/get_track_notes')
async def get_track_notes(request):
data = await request.json()
midi_file = data.get('midi_file')
if not midi_file:
return web.json_response({"error": "Missing required parameters"}, status=400)
midi_path = folder_paths.get_full_path("midi_files", midi_file)
if not midi_path or not os.path.exists(midi_path):
return web.json_response({"error": "MIDI file not found"}, status=404)
analysis = MIDILoadAndExtract.analyze_midi(midi_path)
return web.json_response(analysis)
@routes.post('/upload_midi')
async def upload_midi(request):
data = await request.post()
midi_file = data['file']
if midi_file and midi_file.filename:
safe_filename = os.path.basename(midi_file.filename)
midi_dir = folder_paths.get_folder_paths("midi_files")[0]
if not os.path.exists(midi_dir):
os.makedirs(midi_dir, exist_ok=True)
midi_path = os.path.join(midi_dir, safe_filename)
with open(midi_path, 'wb') as f:
shutil.copyfileobj(midi_file.file, f)
midi_files = folder_paths.get_filename_list("midi_files")
analysis = MIDILoadAndExtract.analyze_midi(midi_path)
return web.json_response({
"status": "success",
"uploaded_file": safe_filename,
"midi_files": midi_files,
"analysis": analysis
})
else:
return web.json_response({"status": "error", "message": "No file uploaded"}, status=400)
@PromptServer.instance.routes.post('/refresh_midi_data')
async def refresh_midi_data(request):
data = await request.json()
midi_file = data.get('midi_file')
track_selection = data.get('track_selection')
if not midi_file:
return web.json_response({"error": "Missing required parameters"}, status=400)
midi_path = folder_paths.get_full_path("midi_files", midi_file)
if not midi_path or not os.path.exists(midi_path):
return web.json_response({"error": "MIDI file not found"}, status=404)
analysis = MIDILoadAndExtract.analyze_midi(midi_path)
# Filter notes based on track selection
if track_selection != "all":
track_index = track_selection.split(':')[0]
analysis['all_notes'] = analysis['track_notes'].get(track_index, "")
return web.json_response(analysis)
+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(",")))
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui_ryanonyheinside"
description = "Custom nodes introducing particle simulations, optical flow, audio manipulation & reactivity, and temporal masks"
version = "1.8.0"
version = "1.8.2"
license = {file = "LICENSE"}
dependencies = ["opencv-python==4.10.0","scipy","torchaudio", "pillow","librosa","pymunk==6.8.1","matplotlib","openunmix","mido"]