|
|
- """
- face_sculpt_tab.py - 捏脸Tab面板
- 共享组件,集成到step8数字人面板。
- 布局:上方SD配置(prompt/负向词/推理参数),下方五官参数滑块。
- 五官参数不映射到prompt,而是对已生成图片做landmark位移+图像warp实时变形。
- """
- import gc
- import threading
- import logging
- import random
- from pathlib import Path
- import cv2
- import numpy as np
- from PyQt6.QtCore import Qt, pyqtSignal, QObject, QTimer
- from PyQt6.QtWidgets import (
- QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
- QSpinBox, QDoubleSpinBox, QTextEdit, QGroupBox, QGridLayout,
- QMessageBox, QFileDialog, QSizePolicy,
- )
- import orjson
- from faceswap.setting import LORA_MODEL_DIR
- from faceswap.gui_app.widgets.convert_model_selector import ConvertModelSelector
- from faceswap.gui_app.widgets.face_param_sliders import FaceParamSliders
- class _SculptSignal(QObject):
- log = pyqtSignal(str)
- done = pyqtSignal(bool, object)
- class _QtLogHandler(logging.Handler):
- def __init__(self, emit_fn):
- super().__init__()
- self._emit_fn = emit_fn
- def emit(self, record):
- try:
- self._emit_fn(self.format(record))
- except Exception:
- pass
- class FaceSculptTab(QWidget):
- """捏脸Tab面板。"""
- PREVIEW_SIZE = 512
- def __init__(self, parent=None):
- super().__init__(parent)
- self._sculpt_sig = _SculptSignal()
- self._sculpt_sig.log.connect(self._on_sculpt_log)
- self._sculpt_sig.done.connect(self._on_sculpt_done)
- self._sculpt_ref_image_path = None
- self._sculpt_stop_event = threading.Event()
- self._sculpt_running = False
- self._sculpt_base_image = None
- self._sculpt_current_image = None
- self._sculpt_base_landmarks = None
- self._warp_timer = QTimer()
- self._warp_timer.setSingleShot(True)
- self._warp_timer.timeout.connect(self._do_warp)
- self._build_ui()
- from faceswap.gui_app.widgets.image_utils import attach_save_image_menu
- attach_save_image_menu(
- self._sculpt_result_preview,
- lambda: cv2.cvtColor(self._sculpt_current_image, cv2.COLOR_RGB2BGR) if self._sculpt_current_image is not None else None,
- )
- def _build_ui(self):
- lay = QVBoxLayout(self)
- lay.setContentsMargins(4, 8, 4, 4)
- lay.setSpacing(8)
- # 模型选择 GroupBox(与转换数字人面板样式一致)
- mod_grp = QGroupBox("模型选择")
- mod_lay = QVBoxLayout(mod_grp)
- mod_lay.setSpacing(6)
- self._sculpt_model_selector = ConvertModelSelector(self)
- self._sculpt_model_selector.model_changed.connect(self._on_sculpt_model_changed)
- mod_lay.addWidget(self._sculpt_model_selector)
- info_row = QHBoxLayout()
- self._sculpt_base_label = QLabel("底座: 自动检测")
- self._sculpt_base_label.setStyleSheet("color: #666; font-size: 11px;")
- info_row.addWidget(self._sculpt_base_label)
- self._sculpt_arch_label = QLabel("架构: -")
- self._sculpt_arch_label.setStyleSheet("color: #666; font-size: 11px;")
- info_row.addWidget(self._sculpt_arch_label)
- self._sculpt_output_label = QLabel("输出: -")
- self._sculpt_output_label.setStyleSheet("color: #666; font-size: 11px;")
- info_row.addWidget(self._sculpt_output_label)
- info_row.addStretch()
- mod_lay.addLayout(info_row)
- lay.addWidget(mod_grp)
- content_row = QHBoxLayout()
- content_row.setSpacing(8)
- left_lay = QVBoxLayout()
- left_lay.setSpacing(6)
- base_prompt_grp = QGroupBox("基础 Prompt(配合SD生成图片)")
- base_prompt_lay = QVBoxLayout(base_prompt_grp)
- base_prompt_row = QHBoxLayout()
- self._sculpt_base_prompt = QTextEdit("a photo of a person")
- self._sculpt_base_prompt.setFixedHeight(50)
- self._sculpt_base_prompt.setStyleSheet("font-family: Consolas, monospace; font-size: 11px;")
- base_prompt_row.addWidget(self._sculpt_base_prompt)
- self._sculpt_prompt_translate_btn = QPushButton("翻译")
- self._sculpt_prompt_translate_btn.setFixedWidth(90)
- self._sculpt_prompt_translate_btn.setToolTip("将中文翻译为英文")
- self._sculpt_prompt_translate_btn.clicked.connect(lambda: self._do_translate(self._sculpt_base_prompt, self._sculpt_prompt_translate_btn))
- base_prompt_row.addWidget(self._sculpt_prompt_translate_btn)
- base_prompt_lay.addLayout(base_prompt_row)
- left_lay.addWidget(base_prompt_grp)
- base_prompt_grp.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
- neg_grp = QGroupBox("负向词")
- neg_lay = QVBoxLayout(neg_grp)
- neg_row = QHBoxLayout()
- self._sculpt_negative = QTextEdit("ugly, deformed, blurry, low quality")
- self._sculpt_negative.setFixedHeight(50)
- self._sculpt_negative.setStyleSheet("font-family: Consolas, monospace; font-size: 11px;")
- neg_row.addWidget(self._sculpt_negative)
- self._sculpt_neg_translate_btn = QPushButton("翻译")
- self._sculpt_neg_translate_btn.setFixedWidth(90)
- self._sculpt_neg_translate_btn.setToolTip("将中文翻译为英文")
- self._sculpt_neg_translate_btn.clicked.connect(lambda: self._do_translate(self._sculpt_negative, self._sculpt_neg_translate_btn))
- neg_row.addWidget(self._sculpt_neg_translate_btn)
- neg_lay.addLayout(neg_row)
- left_lay.addWidget(neg_grp)
- neg_grp.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
- param_grp = QGroupBox("推理参数")
- param_lay = QGridLayout(param_grp)
- param_lay.setSpacing(6)
- param_lay.setColumnStretch(0, 0)
- param_lay.setColumnStretch(1, 1)
- param_lay.setColumnStretch(2, 0)
- param_lay.setColumnStretch(3, 1)
- param_lay.addWidget(QLabel("分辨率:"), 0, 0)
- self._sculpt_resolution = QSpinBox()
- self._sculpt_resolution.setRange(256, 2048)
- self._sculpt_resolution.setSingleStep(64)
- self._sculpt_resolution.setValue(512)
- param_lay.addWidget(self._sculpt_resolution, 0, 1)
- param_lay.addWidget(QLabel("强度:"), 0, 2)
- self._sculpt_strength = QDoubleSpinBox()
- self._sculpt_strength.setRange(0.1, 0.9)
- self._sculpt_strength.setSingleStep(0.05)
- self._sculpt_strength.setValue(0.5)
- param_lay.addWidget(self._sculpt_strength, 0, 3)
- param_lay.addWidget(QLabel("步数:"), 1, 0)
- self._sculpt_steps = QSpinBox()
- self._sculpt_steps.setRange(10, 100)
- self._sculpt_steps.setValue(30)
- param_lay.addWidget(self._sculpt_steps, 1, 1)
- param_lay.addWidget(QLabel("CFG:"), 1, 2)
- self._sculpt_cfg = QDoubleSpinBox()
- self._sculpt_cfg.setRange(1.0, 20.0)
- self._sculpt_cfg.setSingleStep(0.5)
- self._sculpt_cfg.setValue(7.5)
- param_lay.addWidget(self._sculpt_cfg, 1, 3)
- param_lay.addWidget(QLabel("LoRA强度:"), 2, 0)
- self._sculpt_lora_scale = QDoubleSpinBox()
- self._sculpt_lora_scale.setRange(0.0, 2.0)
- self._sculpt_lora_scale.setSingleStep(0.1)
- self._sculpt_lora_scale.setValue(1.0)
- param_lay.addWidget(self._sculpt_lora_scale, 2, 1)
- param_lay.addWidget(QLabel("种子:"), 3, 0)
- self._sculpt_seed = QSpinBox()
- self._sculpt_seed.setRange(0, 999999)
- self._sculpt_seed.setValue(42)
- param_lay.addWidget(self._sculpt_seed, 3, 1)
- self._sculpt_random_seed_btn = QPushButton("随机种子")
- self._sculpt_random_seed_btn.clicked.connect(self._on_random_seed)
- param_lay.addWidget(self._sculpt_random_seed_btn, 3, 2, 1, 2)
- left_lay.addWidget(param_grp)
- param_grp.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
- sculpt_grp = QGroupBox("五官参数(实时改变已生成图片,无需重新生成)")
- sculpt_lay = QVBoxLayout(sculpt_grp)
- self._sculpt_param_sliders = FaceParamSliders()
- self._sculpt_param_sliders.params_changed.connect(self._on_sculpt_params_changed)
- self._sculpt_param_sliders.setEnabled(False)
-
- # 设置推荐配置回调:根据当前 landmarks 计算黄金比例
- def _calc_recommend():
- if self._sculpt_base_landmarks is None:
- QMessageBox.warning(self, "无参考图", "请先上传参考图或生成图片后再使用推荐配置")
- return {}
- return self.calc_golden_ratio_params(self._sculpt_base_landmarks)
-
- self._sculpt_param_sliders.recommend_callback = _calc_recommend
- sculpt_lay.addWidget(self._sculpt_param_sliders)
- reset_row = QHBoxLayout()
- reset_row.addStretch()
- self._sculpt_reset_btn = QPushButton("重置全部")
- self._sculpt_reset_btn.clicked.connect(self._sculpt_param_sliders.reset)
- reset_row.addWidget(self._sculpt_reset_btn)
- sculpt_lay.addLayout(reset_row)
- left_lay.addWidget(sculpt_grp)
- sculpt_grp.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
- left_lay.addStretch()
- content_row.addLayout(left_lay, stretch=1)
- right_widget = QWidget()
- right_widget.setFixedWidth(560)
- right_lay = QVBoxLayout(right_widget)
- right_lay.setContentsMargins(2, 2, 2, 2)
- right_lay.setSpacing(6)
- ref_grp = QGroupBox("参考图(可选,上传后基于参考图捏脸)")
- ref_lay = QVBoxLayout(ref_grp)
- self._sculpt_ref_preview = QLabel("点击上传参考图\n或留空随机生成")
- self._sculpt_ref_preview.setAlignment(Qt.AlignmentFlag.AlignCenter)
- self._sculpt_ref_preview.setFixedSize(512, 256)
- self._sculpt_ref_preview.setStyleSheet("border: 1px solid #CCC; background: #F0F0F0; color: #999;")
- self._sculpt_ref_preview.mousePressEvent = lambda e: self._on_upload_ref()
- ref_lay.addWidget(self._sculpt_ref_preview)
- self._sculpt_clear_ref_btn = QPushButton("清除参考图")
- self._sculpt_clear_ref_btn.clicked.connect(self._on_clear_ref)
- ref_lay.addWidget(self._sculpt_clear_ref_btn)
- right_lay.addWidget(ref_grp)
- result_grp = QGroupBox("生成结果")
- result_lay = QVBoxLayout(result_grp)
- self._sculpt_result_preview = QLabel("点击生成按钮创建人脸")
- self._sculpt_result_preview.setAlignment(Qt.AlignmentFlag.AlignCenter)
- self._sculpt_result_preview.setFixedSize(512, 420)
- self._sculpt_result_preview.setStyleSheet("border: 1px solid #CCC; background: #F0F0F0; color: #999;")
- result_lay.addWidget(self._sculpt_result_preview)
- right_lay.addWidget(result_grp)
- content_row.addWidget(right_widget)
- lay.addLayout(content_row)
- run_row = QHBoxLayout()
- run_row.addStretch()
- self._sculpt_start_btn = QPushButton("生成")
- self._sculpt_start_btn.setFixedWidth(120)
- self._sculpt_start_btn.setStyleSheet(
- "QPushButton { background-color: #0078D4; color: white; font-weight: bold; "
- "padding: 6px 16px; border-radius: 3px; }"
- "QPushButton:hover { background-color: #106EBE; }"
- "QPushButton:disabled { background-color: #A0A0A0; color: #E0E0E0; }")
- self._sculpt_start_btn.clicked.connect(self._on_start_sculpt)
- run_row.addWidget(self._sculpt_start_btn)
- self._sculpt_stop_btn = QPushButton("停止")
- self._sculpt_stop_btn.setFixedWidth(80)
- self._sculpt_stop_btn.setEnabled(False)
- self._sculpt_stop_btn.setProperty("busy_keep", True)
- self._sculpt_stop_btn.setStyleSheet(
- "QPushButton { background-color: #C42B1C; color: white; font-weight: bold; "
- "padding: 6px 16px; border-radius: 3px; }"
- "QPushButton:hover { background-color: #D43B2C; }"
- "QPushButton:disabled { background-color: #A0A0A0; color: #E0E0E0; }")
- self._sculpt_stop_btn.clicked.connect(self._on_stop_sculpt)
- run_row.addWidget(self._sculpt_stop_btn)
- lay.addLayout(run_row)
- self._sculpt_log_label = QLabel("")
- self._sculpt_log_label.setStyleSheet(
- "background: #1E1E1E; color: #00FF00; font-family: Consolas, monospace; "
- "font-size: 11px; padding: 2px 8px; border-radius: 2px;")
- self._sculpt_log_label.setWordWrap(False)
- self._sculpt_log_label.setVisible(False)
- lay.addWidget(self._sculpt_log_label)
- def _on_sculpt_model_changed(self, kind, lora_path, base_model_path, arch, extra):
- # 更新信息标签(与转换数字人面板一致)
- self._sculpt_base_label.setText(f"底座: {Path(base_model_path).name if base_model_path else '未知'}")
- self._sculpt_arch_label.setText(f"架构: {arch}")
- if kind == "lora" and lora_path:
- self._sculpt_output_label.setText(f"输出: LoRA")
- lora_dir = Path(lora_path).parent
- if lora_dir.name == "final":
- lora_dir = lora_dir.parent
- cfg_path = lora_dir / "lora_training_config.json"
- if cfg_path.exists():
- try:
- cfg = orjson.loads(cfg_path.read_bytes())
- res = cfg.get("resolution", 512)
- pc = cfg.get("prompt_config", {})
- self._sculpt_resolution.setValue(res)
- neg = pc.get("negative_prompt", "")
- if neg:
- self._sculpt_negative.setPlainText(neg)
- if "gen_steps" in pc:
- self._sculpt_steps.setValue(int(pc["gen_steps"]))
- if "gen_cfg" in pc:
- self._sculpt_cfg.setValue(float(pc["gen_cfg"]))
- if "gen_lora_scale" in pc:
- self._sculpt_lora_scale.setValue(float(pc["gen_lora_scale"]))
- if "gen_seed" in pc:
- self._sculpt_seed.setValue(int(pc["gen_seed"]))
- except Exception:
- pass
- elif kind == "base_only":
- self._sculpt_output_label.setText("输出: 仅底座")
- else:
- self._sculpt_output_label.setText("输出: -")
- def _on_sculpt_params_changed(self, params):
- if self._sculpt_base_image is None:
- QMessageBox.warning(self, "无参考图", "请先上传参考图或生成图片后再调节参数")
- return
- self._warp_timer.start(50)
- def _do_warp(self):
- if self._sculpt_base_image is None:
- return
- if self._sculpt_base_landmarks is None:
- self.window().statusBar().showMessage("未检测到人脸landmark,无法捏脸", 3000)
- return
- params = self._sculpt_param_sliders.get_params()
- if not any(abs(v) > 0.01 for v in params.values()):
- self._sculpt_current_image = self._sculpt_base_image
- self._display_image(self._sculpt_base_image, self._sculpt_result_preview)
- return
- try:
- warped = self._warp_face(self._sculpt_base_image, self._sculpt_base_landmarks, params)
- warped = self._widen_forehead(warped, self._sculpt_base_landmarks)
- if warped is not None and warped.size > 0:
- self._sculpt_current_image = warped
- self._display_image(warped, self._sculpt_result_preview)
- else:
- self.window().statusBar().showMessage("变形结果为空", 3000)
- except Exception as e:
- self.window().statusBar().showMessage(f"变形失败: {e}", 3000)
- def _widen_forehead(self, img: np.ndarray, landmarks: np.ndarray) -> np.ndarray:
- """用cv2.remap平滑拉伸额头x,确保额头≥颧骨。单图变形无重影。"""
- import cv2
- h, w = img.shape[:2]
- if len(landmarks) <= 101:
- return img
- brow_y = int((landmarks[43, 1] + landmarks[101, 1]) / 2)
- brow_width = abs(landmarks[101, 0] - landmarks[43, 0])
- face_cx = float((landmarks[43, 0] + landmarks[101, 0]) / 2.0)
- from faceswap.core.landmarks106 import LANDMARK_GROUPS_106
- groups = dict(LANDMARK_GROUPS_106)
- jaw_idx = groups.get("jaw_cheek", [])
- valid_jaw = [i for i in jaw_idx if i < len(landmarks)]
- if not valid_jaw:
- return img
- cheek_width = float(landmarks[valid_jaw, 0].max() - landmarks[valid_jaw, 0].min())
- target_width = max(brow_width, cheek_width * 1.05)
- if brow_width <= 0.5 or target_width <= brow_width or brow_y <= 2:
- return img
- s = target_width / brow_width
- map_x = np.tile(np.arange(w, dtype=np.float32), (h, 1))
- map_y = np.tile(np.arange(h, dtype=np.float32).reshape(-1, 1), (1, w))
- fh = min(brow_y, h)
- ys = np.arange(fh, dtype=np.float32)
- t = 1.0 - ys / fh
- smooth = t * t * (3.0 - 2.0 * t)
- stretch = (s - 1.0) * smooth
- offset = stretch[:, None] * (np.arange(w, dtype=np.float32)[None, :] - face_cx)
- map_x[:fh, :] -= offset
- return cv2.remap(img, map_x, map_y, cv2.INTER_LINEAR,
- borderMode=cv2.BORDER_REFLECT_101)
- @staticmethod
- def _warp_face(base_img: np.ndarray, landmarks: np.ndarray, params: dict) -> np.ndarray:
- import cv2
- from faceswap.core.landmarks106 import LANDMARK_GROUPS_106
- groups = dict(LANDMARK_GROUPS_106)
- h, w = base_img.shape[:2]
- src_pts = landmarks.astype(np.float32).copy()
- dst_pts = src_pts.copy()
- def scale_region(idx_list, scale_x, scale_y):
- if not idx_list:
- return
- valid = [i for i in idx_list if i < len(dst_pts)]
- if not valid:
- return
- local_cx = float(np.mean(dst_pts[valid, 0]))
- local_cy = float(np.mean(dst_pts[valid, 1]))
- for idx in valid:
- dx = dst_pts[idx, 0] - local_cx
- dy = dst_pts[idx, 1] - local_cy
- dst_pts[idx, 0] = local_cx + dx * scale_x
- dst_pts[idx, 1] = local_cy + dy * scale_y
- def shift_region(idx_list, shift_x, shift_y):
- for idx in idx_list:
- if idx >= len(dst_pts):
- continue
- dst_pts[idx, 0] += shift_x
- dst_pts[idx, 1] += shift_y
- # ===== 骨骼结构参数 =====
- face_w = 1.0 + params.get("face_width", 0) * 0.15
- face_h = 1.0 + params.get("face_height", 0) * 0.1
- cheekbone_w = 1.0 + params.get("cheekbone_width", 0) * 0.1
- jaw_w = 1.0 + params.get("jaw_width", 0) * 0.12
- chin_scale_y = 1.0 + params.get("chin_shape", 0) * 0.2
- face_round = 1.0 + params.get("face_roundness", 0) * 0.1
-
- # ===== 五官细节参数 =====
- eye_scale = 1.0 + params.get("eye_size", 0) * 0.3
- mouth_scale = 1.0 + params.get("mouth_size", 0) * 0.3
- nose_scale = 1.0 + params.get("nose_size", 0) * 0.25
- brow_scale = 1.0 + params.get("eyebrow_thickness", 0) * 0.2
- lip_scale = 1.0 + params.get("lip_thickness", 0) * 0.25
- eye_dist_shift = params.get("eye_distance", 0) * 15.0
- # ===== 骨骼结构变形 =====
- # 分层独立控制:face_w只拉宽最高1/3(额头),cheekbone_w缩窄中1/3(颧骨),jaw_w缩窄下1/3(下颌)
- jaw_cheek = groups.get("jaw_cheek", [])
- cheekbone_pts = []
- if jaw_cheek:
- valid_jaw = [i for i in jaw_cheek if i < len(dst_pts)]
- if valid_jaw:
- sorted_by_y = sorted(valid_jaw, key=lambda i: src_pts[i, 1])
- n = len(sorted_by_y)
- temple_pts = sorted_by_y[:n // 3]
- cheekbone_pts = sorted_by_y[n // 3: 2 * n // 3]
- jaw_pts = sorted_by_y[2 * n // 3:]
- if temple_pts:
- t_cx = float(np.mean(dst_pts[temple_pts, 0]))
- t_cy = float(np.mean(dst_pts[temple_pts, 1]))
- for idx in temple_pts:
- dx = dst_pts[idx, 0] - t_cx
- dy = dst_pts[idx, 1] - t_cy
- dst_pts[idx, 0] = t_cx + dx * face_w
- dst_pts[idx, 1] = t_cy + dy * face_h
- if cheekbone_pts:
- cheek_cx = float(np.mean(dst_pts[cheekbone_pts, 0]))
- for idx in cheekbone_pts:
- dx = dst_pts[idx, 0] - cheek_cx
- dst_pts[idx, 0] = cheek_cx + dx * cheekbone_w
- if jaw_pts:
- jaw_cx = float(np.mean(dst_pts[jaw_pts, 0]))
- for idx in jaw_pts:
- dx = dst_pts[idx, 0] - jaw_cx
- dst_pts[idx, 0] = jaw_cx + dx * jaw_w
- face_cy = float(np.mean(dst_pts[valid_jaw, 1]))
- for idx in valid_jaw:
- dy = dst_pts[idx, 1] - face_cy
- dst_pts[idx, 1] = face_cy + dy * face_round
- # ===== 五官细节变形 =====
- scale_region(groups.get("right_eye", []), eye_scale, eye_scale)
- scale_region(groups.get("left_eye", []), eye_scale, eye_scale)
- scale_region(groups.get("outer_lip", []), mouth_scale, mouth_scale)
- scale_region(groups.get("inner_lip", []), lip_scale, lip_scale)
- scale_region(groups.get("nose", []), nose_scale, nose_scale)
- scale_region(groups.get("right_eyebrow", []), brow_scale, 1.0)
- scale_region(groups.get("left_eyebrow", []), brow_scale, 1.0)
- jaw = groups.get("jaw_cheek", [])
- chin_idx = [i for i in jaw if i < len(dst_pts)]
- if chin_idx:
- chin_cy = float(np.mean(dst_pts[chin_idx, 1]))
- for idx in chin_idx:
- dy = dst_pts[idx, 1] - chin_cy
- dst_pts[idx, 1] = chin_cy + dy * chin_scale_y
- shift_region(groups.get("left_eye", []), -eye_dist_shift, 0)
- shift_region(groups.get("right_eye", []), eye_dist_shift, 0)
- displacement = dst_pts - src_pts
- moved = np.any(np.abs(displacement) > 0.01, axis=1)
- if not np.any(moved):
- return base_img
- src_moved = src_pts[moved]
- disp_moved = displacement[moved]
- bbox_w = float(src_pts[:, 0].max() - src_pts[:, 0].min())
- bbox_h = float(src_pts[:, 1].max() - src_pts[:, 1].min())
- sigma = max(bbox_w, bbox_h) * 0.2
- step = 4
- yy, xx = np.mgrid[0:h:step, 0:w:step].astype(np.float32)
- map_x = xx.copy()
- map_y = yy.copy()
- for i in range(len(src_moved)):
- sx, sy = src_moved[i]
- dx, dy = disp_moved[i]
- dist_sq = (xx - sx) ** 2 + (yy - sy) ** 2
- weight = np.exp(-dist_sq / (2 * sigma * sigma))
- map_x += dx * weight
- map_y += dy * weight
- map_x_full = cv2.resize(map_x, (w, h), interpolation=cv2.INTER_LINEAR)
- map_y_full = cv2.resize(map_y, (w, h), interpolation=cv2.INTER_LINEAR)
- warped = cv2.remap(base_img, map_x_full, map_y_full,
- cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT)
- return warped
- def calc_golden_ratio_params(self, landmarks: np.ndarray) -> dict:
- """根据landmarks实测比例与黄金比例的偏差,按滑块灵敏度换算推荐值。"""
- if len(landmarks) < 10:
- return {}
- from faceswap.core.landmarks106 import LANDMARK_GROUPS_106
- groups = dict(LANDMARK_GROUPS_106)
- def _idx(name):
- return [i for i in groups.get(name, []) if i < len(landmarks)]
- # ===== 测量 =====
- face_top = landmarks[:, 1].min()
- face_bottom = landmarks[:, 1].max()
- H = face_bottom - face_top
- if H < 1:
- return {}
- # jaw_cheek 按y排序分三层
- jaw_cheek = _idx("jaw_cheek")
- if not jaw_cheek:
- return {}
- sorted_jaw = sorted(jaw_cheek, key=lambda i: landmarks[i, 1])
- n = len(sorted_jaw)
- temple_pts = sorted_jaw[:n // 3]
- cheek_pts = sorted_jaw[n // 3: 2 * n // 3]
- jaw_pts = sorted_jaw[2 * n // 3:]
- W_temple = landmarks[temple_pts, 0].max() - landmarks[temple_pts, 0].min()
- W_cheek = landmarks[cheek_pts, 0].max() - landmarks[cheek_pts, 0].min()
- W_jaw = landmarks[jaw_pts, 0].max() - landmarks[jaw_pts, 0].min()
- # 三庭
- brow_idx = _idx("right_eyebrow") + _idx("left_eyebrow")
- nose_idx = _idx("nose")
- eye_idx = _idx("right_eye") + _idx("left_eye")
- brow_y = landmarks[brow_idx, 1].mean() if brow_idx else face_top
- nose_bottom_y = landmarks[nose_idx, 1].max() if nose_idx else face_bottom
- upper = brow_y - face_top
- middle = nose_bottom_y - brow_y
- lower = face_bottom - nose_bottom_y
- # 眼睛位置
- eye_y_ratio = 0.5
- if eye_idx:
- eye_y = landmarks[eye_idx, 1].mean()
- eye_y_ratio = (eye_y - face_top) / H
- # ===== 计算差值 → 按灵敏度换算滑块值 =====
- # 灵敏度: face_width=15%, face_height=10%, cheekbone=10%, jaw=12%, chin=20%, roundness=10%
- params = {}
- # 1. 颧骨: W_cheek/H 理想≈0.618
- if W_cheek > 0:
- cheek_ratio = W_cheek / H
- cheek_pct = (cheek_ratio - 0.618) / cheek_ratio
- params["cheekbone_width"] = max(-50, min(50, int(-cheek_pct / 0.10 * 100)))
- # 2. 下颌: W_jaw/W_cheek 理想≈0.62
- if W_jaw > 0 and W_cheek > 0:
- jaw_ratio = W_jaw / W_cheek
- jaw_pct = (jaw_ratio - 0.62) / jaw_ratio
- params["jaw_width"] = max(-50, min(50, int(-jaw_pct / 0.12 * 100)))
- # 3. 太阳穴: W_temple/W_cheek 理想≥1.0(额头应≥颧骨)
- if W_temple > 0 and W_cheek > 0:
- temple_ratio = W_temple / W_cheek
- if temple_ratio < 1.0:
- temple_pct = (1.0 - temple_ratio) / temple_ratio
- params["face_width"] = max(-30, min(30, int(temple_pct / 0.15 * 100)))
- else:
- params["face_width"] = 0
- # 4. 下庭: lower 理想=H/3,过长→chin_shape负
- lower_pct = (lower - H / 3) / H
- params["chin_shape"] = max(-40, min(40, int(-lower_pct / 0.20 * 100)))
- # 5. 中庭: middle 理想=H/3,过长→face_roundness负(纵向压缩)
- middle_pct = (middle - H / 3) / H
- params["face_roundness"] = max(-30, min(30, int(-middle_pct / 0.10 * 100)))
- # 6. 眼睛位置: eye_y_ratio 理想=0.5,太高→拉长上庭
- eye_diff = 0.5 - eye_y_ratio
- params["face_height"] = max(-20, min(20, int(eye_diff / 0.10 * 100)))
- # 五官不自动推荐(landmark均匀缩放破坏比例)
- params.setdefault("eye_size", 0)
- params.setdefault("eye_distance", 0)
- params.setdefault("nose_size", 0)
- params.setdefault("mouth_size", 0)
- params.setdefault("lip_thickness", 0)
- params.setdefault("eyebrow_thickness", 0)
- return params
- def _on_random_seed(self):
- self._sculpt_seed.setValue(random.randint(0, 999999))
- def _on_upload_ref(self):
- path, _ = QFileDialog.getOpenFileName(self, "选择参考图", "", "图片 (*.jpg *.jpeg *.png *.bmp *.webp)")
- if path:
- self._sculpt_ref_image_path = path
- from PyQt6.QtGui import QPixmap
- pm = QPixmap(path)
- lw = self._sculpt_ref_preview.width()
- lh = self._sculpt_ref_preview.height()
- pm = pm.scaled(lw, lh,
- Qt.AspectRatioMode.KeepAspectRatio,
- Qt.TransformationMode.SmoothTransformation)
- self._sculpt_ref_preview.setPixmap(pm)
- self._sculpt_ref_preview.setText("")
- self._sculpt_result_preview.setText("正在加载并检测人脸...")
- class _RefSig(QObject):
- done = pyqtSignal(bool, object, object)
- self._ref_load_sig = _RefSig()
- self._ref_load_sig.done.connect(self._on_ref_loaded)
- def _worker():
- try:
- import cv2
- img = cv2.imread(path)
- if img is None:
- self._ref_load_sig.done.emit(False, None, None)
- return
- img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
- landmarks = FaceSculptTab._detect_landmarks(img_rgb)
- self._ref_load_sig.done.emit(True, img_rgb, landmarks)
- except Exception:
- self._ref_load_sig.done.emit(False, None, None)
- threading.Thread(target=_worker, daemon=True).start()
- def _on_ref_loaded(self, success, img_rgb, landmarks):
- if success and img_rgb is not None:
- self._sculpt_base_image = img_rgb.copy()
- self._sculpt_current_image = img_rgb.copy()
- self._sculpt_base_landmarks = landmarks
- self._display_image(img_rgb, self._sculpt_result_preview)
- if landmarks is not None:
- self._sculpt_param_sliders.setEnabled(True)
- self.window().statusBar().showMessage(
- f"参考图已加载,检测到 {len(landmarks)} 个landmark点", 3000)
- else:
- self._sculpt_param_sliders.setEnabled(False)
- QMessageBox.warning(self, "Landmark检测失败",
- "未检测到人脸landmark,请确认图片中包含清晰人脸。")
- else:
- self._sculpt_result_preview.setText("点击上传参考图\n或点击生成")
- QMessageBox.warning(self, "加载失败", "图片加载失败,请重试。")
- def _on_clear_ref(self):
- self._sculpt_ref_image_path = None
- self._sculpt_ref_preview.clear()
- self._sculpt_ref_preview.setText("点击上传参考图\n或留空随机生成")
- self._sculpt_base_image = None
- self._sculpt_current_image = None
- self._sculpt_base_landmarks = None
- self._sculpt_param_sliders.setEnabled(False)
- self._sculpt_result_preview.clear()
- self._sculpt_result_preview.setText("点击上传参考图\n或点击生成")
- def _on_start_sculpt(self):
- sel = self._sculpt_model_selector.get_selection()
- if not sel.get("base_model_path"):
- QMessageBox.warning(self, "未选择模型", "请先选择底座模型或LoRA模型")
- return
- self._sculpt_running = True
- self._sculpt_stop_event.clear()
- self.set_busy(True)
- self._sculpt_stop_btn.setEnabled(True)
- self._sculpt_log_label.setVisible(True)
- self._sculpt_log_label.setText("准备中...")
- params = {
- "lora_path": sel.get("lora_path", ""),
- "base_model": sel.get("base_model_path", ""),
- "arch": sel.get("arch", ""),
- "ref_image_path": self._sculpt_ref_image_path or "",
- "prompt": self._sculpt_base_prompt.toPlainText().strip(),
- "negative_prompt": self._sculpt_negative.toPlainText().strip(),
- "resolution": self._sculpt_resolution.value(),
- "strength": self._sculpt_strength.value(),
- "steps": self._sculpt_steps.value(),
- "cfg": self._sculpt_cfg.value(),
- "lora_scale": self._sculpt_lora_scale.value(),
- "seed": self._sculpt_seed.value(),
- }
- stop_event = self._sculpt_stop_event
- def _worker_safe():
- _qt_handler = None
- try:
- _qt_handler = _QtLogHandler(self._sculpt_sig.log.emit)
- _sculpt_logger = logging.getLogger("faceswap")
- _sculpt_logger.addHandler(_qt_handler)
- self._sculpt_worker(params, stop_event)
- except Exception as e:
- import traceback
- self._sculpt_sig.done.emit(False, traceback.format_exc())
- finally:
- if _qt_handler:
- _sculpt_logger = logging.getLogger("faceswap")
- _sculpt_logger.removeHandler(_qt_handler)
- threading.Thread(target=_worker_safe, daemon=True).start()
- def _sculpt_worker(self, params, stop_event):
- from faceswap.setting import init_vram_manager, get_torch_device
- from faceswap.business.vram_manager import run_with_oom_fallback, clear_cuda_cache
- import torch
- from PIL import Image as PILImage
- from pathlib import Path as _P
- from diffusers import (
- DiffusionPipeline, StableDiffusionPipeline, StableDiffusionXLPipeline,
- StableDiffusion3Pipeline, AutoPipelineForImage2Image,
- )
- init_vram_manager()
- device = get_torch_device()
- load_dtype = torch.float16 if device.type == "cuda" else torch.float32
- base_path = _P(params["base_model"])
- self._sculpt_sig.log.emit("加载模型...")
- _steps = [
- ("sequential_cpu_offload", {"offload": "sequential"}),
- ("model_cpu_offload", {"offload": "model"}),
- ("direct", {"offload": "direct"}),
- ]
- _pipe_holder = [None]
- def _cleanup():
- if _pipe_holder[0] is not None:
- del _pipe_holder[0]
- _pipe_holder[0] = None
- gc.collect()
- clear_cuda_cache()
- def _execute(step_params):
- offload = step_params["offload"]
- if stop_event.is_set():
- raise RuntimeError("用户停止")
- if (base_path / "model_index.json").exists():
- pipe = DiffusionPipeline.from_pretrained(
- str(base_path), torch_dtype=load_dtype,
- requires_safety_checker=False, local_files_only=True)
- else:
- arch = params["arch"]
- sf_files = list(base_path.glob("*.safetensors")) + list(base_path.glob("*.ckpt"))
- if not sf_files:
- raise FileNotFoundError(f"底座目录无模型文件: {base_path}")
- pipe_cls = StableDiffusionXLPipeline if arch == "sdxl" else (
- StableDiffusion3Pipeline if arch in ("sd3", "sd35") else StableDiffusionPipeline)
- _sf_kwargs = dict(torch_dtype=load_dtype, requires_safety_checker=False,
- local_files_only=True)
- sibling_dirs = [d for d in base_path.parent.iterdir()
- if d.is_dir() and (d / "model_index.json").exists()]
- for d in sibling_dirs:
- try:
- test_pipe = DiffusionPipeline.from_pretrained(
- str(d), torch_dtype=load_dtype, local_files_only=True)
- if type(test_pipe).__name__ == type(pipe_cls).__name__:
- _sf_kwargs["config"] = str(d)
- del test_pipe
- break
- del test_pipe
- except Exception:
- pass
- pipe = pipe_cls.from_single_file(str(sf_files[0]), **_sf_kwargs)
- if stop_event.is_set():
- raise RuntimeError("用户停止")
- _pipe_holder[0] = pipe
- if params.get("lora_path"):
- self._sculpt_sig.log.emit("加载LoRA权重...")
- pipe.load_lora_weights(params["lora_path"])
- has_ref = bool(params.get("ref_image_path"))
- if has_ref:
- pipe = AutoPipelineForImage2Image.from_pipe(pipe)
- for attr in ("tokenizer", "tokenizer_2", "tokenizer_3"):
- tok = getattr(pipe, attr, None)
- if tok is not None and hasattr(tok, "model_max_length") and tok.model_max_length > 512:
- tok.model_max_length = 77
- from faceswap.business.vram_manager import safe_to_device
- pipe, offload = safe_to_device(pipe, offload, device)
- gen_device = "cpu" if offload == "sequential" else str(device)
- generator = torch.Generator(device=gen_device).manual_seed(params["seed"])
- call_kwargs = dict(
- prompt=params["prompt"],
- negative_prompt=params["negative_prompt"],
- num_inference_steps=params["steps"],
- guidance_scale=params["cfg"],
- generator=generator,
- )
- if params.get("lora_path"):
- pipe_cls_name = type(pipe).__name__
- if "SD3" in pipe_cls_name or "StableDiffusion3" in pipe_cls_name:
- call_kwargs["joint_attention_kwargs"] = {"scale": params["lora_scale"]}
- else:
- call_kwargs["cross_attention_kwargs"] = {"scale": params["lora_scale"]}
- if has_ref:
- ref_img = PILImage.open(params["ref_image_path"]).convert("RGB")
- res = params["resolution"]
- ref_img = ref_img.resize((res, res), PILImage.LANCZOS)
- call_kwargs["image"] = ref_img
- call_kwargs["strength"] = params["strength"]
- self._sculpt_sig.log.emit("基于参考图生成...")
- else:
- call_kwargs["width"] = params["resolution"]
- call_kwargs["height"] = params["resolution"]
- self._sculpt_sig.log.emit("随机生成人脸...")
- result = pipe(**call_kwargs)
- out_img = result.images[0]
- pipe.remove_all_hooks()
- del pipe, result
- _pipe_holder[0] = None
- gc.collect()
- return out_img
- try:
- out_img = run_with_oom_fallback(_steps, _execute, _cleanup)
- self._sculpt_sig.done.emit(True, out_img)
- except Exception as e:
- import traceback
- self._sculpt_sig.log.emit(f"[错误] {e}")
- self._sculpt_sig.done.emit(False, traceback.format_exc())
- def _on_stop_sculpt(self):
- self._sculpt_stop_event.set()
- self._sculpt_log_label.setText("停止中...")
- def _on_sculpt_log(self, msg):
- self._sculpt_log_label.setText(msg)
- def _on_sculpt_done(self, success, result):
- self._sculpt_running = False
- self.set_busy(False)
- self._sculpt_stop_btn.setEnabled(False)
- self._sculpt_log_label.setVisible(False)
- if success and result is not None:
- img_array = np.array(result.convert("RGB"))
- self._sculpt_base_image = img_array.copy()
- self._sculpt_base_landmarks = self._detect_landmarks(img_array)
- if self._sculpt_base_landmarks is not None:
- self._sculpt_param_sliders.setEnabled(True)
- QMessageBox.information(self, "生成完成", f"已检测到 {len(self._sculpt_base_landmarks)} 个landmark点,可拖动滑块实时捏脸")
- else:
- self._sculpt_param_sliders.setEnabled(False)
- QMessageBox.warning(self, "Landmark检测失败", "生成完成但未检测到人脸landmark,捏脸滑块不可用。\n请确认生成的图片中包含清晰人脸。")
- self._sculpt_current_image = img_array
- self._display_image(img_array, self._sculpt_result_preview)
- from PyQt6.QtGui import QPixmap, QImage
- h, w = img_array.shape[:2]
- qimg = QImage(img_array.tobytes(), w, h, w * 3, QImage.Format.Format_RGB888)
- pm = QPixmap.fromImage(qimg)
- lw = self._sculpt_ref_preview.width()
- lh = self._sculpt_ref_preview.height()
- pm = pm.scaled(lw, lh,
- Qt.AspectRatioMode.KeepAspectRatio,
- Qt.TransformationMode.SmoothTransformation)
- self._sculpt_ref_preview.setPixmap(pm)
- self._sculpt_ref_preview.setText("")
- else:
- QMessageBox.critical(self, "生成失败", str(result))
- @staticmethod
- def _detect_landmarks(img_array: np.ndarray) -> np.ndarray | None:
- try:
- from faceswap.core.insightface_adapter import InsightFaceAdapter
- adapter = InsightFaceAdapter.get_instance()
- detected = adapter.detect_faces(img_array, max_num=1)
- if not detected:
- return None
- lm = detected[0].landmarks_106
- if lm is not None and lm.size > 0:
- return lm.astype(np.float32)
- return None
- except Exception as e:
- import traceback
- traceback.print_exc()
- return None
- def _display_image(self, img_array: np.ndarray, label: QLabel):
- from PyQt6.QtGui import QPixmap, QImage
- h, w = img_array.shape[:2]
- qimg = QImage(img_array.tobytes(), w, h, 3 * w, QImage.Format.Format_RGB888)
- pm = QPixmap.fromImage(qimg)
- lw = max(label.width(), 64)
- lh = max(label.height(), 64)
- pm = pm.scaled(lw, lh,
- Qt.AspectRatioMode.KeepAspectRatio,
- Qt.TransformationMode.SmoothTransformation)
- label.setPixmap(pm)
- label.setText("")
- def _do_translate(self, target_edit: QTextEdit, btn: QPushButton):
- text = target_edit.toPlainText().strip()
- if not text:
- return
- self.set_busy(True)
- btn.setEnabled(False)
- btn.setText("翻译中...")
- class _Sig(QObject):
- done = pyqtSignal(str)
- error = pyqtSignal(str)
- sig = _Sig()
- sig.done.connect(lambda r: self._on_translate_done(target_edit, btn, r))
- sig.error.connect(lambda m: self._on_translate_error(btn, m))
- def _worker():
- try:
- from faceswap.business.translator import translate_zh_to_en
- translated = translate_zh_to_en(text)
- sig.done.emit(translated or text)
- except Exception as e:
- sig.error.emit(str(e))
- threading.Thread(target=_worker, daemon=True).start()
- def _on_translate_done(self, target_edit: QTextEdit, btn: QPushButton, result: str):
- self.set_busy(False)
- target_edit.setPlainText(result)
- btn.setEnabled(True)
- btn.setText("翻译")
- def _on_translate_error(self, btn: QPushButton, msg: str):
- self.set_busy(False)
- btn.setEnabled(True)
- btn.setText("翻译")
- QMessageBox.warning(self, "翻译失败", msg)
- def set_busy(self, busy: bool):
- win = self.window()
- if hasattr(win, 'set_busy'):
- win.set_busy(busy)
- self._sculpt_model_selector.set_enabled(not busy)
- self._sculpt_param_sliders.setEnabled(not busy)
- self._sculpt_start_btn.setEnabled(not busy)
- self._sculpt_random_seed_btn.setEnabled(not busy)
- def release(self):
- """只在有资源时才执行释放,避免无谓的gc+cleanup。"""
- if self._sculpt_base_image is None and not getattr(self, '_sculpt_running', False):
- return
- try:
- if hasattr(self, "_sculpt_stop_event"):
- self._sculpt_stop_event.set()
- except Exception:
- pass
- self._sculpt_base_image = None
- self._sculpt_base_landmarks = None
复制代码 |
|