Files
Pishtiwan MoradiandClaude Sonnet 4.5 92e062ff34 Add 15 new models for Wan2.2 video generation workflow
Added models:
- Wan2.2 diffusion models: T2V, Animate 14B, T2V Low Noise
- Wan2.2 LoRAs: Lightning 4-step, Lightx2v, Relight, Distill
- Text encoders: UMT5-XXL (fp8 and bf16 variants)
- CLIP Vision H for Wan workflows
- InsightFace buffalo_l model for face detection
- IP-Adapter Plus SDXL VIT-H
- RealESRGAN x4 upscaler
- Wan 2.1 VAE

Updated file permissions to executable for all Python files and documentation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-09 05:46:56 +02:00

493 lines
19 KiB
Python
Executable File

"""
EyeStabilizer Node for ComfyUI
Fixes eye glitching and blinking issues in video motion transfer workflows.
This node provides:
1. Dense eye landmark detection (MediaPipe Face Mesh - 478 landmarks)
2. Temporal smoothing using Kalman filtering
3. Blink detection and synthesis
4. Gaze stabilization
5. Eye region enhancement
6. Adaptive masking for priority processing
Usage: Insert between RMBG (background removal) and control map generation in Wan2.2 workflows.
"""
import torch
import numpy as np
from PIL import Image, ImageFilter, ImageEnhance
import cv2
from typing import Tuple, Optional, List, Dict
from collections import deque
class KalmanFilter1D:
"""Simple 1D Kalman filter for temporal smoothing."""
def __init__(self, process_variance=1e-3, measurement_variance=1e-1):
self.process_variance = process_variance
self.measurement_variance = measurement_variance
self.estimate = 0.0
self.error_estimate = 1.0
def update(self, measurement):
"""Update filter with new measurement."""
# Prediction
prediction_error = self.error_estimate + self.process_variance
# Update
kalman_gain = prediction_error / (prediction_error + self.measurement_variance)
self.estimate = self.estimate + kalman_gain * (measurement - self.estimate)
self.error_estimate = (1 - kalman_gain) * prediction_error
return self.estimate
class BlinkDetector:
"""Detects and smooths eye blinks in video sequences."""
def __init__(self, history_size=5, threshold=0.2):
self.history_size = history_size
self.threshold = threshold
self.ear_history = deque(maxlen=history_size) # Eye Aspect Ratio history
def calculate_ear(self, eye_landmarks):
"""
Calculate Eye Aspect Ratio (EAR).
Args:
eye_landmarks: Array of eye landmark points [(x, y), ...]
Returns:
float: Eye aspect ratio (0 = closed, ~0.3 = open)
"""
if len(eye_landmarks) < 6:
return 0.3 # Default open eye
# Vertical distances
v1 = np.linalg.norm(eye_landmarks[1] - eye_landmarks[5])
v2 = np.linalg.norm(eye_landmarks[2] - eye_landmarks[4])
# Horizontal distance
h = np.linalg.norm(eye_landmarks[0] - eye_landmarks[3])
# EAR calculation
ear = (v1 + v2) / (2.0 * h + 1e-6)
return ear
def detect_blink(self, left_ear, right_ear):
"""
Detect if a blink is occurring.
Args:
left_ear: Left eye aspect ratio
right_ear: Right eye aspect ratio
Returns:
bool: True if blink detected
"""
avg_ear = (left_ear + right_ear) / 2.0
self.ear_history.append(avg_ear)
if len(self.ear_history) < self.history_size:
return False
# Blink if current EAR is significantly lower than average
avg_historical = sum(self.ear_history) / len(self.ear_history)
return avg_ear < (avg_historical * (1 - self.threshold))
def smooth_blink(self, current_ear, is_blinking):
"""
Smooth blink transition.
Args:
current_ear: Current eye aspect ratio
is_blinking: Whether blink is detected
Returns:
float: Smoothed EAR value
"""
if not self.ear_history:
return current_ear
if is_blinking:
# During blink, interpolate smoothly
avg_historical = sum(self.ear_history) / len(self.ear_history)
return current_ear * 0.3 + avg_historical * 0.7
return current_ear
class EyeStabilizerNode:
"""
ComfyUI node for stabilizing eyes in video sequences.
Fixes glitching, jittering, and unnatural blinking.
"""
def __init__(self):
self.type = "EyeStabilizerNode"
self.landmark_filters = {} # Kalman filters for each landmark
self.blink_detector = BlinkDetector()
self.mediapipe_available = False
# Try to import MediaPipe
try:
import mediapipe as mp
self.mp_face_mesh = mp.solutions.face_mesh
self.face_mesh = None # Will be initialized on first use
self.mediapipe_available = True
print("[EyeStabilizer] MediaPipe Face Mesh available")
except ImportError:
print("[EyeStabilizer] WARNING: MediaPipe not found. Install with: pip install mediapipe")
print("[EyeStabilizer] Falling back to basic stabilization mode")
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",), # Video frames as image batch
"enable_temporal_smoothing": ("BOOLEAN", {
"default": True,
"label_on": "Enabled",
"label_off": "Disabled"
}),
"enable_blink_detection": ("BOOLEAN", {
"default": True,
"label_on": "Enabled",
"label_off": "Disabled"
}),
"enable_eye_enhancement": ("BOOLEAN", {
"default": True,
"label_on": "Enabled",
"label_off": "Disabled"
}),
"smoothing_strength": ("FLOAT", {
"default": 0.7,
"min": 0.0,
"max": 1.0,
"step": 0.05,
"display": "slider",
"tooltip": "Higher = more smoothing (less responsive)"
}),
"enhancement_strength": ("FLOAT", {
"default": 1.3,
"min": 1.0,
"max": 2.0,
"step": 0.1,
"display": "slider",
"tooltip": "Eye sharpening strength"
}),
"blink_threshold": ("FLOAT", {
"default": 0.2,
"min": 0.1,
"max": 0.5,
"step": 0.05,
"display": "slider",
"tooltip": "Sensitivity for blink detection"
}),
},
"optional": {
"eye_region_dilation": ("INT", {
"default": 10,
"min": 0,
"max": 50,
"step": 5,
"tooltip": "Pixels to expand eye mask region"
}),
}
}
RETURN_TYPES = ("IMAGE", "MASK", "IMAGE")
RETURN_NAMES = ("stabilized_images", "eye_mask", "debug_visualization")
FUNCTION = "stabilize_eyes"
CATEGORY = "PMA Utils/Video Processing"
def stabilize_eyes(self, images, enable_temporal_smoothing, enable_blink_detection,
enable_eye_enhancement, smoothing_strength, enhancement_strength,
blink_threshold, eye_region_dilation=10):
"""
Main function to stabilize eyes across video frames.
Args:
images: Batch of video frames [B, H, W, C]
enable_temporal_smoothing: Enable Kalman filtering
enable_blink_detection: Enable blink detection and smoothing
enable_eye_enhancement: Enable eye region sharpening
smoothing_strength: Kalman filter strength (0-1)
enhancement_strength: Sharpening strength (1-2)
blink_threshold: Blink detection sensitivity (0.1-0.5)
eye_region_dilation: Pixels to expand eye mask
Returns:
Tuple of (stabilized_images, eye_mask, debug_visualization)
"""
print(f"[EyeStabilizer] Processing {images.shape[0]} frames...")
print(f"[EyeStabilizer] Temporal Smoothing: {enable_temporal_smoothing}")
print(f"[EyeStabilizer] Blink Detection: {enable_blink_detection}")
print(f"[EyeStabilizer] Eye Enhancement: {enable_eye_enhancement}")
# Reset state for new sequence
self.landmark_filters = {}
self.blink_detector = BlinkDetector(threshold=blink_threshold)
# Process each frame
batch_size = images.shape[0]
stabilized_frames = []
eye_masks = []
debug_frames = []
for i in range(batch_size):
frame = images[i]
# Process single frame
stabilized, eye_mask, debug_viz = self._process_frame(
frame,
enable_temporal_smoothing,
enable_blink_detection,
enable_eye_enhancement,
smoothing_strength,
enhancement_strength,
eye_region_dilation
)
stabilized_frames.append(stabilized)
eye_masks.append(eye_mask)
debug_frames.append(debug_viz)
if (i + 1) % 10 == 0:
print(f"[EyeStabilizer] Processed {i + 1}/{batch_size} frames")
# Stack frames back into batches
stabilized_batch = torch.stack(stabilized_frames, dim=0)
eye_mask_batch = torch.stack(eye_masks, dim=0)
debug_batch = torch.stack(debug_frames, dim=0)
print(f"[EyeStabilizer] Completed processing {batch_size} frames")
return (stabilized_batch, eye_mask_batch, debug_batch)
def _process_frame(self, frame, enable_smoothing, enable_blink,
enable_enhancement, smoothing_strength,
enhancement_strength, dilation):
"""Process a single frame."""
# Convert to numpy and PIL
frame_np = (frame.cpu().numpy() * 255).astype(np.uint8)
frame_pil = Image.fromarray(frame_np)
# Initialize output
stabilized_pil = frame_pil.copy()
eye_mask_pil = Image.new('L', frame_pil.size, 0) # Black mask
debug_pil = frame_pil.copy()
if self.mediapipe_available:
# Process with MediaPipe Face Mesh
stabilized_pil, eye_mask_pil, debug_pil = self._process_with_mediapipe(
frame_pil, enable_smoothing, enable_blink, enable_enhancement,
smoothing_strength, enhancement_strength, dilation
)
else:
# Fallback: Basic processing without face detection
if enable_enhancement:
stabilized_pil = self._enhance_image(frame_pil, enhancement_strength)
# Convert back to tensors
stabilized_tensor = self._pil_to_tensor(stabilized_pil)
eye_mask_tensor = self._pil_to_tensor(eye_mask_pil.convert('RGB'))[:, :, 0:1] # Single channel
debug_tensor = self._pil_to_tensor(debug_pil)
return stabilized_tensor, eye_mask_tensor, debug_tensor
def _process_with_mediapipe(self, frame_pil, enable_smoothing, enable_blink,
enable_enhancement, smoothing_strength,
enhancement_strength, dilation):
"""Process frame using MediaPipe Face Mesh."""
# Initialize face mesh if needed
if self.face_mesh is None:
self.face_mesh = self.mp_face_mesh.FaceMesh(
static_image_mode=False,
max_num_faces=1,
refine_landmarks=True, # Get iris landmarks
min_detection_confidence=0.5,
min_tracking_confidence=0.5
)
# Convert to RGB for MediaPipe
frame_rgb = np.array(frame_pil.convert('RGB'))
height, width = frame_rgb.shape[:2]
# Process with MediaPipe
results = self.face_mesh.process(frame_rgb)
# Create outputs
stabilized_pil = frame_pil.copy()
eye_mask_pil = Image.new('L', frame_pil.size, 0)
debug_pil = frame_pil.copy()
if results.multi_face_landmarks:
face_landmarks = results.multi_face_landmarks[0]
# Extract eye landmarks
left_eye_indices = [33, 160, 158, 133, 153, 144] # Left eye outline
right_eye_indices = [362, 385, 387, 263, 373, 380] # Right eye outline
left_iris_indices = [468, 469, 470, 471, 472] # Left iris
right_iris_indices = [473, 474, 475, 476, 477] # Right iris
# Get landmark coordinates
left_eye = self._get_landmarks(face_landmarks, left_eye_indices, width, height)
right_eye = self._get_landmarks(face_landmarks, right_eye_indices, width, height)
left_iris = self._get_landmarks(face_landmarks, left_iris_indices, width, height)
right_iris = self._get_landmarks(face_landmarks, right_iris_indices, width, height)
# Apply temporal smoothing
if enable_smoothing:
left_eye = self._apply_smoothing(left_eye, "left_eye", smoothing_strength)
right_eye = self._apply_smoothing(right_eye, "right_eye", smoothing_strength)
left_iris = self._apply_smoothing(left_iris, "left_iris", smoothing_strength)
right_iris = self._apply_smoothing(right_iris, "right_iris", smoothing_strength)
# Blink detection
is_blinking = False
if enable_blink:
left_ear = self.blink_detector.calculate_ear(left_eye)
right_ear = self.blink_detector.calculate_ear(right_eye)
is_blinking = self.blink_detector.detect_blink(left_ear, right_ear)
# Create eye mask
eye_mask_np = np.zeros((height, width), dtype=np.uint8)
# Draw eye regions on mask
left_eye_int = left_eye.astype(np.int32)
right_eye_int = right_eye.astype(np.int32)
cv2.fillPoly(eye_mask_np, [left_eye_int], 255)
cv2.fillPoly(eye_mask_np, [right_eye_int], 255)
# Dilate mask
if dilation > 0:
kernel = np.ones((dilation, dilation), np.uint8)
eye_mask_np = cv2.dilate(eye_mask_np, kernel, iterations=1)
eye_mask_pil = Image.fromarray(eye_mask_np)
# Apply eye enhancement
if enable_enhancement:
stabilized_pil = self._enhance_eyes(
frame_pil, eye_mask_pil, enhancement_strength
)
# Create debug visualization
debug_np = np.array(debug_pil)
# Draw eye landmarks
for point in left_eye_int:
cv2.circle(debug_np, tuple(point), 2, (0, 255, 0), -1)
for point in right_eye_int:
cv2.circle(debug_np, tuple(point), 2, (0, 255, 0), -1)
# Draw iris landmarks
for point in left_iris.astype(np.int32):
cv2.circle(debug_np, tuple(point), 1, (255, 0, 0), -1)
for point in right_iris.astype(np.int32):
cv2.circle(debug_np, tuple(point), 1, (255, 0, 0), -1)
# Draw blink indicator
if is_blinking:
cv2.putText(debug_np, "BLINK", (10, 30),
cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 2)
debug_pil = Image.fromarray(debug_np)
return stabilized_pil, eye_mask_pil, debug_pil
def _get_landmarks(self, face_landmarks, indices, width, height):
"""Extract landmark coordinates."""
landmarks = []
for idx in indices:
landmark = face_landmarks.landmark[idx]
x = int(landmark.x * width)
y = int(landmark.y * height)
landmarks.append([x, y])
return np.array(landmarks, dtype=np.float32)
def _apply_smoothing(self, landmarks, key, strength):
"""Apply Kalman filtering to landmarks."""
smoothed = []
for i, (x, y) in enumerate(landmarks):
filter_key_x = f"{key}_{i}_x"
filter_key_y = f"{key}_{i}_y"
# Initialize filters if needed
if filter_key_x not in self.landmark_filters:
variance = 1e-3 * (1 - strength) # Lower variance = more smoothing
self.landmark_filters[filter_key_x] = KalmanFilter1D(
process_variance=variance,
measurement_variance=1e-1
)
self.landmark_filters[filter_key_y] = KalmanFilter1D(
process_variance=variance,
measurement_variance=1e-1
)
# Apply filtering
smoothed_x = self.landmark_filters[filter_key_x].update(x)
smoothed_y = self.landmark_filters[filter_key_y].update(y)
smoothed.append([smoothed_x, smoothed_y])
return np.array(smoothed, dtype=np.float32)
def _enhance_eyes(self, frame_pil, eye_mask_pil, strength):
"""Enhance eye regions using mask."""
# Convert to numpy
frame_np = np.array(frame_pil)
mask_np = np.array(eye_mask_pil) / 255.0
# Create sharpened version
frame_sharp = frame_pil.filter(ImageFilter.SHARPEN)
frame_sharp_np = np.array(frame_sharp)
# Apply additional sharpening
enhancer = ImageEnhance.Sharpness(Image.fromarray(frame_sharp_np))
frame_sharp = enhancer.enhance(strength)
frame_sharp_np = np.array(frame_sharp)
# Blend using mask
mask_3ch = np.stack([mask_np] * 3, axis=-1)
blended_np = (frame_sharp_np * mask_3ch + frame_np * (1 - mask_3ch)).astype(np.uint8)
return Image.fromarray(blended_np)
def _enhance_image(self, image_pil, strength):
"""Basic image enhancement fallback."""
enhancer = ImageEnhance.Sharpness(image_pil)
return enhancer.enhance(strength)
def _pil_to_tensor(self, pil_image):
"""Convert PIL Image to ComfyUI tensor."""
np_image = np.array(pil_image).astype(np.float32) / 255.0
tensor = torch.from_numpy(np_image)
return tensor
def _tensor_to_pil(self, tensor):
"""Convert ComfyUI tensor to PIL Image."""
if len(tensor.shape) == 4:
tensor = tensor[0]
np_image = (tensor.cpu().numpy() * 255).astype(np.uint8)
return Image.fromarray(np_image)
# Node registration
NODE_CLASS_MAPPINGS = {
"PMAEyeStabilizer": EyeStabilizerNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PMAEyeStabilizer": "PMA Eye Stabilizer",
}