Files
hair/face_analysis/hairline_grow.py
T
xsl 41bb164a52 接口11/12:新增发际线外推遮罩(pushed)模式 + multiband融合修复 + 调试日志 + 对比报告脚本
发际线生发遮罩算法(mask_type=pushed):
- _extract_hairline:提取头发/皮肤交界线(逐列头发下沿),用 baseline 水平 y 线截断(无竖线)
- _pushed_mask:以眉心(151点)为圆心逐点径向外推 push_cm,与 baseline 组闭合区域
- 径向归并锯齿用插值填补,避免遮罩碎裂
- pushed 模式过程可视化(①-f 交界线 / ①-g 外推+遮罩),eroded/closed 不展示无关步骤

multiband 金字塔融合修复(hairline_grow.py):
- mb_levels 按层数膨胀外缘 keep 区,让过渡带随层数变宽(旧硬二值钳回导致 mb_levels 形同虚设)

接口12 grow_v2(固定参数精简版):
- 固定 multiband/mb_levels=5/erode_cm=0.6,仅返回 final_base64
- 支持 mask_type=pushed + hairline_push_cm/hairline_edge

调试支持:
- 调试页 test_interface11_debug.html(前后端日志面板 + 下载日志按钮)
- hairline_grow.log 全链路日志(按 rid 关联),/api/v1/debug/hairline_log 下载接口
- 遮罩计算过程可视化(baseline/upper/头发分割/交界线/外推/最终遮罩)

文档与脚本:
- docs/发际线生发遮罩算法_pushed模式.md 算法说明
- scripts/batch_grow_v2.py 批量调用、gen_report_hairline_v2.py 对比报告生成
2026-07-11 22:23:57 +08:00

744 lines
35 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.
"""接口11:发际线生发。
输入一张发际线较高 / 头发稀少的正脸图 + 发际线类型 ID= change_hair 的 hair_id
如 chang_tuoyuan/chang_bolang/...),输出同一个人、同一发型、按该发际线类型压低发际线
后的图片。管线(见 docs/发际线增强算法.md):
1. 用接口9 的算法算出头发遮罩(含额头闭合区域,外缘内缩 erode_cm)。
—— seg_model 选 bisenet/segformermask_type 选 eroded(内缩)/closed(未内缩闭合区域)。
2. 调 change_hair 换发型服务(/api/swapHair/v1)生成该发际线类型的图。返回的结果图已被
change_hair 用 M_inv 贴回、与输入原图**同分辨率同对齐**,可直接按遮罩合成。
两种取图模式(swap_mode):
- ext_mask:把步骤1 的遮罩作为 ext_mask 传给 swapHair,让 webui 精确重绘该区域
(忠于算法文档「换发型的遮罩用接口9 遮罩」)。
- as_is:不改 change_hairswapHair 用它自己的内部遮罩,贴回时再裁到接口9 遮罩。
3. 严格按接口9 遮罩把生成图贴回原图(遮罩外=原图,纹丝不动)。
4. 融合接缝:blend_method 选 feather(高斯羽化) / alpha_gradient(距离变换内渐变) /
seamless(泊松无缝克隆) / multiband(多频段金字塔融合)feather_px、edge_erode_px
控制过渡细节(feather_px 仅 feather/alpha_gradient 用;multiband 用 mb_levels 控制金字塔层数)。
可选 color_match=True 先在遮罩区做 Reinhard 颜色统计迁移,消除生成图与原图
的整体色差(对 feather/alpha_gradient/multiband 有效;seamless 自带色彩调和,自动跳过)。
对外返回每一步可视化(base64,data URI),供测试页逐步展示。经网关时 *_base64 字段会被
落盘改写为 *_url。
"""
import base64
import logging
import os
import time
from uuid import uuid4
import cv2
import numpy as np
from face_analysis.detector import detector
from face_analysis.calibration import estimate_scale_factor
from face_analysis.head_mask import (
NoFaceError,
_baseline_points,
_upper_region_mask,
_bisenet_hair_mask,
_segformer_hair_mask,
_fill_to_baseline,
_erode,
_largest_cc,
_overlay,
_draw_baseline,
)
# 调试日志:写 /home/xsl/hair/log/hairline_grow.log,每个步骤详细记录
_LOG_DIR = "/home/xsl/hair/log"
os.makedirs(_LOG_DIR, exist_ok=True)
logger = logging.getLogger("hairline_grow")
_log_fh = logging.FileHandler(os.path.join(_LOG_DIR, "hairline_grow.log"), encoding="utf-8")
_log_fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
logger.addHandler(_log_fh)
logger.setLevel(logging.DEBUG)
# change_hair 服务地址(可用环境变量覆盖)
SWAP_URL = os.getenv("SWAP_HAIR_URL", "http://127.0.0.1:8801/api/swapHair/v1")
HAIRGROW_URL = os.getenv("HAIR_GROW_URL", "http://127.0.0.1:8801/api/hairGrow/v1")
SWAP_TIMEOUT = float(os.getenv("SWAP_HAIR_TIMEOUT", "300"))
DEFAULTS = {
"gen_backend": "swaphair", # swaphair(换发型LoRA) | hairgrow(区域生发inpaint)
"is_hr": False,
"seg_model": "segformer", # bisenet | segformer
"mask_type": "eroded", # eroded | closed
"erode_cm": 1.2,
"swap_mode": "ext_mask", # ext_mask | as_is(仅 swaphair
"denoising_strength": 0.6, # 仅 swaphair
"hairgrow_strength": 0.75, # 仅 hairgrow
"blend_method": "feather", # feather | alpha_gradient | seamless | multiband
"feather_px": 15,
"edge_erode_px": 3,
"color_match": False, # True 时对生成图做 Reinhard 颜色校正(seamless 下自动跳过)
"mb_levels": 5, # multiband 金字塔层数(2~6,越大色差抹得越宽)
}
class SwapError(Exception):
"""调用 change_hair 换发型服务失败。"""
# ---------------------------------------------------------------------------
# 编码
# ---------------------------------------------------------------------------
def _jpg_b64(bgr):
ok, buf = cv2.imencode(".jpg", bgr, [cv2.IMWRITE_JPEG_QUALITY, 92])
return "data:image/jpeg;base64," + base64.b64encode(buf.tobytes()).decode()
def _png_b64(bgr_or_gray):
ok, buf = cv2.imencode(".png", bgr_or_gray)
return "data:image/png;base64," + base64.b64encode(buf.tobytes()).decode()
def _gray_b64(gray_float):
"""0~1 的浮点图 → 灰度 PNG data URI。"""
g = np.clip(gray_float * 255.0, 0, 255).astype(np.uint8)
return _png_b64(g)
# ---------------------------------------------------------------------------
# 步骤1:接口9 头发遮罩(复用 head_mask 构件)
# ---------------------------------------------------------------------------
def _extract_hairline(hair_mask, upper=None, mode="column", rid="",
baseline_pts=None, band_expand_px=0):
"""提取头发/皮肤交界线(头发区域内轮廓朝脸一侧),用 baseline 水平 y 线截断。
直接取头发区域的最大轮廓(头发/皮肤交界),逐列取最靠下的边界点作为该列发际线 y。
然后用 baseline 的 y 值做水平截断:只保留每列发际线 y < baseline_y 的部分
baseline 线以上=额头+发际线区域;baseline 以下=脸下半部,丢弃)。无任何竖线。
upper:兼容旧签名保留,不参与计算。
baseline_ptsbaseline 关键点,其折线 y 值用于水平截断。
band_expand_px:兼容签名,不再使用(已不竖向截断)。
modecontour(轮廓,推荐)| column(逐列下沿,兜底)。
返回 (hairline_y, lo, hi) —— hairline_y: 长度=w 的 y 数组(baseline 以下或无轮廓处置 NaN)。
lo/hi: 发际线有效范围的首末列(用于 _pushed_mask 限定填充范围)。
"""
lg = lambda msg: logger.info("[%s] %s", rid, msg) if rid else None
h, w = hair_mask.shape
col_down = np.full(w, -1, dtype=np.int32)
if mode == "contour":
mask_u8 = hair_mask.astype(np.uint8)
if mask_u8.sum() > 0:
cnts, _ = cv2.findContours(mask_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
lg(f"_extract_hairline contour: 轮廓数={len(cnts) if cnts else 0}")
if cnts:
big = max(cnts, key=cv2.contourArea)
lg(f" 最大轮廓面积={cv2.contourArea(big):.0f}")
for pt in big[:, 0]:
x, y = int(pt[0]), int(pt[1])
if y > col_down[x]:
col_down[x] = y
else: # column(兜底:逐列下沿,与 contour 结果接近)
cols = np.where(hair_mask.any(axis=0))[0]
lg(f"_extract_hairline column: 有头发的列数={len(cols)}/{w}")
for x in cols:
col_down[x] = int(np.where(hair_mask[:, x])[0].max())
valid = col_down >= 0
if valid.sum() < 2:
lg(f" 警告: 有效列<2,退化为空发际线")
nan = np.full(w, np.nan)
return nan, 0, w - 1
xs = np.where(valid)[0]
ys = col_down[valid].astype(np.float64)
raw = np.interp(np.arange(w), xs, ys)
raw = np.clip(raw, 0, h - 1)
# baseline 每列的 y(水平截断线:发际线 y 必须 < baseline_y 才保留)
if baseline_pts is not None and len(baseline_pts) >= 2:
x0, y0 = baseline_pts[0]
x1, y1 = baseline_pts[-1]
chain_x = np.array([0] + [p[0] for p in baseline_pts] + [w - 1])
chain_y = np.array([y0] + [p[1] for p in baseline_pts] + [y1])
baseline_y = np.interp(np.arange(w), chain_x, chain_y)
# 水平截断:只保留发际线在 baseline 以上(y 更小)的列
keep = raw < baseline_y
out = np.where(keep, raw, np.nan)
valid_cols = np.where(keep)[0]
lo = int(valid_cols.min()) if len(valid_cols) else 0
hi = int(valid_cols.max()) if len(valid_cols) else w - 1
lg(f" baseline 水平截断: baseline_y范围[{int(baseline_y.min())},{int(baseline_y.max())}] "
f"保留{keep.sum()}列 → 发际线范围[{lo},{hi}]")
else:
out = raw
lo, hi = 0, w - 1
lg(f" 无 baseline,取全宽")
return out, lo, hi
def _pushed_mask(hair_mask, upper, baseline_pts, push_px, mode, rid="",
center=None, band_expand_px=0):
"""发际线外推遮罩:发际线(仅额带凹处)以眉心为圆心逐点径向外推 push_px,
与 baseline 组闭合区域。
center: 圆心 (cx, cy),一般取 151 点(眉心)。
band_expand_px: 额带边界(黄竖线)两侧再外扩的像素数。
返回 (mask_bool, hairline_y, pushed_y, lo, hi)
hairline_y/pushed_y —— 长度=w 的 y 数组(额带外 NaN),每列发际线下沿/外推后下沿。
注:径向外推后,同一列可能出现多个外推点,这里按列取最靠上的作为遮罩顶界。
lo/hi —— 扩展后的额带左右边界列。
"""
lg = lambda msg: logger.info("[%s] %s", rid, msg) if rid else None
h, w = hair_mask.shape
# 截断完全交给 _extract_hairline(用 baseline 范围 + 两侧外扩)
hairline_y, lo, hi = _extract_hairline(hair_mask, upper, mode, rid=rid,
baseline_pts=baseline_pts, band_expand_px=band_expand_px)
valid = ~np.isnan(hairline_y)
hairline_y = np.where(valid, np.clip(hairline_y, 0, h - 1), np.nan).astype(np.float64)
valid = ~np.isnan(hairline_y)
lg(f"_pushed_mask: push_px={push_px} 额带(baseline截断)[{lo},{hi}] 圆心={center}")
# 逐点径向外推:每个发际线点沿「从圆心指向它」的方向往外推 push_px。
# 外推后新位置可能不在原列,按列收集所有外推点取最靠上的 y 作为该列遮罩顶界。
pushed_y = np.full(w, np.nan)
if center is not None and valid.any():
cx, cy = float(center[0]), float(center[1])
xs = np.where(valid)[0]
ys = hairline_y[valid]
# 每个点的径向方向(从圆心指向该点),单位向量
dx = xs - cx
dy = ys - cy
dist = np.sqrt(dx * dx + dy * dy)
dist = np.where(dist < 1e-3, 1.0, dist) # 圆心点本身防除零
ux, uy = dx / dist, dy / dist
# 外推后的新坐标
nx = xs + ux * push_px
ny = ys + uy * push_px
ny = np.clip(ny, 0, h - 1)
# 按新 x 做最近邻归并到整数列,取每列最小 ny(最靠上=遮罩顶界)
nx_int = np.clip(np.round(nx).astype(int), 0, w - 1)
for xi, yi in zip(nx_int, ny):
if np.isnan(pushed_y[xi]) or yi < pushed_y[xi]:
pushed_y[xi] = yi
lg(f" 径向外推: 推前y范围[{int(np.nanmin(ys))},{int(np.nanmax(ys))}] "
f"推后y范围[{int(np.nanmin(ny))},{int(np.nanmax(ny))}]")
# 径向归并会产生空列(锯齿),在额带 [lo,hi] 内插值填补,保证遮罩顶界连续
v3 = ~np.isnan(pushed_y)
if v3.any():
xv = np.where(v3)[0]
yv = pushed_y[v3]
pushed_y[lo:hi + 1] = np.interp(np.arange(lo, hi + 1), xv, yv)
else:
# 无圆心:退化为统一往上推
pushed_y = hairline_y - int(push_px)
v2 = ~np.isnan(pushed_y)
pushed_y = np.where(v2, np.clip(pushed_y, 0, h - 1), np.nan)
# baseline 每列的 y
x0, y0 = baseline_pts[0]
x1, y1 = baseline_pts[-1]
chain_x = np.array([0] + [p[0] for p in baseline_pts] + [w - 1])
chain_y = np.array([y0] + [p[1] for p in baseline_pts] + [y1])
baseline_y = np.interp(np.arange(w), chain_x, chain_y).astype(np.int32)
# 逐列填充:仅额带内、且 pushed_y < baseline_y 的列
mask = np.zeros((h, w), dtype=bool)
fill_valid = ~np.isnan(pushed_y)
cols = np.where(fill_valid & (pushed_y.astype(int) < baseline_y))[0]
lg(f" 有效填充列数={len(cols)} (额带内且 pushed_y<baseline_y)")
for x in cols:
mask[int(pushed_y[x]):baseline_y[x] + 1, x] = True
lg(f" 填充后 mask 像素={int(mask.sum())}")
mask = _largest_cc(mask & upper)
return mask, hairline_y, pushed_y, lo, hi
def _draw_curve(image, y_per_col, color, thickness=3):
"""把"每列一个 y"的曲线画到图上(用于发际线/外推线可视化)。NaN 列跳过。"""
out = image.copy()
pts = []
segs = []
for x in range(len(y_per_col)):
v = y_per_col[x]
if np.isnan(v):
if len(pts) >= 2:
segs.append(np.array(pts, dtype=np.int32))
pts = []
else:
pts.append([x, int(v)])
if len(pts) >= 2:
segs.append(np.array(pts, dtype=np.int32))
for seg in segs:
cv2.polylines(out, [seg], isClosed=False, color=color, thickness=thickness, lineType=cv2.LINE_AA)
return out
def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm,
hairline_push_cm=0.0, hairline_edge="column", rid=""):
"""算出布尔遮罩 + 可视化。
seg_model: bisenet | segformer。
mask_type: eroded(外缘内缩) | closed(闭合区域未内缩) | pushed(发际线外推)。
hairline_push_cm: 仅 pushed 模式——发际线往头发方向外推的厘米数(进入现有头发)。
hairline_edge: 仅 pushed 模式——发际线提取方式 column(逐列最低点) | contour(形态学轮廓)。
rid: 调用方的 request id,用于日志关联。
返回 (mask_bool, viz_dict)。
"""
lg = lambda msg: logger.info("[%s] %s", rid, msg) if rid else None
lg(f"compute_mask 入参: mask_type={mask_type!r} erode_cm={erode_cm} "
f"px_per_cm={px_per_cm:.3f} hairline_push_cm={hairline_push_cm} hairline_edge={hairline_edge!r}")
h, w = image_bgr.shape[:2]
r = int(round(max(0.0, erode_cm) * px_per_cm))
lg(f"图像尺寸 {w}x{h}, erode_px={r}")
baseline_pts = _baseline_points(landmarks, w, h)
upper = _upper_region_mask(baseline_pts, w, h)
lg(f"baseline 第一点={baseline_pts[0]} 末点={baseline_pts[-1]} upper像素={int(upper.sum())}")
if seg_model == "bisenet":
hair_mask = _bisenet_hair_mask(image_bgr, landmarks, w, h)
elif seg_model == "segformer":
hair_mask = _segformer_hair_mask(image_bgr)
else:
raise ValueError(f"未知 seg_model: {seg_model}")
lg(f"头发分割完成 seg_model={seg_model} hair_pixels={int(hair_mask.sum())}")
top_fill = _fill_to_baseline(hair_mask, upper) # 含额头,延伸到图底
closed = _largest_cc(top_fill & upper) # 闭合区域:头发+额头,底=基线
eroded = _largest_cc(_erode(top_fill, r) & upper) # 外缘内缩 r、底线不动
lg(f"旧流程: top_fill像素={int(top_fill.sum())} closed像素={int(closed.sum())} eroded像素={int(eroded.sum())}")
# pushed 模式:发际线外推遮罩(额外保留 hairline_y/pushed_y/额带边界 供可视化)
pushed_info = None
if mask_type == "pushed":
push_px = int(round(max(0.0, hairline_push_cm) * px_per_cm))
# 圆心 = 151 点(眉心)完整坐标,用于额带搜索起点 + 径向外推圆心
center = baseline_pts[5] if len(baseline_pts) > 5 else None
# 额带边界两侧各外扩 1cm(像素)
band_expand_px = int(round(1.0 * px_per_cm))
lg(f"进入 PUSHED 分支: push_px={push_px} 圆心(151)={center} band_expand={band_expand_px}px "
f"edge={hairline_edge}")
mask_bool, hairline_y, pushed_y, lo, hi = _pushed_mask(
hair_mask, upper, baseline_pts, push_px, hairline_edge, rid=rid,
center=center, band_expand_px=band_expand_px)
pushed_info = (hairline_y, pushed_y, push_px, lo, hi)
lg(f"PUSHED 结果: 额带[{lo},{hi}] mask_pixels={int(mask_bool.sum())}")
elif mask_type == "eroded":
mask_bool = eroded
lg(f"进入 ERODED 分支: 用 eroded 遮罩 pixels={int(eroded.sum())}")
else:
mask_bool = closed
lg(f"进入 CLOSED 分支: 用 closed 遮罩 pixels={int(closed.sum())}")
lg(f"最终遮罩 mask_type={mask_type} mask_pixels={int(mask_bool.sum())}")
# 遮罩计算过程可视化:
# eroded/closed 走 top_fill→closed/eroded 流程;
# pushed 走 baseline→头发分割→发际线→外推 流程,与 top_fill/closed 无关,故置空。
viz = {
"erode_px": r,
"hair_pixels": int(hair_mask.sum()),
"closed_pixels": int(closed.sum()),
"mask_pixels": int(mask_bool.sum()),
# 1. 发际线分割线(baseline):151 中心点标红,其余点标绿,黄线含左右延长线
"baseline_overlay_base64": _jpg_b64(_draw_baseline(image_bgr, baseline_pts, w)),
# 2. 分割线以上区域(upper 半区):青色叠加
"upper_overlay_base64": _jpg_b64(_overlay(image_bgr, upper, (0, 255, 255))),
# 3. 头发分割原始结果(hair_mask):绿色叠加在原图上
"hair_seg_overlay_base64": _jpg_b64(_overlay(image_bgr, hair_mask, (0, 255, 0))),
# 4. top_fill / closed —— 仅 eroded/closed 流程用;pushed 流程无关,留空
"top_fill_overlay_base64": "" if mask_type == "pushed"
else _jpg_b64(_overlay(image_bgr, top_fill, (255, 0, 0))),
"closed_overlay_base64": "" if mask_type == "pushed"
else _jpg_b64(_overlay(image_bgr, closed, (255, 0, 255))),
# 5. pushed 模式专有(发际线提取/外推)—— 非 pushed 留空
"hairline_overlay_base64": "",
"pushed_overlay_base64": "",
# —— 最终遮罩 ——
"mask_overlay_base64": _jpg_b64(_overlay(image_bgr, mask_bool, (0, 0, 255))),
"mask_base64": _png_b64((mask_bool.astype(np.uint8)) * 255),
}
# pushed 模式:补充发际线提取 + 外推线可视化
if pushed_info is not None:
hairline_y, pushed_y, push_px, lo, hi = pushed_info
# ①-f 提取发际线:绿=头发下沿发际线(已用 baseline 范围截断),黄=baseline 折线(截断依据)
hl_img = _draw_baseline(image_bgr, baseline_pts, w) # 画 baseline(黄线+关键点)
hl_img = _draw_curve(hl_img, hairline_y, (0, 255, 0), 3)
viz["hairline_overlay_base64"] = _jpg_b64(hl_img)
# ①-g 外推发际线:圆心红点(151) + 原发际线(绿)+ 外推线(青)+ 遮罩(红半透明)
ps_img = _draw_curve(image_bgr.copy(), hairline_y, (0, 255, 0), 2)
ps_img = _draw_curve(ps_img, pushed_y, (0, 255, 255), 3)
# 画圆心(151 点)红点,标示径向外推的中心
if baseline_pts is not None and len(baseline_pts) > 5:
cx151, cy151 = baseline_pts[5]
cv2.circle(ps_img, (cx151, cy151), 6, (0, 0, 255), -1, cv2.LINE_AA)
cv2.putText(ps_img, "151", (cx151 + 8, cy151 - 8),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 1, cv2.LINE_AA)
ps_img = _overlay(ps_img, mask_bool, (0, 0, 255), 0.3)
viz["pushed_overlay_base64"] = _jpg_b64(ps_img)
viz["push_px"] = push_px
# 记录 viz 各字段是否非空(长度),便于排查前端取不到图的问题
viz_summary = {k: (len(v) if isinstance(v, str) and v else 0)
for k, v in viz.items() if k.endswith("_base64")}
lg(f"viz 生成完毕,各图字节长度: {viz_summary}")
return mask_bool, viz
# ---------------------------------------------------------------------------
# 步骤2:调 change_hair 换发型
# ---------------------------------------------------------------------------
def _call_swap(image_bgr, hairline_id, is_hr, ext_mask_bool, denoising_strength):
"""调 change_hair /api/swapHair/v1,返回与输入同分辨率同对齐的换发型结果(BGR)。
ext_mask_bool 非 None 时作为 ext_mask 传入(swap_mode=ext_mask)。
denoising_strengthwebui img2img 重绘强度(越大生发越激进),透传给换发型。
"""
import requests
ok, ibuf = cv2.imencode(".jpg", image_bgr, [cv2.IMWRITE_JPEG_QUALITY, 95])
payload = {
"hair_id": hairline_id,
"task_id": "if11-" + uuid4().hex[:12],
"is_hr": "true" if is_hr else "false",
"user_img_path": "data:image/jpeg;base64," + base64.b64encode(ibuf.tobytes()).decode(),
"output_format": "base64",
"denoising_strength": float(denoising_strength),
}
if ext_mask_bool is not None:
mbuf = cv2.imencode(".png", (ext_mask_bool.astype(np.uint8)) * 255)[1]
payload["ext_mask"] = "data:image/png;base64," + base64.b64encode(mbuf.tobytes()).decode()
try:
resp = requests.post(SWAP_URL, json=payload, timeout=SWAP_TIMEOUT)
except Exception as ex: # noqa: BLE001
raise SwapError(f"换发型服务不可达({SWAP_URL}):{ex}")
try:
j = resp.json()
except Exception: # noqa: BLE001
raise SwapError(f"换发型服务返回非 JSONHTTP {resp.status_code}):{resp.text[:200]}")
if j.get("state") != 0 or not j.get("data"):
raise SwapError(f"换发型失败:{j.get('msg', j)}")
b64 = j["data"]
if "," in b64 and b64.startswith("data:"):
b64 = b64.split(",", 1)[1]
result = cv2.imdecode(np.frombuffer(base64.b64decode(b64), np.uint8), cv2.IMREAD_COLOR)
if result is None:
raise SwapError("换发型结果解码失败")
# 保险:与原图对齐(change_hair 已贴回原尺寸,若极端情况尺寸不符则拉回)
if result.shape[:2] != image_bgr.shape[:2]:
result = cv2.resize(result, (image_bgr.shape[1], image_bgr.shape[0]),
interpolation=cv2.INTER_LANCZOS4)
return result
def _call_hairgrow(image_bgr, mask_bool, strength):
"""调 change_hair /api/hairGrow/v1(区域生发 inpaint),在遮罩区域长出头发。
返回与输入同分辨率的结果(BGR)。hairGrow 内部已做贴回与颜色迁移,
这里再套接口11 的遮罩羽化贴回以保证遮罩外严格不动。
"""
import requests
ok, ibuf = cv2.imencode(".jpg", image_bgr, [cv2.IMWRITE_JPEG_QUALITY, 95])
mbuf = cv2.imencode(".png", (mask_bool.astype(np.uint8)) * 255)[1]
payload = {
"img": "data:image/jpeg;base64," + base64.b64encode(ibuf.tobytes()).decode(),
"mask": "data:image/png;base64," + base64.b64encode(mbuf.tobytes()).decode(),
"strength": float(strength),
"output_format": "base64",
}
try:
resp = requests.post(HAIRGROW_URL, json=payload, timeout=SWAP_TIMEOUT)
except Exception as ex: # noqa: BLE001
raise SwapError(f"区域生发服务不可达({HAIRGROW_URL}):{ex}")
try:
j = resp.json()
except Exception: # noqa: BLE001
raise SwapError(f"区域生发返回非 JSONHTTP {resp.status_code}):{resp.text[:200]}")
if j.get("state") != 0 or not j.get("result"):
raise SwapError(f"区域生发失败:{j.get('msg', j)}")
b64 = j["result"]
if "," in b64 and b64.startswith("data:"):
b64 = b64.split(",", 1)[1]
result = cv2.imdecode(np.frombuffer(base64.b64decode(b64), np.uint8), cv2.IMREAD_COLOR)
if result is None:
raise SwapError("区域生发结果解码失败")
if result.shape[:2] != image_bgr.shape[:2]:
result = cv2.resize(result, (image_bgr.shape[1], image_bgr.shape[0]),
interpolation=cv2.INTER_LANCZOS4)
return result
# ---------------------------------------------------------------------------
# 步骤3+4:按遮罩贴回 + 接缝融合
# ---------------------------------------------------------------------------
def _color_match_to_orig(swap_result, orig, mask_bool):
"""在 mask_bool 区域内做 Reinhard 颜色迁移:逐通道把 swap_result 的均值/方差对齐 orig。
遮罩外保持 swap_result 原样(不会越界污染)。返回 uint8 BGR。
"""
m = mask_bool.astype(bool)
out = swap_result.astype(np.float32).copy()
if m.sum() < 30:
return swap_result.copy()
for c in range(3):
src_pix = swap_result[..., c][m].astype(np.float32)
dst_pix = orig[..., c][m].astype(np.float32)
s_mean, s_std = src_pix.mean(), src_pix.std() + 1e-6
d_mean, d_std = dst_pix.mean(), dst_pix.std() + 1e-6
out[..., c] = (out[..., c] - s_mean) * (d_std / s_std) + d_mean
return np.clip(out, 0, 255).astype(np.uint8)
def _feather_alpha(mask_bool, blend_method, feather_px, edge_erode_px):
"""由布尔遮罩生成 0~1 的 alpha(贴图权重)。遮罩外恒为 0(原图纹丝不动)。"""
m = mask_bool.astype(np.uint8)
if edge_erode_px > 0:
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * edge_erode_px + 1,) * 2)
m = cv2.erode(m, k)
fp = max(1, int(feather_px))
if blend_method == "alpha_gradient":
# 距离变换:过渡只发生在遮罩内侧(边界 0 → 内部 feather_px 处 1),遮罩外严格为 0
dist = cv2.distanceTransform(m, cv2.DIST_L2, 3)
alpha = np.clip(dist / fp, 0.0, 1.0)
else: # feather(高斯羽化,默认)
ksz = fp * 2 + 1
alpha = cv2.GaussianBlur(m.astype(np.float32), (ksz, ksz), sigmaX=fp / 2.0)
alpha = np.clip(alpha, 0.0, 1.0)
return alpha
def _multiband_alpha(mask_bool, edge_erode_px):
"""多频段融合用的二值掩码:先内缩、保证最小边距,否则最小一层金字塔会塌缩。
返回 uint8 二值 {0,255}(拉普拉斯金字塔融合要求起始掩码为二值,否则粗层会把整图混色)。
"""
m = mask_bool.astype(np.uint8) * 255
if edge_erode_px > 0:
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * edge_erode_px + 1,) * 2)
m = cv2.erode(m, k)
return m
def _multiband_blend(orig, swap_result, mask_bool, levels, edge_erode_px):
"""多频段(拉普拉斯金字塔)融合:低频用宽窗抹色差,高频用窄窗保发丝。
levels:金字塔层数(2~6),越大则低频色差在越宽范围被抹平。
返回 uint8 BGR。
"""
m = _multiband_alpha(mask_bool, edge_erode_px)
if m.sum() < 255:
return orig.copy()
# 层数受分辨率上限约束:每层尺寸减半,最小一层至少 4px,否则金字塔塌缩
min_dim = min(orig.shape[:2])
max_by_res = int(np.floor(np.log2(min_dim / 4))) if min_dim >= 16 else 1
n = int(max(1, min(levels, max_by_res)))
if n < 2:
# 极小图退化:直接按内缩遮罩硬贴,避免单层金字塔无意义
out = orig.copy()
m_bool = _multiband_alpha(mask_bool, edge_erode_px) > 127
out[m_bool] = swap_result[m_bool]
return out
def lap_pyr(img, n):
pyr = [img.astype(np.float32)]
cur = img.astype(np.float32)
for _ in range(n):
cur = cv2.pyrDown(cur)
pyr.append(cur)
laps = [pyr[-1]]
for i in range(n, 0, -1):
size = (pyr[i - 1].shape[1], pyr[i - 1].shape[0])
up = cv2.pyrUp(pyr[i], dstsize=size)
laps.append(pyr[i - 1] - up)
return laps # [最粗层, 细节层L1, ..., 最细层Ln]
def mask_pyr(mask_u8, n):
# 起始必须二值;逐层 pyrDown 后自动变软(金字塔天然多频段软掩码)。
# 返回顺序与 lap_pyr 一致:粗 → 细。
pyr = [mask_u8.astype(np.float32) / 255.0]
cur = mask_u8.astype(np.float32) / 255.0
for _ in range(n):
cur = cv2.pyrDown(cur)
pyr.append(cur)
return list(reversed(pyr)) # 与 lap_pyr 同尺度(最粗层在前)
la = lap_pyr(orig, n)
lb = lap_pyr(swap_result, n)
ma = mask_pyr(m, n)
merged = []
for a, b, mk in zip(la, lb, ma):
m3 = mk[:, :, None]
merged.append(a * (1 - m3) + b * m3)
out = merged[0]
for i in range(1, len(merged)):
size = (merged[i].shape[1], merged[i].shape[0])
out = cv2.pyrUp(out, dstsize=size)
out = out + merged[i]
out = np.clip(out, 0, 255).astype(np.uint8)
# 契约:遮罩远区纹丝不动,但保留多频段的过渡带。多频段融合的意义就在于低频层
# (粗层)的掩码在 pyrDown/pyrUp 后向外扩散变软,形成一条随层数变宽的过渡带——
# 这条带正是 mb_levels 要控制的东西。若像旧实现那样用原始硬二值遮罩钳回,
# 过渡带会被整条抹掉(实测 levels 2↔6 边界差恒为 0),mb_levels 形同虚设。
# 故按层数膨胀出一个外缘 keep 区:keep 内允许过渡,keep 外才强制还原原图。
margin = 2 ** n # n=2→4px … n=6→64px,与粗层掩码的自然扩散宽度匹配
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * margin + 1, 2 * margin + 1))
keep = cv2.dilate(mask_bool.astype(np.uint8), k).astype(bool)
out[~keep] = orig[~keep]
return out
def _composite(orig, swap_result, mask_bool, blend_method, feather_px, edge_erode_px,
color_match=False, mb_levels=5):
"""把 swap_result 按遮罩贴回 orig,返回 (final_bgr, alpha_float or None)。"""
if blend_method == "seamless":
m = mask_bool.astype(np.uint8)
if edge_erode_px > 0:
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * edge_erode_px + 1,) * 2)
m = cv2.erode(m, k)
if m.sum() < 10:
return orig.copy(), None
ys, xs = np.where(m > 0)
center = (int((xs.min() + xs.max()) / 2), int((ys.min() + ys.max()) / 2))
final = cv2.seamlessClone(swap_result, orig, m * 255, center, cv2.NORMAL_CLONE)
return final, None
# 颜色校正前置(seamless 自带色彩调和,已在上面提前返回;其余分支在此生效)
src = _color_match_to_orig(swap_result, orig, mask_bool) if color_match else swap_result
if blend_method == "multiband":
final = _multiband_blend(orig, src, mask_bool, mb_levels, edge_erode_px)
# 可视化用:用多频段的二值掩码做一层 alpha 标记(展示实际合成区)
alpha = (_multiband_alpha(mask_bool, edge_erode_px).astype(np.float32)) / 255.0
return final, alpha
alpha = _feather_alpha(mask_bool, blend_method, feather_px, edge_erode_px)
a3 = alpha[:, :, None]
final = (orig.astype(np.float32) * (1 - a3) + src.astype(np.float32) * a3)
return np.clip(final, 0, 255).astype(np.uint8), alpha
# ---------------------------------------------------------------------------
# 主入口
# ---------------------------------------------------------------------------
def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segformer",
mask_type="eroded", erode_cm=1.2, swap_mode="ext_mask",
blend_method="feather", feather_px=15, edge_erode_px=3,
denoising_strength=0.6, gen_backend="swaphair",
hairgrow_strength=0.75, color_match=False, mb_levels=5,
hairline_push_cm=0.0, hairline_edge="column", rid=None):
"""接口11 完整管线。返回可直接进 ok() 的 data dict。未检出人脸抛 NoFaceError。
rid: 调用方的 request id,用于日志关联。为 None 时自动生成。
"""
if rid is None:
rid = uuid4().hex[:8]
logger.info("[%s] ===== generate_hairline_grow 开始 =====", rid)
logger.info("[%s] 参数: mask_type=%r erode_cm=%s blend=%s hairline_push_cm=%s hairline_edge=%r "
"seg=%s gen_backend=%s swap_mode=%s", rid, mask_type, erode_cm, blend_method,
hairline_push_cm, hairline_edge, seg_model, gen_backend, swap_mode)
h, w = image_bgr.shape[:2]
landmarks = detector.detect(image_bgr)
if landmarks is None:
logger.warning("[%s] 未检出人脸", rid)
raise NoFaceError()
px_per_cm = estimate_scale_factor(landmarks, w, h)
logger.info("[%s] 人脸检出 px_per_cm=%.3f 图尺寸=%dx%d", rid, px_per_cm, w, h)
# 步骤1:接口9 遮罩
t0 = time.time()
mask_bool, mask_viz = compute_mask(
image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm,
hairline_push_cm=hairline_push_cm, hairline_edge=hairline_edge, rid=rid)
t_mask = time.time() - t0
logger.info("[%s] 步骤1 遮罩完成 耗时=%dms mask_pixels=%d", rid, int(t_mask*1000), int(mask_bool.sum()))
# 步骤2:生成(按后端)
t0 = time.time()
if gen_backend == "hairgrow":
swap_result = _call_hairgrow(image_bgr, mask_bool, hairgrow_strength)
else:
ext_mask = mask_bool if swap_mode == "ext_mask" else None
swap_result = _call_swap(image_bgr, hairline_id, is_hr, ext_mask, denoising_strength)
t_swap = time.time() - t0
# 步骤3:严格按遮罩硬贴回(无融合,用于对比)
hard_paste = image_bgr.copy()
hard_paste[mask_bool] = swap_result[mask_bool]
# 步骤4:接缝融合
t0 = time.time()
final, alpha = _composite(
image_bgr, swap_result, mask_bool, blend_method, feather_px, edge_erode_px,
color_match=color_match, mb_levels=mb_levels)
t_blend = time.time() - t0
data = {
"hairline_id": hairline_id,
"gen_backend": gen_backend,
"hairgrow_strength": round(float(hairgrow_strength), 3),
"is_hr": is_hr,
"seg_model": seg_model,
"mask_type": mask_type,
"erode_cm": round(float(erode_cm), 2),
"swap_mode": swap_mode,
"blend_method": blend_method,
"feather_px": int(feather_px),
"edge_erode_px": int(edge_erode_px),
"color_match": bool(color_match) and blend_method != "seamless",
"mb_levels": int(mb_levels),
"hairline_push_cm": round(float(hairline_push_cm), 2),
"hairline_edge": hairline_edge,
"denoising_strength": round(float(denoising_strength), 3),
"px_per_cm": round(float(px_per_cm), 4),
"erode_px": mask_viz["erode_px"],
"hair_pixels": mask_viz["hair_pixels"],
"closed_pixels": mask_viz["closed_pixels"],
"mask_pixels": mask_viz["mask_pixels"],
"image_size": {"width": w, "height": h},
"timings_ms": {
"mask": int(t_mask * 1000),
"swap": int(t_swap * 1000),
"blend": int(t_blend * 1000),
},
"steps": {
"input_base64": _jpg_b64(image_bgr),
# 遮罩计算全过程(接口9 子步骤)
"baseline_overlay_base64": mask_viz["baseline_overlay_base64"],
"upper_overlay_base64": mask_viz["upper_overlay_base64"],
"hair_seg_overlay_base64": mask_viz["hair_seg_overlay_base64"],
"top_fill_overlay_base64": mask_viz["top_fill_overlay_base64"],
"closed_overlay_base64": mask_viz["closed_overlay_base64"],
# pushed 模式专有(非 pushed 时为空串)
"hairline_overlay_base64": mask_viz["hairline_overlay_base64"],
"pushed_overlay_base64": mask_viz["pushed_overlay_base64"],
# 最终遮罩
"mask_overlay_base64": mask_viz["mask_overlay_base64"],
"mask_base64": mask_viz["mask_base64"],
"swap_raw_base64": _jpg_b64(swap_result),
"hard_paste_base64": _jpg_b64(hard_paste),
"alpha_base64": _gray_b64(alpha) if alpha is not None else mask_viz["mask_base64"],
"final_base64": _jpg_b64(final),
},
"_rid": rid, # 调试用:返回本次请求的日志关联 id
}
# 记录 steps 各图字段是否非空,供排查前端取图问题
steps_summary = {k: (len(v) if isinstance(v, str) and v else 0)
for k, v in data["steps"].items() if k.endswith("_base64")}
logger.info("[%s] 返回 steps 字段长度: %s", rid, steps_summary)
logger.info("[%s] ===== generate_hairline_grow 完成 =====", rid)
return data