diff --git a/app.py b/app.py index 58ade69..af99e98 100644 --- a/app.py +++ b/app.py @@ -655,6 +655,11 @@ async def face_measure_v2( - 发际线类型 `hairline_type`(英文 key) - 顺序 `order`(本期固定 `1..N`,不排序) +> **female 走「换发型」模式**:生发图 `grown_image_base64` 由换发型(change_hair) +> + Flux-2 整帧重绘(= 接口12 final 管线,整帧美颜+整帧重绘)生成,其余参数用固化默认值。 +> **male 仍走原生发(ComfyUI add_hair)管线**。入参与返回结构不变。 +> female 依赖 change_hair 与 ComfyUI(:8188) 均在跑。 + {_image_fields_desc} 图片同时支持 `multipart/form-data` 文件上传(字段名 `image_file`)。 @@ -734,10 +739,17 @@ async def hair_grow( try: from fastapi.concurrency import run_in_threadpool - from hairline.service import generate_grow_results - # 预览 + 生发(ComfyUI) 都是阻塞且较慢,放线程池避免卡住事件循环 - items = await run_in_threadpool(generate_grow_results, image, gender, use_mask, prompt, hair_styles) + # 预览 + 生发/换发型 都是阻塞且较慢,放线程池避免卡住事件循环。 + # female:换发型 + Flux-2 整帧重绘(= 接口12 final 管线);male:仍走原生发管线。 + if gender == "female": + from hairline.service import generate_grow_results_swap + items = await run_in_threadpool( + generate_grow_results_swap, image, hair_styles, _V2_FINAL_DEFAULTS) + else: + from hairline.service import generate_grow_results + items = await run_in_threadpool( + generate_grow_results, image, gender, use_mask, prompt, hair_styles) if items is None: return err(1001, "无法识别人像") diff --git a/hairline/service.py b/hairline/service.py index 7a8d112..7418962 100644 --- a/hairline/service.py +++ b/hairline/service.py @@ -20,11 +20,22 @@ from .render import load_ext_mesh, load_texture_rgba, render_hairline_overlay, b 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") @@ -202,6 +213,60 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T return results +def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | None, + redraw_defaults: dict): + """接口2 女性专用:发际线透明叠图(同 generate_grow_results)+ 换发型+Flux-2 整帧重绘图。 + + 与 generate_grow_results 的差异仅在 grown 图的来源:这里对每个选中发型把 female key 映射到 + change_hair 的 chang_* hair_id,调 face_analysis.hairline_grow.generate_hairline_redraw + (= 接口12 final 管线,参数用 redraw_defaults),取 `redraw_full`(整帧重绘)作为生发图。 + + 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 + + ctx = extract_context(image_bgr) + if ctx is None: + return None + uv, ext_faces = load_ext_mesh() + + 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] + 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: + data = generate_hairline_redraw(image_bgr, chang_id, **redraw_defaults) + b64 = (data.get("steps") or {}).get("redraw_full_base64") or "" + if b64.startswith("data:"): + b64 = b64.split(",", 1)[1] + grown_png = base64.b64decode(b64) if b64 else None + if grown_png is None: + logger.warning("接口2 换发型:type=%s 整帧重绘为空(可能 ComfyUI 未生效)", key) + 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), diff --git a/static/test_interface2.html b/static/test_interface2.html index eb2b4a5..3806dbb 100644 --- a/static/test_interface2.html +++ b/static/test_interface2.html @@ -156,7 +156,7 @@ async function submitTest() { fd.append('prompt', $('promptInput').value); const _reqStart = performance.now(); try { - const r = await fetch(API_BASE + '/api/v1/hair/grow', { method:'POST', body:fd }); + const r = await fetch(API_BASE + '/api/v1/hair/grow', { method:'POST', headers:{ 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd }); const json = await r.json(); const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2); $('jsonContent').textContent = JSON.stringify(json, null, 2); @@ -179,6 +179,9 @@ function renderSchemes(results) { let html = ''; results.forEach((r, i) => { const lb = TYPE_LABELS[r.hairline_type] || r.hairline_type; + // 直连 worker 时后端返回 base64(网关才会改写为 *_url),URL 缺失则回退 data URI + const overlaySrc = r.image_url || (r.image_base64 ? 'data:image/png;base64,' + r.image_base64 : ''); + const grownSrc = r.grown_image_url || (r.grown_image_base64 ? 'data:image/jpeg;base64,' + r.grown_image_base64 : ''); // 原图 const origSlot = '
'+ @@ -190,15 +193,15 @@ function renderSchemes(results) { '
原图+发际线叠加
'+ '
'+ '原图'+ - '叠加'+ + '叠加'+ '
'; // 生发效果 let grownSlot; - if (r.grown_image_url) { + if (grownSrc) { grownSlot = '
'+ '
生发效果
'+ - '
生发
'; + '
生发
'; } else { grownSlot = '
'+ '
生发效果
'+