Dreamspire's picture
custom_nodes
f2dbf59
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',
}