Files
ryanontheinside-ComfyUI_Rea…/node_wrappers/coordinates/position_tracking.py
T

484 lines
21 KiB
Python

import logging
import math
from typing import List, Tuple, Union
import cv2
import numpy as np
import torch
from ...src.coordinates.coordinate_delta import BaseCoordinateDelta, MAX_POSITION_HISTORY, FLOAT_EQUALITY_TOLERANCE
# TODO: consider moving base nodes to src
# Import from the new consolidated utilities
from ...src.utils.math import scale_value
logger = logging.getLogger(__name__)
# --- Position Delta Control Nodes (Int/Float) (Handling Lists) ---
class CoordinateDeltaControlNode(BaseCoordinateDelta):
"""Base class for position-based delta control nodes (Int/Float).
Handles single floats or lists of coordinates.
Outputs FLOAT type (actual value is float or list[float]).
"""
# Output semantic type is FLOAT (or INT for subclass)
RETURN_TYPES = ("FLOAT",)
RETURN_NAMES = ("value",) # Singular name
IS_INT_OUTPUT = False
DEFAULT_DELTA_MIN = 0.0
DEFAULT_DELTA_MAX = 0.1
DEFAULT_OUTPUT_MIN = 0.0
DEFAULT_OUTPUT_MAX = 1.0
DEFAULT_CLAMP = True
CATEGORY = "Realtime Nodes/MediaPipe Vision/Common/Delta"
@classmethod
def INPUT_TYPES(cls):
# Input types remain the same
output_type_str = "INT" if cls.IS_INT_OUTPUT else "FLOAT"
output_min_key = f"output_min_{output_type_str.lower()}"
output_max_key = f"output_max_{output_type_str.lower()}"
return {
"required": {
"x": ("FLOAT", {"default": 0.0, "forceInput": True}),
"y": ("FLOAT", {"default": 0.0, "forceInput": True}),
"z": ("FLOAT", {"default": 0.0, "forceInput": True}),
"window_size": (
"INT",
{
"default": 1,
"min": 1,
"max": MAX_POSITION_HISTORY - 1,
"tooltip": "Number of frames back for delta",
},
),
"delta_min": ("FLOAT", {"default": cls.DEFAULT_DELTA_MIN, "step": 0.01}),
"delta_max": ("FLOAT", {"default": cls.DEFAULT_DELTA_MAX, "step": 0.01}),
output_min_key: (output_type_str, {"default": cls.DEFAULT_OUTPUT_MIN}),
output_max_key: (output_type_str, {"default": cls.DEFAULT_OUTPUT_MAX}),
"clamp": ("BOOLEAN", {"default": cls.DEFAULT_CLAMP}),
}
}
def execute(self, **kwargs):
output_type_str = "INT" if self.IS_INT_OUTPUT else "FLOAT"
output_min_key = f"output_min_{output_type_str.lower()}"
output_max_key = f"output_max_{output_type_str.lower()}"
x_in, y_in, z_in = kwargs.get("x"), kwargs.get("y"), kwargs.get("z")
window_size = kwargs.get("window_size", 1)
delta_min = kwargs.get("delta_min")
delta_max = kwargs.get("delta_max")
output_min = kwargs.get(output_min_key)
output_max = kwargs.get(output_max_key)
clamp = kwargs.get("clamp")
fallback_value = int(output_min) if self.IS_INT_OUTPUT else float(output_min)
if None in [x_in, y_in, z_in, delta_min, delta_max, output_min, output_max, clamp]:
logger.error(f"{self.__class__.__name__}: Missing required arguments.")
return (fallback_value,)
# --- Input Type Handling & History Cleanup ---
is_list = isinstance(x_in, list)
if is_list != isinstance(y_in, list) or is_list != isinstance(z_in, list):
raise ValueError(f"{self.__class__.__name__}: Inputs x, y, z must be all floats or all lists.")
if is_list:
current_batch_size = len(x_in)
# Remove histories for indices no longer present
keys_to_remove = [k for k in self._histories if k >= current_batch_size]
if keys_to_remove:
# logger.debug(f"{self.__class__.__name__}: Removing histories for indices {keys_to_remove}")
for k in keys_to_remove:
del self._histories[k]
if current_batch_size == 0:
logger.warning(f"{self.__class__.__name__}: Received empty lists for coordinates.")
return ([],)
# Process List Input
results = []
for i in range(current_batch_size):
xi, yi, zi = x_in[i], y_in[i], z_in[i]
if not all(isinstance(c, (float, int)) for c in [xi, yi, zi]):
logger.warning(f"{self.__class__.__name__}: Invalid coordinate type at index {i}. Using fallback value.")
results.append(fallback_value)
continue
raw_delta = self._get_position_delta(float(xi), float(yi), float(zi), window_size, item_index=i)
scaled_value = scale_value(raw_delta, delta_min, delta_max, output_min, output_max, clamp)
if self.IS_INT_OUTPUT:
output_val = int(round(scaled_value)) if scaled_value is not None else int(output_min)
else:
output_val = scaled_value if scaled_value is not None else float(output_min)
results.append(output_val)
return (results,)
else:
# Single Float Input: Remove all histories except for index 0
keys_to_remove = [k for k in self._histories if k != 0]
if keys_to_remove:
# logger.debug(f"{self.__class__.__name__}: Switching to single input, removing histories for indices {keys_to_remove}")
for k in keys_to_remove:
del self._histories[k]
# Process Single Float Input
if not all(isinstance(c, (float, int)) for c in [x_in, y_in, z_in]):
logger.error(f"{self.__class__.__name__}: Invalid coordinate types for single float input.")
return (fallback_value,)
raw_delta = self._get_position_delta(float(x_in), float(y_in), float(z_in), window_size, item_index=0)
scaled_value = scale_value(raw_delta, delta_min, delta_max, output_min, output_max, clamp)
if self.IS_INT_OUTPUT:
output_val = int(round(scaled_value)) if scaled_value is not None else int(output_min)
else:
output_val = scaled_value if scaled_value is not None else float(output_min)
return (output_val,)
class CoordinateDeltaIntControlNode(CoordinateDeltaControlNode):
RETURN_TYPES = ("INT",) # Semantic type is INT
RETURN_NAMES = ("value",)
IS_INT_OUTPUT = True
DEFAULT_OUTPUT_MIN = 0
DEFAULT_OUTPUT_MAX = 100
class CoordinateDeltaFloatControlNode(CoordinateDeltaControlNode):
RETURN_TYPES = ("FLOAT",) # Semantic type is FLOAT
RETURN_NAMES = ("value",)
IS_INT_OUTPUT = False
DEFAULT_OUTPUT_MIN = 0.0
DEFAULT_OUTPUT_MAX = 1.0
# --- Position Delta Trigger Node (Handling Lists) ---
class CoordinateDeltaTriggerNode(BaseCoordinateDelta):
"""Outputs a BOOLEAN trigger if position delta crosses a threshold.
Handles single floats or lists of coordinates.
Outputs BOOLEAN type (actual value is boolean or list[boolean]).
"""
RETURN_TYPES = ("BOOLEAN",) # Semantic type
RETURN_NAMES = ("trigger",) # Singular name
DEFAULT_THRESHOLD = 0.05
CATEGORY = "Realtime Nodes/MediaPipe Vision/Common/Delta"
@classmethod
def INPUT_TYPES(cls):
# Inputs remain the same
return {
"required": {
"x": ("FLOAT", {"default": 0.0, "forceInput": True}),
"y": ("FLOAT", {"default": 0.0, "forceInput": True}),
"z": ("FLOAT", {"default": 0.0, "forceInput": True}),
"window_size": ("INT", {"default": 1, "min": 1, "max": MAX_POSITION_HISTORY - 1}),
"threshold": ("FLOAT", {"default": cls.DEFAULT_THRESHOLD, "step": 0.01}),
"condition": (["Above", "Below", "Equals", "Not Equals"],),
}
}
def execute(self, **kwargs):
x_in, y_in, z_in = kwargs.get("x"), kwargs.get("y"), kwargs.get("z")
window_size = kwargs.get("window_size", 1)
threshold = kwargs.get("threshold")
condition = kwargs.get("condition")
fallback_value = False
if None in [x_in, y_in, z_in, threshold, condition]:
logger.error(f"{self.__class__.__name__}: Missing required arguments.")
return (fallback_value,)
# --- Input Type Handling & History Cleanup ---
is_list = isinstance(x_in, list)
if is_list != isinstance(y_in, list) or is_list != isinstance(z_in, list):
raise ValueError(f"{self.__class__.__name__}: Inputs x, y, z must be all floats or all lists.")
if is_list:
current_batch_size = len(x_in)
# Remove histories for indices no longer present
keys_to_remove = [k for k in self._histories if k >= current_batch_size]
if keys_to_remove:
# logger.debug(f"{self.__class__.__name__}: Removing histories for indices {keys_to_remove}")
for k in keys_to_remove:
del self._histories[k]
if current_batch_size == 0:
logger.warning(f"{self.__class__.__name__}: Received empty lists for coordinates.")
return ([],)
# Process List Input
results = []
for i in range(current_batch_size):
xi, yi, zi = x_in[i], y_in[i], z_in[i]
if not all(isinstance(c, (float, int)) for c in [xi, yi, zi]):
logger.warning(f"{self.__class__.__name__}: Invalid coordinate type at index {i}. Using fallback value.")
results.append(fallback_value)
continue
raw_delta = self._get_position_delta(float(xi), float(yi), float(zi), window_size, item_index=i)
triggered = fallback_value
if raw_delta is not None:
if condition == "Above":
triggered = raw_delta > threshold
elif condition == "Below":
triggered = raw_delta < threshold
elif condition == "Equals":
triggered = abs(raw_delta - threshold) < FLOAT_EQUALITY_TOLERANCE
elif condition == "Not Equals":
triggered = abs(raw_delta - threshold) >= FLOAT_EQUALITY_TOLERANCE
results.append(triggered)
return (results,)
else:
# Single Float Input: Remove all histories except for index 0
keys_to_remove = [k for k in self._histories if k != 0]
if keys_to_remove:
# logger.debug(f"{self.__class__.__name__}: Switching to single input, removing histories for indices {keys_to_remove}")
for k in keys_to_remove:
del self._histories[k]
# Process Single Float Input
if not all(isinstance(c, (float, int)) for c in [x_in, y_in, z_in]):
logger.error(f"{self.__class__.__name__}: Invalid coordinate types for single float input.")
return (fallback_value,)
raw_delta = self._get_position_delta(float(x_in), float(y_in), float(z_in), window_size, item_index=0)
triggered = fallback_value
if raw_delta is not None:
if condition == "Above":
triggered = raw_delta > threshold
elif condition == "Below":
triggered = raw_delta < threshold
elif condition == "Equals":
triggered = abs(raw_delta - threshold) < FLOAT_EQUALITY_TOLERANCE
elif condition == "Not Equals":
triggered = abs(raw_delta - threshold) >= FLOAT_EQUALITY_TOLERANCE
return (triggered,)
# --- Landmark Proximity Node (Handles Float or List Inputs) ---
class CoordinateProximityNode:
"""
Calculates the Euclidean distance between landmark positions.
Handles single coordinate sets or lists thereof.
Outputs FLOAT type (actual value is float or list[float]).
"""
CATEGORY = "Realtime Nodes/MediaPipe Vision/Common/Utils"
FUNCTION = "calculate_distances"
# Output semantic type is FLOAT
RETURN_TYPES = ("FLOAT",)
RETURN_NAMES = ("distance",) # Singular name
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
# Keep FLOAT type
"x1": ("FLOAT", {"default": 0.0, "forceInput": True}),
"y1": ("FLOAT", {"default": 0.0, "forceInput": True}),
"z1": ("FLOAT", {"default": 0.0, "forceInput": True}),
"x2": ("FLOAT", {"default": 0.0, "forceInput": True}),
"y2": ("FLOAT", {"default": 0.0, "forceInput": True}),
"z2": ("FLOAT", {"default": 0.0, "forceInput": True}),
}
}
def calculate_distances(
self,
x1: Union[float, List[float]],
y1: Union[float, List[float]],
z1: Union[float, List[float]],
x2: Union[float, List[float]],
y2: Union[float, List[float]],
z2: Union[float, List[float]],
) -> Union[float, List[float]]: # Return type hint updated
# --- Input Type Handling ---
inputs = [x1, y1, z1, x2, y2, z2]
is_list_input = isinstance(x1, list)
fallback_value = 0.0
if any(isinstance(inp, list) != is_list_input for inp in inputs):
raise ValueError("All coordinate inputs (x1..z2) must be of the same type (all floats or all lists).")
if is_list_input:
# Process List Input
list_len = len(x1)
if any(len(inp) != list_len for inp in inputs if isinstance(inp, list)):
raise ValueError("If inputs are lists, all coordinate lists (x1..z2) must have the same length.")
if list_len == 0:
logger.warning("Input coordinate lists are empty. Returning empty distance list.")
return ([],) # Return empty list for FLOAT type
num_calcs = list_len
x1_list, y1_list, z1_list = x1, y1, z1
x2_list, y2_list, z2_list = x2, y2, z2
distances = []
for i in range(num_calcs):
try:
_x1, _y1, _z1 = x1_list[i], y1_list[i], z1_list[i]
_x2, _y2, _z2 = x2_list[i], y2_list[i], z2_list[i]
if not all(isinstance(c, (float, int)) for c in [_x1, _y1, _z1, _x2, _y2, _z2]):
logger.warning(f"Invalid coordinate type found at index {i}. Using fallback distance {fallback_value}.")
distances.append(fallback_value)
continue
dist = math.sqrt((_x2 - _x1) ** 2 + (_y2 - _y1) ** 2 + (_z2 - _z1) ** 2)
distances.append(dist)
except Exception as e:
logger.error(f"Error calculating distance at index {i}: {e}", exc_info=True)
distances.append(fallback_value)
return (distances,) # Return list of results
else:
# Process Single Float Input
_x1, _y1, _z1 = x1, y1, z1
_x2, _y2, _z2 = x2, y2, z2
if not all(isinstance(c, (float, int)) for c in [_x1, _y1, _z1, _x2, _y2, _z2]):
logger.error(f"Invalid coordinate types for single float input.")
return (fallback_value,) # Return single fallback
try:
dist = math.sqrt((_x2 - _x1) ** 2 + (_y2 - _y1) ** 2 + (_z2 - _z1) ** 2)
return (dist,) # Return single float value
except Exception as e:
logger.error(f"Error calculating distance for single input: {e}", exc_info=True)
return (fallback_value,) # Return single fallback
class MaskFromCoordinate:
"""
Creates geometric masks (circle or square) centered at landmark coordinates.
Handles single (x, y) floats or lists (x_list, y_list) as input.
Outputs a batch of masks (N, 1, H, W).
"""
CATEGORY = "Realtime Nodes/MediaPipe Vision/Common/Masking"
FUNCTION = "create_masks" # Keep plural name for clarity
RETURN_TYPES = ("MASK",)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"x": (
"FLOAT",
{
"default": 0.5,
"forceInput": True,
"tooltip": "Normalized X coordinate (0.0-1.0) or List thereof",
},
),
"y": (
"FLOAT",
{
"default": 0.5,
"forceInput": True,
"tooltip": "Normalized Y coordinate (0.0-1.0) or List thereof",
},
),
"image_for_dimensions": ("IMAGE", {"tooltip": "Reference image to determine the output mask size"}),
"radius": (
"INT",
{"default": 25, "min": 1, "max": 1024, "tooltip": "Radius of the mask shape in pixels"},
),
"shape": (["Circle", "Square"], {"default": "Circle"}),
"invert": ("BOOLEAN", {"default": False}),
}
}
def create_masks(
self,
x: Union[float, List[float]],
y: Union[float, List[float]],
image_for_dimensions: torch.Tensor,
radius: int,
shape: str,
invert: bool,
) -> Tuple[torch.Tensor]:
# --- Input Type Handling ---
is_x_list = isinstance(x, list)
is_y_list = isinstance(y, list)
if is_x_list != is_y_list:
raise ValueError("Inputs x and y must be both floats or both lists.")
if is_x_list:
# Input is List[float]
x_list = x
y_list = y
if len(x_list) != len(y_list):
raise ValueError(f"x_list (len {len(x_list)}) and y_list (len {len(y_list)}) must have the same length.")
if not x_list: # Handle empty list
logger.warning("Input coordinate lists are empty. Returning empty mask batch.")
# Determine shape for empty tensor
if image_for_dimensions.dim() != 4:
logger.error("Input image must be BHWC format to determine empty mask size.")
return (torch.zeros((0, 1, 64, 64), dtype=torch.float32),)
_, height, width, _ = image_for_dimensions.shape
return (torch.zeros((0, 1, height, width), dtype=torch.float32, device=image_for_dimensions.device),)
num_coords = len(x_list)
else:
# Input is float - wrap in lists for unified processing
x_list = [x]
y_list = [y]
num_coords = 1
# --- Get Dimensions ---
if image_for_dimensions.dim() != 4:
logger.error("Input image must be BHWC format.")
# Return empty batch matching input size
return (torch.zeros((num_coords, 1, 64, 64), dtype=torch.float32),)
_, height, width, _ = image_for_dimensions.shape
device = image_for_dimensions.device
# --- Generate Masks ---
output_masks = []
for i in range(num_coords):
xi = x_list[i]
yi = y_list[i]
# Validate individual coordinates
if not isinstance(xi, (float, int)) or not isinstance(yi, (float, int)):
logger.warning(
f"Invalid coordinate type at index {i} (x={type(xi)}, y={type(yi)}). Skipping mask generation for this index."
)
mask_tensor = torch.zeros((1, height, width), dtype=torch.float32, device=device)
output_masks.append(mask_tensor)
continue
# Calculate pixel coordinates
px = max(0, min(width - 1, int(xi * width)))
py = max(0, min(height - 1, int(yi * height)))
mask_np = np.zeros((height, width), dtype=np.uint8)
try:
center = (px, py)
if shape == "Circle":
cv2.circle(mask_np, center, radius, 1, -1)
elif shape == "Square":
half_size = radius
top_left = (max(0, px - half_size), max(0, py - half_size))
bottom_right = (min(width - 1, px + half_size), min(height - 1, py + half_size))
cv2.rectangle(mask_np, top_left, bottom_right, 1, -1)
else:
cv2.circle(mask_np, center, radius, 1, -1) # Default circle
if invert:
mask_np = 1 - mask_np
except Exception as e:
logger.error(f"Error drawing mask shape at index {i}: {e}", exc_info=True)
# mask_np remains zeros
# Convert to tensor (1, H, W)
mask_tensor = torch.from_numpy(mask_np).float().unsqueeze(0).to(device)
output_masks.append(mask_tensor)
# Stack into batch (N, 1, H, W)
output_batch = torch.stack(output_masks, dim=0)
return (output_batch,)