Files
change_hair/project/hair_service_sd/hair_grow_swap.py
T
xsl 443cfa298f 初始化:换发型/换发色/训练发型服务
包含:
- hair_service_sd: 主服务(换发型/换发色/生发,端口8801)
- photo_service: LoRA调度+训练(端口32678)
- hair_grow_service: 调试测试页(端口8888,含4个测试页)
- 批量训练脚本(batch_train_hairstyles.py)
- 发际线mask自动识别(hairline_mask.py,4种方案)
- 手绘mask换发型(hair_swap_manual.py)
- 文档:README.md + LARGE_FILES.md + docs/

大文件(模型权重200G、训练数据123G)已排除,见 LARGE_FILES.md
OSS/COS密钥已脱敏为环境变量,原文件备份在本地
2026-07-07 13:53:52 +08:00

198 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""生发模块(走换发型工作流)
和换发型 /api/swapHair/v1 完全一致的工作流:infer_hairstyle_diy_jy 粗推理
→ warpAffine → photo_service + LoRA → webui inpainting → 贴回原图。
唯一区别:mask 是 origin_matting new_matting 手绘mask 的并集,
不减刘海/眼眶。
核心函数:
hair_grow_swap(origin_img, hand_mask, hair_id, hairstyle_process,
landmark_processor, task_id, is_hr) -> BGR ndarray
"""
import os
import json
import glob
import time
import shutil
import cv2
import numpy as np
# 复用换发型的工具链
from gen_super_image import webui_img2img
from common.logger import config
from utils.landmark_processor import high_quality_warpAffine
def hair_grow_swap(origin_img, hand_mask, hair_id, hairstyle_process,
landmark_processor, task_id, is_hr=True):
"""在用户涂抹的缺发区生发(走换发型工作流 + 该发型 LoRA)。
参数:
origin_img: 用户原图 BGR ndarray
hand_mask: 用户手绘mask,灰度 ndarray,白色(255)=生发区,与 origin_img 同尺寸
hair_id: 选择的发型ID(用于加载 LoRA + 读材质/prompt
hairstyle_process: hair_init 初始化的 HairstyleModel 实例
landmark_processor: landmark_processor 模块(含 high_quality_warpAffine
task_id: 任务ID(唯一字符串)
is_hr: 是否高清模式(True=1152x1536False=576x768
返回:
BGR ndarray 生发结果图(与原图同尺寸)
"""
start_all = time.time()
# ===== 路径配置(与 swapHair 一致)=====
hairstyle_dir = config.get('default', 'hairstyleDir') # ref_hairstyle/
user_dir = config.get('default', 'userDir')
train_dir = config.get('default', 'train_dir') # train_material/
userInfo_dir = config.get('default', 'userInfo_dir')
res_dir = config.get('default', 'res_dir')
hair_material_dir = os.path.join(train_dir, hair_id) # train_material/<id>
material_save_path = os.path.join(hairstyle_dir, hair_id) # ref_hairstyle/<id>
# 校验:ref 材质必须存在(infer_hairstyle_diy_jy 依赖)
ref_img_path = os.path.join(material_save_path, "ref_rgb_8uc3_768.png")
if not os.path.exists(ref_img_path):
raise FileNotFoundError(f"发型材质不存在: {ref_img_path}")
# 保存用户原图到工作目录(infer_hairstyle_diy_jy 需要读文件路径)
user_img_name = f"hairgrow_{task_id}.jpg"
new_user_img_path = os.path.join(user_dir, user_img_name)
if os.path.exists(new_user_img_path):
os.remove(new_user_img_path)
cv2.imwrite(new_user_img_path, origin_img)
origin_img = cv2.imread(new_user_img_path)
# ===== 功能6: infer_hairstyle_diy_jy 粗推理 =====
# 产出 img_res(带新发型的粗推理图)+ 临时文件(user_orig_mask.png, hair_mask_2.png, kpt_1k.txt
start = time.time()
ref_img = cv2.imread(ref_img_path)
work_dir = os.path.join(userInfo_dir, task_id)
os.makedirs(work_dir, exist_ok=True)
import torch
with torch.no_grad():
img_res, status, _, landmarks_1k, isEyeOccluded = hairstyle_process.infer_hairstyle_diy_jy(
origin_img, ref_img, os.path.join(work_dir, task_id), f"{task_id}.png")
if status != 0:
raise RuntimeError("发型粗推理失败")
print(f"[hair_grow] 功能6 粗推理完成: {time.time()-start:.1f}s")
# ===== 功能8: warpAffine + mask 合并(核心改造点)=====
start = time.time()
user_material_dir = os.path.join(work_dir, task_id)
hair_matting_path = os.path.join(user_material_dir, "hair_mask_2.png")
user_orig_mask_path = os.path.join(user_material_dir, "user_orig_mask.png")
origin_matting = cv2.imread(user_orig_mask_path, cv2.IMREAD_GRAYSCALE)
new_matting = cv2.imread(hair_matting_path, cv2.IMREAD_GRAYSCALE)
if origin_matting is None or new_matting is None:
raise RuntimeError(f"粗推理产物缺失: origin={origin_matting is None}, new={new_matting is None}")
# 【关键差异】mask = origin_matting new_matting hand_mask
# - 不减刘海圆、不减眼眶(与换发型不同)
# - 手绘 mask 加入并集,覆盖用户指定的缺发区
# 手绘 mask 与 origin_matting 同尺寸(都是原图尺寸)
if hand_mask.shape != origin_matting.shape:
hand_mask = cv2.resize(hand_mask, (origin_matting.shape[1], origin_matting.shape[0]),
interpolation=cv2.INTER_NEAREST)
_, hand_mask_bin = cv2.threshold(hand_mask, 127, 255, cv2.THRESH_BINARY)
matting_merge = np.max(
np.stack([origin_matting, new_matting, hand_mask_bin], axis=2), axis=2
).astype(np.uint8)
# warpAffine 到固定尺寸(与换发型第514-527行完全一致)
dst_size = (1152, 1536) if is_hr else (576, 768)
box_info = hairstyle_process.get_body_info(img_res)
box_w = box_info[2] - box_info[0]
box_h = box_info[3] - box_info[1]
scale = min(dst_size[1] / max(box_h, 1), dst_size[0] / max(box_w, 1))
rotate_center = [(box_info[2] + box_info[0]) * 0.5, (box_info[3] + box_info[1]) * 0.5]
M = cv2.getRotationMatrix2D(rotate_center, 0, scale)
M[:, 2] += np.float32([dst_size[0] * 0.5, dst_size[1] * 0.5]) - np.float32(rotate_center)
crop_result = high_quality_warpAffine(img_res, M, dst_size)
crop_matting = cv2.warpAffine(matting_merge, M, dst_size)
mask = (crop_matting > 10).astype(np.float32)
# dilate(与换发型一致:非HR核3x9HR核6x18
dilate_kernel = (6, 18) if is_hr else (3, 9)
mask_dilate = cv2.dilate(mask, np.ones(dilate_kernel, np.uint8))
mask_dilate = np.clip(mask_dilate * 255, 0, 255).astype(np.uint8)
final_img = crop_result
# 读取 config.json 的 gender + 训练素材的 prompt tag(与换发型第565-578行一致)
config_json_path = os.path.join(material_save_path, "config.json")
with open(config_json_path, "r") as f:
in_gender = json.load(f)["gender"]
images_dir = os.path.join(hair_material_dir, "images")
txt_dir = os.path.join(images_dir, os.listdir(images_dir)[0])
txt_path = glob.glob(txt_dir + '/*.txt')[0]
with open(txt_path, 'r') as f:
p_tag = f.readline()
if "titor hairstyle, faceless, no human, gray background, simple background" in p_tag:
p_tag = p_tag[p_tag.find("simple background, ") + len("simple background, "):]
else:
p_tag = ""
print(f"[hair_grow] 功能8 mask合并+warpAffine完成: {time.time()-start:.1f}s, "
f"in_gender={in_gender}")
# ===== 功能9: webui 推理(走 photo_service + LoRA,复用 webui_img2img=====
start = time.time()
sd_result = webui_img2img(
img=final_img, mask_img=mask_dilate,
in_gender=in_gender, task_id=task_id,
hair_id=hair_id, lora_material_path=hair_material_dir,
tag=p_tag, is_hr=is_hr,
denoising_strength=0.6, inference_port="57860")
print(f"[hair_grow] 功能9 webui推理完成: {time.time()-start:.1f}s")
# ===== 后处理: warpAffine 贴回原图(与换发型第601-605行一致)=====
M_inv = cv2.invertAffineTransform(M)
result = origin_img.copy()
cv2.warpAffine(sd_result, M_inv, (origin_img.shape[1], origin_img.shape[0]),
dst=result, borderMode=cv2.BORDER_TRANSPARENT, flags=cv2.INTER_LANCZOS4)
# 清理临时文件
try:
shutil.rmtree(user_material_dir)
os.remove(new_user_img_path)
except Exception:
pass
print(f"[hair_grow] 总耗时: {time.time()-start_all:.1f}s")
return result
def list_hairstyles(hairstyle_dir, train_dir, upload_dir, limit=None):
"""列出所有完整可用的发型。
参数:
hairstyle_dir: ref_hairstyle/ 目录
train_dir: train_material/ 目录
upload_dir: upload_train_imgs/ 目录
limit: 限制返回数量(测试用)
返回:
[{hair_id, gender, has_lora}, ...]
"""
styles = []
if not os.path.isdir(hairstyle_dir):
return styles
for hair_id in os.listdir(hairstyle_dir):
cfg_path = os.path.join(hairstyle_dir, hair_id, "config.json")
ref_path = os.path.join(hairstyle_dir, hair_id, "ref_rgb_8uc3_768.png")
lora_path = os.path.join(train_dir, hair_id, "model", "hairstyle_hd_lora.safetensors")
if not (os.path.exists(cfg_path) and os.path.exists(ref_path) and os.path.exists(lora_path)):
continue
try:
with open(cfg_path) as f:
gender = json.load(f).get("gender", "unknown")
except Exception:
gender = "unknown"
styles.append({"hair_id": hair_id, "gender": gender})
if limit and len(styles) >= limit:
break
return styles