Spaces:
Running on Zero
Running on Zero
| from __future__ import annotations | |
| from pathlib import Path | |
| from typing import Iterable, Mapping, Sequence | |
| import cv2 | |
| import numpy as np | |
| import os | |
| Operation = Mapping[str, object] | |
| class SimilarityMLS: | |
| def __init__(self, grid_size=50, alpha=1.0): | |
| self.grid_size = grid_size | |
| self.alpha = alpha | |
| def _calculate_weights(self, grid_pts, ctrl_pts): | |
| n_grid = grid_pts.shape[0] | |
| n_ctrl = ctrl_pts.shape[0] | |
| grid_pts_exp = np.tile(grid_pts[:, np.newaxis, :], (1, n_ctrl, 1)) | |
| ctrl_pts_exp = np.tile(ctrl_pts[np.newaxis, :, :], (n_grid, 1, 1)) | |
| d2 = np.sum((grid_pts_exp - ctrl_pts_exp) ** 2, axis=2) + 1e-8 | |
| return 1.0 / (d2 ** self.alpha) | |
| def warp(self, img, src_pts, dst_pts): | |
| h, w = img.shape[:2] | |
| grid_x = np.linspace(0, w, w // self.grid_size + 1) | |
| grid_y = np.linspace(0, h, h // self.grid_size + 1) | |
| grid_x, grid_y = np.meshgrid(grid_x, grid_y) | |
| grid_pts = np.vstack([grid_x.ravel(), grid_y.ravel()]).T | |
| n_grid = grid_pts.shape[0] | |
| weights = self._calculate_weights(grid_pts, src_pts) | |
| total_weights = np.sum(weights, axis=1, keepdims=True) | |
| p_star = (weights @ src_pts) / total_weights | |
| q_star = (weights @ dst_pts) / total_weights | |
| p_hat = src_pts[np.newaxis, :, :] - p_star[:, np.newaxis, :] | |
| q_hat = dst_pts[np.newaxis, :, :] - q_star[:, np.newaxis, :] | |
| mu = np.sum(weights[:, :, np.newaxis] * p_hat ** 2, axis=(1, 2), keepdims=True) | |
| v_hat = grid_pts - p_star | |
| p_hat_perp = np.stack([-p_hat[:, :, 1], p_hat[:, :, 0]], axis=2) | |
| vp_dot_phat = np.sum(v_hat[:, np.newaxis, :] * p_hat, axis=2) | |
| vp_dot_phat_perp = np.sum(v_hat[:, np.newaxis, :] * p_hat_perp, axis=2) | |
| T1 = vp_dot_phat[:, :, np.newaxis] * q_hat | |
| T2 = vp_dot_phat_perp[:, :, np.newaxis] * np.stack([-q_hat[:, :, 1], q_hat[:, :, 0]], axis=2) | |
| weighted_sum = np.sum(weights[:, :, np.newaxis] * (T1 + T2), axis=1) | |
| result_grid = q_star + weighted_sum / mu.squeeze(-1) | |
| map_x = cv2.resize(result_grid[:, 0].reshape(grid_x.shape).astype(np.float32), (w, h)) | |
| map_y = cv2.resize(result_grid[:, 1].reshape(grid_y.shape).astype(np.float32), (w, h)) | |
| return cv2.remap(img, map_x, map_y, interpolation=cv2.INTER_LINEAR) | |
| class FaceEditor: | |
| def __init__(self): | |
| try: | |
| import mediapipe as mp | |
| except ImportError as exc: | |
| raise ImportError("LLW face retouching requires mediapipe. Install the mirrorppr data extra or run the provided environment setup.") from exc | |
| self.mp_face_mesh = mp.solutions.face_mesh | |
| self.face_mesh = self.mp_face_mesh.FaceMesh( | |
| static_image_mode=True, | |
| max_num_faces=1, | |
| refine_landmarks=True, | |
| min_detection_confidence=0.5 | |
| ) | |
| def _get_landmarks(self, image): | |
| h, w = image.shape[:2] | |
| results = self.face_mesh.process(cv2.cvtColor(image, cv2.COLOR_BGR2RGB)) | |
| if not results.multi_face_landmarks: | |
| return None | |
| landmarks = results.multi_face_landmarks[0].landmark | |
| points = np.array([(int(l.x * w), int(l.y * h)) for l in landmarks]) | |
| return points | |
| def _create_roi_mask(self, h, w, points, regions_indices, blur_ratio): | |
| """ | |
| regions_indices: List[List[int]], 例如 [[左眼索引...], [右眼索引...]] | |
| """ | |
| mask = np.zeros((h, w), dtype=np.uint8) | |
| for indices in regions_indices: | |
| if not indices: | |
| continue | |
| roi_points = points[indices].astype(np.int32) | |
| hull = cv2.convexHull(roi_points) | |
| cv2.fillConvexPoly(mask, hull, 255) | |
| blur_k = int(min(h, w) * blur_ratio) | |
| if blur_k % 2 == 0: | |
| blur_k += 1 | |
| mask = cv2.GaussianBlur(mask, (blur_k, blur_k), 0) | |
| return mask.astype(np.float32) / 255.0 | |
| def _get_default_params(self, op_type): | |
| """ | |
| 获取操作的默认参数,方便在 process_batch 中合并 | |
| """ | |
| defaults = { | |
| 'grid_size': 30, | |
| 'alpha': 1.0, | |
| 'blur_ratio': 0.05 | |
| } | |
| return defaults | |
| def _get_operation_config(self, op_type, landmarks, strength, params): | |
| """ | |
| 根据操作类型分发配置逻辑 | |
| """ | |
| if op_type == 'eye_resize': | |
| return self._config_eye_resize(landmarks, strength, params) | |
| elif op_type == 'eye_distance': | |
| return self._config_eye_distance(landmarks, strength, params) | |
| elif op_type == 'nose_length': | |
| return self._config_nose_length(landmarks, strength, params) | |
| elif op_type == 'nose_alar': | |
| return self._config_nose_alar(landmarks, strength, params) | |
| elif op_type == 'mouth_position': | |
| return self._config_mouth_position(landmarks, strength, params) | |
| elif op_type == 'lip_thickness': | |
| return self._config_lip_thickness(landmarks, strength, params) | |
| elif op_type == 'mouth_resize': | |
| return self._config_mouth_resize(landmarks, strength, params) | |
| elif op_type == 'nose_bridge': | |
| return self._config_nose_bridge(landmarks, strength, params) | |
| else: | |
| raise ValueError(f"Unknown operation: {op_type}") | |
| def _config_nose_length(self, landmarks, strength, params): | |
| """ | |
| 鼻子变短/变长配置 | |
| Strength < 0: 变短 (Shorten) - 鼻头上移,人中变长 | |
| Strength > 0: 变长 (Lengthen) - 鼻头下移,人中变短 | |
| """ | |
| moving_indices = [ | |
| 1, 4, 19, | |
| 279, 49, | |
| 2, 98, 327, 456, 236, | |
| 94 | |
| ] | |
| anchor_idx = [ | |
| 168, 6, 197, 195, | |
| 33, 133, 362, 263, | |
| 185,40,39,37,0,267,269,270,409, | |
| 116, 123, 345, 352 | |
| ] | |
| mask_idx_nose_long = [47, 128, 142, 164, 165, 167, 168, 193, 203, 244, 277, 357, 371, 391, 393, 417, 423, 464] | |
| mask_groups = [mask_idx_nose_long] | |
| src_pts = [] | |
| dst_pts = [] | |
| nose_vec_ref = landmarks[1] - landmarks[168] | |
| nose_length = np.linalg.norm(nose_vec_ref) | |
| if strength >= 0: | |
| factor = params.get('max_nose_len_widen_ratio', 0.15) | |
| else: | |
| factor = params.get('max_nose_len_shorten_ratio', 0.15) | |
| move_dist = nose_length * (abs(strength) / 100.0) * factor | |
| vec_axis = (landmarks[1] - landmarks[168]).astype(np.float32) | |
| norm_axis = np.linalg.norm(vec_axis) | |
| if norm_axis > 0: vec_axis /= norm_axis | |
| if strength >= 0: | |
| final_vec = vec_axis * move_dist | |
| else: | |
| final_vec = -vec_axis * move_dist | |
| for idx in moving_indices: | |
| pt = landmarks[idx] | |
| src_pts.append(pt) | |
| dst_pts.append(pt + final_vec) | |
| for idx in anchor_idx: | |
| pt = landmarks[idx] | |
| src_pts.append(pt) | |
| dst_pts.append(pt) | |
| return np.array(src_pts, dtype=np.float32), np.array(dst_pts, dtype=np.float32), mask_groups | |
| def _config_nose_alar(self, landmarks, strength, params): | |
| """ | |
| 鼻翼变窄/变宽配置 | |
| Strength < 0: 变窄 (Narrow) - 鼻翼向内收 | |
| Strength > 0: 变宽 (Widen) | |
| """ | |
| left_alar_indices = [49, 102, 64, 218, 129] | |
| right_alar_indices = [279, 331, 294, 438, 358] | |
| anchor_idx = [ | |
| 1, 2, 94, 19, | |
| 168, 6, 197, 195, 4, | |
| 0, 37, 267, | |
| 205, 50, 123, 116, | |
| 425, 280, 352, 345 | |
| ] | |
| mask_idx_alar = [ | |
| 195, 4, | |
| 279, 425, 331, 294, 327, | |
| 2, 94, | |
| 98, 64, 102, 205, 49 | |
| ] | |
| mask_groups = [mask_idx_alar] | |
| src_pts = [] | |
| dst_pts = [] | |
| alar_width = np.linalg.norm(landmarks[331] - landmarks[102]) | |
| if strength >= 0: | |
| factor = params.get('max_alar_widen_ratio', 0.25) | |
| scale = 1.0 + (strength / 100.0) * factor | |
| else: | |
| factor = params.get('max_alar_narrow_ratio', 0.25) | |
| scale = 1.0 - (abs(strength) / 100.0) * factor | |
| vec_center_line = (landmarks[2] - landmarks[168]).astype(np.float32) | |
| norm_cl = np.linalg.norm(vec_center_line) | |
| if norm_cl > 0: vec_center_line /= norm_cl | |
| def get_projection_point(pt, line_start, line_vec): | |
| vec_ap = (pt - line_start).astype(np.float32) | |
| proj_len = np.dot(vec_ap, line_vec) | |
| return line_start + line_vec * proj_len | |
| all_moving_indices = left_alar_indices + right_alar_indices | |
| for idx in all_moving_indices: | |
| pt = landmarks[idx] | |
| src_pts.append(pt) | |
| proj_pt = get_projection_point(pt, landmarks[168], vec_center_line) | |
| vec_radial = pt - proj_pt | |
| dst_pts.append(proj_pt + vec_radial * scale) | |
| for idx in anchor_idx: | |
| pt = landmarks[idx] | |
| src_pts.append(pt) | |
| dst_pts.append(pt) | |
| return np.array(src_pts, dtype=np.float32), np.array(dst_pts, dtype=np.float32), mask_groups | |
| def _config_eye_resize(self, landmarks, strength, params): | |
| """ | |
| 眼睛放大/缩小的具体配置 | |
| """ | |
| left_eye_idx = [249, 263, 362, 373, 374, 380, 381, 382, 384, 385, 386, 387, 388, 390, 398, 466] | |
| right_eye_idx = [7, 33, 133, 144, 145, 153, 154, 155, 157, 158, 159, 160, 161, 163, 173, 246] | |
| anchor_idx = [1, 168, 197, 6, 195, 4, 226, 446, 152, 10, 50, 280] | |
| mask_idx_right =[22, 23, 24, 25, 26, 27, 28, 29, 30, 56, 110, 112, 130, 190, 243, 247] | |
| mask_idx_left = [252, 253, 254, 255, 256, 257, 258, 259, 260, 286, 339, 341, 359, 414, 463, 467] | |
| mask_groups = [mask_idx_right, mask_idx_left] | |
| src_pts = [] | |
| dst_pts = [] | |
| if strength >= 0: | |
| factor = params.get('max_enlarge', 0.25) | |
| scale = 1.0 + (strength / 100.0) * factor | |
| else: | |
| factor = params.get('max_shrink', 0.20) | |
| scale = 1.0 + (strength / 100.0) * factor | |
| def process_region(indices): | |
| pts = landmarks[indices] | |
| center = np.mean(pts, axis=0) | |
| for pt in pts: | |
| src_pts.append(pt) | |
| vec = pt - center | |
| dst_pts.append(center + vec * scale) | |
| process_region(left_eye_idx) | |
| process_region(right_eye_idx) | |
| for idx in anchor_idx: | |
| pt = landmarks[idx] | |
| src_pts.append(pt) | |
| dst_pts.append(pt) | |
| return np.array(src_pts, dtype=np.float32), np.array(dst_pts, dtype=np.float32), mask_groups | |
| def _config_eye_distance(self, landmarks, strength, params): | |
| """ | |
| 眼距调整 (修正版:只平移,不缩放) | |
| Strength > 0: 眼距变宽 | |
| Strength < 0: 眼距变窄 | |
| """ | |
| right_moving_idx = [22, 23, 24, 25, 26, 27, 28, 29, 30, 56, 110, 112, 130, 190, 243, 247] | |
| left_moving_idx = [252, 253, 254, 255, 256, 257, 258, 259, 260, 286, 339, 341, 359, 414, 463, 467] | |
| anchor_idx = [ | |
| 1, 2, 98, 327, | |
| 10, 152, | |
| 234, 454, | |
| 13, 14, 78, 308 | |
| ] | |
| mask_idx_right = [31, 113, 189, 221, 222, 223, 224, 225, 226, 228, 229, 230, 231, 232, 233, 244] | |
| mask_idx_left=[261, 342, 413, 441, 442, 443, 444, 445, 446, 448, 449, 450, 451, 452, 453, 464] | |
| mask_groups = [mask_idx_right, mask_idx_left] | |
| src_pts = [] | |
| dst_pts = [] | |
| center_idx = 168 | |
| center_pt = landmarks[center_idx] | |
| if strength >= 0: | |
| factor = params.get('max_dist_widen', 0.15) | |
| scale = 1.0 + (strength / 100.0) * factor | |
| else: | |
| factor = params.get('max_dist_narrow', 0.15) | |
| scale = 1.0 - (abs(strength) / 100.0) * factor | |
| r_pts = landmarks[right_moving_idx] | |
| r_centroid = np.mean(r_pts, axis=0) | |
| r_vec = r_centroid - center_pt | |
| r_centroid_new = center_pt + r_vec * scale | |
| r_translation = r_centroid_new - r_centroid | |
| for idx in right_moving_idx: | |
| pt = landmarks[idx] | |
| src_pts.append(pt) | |
| dst_pts.append(pt + r_translation) | |
| l_pts = landmarks[left_moving_idx] | |
| l_centroid = np.mean(l_pts, axis=0) | |
| l_vec = l_centroid - center_pt | |
| l_centroid_new = center_pt + l_vec * scale | |
| l_translation = l_centroid_new - l_centroid | |
| for idx in left_moving_idx: | |
| pt = landmarks[idx] | |
| src_pts.append(pt) | |
| dst_pts.append(pt + l_translation) | |
| for idx in anchor_idx: | |
| pt = landmarks[idx] | |
| src_pts.append(pt) | |
| dst_pts.append(pt) | |
| src_pts.append(center_pt) | |
| dst_pts.append(center_pt) | |
| return np.array(src_pts, dtype=np.float32), np.array(dst_pts, dtype=np.float32), mask_groups | |
| def _config_mouth_position(self, landmarks, strength, params): | |
| """ | |
| 嘴巴上下移动配置 (基于解剖学距离限制) | |
| Strength < 0: 上移 (最大幅度参照 人中长度) | |
| Strength > 0: 下移 (最大幅度参照 下唇窝距离) | |
| """ | |
| lips_indices = [0, 13, 14, 17, 37, 39, 40, 61, 78, 80, 81, 82, 84, 87, 88, 91, 95, 146, 178, 181, 185, 191, 267, 269, 270, 291, 308, 310, 311, 312, 314, 317, 318, 321, 324, 375, 402, 405, 409, 415] | |
| anchor_idx = [ | |
| 2, 98, 327, 94, 19, 1, | |
| 152, 377, 148, 365, 136, | |
| 234, 454, 58, 288, 361, 132, 93, 323, 45, 275 | |
| ] | |
| mask_idx_mouth_zone = [18, 43, 57, 83, 92, 106, 164, 165, 167, 182, 186, 273, 287, 313, 322, 335, 391, 393, 406, 410] | |
| mask_groups = [mask_idx_mouth_zone] | |
| src_pts = [] | |
| dst_pts = [] | |
| pt_0 = landmarks[0] | |
| pt_164 = landmarks[164] | |
| pt_17 = landmarks[17] | |
| pt_18 = landmarks[18] | |
| dist_up_limit = float(abs(pt_0[1] - pt_164[1])) | |
| dist_down_limit = float(abs(pt_17[1] - pt_18[1])) | |
| move_y = 0.0 | |
| if strength < 0: | |
| factor = params.get('limit_mouth_up_factor', 0.8) | |
| move_dist = dist_up_limit * (abs(strength) / 100.0) * factor | |
| move_y = -move_dist | |
| else: | |
| factor = params.get('limit_mouth_down_factor', 0.8) | |
| move_dist = dist_down_limit * (strength / 100.0) * factor | |
| move_y = move_dist | |
| translation = np.array([0, move_y], dtype=np.float32) | |
| for idx in lips_indices: | |
| pt = landmarks[idx] | |
| src_pts.append(pt) | |
| dst_pts.append(pt + translation) | |
| for idx in anchor_idx: | |
| pt = landmarks[idx] | |
| src_pts.append(pt) | |
| dst_pts.append(pt) | |
| return np.array(src_pts, dtype=np.float32), np.array(dst_pts, dtype=np.float32), mask_groups | |
| def _config_lip_thickness(self, landmarks, strength, params): | |
| """ | |
| 嘴唇变厚/变薄配置 (严格解剖学版:上下唇独立参照各自厚度) | |
| """ | |
| upper_lip_outer_moving = [185,40,39,37,0,267,269,270,409] | |
| lower_lip_outer_moving = [146, 91, 181, 84, 17, 314, 405, 321, 375] | |
| anchor_idx = [191, 80, 81, 82, 13, 312, 311, 310, 415, 14, 87, 88, 95, 178, 317, 318, 324, 402, 61, 291, 2, 98, 327, 18, 200, 234, 454, 58, 288, 361, 132, 93, 323] | |
| mask_idx_expanded =[18, 43, 57, 83, 92, 106, 164, 165, 167, 182, 186, 273, 287, 313, 322, 335, 391, 393, 406, 410] | |
| mask_groups = [mask_idx_expanded] | |
| src_pts = [] | |
| dst_pts = [] | |
| upper_thickness = np.linalg.norm(landmarks[13] - landmarks[0]) | |
| lower_thickness = np.linalg.norm(landmarks[14] - landmarks[17]) | |
| thickness = max(upper_thickness,lower_thickness) | |
| if strength >= 0: | |
| factor = params.get('max_lip_thicken_ratio', 0.4) | |
| else: | |
| factor = params.get('max_lip_thin_ratio', 0.3) | |
| move_dist_upper = thickness * (abs(strength) / 100.0) * factor | |
| move_dist_lower = thickness * (abs(strength) / 100.0) * factor | |
| vec_up = (landmarks[2] - landmarks[13]).astype(np.float32) | |
| norm_up = np.linalg.norm(vec_up) | |
| if norm_up > 0: vec_up /= norm_up | |
| vec_down = (landmarks[152] - landmarks[14]).astype(np.float32) | |
| norm_down = np.linalg.norm(vec_down) | |
| if norm_down > 0: vec_down /= norm_down | |
| if strength >= 0: | |
| final_vec_upper = vec_up * move_dist_upper | |
| final_vec_lower = vec_down * move_dist_lower | |
| else: | |
| final_vec_upper = -vec_up * move_dist_upper | |
| final_vec_lower = -vec_down * move_dist_lower | |
| for idx in upper_lip_outer_moving: | |
| pt = landmarks[idx] | |
| src_pts.append(pt) | |
| dst_pts.append(pt + final_vec_upper) | |
| for idx in lower_lip_outer_moving: | |
| pt = landmarks[idx] | |
| src_pts.append(pt) | |
| dst_pts.append(pt + final_vec_lower) | |
| for idx in anchor_idx: | |
| pt = landmarks[idx] | |
| src_pts.append(pt) | |
| dst_pts.append(pt) | |
| return np.array(src_pts, dtype=np.float32), np.array(dst_pts, dtype=np.float32), mask_groups | |
| def _config_mouth_resize(self, landmarks, strength, params): | |
| """ | |
| 嘴巴整体缩放配置 | |
| Strength > 0: 变大 (Enlarge) | |
| Strength < 0: 变小 (Shrink) | |
| """ | |
| lips_indices = [0, 13, 14, 17, 37, 39, 40, 61, 78, 80, 81, 82, 84, 87, 88, 91, 95, 146, 178, 181, 185, 191, 267, 269, 270, 291, 308, 310, 311, 312, 314, 317, 318, 321, 324, 375, 402, 405, 409, 415] | |
| anchor_idx = [ | |
| 2, 98, 327, | |
| 205, 425, | |
| 152, 377, 148, | |
| 234, 454, 58, 288, 361, 132, 93, 323, | |
| 164 | |
| ] | |
| mask_idx_expanded = [18, 43, 57, 83, 92, 106, 164, 165, 167, 182, 186, 273, 287, 313, 322, 335, 391, 393, 406, 410] | |
| mask_groups = [mask_idx_expanded] | |
| src_pts = [] | |
| dst_pts = [] | |
| mouth_pts = landmarks[lips_indices] | |
| center = np.mean(mouth_pts, axis=0) | |
| if strength >= 0: | |
| factor = params.get('max_mouth_enlarge', 0.25) | |
| scale = 1.0 + (strength / 100.0) * factor | |
| else: | |
| factor = params.get('max_mouth_shrink', 0.25) | |
| scale = 1.0 - (abs(strength) / 100.0) * factor | |
| for idx in lips_indices: | |
| pt = landmarks[idx] | |
| src_pts.append(pt) | |
| vec = pt - center | |
| dst_pts.append(center + vec * scale) | |
| for idx in anchor_idx: | |
| pt = landmarks[idx] | |
| src_pts.append(pt) | |
| dst_pts.append(pt) | |
| src_pts.append(center) | |
| dst_pts.append(center) | |
| return np.array(src_pts, dtype=np.float32), np.array(dst_pts, dtype=np.float32), mask_groups | |
| def _config_nose_bridge(self, landmarks, strength, params): | |
| """ | |
| 鼻梁变窄/变宽配置 | |
| Strength < 0: 变窄 (Narrow) - 更加立体/精致 | |
| Strength > 0: 变宽 (Widen) | |
| """ | |
| left_bridge_indices = [ | |
| 193, 245, 128, 122, 121, 100 | |
| ] | |
| right_bridge_indices = [ | |
| 417, 465, 357, 351, 350, 329 | |
| ] | |
| anchor_idx = [ | |
| 1, 2, 4, 5, 6, 19, 48, 64, 94, 98, 115, 168, 195, 197, 278, 294, 327, 344, | |
| 362, 133, | |
| 359, 130, | |
| 123, 50, 116, | |
| 352, 280, 345 | |
| ] | |
| mask_idx_bridge = [9, 55, 97, 134, 164, 174, 188, 193, 220, 236, 237, 245, 285, 326, 363, 399, 412, 417, 440, 456, 457, 465] | |
| mask_groups = [mask_idx_bridge] | |
| src_pts = [] | |
| dst_pts = [] | |
| if strength >= 0: | |
| factor = params.get('max_nose_widen_ratio', 0.3) | |
| scale = 1.0 + (strength / 100.0) * factor | |
| else: | |
| factor = params.get('max_nose_narrow_ratio', 0.25) | |
| scale = 1.0 - (abs(strength) / 100.0) * factor | |
| vec_center_line = (landmarks[2] - landmarks[168]).astype(np.float32) | |
| norm_cl = np.linalg.norm(vec_center_line) | |
| if norm_cl > 0: vec_center_line /= norm_cl | |
| def get_projection_point(pt, line_start, line_vec): | |
| vec_ap = (pt - line_start).astype(np.float32) | |
| proj_len = np.dot(vec_ap, line_vec) | |
| return line_start + line_vec * proj_len | |
| all_moving_indices = [3, 44, 45, 51, 122, 193, 196, 248, 274, 275, 281, 351, 417, 419] | |
| for idx in all_moving_indices: | |
| pt = landmarks[idx] | |
| src_pts.append(pt) | |
| proj_pt = get_projection_point(pt, landmarks[168], vec_center_line) | |
| vec_radial = pt - proj_pt | |
| dst_pts.append(proj_pt + vec_radial * scale) | |
| for idx in anchor_idx: | |
| pt = landmarks[idx] | |
| src_pts.append(pt) | |
| dst_pts.append(pt) | |
| return np.array(src_pts, dtype=np.float32), np.array(dst_pts, dtype=np.float32), mask_groups | |
| def apply_deformation(self, image, src_pts, dst_pts, mask_indices, landmarks, params): | |
| h, w = image.shape[:2] | |
| mls = SimilarityMLS( | |
| grid_size=params.get('grid_size', 50), | |
| alpha=params.get('alpha', 1.0) | |
| ) | |
| warped_image = mls.warp(image, dst_pts, src_pts) | |
| mask = self._create_roi_mask( | |
| h, w, landmarks, mask_indices, | |
| blur_ratio=params.get('blur_ratio', 0.08) | |
| ) | |
| mask = mask[:, :, np.newaxis] | |
| result = warped_image * mask + image * (1.0 - mask) | |
| return result.astype(np.uint8) | |
| def process_and_save(self, image_path, output_path, op_type='eye_resize', strength=50, hyperparams=None): | |
| """ | |
| 单次操作的便捷入口,内部调用 process_batch | |
| """ | |
| operation = { | |
| 'op_type': op_type, | |
| 'strength': strength, | |
| 'params': hyperparams if hyperparams else {} | |
| } | |
| self.process_batch(image_path, output_path, [operation]) | |
| def process_batch(self, image_path, output_path, operations, diff_output_path=None): | |
| """ | |
| 批量执行多个编辑操作 (Pipeline模式) | |
| :param operations: 操作列表 | |
| :param diff_output_path: (新增) 指定差异图的保存路径,如果为None则不保存或使用默认命名 | |
| """ | |
| img = cv2.imread(image_path) | |
| if img is None: | |
| print(f"Error: Could not read image: {image_path}") | |
| return | |
| arr_source = img.astype(np.float32) | |
| current_img = img.copy() | |
| for i, op in enumerate(operations): | |
| op_type = op.get('op_type') | |
| strength = op.get('strength') | |
| custom_params = op.get('params', {}) | |
| landmarks = self._get_landmarks(current_img) | |
| if landmarks is None: | |
| break | |
| params = self._get_default_params(op_type) | |
| if custom_params: | |
| params.update(custom_params) | |
| try: | |
| src_pts, dst_pts, mask_groups = self._get_operation_config( | |
| op_type, landmarks, strength, params | |
| ) | |
| current_img = self.apply_deformation( | |
| current_img, src_pts, dst_pts, mask_groups, landmarks, params | |
| ) | |
| except ValueError as e: | |
| continue | |
| os.makedirs(os.path.dirname(output_path), exist_ok=True) | |
| cv2.imwrite(output_path, current_img) | |
| class LLWFaceRetoucher(FaceEditor): | |
| """Public wrapper for Landmark-Guided Local Warping face retouching.""" | |
| def apply_operations_to_array(self, image_bgr: np.ndarray, operations: Sequence[Operation]) -> np.ndarray: | |
| current_img = image_bgr.copy() | |
| for op in operations: | |
| op_type = str(op.get("op_type") or op.get("operation") or op.get("name")) | |
| strength = float(op.get("strength", op.get("value", 100))) | |
| custom_params = op.get("params", {}) or {} | |
| landmarks = self._get_landmarks(current_img) | |
| if landmarks is None: | |
| raise RuntimeError("No face landmarks were detected before applying operation: " + op_type) | |
| params = self._get_default_params(op_type) | |
| params.update(custom_params) | |
| src_pts, dst_pts, mask_groups = self._get_operation_config(op_type, landmarks, strength, params) | |
| current_img = self.apply_deformation(current_img, src_pts, dst_pts, mask_groups, landmarks, params) | |
| return current_img | |
| def apply_operations(self, image_path: str | Path, operations: Sequence[Operation], output_path: str | Path | None = None) -> np.ndarray: | |
| image = cv2.imread(str(image_path)) | |
| if image is None: | |
| raise FileNotFoundError(f"Could not read image: {image_path}") | |
| result = self.apply_operations_to_array(image, operations) | |
| if output_path is not None: | |
| output_path = Path(output_path) | |
| output_path.parent.mkdir(parents=True, exist_ok=True) | |
| cv2.imwrite(str(output_path), result) | |
| return result | |
| def normalize_operations(operation_specs: Iterable[str]) -> list[dict[str, object]]: | |
| operations = [] | |
| for spec in operation_specs: | |
| if ":" not in spec: | |
| raise ValueError(f"Operation must be formatted as name:strength, got {spec!r}") | |
| name, value = spec.split(":", 1) | |
| operations.append({"op_type": name.strip(), "strength": float(value)}) | |
| return operations | |