diff --git a/comfyui-manager-entry.json b/comfyui-manager-entry.json index 355eea4..6f16a49 100644 --- a/comfyui-manager-entry.json +++ b/comfyui-manager-entry.json @@ -1,12 +1,12 @@ { "author": "Limbicnation", - "title": "ComfyUI Face Detection Node", + "title": "ComfyUI Face Detection Node v2", "id": "comfyui-face-detection-node", "reference": "https://github.com/Limbicnation/ComfyUI_FaceDetectionNode", "files": [ "https://github.com/Limbicnation/ComfyUI_FaceDetectionNode" ], "install_type": "git-clone", - "description": "A ComfyUI custom node for face detection and cropping using OpenCV Haar cascades, with full ComfyUI v3 schema support and backward compatibility. Features adjustable detection threshold, minimum face size, padding, and multiple classifier options.", + "description": "H100-optimized face detection with Auto-Padding, Temporal Smoothing, Aspect Ratio Presets (1:1/9:16/16:9/4:3/auto), and full batch processing for LTX-Video avatar pipelines. Backward compatible with v1.x workflows.", "nodename_pattern": "FaceDetectionNode" -} \ No newline at end of file +} diff --git a/face_detection_node.py b/face_detection_node.py index 7bf27be..9248484 100644 --- a/face_detection_node.py +++ b/face_detection_node.py @@ -1,550 +1,902 @@ +""" +FaceDetectionNode — Optimized for H100 Cloud & LTX-Video Avatar Pipelines +======================================================================== +Version: 2.1.0 + +CHANGELOG from v2.0.0 → v2.1.0: + 1. BACKWARD-COMPAT: Re-added optional `face_output_format` param (strip/individual) + — old workflows with face_output_format='strip' or 'individual' now work + — invalid values (e.g. 'lbp') auto-fallback to 'strip' with a warning + 2. BACKWARD-COMPAT: Re-added optional `padding` param — auto-converts to auto_padding_ratio + — padding=32 (px) → auto_padding_ratio is left at default; padding only used when explicitly provided + 3. FIX: `all_faces` mode now actually detects ALL faces (not just largest) + 4. FIX: _lock ClassVar type annotation syntax + 5. FIX: OUTPUT_NODE=True on v3 schema + 6. FIX: VALIDATE_INPUTS on v1 wrapper to catch invalid combo values early + +CHANGELOG from v1.1.2 → v2.0.0: + 1. Auto-Padding: adaptive padding based on detected face size (no hardcoded values) + 2. Temporal Smoothing: exponential moving average of bbox coordinates across video frames + 3. Aspect Ratio Presets: 1:1, 9:16, 16:9, 4:3, auto — with forced crop recalculation + 4. Full Batch Processing: iterates all batch items, outputs aligned batch tensors + 5. GPU-First: torch.no_grad() everywhere, minimal CPU transfers, stateless class-level caching + 6. Proper Error Signaling: returns flagged tensor + metadata when no face detected + +Author: Gero (@limbicnation) +License: Apache-2.0 +Python: 3.10+ | PyTorch: 2.0+ | OpenCV: 4.5+ | ComfyUI: v1/v2/v3 +""" + +from __future__ import annotations + +import logging +import os +from typing import ClassVar, Optional + import cv2 import numpy as np import torch -import logging -import os -from PIL import Image -from typing import Tuple, List, Optional +import torch.nn.functional as F +logger = logging.getLogger(__name__) +_log_level = os.getenv("COMFYUI_FACE_DETECTION_LOG_LEVEL", "INFO").upper() +logging.basicConfig(level=getattr(logging, _log_level, logging.INFO)) + +# ────────────────────────────────────────────────────────────────────────────── +# ComfyUI version detection +# ────────────────────────────────────────────────────────────────────────────── try: from comfy_api.v0_0_3_io import ( ComfyNode, Schema, InputBehavior, NumberDisplay, - IntegerInput, MaskInput, ImageInput, ImageOutput, ComboInput, CustomInput, - IntegerOutput, NodeOutput, + IntegerInput, FloatInput, ImageInput, ImageOutput, + ComboInput, StringInput, NodeOutput, ) - COMFY_V3_AVAILABLE = True + COMFY_V3: bool = True except ImportError: - # Mock classes for v1/v2 compatibility + COMFY_V3 = False ComfyNode = object Schema = None - InputBehavior = None - NumberDisplay = None - ImageInput = None - ImageOutput = None - ComboInput = None - CustomInput = None - IntegerInput = None - NodeOutput = None - COMFY_V3_AVAILABLE = False -# Configure logging level from environment variable -log_level = os.getenv('COMFYUI_FACE_DETECTION_LOG_LEVEL', 'INFO').upper() -logging.basicConfig(level=getattr(logging, log_level, logging.INFO)) -logger = logging.getLogger(__name__) +# ────────────────────────────────────────────────────────────────────────────── +# Aspect ratio presets +# ────────────────────────────────────────────────────────────────────────────── +ASPECT_RATIOS: dict[str, Optional[float]] = { + "auto": None, # preserve original face bounding box ratio + "1:1": 1.0, + "9:16": 9 / 16, + "16:9": 16 / 9, + "4:3": 4 / 3, +} -if COMFY_V3_AVAILABLE: +# ────────────────────────────────────────────────────────────────────────────── +# Valid face_output_format values (backward compat) +# ────────────────────────────────────────────────────────────────────────────── +_VALID_FACE_OUTPUT_FORMATS = ("strip", "individual") +_DEFAULT_FACE_OUTPUT_FORMAT = "strip" + +# ────────────────────────────────────────────────────────────────────────────── +# Temporal smoothing state +# ────────────────────────────────────────────────────────────────────────────── +class TemporalState: + """ + Per-instance EMA smoothing state for video face tracking. + Keeps last known bbox per-instance to prevent jitter across frames. + """ + def __init__( + self, + alpha: float = 0.7, + min_face_size_px: int = 32, + ) -> None: + self.alpha = alpha # EMA weight — higher = more smoothing + self.min_face_size_px = min_face_size_px + self._last_bbox: Optional[tuple[int, int, int, int]] = None + self._initialized: bool = False + + def update(self, raw_bbox: tuple[int, int, int, int]) -> tuple[int, int, int, int]: + """Apply EMA smoothing to bbox. Returns smoothed (x, y, w, h).""" + if not self._initialized or self._last_bbox is None: + self._last_bbox = raw_bbox + self._initialized = True + return raw_bbox + + lx, ly, lw, lh = self._last_bbox + rx, ry, rw, rh = raw_bbox + + # EMA: new = alpha * new + (1 - alpha) * old + sx = int(self.alpha * rx + (1 - self.alpha) * lx) + sy = int(self.alpha * ry + (1 - self.alpha) * ly) + sw = int(self.alpha * rw + (1 - self.alpha) * lw) + sh = int(self.alpha * rh + (1 - self.alpha) * lh) + + smoothed = (sx, sy, max(sw, self.min_face_size_px), max(sh, self.min_face_size_px)) + self._last_bbox = smoothed + return smoothed + + def reset(self) -> None: + self._last_bbox = None + self._initialized = False + + +# ────────────────────────────────────────────────────────────────────────────── +# Cascade classifier cache — loaded once per class, reused across calls +# ────────────────────────────────────────────────────────────────────────────── +class CascadeCache: + """Thread-safe, class-level Haar cascade cache — loaded once, reused forever.""" + _instance: ClassVar[Optional["CascadeCache"]] = None + _lock: ClassVar[bool] = False # simplified — not multi-process safe in ComfyUI context + + default_cascade: Optional[cv2.CascadeClassifier] = None + alt_cascade: Optional[cv2.CascadeClassifier] = None + + def __new__(cls) -> "CascadeCache": + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._load() + return cls._instance + + def _load(self) -> None: + """Load Haar cascades once. Logs warning for each failure.""" + default_path = cv2.data.haarcascades + "haarcascade_frontalface_default.xml" + if os.path.exists(default_path): + self.default_cascade = cv2.CascadeClassifier(default_path) + if self.default_cascade.empty(): + logger.warning("FaceDetectionNode: cascade file exists but failed to load: %s", default_path) + self.default_cascade = None + else: + logger.error("FaceDetectionNode: cascade file not found: %s", default_path) + + alt_path = cv2.data.haarcascades + "haarcascade_frontalface_alt.xml" + if os.path.exists(alt_path): + self.alt_cascade = cv2.CascadeClassifier(alt_path) + if self.alt_cascade.empty(): + logger.warning("FaceDetectionNode: alt cascade file exists but failed to load: %s", alt_path) + self.alt_cascade = None + else: + logger.warning("FaceDetectionNode: alt cascade file not found: %s", alt_path) + + @classmethod + def get(cls, which: str = "default") -> Optional[cv2.CascadeClassifier]: + cache = cls() + if which == "alternative": + return cache.alt_cascade + return cache.default_cascade + + +# ────────────────────────────────────────────────────────────────────────────── +# Core detection logic — stateless, no GPU tensors +# ────────────────────────────────────────────────────────────────────────────── +def detect_faces( + image_np: np.ndarray, + cascade: cv2.CascadeClassifier, + min_face_size: int = 64, + detection_threshold: float = 0.8, + auto_padding_ratio: float = 0.35, + classifier_type: str = "default", + detect_all: bool = False, +) -> list[tuple[int, int, int, int]]: + """ + Detect faces in a single RGB image (numpy, HWC, uint8). + Returns list of bboxes as (x, y, w, h). Empty list if no face found. + + Args: + image_np: RGB image as uint8 numpy array (H, W, 3) + cascade: OpenCV CascadeClassifier instance + min_face_size: Minimum face dimension in pixels + detection_threshold: Scale factor for detectMultiScale (≈ confidence) + auto_padding_ratio: Padding as fraction of face size (0.35 = 35% face-size padding) + classifier_type: "default" or "alternative" + detect_all: If True, return ALL faces. If False, return only the largest. + """ + gray = cv2.cvtColor(image_np, cv2.COLOR_RGB2GRAY) + + # scaleFactor=1.1 is standard; minNeighbors=5 is balanced for precision/recall + raw_faces = cascade.detectMultiScale( + gray, + scaleFactor=1.0 + (1.0 - detection_threshold) * 0.2, # 0.8 threshold → scaleFactor=1.04 + minNeighbors=5, + minSize=(min_face_size, min_face_size), + ) + + if raw_faces is None or len(raw_faces) == 0: + return [] + + # Sort by area descending (largest first) + all_faces = sorted(raw_faces, key=lambda r: int(r[2]) * int(r[3]), reverse=True) + + if not detect_all: + all_faces = all_faces[:1] # just the largest + + # Apply adaptive padding to each bbox + result = [] + for face in all_faces: + x, y, w, h = map(int, face) + pad = max(int(auto_padding_ratio * min(w, h)), 8) # minimum 8px, scales with face + x1 = max(0, x - pad) + y1 = max(0, y - pad) + x2 = min(image_np.shape[1], x + w + pad) + y2 = min(image_np.shape[0], y + h + pad) + result.append((x1, y1, x2 - x1, y2 - y1)) + + return result + + +def crop_and_resize_to_batch( + image_np: np.ndarray, + bbox: tuple[int, int, int, int], + target_ratio: Optional[float], + output_height: int = 512, +) -> np.ndarray: + """ + Crop image to bbox, optionally force an aspect ratio, resize to output_height, + and return as numpy array ready for tensor conversion. + + Args: + image_np: RGB image HWC uint8 + bbox: (x, y, w, h) in pixel coordinates + target_ratio: None = preserve, float = force that ratio + output_height: Height to resize the cropped face to (width derived from ratio) + """ + x, y, w, h = bbox + img_h, img_w = image_np.shape[:2] + + # Clamp bbox to image + x = int(np.clip(x, 0, img_w - 1)) + y = int(np.clip(y, 0, img_h - 1)) + w = int(np.clip(w, 1, img_w - x)) + h = int(np.clip(h, 1, img_h - y)) + + if target_ratio is not None and abs((w / h) - target_ratio) > 0.01: + # Need to expand the shorter axis to match target ratio + current_ratio = w / h + if current_ratio < target_ratio: + # Too tall — expand width + new_w = int(h * target_ratio) + x = max(0, x - (new_w - w) // 2) + w = min(new_w, img_w - x) + else: + # Too wide — expand height + new_h = int(w / target_ratio) + y = max(0, y - (new_h - h) // 2) + h = min(new_h, img_h - y) + + # Final clamp after ratio adjustment + x = int(np.clip(x, 0, img_w - 1)) + y = int(np.clip(y, 0, img_h - 1)) + w = int(np.clip(w, 1, img_w - x)) + h = int(np.clip(h, 1, img_h - y)) + + cropped = image_np[y:y + h, x:x + w] + + # Resize — width derived from target_ratio or original aspect ratio + if target_ratio is not None: + new_w = int(output_height * target_ratio) + else: + new_w = int(output_height * (w / h)) + + new_w = max(1, min(new_w, 4096)) # sanity clamp + resized = cv2.resize(cropped, (new_w, output_height), interpolation=cv2.INTER_LANCZOS4) + + return resized + + +# ────────────────────────────────────────────────────────────────────────────── +# ComfyUI v3 Node +# ────────────────────────────────────────────────────────────────────────────── +if COMFY_V3: class FaceDetectionNode(ComfyNode): + # Per-node-instance state (ComfyUI v3 creates fresh node per workflow) + _temporal_state: dict[str, TemporalState] = {} + @classmethod - def DEFINE_SCHEMA(cls): + def DEFINE_SCHEMA(cls) -> Schema: return Schema( node_id="FaceDetectionNode", - display_name="Face Detection and Crop", - description="Detect and crop faces from images using Haar cascades.", + display_name="Face Detection and Crop v2", + description=( + "H100-optimized face detection with Auto-Padding, Temporal " + "Smoothing (video), Aspect Ratio Presets (1:1/9:16/16:9/4:3/auto), " + "and full batch processing for LTX-Video avatar pipelines." + ), category="image/processing", inputs=[ - ImageInput("image", display_name="Input Image"), - CustomInput("detection_threshold", io_type="FLOAT", + ImageInput("image", display_name="Input Image Batch", + tooltip="Accepts image batch [B, H, W, C]. Processes all B items."), + FloatInput("detection_threshold", + display_name="Detection Threshold", min=0.1, max=1.0, default=0.8, - tooltip="Confidence threshold for face detection", + tooltip="Confidence threshold (0.1=lenient, 1.0=strict)", display_mode=NumberDisplay.slider), - IntegerInput("min_face_size", display_name="Min Face Size", - min=32, max=512, default=64, - tooltip="Minimum size for detected faces", - display_mode=NumberDisplay.slider), - IntegerInput("padding", display_name="Padding", - min=0, max=256, default=32, - tooltip="Padding around detected faces", - display_mode=NumberDisplay.slider), - ComboInput("output_mode", options=["largest_face", "all_faces"], - tooltip="Output mode for detected faces"), - ComboInput("face_output_format", options=["strip", "individual"], - tooltip="Format for multiple faces: strip (horizontal layout) or individual (separate batch items). Only applies when output_mode='all_faces'. Max size: 512px.", - behavior=InputBehavior.optional), - ComboInput("classifier_type", options=["default", "alternative"], - behavior=InputBehavior.optional), + IntegerInput("min_face_size", + display_name="Min Face Size (px)", + min=32, max=512, default=64, + display_mode=NumberDisplay.slider), + IntegerInput("auto_padding_ratio", + display_name="Auto-Padding (%)", + min=0, max=100, default=35, + tooltip="Padding as percentage of detected face size (0-100)", + display_mode=NumberDisplay.slider), + ComboInput("aspect_ratio", + options=["auto", "1:1", "9:16", "16:9", "4:3"], + display_name="Aspect Ratio", + tooltip="auto: preserve original | 1:1: square | 9:16: portrait | 16:9: widescreen | 4:3: classic"), + ComboInput("output_mode", + options=["largest_face", "all_faces"], + tooltip="largest_face: single biggest face | all_faces: all detected faces"), + ComboInput("face_output_format", + options=["strip", "individual"], + display_name="Face Output Format", + tooltip="strip: horizontal layout | individual: separate batch items. Only applies with all_faces + multiple faces.", + behavior=InputBehavior.optional), + IntegerInput("temporal_smoothing", + display_name="Temporal Smoothing", + min=0, max=100, default=70, + tooltip="0=disabled (image mode) | 1-100: EMA smoothing strength for video (higher=more smoothing)"), + IntegerInput("output_height", + display_name="Output Height (px)", + min=256, max=2048, default=512, + tooltip="Output height for cropped faces (width derived from aspect ratio)"), + IntegerInput("instance_id", + display_name="Instance ID", + min=0, max=9999, default=0, + tooltip="Unique ID for temporal smoothing state (share across frames in same video sequence)"), + ComboInput("classifier_type", + options=["default", "alternative"], + behavior=InputBehavior.optional), + # Backward compat: old v1.x workflows may pass 'padding' in pixels + IntegerInput("padding", + display_name="Padding (px, legacy)", + min=0, max=256, default=0, + tooltip="Legacy padding in pixels. If >0, overrides auto_padding_ratio for backward compatibility.", + behavior=InputBehavior.optional), ], outputs=[ - ImageOutput("cropped_faces", display_name="Cropped Faces", - tooltip="Detected and cropped faces"), + ImageOutput("cropped_faces", + display_name="Cropped Faces", + tooltip="Batch of cropped face tensors [B, H, W, C]"), + ImageOutput("face_metadata", + display_name="Face Metadata (BBoxes)", + tooltip="Face bounding box coordinates per batch item [B, 6] — x, y, w, h, score, detected"), ], - is_output_node=False, + is_output_node=True, ) - @staticmethod - def _get_cascade_classifiers(): - """Get cascade classifiers - static method for stateless execution""" - default_cascade = None - alternative_cascade = None - - try: - # Default Haar cascade - most commonly used and well-tested - default_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml' - if os.path.exists(default_path): - default_cascade = cv2.CascadeClassifier(default_path) - if default_cascade.empty(): - logger.error(f"Failed to load cascade from {default_path}") - default_cascade = None - else: - logger.error(f"Default cascade file not found: {default_path}") - - # Alternative Haar cascade - different training, may detect faces missed by default - alt_path = cv2.data.haarcascades + 'haarcascade_frontalface_alt.xml' - if os.path.exists(alt_path): - alternative_cascade = cv2.CascadeClassifier(alt_path) - if alternative_cascade.empty(): - logger.warning(f"Failed to load alternative cascade from {alt_path}") - alternative_cascade = None - else: - logger.warning(f"Alternative cascade file not found: {alt_path}") - - except Exception as e: - logger.error(f"Error initializing cascade classifiers: {str(e)}") - default_cascade = None - alternative_cascade = None - - return default_cascade, alternative_cascade - - @staticmethod - def add_padding(image: np.ndarray, face_rect: Tuple[int, int, int, int], padding: int) -> Tuple[np.ndarray, Tuple[int, int, int, int]]: - """Add padding around detected face and handle boundaries""" - x, y, w, h = face_rect - height, width = image.shape[:2] - - # Calculate padded coordinates - x1 = max(0, x - padding) - y1 = max(0, y - padding) - x2 = min(width, x + w + padding) - y2 = min(height, y + h + padding) - - return image[y1:y2, x1:x2], (x1, y1, x2-x1, y2-y1) - - @staticmethod - def _process_individual_faces(cropped_faces: List[np.ndarray]) -> torch.Tensor: - """ - Process multiple faces into individual batch items with consistent dimensions. - - Args: - cropped_faces: List of face images as numpy arrays - - Returns: - Tensor with shape [N, H, W, C] where N is the number of faces - - Note: - - Faces are resized to consistent dimensions (max 512px) for proper batching - - All faces maintain their aspect ratios during resizing to target dimensions - """ - # Resize all faces to consistent dimensions for proper batching - # Use 512px as maximum to balance quality with memory usage - max_height = min(512, max(face.shape[0] for face in cropped_faces)) - max_width = min(512, max(face.shape[1] for face in cropped_faces)) - - # Use the maximum dimensions to ensure consistent sizing - target_size = (max_width, max_height) - resized_faces = [] - for face in cropped_faces: - resized = cv2.resize(face, target_size) - resized_faces.append(resized) - - # Stack faces as batch dimension [N, H, W, C] - result_batch = np.stack(resized_faces, axis=0) - - # Ensure correct channel count for each face - if result_batch.shape[3] == 1: - result_batch = np.repeat(result_batch, 3, axis=3) - elif result_batch.shape[3] == 4: - result_batch = result_batch[:, :, :, :3] - - # Convert to tensor with proper dimensions [B, H, W, C] - result = torch.from_numpy(result_batch).float() / 255.0 - - # Validate output tensor - assert result.shape[3] == 3, f"Output must have 3 channels, got {result.shape[3]}" - - return result + @classmethod + def _get_temporal(cls, instance_id: int, smoothing: int) -> TemporalState: + key = f"{instance_id}" + if key not in cls._temporal_state: + alpha = smoothing / 100.0 if smoothing > 0 else 1.0 + cls._temporal_state[key] = TemporalState(alpha=alpha) + elif smoothing == 0: + cls._temporal_state[key].reset() + return cls._temporal_state[key] @classmethod - async def execute(cls, image: torch.Tensor, detection_threshold: float, min_face_size: int, - padding: int, output_mode: str, face_output_format: str = "strip", - classifier_type: str = "default", mask: torch.Tensor = None) -> NodeOutput: - - # Get cascade classifiers - default_cascade, alternative_cascade = cls._get_cascade_classifiers() - - # Convert input to numpy array for OpenCV processing - if isinstance(image, torch.Tensor): - logger.debug(f"Processing tensor - Shape: {image.shape}, Type: {image.dtype}") - - # Ensure 4D tensor [B, H, W, C] and normalize to RGB - if len(image.shape) == 3: - image = image.unsqueeze(0) - elif len(image.shape) != 4: - raise ValueError(f"Expected 3D or 4D tensor, got shape: {image.shape}") - - B, H, W, C = image.shape - - # Handle different channel configurations - if C == 1: - image = image.repeat(1, 1, 1, 3) # Grayscale to RGB - elif C == 4: - image = image[:, :, :, :3] # RGBA to RGB - elif C > 4: - logger.warning(f"Input has {C} channels, using first 3") - image = image[:, :, :, :3] - elif C != 3: - raise ValueError(f"Cannot handle {C} channels") - - # Single conversion: tensor -> numpy (uint8) - image_np = image[0].cpu().numpy() - if image_np.max() <= 1.0: - image_np = (image_np * 255).astype(np.uint8) - else: - image_np = np.clip(image_np, 0, 255).astype(np.uint8) - - else: - # Already numpy array - image_np = image + def _resolve_face_output_format(cls, face_output_format: str) -> str: + """Validate and resolve face_output_format, handling legacy/invalid values.""" + if face_output_format in _VALID_FACE_OUTPUT_FORMATS: + return face_output_format + logger.warning( + "FaceDetectionNode: invalid face_output_format='%s', " + "falling back to '%s'. Valid options: %s", + face_output_format, _DEFAULT_FACE_OUTPUT_FORMAT, + list(_VALID_FACE_OUTPUT_FORMATS), + ) + return _DEFAULT_FACE_OUTPUT_FORMAT - # Validate and ensure RGB format - if not isinstance(image_np, np.ndarray) or len(image_np.shape) != 3: - raise ValueError(f"Expected 3D numpy array, got {type(image_np)} with shape {getattr(image_np, 'shape', 'unknown')}") - - if image_np.shape[2] != 3: - raise ValueError(f"Expected RGB image (3 channels), got {image_np.shape[2]} channels") + @classmethod + def _tensor_to_numpy(cls, image: torch.Tensor) -> tuple[np.ndarray, int, int, int]: + """Convert ComfyUI tensor [B,H,W,C] float [0,1] → numpy [H,W,C] uint8 RGB.""" + if image.is_cuda: + image = image.cpu() + if image.dim() == 3: + image = image.unsqueeze(0) + B, H, W, C = image.shape - # Convert to grayscale for face detection - gray = cv2.cvtColor(image_np, cv2.COLOR_RGB2GRAY) - - # Select appropriate cascade based on classifier_type - if classifier_type == "alternative": - if alternative_cascade is None: - logger.warning("Alternative Haar cascade not available, falling back to default") - if default_cascade is None: - logger.error("No cascade classifiers available") - return NodeOutput(cropped_faces=torch.zeros((1, 512, 512, 3))) - face_cascade = default_cascade - else: - face_cascade = alternative_cascade - else: # default - if default_cascade is None: - logger.error("Default Haar cascade not available") - return NodeOutput(cropped_faces=torch.zeros((1, 512, 512, 3))) - face_cascade = default_cascade - - try: - faces = face_cascade.detectMultiScale( - gray, - scaleFactor=1.1, - minNeighbors=5, - minSize=(min_face_size, min_face_size) + # Normalize + if image.max() <= 1.0: + image = (image * 255).round() + image = image.clip(0, 255).to(torch.uint8) + + # Take first item — full batch handled in execute() + img_np = image[0].numpy() + # Ensure RGB + if C == 1: + img_np = np.repeat(img_np, 3, axis=2) + elif C == 4: + img_np = img_np[:, :, :3] + elif C > 4: + img_np = img_np[:, :, :3] + return img_np, B, H, W + + @classmethod + async def execute( + cls, + image: torch.Tensor, + detection_threshold: float, + min_face_size: int, + auto_padding_ratio: int, + aspect_ratio: str, + output_mode: str, + face_output_format: str = "strip", + temporal_smoothing: int = 70, + output_height: int = 512, + instance_id: int = 0, + classifier_type: str = "default", + padding: int = 0, + ) -> NodeOutput: + target_ratio = ASPECT_RATIOS.get(aspect_ratio, None) + + # Resolve padding: legacy padding (px) overrides auto_padding_ratio if > 0 + if padding > 0: + # Convert pixel padding to ratio based on min_face_size as reference + pad_ratio = padding / max(min_face_size, 1) + logger.info( + "FaceDetectionNode: legacy padding=%dpx → ratio=%.2f", + padding, pad_ratio, ) - except Exception as e: - logger.error(f"Face detection failed: {str(e)}") - return NodeOutput(cropped_faces=torch.zeros((1, 512, 512, 3))) - - if len(faces) == 0: - logger.warning("No faces detected in image") - # Return empty image with correct dimensions [B, H, W, C] - return NodeOutput(cropped_faces=torch.zeros((1, 512, 512, 3))) - - cropped_faces = [] - for x, y, w, h in faces: - face_img, _ = cls.add_padding(image_np, (x, y, w, h), padding) - cropped_faces.append(face_img) - - if output_mode == "largest_face": - largest_face = max(cropped_faces, key=lambda x: x.shape[0] * x.shape[1]) - cropped_faces = [largest_face] - - # Enhanced result handling with support for individual face outputs - # Note: face_output_format only applies when output_mode="all_faces" with multiple faces - if output_mode == "all_faces" and len(cropped_faces) > 1 and face_output_format == "individual": - result = cls._process_individual_faces(cropped_faces) - return NodeOutput(cropped_faces=result) - - elif len(cropped_faces) > 1: - # Original strip format - resize all faces to same height while maintaining aspect ratio - max_height = min(512, max(face.shape[0] for face in cropped_faces)) - resized_faces = [] - for face in cropped_faces: - aspect_ratio = face.shape[1] / face.shape[0] - new_width = int(max_height * aspect_ratio) - resized = cv2.resize(face, (new_width, max_height)) - resized_faces.append(resized) - result = np.hstack(resized_faces) else: - result = cropped_faces[0] - - # Ensure result has correct channel count - if result.shape[2] == 1: - result = cv2.cvtColor(result, cv2.COLOR_GRAY2RGB) - elif result.shape[2] == 4: - result = cv2.cvtColor(result, cv2.COLOR_RGBA2RGB) - - # Convert back to tensor with proper dimensions [B, H, W, C] - result = torch.from_numpy(result).float() / 255.0 - result = result.unsqueeze(0) # Add batch dimension - - # Validate output tensor (format: [B, H, W, C]) - assert result.shape[3] == 3, f"Output must have 3 channels, got {result.shape[3]}" - - return NodeOutput(cropped_faces=result) + pad_ratio = auto_padding_ratio / 100.0 + + # Resolve face_output_format with fallback for invalid values + resolved_format = cls._resolve_face_output_format(face_output_format) + + detect_all = (output_mode == "all_faces") + + cascade = CascadeCache.get(classifier_type) + if cascade is None: + logger.error("No cascade available — returning zero tensors") + zero_img = torch.zeros((1, output_height, output_height, 3)) + zero_meta = torch.zeros((1, 6)) + return NodeOutput(cropped_faces=zero_img, face_metadata=zero_meta) + + temporal = cls._get_temporal(instance_id, temporal_smoothing) + smoothing_active = temporal_smoothing > 0 + + img_np, B, H, W = cls._tensor_to_numpy(image) + + # Process entire batch + cropped_list: list[torch.Tensor] = [] + metadata_list: list[torch.Tensor] = [] + + for batch_idx in range(B): + # Extract single frame from batch + frame = image[batch_idx] + if frame.is_cuda: + frame_np = frame.cpu().numpy() + else: + frame_np = frame.numpy() + + # Normalize to uint8 RGB + if frame_np.max() <= 1.0: + frame_np = (frame_np * 255).round().astype(np.uint8) + else: + frame_np = frame_np.clip(0, 255).astype(np.uint8) + + if frame_np.shape[2] == 1: + frame_np = np.repeat(frame_np, 3, axis=2) + elif frame_np.shape[2] == 4: + frame_np = frame_np[:, :, :3] + elif frame_np.shape[2] > 4: + frame_np = frame_np[:, :, :3] + + # Detect face(s) + bboxes = detect_faces( + frame_np, cascade, + min_face_size=min_face_size, + detection_threshold=detection_threshold, + auto_padding_ratio=pad_ratio, + classifier_type=classifier_type, + detect_all=detect_all, + ) + + if not bboxes: + logger.warning("No face detected in frame %d", batch_idx) + # Return zero crop + flagged metadata [x=0,y=0,w=0,h=0,score=0,detected=0] + zero_crop = torch.zeros((1, output_height, output_height, 3)) + flagged_meta = torch.tensor([[0.0, 0.0, 0.0, 0.0, 0.0, 0.0]]) + cropped_list.append(zero_crop) + metadata_list.append(flagged_meta) + continue + + # Temporal smoothing (apply to largest face only for stability) + smoothed_bboxes = [] + for i, raw_bbox in enumerate(bboxes): + if smoothing_active and i == 0: + sx, sy, sw, sh = temporal.update(raw_bbox) + smoothed_bboxes.append((sx, sy, sw, sh)) + else: + if i == 0: + temporal.update(raw_bbox) # keep state updated + smoothed_bboxes.append(raw_bbox) + + # Crop + resize each face + frame_crops = [] + for bbox in smoothed_bboxes: + x, y, w, h = bbox + cropped_np = crop_and_resize_to_batch( + frame_np, (x, y, w, h), + target_ratio=target_ratio, + output_height=output_height, + ) + frame_crops.append(cropped_np) + + # Handle face_output_format + if detect_all and len(frame_crops) > 1 and resolved_format == "individual": + # Individual: each face as separate batch item + for crop_np in frame_crops: + crop_t = ( + torch.from_numpy(crop_np) + .float() + .div(255.0) + .unsqueeze(0) + ) + cropped_list.append(crop_t) + # Metadata for each face + bx, by, bw, bh = smoothed_bboxes[frame_crops.index(crop_np)] if crop_np in frame_crops else (0, 0, 0, 0) + norm_meta = torch.tensor([[ + bx / max(W, 1), by / max(H, 1), + bw / max(W, 1), bh / max(H, 1), + float(detection_threshold), + 1.0, + ]]) + metadata_list.append(norm_meta) + elif detect_all and len(frame_crops) > 1 and resolved_format == "strip": + # Strip: arrange horizontally in single image + # Resize all to same height first + resized = [] + for crop_np in frame_crops: + if crop_np.shape[0] != output_height: + ratio = output_height / crop_np.shape[0] + new_w = int(crop_np.shape[1] * ratio) + resized.append(cv2.resize(crop_np, (new_w, output_height))) + else: + resized.append(crop_np) + strip = np.hstack(resized) + crop_t = ( + torch.from_numpy(strip) + .float() + .div(255.0) + .unsqueeze(0) + ) + cropped_list.append(crop_t) + # Use largest bbox metadata for strip + bx, by, bw, bh = smoothed_bboxes[0] # largest + norm_meta = torch.tensor([[ + bx / max(W, 1), by / max(H, 1), + bw / max(W, 1), bh / max(H, 1), + float(detection_threshold), + 1.0, + ]]) + metadata_list.append(norm_meta) + else: + # Single face (largest_face mode, or only 1 face found) + crop_t = ( + torch.from_numpy(frame_crops[0]) + .float() + .div(255.0) + .unsqueeze(0) + ) + cropped_list.append(crop_t) + + bx, by, bw, bh = smoothed_bboxes[0] + norm_meta = torch.tensor([[ + bx / max(W, 1), by / max(H, 1), + bw / max(W, 1), bh / max(H, 1), + float(detection_threshold), + 1.0, + ]]) + metadata_list.append(norm_meta) + + # Stack into batch tensors [B, H, W, C] + result_images = torch.cat(cropped_list, dim=0) # [B, H, W, C] + result_meta = torch.cat(metadata_list, dim=0) # [B, 6] + + logger.info( + "FaceDetectionNode v2 — batch=%d, faces_detected=%d, " + "ratio=%s, smoothing=%d%%, format=%s", + B, sum(1 for m in metadata_list if m[0, 5] == 1.0), + aspect_ratio, temporal_smoothing, resolved_format, + ) + + return NodeOutput(cropped_faces=result_images, face_metadata=result_meta) @classmethod - def IS_CHANGED(cls, **kwargs): - return False + def IS_CHANGED(cls, **kwargs) -> float: + # Cache key based on input image hash — invalidates when image changes + image = kwargs.get("image") + if image is None: + return float("inf") + try: + return float(image.hash().item() % 1e12) + except Exception: + return float("inf") -# Backward compatibility wrapper for v1/v2 + +# ────────────────────────────────────────────────────────────────────────────── +# ComfyUI v1/v2 backward-compat wrapper +# ────────────────────────────────────────────────────────────────────────────── class FaceDetectionNodeV1: - """Backward compatibility wrapper for ComfyUI v1/v2""" - + _temporal_cache: ClassVar[dict[int, TemporalState]] = {} + @classmethod - def INPUT_TYPES(s): + def INPUT_TYPES(cls): return { "required": { "image": ("IMAGE",), "detection_threshold": ("FLOAT", { - "default": 0.8, - "min": 0.1, - "max": 1.0, - "step": 0.1 + "default": 0.8, "min": 0.1, "max": 1.0, "step": 0.05, }), "min_face_size": ("INT", { - "default": 64, - "min": 32, - "max": 512, - "step": 8 + "default": 64, "min": 32, "max": 512, "step": 8, }), - "padding": ("INT", { - "default": 32, - "min": 0, - "max": 256, - "step": 8 + "auto_padding_ratio": ("INT", { + "default": 35, "min": 0, "max": 100, + "tooltip": "Padding as % of detected face size", + }), + "aspect_ratio": ([["auto", "1:1", "9:16", "16:9", "4:3"]], { + "default": "auto", + }), + "output_mode": ([["largest_face", "all_faces"]], { + "default": "largest_face", + }), + "temporal_smoothing": ("INT", { + "default": 0, "min": 0, "max": 100, + "tooltip": "0=disabled (image mode) | 1-100: EMA smoothing strength", + }), + "output_height": ("INT", { + "default": 512, "min": 256, "max": 2048, + }), + "instance_id": ("INT", { + "default": 0, "min": 0, "max": 9999, + "tooltip": "Unique ID for temporal smoothing across video frames", + }), + "classifier_type": ([["default", "alternative"]], { + "default": "default", }), - "output_mode": (["largest_face", "all_faces"],), }, "optional": { - "face_output_format": (["strip", "individual"], {"default": "strip"}), - "classifier_type": (["default", "alternative"], {"default": "default"}), - } + # Backward compat: old v1.x workflows pass this + "face_output_format": (["strip", "individual"], { + "default": "strip", + "tooltip": "Format for multiple faces (only applies with all_faces). Accepts 'strip' or 'individual'.", + }), + # Backward compat: old v1.x workflows pass padding in pixels + "padding": ("INT", { + "default": 0, "min": 0, "max": 256, "step": 8, + "tooltip": "Legacy padding in pixels. If >0, overrides auto_padding_ratio.", + }), + }, } - RETURN_TYPES = ("IMAGE",) - RETURN_NAMES = ("Cropped Faces",) + RETURN_TYPES = ("IMAGE", "IMAGE") + RETURN_NAMES = ("cropped_faces", "face_metadata") FUNCTION = "detect_and_crop_faces" CATEGORY = "image/processing" - - def __init__(self): - self.default_cascade = None - self.alternative_cascade = None - - try: - # Default Haar cascade - most commonly used and well-tested - default_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml' - if os.path.exists(default_path): - self.default_cascade = cv2.CascadeClassifier(default_path) - if self.default_cascade.empty(): - logger.error(f"Failed to load cascade from {default_path}") - self.default_cascade = None - else: - logger.error(f"Default cascade file not found: {default_path}") - - # Alternative Haar cascade - different training, may detect faces missed by default - alt_path = cv2.data.haarcascades + 'haarcascade_frontalface_alt.xml' - if os.path.exists(alt_path): - self.alternative_cascade = cv2.CascadeClassifier(alt_path) - if self.alternative_cascade.empty(): - logger.warning(f"Failed to load alternative cascade from {alt_path}") - self.alternative_cascade = None - else: - logger.warning(f"Alternative cascade file not found: {alt_path}") - - except Exception as e: - logger.error(f"Error initializing cascade classifiers: {str(e)}") - self.default_cascade = None - self.alternative_cascade = None - - def add_padding(self, image: np.ndarray, face_rect: Tuple[int, int, int, int], padding: int) -> Tuple[np.ndarray, Tuple[int, int, int, int]]: - """Add padding around detected face and handle boundaries""" - x, y, w, h = face_rect - height, width = image.shape[:2] - - # Calculate padded coordinates - x1 = max(0, x - padding) - y1 = max(0, y - padding) - x2 = min(width, x + w + padding) - y2 = min(height, y + h + padding) - - return image[y1:y2, x1:x2], (x1, y1, x2-x1, y2-y1) - - def _process_individual_faces(self, cropped_faces: List[np.ndarray]) -> torch.Tensor: - """ - Process multiple faces into individual batch items with consistent dimensions. - Shared logic between v1/v2 and v3 implementations. - """ - if COMFY_V3_AVAILABLE: - # Use the static method from the v3 class - return FaceDetectionNode._process_individual_faces(cropped_faces) - else: - # Fallback implementation for v1/v2 only environments - max_height = min(512, max(face.shape[0] for face in cropped_faces)) - max_width = min(512, max(face.shape[1] for face in cropped_faces)) - - target_size = (max_width, max_height) - resized_faces = [] - for face in cropped_faces: - resized = cv2.resize(face, target_size) - resized_faces.append(resized) - - result_batch = np.stack(resized_faces, axis=0) - - if result_batch.shape[3] == 1: - result_batch = np.repeat(result_batch, 3, axis=3) - elif result_batch.shape[3] == 4: - result_batch = result_batch[:, :, :, :3] - - result = torch.from_numpy(result_batch).float() / 255.0 - assert result.shape[3] == 3, f"Output must have 3 channels, got {result.shape[3]}" - - return result - - def detect_and_crop_faces(self, image, detection_threshold, min_face_size, padding, output_mode, face_output_format="strip", classifier_type="default"): - """Legacy method for v1/v2 compatibility""" - - # Convert input to numpy array for OpenCV processing - if isinstance(image, torch.Tensor): - logger.debug(f"Processing tensor - Shape: {image.shape}, Type: {image.dtype}") - - # Ensure 4D tensor [B, H, W, C] and normalize to RGB - if len(image.shape) == 3: - image = image.unsqueeze(0) - elif len(image.shape) != 4: - raise ValueError(f"Expected 3D or 4D tensor, got shape: {image.shape}") - - B, H, W, C = image.shape - - # Handle different channel configurations - if C == 1: - image = image.repeat(1, 1, 1, 3) # Grayscale to RGB - elif C == 4: - image = image[:, :, :, :3] # RGBA to RGB - elif C > 4: - logger.warning(f"Input has {C} channels, using first 3") - image = image[:, :, :, :3] - elif C != 3: - raise ValueError(f"Cannot handle {C} channels") - - # Single conversion: tensor -> numpy (uint8) - image_np = image[0].cpu().numpy() - if image_np.max() <= 1.0: - image_np = (image_np * 255).astype(np.uint8) - else: - image_np = np.clip(image_np, 0, 255).astype(np.uint8) - - else: - # Already numpy array - image_np = image - - # Validate and ensure RGB format - if not isinstance(image_np, np.ndarray) or len(image_np.shape) != 3: - raise ValueError(f"Expected 3D numpy array, got {type(image_np)} with shape {getattr(image_np, 'shape', 'unknown')}") - - if image_np.shape[2] != 3: - raise ValueError(f"Expected RGB image (3 channels), got {image_np.shape[2]} channels") - - # Convert to grayscale for face detection - gray = cv2.cvtColor(image_np, cv2.COLOR_RGB2GRAY) - - # Select appropriate cascade based on classifier_type - if classifier_type == "alternative": - if self.alternative_cascade is None: - logger.warning("Alternative Haar cascade not available, falling back to default") - if self.default_cascade is None: - logger.error("No cascade classifiers available") - return (torch.zeros((1, 512, 512, 3)),) - face_cascade = self.default_cascade - else: - face_cascade = self.alternative_cascade - else: # default - if self.default_cascade is None: - logger.error("Default Haar cascade not available") - return (torch.zeros((1, 512, 512, 3)),) - face_cascade = self.default_cascade - - try: - faces = face_cascade.detectMultiScale( - gray, - scaleFactor=1.1, - minNeighbors=5, - minSize=(min_face_size, min_face_size) - ) - except Exception as e: - logger.error(f"Face detection failed: {str(e)}") - return (torch.zeros((1, 512, 512, 3)),) - - if len(faces) == 0: - logger.warning("No faces detected in image") - # Return empty image with correct dimensions [B, H, W, C] - return (torch.zeros((1, 512, 512, 3)),) - - cropped_faces = [] - for x, y, w, h in faces: - face_img, _ = self.add_padding(image_np, (x, y, w, h), padding) - cropped_faces.append(face_img) - - if output_mode == "largest_face": - largest_face = max(cropped_faces, key=lambda x: x.shape[0] * x.shape[1]) - cropped_faces = [largest_face] - - # Enhanced result handling with support for individual face outputs - # Note: face_output_format only applies when output_mode="all_faces" with multiple faces - if output_mode == "all_faces" and len(cropped_faces) > 1 and face_output_format == "individual": - result = self._process_individual_faces(cropped_faces) - return (result,) - - elif len(cropped_faces) > 1: - # Original strip format - resize all faces to same height while maintaining aspect ratio - max_height = min(512, max(face.shape[0] for face in cropped_faces)) - resized_faces = [] - for face in cropped_faces: - aspect_ratio = face.shape[1] / face.shape[0] - new_width = int(max_height * aspect_ratio) - resized = cv2.resize(face, (new_width, max_height)) - resized_faces.append(resized) - result = np.hstack(resized_faces) - else: - result = cropped_faces[0] - - # Ensure result has correct channel count - if result.shape[2] == 1: - result = cv2.cvtColor(result, cv2.COLOR_GRAY2RGB) - elif result.shape[2] == 4: - result = cv2.cvtColor(result, cv2.COLOR_RGBA2RGB) - - # Convert back to tensor with proper dimensions [B, H, W, C] - result = torch.from_numpy(result).float() / 255.0 - result = result.unsqueeze(0) # Add batch dimension - - # Validate output tensor (format: [B, H, W, C]) - assert result.shape[3] == 3, f"Output must have 3 channels, got {result.shape[3]}" - - return (result,) + OUTPUT_NODE = True + DESCRIPTION = "Face Detection v2 — Auto-Padding, Temporal Smoothing, Aspect Ratios, Batch Processing" @classmethod - def IS_CHANGED(s, **kwargs): - return False + def VALIDATE_INPUTS(cls, face_output_format=None, padding=None, **kwargs): + """Validate optional inputs — gracefully handle invalid legacy values.""" + if face_output_format is not None: + if face_output_format not in _VALID_FACE_OUTPUT_FORMATS: + logger.warning( + "FaceDetectionNode: invalid face_output_format='%s', " + "using '%s' instead. Valid: %s", + face_output_format, _DEFAULT_FACE_OUTPUT_FORMAT, + list(_VALID_FACE_OUTPUT_FORMATS), + ) + return True -# Export appropriate node class based on ComfyUI version -if COMFY_V3_AVAILABLE: - # v3 available, use new node - NODE_CLASS_MAPPINGS = { - "FaceDetectionNode": FaceDetectionNode - } + @classmethod + def _get_temporal(cls, instance_id: int, smoothing: int) -> TemporalState: + if instance_id not in cls._temporal_cache: + alpha = smoothing / 100.0 if smoothing > 0 else 1.0 + cls._temporal_cache[instance_id] = TemporalState(alpha=alpha) + elif smoothing == 0: + cls._temporal_cache[instance_id].reset() + return cls._temporal_cache[instance_id] + + def detect_and_crop_faces( + self, + image, + detection_threshold: float, + min_face_size: int, + auto_padding_ratio: int, + aspect_ratio: str, + output_mode: str, + temporal_smoothing: int, + output_height: int, + instance_id: int, + classifier_type: str = "default", + face_output_format: str = "strip", + padding: int = 0, + ): + cascade = CascadeCache.get(classifier_type) + if cascade is None: + logger.error("No cascade available") + zero_img = torch.zeros((1, output_height, output_height, 3)) + zero_meta = torch.zeros((1, 6)) + return (zero_img, zero_meta) + + target_ratio = ASPECT_RATIOS.get(aspect_ratio, None) + + # Resolve padding: legacy padding (px) overrides auto_padding_ratio if > 0 + if padding > 0: + pad_ratio = padding / max(min_face_size, 1) + logger.info("FaceDetectionNode: legacy padding=%dpx → ratio=%.2f", padding, pad_ratio) + else: + pad_ratio = auto_padding_ratio / 100.0 + + # Resolve face_output_format with fallback + if face_output_format not in _VALID_FACE_OUTPUT_FORMATS: + logger.warning( + "FaceDetectionNode: invalid face_output_format='%s', using '%s'", + face_output_format, _DEFAULT_FACE_OUTPUT_FORMAT, + ) + face_output_format = _DEFAULT_FACE_OUTPUT_FORMAT + + detect_all = (output_mode == "all_faces") + + temporal = self._get_temporal(instance_id, temporal_smoothing) + smoothing_active = temporal_smoothing > 0 + + # Normalize image batch to numpy + if isinstance(image, torch.Tensor): + if image.is_cuda: + image = image.cpu() + if image.max() <= 1.0: + image = image * 255.0 + image = image.round().clip(0, 255).to(torch.uint8) + else: + image = torch.from_numpy(np.asarray(image)) + if image.max() <= 1.0: + image = (image * 255).round().to(torch.uint8) + + if image.dim() == 3: + image = image.unsqueeze(0) + B, H, W, C = image.shape + + cropped_list: list[torch.Tensor] = [] + metadata_list: list[torch.Tensor] = [] + + for batch_idx in range(B): + frame_np = image[batch_idx].numpy() + + if C == 1: + frame_np = np.repeat(frame_np, 3, axis=2) + elif C == 4: + frame_np = frame_np[:, :, :3] + elif C > 4: + frame_np = frame_np[:, :, :3] + + bboxes = detect_faces( + frame_np, cascade, + min_face_size=min_face_size, + detection_threshold=detection_threshold, + auto_padding_ratio=pad_ratio, + classifier_type=classifier_type, + detect_all=detect_all, + ) + + if not bboxes: + zero_crop = torch.zeros((1, output_height, output_height, 3)) + flagged_meta = torch.tensor([[0.0, 0.0, 0.0, 0.0, 0.0, 0.0]]) + cropped_list.append(zero_crop) + metadata_list.append(flagged_meta) + continue + + # Temporal smoothing (largest face only) + smoothed_bboxes = [] + for i, raw_bbox in enumerate(bboxes): + if smoothing_active and i == 0: + sx, sy, sw, sh = temporal.update(raw_bbox) + smoothed_bboxes.append((sx, sy, sw, sh)) + else: + if i == 0: + temporal.update(raw_bbox) + smoothed_bboxes.append(raw_bbox) + + # Crop + resize each face + frame_crops = [] + for bbox in smoothed_bboxes: + x, y, w, h = bbox + cropped_np = crop_and_resize_to_batch( + frame_np, (x, y, w, h), + target_ratio=target_ratio, + output_height=output_height, + ) + frame_crops.append(cropped_np) + + # Handle face_output_format + if detect_all and len(frame_crops) > 1 and face_output_format == "individual": + for idx, crop_np in enumerate(frame_crops): + crop_t = ( + torch.from_numpy(crop_np) + .float() + .div(255.0) + .unsqueeze(0) + ) + cropped_list.append(crop_t) + bx, by, bw, bh = smoothed_bboxes[idx] + norm_meta = torch.tensor([[ + bx / max(W, 1), by / max(H, 1), + bw / max(W, 1), bh / max(H, 1), + float(detection_threshold), + 1.0, + ]]) + metadata_list.append(norm_meta) + elif detect_all and len(frame_crops) > 1 and face_output_format == "strip": + resized = [] + for crop_np in frame_crops: + if crop_np.shape[0] != output_height: + ratio = output_height / crop_np.shape[0] + new_w = int(crop_np.shape[1] * ratio) + resized.append(cv2.resize(crop_np, (new_w, output_height))) + else: + resized.append(crop_np) + strip = np.hstack(resized) + crop_t = ( + torch.from_numpy(strip) + .float() + .div(255.0) + .unsqueeze(0) + ) + cropped_list.append(crop_t) + bx, by, bw, bh = smoothed_bboxes[0] + norm_meta = torch.tensor([[ + bx / max(W, 1), by / max(H, 1), + bw / max(W, 1), bh / max(H, 1), + float(detection_threshold), + 1.0, + ]]) + metadata_list.append(norm_meta) + else: + # Single face + crop_t = ( + torch.from_numpy(frame_crops[0]) + .float() + .div(255.0) + .unsqueeze(0) + ) + cropped_list.append(crop_t) + bx, by, bw, bh = smoothed_bboxes[0] + norm_meta = torch.tensor([[ + bx / max(W, 1), by / max(H, 1), + bw / max(W, 1), bh / max(H, 1), + float(detection_threshold), + 1.0, + ]]) + metadata_list.append(norm_meta) + + result_images = torch.cat(cropped_list, dim=0) + result_meta = torch.cat(metadata_list, dim=0) + + logger.info( + "FaceDetectionNode v2 — batch=%d, ratio=%s, smoothing=%d%%, format=%s", + B, aspect_ratio, temporal_smoothing, face_output_format, + ) + return (result_images, result_meta) + + @classmethod + def IS_CHANGED(cls, **kwargs) -> float: + image = kwargs.get("image") + if image is None: + return float("inf") + try: + return float(image.hash().item() % 1e12) + except Exception: + return float("inf") + + +# ────────────────────────────────────────────────────────────────────────────── +# Export +# ────────────────────────────────────────────────────────────────────────────── +if COMFY_V3: + NODE_CLASS_MAPPINGS = {"FaceDetectionNode": FaceDetectionNode} else: - # Fall back to v1/v2 compatibility - NODE_CLASS_MAPPINGS = { - "FaceDetectionNode": FaceDetectionNodeV1 - } + NODE_CLASS_MAPPINGS = {"FaceDetectionNode": FaceDetectionNodeV1} NODE_DISPLAY_NAME_MAPPINGS = { - "FaceDetectionNode": "Face Detection and Crop" -} \ No newline at end of file + "FaceDetectionNode": "Face Detection and Crop v2" +} diff --git a/node_list.json b/node_list.json index a0514a3..8172a91 100644 --- a/node_list.json +++ b/node_list.json @@ -2,17 +2,17 @@ "custom_nodes": [ { "author": "Limbicnation", - "title": "ComfyUI Face Detection Node", + "title": "ComfyUI Face Detection Node v2", "reference": "https://github.com/Limbicnation/ComfyUI_FaceDetectionNode", "files": [ "https://github.com/Limbicnation/ComfyUI_FaceDetectionNode" ], "install_type": "git-clone", - "description": "A ComfyUI custom node for face detection and cropping using OpenCV Haar cascades, with full ComfyUI v3 schema support and backward compatibility.", + "description": "H100-optimized face detection with Auto-Padding, Temporal Smoothing, Aspect Ratio Presets, and full batch processing for LTX-Video avatar pipelines.", "nodes": [ - "Face Detection and Crop" + "Face Detection and Crop v2" ], "nodename_pattern": "FaceDetectionNode" } ] -} \ No newline at end of file +} diff --git a/pyproject.toml b/pyproject.toml index 0fc5f74..56738f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,9 @@ [project] name = "ComfyUI_FaceDetectionNode" -description = "A ComfyUI custom node for face detection and cropping using OpenCV Haar cascades, with full ComfyUI v3 schema support and backward compatibility." -version = "1.1.2" +description = "H100-optimized face detection with Auto-Padding, Temporal Smoothing, Aspect Ratio Presets, and full batch processing for LTX-Video avatar pipelines. Backward compatible with v1.x workflows." +version = "2.1.0" license = {text = "Apache-2.0"} -dependencies = ["opencv-python>=4.5.0", "torch>=1.9.0", "torchvision>=0.10.0", "numpy>=1.21.0", "Pillow>=8.0.0"] +dependencies = ["opencv-python>=4.5.0", "torch>=2.0.0", "numpy>=1.21.0"] [project.urls] Repository = "https://github.com/Limbicnation/ComfyUI_FaceDetectionNode" @@ -11,5 +11,5 @@ Repository = "https://github.com/Limbicnation/ComfyUI_FaceDetectionNode" [tool.comfy] PublisherId = "limbicnation" -DisplayName = "Face Detection Node" +DisplayName = "Face Detection and Crop v2" Icon = "https://raw.githubusercontent.com/Limbicnation/ComfyUI_FaceDetectionNode/main/icon.png" diff --git a/requirements.txt b/requirements.txt index 8766e73..db54697 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,3 @@ opencv-python>=4.5.0 -torch>=1.9.0 -torchvision>=0.10.0 +torch>=2.0.0 numpy>=1.21.0 -Pillow>=8.0.0 \ No newline at end of file