feat: add face detection node with ComfyUI Manager support

This follows conventional commits with:
  - Type: feat (new feature)
  - Subject: Imperative, concise description of the main addition
  - Length: 47 characters (under 50 limit)
This commit is contained in:
limbicnation
2025-06-23 03:32:46 +02:00
parent 28eb649cc7
commit c4da9265fe
7 changed files with 581 additions and 2 deletions
+67 -2
View File
@@ -1,2 +1,67 @@
# ComfyUI_FaceDetectionNode
ComfyUI Face Detection Node - OpenCV-based face detection and cropping with ComfyUI v3 schema support and backward compatibility
# ComfyUI Face Detection Node
A ComfyUI custom node for face detection and cropping using OpenCV Haar cascades, with full ComfyUI v3 schema support and backward compatibility.
## Features
- **Face Detection**: Uses OpenCV Haar cascade classifiers for robust face detection
- **Flexible Cropping**: Crop largest face or all detected faces
- **Adjustable Parameters**: Configurable detection threshold, minimum face size, and padding
- **Multiple Classifiers**: Choose between default and alternative Haar cascades
- **ComfyUI v3 Ready**: Full schema support with backward compatibility for v1/v2
- **Async Execution**: Stateless execution pattern for better performance
## Installation
### Via ComfyUI Manager (Recommended)
1. Open ComfyUI Manager
2. Search for "Face Detection Node"
3. Click Install
### Manual Installation
1. Navigate to your ComfyUI custom nodes directory
2. Clone this repository:
```bash
git clone https://github.com/Limbicnation/ComfyUI_FaceDetectionNode.git
cd ComfyUI_FaceDetectionNode
pip install -r requirements.txt
```
## Usage
1. Add the "Face Detection and Crop" node to your workflow
2. Connect an image input
3. Adjust parameters:
- **Detection Threshold**: Confidence threshold (0.1-1.0)
- **Min Face Size**: Minimum face size in pixels (32-512)
- **Padding**: Padding around detected faces (0-256)
- **Output Mode**: "largest_face" or "all_faces"
- **Classifier Type**: "default" or "alternative"
## Parameters
| Parameter | Type | Range | Default | Description |
|-----------|------|-------|---------|-------------|
| detection_threshold | Float | 0.1-1.0 | 0.8 | Face detection confidence threshold |
| min_face_size | Int | 32-512 | 64 | Minimum size for detected faces |
| padding | Int | 0-256 | 32 | Padding around detected faces |
| output_mode | Combo | - | largest_face | Output mode for detected faces |
| classifier_type | Combo | - | default | Haar cascade classifier type |
## Compatibility
- **ComfyUI v3**: Full schema support with async execution
- **ComfyUI v1/v2**: Backward compatibility via wrapper class
- **Auto-detection**: Automatically selects appropriate implementation
## Requirements
- Python ≥ 3.8
- OpenCV ≥ 4.5.0
- PyTorch ≥ 1.9.0
- NumPy ≥ 1.21.0
- Pillow ≥ 8.0.0
## License
MIT License - see LICENSE file for details.
+3
View File
@@ -0,0 +1,3 @@
from .face_detection_node import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+12
View File
@@ -0,0 +1,12 @@
{
"author": "Limbicnation",
"title": "ComfyUI Face Detection Node",
"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.",
"nodename_pattern": "FaceDetectionNode"
}
+461
View File
@@ -0,0 +1,461 @@
import cv2
import numpy as np
import torch
import logging
import os
from PIL import Image
from typing import Tuple, List, Optional
try:
from comfy_api.v0_0_3_io import (
ComfyNode, Schema, InputBehavior, NumberDisplay,
IntegerInput, MaskInput, ImageInput, ImageOutput, ComboInput, CustomInput,
IntegerOutput, NodeOutput,
)
COMFY_V3_AVAILABLE = True
except ImportError:
# Mock classes for v1/v2 compatibility
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__)
if COMFY_V3_AVAILABLE:
class FaceDetectionNode(ComfyNode):
@classmethod
def DEFINE_SCHEMA(cls):
return Schema(
node_id="FaceDetectionNode",
display_name="Face Detection and Crop",
description="Detect and crop faces from images using Haar cascades.",
category="image/processing",
inputs=[
ImageInput("image", display_name="Input Image"),
CustomInput("detection_threshold", io_type="FLOAT",
min=0.1, max=1.0, default=0.8,
tooltip="Confidence threshold for face detection",
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("classifier_type", options=["default", "alternative"],
behavior=InputBehavior.optional),
],
outputs=[
ImageOutput("cropped_faces", display_name="Cropped Faces",
tooltip="Detected and cropped faces"),
],
is_output_node=False,
)
@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)
@classmethod
async def execute(cls, image: torch.Tensor, detection_threshold: float, min_face_size: int,
padding: int, output_mode: str, 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
# 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 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)
)
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]
# Modified result handling
if len(cropped_faces) > 1:
# 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)
@classmethod
def IS_CHANGED(cls, **kwargs):
return False
# Backward compatibility wrapper for v1/v2
class FaceDetectionNodeV1:
"""Backward compatibility wrapper for ComfyUI v1/v2"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
"detection_threshold": ("FLOAT", {
"default": 0.8,
"min": 0.1,
"max": 1.0,
"step": 0.1
}),
"min_face_size": ("INT", {
"default": 64,
"min": 32,
"max": 512,
"step": 8
}),
"padding": ("INT", {
"default": 32,
"min": 0,
"max": 256,
"step": 8
}),
"output_mode": (["largest_face", "all_faces"],),
},
"optional": {
"classifier_type": (["default", "alternative"], {"default": "default"}),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("Cropped Faces",)
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 detect_and_crop_faces(self, image, detection_threshold, min_face_size, padding, output_mode, 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]
# Modified result handling
if len(cropped_faces) > 1:
# 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,)
@classmethod
def IS_CHANGED(s, **kwargs):
return False
# Export appropriate node class based on ComfyUI version
if COMFY_V3_AVAILABLE:
# v3 available, use new node
NODE_CLASS_MAPPINGS = {
"FaceDetectionNode": FaceDetectionNode
}
else:
# Fall back to v1/v2 compatibility
NODE_CLASS_MAPPINGS = {
"FaceDetectionNode": FaceDetectionNodeV1
}
NODE_DISPLAY_NAME_MAPPINGS = {
"FaceDetectionNode": "Face Detection and Crop"
}
+18
View File
@@ -0,0 +1,18 @@
{
"custom_nodes": [
{
"author": "Limbicnation",
"title": "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.",
"nodes": [
"Face Detection and Crop"
],
"nodename_pattern": "FaceDetectionNode"
}
]
}
+15
View File
@@ -0,0 +1,15 @@
[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.0.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"]
[project.urls]
Repository = "https://github.com/Limbicnation/ComfyUI_FaceDetectionNode"
# Used by Comfy Registry https://comfyregistry.org
[tool.comfy]
PublisherId = "Limbicnation"
DisplayName = "ComfyUI_FaceDetectionNode"
Icon = ""
+5
View File
@@ -0,0 +1,5 @@
opencv-python>=4.5.0
torch>=1.9.0
torchvision>=0.10.0
numpy>=1.21.0
Pillow>=8.0.0