import math from pathlib import Path import cv2 import numpy as np import onnxruntime import torch from PIL import Image, ImageDraw, ImageFilter import folder_paths from comfy.utils import ProgressBar, common_upscale from .utils.downloader import download_model from .utils.image_convert import np2tensor, pil2mask, pil2tensor, tensor2mask, tensor2np, tensor2pil from .utils.mask_utils import blur_mask, expand_mask, fill_holes, invert_mask _CATEGORY = 'fnodes/face_analysis' class Occluder: def __init__(self, occluder_model_path): self.occluder_model_path = occluder_model_path self.face_occluder = self.get_face_occluder() def get_face_occluder(self): return onnxruntime.InferenceSession( self.occluder_model_path, providers=['CPUExecutionProvider'], ) def create_occlusion_mask(self, crop_vision_frame): prepare_vision_frame = cv2.resize(crop_vision_frame, self.face_occluder.get_inputs()[0].shape[1:3][::-1]) prepare_vision_frame = np.expand_dims(prepare_vision_frame, axis=0).astype(np.float32) / 255 prepare_vision_frame = prepare_vision_frame.transpose(0, 1, 2, 3) occlusion_mask = self.face_occluder.run(None, {self.face_occluder.get_inputs()[0].name: prepare_vision_frame})[0][0] occlusion_mask = occlusion_mask.transpose(0, 1, 2).clip(0, 1).astype(np.float32) occlusion_mask = cv2.resize(occlusion_mask, crop_vision_frame.shape[:2][::-1]) occlusion_mask = (cv2.GaussianBlur(occlusion_mask.clip(0, 1), (0, 0), 5).clip(0.5, 1) - 0.5) * 2 return occlusion_mask class GeneratePreciseFaceMask: @classmethod def INPUT_TYPES(cls): return { 'required': { 'input_image': ('IMAGE',), }, 'optional': { 'grow': ('INT', {'default': 0, 'min': -4096, 'max': 4096, 'step': 1}), 'grow_percent': ( 'FLOAT', {'default': 0.00, 'min': 0.00, 'max': 2.0, 'step': 0.01}, ), 'grow_tapered': ('BOOLEAN', {'default': False}), 'blur': ('INT', {'default': 0, 'min': 0, 'max': 4096, 'step': 1}), 'fill': ('BOOLEAN', {'default': False}), }, } RETURN_TYPES = ( 'MASK', 'MASK', 'IMAGE', ) RETURN_NAMES = ( 'mask', 'inverted_mask', 'image', ) FUNCTION = 'generate_mask' CATEGORY = _CATEGORY DESCRIPTION = '生成精确人脸遮罩' def _load_occluder_model(self): """加载人脸遮挡模型""" model_url = 'https://github.com/facefusion/facefusion-assets/releases/download/models-3.0.0/dfl_xseg.onnx' save_loc = Path(folder_paths.models_dir) / 'fnodes' / 'occluder' model_name = 'occluder.onnx' download_model(model_url, save_loc, model_name) return Occluder(str(save_loc / model_name)) def generate_mask(self, input_image, grow, grow_percent, grow_tapered, blur, fill): face_occluder_model = self._load_occluder_model() out_mask, out_inverted_mask, out_image = [], [], [] steps = input_image.shape[0] if steps > 1: pbar = ProgressBar(steps) for i in range(steps): mask, processed_img = self._process_single_image(input_image[i], face_occluder_model, grow, grow_percent, grow_tapered, blur, fill) out_mask.append(mask) out_inverted_mask.append(invert_mask(mask)) out_image.append(processed_img) if steps > 1: pbar.update(1) return torch.stack(out_mask).squeeze(-1), torch.stack(out_inverted_mask).squeeze(-1), torch.stack(out_image) def _process_single_image(self, img, face_occluder_model, grow, grow_percent, grow_tapered, blur, fill): """处理单张图像""" face = tensor2np(img) if face is None: print('\033[96mNo face detected\033[0m') return torch.zeros_like(img)[:, :, :1], torch.zeros_like(img) cv2_image = cv2.cvtColor(np.array(face), cv2.COLOR_RGB2BGR) occlusion_mask = face_occluder_model.create_occlusion_mask(cv2_image) if occlusion_mask is None: print('\033[96mNo landmarks detected\033[0m') return torch.zeros_like(img)[:, :, :1], torch.zeros_like(img) mask = self._process_mask(occlusion_mask, img, grow, grow_percent, grow_tapered, blur, fill) processed_img = img * mask.repeat(1, 1, 3) return mask, processed_img def _process_mask(self, occlusion_mask, img, grow, grow_percent, grow_tapered, blur, fill): """处理遮罩""" mask = np2tensor(occlusion_mask).unsqueeze(0).squeeze(-1).clamp(0, 1).to(device=img.device) grow_count = int(grow_percent * max(mask.shape)) + grow if grow_count > 0: mask = expand_mask(mask, grow_count, grow_tapered) if fill: mask = fill_holes(mask) if blur > 0: mask = blur_mask(mask, blur) return mask.squeeze(0).unsqueeze(-1) class AlignImageByFace: @classmethod def INPUT_TYPES(cls): return { 'required': { 'analysis_models': ('ANALYSIS_MODELS',), 'image_from': ('IMAGE',), 'expand': ('BOOLEAN', {'default': True}), 'simple_angle': ('BOOLEAN', {'default': False}), }, 'optional': { 'image_to': ('IMAGE',), }, } RETURN_TYPES = ('IMAGE', 'FLOAT', 'FLOAT') RETURN_NAMES = ('aligned_image', 'rotation_angle', 'inverse_rotation_angle') FUNCTION = 'align' CATEGORY = _CATEGORY DESCRIPTION = '根据图像中的人脸进行旋转对齐' def align(self, analysis_models, image_from, expand=True, simple_angle=False, image_to=None): source_image = tensor2np(image_from[0]) def find_nearest_angle(angle): angles = [-360, -270, -180, -90, 0, 90, 180, 270, 360] normalized_angle = angle % 360 return min(angles, key=lambda x: min(abs(x - normalized_angle), abs(x - normalized_angle - 360), abs(x - normalized_angle + 360))) def calculate_angle(shape): left_eye, right_eye = shape[:2] return float(np.degrees(np.arctan2(left_eye[1] - right_eye[1], left_eye[0] - right_eye[0]))) def detect_face(img, flip=False): if flip: img = Image.fromarray(img).rotate(180, expand=expand, resample=Image.Resampling.BICUBIC) img = np.array(img) face_shape = analysis_models.get_keypoints(img) return face_shape, img # 尝试检测人脸,如果失败则翻转图像再次尝试 face_shape, processed_image = detect_face(source_image) if face_shape is None: face_shape, processed_image = detect_face(source_image, flip=True) is_flipped = True if face_shape is None: raise Exception('无法在图像中检测到人脸。') else: is_flipped = False rotation_angle = calculate_angle(face_shape) if simple_angle: rotation_angle = find_nearest_angle(rotation_angle) # 如果提供了目标图像,计算相对旋转角度 if image_to is not None: target_shape = analysis_models.get_keypoints(tensor2np(image_to[0])) if target_shape is not None: print(f'目标图像人脸关键点: {target_shape}') rotation_angle -= calculate_angle(target_shape) original_image = tensor2np(image_from[0]) if not is_flipped else processed_image rows, cols = original_image.shape[:2] M = cv2.getRotationMatrix2D((cols / 2, rows / 2), rotation_angle, 1) if expand: # 计算新的边界以确保整个图像都包含在内 cos = np.abs(M[0, 0]) sin = np.abs(M[0, 1]) new_cols = int((rows * sin) + (cols * cos)) new_rows = int((rows * cos) + (cols * sin)) M[0, 2] += (new_cols / 2) - cols / 2 M[1, 2] += (new_rows / 2) - rows / 2 new_size = (new_cols, new_rows) else: new_size = (cols, rows) aligned_image = cv2.warpAffine(original_image, M, new_size, flags=cv2.INTER_LINEAR) # 转换为张量 aligned_image_tensor = np2tensor(aligned_image).unsqueeze(0) if is_flipped: rotation_angle += 180 return (aligned_image_tensor, rotation_angle, 360 - rotation_angle) class FaceCutout: @classmethod def INPUT_TYPES(cls): return { 'required': { 'analysis_models': ('ANALYSIS_MODELS',), 'image': ('IMAGE',), 'padding': ('INT', {'default': 0, 'min': 0, 'max': 4096, 'step': 1}), 'padding_percent': ('FLOAT', {'default': 0.1, 'min': 0.0, 'max': 2.0, 'step': 0.01}), 'face_index': ('INT', {'default': -1, 'min': -1, 'max': 4096, 'step': 1}), 'rescale_mode': (['sdxl', 'sd15', 'sdxl+', 'sd15+', 'none', 'custom'],), 'custom_megapixels': ('FLOAT', {'default': 1.0, 'min': 0.01, 'max': 16.0, 'step': 0.01}), }, } RETURN_TYPES = ('IMAGE', 'BOUNDINGINFO') RETURN_NAMES = ('cutout_image', 'bounding_info') FUNCTION = 'execute' CATEGORY = _CATEGORY DESCRIPTION = '切下人脸并进行缩放' def execute(self, analysis_models, image, padding, padding_percent, rescale_mode, custom_megapixels, face_index=-1): target_size = self._get_target_size(rescale_mode, custom_megapixels) img = image[0] pil_image = tensor2pil(img) faces, x_coords, y_coords, widths, heights = analysis_models.get_bbox(pil_image, padding, padding_percent) face_count = len(faces) if face_count == 0: raise Exception('未在图像中检测到人脸。') if face_index == -1: face_index = 0 face_index = min(face_index, face_count - 1) face = faces[face_index] x = x_coords[face_index] y = y_coords[face_index] w = widths[face_index] h = heights[face_index] scale_factor = 1 if target_size > 0: scale_factor = math.sqrt(target_size / (w * h)) new_width = round(w * scale_factor) new_height = round(h * scale_factor) face = self._rescale_image(face, new_width, new_height) bounding_info = { 'x': x, 'y': y, 'width': w, 'height': h, 'scale_factor': scale_factor, } return (face, bounding_info) @staticmethod def _get_target_size(rescale_mode, custom_megapixels): if rescale_mode == 'custom': return int(custom_megapixels * 1024 * 1024) size_map = {'sd15': 512 * 512, 'sd15+': 512 * 768, 'sdxl': 1024 * 1024, 'sdxl+': 1024 * 1280, 'none': -1} return size_map.get(rescale_mode, -1) @staticmethod def _rescale_image(image, width, height): samples = image.movedim(-1, 1) resized = common_upscale(samples, width, height, 'lanczos', 'disabled') return resized.movedim(1, -1) class FacePaste: @classmethod def INPUT_TYPES(cls): return { 'required': { 'destination': ('IMAGE',), 'source': ('IMAGE',), 'bounding_info': ('BOUNDINGINFO',), 'margin': ('INT', {'default': 0, 'min': 0, 'max': 4096, 'step': 1}), 'margin_percent': ('FLOAT', {'default': 0.10, 'min': 0.0, 'max': 2.0, 'step': 0.05}), 'blur_radius': ('INT', {'default': 10, 'min': 0, 'max': 4096, 'step': 1}), }, } RETURN_TYPES = ('IMAGE', 'MASK') RETURN_NAMES = ('image', 'mask') FUNCTION = 'paste' CATEGORY = _CATEGORY DESCRIPTION = '将人脸图像贴回原图' @staticmethod def create_soft_edge_mask(size, margin, blur_radius): mask = Image.new('L', size, 255) draw = ImageDraw.Draw(mask) draw.rectangle(((0, 0), size), outline='black', width=margin) return mask.filter(ImageFilter.GaussianBlur(blur_radius)) def paste(self, destination, source, bounding_info, margin, margin_percent, blur_radius): if not bounding_info: return destination, None destination = tensor2pil(destination[0]) source = tensor2pil(source[0]) if bounding_info.get('scale_factor', 1) != 1: new_size = (bounding_info['width'], bounding_info['height']) source = source.resize(new_size, resample=Image.Resampling.LANCZOS) ref_size = max(source.width, source.height) margin_border = int(ref_size * margin_percent) + margin mask = self.create_soft_edge_mask(source.size, margin_border, blur_radius) position = (bounding_info['x'], bounding_info['y']) destination.paste(source, position, mask) return pil2tensor(destination), pil2mask(mask) class ExtractBoundingBox: @classmethod def INPUT_TYPES(cls): return { 'required': { 'bounding_info': ('BOUNDINGINFO',), }, } RETURN_TYPES = ('INT', 'INT', 'INT', 'INT') RETURN_NAMES = ('x', 'y', 'width', 'height') FUNCTION = 'extract' CATEGORY = _CATEGORY DESCRIPTION = '从边界框信息中提取坐标和尺寸' def extract(self, bounding_info): return (bounding_info['x'], bounding_info['y'], bounding_info['width'], bounding_info['height']) FACE_ANALYSIS_CLASS_MAPPINGS = { 'GeneratePreciseFaceMask-': GeneratePreciseFaceMask, 'AlignImageByFace-': AlignImageByFace, 'FaceCutout-': FaceCutout, 'FacePaste-': FacePaste, 'ExtractBoundingBox-': ExtractBoundingBox, } FACE_ANALYSIS_NAME_MAPPINGS = { 'GeneratePreciseFaceMask-': 'Generate PreciseFaceMask', 'AlignImageByFace-': 'Align Image By Face', 'FaceCutout-': 'Face Cutout', 'FacePaste-': 'Face Paste', 'ExtractBoundingBox-': 'Extract Bounding Box', }