Files
hair/hairline/mask.py
T
xslandClaude Opus 4.8 94ad95850e feat(接口2): 新增生发后图片(ComfyUI/Flux inpaint)
在发际线预览基础上,每种发际线再出一张「植发3个月」生发图:

- hairline/mask.py: headmark 5步法遮罩(额头上部区域∩SegFormer头部=ROI,
  取发际线曲线以上闭合区域);用 hairline_texture_black 渲染黑线替代手绘检测;
  compose_comfy_rgba 合成 RGBA(alpha=255-mask, 透明=重绘区, 对齐 ComfyUI mask=1-alpha)
- hairline/comfyui.py: ComfyUI 客户端(默认8182),/upload/image+/prompt(改节点26+随机seed)
  +轮询/history+/view 取回生发图
- hairline/render.py: 抽出 build_overlay_layer 供遮罩取曲线像素
- hairline/service.py: extract_context 一次出 landmarks/parse_map/502点;
  generate_grow_results 每种=预览+生发图(同步串行N张,单张ComfyUI失败则grown置空不拖垮整请求)
- app.py: /hair/grow 返回 results[].grown_image_base64;重活放线程池避免卡事件循环
- add_hair.json 工作流 + hairline_texture_black/ 黑贴图入库
- 测试: test_mask.py(遮罩几何) + test_api mock ComfyUI 验 grown 字段,35 全绿

实测(5090): female 5张生发图同步约18s;预览/生发图人物五官服饰背景保持、黑线已清除。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 23:42:30 +08:00

150 lines
6.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.
"""接口2 第二步:inpaint 遮罩 + 黑色发际线划线合成(参考 headmark 5步法)。
算法(用 hairline_texture_black 渲染黑线替代 headmark 的手绘检测):
① 额头上部区域:MediaPipe 额头边界关键点连线,向上+两侧补到图像边缘填充
② 头部轮廓:SegFormer 头部类(hairskin∪…,排除 bg/neck/cloth
③ ROI = ① ∩ ②
④ 渲染黑色发际线 → 烧进照片(marked) + 得到曲线像素
⑤ mask = ROI 中"发际线曲线以上",闭运算去洞 + 最大连通域 + 轻羽化
合成 RGBARGB=markedalpha=255×(1mask)(透明=重绘区,对齐 ComfyUI mask=1alpha)。
"""
from __future__ import annotations
import cv2
import numpy as np
from PIL import Image
from .render import load_ext_mesh, load_texture_rgba, render_hairline_overlay, build_overlay_layer
# headmark 额头边界关键点(MediaPipe canonical 索引,左→右沿上额)
FOREHEAD_LANDMARKS = [21, 68, 104, 69, 108, 151, 337, 299, 333, 298, 251]
# SegFormer 头部类(含 skin..hat;排除 bg=0 / ear_r=15 / neck_l=16 / neck=17 / cloth=18
_HEAD_CLASSES = list(range(1, 15))
def forehead_upper_region(landmarks_mp: np.ndarray, w: int, h: int) -> np.ndarray:
"""headmark step1:额头边界关键点以上的"上部区域"填充 maskuint8 0/255)。"""
pts = [(int(landmarks_mp[i, 0] * w), int(landmarks_mp[i, 1] * h)) for i in FOREHEAD_LANDMARKS]
left_ext = (0, pts[0][1])
right_ext = (w - 1, pts[-1][1])
polygon = np.array([left_ext] + pts + [right_ext, (w - 1, 0), (0, 0)], dtype=np.int32)
m = np.zeros((h, w), np.uint8)
cv2.fillPoly(m, [polygon], 255)
return m
def head_silhouette(parse_map: np.ndarray) -> np.ndarray:
"""headmark step2SegFormer 头部轮廓 maskuint8 0/255)。"""
return (np.isin(parse_map, _HEAD_CLASSES).astype(np.uint8) * 255)
def _curve_bottom_per_column(curve_mask: np.ndarray):
"""每列发际线曲线的**最低**像素 y(线下沿),返回 (xs, ys) 仅含有曲线的列。"""
ys_idx, xs_idx = np.where(curve_mask > 0)
if xs_idx.size == 0:
return None, None
w = curve_mask.shape[1]
bottom = np.full(w, -1, np.int32)
np.maximum.at(bottom, xs_idx, ys_idx)
cols = np.where(bottom >= 0)[0]
return cols, bottom[cols]
def _above_curve_region(curve_mask: np.ndarray, h: int, w: int) -> np.ndarray:
"""由发际线曲线得到"曲线以上"区域(uint8 0/255)。
曲线 x 跨度内逐列插值出下沿 y_line(x),两侧按端点 y 水平延伸;
above = 所有 y ≤ y_line(x)。曲线缺失(极端)则返回全 1(交给 ROI 兜底)。
"""
cols, ybot = _curve_bottom_per_column(curve_mask)
if cols is None:
return np.full((h, w), 255, np.uint8)
x0, x1 = int(cols.min()), int(cols.max())
# 全列插值 y_line:[x0,x1] 内线性插值,两侧水平延伸
yline = np.interp(np.arange(w), cols, ybot,
left=float(ybot[0]), right=float(ybot[-1])).astype(np.int32)
yy = np.arange(h)[:, None] # (h,1)
above = (yy <= yline[None, :]).astype(np.uint8) * 255 # (h,w)
return above
def _clean_mask(mask: np.ndarray, w: int) -> np.ndarray:
"""闭运算去洞 + 取最大连通域填充 + 轻羽化。"""
k = max(3, (int(w * 0.015) | 1))
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k))
closed = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
cnts, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
out = np.zeros_like(mask)
if cnts:
largest = max(cnts, key=cv2.contourArea)
cv2.drawContours(out, [largest], -1, 255, -1)
# 轻羽化(柔化边缘,利于扩散衔接)
out = cv2.GaussianBlur(out, (0, 0), sigmaX=max(1.0, w * 0.004))
return out
def build_inpaint_mask(photo_bgr: np.ndarray, landmarks_mp: np.ndarray,
parse_map: np.ndarray, points502: np.ndarray,
black_texture_rgba: np.ndarray):
"""返回 (marked_bgr 划线图, mask uint8 0..255 重绘区)。"""
h, w = photo_bgr.shape[:2]
uv, ext_faces = load_ext_mesh()
# ④ 渲染黑线:marked = 烧进照片;curve_mask = 曲线像素
marked = render_hairline_overlay(photo_bgr, points502, ext_faces, uv, black_texture_rgba)
overlay = build_overlay_layer(h, w, points502, ext_faces, uv, black_texture_rgba)
curve_mask = (overlay[:, :, 3] > 40).astype(np.uint8) * 255
# ①②③ ROI
upper = forehead_upper_region(landmarks_mp, w, h)
head = head_silhouette(parse_map)
roi = cv2.bitwise_and(upper, head)
# ⑤ ROI ∩ 曲线以上 → 清理
above = _above_curve_region(curve_mask, h, w)
mask = cv2.bitwise_and(roi, above)
mask = _clean_mask(mask, w)
return marked, mask
def compose_comfy_rgba(marked_bgr: np.ndarray, mask: np.ndarray) -> Image.Image:
"""合成 ComfyUI LoadImage 用的 RGBARGB=划线图,alpha=255mask(透明=重绘区)。"""
rgb = cv2.cvtColor(marked_bgr, cv2.COLOR_BGR2RGB)
alpha = (255 - mask).astype(np.uint8)
rgba = np.dstack([rgb, alpha])
return Image.fromarray(rgba, mode="RGBA")
if __name__ == "__main__":
import sys, os
from .service import get_landmarker, get_parser
path = sys.argv[1] if len(sys.argv) > 1 else "tests/fixtures/frontal.jpg"
tex_name = sys.argv[2] if len(sys.argv) > 2 else "girl_straight"
img = cv2.imread(path)
h, w = img.shape[:2]
rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
from .hairline_2d import sample_hairline, smooth_hairline
from .lift_3d import lift_hairline_to_3d, build_middle_row, assemble_full
lm = get_landmarker().detect(rgb)
parse_map = get_parser().parse(rgb)
h2d, valid = sample_hairline(lm, parse_map); h2d = smooth_hairline(h2d, valid)
h3d = lift_hairline_to_3d(lm, h2d); mid = build_middle_row(lm, h3d)
pts = assemble_full(lm, mid, h3d)
black = load_texture_rgba(f"hairline_texture_black/{tex_name}.png")
marked, mask = build_inpaint_mask(img, lm, parse_map, pts, black)
os.makedirs("tests/output", exist_ok=True)
cv2.imwrite("tests/output/mask_marked.png", marked)
cv2.imwrite("tests/output/mask_binary.png", mask)
# 三联可视化:划线图 / ROI / mask 叠加
upper = forehead_upper_region(lm, w, h); head = head_silhouette(parse_map)
roi = cv2.bitwise_and(upper, head)
vis = marked.copy()
vis[roi > 0] = (vis[roi > 0] * 0.6 + np.array([0, 40, 0])).clip(0, 255).astype(np.uint8)
vis[mask > 128] = (vis[mask > 128] * 0.4 + np.array([0, 0, 150])).clip(0, 255).astype(np.uint8)
cv2.imwrite("tests/output/mask_vis.png", vis)
compose_comfy_rgba(marked, mask).save("tests/output/comfy_input.png")
print(f"saved mask_marked/mask_binary/mask_vis/comfy_inputmask 像素 {int((mask>128).sum())}")