Spaces:
Runtime error
Runtime error
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: | |
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: | |
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: | |
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) | |
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) | |
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: | |
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 = '将人脸图像贴回原图' | |
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: | |
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', | |
} | |