Files
ryanontheinside-ComfyUI_Rea…/node_wrappers/mediapipe_vision/common/base_visualization_nodes.py
T
2025-04-26 18:12:11 -04:00

323 lines
12 KiB
Python

import logging
from typing import Any, List, Optional
import cv2
from ....src.utils.image import convert_to_cv2, convert_to_tensor
logger = logging.getLogger(__name__)
# Default font setup - can be used by all visualization classes
DEFAULT_FONT = cv2.FONT_HERSHEY_SIMPLEX
DEFAULT_FONT_SCALE = 0.5
DEFAULT_FONT_THICKNESS = 1
class BaseLandmarkVisualizationNode:
"""Base class for landmark visualization nodes."""
CATEGORY = "Realtime Nodes/MediaPipe Vision/Visualization"
RETURN_TYPES = ("IMAGE",)
FUNCTION = "draw_visualizations"
# --- To be overridden by subclasses ---
LANDMARKS_TYPE = "ANY" # The specific ComfyUI landmark type string (e.g., "FACE_LANDMARKS")
CONNECTIONS = None # MediaPipe connections list (e.g., mp.solutions.face_mesh.FACEMESH_TESSELATION)
DEFAULT_DRAW_POINTS = True
DEFAULT_POINT_COLOR = "#FFFFFF"
DEFAULT_POINT_RADIUS = 3
DEFAULT_DRAW_CONNECTIONS = True
DEFAULT_CONNECTION_COLOR = "#00FF00"
DEFAULT_CONNECTION_THICKNESS = 2
SUPPORTS_VISIBILITY = False # Does this landmark type provide visibility scores?
# --- End Override Section ---
@classmethod
def INPUT_TYPES(cls):
"""Generate input types based on class properties."""
inputs = {
"required": {
"landmarks": (cls.LANDMARKS_TYPE,),
"image": ("IMAGE",),
},
"optional": {
"draw_points": ("BOOLEAN", {"default": cls.DEFAULT_DRAW_POINTS}),
"point_color": ("STRING", {"default": cls.DEFAULT_POINT_COLOR}),
"point_radius": ("INT", {"default": cls.DEFAULT_POINT_RADIUS, "min": 1, "max": 10}),
"draw_connections": ("BOOLEAN", {"default": cls.DEFAULT_DRAW_CONNECTIONS}),
"connection_color": ("STRING", {"default": cls.DEFAULT_CONNECTION_COLOR}),
"connection_thickness": ("INT", {"default": cls.DEFAULT_CONNECTION_THICKNESS, "min": 1, "max": 10}),
},
}
if cls.SUPPORTS_VISIBILITY:
inputs["optional"]["min_visibility"] = (
"FLOAT",
{
"default": 0.5,
"min": 0.0,
"max": 1.0,
"step": 0.05,
"tooltip": "Minimum visibility score to draw a point/connection endpoint",
},
)
return inputs
def draw_visualizations(self, landmarks, image, **kwargs):
"""Main execution function: handles batching, image conversion, and calls drawing logic."""
# Extract relevant optional arguments using class defaults
draw_points = kwargs.get("draw_points", self.DEFAULT_DRAW_POINTS)
point_color = kwargs.get("point_color", self.DEFAULT_POINT_COLOR)
point_radius = kwargs.get("point_radius", self.DEFAULT_POINT_RADIUS)
draw_connections = kwargs.get("draw_connections", self.DEFAULT_DRAW_CONNECTIONS)
connection_color = kwargs.get("connection_color", self.DEFAULT_CONNECTION_COLOR)
connection_thickness = kwargs.get("connection_thickness", self.DEFAULT_CONNECTION_THICKNESS)
min_visibility = kwargs.get("min_visibility", 0.5) # Default if not supported/provided
if not landmarks:
logger.warning(f"{self.__class__.__name__}: No landmarks provided.")
return (image,)
cv2_images = convert_to_cv2(image)
processed_cv2_images = []
batch_size = len(cv2_images)
if len(landmarks) != batch_size:
logger.error(
f"{self.__class__.__name__}: Batch size mismatch. Images: {batch_size}, Landmark sets: {len(landmarks)}. Cannot proceed reliably."
)
return (image,)
# Convert hex colors to BGR tuples
point_color_bgr = self._hex_to_bgr(point_color)
connection_color_bgr = self._hex_to_bgr(connection_color)
for i, cv2_image in enumerate(cv2_images):
height, width = cv2_image.shape[:2]
landmarks_for_image = landmarks[i] # List of landmark results for this image item
if not landmarks_for_image:
processed_cv2_images.append(cv2_image) # Append unmodified image
continue
# Iterate through all detections (faces, hands, poses) in this image item
for single_detection_result in landmarks_for_image:
self.draw_single_detection(
cv2_image,
width,
height,
single_detection_result,
draw_points,
point_color_bgr,
point_radius,
draw_connections,
connection_color_bgr,
connection_thickness,
min_visibility,
)
processed_cv2_images.append(cv2_image)
output_image = convert_to_tensor(processed_cv2_images)
return (output_image,)
def _hex_to_bgr(self, hex_color):
"""Convert hex color string to BGR tuple."""
hex_color = hex_color.lstrip("#")
r, g, b = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
return (b, g, r) # OpenCV uses BGR
def draw_single_detection(
self,
cv2_image,
width: int,
height: int,
detection_result: Any,
draw_points: bool,
point_color,
point_radius: int,
draw_connections: bool,
connection_color,
connection_thickness: int,
min_visibility: float,
):
"""Draws landmarks and connections for a single detected object (face, hand, pose)."""
# Extract the actual list of landmarks (this might vary slightly based on MediaPipe result structure)
landmark_list = self.get_landmark_list(detection_result)
if not landmark_list:
return # Skip if no landmarks found for this detection
points = {}
# Process landmarks: calculate coordinates and optionally draw points
for lm in landmark_list:
# Check visibility if supported
is_visible = True
if self.SUPPORTS_VISIBILITY:
vis = lm.visibility if hasattr(lm, "visibility") and lm.visibility is not None else 1.0
is_visible = vis >= min_visibility
if is_visible:
cx, cy = int(lm.x * width), int(lm.y * height)
points[lm.index] = (cx, cy) # Store denormalized point coordinates
if draw_points:
cv2.circle(cv2_image, (cx, cy), point_radius, point_color, -1)
# Draw connections if enabled and applicable
if draw_connections and self.CONNECTIONS and points:
for connection in self.CONNECTIONS:
start_idx = connection[0]
end_idx = connection[1]
# Draw connection only if both points are present (i.e., passed visibility check)
if start_idx in points and end_idx in points:
cv2.line(cv2_image, points[start_idx], points[end_idx], connection_color, connection_thickness)
def get_landmark_list(self, detection_result: Any) -> Optional[List[Any]]:
"""Abstract method to extract the list of landmark objects from a single detection result.
Needs to be implemented by subclasses based on the specific MediaPipe result structure.
e.g., for hands/pose: return detection_result.landmarks
for face: return detection_result (it's already the list)
"""
raise NotImplementedError("Subclasses must implement get_landmark_list")
class BaseDetectionVisualizationNode:
"""Base class for visualization nodes that need to draw bounding boxes and labels.
This can be used for object detection, face detection, and similar tasks
that require drawing boxes and text labels with a consistent font rendering approach.
"""
CATEGORY = "Realtime Nodes/MediaPipe Vision/Visualization"
RETURN_TYPES = ("IMAGE",)
FUNCTION = "draw_visualizations"
# Default font settings
FONT = DEFAULT_FONT
FONT_SCALE = DEFAULT_FONT_SCALE
FONT_THICKNESS = DEFAULT_FONT_THICKNESS
@classmethod
def get_text_dimensions(cls, text, font_scale=None, thickness=None):
"""Gets text dimensions using cv2.
Args:
text: The text to measure
font_scale: The font scale (defaults to cls.FONT_SCALE)
thickness: The font thickness (defaults to cls.FONT_THICKNESS)
Returns:
Tuple of (width, height) in pixels
"""
if font_scale is None:
font_scale = cls.FONT_SCALE
if thickness is None:
thickness = cls.FONT_THICKNESS
try:
(text_width, text_height), baseline = cv2.getTextSize(text, cls.FONT, font_scale, thickness)
return text_width, text_height + baseline
except Exception as e:
logger.error(f"Error measuring text dimensions: {e}")
return 100, 20 # Default fallback
def _hex_to_bgr(self, hex_color):
"""Convert hex color string to BGR tuple."""
hex_color = hex_color.lstrip("#")
r, g, b = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
return (b, g, r) # OpenCV uses BGR
def draw_text_with_background(
self,
image,
text,
position,
text_color="#FFFFFF",
bg_color="#000000",
padding=(4, 2),
font_scale=None,
thickness=None,
):
"""Draws text with a background rectangle.
Args:
image: cv2 image to draw on
text: Text to draw
position: (x, y) position for top-left of text
text_color: Hex color for text
bg_color: Hex color for background
padding: (x_pad, y_pad) padding around text
font_scale: Font scale (defaults to cls.FONT_SCALE)
thickness: Font thickness (defaults to cls.FONT_THICKNESS)
"""
if font_scale is None:
font_scale = self.FONT_SCALE
if thickness is None:
thickness = self.FONT_THICKNESS
# Convert hex colors to BGR tuples
text_color_bgr = self._hex_to_bgr(text_color)
bg_color_bgr = self._hex_to_bgr(bg_color)
x, y = position
text_width, text_height = self.get_text_dimensions(text, font_scale, thickness)
# Draw background rectangle
x_pad, y_pad = padding
cv2.rectangle(
image,
(x, y),
(x + text_width + x_pad * 2, y + text_height + y_pad * 2),
bg_color_bgr,
-1, # Filled rectangle
)
# Draw text
cv2.putText(image, text, (x + x_pad, y + text_height), self.FONT, font_scale, text_color_bgr, thickness)
return text_width, text_height
def process_batch(self, detections, image, drawing_function, **kwargs):
"""Process a batch of images and detections.
Args:
detections: List of detection results (one per image)
image: Batch of images as tensor
drawing_function: Function to call for each image
**kwargs: Additional arguments for the drawing function
Returns:
Tuple with processed image tensor
"""
if not detections:
logger.warning(f"{self.__class__.__name__}: No detections provided.")
return (image,)
cv2_images = convert_to_cv2(image)
processed_cv2_images = []
batch_size = len(cv2_images)
if len(detections) != batch_size:
logger.error(
f"{self.__class__.__name__}: Batch size mismatch. Images: {batch_size}, Detection sets: {len(detections)}. Cannot proceed."
)
return (image,)
for i, cv2_image in enumerate(cv2_images):
height, width = cv2_image.shape[:2]
detections_for_image = detections[i]
if not detections_for_image:
processed_cv2_images.append(cv2_image)
continue
# Call the drawing function with the specific parameters
drawing_function(cv2_image, width, height, detections_for_image, **kwargs)
processed_cv2_images.append(cv2_image)
output_image = convert_to_tensor(processed_cv2_images)
return (output_image,)