Files
change_hair/project/hair_service_sd/hair_grow.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

318 lines
14 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 -*-
"""区域生发模块
在用户指定的 mask 区域内,通过 webui inpainting 生成头发(生发)。
不依赖换发型的 photo_service/LoRA 链路,直接调 webui 的 /sdapi/v1/img2img。
核心函数:
hair_grow(img, mask, strength) -> BGR ndarray
"""
import os
import cv2
import time
import base64
import numpy as np
import requests
# webui 地址:优先用环境变量,否则默认本机 57860
# (不依赖 common.logger,使本模块可被独立服务直接 import)
WEBUI_URL = os.environ.get("WEBUI_URL", "http://0.0.0.0:57860/")
# 固定随机种子,保证同输入同输出(可复现)
SEED = 123456789
def _encode_numpy_to_base64(img):
"""ndarray -> 裸 base64 字符串(webui sdapi 约定,不带 data: 前缀)"""
retval, bytes = cv2.imencode('.png', img)
return base64.b64encode(bytes).decode('utf-8')
def _webui_inpaint(img, mask, prompt, negative_prompt,
denoising_strength, mask_blur, steps=30, cfg_scale=7.0):
"""直连 webui img2img inpainting。
参数:
img: BGR ndarray (H,W,3) 原图
mask: 灰度 ndarray (H,W),白色(255)=重绘区
prompt / negative_prompt: 文本提示
denoising_strength: 重绘强度 0~1
mask_blur: mask 边缘羽化像素数
返回:
BGR ndarray 生发结果
"""
url = f"{WEBUI_URL}sdapi/v1/img2img"
request_dict = {
"prompt": prompt,
"negative_prompt": negative_prompt,
"sampler_name": "DPM++ 2M Karras",
"batch_size": 1,
"steps": steps,
"width": img.shape[1],
"height": img.shape[0],
"cfg_scale": cfg_scale,
"seed": SEED,
"mask_blur": mask_blur,
"init_images": [_encode_numpy_to_base64(img)],
"inpaint_full_res": False, # 全图重绘模式,mask 像素精确对应原图
"inpainting_fill": 1, # mask 区填噪声后去噪(即 inpaint)
"inpainting_mask_invert": 0, # 重绘 mask 白色区域
"mask": _encode_numpy_to_base64(mask),
"denoising_strength": denoising_strength,
"alwayson_scripts": {}
}
start = time.time()
response = requests.post(url=url, json=request_dict, timeout=300)
ret_json = response.json()
if 'images' not in ret_json or not ret_json['images']:
err = ret_json.get('detail') or ret_json.get('error') or str(ret_json)
raise RuntimeError(f"webui inpainting 返回异常: {err}")
result_b64 = ret_json['images'][0]
# 去掉可能的 data:image/png;base64, 前缀
if "," in result_b64 and result_b64.startswith("data:"):
result_b64 = result_b64.split(",", 1)[1]
result_img = cv2.imdecode(
np.frombuffer(base64.b64decode(result_b64), np.uint8), cv2.IMREAD_COLOR)
print(f"[hair_grow] webui inpainting 完成,耗时 {time.time()-start:.1f}s, "
f"denoising={denoising_strength}, mask_blur={mask_blur}")
return result_img
def _estimate_hair_color(img, mask):
"""从原图估计头发的颜色统计(LAB空间均值/方差)。
策略:在 mask 周边(外扩区域)取"深色像素"作为已有头发样本。
用户涂抹的生发区周边必然紧邻真实头发,比从图像顶部取样更可靠。
返回 (mean_l, mean_a, mean_b), (std_l, std_a, std_b) in LAB。
"""
h, w = img.shape[:2]
lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB).astype(np.float32)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
mb = mask > 127
# 1. mask 外扩一圈(25px),作为"周边采样区"
dilated = cv2.dilate(mb.astype(np.uint8) * 255, np.ones((51, 51), np.uint8))
surround = (dilated > 0) & (~mb)
# 2. 取周边全部像素作为参考(含头发+皮肤过渡区,色调更平衡)
# 单独筛"深色"会偏向阴影色导致发黑,用全采样更稳健
if surround.sum() > 200:
samples = lab[surround]
mean = samples.mean(axis=0)
# 方差用原图全局方差(保证生发区有合理色彩范围,不发灰)
global_std = lab.reshape(-1, 3).std(axis=0)
std = np.maximum(global_std * 0.7, [15, 8, 8])
print(f"[hair_grow] 头发色估计(mask周边全采样) LAB: 均值={mean.astype(int)} "
f"方差={std.astype(int)} 样本{surround.sum()}px")
return mean, std
# 3. fallback: 图像顶部深色像素
top_gray = gray[:max(1, int(h * 0.25))]
top_lab = lab[:max(1, int(h * 0.25))]
thresh = np.percentile(top_gray, 30)
hair_mask = (top_gray <= thresh) & (top_gray >= 5)
samples = top_lab[hair_mask] if hair_mask.sum() > 200 else top_lab.reshape(-1, 3)
mean = samples.mean(axis=0)
std = np.maximum(samples.std(axis=0), [15, 8, 8])
print(f"[hair_grow] 头发色估计(顶部fallback) LAB: 均值={mean.astype(int)} "
f"方差={std.astype(int)} 样本{len(samples)}px")
return mean, std
def _color_transfer_to_hair(result_img, orig_img, mask):
"""让生发区域颜色与原图自然融合。
放弃强制染色(会导致偏绿/偏蓝),改用:
1. 保留 webui 生成的原始颜色和纹理(自然发色)
2. 轻度亮度/对比度匹配,让生发区与周边亮度一致
3. 轻微降低饱和度(防止异常色相凸显)
"""
mb = mask > 127
if not mb.any():
return result_img
# 1. 亮度匹配:让生发区平均亮度对齐周边
dilated = cv2.dilate(mb.astype(np.uint8) * 255, np.ones((31, 31), np.uint8))
surround = (dilated > 0) & (~mb)
if surround.sum() > 100:
orig_gray = cv2.cvtColor(orig_img, cv2.COLOR_BGR2GRAY)
ref_lum = orig_gray[surround].mean()
else:
ref_lum = cv2.cvtColor(orig_img, cv2.COLOR_BGR2GRAY).mean()
res_gray = cv2.cvtColor(result_img, cv2.COLOR_BGR2GRAY)
grow_lum = res_gray[mb].mean() if mb.sum() > 0 else ref_lum
if grow_lum > 5:
lum_ratio = np.clip(ref_lum / grow_lum, 0.7, 1.3)
else:
lum_ratio = 1.0
# 2. 轻度饱和度降低 + 去除异常色相(绿/蓝/青)
# SD1.5 生成头发时常出现偏绿/偏蓝,需要把异常色相拉回头发自然色(棕/灰)
res_hsv = cv2.cvtColor(result_img, cv2.COLOR_BGR2HSV).astype(np.float32)
# 2a. 饱和度降低15%
res_hsv[mb, 1] *= 0.85
# 2b. 亮度匹配
res_hsv[mb, 2] *= lum_ratio
# 2c. 去除绿色/青色/蓝色色相:
# OpenCV HSV: H=0-180。绿35-85, 青85-105, 蓝105-130
# 把这些异常色相改成棕色(H≈15-25,头发的自然色)
h_flat = res_hsv[:, :, 0][mb].copy()
s_flat = res_hsv[:, :, 1][mb].copy()
abnormal = (h_flat >= 35) & (h_flat <= 130) # 绿~蓝全范围
# 异常色相 → 棕色(H=20),饱和度降到40%(保留一些色调但不刺眼)
h_flat[abnormal] = 20.0
s_flat[abnormal] *= 0.4
res_hsv[:, :, 0][mb] = h_flat
res_hsv[:, :, 1][mb] = s_flat
res_hsv = np.clip(res_hsv, 0, 255).astype(np.uint8)
result = cv2.cvtColor(res_hsv, cv2.COLOR_HSV2BGR)
print(f"[hair_grow] 去色完成: 异常色相(绿/青/蓝)像素 {abnormal.mean()*100:.0f}% 已转为棕色")
return result
def _seamless_blend(result_img, orig_img, mask):
"""把生发区域贴回原图,消除拼接痕迹。
关键原则:mask 内部全部用生发结果(不让皮肤透出形成白边),
只在 mask 外侧做轻微羽化过渡。
"""
mb = mask > 127
if mb.sum() < 10:
return result_img
# 1. alpha: mask 内 = 1.0(全覆盖,不透出原图皮肤),mask 外 = 0
alpha = mb.astype(np.float32)
# 2. mask 边缘内缩 2px(避免最边缘像素因生成质量差透出),
# 但不渐变到0——用腐蚀保证内边缘是实心的
eroded = cv2.erode(mb.astype(np.uint8) * 255, np.ones((5, 5), np.uint8))
alpha = np.maximum(alpha, eroded.astype(np.float32) / 255)
# 3. mask 外侧轻微羽化(让生发边缘自然延伸 3-4px 到皮肤,柔化过渡)
# 用很小的 GaussianBlur,不会让 mask 内部透出皮肤
alpha = cv2.GaussianBlur(alpha, (7, 7), sigmaX=1.5)
alpha = np.clip(alpha, 0, 1.0)
# 4. 亮度微调:生发区与周边的亮度适度靠拢(80%生发 + 20%周边)
# 避免头发过暗形成明显色块,但又保留头发的深色调
dil_outer = cv2.dilate(mb.astype(np.uint8) * 255, np.ones((31, 31), np.uint8))
ring_outer = (dil_outer > 0) & (~mb)
if ring_outer.sum() > 100:
orig_gray = cv2.cvtColor(orig_img, cv2.COLOR_BGR2GRAY)
ref_lum = orig_gray[ring_outer].mean()
res_gray = cv2.cvtColor(result_img, cv2.COLOR_BGR2GRAY)
grow_lum = res_gray[mb].mean() if mb.sum() > 0 else ref_lum
if grow_lum > 5:
# 生发区亮度向周边靠拢20%(不至于让头发太亮,但减少色块感)
lum_ratio = np.clip(ref_lum / grow_lum, 0.8, 1.2)
# 只对较暗的生发区提亮一点
result_adj = result_img.astype(np.float32).copy()
dark_pixels = res_gray[mb] < ref_lum * 0.85
mb_idx = np.where(mb)
# 对暗像素适度提亮
res_gray_full = res_gray.astype(np.float32)
lift = np.clip((ref_lum * 0.9 - res_gray_full) / np.maximum(res_gray_full, 1), 0, 0.3)
result_adj = np.clip(result_adj * (1 + lift[..., None] * 0.5), 0, 255).astype(np.uint8)
else:
result_adj = result_img
else:
result_adj = result_img
# 5. alpha 混合
alpha3 = alpha[..., None]
blended = (result_adj.astype(np.float32) * alpha3 +
orig_img.astype(np.float32) * (1 - alpha3))
return np.clip(blended, 0, 255).astype(np.uint8)
def hair_grow(img, mask, strength=0.5):
"""在 mask 区域内生发。
参数:
img: BGR ndarray (H,W,3) 人头像原图
mask: 灰度 ndarray (H,W),与 img 同分辨率,白色(255)=生发区
strength: 生发强度 0.1~1.0,控制重绘强度与边缘处理
- 低强度(0.1~0.3): 轻微生发,严格保持 mask 边界
- 中强度(0.4~0.6): 明显生发
- 高强度(0.7~1.0): 浓密生发,mask 轻微膨胀+高羽化让边缘自然过渡
返回:
BGR ndarray 生发结果图(与原图同尺寸)
"""
# ---- 参数校验 ----
if img is None or mask is None:
raise ValueError("img 和 mask 不能为空")
if img.ndim != 3 or mask.ndim != 2:
raise ValueError(f"img 须为3通道BGR(H,W,3)mask 须为单通道灰度(H,W)"
f"got img.ndim={img.ndim}, mask.ndim={mask.ndim}")
if img.shape[:2] != mask.shape[:2]:
raise ValueError(f"img 与 mask 分辨率不一致: img={img.shape[:2]}, mask={mask.shape[:2]}")
# 二值化 mask(防止用户传入带灰度的 mask)
_, mask_bin = cv2.threshold(mask, 127, 255, cv2.THRESH_BINARY)
if cv2.countNonZero(mask_bin) == 0:
raise ValueError("mask 全黑,没有指定生发区域")
# ---- 强度参数映射 ----
# strength -> denoising_strength (0.7~0.85)
# 经实测:生发需要 denoise≥0.7 才能生成全新头发纹理(低于此值只会磨皮)
# denoise 0.85 是甜点(纹理量最高),超过 0.9 反而细节下降
denoising_strength = 0.7 + float(strength) * 0.15
# strength -> mask_blur (4~8),边缘羽化
mask_blur = int(4 + float(strength) * 4)
# ---- mask 边缘处理(强度控制)----
# 高强度时 mask 轻微 dilate,让生发边缘与原有头发自然衔接
work_mask = mask_bin
if strength >= 0.7:
work_mask = cv2.dilate(work_mask, np.ones((3, 3), np.uint8), iterations=1)
print(f"[hair_grow] 高强度({strength})mask 已 dilate 3px")
# ---- 生发 prompt ----
# 经实测:SD1.5 inpainting 对极简短词最敏感,冗长描述反而效果差(纹理量降30%+)
prompt = "hair, thick hair"
negative_prompt = ("(bald:1.5), (skin:1.4), smooth skin, scalp, "
"(green:1.5), (blue:1.5), (cyan:1.4), (green hair:1.5), "
"(colored hair:1.3), low quality, blurry")
# ---- 尺寸对齐到8的倍数(SD要求) ----
# webui 内部会把图 resize 到 8 的倍数,这里提前对齐,保证结果与原图同尺寸
orig_h, orig_w = img.shape[:2]
align_w = orig_w - (orig_w % 8)
align_h = orig_h - (orig_h % 8)
if (align_w, align_h) != (orig_w, orig_h):
img_in = cv2.resize(img, (align_w, align_h), interpolation=cv2.INTER_AREA)
mask_in = cv2.resize(work_mask, (align_w, align_h), interpolation=cv2.INTER_NEAREST)
else:
img_in, mask_in = img, work_mask
# ---- 调 webui inpainting ----
result_img = _webui_inpaint(
img_in, mask_in, prompt, negative_prompt,
denoising_strength=denoising_strength,
mask_blur=mask_blur,
steps=40, cfg_scale=7.0 # 40步(高于30)减少镂空,细节更连续
)
# ---- 结果 resize 回原图尺寸 ----
if result_img.shape[:2] != (orig_h, orig_w):
result_img = cv2.resize(result_img, (orig_w, orig_h), interpolation=cv2.INTER_LANCZOS4)
# mask 也 resize 回原图尺寸,后续后处理要用
mask_full = cv2.resize(mask_in, (orig_w, orig_h), interpolation=cv2.INTER_NEAREST)
# ---- 后处理(顺序很重要)----
# 1. 先泊松融合:消除生发区与原图的拼接痕迹(结构层面无缝)
result_img = _seamless_blend(result_img, img, mask_full)
print("[hair_grow] 泊松融合完成(消除拼接痕迹)")
# 2. 再颜色迁移:泊松融合会把颜色拉向周边皮肤,需重新染回头发色
# (放最后,确保最终颜色与原图头发一致)
result_img = _color_transfer_to_hair(result_img, img, mask_full)
print("[hair_grow] 颜色迁移完成(生发区已对齐原图发色)")
return result_img