diff --git a/app.py b/app.py index 8eadf5b..c050216 100644 --- a/app.py +++ b/app.py @@ -14,6 +14,7 @@ from typing import Any, List, Optional import cv2 import numpy as np +from PIL import Image from fastapi import FastAPI, File, Form, Request, UploadFile from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles @@ -229,6 +230,20 @@ def _jpg_b64(bgr) -> str: return base64.b64encode(buf.tobytes()).decode() +def _rgba_png_b64(rgba) -> str: + """(H,W,4) float32/uint8 RGBA 透明层 → PNG base64(保留 alpha 通道)。 + + 用于发际线叠图/预览图:只含发际线曲线像素、背景透明,前端叠加到原图上显示。 + """ + arr = np.asarray(rgba) + if arr.dtype != np.uint8: + arr = np.clip(arr, 0, 255).astype(np.uint8) + img = Image.fromarray(arr, mode="RGBA") + buf = BytesIO() + img.save(buf, format="PNG") + return base64.b64encode(buf.getvalue()).decode() + + def _png_to_jpg_b64(png_bytes) -> str: """ComfyUI 返回的 PNG 字节 → 重编码为 JPG base64;无法解码则原样透传。""" img = cv2.imdecode(np.frombuffer(png_bytes, np.uint8), cv2.IMREAD_COLOR) @@ -723,7 +738,7 @@ async def hair_grow( results = [] for p in items: results.append({ - "image_base64": _jpg_b64(p["image_bgr"]), # 预览图 JPG + "image_base64": _rgba_png_b64(p["overlay"]), # 发际线曲线透明 PNG "grown_image_base64": (_png_to_jpg_b64(p["grown_png"]) # 生发图 JPG if p["grown_png"] else None), "hairline_type": p["hairline_type"], @@ -1133,9 +1148,9 @@ async def hairline_generate( ov = it["overlays"] hairline_images.append({ "hairline_type": it["hairline_type"], - "image_middle_base64": _jpg_b64(ov["middle"]), # 发际线叠图 middle 档 JPG - "image_high_base64": _jpg_b64(ov["high"]), # 发际线叠图 high 档 JPG - "image_low_base64": _jpg_b64(ov["low"]), # 发际线叠图 low 档 JPG + "image_middle_base64": _rgba_png_b64(ov["middle"]), # 发际线曲线透明 PNG middle 档 + "image_high_base64": _rgba_png_b64(ov["high"]), # 发际线曲线透明 PNG high 档 + "image_low_base64": _rgba_png_b64(ov["low"]), # 发际线曲线透明 PNG low 档 "grown_image_base64": (_png_to_jpg_b64(it["grown_png"]) # 生发图 JPG(失败为 null) if it.get("grown_png") else None), "order": it["order"], diff --git a/docs/接口文档.md b/docs/接口文档.md index 4656bb8..cb31cd2 100644 --- a/docs/接口文档.md +++ b/docs/接口文档.md @@ -244,10 +244,10 @@ ## 接口 2:C 端生发接口 -**说明**:输入用户正面照 + 性别 + 发型序号(可多选),按指定发际线类型渲染预览图 + 生发图。 +**说明**:输入用户正面照 + 性别 + 发型序号(可多选),按指定发际线类型渲染发际线曲线透明 PNG + 生发图。 -> **每个方案返回两张图**:`image_url`=「原照片 + 发际线曲线叠加的**预览图**」;`grown_image_url`= -> 经 ComfyUI/Flux 的「植发 3 个月**生发后图片**」。两者均已实现,实现简述见 [`实现说明.md`](实现说明.md)。 +> **每个方案返回两张图**:`image_url`=「发际线曲线**透明 PNG**(仅白色曲线,透明底,需叠加原图显示)」;`grown_image_url`= +> 经 ComfyUI/Flux 的「植发 3 个月**生发后图片**」(完整人像照片)。两者均已实现,实现简述见 [`实现说明.md`](实现说明.md)。 **请求**:`POST /api/v1/hair/grow` @@ -268,14 +268,16 @@ | 字段 | 类型 | 说明 | |------|------|------| -| image_url | string | 方案**预览图** URL(发际线曲线叠加图) | -| grown_image_url | string | **生发后图片** URL(ComfyUI/Flux「植发 3 个月」效果图) | +| image_url | string | 发际线曲线**透明 PNG** URL(仅白色发际线曲线,透明底,**不含人物**,需前端叠加原图显示) | +| grown_image_url | string | **生发后图片** URL(ComfyUI/Flux「植发 3 个月」效果图,完整人像照片) | | hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`(female),`ellipse`/`m`/`straight`/`inverse_arc`(male) | | order | int | 排序序号(当前阶段固定 `1..N`,按贴图顺序,暂不计算合适度) | > ⚠️ 生发图由本机 ComfyUI(Flux-2,端口 8182)生成,**一次请求生成指定发型的 1 张、同步返回**。 > worker 侧返回 `image_base64` / `grown_image_base64`, > 网关落盘后改写为上表的 `image_url` / `grown_image_url`。 +> +> 💡 `image_url` 为透明底 PNG,前端需用绝对定位叠加到原图上显示(参考[测试页](https://hair.xiangsilian.com/static/test_interface2.html)的 `.img-stack` 叠加结构)。 ### 响应示例 @@ -414,13 +416,15 @@ | 字段 | 类型 | 说明 | |------|------|------| | hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`(female),`ellipse`/`m`/`straight`/`inverse_arc`(male) | -| image_middle_url | string | middle 档发际线叠加图 URL | -| image_high_url | string | high 档发际线叠加图 URL | -| image_low_url | string | low 档发际线叠加图 URL | -| grown_image_url | string \| null | **生发后图片** URL(ComfyUI「植发」效果图,生发失败时为 `null`) | +| image_middle_url | string | middle 档发际线曲线**透明 PNG** URL(仅曲线,透明底,**不含人物**,需叠加原图显示) | +| image_high_url | string | high 档发际线曲线**透明 PNG** URL(同上,high 档曲线) | +| image_low_url | string | low 档发际线曲线**透明 PNG** URL(同上,low 档曲线) | +| grown_image_url | string \| null | **生发后图片** URL(ComfyUI「植发」效果图,完整人像照片,生发失败时为 `null`) | | order | int | 发型序号(= 传入的 hair_style 值) | > worker 侧返回 `image_middle_base64` / `image_high_base64` / `image_low_base64` / `grown_image_base64`,网关落盘后改写为上表对应的 `*_url`。 +> +> 💡 三档 `image_*_url` 为透明底 PNG,前端需用绝对定位叠加到原图上显示(参考[测试页](https://hair.xiangsilian.com/static/test_interface5.html)的 `.img-stack` 叠加结构)。`grown_image_url` 是完整人像照片,直接显示即可。 `face_measure` 元素(与[接口1](#接口-1四庭七眼测量标注接口)的 `data` 同构,**不含** `annotated_image_*` 标注图字段): diff --git a/hairline/service.py b/hairline/service.py index af9c933..6f75237 100644 --- a/hairline/service.py +++ b/hairline/service.py @@ -141,7 +141,7 @@ def generate_previews(image_bgr: np.ndarray, gender: str): 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): - """指定发际线类型:预览图(白线) + 生发图(ComfyUI)。 + """指定发际线类型:发际线透明叠图(白线 RGBA) + 生发图(ComfyUI)。 hair_styles(1-indexed 列表):指定生成哪几张发际线(按贴图排序)。female: 1..5,male: 1..4。 为 None 时生成全部(兼容旧调用)。 @@ -149,7 +149,8 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T False 时用**干净原图 + 空遮罩**送 ComfyUI(不烧黑色模板线)。 prompt(默认 None):ComfyUI 提示词,非 None 时替换工作流节点60文本。 workflow_path(默认 None):ComfyUI 工作流 JSON 路径,None 用默认 add_hair.json。 - Returns: list[dict] {"hairline_type","order","image_bgr"(预览), "grown_png"(bytes 或 None)}。 + 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"): @@ -177,9 +178,10 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T 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) - preview = render_hairline_overlay(image_bgr, ctx["points"], ext_faces, uv, white) + overlay = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white) if not use_mask: grown_png = shared_grown @@ -196,7 +198,7 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T logger.warning("接口2 生发图失败 type=%s:%s", key, e) results.append({"hairline_type": key, "order": order, - "image_bgr": preview, "grown_png": grown_png}) + "overlay": overlay, "grown_png": grown_png}) return results @@ -226,13 +228,13 @@ def _grow_from_texture(image_bgr: np.ndarray, ctx: dict, white_path: str | None, def generate_hairline_pngs(image_bgr: np.ndarray, gender: str, hair_styles: list[int], use_mask: bool = True, prompt: str | None = None): - """接口5:对选中发型返回 middle/high/low 三档发际线叠图 + 生发图(同接口2)。 + """接口5:对选中发型返回 middle/high/low 三档发际线透明叠图 + 生发图(同接口2)。 入参同接口2:先选 gender,再多选 hair_styles(必填,1-indexed 按贴图排序)。 - 每个选中发型返回三档叠图(middle/high/low)与一张生发图;三档贴图同名, - 生发黑模板固定取自 hairline_texture_black/(middle),故生发目标固定 middle 档。 + 每个选中发型返回三档叠图(middle/high/low,RGBA 透明层只含发际线曲线)与一张生发图; + 三档贴图同名,生发黑模板固定取自 hairline_texture_black/(middle),故生发目标固定 middle 档。 use_mask/prompt:同接口2 的生发参数。 - Returns: {"images":[{hairline_type,order,overlays:{middle,high,low}(BGR),grown_png}], + Returns: {"images":[{hairline_type,order,overlays:{middle,high,low}((H,W,4) RGBA 透明层),grown_png}], "best_center":(x,y)};无人脸 None。best_center 取首个选中发型的 middle 档。 """ if gender not in ("male", "female"): @@ -262,7 +264,7 @@ def generate_hairline_pngs(image_bgr: np.ndarray, gender: str, overlays = {} for lv in _TEXTURE_DIRS: white = load_texture_rgba(tex_by_level[lv][s - 1][1]) - overlays[lv] = render_hairline_overlay(image_bgr, ctx["points"], ext_faces, uv, white) + overlays[lv] = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white) # 生发:固定 middle 黑模板 grown_png = shared_grown if not use_mask else \ _grow_from_texture(image_bgr, ctx, mid_path, use_mask=True, prompt=prompt) @@ -270,8 +272,7 @@ def generate_hairline_pngs(image_bgr: np.ndarray, gender: str, "overlays": overlays, "grown_png": grown_png}) # best_center:首个选中发型的 middle 档发际线中点(面部中轴处的发际线 y) if best_center is None: - white = load_texture_rgba(mid_path) - overlay = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white) + overlay = overlays["middle"] # 复用已渲染的 middle 档透明层 ys, xs = np.where(overlay[:, :, 3] > 40) if xs.size: near = np.abs(xs - face_cx) <= max(2, int(w * 0.02)) diff --git a/static/integration.html b/static/integration.html index cee4908..367b00b 100644 --- a/static/integration.html +++ b/static/integration.html @@ -146,12 +146,14 @@ const { code, data } = await res.json();
data.results[] 元素
| 字段 | 类型 | 说明 |
|---|---|---|
image_url | string | 发际线叠加预览图(曲线叠在原图上) |
grown_image_url | string | 生发后效果图(ComfyUI/Flux「植发3个月」)⚠ 可空 |
image_url | string | 发际线曲线透明 PNG(仅白色曲线,透明底,需叠加原图显示) |
grown_image_url | string | 生发后效果图(ComfyUI/Flux「植发3个月」完整人像)⚠ 可空 |
hairline_type | string | 发际线类型 key |
order | int | 排序(1=最佳,当前按贴图顺序) |
💡 image_url 是透明底 PNG,前端需用绝对定位叠加到原图上显示(参考 测试页 的 .img-stack 叠加结构)。
Female 5 种:ellipse/flower/heart/straight/wave |
Male 4 种:ellipse/m/straight/inverse_arc
@@ -233,7 +235,7 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保
data 字段
| 字段 | 类型 | 说明 |
|---|---|---|
hairline_images[] | object[] | 选中发型列表,每项含 hairline_type、image_middle_url/image_high_url/image_low_url 三档叠图、grown_image_url 生发图(失败为 null)、order |
hairline_images[] | object[] | 选中发型列表,每项含 hairline_type、image_middle_url/image_high_url/image_low_url 三档透明 PNG 叠图(仅曲线,需叠加原图)、grown_image_url 生发图(完整人像,失败为 null)、order |
best_hairline_center_point | object | 首个选中发型 middle 档发际线中心点像素坐标 { x: number, y: number } |
face_measure | object \| null | 复用接口1的四庭七眼测量数值(不含标注图)。独立流程,测量失败时为 null,不影响发际线主结果。结构见下表 |