"""接口2 服务层:模型单例 + 性别贴图映射 + 「照片→N 张发际线预览图」管线。 把 head3d 的 extract_hairline 步骤包成单例复用(避免每请求重建模型),再按性别 对每张贴图调 render.render_hairline_overlay 生成预览图。 """ from __future__ import annotations import glob import os import cv2 import numpy as np from . import constants as C from . import comfyui from .face_landmarks import FaceLandmarker from .face_parsing import FaceParser from .hairline_2d import ( smooth_hairline, sample_hairline_clamped, ) from .lift_3d import lift_hairline_to_3d, build_middle_row, assemble_full from .render import load_ext_mesh, load_texture_rgba, render_hairline_overlay, build_overlay_layer from .mask import build_inpaint_mask, compose_comfy_rgba, mask_from_curve from .marker_detect import detect_marker_hairline, path_to_curve_mask import base64 import io import logging logger = logging.getLogger("hair.worker") # 接口2 女性发型 key → change_hair hair_id(chang_*)映射:换发型+Flux-2 整帧重绘用。 # 与接口12 final 的 5 型一一对应。 _FEMALE_KEY_TO_CHANG = { "ellipse": "chang_tuoyuan", # 椭圆 "flower": "chang_huaban", # 花瓣 "heart": "chang_xinxing", # 心形 "straight": "chang_zhixian", # 直线 "wave": "chang_bolang", # 波浪 } _REPO = os.path.dirname(os.path.dirname(__file__)) _TEXTURE_DIR = os.path.join(_REPO, "hairline_texture") _BLACK_TEXTURE_DIR = os.path.join(_REPO, "hairline_texture_black") # 三接口(接口2女重绘 / 接口2男 / 接口3)统一的 ComfyUI 重绘 prompt。 # 关键:ComfyUI 单卡显存装不下 Flux(7.7G)+qwen CLIP(3.9G) 同驻,靠缓存 CLIP 文本条件避免重载。 # prompt 不同会使缓存失效 → 重载 CLIP 并挤出 Flux(每次 +4s)。三接口用同一字符串即可全程命中。 # 与 app.py 接口2/接口3 的默认 prompt 保持一致;可用 REDRAW_PROMPT 覆盖。 _REDRAW_PROMPT = os.getenv("REDRAW_PROMPT", "填充遮罩区域的头发") # 接口2 女重绘整条管线(swapHair + ComfyUI)送模型前限边。真实照片常达 1257x1495: # 全分辨率 ComfyUI 重绘要 13~21s 且激活显存把模型挤出。女性路径含 swapHair(SD WebUI ~5.3s # 固定地板) + ComfyUI 两段串行。1024 档画质更好但部分大图会踩 12s 线, # 默认压到 896 兜底(ComfyUI ~4s,女性总耗时 9~11s);追画质可设 REDRAW_MAX_SIDE=1024。 _REDRAW_MAX_SIDE = int(os.getenv("REDRAW_MAX_SIDE", "896")) def _call_local_redraw(image_png_bytes, mask_png_bytes, timeout=300.0, max_side=None, unet_name=None): """直接调 ComfyUI 重绘(替代原 local_test HTTP 服务)。 传 final 图 + 纯红遮罩 PNG,返回重绘后的 PNG bytes。 失败抛异常(调用方负责 try/except 跳过)。 max_side:送 ComfyUI 前长边压到多少像素,None 用全局默认 _REDRAW_MAX_SIDE。 unet_name:非 None 时切换 Flux 模型,None 用工作流内置默认。 """ from .redraw import run_redraw eff_side = _REDRAW_MAX_SIDE if max_side is None else max_side img = cv2.imdecode(np.frombuffer(image_png_bytes, np.uint8), cv2.IMREAD_UNCHANGED) scale = 1.0 orig_w = orig_h = 0 if img is not None: orig_h, orig_w = img.shape[:2] m = max(orig_h, orig_w) if eff_side > 0 and m > eff_side: scale = eff_side / float(m) nw, nh = max(1, round(orig_w * scale)), max(1, round(orig_h * scale)) msk = cv2.imdecode(np.frombuffer(mask_png_bytes, np.uint8), cv2.IMREAD_UNCHANGED) img_s = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_AREA) msk_s = cv2.resize(msk, (nw, nh), interpolation=cv2.INTER_NEAREST) image_png_bytes = cv2.imencode(".png", img_s)[1].tobytes() mask_png_bytes = cv2.imencode(".png", msk_s)[1].tobytes() logger.info("接口2女 缩图送 Comfy: %dx%d → %dx%d (max_side=%d)", orig_w, orig_h, nw, nh, eff_side) # front=True:接口2 时延敏感,插到 ComfyUI 队列最前,避免排在接口3/5 的批量任务后面 out = run_redraw(image_png_bytes, mask_png_bytes, timeout=timeout, prompt=_REDRAW_PROMPT, front=True, unet_name=unet_name) if scale < 1.0 and out: out = _upscale_png_to(out, orig_w, orig_h) return out # 发际线贴图档位:middle=默认(hairline_texture/),high/low 各自独立文件夹。 _TEXTURE_DIRS = { "middle": _TEXTURE_DIR, "high": os.path.join(_REPO, "hairline_texture_high"), "low": os.path.join(_REPO, "hairline_texture_low"), } # torch 2.7.1+cu128 已支持 RTX 5090 (sm_120),SegFormer 走 GPU(~0.05s/张) _SEG_DEVICE = os.getenv("SEG_DEVICE", "cuda") _landmarker = None _parser = None _texture_maps: dict = {} # {level: {gender: [(key, path)]}},按档位缓存 def get_landmarker() -> FaceLandmarker: global _landmarker if _landmarker is None: _landmarker = FaceLandmarker(static_image_mode=True) return _landmarker def get_parser() -> FaceParser: global _parser if _parser is None: _parser = FaceParser(device=_SEG_DEVICE) return _parser def _gender_key(stem: str): """文件名 stem → (gender, key);非 girl_/man_ 前缀返回 (None, None)。""" if stem.startswith("girl_"): return "female", stem[5:].replace(" ", "").strip() if stem.startswith("man_"): return "male", stem[4:].replace(" ", "").strip() return None, None def get_texture_map(level: str = "middle") -> dict: """扫描指定档位贴图目录建 {gender: [(key, path)]},按 key 排序、按档位缓存。 level:middle(默认) / high / low,分别对应 hairline_texture[/_high|/_low]。 文件名规范化去空格(如 `man_ inverse_arc.png` → key `inverse_arc`)。 """ if level not in _TEXTURE_DIRS: raise ValueError(f"hairline_level 必须是 middle/high/low,收到 {level!r}") cached = _texture_maps.get(level) if cached is not None: return cached mapping: dict[str, list] = {"female": [], "male": []} for path in sorted(glob.glob(os.path.join(_TEXTURE_DIRS[level], "*.png"))): stem = os.path.splitext(os.path.basename(path))[0] gender, key = _gender_key(stem) if gender: mapping[gender].append((key, path)) for g in mapping: mapping[g].sort(key=lambda kp: kp[0]) _texture_maps[level] = mapping return mapping def extract_502(image_bgr: np.ndarray): """照片(BGR) → (points502 MP序, valid17)。无人脸返回 (None, None)。""" ctx = extract_context(image_bgr) if ctx is None: return None, None return ctx["points"], ctx["valid"] def extract_context(image_bgr: np.ndarray): """照片(BGR) → {landmarks, parse_map, points, valid}。无人脸返回 None。 发际线几何检测固定用 `sample_hairline_clamped`(射线检测 + 头部轮廓钳制): 短发/剃光头照片(如 man_test.jpg)中间锚点检测失效时,纯射线检测的固定 fallback 偏移会把点顶到头部轮廓外面的背景,产生"发际线贴到头部外面"的视觉 bug;钳制兜底后 fallback 点不会再跑出头部轮廓,正常长发照片结果与旧行为一致。 """ rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB) landmarks = get_landmarker().detect(rgb) if landmarks is None: return None parse_map = get_parser().parse(rgb) hairline_2d, valid = sample_hairline_clamped(landmarks, parse_map) hairline_2d = smooth_hairline(hairline_2d, valid) hairline_3d = lift_hairline_to_3d(landmarks, hairline_2d) middle_3d = build_middle_row(landmarks, hairline_3d) points = assemble_full(landmarks, middle_3d, hairline_3d) return {"landmarks": landmarks, "parse_map": parse_map, "points": points, "valid": valid} def _black_texture_path(white_path: str) -> str: """白贴图路径 → 同名黑贴图路径(hairline_texture_black/)。""" return os.path.join(_BLACK_TEXTURE_DIR, os.path.basename(white_path)) def generate_previews(image_bgr: np.ndarray, gender: str): """生成该性别全部发际线预览图(仅预览,不生发)。 Returns: list[dict] {"hairline_type", "image_bgr", "order"};无人脸返回 None。 """ if gender not in ("male", "female"): raise ValueError(f"gender 必须是 male/female,收到 {gender!r}") ctx = extract_context(image_bgr) if ctx is None: return None uv, ext_faces = load_ext_mesh() results = [] for order, (key, path) in enumerate(get_texture_map()[gender], start=1): preview = render_hairline_overlay(image_bgr, ctx["points"], ext_faces, uv, load_texture_rgba(path)) results.append({"hairline_type": key, "image_bgr": preview, "order": order}) return results def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = True, prompt: str = None, hair_styles: list[int] | None = None, workflow_path: str | None = None, unet_name: str | None = None): """指定发际线类型:发际线透明叠图(白线 RGBA) + 生发图(ComfyUI)。 hair_styles(1-indexed 列表):指定生成哪几张发际线(按贴图排序)。female: 1..5,male: 1..4。 为 None 时生成全部(兼容旧调用)。 use_mask(默认 True):是否启用 inpaint 遮罩,用于测试对比(同接口3)。 False 时用**干净原图 + 空遮罩**送 ComfyUI(不烧黑色模板线)。 prompt(默认 None):ComfyUI 提示词,非 None 时替换工作流节点60文本。 workflow_path(默认 None):ComfyUI 工作流 JSON 路径,None 用默认 add_hair.json。 Returns: list[dict] {"hairline_type","order","overlay"((H,W,4) RGBA 透明层), "grown_png"(bytes 或 None)}。 无人脸返回 None。某张 ComfyUI 失败时该项 grown_png=None,不抛异常。 """ if gender not in ("male", "female"): raise ValueError(f"gender 必须是 male/female,收到 {gender!r}") ctx = extract_context(image_bgr) if ctx is None: return None uv, ext_faces = load_ext_mesh() textures = get_texture_map()[gender] # [(key, path), ...] 已排序 if hair_styles is not None: items = [(s, textures[s - 1]) for s in hair_styles] else: items = list(enumerate(textures, start=1)) # 禁用遮罩:干净原图 + 空遮罩,与模板无关 → 只跑一次 ComfyUI,下面 N 项复用 shared_grown = None if not use_mask: try: h, w = image_bgr.shape[:2] img_s, msk_s, gsc = _prep_comfy_input(image_bgr, np.zeros((h, w), np.uint8)) buf = io.BytesIO() compose_comfy_rgba(img_s, msk_s).save(buf, format="PNG", compress_level=1) # front=True:接口2 时延敏感,插到 ComfyUI 队列最前 shared_grown = comfyui.run(buf.getvalue(), prompt=prompt, workflow_path=workflow_path, front=True, unet_name=unet_name) if gsc < 1.0 and shared_grown: shared_grown = _upscale_png_to(shared_grown, w, h) except Exception as e: # noqa: BLE001 logger.warning("接口2 生发图失败(无遮罩):%s", e) results = [] h, w = image_bgr.shape[:2] for order, (key, white_path) in items: white = load_texture_rgba(white_path) overlay = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white) if not use_mask: grown_png = shared_grown else: grown_png = None try: black = load_texture_rgba(_black_texture_path(white_path)) marked, mask = build_inpaint_mask( image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black) m_s, msk_s, gsc = _prep_comfy_input(marked, mask) buf = io.BytesIO() compose_comfy_rgba(m_s, msk_s).save(buf, format="PNG", compress_level=1) # front=True:接口2 时延敏感,插到 ComfyUI 队列最前 grown_png = comfyui.run(buf.getvalue(), prompt=prompt, workflow_path=workflow_path, front=True, unet_name=unet_name) if gsc < 1.0 and grown_png: grown_png = _upscale_png_to(grown_png, w, h) except Exception as e: # noqa: BLE001 单张失败不拖垮整请求 logger.warning("接口2 生发图失败 type=%s:%s", key, e) results.append({"hairline_type": key, "order": order, "overlay": overlay, "grown_png": grown_png}) return results def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | None, redraw_defaults: dict, redraw_max_side: int | None = None, unet_name: str | None = None): """接口2 女性专用:发际线透明叠图(同 generate_grow_results)+ 换发型重绘图。 grown 图来源(新流程):对每个选中发型把 female key 映射到 change_hair 的 chang_* hair_id, 调 face_analysis.hairline_grow.generate_hairline_redraw(= 接口12 final 管线,参数用 redraw_defaults)拿到 ④ final(接缝融合基底)+ ⑤-② 纯红遮罩 PNG,再**后端直接调 ComfyUI**(0716add-hair-api.json 工作流)完成发际线带重绘,重绘结果作为生发图。 overlay 仍是发际线曲线透明层(与 generate_grow_results 完全一致)。 Returns: list[dict] {"hairline_type","order","overlay","grown_png"(jpg bytes 或 None)}; 无人脸返回 None。单个发型换发型/重绘失败时 grown_png=None,不抛异常。 """ from face_analysis.hairline_grow import generate_hairline_redraw, NoFaceError from face_analysis.head_mask import SEGFORMER_HAIR ctx = extract_context(image_bgr) if ctx is None: return None uv, ext_faces = load_ext_mesh() # 复用 extract_context 已算好的 SegFormer parse_map,避免 generate_hairline_redraw 内部重复分割 hair_mask_reuse = (ctx["parse_map"] == SEGFORMER_HAIR) textures = get_texture_map()["female"] # [(key, path), ...] 已排序 if hair_styles is not None: items = [(s, textures[s - 1]) for s in hair_styles] else: items = list(enumerate(textures, start=1)) results = [] h, w = image_bgr.shape[:2] # 重绘管线(swapHair + ComfyUI)统一降分辨率:真实照片 swap(SD WebUI)~5s、blend、ComfyUI # 均随分辨率线性下降。overlay 预览仍用全分辨率;grown_png 最后放大回原尺寸。 eff_side = _REDRAW_MAX_SIDE if redraw_max_side is None else redraw_max_side redraw_img = image_bgr hair_mask_redraw = hair_mask_reuse if eff_side > 0 and max(h, w) > eff_side: redraw_img, _rs = _downscale_max_side(image_bgr, eff_side) _nh, _nw = redraw_img.shape[:2] if hair_mask_redraw is not None: hair_mask_redraw = cv2.resize(hair_mask_reuse.astype(np.uint8), (_nw, _nh), interpolation=cv2.INTER_NEAREST).astype(bool) logger.info("接口2女 管线降分辨率: %dx%d → %dx%d (max_side=%d)", w, h, _nw, _nh, eff_side) for order, (key, white_path) in items: white = load_texture_rgba(white_path) overlay = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white) grown_png = None chang_id = _FEMALE_KEY_TO_CHANG.get(key) if chang_id is None: logger.warning("接口2 换发型:female key=%s 无对应 chang_id,跳过生发图", key) else: try: import time as _t _ts0 = _t.perf_counter() data = generate_hairline_redraw(redraw_img, chang_id, hair_mask=hair_mask_redraw, **redraw_defaults) _ts1 = _t.perf_counter() steps = data.get("steps") or {} # ④ final(接缝融合基底)+ ⑤-② 纯红遮罩 PNG final_b64 = steps.get("final_base64") or "" mask_b64 = steps.get("redraw_band_mask_base64") or "" if not final_b64 or not mask_b64: logger.warning("接口2 换发型:type=%s final/遮罩缺失(final=%d mask=%d)", key, len(final_b64), len(mask_b64)) else: # 去掉 data URI 前缀 if final_b64.startswith("data:"): final_b64 = final_b64.split(",", 1)[1] if mask_b64.startswith("data:"): mask_b64 = mask_b64.split(",", 1)[1] final_bytes = base64.b64decode(final_b64) mask_bytes = base64.b64decode(mask_b64) # 后端直接调 ComfyUI 重绘,返回重绘后的 PNG _tr0 = _t.perf_counter() grown_png = _call_local_redraw(final_bytes, mask_bytes, max_side=redraw_max_side, unet_name=unet_name) _tr1 = _t.perf_counter() _tm = data.get("timings_ms") or {} logger.info("接口2女 分段计时 type=%s: swapHair管线=%.2fs (mask=%dms swap=%dms blend=%dms), ComfyUI重绘=%.2fs", key, _ts1 - _ts0, _tm.get("mask", 0), _tm.get("swap", 0), _tm.get("blend", 0), _tr1 - _tr0) if grown_png is None: logger.warning("接口2 换发型:type=%s 重绘结果为空", key) elif redraw_img is not image_bgr: # 管线在降分辨率图上跑,结果放大回原尺寸 grown_png = _upscale_png_to(grown_png, w, h) except NoFaceError: logger.warning("接口2 换发型:type=%s 未检出人脸", key) except Exception as e: # noqa: BLE001 单张失败不拖垮整请求 logger.warning("接口2 换发型图失败 type=%s:%s", key, e) results.append({"hairline_type": key, "order": order, "overlay": overlay, "grown_png": grown_png}) return results def _grow_from_texture(image_bgr: np.ndarray, ctx: dict, white_path: str | None, use_mask: bool, prompt: str | None): """对单个发际线做生发(ComfyUI)。黑模板固定取 hairline_texture_black/(middle), 与 hairline_level 无关(high/low 贴图与 middle 同名,basename 映射即落回 middle 黑模板)。 use_mask=False 时用干净原图+空遮罩(与贴图无关,white_path 可为 None)。 失败返回 None,不抛异常。 """ try: if use_mask: black = load_texture_rgba(_black_texture_path(white_path)) marked, mask = build_inpaint_mask( image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black) else: h, w = image_bgr.shape[:2] marked, mask = image_bgr, np.zeros((h, w), np.uint8) buf = io.BytesIO() compose_comfy_rgba(marked, mask).save(buf, format="PNG", compress_level=1) return comfyui.run(buf.getvalue(), prompt=prompt) except Exception as e: # noqa: BLE001 单张失败不拖垮整请求 logger.warning("接口5 生发图失败:%s", e) return None def generate_hairline_pngs(image_bgr: np.ndarray, gender: str, hair_styles: list[int], use_mask: bool = True, prompt: str | None = None, generate_grow_image: bool = True, redraw_max_side: int | None = None, unet_name: str | None = None, v2_defaults: dict | None = None): """接口5:对选中发型返回 middle/high/low 三档发际线透明叠图 + 生发图(同接口2)。 入参同接口2:先选 gender,再多选 hair_styles(必填,1-indexed 按贴图排序)。 每个选中发型返回三档叠图(middle/high/low,RGBA 透明层只含发际线曲线)与一张生发图; 生发机制(同接口2,按性别分流): - female:generate_grow_results_swap(swapHair + Flux-2 整帧重绘) - male:generate_grow_results(ComfyUI add_hair inpaint) redraw_max_side / unet_name / v2_defaults:female 路径参数,同接口2。 male 路径仅用 unet_name;redraw_max_side/v2_defaults 对 male 无意义(忽略)。 use_mask/prompt:仅 male 路径生效(同接口2 male)。 generate_grow_image(默认 True):是否生成生发图(最耗时)。False 时跳过生发, 各发型 grown_png 恒为 None,可大幅降低耗时(仅留三档发际线叠图与中心点)。 Returns: {"images":[{hairline_type,order,overlays:{middle,high,low}((H,W,4) RGBA 透明层),grown_png}], "best_centers":{"middle":(x,y),"high":(x,y),"low":(x,y)}};无人脸 None。 best_centers 取首个选中发型三档各自的发际线中点。 """ if gender not in ("male", "female"): raise ValueError(f"gender 必须是 male/female,收到 {gender!r}") if not hair_styles: raise ValueError("hair_styles 必填且不能为空") ctx = extract_context(image_bgr) if ctx is None: return None h, w = image_bgr.shape[:2] uv, ext_faces = load_ext_mesh() lm = ctx["landmarks"] # 面部中轴 x = 眉心(9/151 中点) face_cx = float((lm[9, 0] + lm[151, 0]) / 2 * w) # 三档贴图表(同性别、同 key 顺序,因三个文件夹同名) tex_by_level = {lv: get_texture_map(lv)[gender] for lv in _TEXTURE_DIRS} # 生发图(同接口2,按性别分流):一次性算出所有选中发型的生发图,按 order 对应回叠图。 # female→generate_grow_results_swap(swapHair+Flux-2 整帧重绘); # male→generate_grow_results(ComfyUI add_hair inpaint)。 # generate_grow_image=False 时跳过,grown_by_order 为空 dict(各发型 grown_png 恒 None)。 grown_by_order: dict[int, bytes | None] = {} if generate_grow_image: try: if gender == "female": items = generate_grow_results_swap( image_bgr, hair_styles, v2_defaults or {}, redraw_max_side=redraw_max_side, unet_name=unet_name) else: items = generate_grow_results( image_bgr, gender, use_mask, prompt, hair_styles, unet_name=unet_name) if items is None: return None # 无人脸(同接口2 的 None 语义) for it in items: grown_by_order[it["order"]] = it.get("grown_png") except Exception as e: # noqa: BLE001 整批生发失败不拖垮叠图主结果 logger.warning("接口5 生发批量失败(gender=%s):%s", gender, e) def _center_of(overlay): """从某档发际线透明叠图取面部中轴处的发际线中点 (x,y),无像素返回 None。""" ys, xs = np.where(overlay[:, :, 3] > 40) if not xs.size: return None near = np.abs(xs - face_cx) <= max(2, int(w * 0.02)) col_ys = ys[near] if near.any() else ys[np.argsort(np.abs(xs - face_cx))[:20]] return (int(round(face_cx)), int(round(float(col_ys.mean())))) images, best_centers = [], None for s in hair_styles: # s = 1-indexed 发型序号 key, _mid_path = tex_by_level["middle"][s - 1] overlays = {} for lv in _TEXTURE_DIRS: white = load_texture_rgba(tex_by_level[lv][s - 1][1]) overlays[lv] = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white) # 生发图:从按性别算好的结果里按 order 取(generate_grow_image=False 时缺省 None) grown_png = grown_by_order.get(s) images.append({"hairline_type": key, "order": s, "overlays": overlays, "grown_png": grown_png}) # best_centers:首个选中发型三档(middle/high/low)发际线中点 if best_centers is None: best_centers = {lv: _center_of(overlays[lv]) for lv in _TEXTURE_DIRS} return {"images": images, "best_centers": best_centers} # 接口3 送 ComfyUI 前限边,降低峰值显存,避免与接口2 切换时把 Flux 挤出。 # 统一 prompt 后 Flux 不再被 CLIP 挤出,接口3 可用较高分辨率。可用 GROW_B_MAX_SIDE 覆盖。 _GROW_B_MAX_SIDE = int(os.getenv("GROW_B_MAX_SIDE", "1024")) def _downscale_max_side(img_bgr: np.ndarray, max_side: int) -> tuple[np.ndarray, float]: """长边超过 max_side 时等比例缩小;返回 (图, scale),scale=新/旧。""" h, w = img_bgr.shape[:2] m = max(h, w) if max_side <= 0 or m <= max_side: return img_bgr, 1.0 scale = max_side / float(m) nw = max(1, int(round(w * scale))) nh = max(1, int(round(h * scale))) out = cv2.resize(img_bgr, (nw, nh), interpolation=cv2.INTER_AREA) return out, scale def _upscale_png_to(png_bytes: bytes, out_w: int, out_h: int) -> bytes: """把 Comfy 输出 PNG 双线性拉回原图尺寸(仅展示对齐,不增加推理细节)。""" arr = np.frombuffer(png_bytes, np.uint8) img = cv2.imdecode(arr, cv2.IMREAD_UNCHANGED) if img is None: return png_bytes if img.shape[1] == out_w and img.shape[0] == out_h: return png_bytes resized = cv2.resize(img, (out_w, out_h), interpolation=cv2.INTER_LINEAR) ok, buf = cv2.imencode(".png", resized) return buf.tobytes() if ok else png_bytes def _prep_comfy_input(img_bgr: np.ndarray, mask: np.ndarray) -> tuple[np.ndarray, np.ndarray, float]: """单段 ComfyUI 生发(接口2男 / 接口3)送图前限边到 GROW_B_MAX_SIDE。 返回 (缩后图, 缩后遮罩, scale);scale<1 时调用方需把结果放大回原尺寸。""" h, w = img_bgr.shape[:2] if _GROW_B_MAX_SIDE <= 0 or max(h, w) <= _GROW_B_MAX_SIDE: return img_bgr, mask, 1.0 out, scale = _downscale_max_side(img_bgr, _GROW_B_MAX_SIDE) nh, nw = out.shape[:2] msk = cv2.resize(mask, (nw, nh), interpolation=cv2.INTER_NEAREST) logger.info("接口2男/接口3 缩图送 Comfy: %dx%d → %dx%d (max_side=%d)", w, h, nw, nh, _GROW_B_MAX_SIDE) return out, msk, scale def generate_grow_b(marked_bgr: np.ndarray, use_mask: bool = True, prompt: str = None): """接口3:检测医生手绘发际线 → 遮罩 → 送 ComfyUI 生发(仅需划线图一张)。 检测路径只用来**建遮罩**;ComfyUI 输入图用 **marked 原图**(含医生手绘线, 工作流提示词会清除黑线再生发)。 进 Comfy 前若长边 > GROW_B_MAX_SIDE(默认 896)会先等比例缩小,降低峰值显存; 输出再拉回原图尺寸。 use_mask(默认 True):是否启用自动检测的遮罩,用于测试对比。 - True:检测手绘线 → 建遮罩 → alpha=255−mask(透明区=重绘区,节点44 画黄色参考区)。 - False:跳过检测,直接送划线图,alpha 全 255(空遮罩,节点26 mask 为空), 模型仅凭医生黑线参考生发。无需改工作流,唯一变量是遮罩。 Returns: {"grown_png": bytes 或 None, "status": "ok"|"no_face"|"no_line"}。 """ orig_h, orig_w = marked_bgr.shape[:2] marked_bgr, _scale = _downscale_max_side(marked_bgr, _GROW_B_MAX_SIDE) if _scale < 1.0: logger.info( "接口3 缩图送 Comfy: %dx%d → %dx%d (max_side=%d)", orig_w, orig_h, marked_bgr.shape[1], marked_bgr.shape[0], _GROW_B_MAX_SIDE, ) h, w = marked_bgr.shape[:2] if use_mask: rgb = cv2.cvtColor(marked_bgr, cv2.COLOR_BGR2RGB) landmarks = get_landmarker().detect(rgb) if landmarks is None: return {"grown_png": None, "status": "no_face"} parse_map = get_parser().parse(rgb) path = detect_marker_hairline(marked_bgr, landmarks, parse_map) if path is None: return {"grown_png": None, "status": "no_line"} line_w = max(2, int(w * 0.006)) curve_mask = path_to_curve_mask(path, h, w, thickness=max(3, line_w)) mask = mask_from_curve(curve_mask, landmarks, parse_map) else: mask = np.zeros((h, w), np.uint8) # 空遮罩:alpha 全 255,跳过检测 buf = io.BytesIO() compose_comfy_rgba(marked_bgr, mask).save(buf, format="PNG", compress_level=1) # marked + 遮罩 grown_png = comfyui.run(buf.getvalue(), prompt=prompt) if _scale < 1.0 and grown_png: grown_png = _upscale_png_to(grown_png, orig_w, orig_h) return {"grown_png": grown_png, "status": "ok"} if __name__ == "__main__": import sys g = sys.argv[2] if len(sys.argv) > 2 else "female" img = cv2.imread(sys.argv[1] if len(sys.argv) > 1 else "tests/fixtures/frontal.jpg") os.makedirs("tests/output", exist_ok=True) print("texture map:", {k: [kp[0] for kp in v] for k, v in get_texture_map().items()}) res = generate_previews(img, g) if res is None: print("无人脸") sys.exit(1) for r in res: out = f"tests/output/preview_{g}_{r['hairline_type']}.png" cv2.imwrite(out, r["image_bgr"]) print(f" order={r['order']} type={r['hairline_type']} -> {out}")