From 47b3136a462d9c67baa2ce8f57d4d099e2576f28 Mon Sep 17 00:00:00 2001 From: VangengLab <145450894+VangengLab@users.noreply.github.com> Date: Thu, 31 Oct 2024 16:28:58 +0800 Subject: [PATCH] Add files via upload --- src/utils/__init__.py | 0 src/utils/animal_landmark_runner.py | 138 +++++++ src/utils/camera.py | 73 ++++ src/utils/check_windows_port.py | 18 + src/utils/crop.py | 423 ++++++++++++++++++++++ src/utils/cropper.py | 313 ++++++++++++++++ src/utils/face_analysis_diy.py | 79 ++++ src/utils/filter.py | 19 + src/utils/helper.py | 199 ++++++++++ src/utils/human_landmark_runner.py | 95 +++++ src/utils/io.py | 117 ++++++ src/utils/resources/clip_embedding_68.pkl | Bin 0 -> 71352 bytes src/utils/resources/clip_embedding_9.pkl | Bin 0 -> 10923 bytes src/utils/resources/lip_array.pkl | Bin 0 -> 658 bytes src/utils/resources/mask_template.png | Bin 0 -> 3470 bytes src/utils/retargeting_utils.py | 24 ++ src/utils/rprint.py | 16 + src/utils/timer.py | 29 ++ src/utils/video.py | 218 +++++++++++ src/utils/viz.py | 19 + 20 files changed, 1780 insertions(+) create mode 100644 src/utils/__init__.py create mode 100644 src/utils/animal_landmark_runner.py create mode 100644 src/utils/camera.py create mode 100644 src/utils/check_windows_port.py create mode 100644 src/utils/crop.py create mode 100644 src/utils/cropper.py create mode 100644 src/utils/face_analysis_diy.py create mode 100644 src/utils/filter.py create mode 100644 src/utils/helper.py create mode 100644 src/utils/human_landmark_runner.py create mode 100644 src/utils/io.py create mode 100644 src/utils/resources/clip_embedding_68.pkl create mode 100644 src/utils/resources/clip_embedding_9.pkl create mode 100644 src/utils/resources/lip_array.pkl create mode 100644 src/utils/resources/mask_template.png create mode 100644 src/utils/retargeting_utils.py create mode 100644 src/utils/rprint.py create mode 100644 src/utils/timer.py create mode 100644 src/utils/video.py create mode 100644 src/utils/viz.py diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/animal_landmark_runner.py b/src/utils/animal_landmark_runner.py new file mode 100644 index 0000000..963acbd --- /dev/null +++ b/src/utils/animal_landmark_runner.py @@ -0,0 +1,138 @@ +# coding: utf-8 + +""" +face detectoin and alignment using XPose +""" + +import os +import pickle +import torch +import numpy as np +from PIL import Image +from torchvision.ops import nms + +from .timer import Timer +from .rprint import rlog as log +from .helper import clean_state_dict + +from .dependencies.XPose import transforms as T +from .dependencies.XPose.models import build_model +from .dependencies.XPose.predefined_keypoints import * +from .dependencies.XPose.util import box_ops +from .dependencies.XPose.util.config import Config + + +class XPoseRunner(object): + def __init__(self, model_config_path, model_checkpoint_path, embeddings_cache_path=None, cpu_only=False, **kwargs): + self.device_id = kwargs.get("device_id", 0) + self.flag_use_half_precision = kwargs.get("flag_use_half_precision", True) + self.device = f"cuda:{self.device_id}" if not cpu_only else "cpu" + self.model = self.load_animal_model(model_config_path, model_checkpoint_path, self.device) + self.timer = Timer() + # Load cached embeddings if available + try: + with open(f'{embeddings_cache_path}_9.pkl', 'rb') as f: + self.ins_text_embeddings_9, self.kpt_text_embeddings_9 = pickle.load(f) + with open(f'{embeddings_cache_path}_68.pkl', 'rb') as f: + self.ins_text_embeddings_68, self.kpt_text_embeddings_68 = pickle.load(f) + print("Loaded cached embeddings from file.") + except Exception: + raise ValueError("Could not load clip embeddings from file, please check your file path.") + + def load_animal_model(self, model_config_path, model_checkpoint_path, device): + args = Config.fromfile(model_config_path) + args.device = device + model = build_model(args) + checkpoint = torch.load(model_checkpoint_path, map_location=lambda storage, loc: storage) + load_res = model.load_state_dict(clean_state_dict(checkpoint["model"]), strict=False) + model.eval() + return model + + def load_image(self, input_image): + image_pil = input_image.convert("RGB") + transform = T.Compose([ + T.RandomResize([800], max_size=1333), # NOTE: fixed size to 800 + T.ToTensor(), + T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), + ]) + image, _ = transform(image_pil, None) + return image_pil, image + + def get_unipose_output(self, image, instance_text_prompt, keypoint_text_prompt, box_threshold, IoU_threshold): + instance_list = instance_text_prompt.split(',') + + if len(keypoint_text_prompt) == 9: + # torch.Size([1, 512]) torch.Size([9, 512]) + ins_text_embeddings, kpt_text_embeddings = self.ins_text_embeddings_9, self.kpt_text_embeddings_9 + elif len(keypoint_text_prompt) ==68: + # torch.Size([1, 512]) torch.Size([68, 512]) + ins_text_embeddings, kpt_text_embeddings = self.ins_text_embeddings_68, self.kpt_text_embeddings_68 + else: + raise ValueError("Invalid number of keypoint embeddings.") + target = { + "instance_text_prompt": instance_list, + "keypoint_text_prompt": keypoint_text_prompt, + "object_embeddings_text": ins_text_embeddings.float(), + "kpts_embeddings_text": torch.cat((kpt_text_embeddings.float(), torch.zeros(100 - kpt_text_embeddings.shape[0], 512, device=self.device)), dim=0), + "kpt_vis_text": torch.cat((torch.ones(kpt_text_embeddings.shape[0], device=self.device), torch.zeros(100 - kpt_text_embeddings.shape[0], device=self.device)), dim=0) + } + + self.model = self.model.to(self.device) + image = image.to(self.device) + + with torch.no_grad(): + with torch.autocast(device_type=self.device[:4], dtype=torch.float16, enabled=self.flag_use_half_precision): + outputs = self.model(image[None], [target]) + + logits = outputs["pred_logits"].sigmoid()[0] + boxes = outputs["pred_boxes"][0] + keypoints = outputs["pred_keypoints"][0][:, :2 * len(keypoint_text_prompt)] + + logits_filt = logits.cpu().clone() + boxes_filt = boxes.cpu().clone() + keypoints_filt = keypoints.cpu().clone() + filt_mask = logits_filt.max(dim=1)[0] > box_threshold + logits_filt = logits_filt[filt_mask] + boxes_filt = boxes_filt[filt_mask] + keypoints_filt = keypoints_filt[filt_mask] + + keep_indices = nms(box_ops.box_cxcywh_to_xyxy(boxes_filt), logits_filt.max(dim=1)[0], iou_threshold=IoU_threshold) + + filtered_boxes = boxes_filt[keep_indices] + filtered_keypoints = keypoints_filt[keep_indices] + + return filtered_boxes, filtered_keypoints + + def run(self, input_image, instance_text_prompt, keypoint_text_example, box_threshold, IoU_threshold): + if keypoint_text_example in globals(): + keypoint_dict = globals()[keypoint_text_example] + elif instance_text_prompt in globals(): + keypoint_dict = globals()[instance_text_prompt] + else: + keypoint_dict = globals()["animal"] + + keypoint_text_prompt = keypoint_dict.get("keypoints") + keypoint_skeleton = keypoint_dict.get("skeleton") + + image_pil, image = self.load_image(input_image) + boxes_filt, keypoints_filt = self.get_unipose_output(image, instance_text_prompt, keypoint_text_prompt, box_threshold, IoU_threshold) + + size = image_pil.size + H, W = size[1], size[0] + keypoints_filt = keypoints_filt[0].squeeze(0) + kp = np.array(keypoints_filt.cpu()) + num_kpts = len(keypoint_text_prompt) + Z = kp[:num_kpts * 2] * np.array([W, H] * num_kpts) + Z = Z.reshape(num_kpts * 2) + x = Z[0::2] + y = Z[1::2] + return np.stack((x, y), axis=1) + + def warmup(self): + self.timer.tic() + + img_rgb = Image.fromarray(np.zeros((512, 512, 3), dtype=np.uint8)) + self.run(img_rgb, 'face', 'face', box_threshold=0.0, IoU_threshold=0.0) + + elapse = self.timer.toc() + log(f'XPoseRunner warmup time: {elapse:.3f}s') diff --git a/src/utils/camera.py b/src/utils/camera.py new file mode 100644 index 0000000..485a1f7 --- /dev/null +++ b/src/utils/camera.py @@ -0,0 +1,73 @@ +# coding: utf-8 + +""" +functions for processing and transforming 3D facial keypoints +""" + +import numpy as np +import torch +import torch.nn.functional as F + +PI = np.pi + + +def headpose_pred_to_degree(pred): + """ + pred: (bs, 66) or (bs, 1) or others + """ + if pred.ndim > 1 and pred.shape[1] == 66: + # NOTE: note that the average is modified to 97.5 + device = pred.device + idx_tensor = [idx for idx in range(0, 66)] + idx_tensor = torch.FloatTensor(idx_tensor).to(device) + pred = F.softmax(pred, dim=1) + degree = torch.sum(pred*idx_tensor, axis=1) * 3 - 97.5 + + return degree + + return pred + + +def get_rotation_matrix(pitch_, yaw_, roll_): + """ the input is in degree + """ + # transform to radian + pitch = pitch_ / 180 * PI + yaw = yaw_ / 180 * PI + roll = roll_ / 180 * PI + + device = pitch.device + + if pitch.ndim == 1: + pitch = pitch.unsqueeze(1) + if yaw.ndim == 1: + yaw = yaw.unsqueeze(1) + if roll.ndim == 1: + roll = roll.unsqueeze(1) + + # calculate the euler matrix + bs = pitch.shape[0] + ones = torch.ones([bs, 1]).to(device) + zeros = torch.zeros([bs, 1]).to(device) + x, y, z = pitch, yaw, roll + + rot_x = torch.cat([ + ones, zeros, zeros, + zeros, torch.cos(x), -torch.sin(x), + zeros, torch.sin(x), torch.cos(x) + ], dim=1).reshape([bs, 3, 3]) + + rot_y = torch.cat([ + torch.cos(y), zeros, torch.sin(y), + zeros, ones, zeros, + -torch.sin(y), zeros, torch.cos(y) + ], dim=1).reshape([bs, 3, 3]) + + rot_z = torch.cat([ + torch.cos(z), -torch.sin(z), zeros, + torch.sin(z), torch.cos(z), zeros, + zeros, zeros, ones + ], dim=1).reshape([bs, 3, 3]) + + rot = rot_z @ rot_y @ rot_x + return rot.permute(0, 2, 1) # transpose diff --git a/src/utils/check_windows_port.py b/src/utils/check_windows_port.py new file mode 100644 index 0000000..2a5f928 --- /dev/null +++ b/src/utils/check_windows_port.py @@ -0,0 +1,18 @@ +import socket +import sys + +if len(sys.argv) != 2: + print("Usage: python check_port.py ") + sys.exit(1) + +port = int(sys.argv[1]) + +sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +sock.settimeout(1) +result = sock.connect_ex(('127.0.0.1', port)) + +if result == 0: + print("LISTENING") +else: + print("NOT LISTENING") +sock.close diff --git a/src/utils/crop.py b/src/utils/crop.py new file mode 100644 index 0000000..5671e95 --- /dev/null +++ b/src/utils/crop.py @@ -0,0 +1,423 @@ +# coding: utf-8 + +""" +cropping function and the related preprocess functions for cropping +""" + +import numpy as np +import os.path as osp +from math import sin, cos, acos, degrees +import cv2; cv2.setNumThreads(0); cv2.ocl.setUseOpenCL(False) # NOTE: enforce single thread +from .rprint import rprint as print + +DTYPE = np.float32 +CV2_INTERP = cv2.INTER_LINEAR + +def make_abs_path(fn): + return osp.join(osp.dirname(osp.realpath(__file__)), fn) + +def _transform_img(img, M, dsize, flags=CV2_INTERP, borderMode=None): + """ conduct similarity or affine transformation to the image, do not do border operation! + img: + M: 2x3 matrix or 3x3 matrix + dsize: target shape (width, height) + """ + if isinstance(dsize, tuple) or isinstance(dsize, list): + _dsize = tuple(dsize) + else: + _dsize = (dsize, dsize) + + if borderMode is not None: + return cv2.warpAffine(img, M[:2, :], dsize=_dsize, flags=flags, borderMode=borderMode, borderValue=(0, 0, 0)) + else: + return cv2.warpAffine(img, M[:2, :], dsize=_dsize, flags=flags) + + +def _transform_pts(pts, M): + """ conduct similarity or affine transformation to the pts + pts: Nx2 ndarray + M: 2x3 matrix or 3x3 matrix + return: Nx2 + """ + return pts @ M[:2, :2].T + M[:2, 2] + + +def parse_pt2_from_pt101(pt101, use_lip=True): + """ + parsing the 2 points according to the 101 points, which cancels the roll + """ + # the former version use the eye center, but it is not robust, now use interpolation + pt_left_eye = np.mean(pt101[[39, 42, 45, 48]], axis=0) # left eye center + pt_right_eye = np.mean(pt101[[51, 54, 57, 60]], axis=0) # right eye center + + if use_lip: + # use lip + pt_center_eye = (pt_left_eye + pt_right_eye) / 2 + pt_center_lip = (pt101[75] + pt101[81]) / 2 + pt2 = np.stack([pt_center_eye, pt_center_lip], axis=0) + else: + pt2 = np.stack([pt_left_eye, pt_right_eye], axis=0) + return pt2 + + +def parse_pt2_from_pt106(pt106, use_lip=True): + """ + parsing the 2 points according to the 106 points, which cancels the roll + """ + pt_left_eye = np.mean(pt106[[33, 35, 40, 39]], axis=0) # left eye center + pt_right_eye = np.mean(pt106[[87, 89, 94, 93]], axis=0) # right eye center + + if use_lip: + # use lip + pt_center_eye = (pt_left_eye + pt_right_eye) / 2 + pt_center_lip = (pt106[52] + pt106[61]) / 2 + pt2 = np.stack([pt_center_eye, pt_center_lip], axis=0) + else: + pt2 = np.stack([pt_left_eye, pt_right_eye], axis=0) + return pt2 + + +def parse_pt2_from_pt203(pt203, use_lip=True): + """ + parsing the 2 points according to the 203 points, which cancels the roll + """ + pt_left_eye = np.mean(pt203[[0, 6, 12, 18]], axis=0) # left eye center + pt_right_eye = np.mean(pt203[[24, 30, 36, 42]], axis=0) # right eye center + if use_lip: + # use lip + pt_center_eye = (pt_left_eye + pt_right_eye) / 2 + pt_center_lip = (pt203[48] + pt203[66]) / 2 + pt2 = np.stack([pt_center_eye, pt_center_lip], axis=0) + else: + pt2 = np.stack([pt_left_eye, pt_right_eye], axis=0) + return pt2 + + +def parse_pt2_from_pt68(pt68, use_lip=True): + """ + parsing the 2 points according to the 68 points, which cancels the roll + """ + lm_idx = np.array([31, 37, 40, 43, 46, 49, 55], dtype=np.int32) - 1 + if use_lip: + pt5 = np.stack([ + np.mean(pt68[lm_idx[[1, 2]], :], 0), # left eye + np.mean(pt68[lm_idx[[3, 4]], :], 0), # right eye + pt68[lm_idx[0], :], # nose + pt68[lm_idx[5], :], # lip + pt68[lm_idx[6], :] # lip + ], axis=0) + + pt2 = np.stack([ + (pt5[0] + pt5[1]) / 2, + (pt5[3] + pt5[4]) / 2 + ], axis=0) + else: + pt2 = np.stack([ + np.mean(pt68[lm_idx[[1, 2]], :], 0), # left eye + np.mean(pt68[lm_idx[[3, 4]], :], 0), # right eye + ], axis=0) + + return pt2 + + +def parse_pt2_from_pt5(pt5, use_lip=True): + """ + parsing the 2 points according to the 5 points, which cancels the roll + """ + if use_lip: + pt2 = np.stack([ + (pt5[0] + pt5[1]) / 2, + (pt5[3] + pt5[4]) / 2 + ], axis=0) + else: + pt2 = np.stack([ + pt5[0], + pt5[1] + ], axis=0) + return pt2 + +def parse_pt2_from_pt9(pt9, use_lip=True): + ''' + parsing the 2 points according to the 9 points, which cancels the roll + ['right eye right', 'right eye left', 'left eye right', 'left eye left', 'nose tip', 'lip right', 'lip left', 'upper lip', 'lower lip'] + ''' + if use_lip: + pt9 = np.stack([ + (pt9[2] + pt9[3]) / 2, # left eye + (pt9[0] + pt9[1]) / 2, # right eye + pt9[4], + (pt9[5] + pt9[6] ) / 2 # lip + ], axis=0) + pt2 = np.stack([ + (pt9[0] + pt9[1]) / 2, # eye + pt9[3] # lip + ], axis=0) + else: + pt2 = np.stack([ + (pt9[2] + pt9[3]) / 2, + (pt9[0] + pt9[1]) / 2, + ], axis=0) + + return pt2 + +def parse_pt2_from_pt_x(pts, use_lip=True): + if pts.shape[0] == 101: + pt2 = parse_pt2_from_pt101(pts, use_lip=use_lip) + elif pts.shape[0] == 106: + pt2 = parse_pt2_from_pt106(pts, use_lip=use_lip) + elif pts.shape[0] == 68: + pt2 = parse_pt2_from_pt68(pts, use_lip=use_lip) + elif pts.shape[0] == 5: + pt2 = parse_pt2_from_pt5(pts, use_lip=use_lip) + elif pts.shape[0] == 203: + pt2 = parse_pt2_from_pt203(pts, use_lip=use_lip) + elif pts.shape[0] > 101: + # take the first 101 points + pt2 = parse_pt2_from_pt101(pts[:101], use_lip=use_lip) + elif pts.shape[0] == 9: + pt2 = parse_pt2_from_pt9(pts, use_lip=use_lip) + else: + raise Exception(f'Unknow shape: {pts.shape}') + + if not use_lip: + # NOTE: to compile with the latter code, need to rotate the pt2 90 degrees clockwise manually + v = pt2[1] - pt2[0] + pt2[1, 0] = pt2[0, 0] - v[1] + pt2[1, 1] = pt2[0, 1] + v[0] + + return pt2 + + +def parse_rect_from_landmark( + pts, + scale=1.5, + need_square=True, + vx_ratio=0, + vy_ratio=0, + use_deg_flag=False, + **kwargs +): + """parsing center, size, angle from 101/68/5/x landmarks + vx_ratio: the offset ratio along the pupil axis x-axis, multiplied by size + vy_ratio: the offset ratio along the pupil axis y-axis, multiplied by size, which is used to contain more forehead area + + judge with pts.shape + """ + pt2 = parse_pt2_from_pt_x(pts, use_lip=kwargs.get('use_lip', True)) + + uy = pt2[1] - pt2[0] + l = np.linalg.norm(uy) + if l <= 1e-3: + uy = np.array([0, 1], dtype=DTYPE) + else: + uy /= l + ux = np.array((uy[1], -uy[0]), dtype=DTYPE) + + # the rotation degree of the x-axis, the clockwise is positive, the counterclockwise is negative (image coordinate system) + # print(uy) + # print(ux) + angle = acos(ux[0]) + if ux[1] < 0: + angle = -angle + + # rotation matrix + M = np.array([ux, uy]) + + # calculate the size which contains the angle degree of the bbox, and the center + center0 = np.mean(pts, axis=0) + rpts = (pts - center0) @ M.T # (M @ P.T).T = P @ M.T + lt_pt = np.min(rpts, axis=0) + rb_pt = np.max(rpts, axis=0) + center1 = (lt_pt + rb_pt) / 2 + + size = rb_pt - lt_pt + if need_square: + m = max(size[0], size[1]) + size[0] = m + size[1] = m + + size *= scale # scale size + center = center0 + ux * center1[0] + uy * center1[1] # counterclockwise rotation, equivalent to M.T @ center1.T + center = center + ux * (vx_ratio * size) + uy * \ + (vy_ratio * size) # considering the offset in vx and vy direction + + if use_deg_flag: + angle = degrees(angle) + + return center, size, angle + + +def parse_bbox_from_landmark(pts, **kwargs): + center, size, angle = parse_rect_from_landmark(pts, **kwargs) + cx, cy = center + w, h = size + + # calculate the vertex positions before rotation + bbox = np.array([ + [cx-w/2, cy-h/2], # left, top + [cx+w/2, cy-h/2], + [cx+w/2, cy+h/2], # right, bottom + [cx-w/2, cy+h/2] + ], dtype=DTYPE) + + # construct rotation matrix + bbox_rot = bbox.copy() + R = np.array([ + [np.cos(angle), -np.sin(angle)], + [np.sin(angle), np.cos(angle)] + ], dtype=DTYPE) + + # calculate the relative position of each vertex from the rotation center, then rotate these positions, and finally add the coordinates of the rotation center + bbox_rot = (bbox_rot - center) @ R.T + center + + return { + 'center': center, # 2x1 + 'size': size, # scalar + 'angle': angle, # rad, counterclockwise + 'bbox': bbox, # 4x2 + 'bbox_rot': bbox_rot, # 4x2 + } + + +def crop_image_by_bbox(img, bbox, lmk=None, dsize=512, angle=None, flag_rot=False, **kwargs): + left, top, right, bot = bbox + if int(right - left) != int(bot - top): + print(f'right-left {right-left} != bot-top {bot-top}') + size = right - left + + src_center = np.array([(left + right) / 2, (top + bot) / 2], dtype=DTYPE) + tgt_center = np.array([dsize / 2, dsize / 2], dtype=DTYPE) + + s = dsize / size # scale + if flag_rot and angle is not None: + costheta, sintheta = cos(angle), sin(angle) + cx, cy = src_center[0], src_center[1] # ori center + tcx, tcy = tgt_center[0], tgt_center[1] # target center + # need to infer + M_o2c = np.array( + [[s * costheta, s * sintheta, tcx - s * (costheta * cx + sintheta * cy)], + [-s * sintheta, s * costheta, tcy - s * (-sintheta * cx + costheta * cy)]], + dtype=DTYPE + ) + else: + M_o2c = np.array( + [[s, 0, tgt_center[0] - s * src_center[0]], + [0, s, tgt_center[1] - s * src_center[1]]], + dtype=DTYPE + ) + + # if flag_rot and angle is None: + # print('angle is None, but flag_rotate is True', style="bold yellow") + + img_crop = _transform_img(img, M_o2c, dsize=dsize, borderMode=kwargs.get('borderMode', None)) + lmk_crop = _transform_pts(lmk, M_o2c) if lmk is not None else None + + M_o2c = np.vstack([M_o2c, np.array([0, 0, 1], dtype=DTYPE)]) + M_c2o = np.linalg.inv(M_o2c) + + # cv2.imwrite('crop.jpg', img_crop) + + return { + 'img_crop': img_crop, + 'lmk_crop': lmk_crop, + 'M_o2c': M_o2c, + 'M_c2o': M_c2o, + } + + +def _estimate_similar_transform_from_pts( + pts, + dsize, + scale=1.5, + vx_ratio=0, + vy_ratio=-0.1, + flag_do_rot=True, + **kwargs +): + """ calculate the affine matrix of the cropped image from sparse points, the original image to the cropped image, the inverse is the cropped image to the original image + pts: landmark, 101 or 68 points or other points, Nx2 + scale: the larger scale factor, the smaller face ratio + vx_ratio: x shift + vy_ratio: y shift, the smaller the y shift, the lower the face region + rot_flag: if it is true, conduct correction + """ + center, size, angle = parse_rect_from_landmark( + pts, scale=scale, vx_ratio=vx_ratio, vy_ratio=vy_ratio, + use_lip=kwargs.get('use_lip', True) + ) + + s = dsize / size[0] # scale + tgt_center = np.array([dsize / 2, dsize / 2], dtype=DTYPE) # center of dsize + + if flag_do_rot: + costheta, sintheta = cos(angle), sin(angle) + cx, cy = center[0], center[1] # ori center + tcx, tcy = tgt_center[0], tgt_center[1] # target center + # need to infer + M_INV = np.array( + [[s * costheta, s * sintheta, tcx - s * (costheta * cx + sintheta * cy)], + [-s * sintheta, s * costheta, tcy - s * (-sintheta * cx + costheta * cy)]], + dtype=DTYPE + ) + else: + M_INV = np.array( + [[s, 0, tgt_center[0] - s * center[0]], + [0, s, tgt_center[1] - s * center[1]]], + dtype=DTYPE + ) + + M_INV_H = np.vstack([M_INV, np.array([0, 0, 1])]) + M = np.linalg.inv(M_INV_H) + + # M_INV is from the original image to the cropped image, M is from the cropped image to the original image + return M_INV, M[:2, ...] + + +def crop_image(img, pts: np.ndarray, **kwargs): + dsize = kwargs.get('dsize', 224) + scale = kwargs.get('scale', 1.5) # 1.5 | 1.6 + vy_ratio = kwargs.get('vy_ratio', -0.1) # -0.0625 | -0.1 + + M_INV, _ = _estimate_similar_transform_from_pts( + pts, + dsize=dsize, + scale=scale, + vy_ratio=vy_ratio, + flag_do_rot=kwargs.get('flag_do_rot', True), + ) + + img_crop = _transform_img(img, M_INV, dsize) # origin to crop + pt_crop = _transform_pts(pts, M_INV) + + M_o2c = np.vstack([M_INV, np.array([0, 0, 1], dtype=DTYPE)]) + M_c2o = np.linalg.inv(M_o2c) + + ret_dct = { + 'M_o2c': M_o2c, # from the original image to the cropped image 3x3 + 'M_c2o': M_c2o, # from the cropped image to the original image 3x3 + 'img_crop': img_crop, # the cropped image + 'pt_crop': pt_crop, # the landmarks of the cropped image + } + + return ret_dct + +def average_bbox_lst(bbox_lst): + if len(bbox_lst) == 0: + return None + bbox_arr = np.array(bbox_lst) + return np.mean(bbox_arr, axis=0).tolist() + +def prepare_paste_back(mask_crop, crop_M_c2o, dsize): + """prepare mask for later image paste back + """ + mask_ori = _transform_img(mask_crop, crop_M_c2o, dsize) + mask_ori = mask_ori.astype(np.float32) / 255. + return mask_ori + +def paste_back(img_crop, M_c2o, img_ori, mask_ori): + """paste back the image + """ + dsize = (img_ori.shape[1], img_ori.shape[0]) + result = _transform_img(img_crop, M_c2o, dsize=dsize) + result = np.clip(mask_ori * result + (1 - mask_ori) * img_ori, 0, 255).astype(np.uint8) + return result diff --git a/src/utils/cropper.py b/src/utils/cropper.py new file mode 100644 index 0000000..f07bc8c --- /dev/null +++ b/src/utils/cropper.py @@ -0,0 +1,313 @@ +# coding: utf-8 + +import os.path as osp +import torch +import numpy as np +import cv2; cv2.setNumThreads(0); cv2.ocl.setUseOpenCL(False) + +from PIL import Image +from typing import List, Tuple, Union +from dataclasses import dataclass, field + +from ..config.crop_config import CropConfig +from .crop import ( + average_bbox_lst, + crop_image, + crop_image_by_bbox, + parse_bbox_from_landmark, +) +from .io import contiguous +from .rprint import rlog as log +from .face_analysis_diy import FaceAnalysisDIY +from .human_landmark_runner import LandmarkRunner as HumanLandmark + +def make_abs_path(fn): + return osp.join(osp.dirname(osp.realpath(__file__)), fn) + + +@dataclass +class Trajectory: + start: int = -1 # start frame + end: int = -1 # end frame + lmk_lst: Union[Tuple, List, np.ndarray] = field(default_factory=list) # lmk list + bbox_lst: Union[Tuple, List, np.ndarray] = field(default_factory=list) # bbox list + M_c2o_lst: Union[Tuple, List, np.ndarray] = field(default_factory=list) # M_c2o list + + frame_rgb_lst: Union[Tuple, List, np.ndarray] = field(default_factory=list) # frame list + lmk_crop_lst: Union[Tuple, List, np.ndarray] = field(default_factory=list) # lmk list + frame_rgb_crop_lst: Union[Tuple, List, np.ndarray] = field(default_factory=list) # frame crop list + + +class Cropper(object): + def __init__(self, **kwargs) -> None: + self.crop_cfg: CropConfig = kwargs.get("crop_cfg", None) + self.image_type = kwargs.get("image_type", 'human_face') + device_id = kwargs.get("device_id", 0) + flag_force_cpu = kwargs.get("flag_force_cpu", False) + if flag_force_cpu: + device = "cpu" + face_analysis_wrapper_provider = ["CPUExecutionProvider"] + else: + try: + if torch.backends.mps.is_available(): + # Shape inference currently fails with CoreMLExecutionProvider + # for the retinaface model + device = "mps" + face_analysis_wrapper_provider = ["CPUExecutionProvider"] + else: + device = "cuda" + face_analysis_wrapper_provider = ["CUDAExecutionProvider"] + except: + device = "cuda" + face_analysis_wrapper_provider = ["CUDAExecutionProvider"] + self.face_analysis_wrapper = FaceAnalysisDIY( + name="buffalo_l", + root=self.crop_cfg.insightface_root, + providers=face_analysis_wrapper_provider, + ) + self.face_analysis_wrapper.prepare(ctx_id=device_id, det_size=(512, 512), det_thresh=self.crop_cfg.det_thresh) + self.face_analysis_wrapper.warmup() + + self.human_landmark_runner = HumanLandmark( + ckpt_path=self.crop_cfg.landmark_ckpt_path, + onnx_provider=device, + device_id=device_id, + ) + self.human_landmark_runner.warmup() + + if self.image_type == "animal_face": + from .animal_landmark_runner import XPoseRunner as AnimalLandmarkRunner + self.animal_landmark_runner = AnimalLandmarkRunner( + model_config_path=self.crop_cfg.xpose_config_file_path, + model_checkpoint_path=self.crop_cfg.xpose_ckpt_path, + embeddings_cache_path=self.crop_cfg.xpose_embedding_cache_path, + flag_use_half_precision=kwargs.get("flag_use_half_precision", True), + ) + self.animal_landmark_runner.warmup() + + def update_config(self, user_args): + for k, v in user_args.items(): + if hasattr(self.crop_cfg, k): + setattr(self.crop_cfg, k, v) + + def crop_source_image(self, img_rgb_: np.ndarray, crop_cfg: CropConfig): + # crop a source image and get neccessary information + img_rgb = img_rgb_.copy() # copy it + img_bgr = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2BGR) + + if self.image_type == "human_face": + src_face = self.face_analysis_wrapper.get( + img_bgr, + flag_do_landmark_2d_106=True, + direction=crop_cfg.direction, + max_face_num=crop_cfg.max_face_num, + ) + + if len(src_face) == 0: + log("No face detected in the source image.") + return None + elif len(src_face) > 1: + log(f"More than one face detected in the image, only pick one face by rule {crop_cfg.direction}.") + + # NOTE: temporarily only pick the first face, to support multiple face in the future + src_face = src_face[0] + lmk = src_face.landmark_2d_106 # this is the 106 landmarks from insightface + else: + tmp_dct = { + 'animal_face_9': 'animal_face', + 'animal_face_68': 'face' + } + + img_rgb_pil = Image.fromarray(img_rgb) + lmk = self.animal_landmark_runner.run( + img_rgb_pil, + 'face', + tmp_dct[crop_cfg.animal_face_type], + 0, + 0 + ) + + # crop the face + ret_dct = crop_image( + img_rgb, # ndarray + lmk, # 106x2 or Nx2 + dsize=crop_cfg.dsize, + scale=crop_cfg.scale, + vx_ratio=crop_cfg.vx_ratio, + vy_ratio=crop_cfg.vy_ratio, + flag_do_rot=crop_cfg.flag_do_rot, + ) + + # update a 256x256 version for network input + ret_dct["img_crop_256x256"] = cv2.resize(ret_dct["img_crop"], (256, 256), interpolation=cv2.INTER_AREA) + if self.image_type == "human_face": + lmk = self.human_landmark_runner.run(img_rgb, lmk) + ret_dct["lmk_crop"] = lmk + ret_dct["lmk_crop_256x256"] = ret_dct["lmk_crop"] * 256 / crop_cfg.dsize + else: + # 68x2 or 9x2 + ret_dct["lmk_crop"] = lmk + + return ret_dct + + def calc_lmk_from_cropped_image(self, img_rgb_, **kwargs): + direction = kwargs.get("direction", "large-small") + src_face = self.face_analysis_wrapper.get( + contiguous(img_rgb_[..., ::-1]), # convert to BGR + flag_do_landmark_2d_106=True, + direction=direction, + ) + if len(src_face) == 0: + log("No face detected in the source image.") + return None + elif len(src_face) > 1: + log(f"More than one face detected in the image, only pick one face by rule {direction}.") + src_face = src_face[0] + lmk = src_face.landmark_2d_106 + lmk = self.human_landmark_runner.run(img_rgb_, lmk) + + return lmk + + # TODO: support skipping frame with NO FACE + def crop_source_video(self, source_rgb_lst, crop_cfg: CropConfig, **kwargs): + """Tracking based landmarks/alignment and cropping""" + trajectory = Trajectory() + direction = kwargs.get("direction", "large-small") + for idx, frame_rgb in enumerate(source_rgb_lst): + if idx == 0 or trajectory.start == -1: + src_face = self.face_analysis_wrapper.get( + contiguous(frame_rgb[..., ::-1]), + flag_do_landmark_2d_106=True, + direction=crop_cfg.direction, + max_face_num=crop_cfg.max_face_num, + ) + if len(src_face) == 0: + log(f"No face detected in the frame #{idx}") + continue + elif len(src_face) > 1: + log(f"More than one face detected in the source frame_{idx}, only pick one face by rule {direction}.") + src_face = src_face[0] + lmk = src_face.landmark_2d_106 + lmk = self.human_landmark_runner.run(frame_rgb, lmk) + trajectory.start, trajectory.end = idx, idx + else: + # TODO: add IOU check for tracking + lmk = self.human_landmark_runner.run(frame_rgb, trajectory.lmk_lst[-1]) + trajectory.end = idx + + trajectory.lmk_lst.append(lmk) + + # crop the face + ret_dct = crop_image( + frame_rgb, # ndarray + lmk, # 106x2 or Nx2 + dsize=crop_cfg.dsize, + scale=crop_cfg.scale, + vx_ratio=crop_cfg.vx_ratio, + vy_ratio=crop_cfg.vy_ratio, + flag_do_rot=crop_cfg.flag_do_rot, + ) + lmk = self.human_landmark_runner.run(frame_rgb, lmk) + ret_dct["lmk_crop"] = lmk + + # update a 256x256 version for network input + ret_dct["img_crop_256x256"] = cv2.resize(ret_dct["img_crop"], (256, 256), interpolation=cv2.INTER_AREA) + ret_dct["lmk_crop_256x256"] = ret_dct["lmk_crop"] * 256 / crop_cfg.dsize + + trajectory.frame_rgb_crop_lst.append(ret_dct["img_crop_256x256"]) + trajectory.lmk_crop_lst.append(ret_dct["lmk_crop_256x256"]) + trajectory.M_c2o_lst.append(ret_dct['M_c2o']) + + return { + "frame_crop_lst": trajectory.frame_rgb_crop_lst, + "lmk_crop_lst": trajectory.lmk_crop_lst, + "M_c2o_lst": trajectory.M_c2o_lst, + } + + def crop_driving_video(self, driving_rgb_lst, **kwargs): + """Tracking based landmarks/alignment and cropping""" + trajectory = Trajectory() + direction = kwargs.get("direction", "large-small") + for idx, frame_rgb in enumerate(driving_rgb_lst): + if idx == 0 or trajectory.start == -1: + src_face = self.face_analysis_wrapper.get( + contiguous(frame_rgb[..., ::-1]), + flag_do_landmark_2d_106=True, + direction=direction, + ) + if len(src_face) == 0: + log(f"No face detected in the frame #{idx}") + continue + elif len(src_face) > 1: + log(f"More than one face detected in the driving frame_{idx}, only pick one face by rule {direction}.") + src_face = src_face[0] + lmk = src_face.landmark_2d_106 + lmk = self.human_landmark_runner.run(frame_rgb, lmk) + trajectory.start, trajectory.end = idx, idx + else: + lmk = self.human_landmark_runner.run(frame_rgb, trajectory.lmk_lst[-1]) + trajectory.end = idx + + trajectory.lmk_lst.append(lmk) + ret_bbox = parse_bbox_from_landmark( + lmk, + scale=self.crop_cfg.scale_crop_driving_video, + vx_ratio_crop_driving_video=self.crop_cfg.vx_ratio_crop_driving_video, + vy_ratio=self.crop_cfg.vy_ratio_crop_driving_video, + )["bbox"] + bbox = [ + ret_bbox[0, 0], + ret_bbox[0, 1], + ret_bbox[2, 0], + ret_bbox[2, 1], + ] # 4, + trajectory.bbox_lst.append(bbox) # bbox + trajectory.frame_rgb_lst.append(frame_rgb) + + global_bbox = average_bbox_lst(trajectory.bbox_lst) + + for idx, (frame_rgb, lmk) in enumerate(zip(trajectory.frame_rgb_lst, trajectory.lmk_lst)): + ret_dct = crop_image_by_bbox( + frame_rgb, + global_bbox, + lmk=lmk, + dsize=kwargs.get("dsize", 512), + flag_rot=False, + borderValue=(0, 0, 0), + ) + trajectory.frame_rgb_crop_lst.append(ret_dct["img_crop"]) + trajectory.lmk_crop_lst.append(ret_dct["lmk_crop"]) + + return { + "frame_crop_lst": trajectory.frame_rgb_crop_lst, + "lmk_crop_lst": trajectory.lmk_crop_lst, + } + + + def calc_lmks_from_cropped_video(self, driving_rgb_crop_lst, **kwargs): + """Tracking based landmarks/alignment""" + trajectory = Trajectory() + direction = kwargs.get("direction", "large-small") + + for idx, frame_rgb_crop in enumerate(driving_rgb_crop_lst): + if idx == 0 or trajectory.start == -1: + src_face = self.face_analysis_wrapper.get( + contiguous(frame_rgb_crop[..., ::-1]), # convert to BGR + flag_do_landmark_2d_106=True, + direction=direction, + ) + if len(src_face) == 0: + log(f"No face detected in the frame #{idx}") + raise Exception(f"No face detected in the frame #{idx}") + elif len(src_face) > 1: + log(f"More than one face detected in the driving frame_{idx}, only pick one face by rule {direction}.") + src_face = src_face[0] + lmk = src_face.landmark_2d_106 + lmk = self.human_landmark_runner.run(frame_rgb_crop, lmk) + trajectory.start, trajectory.end = idx, idx + else: + lmk = self.human_landmark_runner.run(frame_rgb_crop, trajectory.lmk_lst[-1]) + trajectory.end = idx + + trajectory.lmk_lst.append(lmk) + return trajectory.lmk_lst diff --git a/src/utils/face_analysis_diy.py b/src/utils/face_analysis_diy.py new file mode 100644 index 0000000..359ac17 --- /dev/null +++ b/src/utils/face_analysis_diy.py @@ -0,0 +1,79 @@ +# coding: utf-8 + +""" +face detectoin and alignment using InsightFace +""" + +import numpy as np +from .rprint import rlog as log +from .dependencies.insightface.app import FaceAnalysis +from .dependencies.insightface.app.common import Face +from .timer import Timer + + +def sort_by_direction(faces, direction: str = 'large-small', face_center=None): + if len(faces) <= 0: + return faces + + if direction == 'left-right': + return sorted(faces, key=lambda face: face['bbox'][0]) + if direction == 'right-left': + return sorted(faces, key=lambda face: face['bbox'][0], reverse=True) + if direction == 'top-bottom': + return sorted(faces, key=lambda face: face['bbox'][1]) + if direction == 'bottom-top': + return sorted(faces, key=lambda face: face['bbox'][1], reverse=True) + if direction == 'small-large': + return sorted(faces, key=lambda face: (face['bbox'][2] - face['bbox'][0]) * (face['bbox'][3] - face['bbox'][1])) + if direction == 'large-small': + return sorted(faces, key=lambda face: (face['bbox'][2] - face['bbox'][0]) * (face['bbox'][3] - face['bbox'][1]), reverse=True) + if direction == 'distance-from-retarget-face': + return sorted(faces, key=lambda face: (((face['bbox'][2]+face['bbox'][0])/2-face_center[0])**2+((face['bbox'][3]+face['bbox'][1])/2-face_center[1])**2)**0.5) + return faces + + +class FaceAnalysisDIY(FaceAnalysis): + def __init__(self, name='buffalo_l', root='~/.insightface', allowed_modules=None, **kwargs): + super().__init__(name=name, root=root, allowed_modules=allowed_modules, **kwargs) + + self.timer = Timer() + + def get(self, img_bgr, **kwargs): + max_num = kwargs.get('max_face_num', 0) # the number of the detected faces, 0 means no limit + flag_do_landmark_2d_106 = kwargs.get('flag_do_landmark_2d_106', True) # whether to do 106-point detection + direction = kwargs.get('direction', 'large-small') # sorting direction + face_center = None + + bboxes, kpss = self.det_model.detect(img_bgr, max_num=max_num, metric='default') + if bboxes.shape[0] == 0: + return [] + ret = [] + for i in range(bboxes.shape[0]): + bbox = bboxes[i, 0:4] + det_score = bboxes[i, 4] + kps = None + if kpss is not None: + kps = kpss[i] + face = Face(bbox=bbox, kps=kps, det_score=det_score) + for taskname, model in self.models.items(): + if taskname == 'detection': + continue + + if (not flag_do_landmark_2d_106) and taskname == 'landmark_2d_106': + continue + + # print(f'taskname: {taskname}') + model.get(img_bgr, face) + ret.append(face) + + ret = sort_by_direction(ret, direction, face_center) + return ret + + def warmup(self): + self.timer.tic() + + img_bgr = np.zeros((512, 512, 3), dtype=np.uint8) + self.get(img_bgr) + + elapse = self.timer.toc() + log(f'FaceAnalysisDIY warmup time: {elapse:.3f}s') diff --git a/src/utils/filter.py b/src/utils/filter.py new file mode 100644 index 0000000..a7b1692 --- /dev/null +++ b/src/utils/filter.py @@ -0,0 +1,19 @@ +# coding: utf-8 + +import torch +import numpy as np +from pykalman import KalmanFilter + + +def smooth(x_d_lst, shape, device, observation_variance=3e-7, process_variance=1e-5): + x_d_lst_reshape = [x.reshape(-1) for x in x_d_lst] + x_d_stacked = np.vstack(x_d_lst_reshape) + kf = KalmanFilter( + initial_state_mean=x_d_stacked[0], + n_dim_obs=x_d_stacked.shape[1], + transition_covariance=process_variance * np.eye(x_d_stacked.shape[1]), + observation_covariance=observation_variance * np.eye(x_d_stacked.shape[1]) + ) + smoothed_state_means, _ = kf.smooth(x_d_stacked) + x_d_lst_smooth = [torch.tensor(state_mean.reshape(shape[-2:]), dtype=torch.float32, device=device) for state_mean in smoothed_state_means] + return x_d_lst_smooth diff --git a/src/utils/helper.py b/src/utils/helper.py new file mode 100644 index 0000000..cf97033 --- /dev/null +++ b/src/utils/helper.py @@ -0,0 +1,199 @@ +# coding: utf-8 + +""" +utility functions and classes to handle feature extraction and model loading +""" + +import os +import os.path as osp +import torch +from collections import OrderedDict +import numpy as np +from scipy.spatial import ConvexHull # pylint: disable=E0401,E0611 +from typing import Union +import cv2 + +from ..modules.spade_generator import SPADEDecoder +from ..modules.warping_network import WarpingNetwork +from ..modules.motion_extractor import MotionExtractor +from ..modules.appearance_feature_extractor import AppearanceFeatureExtractor +from ..modules.stitching_retargeting_network import StitchingRetargetingNetwork + + +def tensor_to_numpy(data: Union[np.ndarray, torch.Tensor]) -> np.ndarray: + """transform torch.Tensor into numpy.ndarray""" + if isinstance(data, torch.Tensor): + return data.data.cpu().numpy() + return data + +def calc_motion_multiplier( + kp_source: Union[np.ndarray, torch.Tensor], + kp_driving_initial: Union[np.ndarray, torch.Tensor] +) -> float: + """calculate motion_multiplier based on the source image and the first driving frame""" + kp_source_np = tensor_to_numpy(kp_source) + kp_driving_initial_np = tensor_to_numpy(kp_driving_initial) + + source_area = ConvexHull(kp_source_np.squeeze(0)).volume + driving_area = ConvexHull(kp_driving_initial_np.squeeze(0)).volume + motion_multiplier = np.sqrt(source_area) / np.sqrt(driving_area) + # motion_multiplier = np.cbrt(source_area) / np.cbrt(driving_area) + + return motion_multiplier + +def suffix(filename): + """a.jpg -> jpg""" + pos = filename.rfind(".") + if pos == -1: + return "" + return filename[pos + 1:] + + +def prefix(filename): + """a.jpg -> a""" + pos = filename.rfind(".") + if pos == -1: + return filename + return filename[:pos] + + +def basename(filename): + """a/b/c.jpg -> c""" + return prefix(osp.basename(filename)) + + +def remove_suffix(filepath): + """a/b/c.jpg -> a/b/c""" + return osp.join(osp.dirname(filepath), basename(filepath)) + + +def is_image(file_path): + image_extensions = ('.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp') + return file_path.lower().endswith(image_extensions) + + +def is_video(file_path): + if file_path.lower().endswith((".mp4", ".mov", ".avi", ".webm")) or osp.isdir(file_path): + return True + return False + + +def is_template(file_path): + if file_path.endswith(".pkl"): + return True + return False + + +def mkdir(d, log=False): + # return self-assined `d`, for one line code + if not osp.exists(d): + os.makedirs(d, exist_ok=True) + if log: + print(f"Make dir: {d}") + return d + + +def squeeze_tensor_to_numpy(tensor): + out = tensor.data.squeeze(0).cpu().numpy() + return out + + +def dct2device(dct: dict, device): + for key in dct: + if isinstance(dct[key], torch.Tensor): + dct[key] = dct[key].to(device) + else: + dct[key] = torch.tensor(dct[key]).to(device) + return dct + + +def concat_feat(kp_source: torch.Tensor, kp_driving: torch.Tensor) -> torch.Tensor: + """ + kp_source: (bs, k, 3) + kp_driving: (bs, k, 3) + Return: (bs, 2k*3) + """ + bs_src = kp_source.shape[0] + bs_dri = kp_driving.shape[0] + assert bs_src == bs_dri, 'batch size must be equal' + + feat = torch.cat([kp_source.view(bs_src, -1), kp_driving.view(bs_dri, -1)], dim=1) + return feat + + +def remove_ddp_dumplicate_key(state_dict): + state_dict_new = OrderedDict() + for key in state_dict.keys(): + state_dict_new[key.replace('module.', '')] = state_dict[key] + return state_dict_new + + +def load_model(ckpt_path, model_config, device, model_type): + model_params = model_config['model_params'][f'{model_type}_params'] + + if model_type == 'appearance_feature_extractor': + model = AppearanceFeatureExtractor(**model_params).to(device) + elif model_type == 'motion_extractor': + model = MotionExtractor(**model_params).to(device) + elif model_type == 'warping_module': + model = WarpingNetwork(**model_params).to(device) + elif model_type == 'spade_generator': + model = SPADEDecoder(**model_params).to(device) + elif model_type == 'stitching_retargeting_module': + # Special handling for stitching and retargeting module + config = model_config['model_params']['stitching_retargeting_module_params'] + checkpoint = torch.load(ckpt_path, map_location=lambda storage, loc: storage) + + stitcher = StitchingRetargetingNetwork(**config.get('stitching')) + stitcher.load_state_dict(remove_ddp_dumplicate_key(checkpoint['retarget_shoulder'])) + stitcher = stitcher.to(device) + stitcher.eval() + + retargetor_lip = StitchingRetargetingNetwork(**config.get('lip')) + retargetor_lip.load_state_dict(remove_ddp_dumplicate_key(checkpoint['retarget_mouth'])) + retargetor_lip = retargetor_lip.to(device) + retargetor_lip.eval() + + retargetor_eye = StitchingRetargetingNetwork(**config.get('eye')) + retargetor_eye.load_state_dict(remove_ddp_dumplicate_key(checkpoint['retarget_eye'])) + retargetor_eye = retargetor_eye.to(device) + retargetor_eye.eval() + + return { + 'stitching': stitcher, + 'lip': retargetor_lip, + 'eye': retargetor_eye + } + else: + raise ValueError(f"Unknown model type: {model_type}") + + model.load_state_dict(torch.load(ckpt_path, map_location=lambda storage, loc: storage)) + model.eval() + return model + + +def load_description(fp): + with open(fp, 'r', encoding='utf-8') as f: + content = f.read() + return content + + +def is_square_video(video_path): + video = cv2.VideoCapture(video_path) + + width = int(video.get(cv2.CAP_PROP_FRAME_WIDTH)) + height = int(video.get(cv2.CAP_PROP_FRAME_HEIGHT)) + + video.release() + # if width != height: + # gr.Info(f"Uploaded video is not square, force do crop (driving) to be True") + + return width == height + +def clean_state_dict(state_dict): + new_state_dict = OrderedDict() + for k, v in state_dict.items(): + if k[:7] == 'module.': + k = k[7:] # remove `module.` + new_state_dict[k] = v + return new_state_dict diff --git a/src/utils/human_landmark_runner.py b/src/utils/human_landmark_runner.py new file mode 100644 index 0000000..e25e5a4 --- /dev/null +++ b/src/utils/human_landmark_runner.py @@ -0,0 +1,95 @@ +# coding: utf-8 + +import os.path as osp +import cv2; cv2.setNumThreads(0); cv2.ocl.setUseOpenCL(False) +import torch +import numpy as np +import onnxruntime +from .timer import Timer +from .rprint import rlog +from .crop import crop_image, _transform_pts + + +def make_abs_path(fn): + return osp.join(osp.dirname(osp.realpath(__file__)), fn) + + +def to_ndarray(obj): + if isinstance(obj, torch.Tensor): + return obj.cpu().numpy() + elif isinstance(obj, np.ndarray): + return obj + else: + return np.array(obj) + + +class LandmarkRunner(object): + """landmark runner""" + + def __init__(self, **kwargs): + ckpt_path = kwargs.get('ckpt_path') + onnx_provider = kwargs.get('onnx_provider', 'cuda') # 默认用cuda + device_id = kwargs.get('device_id', 0) + self.dsize = kwargs.get('dsize', 224) + self.timer = Timer() + + if onnx_provider.lower() == 'cuda': + self.session = onnxruntime.InferenceSession( + ckpt_path, providers=[ + ('CUDAExecutionProvider', {'device_id': device_id}) + ] + ) + elif onnx_provider.lower() == 'mps': + self.session = onnxruntime.InferenceSession( + ckpt_path, providers=[ + 'CoreMLExecutionProvider' + ] + ) + else: + opts = onnxruntime.SessionOptions() + opts.intra_op_num_threads = 4 # 默认线程数为 4 + self.session = onnxruntime.InferenceSession( + ckpt_path, providers=['CPUExecutionProvider'], + sess_options=opts + ) + + def _run(self, inp): + out = self.session.run(None, {'input': inp}) + return out + + def run(self, img_rgb: np.ndarray, lmk=None): + if lmk is not None: + crop_dct = crop_image(img_rgb, lmk, dsize=self.dsize, scale=1.5, vy_ratio=-0.1) + img_crop_rgb = crop_dct['img_crop'] + else: + # NOTE: force resize to 224x224, NOT RECOMMEND! + img_crop_rgb = cv2.resize(img_rgb, (self.dsize, self.dsize)) + scale = max(img_rgb.shape[:2]) / self.dsize + crop_dct = { + 'M_c2o': np.array([ + [scale, 0., 0.], + [0., scale, 0.], + [0., 0., 1.], + ], dtype=np.float32), + } + + inp = (img_crop_rgb.astype(np.float32) / 255.).transpose(2, 0, 1)[None, ...] # HxWx3 (BGR) -> 1x3xHxW (RGB!) + + out_lst = self._run(inp) + out_pts = out_lst[2] + + # 2d landmarks 203 points + lmk = to_ndarray(out_pts[0]).reshape(-1, 2) * self.dsize # scale to 0-224 + lmk = _transform_pts(lmk, M=crop_dct['M_c2o']) + + return lmk + + def warmup(self): + self.timer.tic() + + dummy_image = np.zeros((1, 3, self.dsize, self.dsize), dtype=np.float32) + + _ = self._run(dummy_image) + + elapse = self.timer.toc() + rlog(f'LandmarkRunner warmup time: {elapse:.3f}s') diff --git a/src/utils/io.py b/src/utils/io.py new file mode 100644 index 0000000..28ca742 --- /dev/null +++ b/src/utils/io.py @@ -0,0 +1,117 @@ +# coding: utf-8 + +import os.path as osp +import imageio +import numpy as np +import pickle +import cv2; cv2.setNumThreads(0); cv2.ocl.setUseOpenCL(False) + +from .helper import mkdir, suffix + + +def load_image_rgb(image_path: str): + if not osp.exists(image_path): + raise FileNotFoundError(f"Image not found: {image_path}") + img = cv2.imread(image_path, cv2.IMREAD_COLOR) + return cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + + +def load_video(video_info, n_frames=-1): + reader = imageio.get_reader(video_info, "ffmpeg") + + ret = [] + for idx, frame_rgb in enumerate(reader): + if n_frames > 0 and idx >= n_frames: + break + ret.append(frame_rgb) + + reader.close() + return ret + + +def contiguous(obj): + if not obj.flags.c_contiguous: + obj = obj.copy(order="C") + return obj + + +def resize_to_limit(img: np.ndarray, max_dim=1920, division=2): + """ + ajust the size of the image so that the maximum dimension does not exceed max_dim, and the width and the height of the image are multiples of n. + :param img: the image to be processed. + :param max_dim: the maximum dimension constraint. + :param n: the number that needs to be multiples of. + :return: the adjusted image. + """ + h, w = img.shape[:2] + + # ajust the size of the image according to the maximum dimension + if max_dim > 0 and max(h, w) > max_dim: + if h > w: + new_h = max_dim + new_w = int(w * (max_dim / h)) + else: + new_w = max_dim + new_h = int(h * (max_dim / w)) + img = cv2.resize(img, (new_w, new_h)) + + # ensure that the image dimensions are multiples of n + division = max(division, 1) + new_h = img.shape[0] - (img.shape[0] % division) + new_w = img.shape[1] - (img.shape[1] % division) + + if new_h == 0 or new_w == 0: + # when the width or height is less than n, no need to process + return img + + if new_h != img.shape[0] or new_w != img.shape[1]: + img = img[:new_h, :new_w] + + return img + + +def load_img_online(obj, mode="bgr", **kwargs): + max_dim = kwargs.get("max_dim", 1920) + n = kwargs.get("n", 2) + if isinstance(obj, str): + if mode.lower() == "gray": + img = cv2.imread(obj, cv2.IMREAD_GRAYSCALE) + else: + img = cv2.imread(obj, cv2.IMREAD_COLOR) + else: + img = obj + + # Resize image to satisfy constraints + img = resize_to_limit(img, max_dim=max_dim, division=n) + + if mode.lower() == "bgr": + return contiguous(img) + elif mode.lower() == "rgb": + return contiguous(img[..., ::-1]) + else: + raise Exception(f"Unknown mode {mode}") + + +def load(fp): + suffix_ = suffix(fp) + + if suffix_ == "npy": + return np.load(fp) + elif suffix_ == "pkl": + return pickle.load(open(fp, "rb")) + else: + raise Exception(f"Unknown type: {suffix}") + + +def dump(wfp, obj): + wd = osp.split(wfp)[0] + if wd != "" and not osp.exists(wd): + mkdir(wd) + + _suffix = suffix(wfp) + if _suffix == "npy": + np.save(wfp, obj) + elif _suffix == "pkl": + pickle.dump(obj, open(wfp, "wb")) + else: + raise Exception("Unknown type: {}".format(_suffix)) diff --git a/src/utils/resources/clip_embedding_68.pkl b/src/utils/resources/clip_embedding_68.pkl new file mode 100644 index 0000000000000000000000000000000000000000..d69ca7f8481d3d87ee079bfb51f96d98b68961cc GIT binary patch literal 71352 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 literal 0 HcmV?d00001 diff --git a/src/utils/resources/lip_array.pkl b/src/utils/resources/lip_array.pkl new file mode 100644 index 0000000000000000000000000000000000000000..61fe803f2326624d775674a72e1318f57577d5b1 GIT binary patch literal 658 zcmV;D0&V?(1eJ#Z00000004{?Zgp*Nc`jpba%C=Ub!>ELVRCX|d6bL{Uvgz*Z*FsR za&=>Ll#`T<1#We1aCwxB2X16xa&lpLl#`T80ELu80b-Pglv0!^O94v(OBG84hm>do zj0I$Ld2nTvla!1CW;m3Ih=-I?lqgFBi~&59PEJlr|NsC0O8@`=|4RULlwyfO_yGU_ zXJl&hWycu5&$adRjlVBHN<&#J$OQ$zJKqEUQCBNJQdDi9q^v$aZJ}h~E4%7G%QLVC z^HddL8 zdp+GgTJL>|*eL@){4LEY{no#};&edbT`SbSE<+Plb=TcKem=6taHi$H1T}y?tvJpX<^-HH{ydL-40tfQCCMX>u76PqgPs5g@DGH&8W%U zXvVgKB-?gFLA9kP6yHliAbB5Isc9IW=`>NRN0Xx4U?E=Euc-Z+$kle_-5` z2&$E=c>Na>2knpj&47A>K5O<|33JBskZ8~i;lNq zQ+YohxtG6hYD@giu!kPM6||kH-rB5OgunmkL~yT+uV4xbo0^7b1o8X*NzX175tnCX zl10|>0S?D8*_=I=ZA04IU9QeHiNziTrqPDbtI)cop~2EO@fWPNu|rxi6C9snqL%v`K-#f1Pj0@icF}qvkQSH zkB*lgDZntjULG5xJcZYLCH1lt?qL|OcPYAa!twF0&Ea%T1OmS2FAiEqH(+BcIM-}8 zdzbi~j}~Hg^ll;?CPE=`rpP)vfXfv1_o%(R7~5QpR1~-gf>7p{Rfvs&y-QJhW)Rza z6%k0@##7HC>joP8dk8?e4g636Ym!yH#brE%SQY~7w M)pb?FmDg_n1%!;u`2YX_ literal 0 HcmV?d00001 diff --git a/src/utils/retargeting_utils.py b/src/utils/retargeting_utils.py new file mode 100644 index 0000000..cbe9d1c --- /dev/null +++ b/src/utils/retargeting_utils.py @@ -0,0 +1,24 @@ + +""" +Functions to compute distance ratios between specific pairs of facial landmarks +""" + +import numpy as np + + +def calculate_distance_ratio(lmk: np.ndarray, idx1: int, idx2: int, idx3: int, idx4: int, eps: float = 1e-6) -> np.ndarray: + return (np.linalg.norm(lmk[:, idx1] - lmk[:, idx2], axis=1, keepdims=True) / + (np.linalg.norm(lmk[:, idx3] - lmk[:, idx4], axis=1, keepdims=True) + eps)) + + +def calc_eye_close_ratio(lmk: np.ndarray, target_eye_ratio: np.ndarray = None) -> np.ndarray: + lefteye_close_ratio = calculate_distance_ratio(lmk, 6, 18, 0, 12) + righteye_close_ratio = calculate_distance_ratio(lmk, 30, 42, 24, 36) + if target_eye_ratio is not None: + return np.concatenate([lefteye_close_ratio, righteye_close_ratio, target_eye_ratio], axis=1) + else: + return np.concatenate([lefteye_close_ratio, righteye_close_ratio], axis=1) + + +def calc_lip_close_ratio(lmk: np.ndarray) -> np.ndarray: + return calculate_distance_ratio(lmk, 90, 102, 48, 66) diff --git a/src/utils/rprint.py b/src/utils/rprint.py new file mode 100644 index 0000000..18614e8 --- /dev/null +++ b/src/utils/rprint.py @@ -0,0 +1,16 @@ +# coding: utf-8 + +""" +custom print and log functions +""" + +__all__ = ['rprint', 'rlog'] + +try: + from rich.console import Console + console = Console() + rprint = console.print + rlog = console.log +except: + rprint = print + rlog = print diff --git a/src/utils/timer.py b/src/utils/timer.py new file mode 100644 index 0000000..792af24 --- /dev/null +++ b/src/utils/timer.py @@ -0,0 +1,29 @@ +# coding: utf-8 + +""" +tools to measure elapsed time +""" + +import time + +class Timer(object): + """A simple timer.""" + + def __init__(self): + self.total_time = 0. + self.calls = 0 + self.start_time = 0. + self.diff = 0. + + def tic(self): + # using time.time instead of time.clock because time time.clock + # does not normalize for multithreading + self.start_time = time.time() + + def toc(self, average=True): + self.diff = time.time() - self.start_time + return self.diff + + def clear(self): + self.start_time = 0. + self.diff = 0. diff --git a/src/utils/video.py b/src/utils/video.py new file mode 100644 index 0000000..9331c90 --- /dev/null +++ b/src/utils/video.py @@ -0,0 +1,218 @@ +# coding: utf-8 + +""" +Functions for processing video + +ATTENTION: you need to install ffmpeg and ffprobe in your env! +""" + +import os.path as osp +import numpy as np +import subprocess +import imageio +import cv2 +from rich.progress import track + +from .rprint import rlog as log +from .rprint import rprint as print +from .helper import prefix + + +def exec_cmd(cmd): + return subprocess.run(cmd, shell=True, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + + +def images2video(images, wfp, **kwargs): + fps = kwargs.get('fps', 30) + video_format = kwargs.get('format', 'mp4') # default is mp4 format + codec = kwargs.get('codec', 'libx264') # default is libx264 encoding + quality = kwargs.get('quality') # video quality + pixelformat = kwargs.get('pixelformat', 'yuv420p') # video pixel format + image_mode = kwargs.get('image_mode', 'rgb') + macro_block_size = kwargs.get('macro_block_size', 2) + ffmpeg_params = ['-crf', str(kwargs.get('crf', 18))] + + writer = imageio.get_writer( + wfp, fps=fps, format=video_format, + codec=codec, quality=quality, ffmpeg_params=ffmpeg_params, pixelformat=pixelformat, macro_block_size=macro_block_size + ) + + n = len(images) + for i in track(range(n), description='Writing', transient=True): + if image_mode.lower() == 'bgr': + writer.append_data(images[i][..., ::-1]) + else: + writer.append_data(images[i]) + + writer.close() + + +def video2gif(video_fp, fps=30, size=256): + if osp.exists(video_fp): + d = osp.split(video_fp)[0] + fn = prefix(osp.basename(video_fp)) + palette_wfp = osp.join(d, 'palette.png') + gif_wfp = osp.join(d, f'{fn}.gif') + # generate the palette + cmd = f'ffmpeg -i "{video_fp}" -vf "fps={fps},scale={size}:-1:flags=lanczos,palettegen" "{palette_wfp}" -y' + exec_cmd(cmd) + # use the palette to generate the gif + cmd = f'ffmpeg -i "{video_fp}" -i "{palette_wfp}" -filter_complex "fps={fps},scale={size}:-1:flags=lanczos[x];[x][1:v]paletteuse" "{gif_wfp}" -y' + exec_cmd(cmd) + return gif_wfp + else: + raise FileNotFoundError(f"video_fp: {video_fp} not exists!") + + +def merge_audio_video(video_fp, audio_fp, wfp): + if osp.exists(video_fp) and osp.exists(audio_fp): + cmd = f'ffmpeg -i "{video_fp}" -i "{audio_fp}" -c:v copy -c:a aac "{wfp}" -y' + exec_cmd(cmd) + print(f'merge {video_fp} and {audio_fp} to {wfp}') + else: + print(f'video_fp: {video_fp} or audio_fp: {audio_fp} not exists!') + + +def blend(img: np.ndarray, mask: np.ndarray, background_color=(255, 255, 255)): + mask_float = mask.astype(np.float32) / 255. + background_color = np.array(background_color).reshape([1, 1, 3]) + bg = np.ones_like(img) * background_color + img = np.clip(mask_float * img + (1 - mask_float) * bg, 0, 255).astype(np.uint8) + return img + + +def concat_frames(driving_image_lst, source_image_lst, I_p_lst): + # TODO: add more concat style, e.g., left-down corner driving + out_lst = [] + h, w, _ = I_p_lst[0].shape + source_image_resized_lst = [cv2.resize(img, (w, h)) for img in source_image_lst] + + for idx, _ in track(enumerate(I_p_lst), total=len(I_p_lst), description='Concatenating result...'): + I_p = I_p_lst[idx] + source_image_resized = source_image_resized_lst[idx] if len(source_image_lst) > 1 else source_image_resized_lst[0] + + if driving_image_lst is None: + out = np.hstack((source_image_resized, I_p)) + else: + driving_image = driving_image_lst[idx] + driving_image_resized = cv2.resize(driving_image, (w, h)) + out = np.hstack((driving_image_resized, source_image_resized, I_p)) + + out_lst.append(out) + return out_lst + + +class VideoWriter: + def __init__(self, **kwargs): + self.fps = kwargs.get('fps', 30) + self.wfp = kwargs.get('wfp', 'video.mp4') + self.video_format = kwargs.get('format', 'mp4') + self.codec = kwargs.get('codec', 'libx264') + self.quality = kwargs.get('quality') + self.pixelformat = kwargs.get('pixelformat', 'yuv420p') + self.image_mode = kwargs.get('image_mode', 'rgb') + self.ffmpeg_params = kwargs.get('ffmpeg_params') + + self.writer = imageio.get_writer( + self.wfp, fps=self.fps, format=self.video_format, + codec=self.codec, quality=self.quality, + ffmpeg_params=self.ffmpeg_params, pixelformat=self.pixelformat + ) + + def write(self, image): + if self.image_mode.lower() == 'bgr': + self.writer.append_data(image[..., ::-1]) + else: + self.writer.append_data(image) + + def close(self): + if self.writer is not None: + self.writer.close() + + +def change_video_fps(input_file, output_file, fps=20, codec='libx264', crf=12): + cmd = f'ffmpeg -i "{input_file}" -c:v {codec} -crf {crf} -r {fps} "{output_file}" -y' + exec_cmd(cmd) + + +def get_fps(filepath, default_fps=25): + try: + fps = cv2.VideoCapture(filepath).get(cv2.CAP_PROP_FPS) + + if fps in (0, None): + fps = default_fps + except Exception as e: + log(e) + fps = default_fps + + return fps + + +def has_audio_stream(video_path: str) -> bool: + """ + Check if the video file contains an audio stream. + + :param video_path: Path to the video file + :return: True if the video contains an audio stream, False otherwise + """ + if osp.isdir(video_path): + return False + + cmd = [ + 'ffprobe', + '-v', 'error', + '-select_streams', 'a', + '-show_entries', 'stream=codec_type', + '-of', 'default=noprint_wrappers=1:nokey=1', + f'"{video_path}"' + ] + + try: + # result = subprocess.run(cmd, capture_output=True, text=True) + result = exec_cmd(' '.join(cmd)) + if result.returncode != 0: + log(f"Error occurred while probing video: {result.stderr}") + return False + + # Check if there is any output from ffprobe command + return bool(result.stdout.strip()) + except Exception as e: + log( + f"Error occurred while probing video: {video_path}, " + "you may need to install ffprobe! (https://ffmpeg.org/download.html) " + "Now set audio to false!", + style="bold red" + ) + return False + + +def add_audio_to_video(silent_video_path: str, audio_video_path: str, output_video_path: str): + cmd = [ + 'ffmpeg', + '-y', + '-i', f'"{silent_video_path}"', + '-i', f'"{audio_video_path}"', + '-map', '0:v', + '-map', '1:a', + '-c:v', 'copy', + '-shortest', + f'"{output_video_path}"' + ] + + try: + exec_cmd(' '.join(cmd)) + log(f"Video with audio generated successfully: {output_video_path}") + except subprocess.CalledProcessError as e: + log(f"Error occurred: {e}") + + +def bb_intersection_over_union(boxA, boxB): + xA = max(boxA[0], boxB[0]) + yA = max(boxA[1], boxB[1]) + xB = min(boxA[2], boxB[2]) + yB = min(boxA[3], boxB[3]) + interArea = max(0, xB - xA + 1) * max(0, yB - yA + 1) + boxAArea = (boxA[2] - boxA[0] + 1) * (boxA[3] - boxA[1] + 1) + boxBArea = (boxB[2] - boxB[0] + 1) * (boxB[3] - boxB[1] + 1) + iou = interArea / float(boxAArea + boxBArea - interArea) + return iou diff --git a/src/utils/viz.py b/src/utils/viz.py new file mode 100644 index 0000000..59b35d2 --- /dev/null +++ b/src/utils/viz.py @@ -0,0 +1,19 @@ +# coding: utf-8 + +import cv2; cv2.setNumThreads(0); cv2.ocl.setUseOpenCL(False) + + +def viz_lmk(img_, vps, **kwargs): + """可视化点""" + lineType = kwargs.get("lineType", cv2.LINE_8) # cv2.LINE_AA + img_for_viz = img_.copy() + for pt in vps: + cv2.circle( + img_for_viz, + (int(pt[0]), int(pt[1])), + radius=kwargs.get("radius", 1), + color=(0, 255, 0), + thickness=kwargs.get("thickness", 1), + lineType=lineType, + ) + return img_for_viz