接口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 对比报告生成
This commit is contained in:
+419
-13
@@ -14,12 +14,16 @@
|
||||
- as_is:不改 change_hair,swapHair 用它自己的内部遮罩,贴回时再裁到接口9 遮罩。
|
||||
3. 严格按接口9 遮罩把生成图贴回原图(遮罩外=原图,纹丝不动)。
|
||||
4. 融合接缝:blend_method 选 feather(高斯羽化) / alpha_gradient(距离变换内渐变) /
|
||||
seamless(泊松无缝克隆);feather_px、edge_erode_px 控制过渡细节。
|
||||
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
|
||||
@@ -39,8 +43,18 @@ from face_analysis.head_mask import (
|
||||
_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")
|
||||
@@ -55,9 +69,11 @@ DEFAULTS = {
|
||||
"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
|
||||
"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,越大色差抹得越宽)
|
||||
}
|
||||
|
||||
|
||||
@@ -89,16 +105,196 @@ def _gray_b64(gray_float):
|
||||
# 步骤1:接口9 头发遮罩(复用 head_mask 构件)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm):
|
||||
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_pts:baseline 关键点,其折线 y 值用于水平截断。
|
||||
band_expand_px:兼容签名,不再使用(已不竖向截断)。
|
||||
mode:contour(轮廓,推荐)| 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(闭合区域未内缩)。
|
||||
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)
|
||||
@@ -106,20 +302,86 @@ def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm
|
||||
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、底线不动
|
||||
mask_bool = eroded if mask_type == "eroded" else closed
|
||||
lg(f"旧流程: top_fill像素={int(top_fill.sum())} closed像素={int(closed.sum())} eroded像素={int(eroded.sum())}")
|
||||
|
||||
# 只输出最终遮罩(叠加图 + 纯遮罩),不展开接口9 内部子步骤
|
||||
# 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
|
||||
|
||||
|
||||
@@ -217,6 +479,24 @@ def _call_hairgrow(image_bgr, mask_bool, strength):
|
||||
# 步骤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)
|
||||
@@ -236,7 +516,92 @@ def _feather_alpha(mask_bool, blend_method, feather_px, edge_erode_px):
|
||||
return alpha
|
||||
|
||||
|
||||
def _composite(orig, swap_result, mask_bool, blend_method, feather_px, edge_erode_px):
|
||||
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)
|
||||
@@ -250,9 +615,18 @@ def _composite(orig, swap_result, mask_bool, blend_method, feather_px, edge_erod
|
||||
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) + swap_result.astype(np.float32) * a3)
|
||||
final = (orig.astype(np.float32) * (1 - a3) + src.astype(np.float32) * a3)
|
||||
return np.clip(final, 0, 255).astype(np.uint8), alpha
|
||||
|
||||
|
||||
@@ -264,23 +638,33 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
|
||||
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):
|
||||
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。
|
||||
|
||||
gen_backend:生成后端。swaphair=换发型LoRA(应用发际线类型);
|
||||
hairgrow=区域生发inpaint(在遮罩内长出头发、压低发际线)。
|
||||
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)
|
||||
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()
|
||||
@@ -298,7 +682,8 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
|
||||
# 步骤4:接缝融合
|
||||
t0 = time.time()
|
||||
final, alpha = _composite(
|
||||
image_bgr, swap_result, mask_bool, blend_method, feather_px, edge_erode_px)
|
||||
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 = {
|
||||
@@ -313,10 +698,15 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
|
||||
"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": {
|
||||
@@ -326,6 +716,16 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
|
||||
},
|
||||
"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),
|
||||
@@ -333,5 +733,11 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
|
||||
"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
|
||||
|
||||
Reference in New Issue
Block a user