From 95c6a2d92960d91bec33562fc3e7a7f8fa5d61ee Mon Sep 17 00:00:00 2001 From: xsl Date: Mon, 13 Jul 2026 23:38:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=8F=A32/5=20=E5=8F=91?= =?UTF-8?q?=E9=99=85=E7=BA=BF=E5=8F=A0=E5=9B=BE=E6=94=B9=E4=B8=BA=E9=80=8F?= =?UTF-8?q?=E6=98=8E=20PNG=EF=BC=88=E4=BB=85=E6=9B=B2=E7=BA=BF=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E7=94=9F=E5=8F=91=E5=9B=BE=E4=B8=8D=E5=8F=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接口2(/api/v1/hair/grow)的 image_url 和接口5(/api/v1/hairline/generate) 的 image_middle/high/low_url 从「原图+白线合成 JPG」改为「透明底 PNG(仅含 发际线曲线)」,前端需叠加原图显示。grown_image_url 生发图保持不变(ComfyUI 完整人像照片)。 实现: - app.py 新增 _rgba_png_b64() 编码 RGBA 透明层为 PNG base64(保留 alpha) - hairline/service.py 接口2/5 改用 build_overlay_layer(返回 RGBA 透明层) 替代 render_hairline_overlay(合成到原图) - 网关零改动:rewrite_base64_to_url 已按 \x89PNG 魔数嗅探落盘为 .png 测试页:test_interface2/5.html 改为「原图打底 + 透明PNG 绝对定位叠加」显示 (复用 .img-stack 结构,固定叠加无开关)。 文档:接口文档.md / integration.html 更新接口2/5 图片字段说明。 测试:43 passed,接口2 image_base64 改断言为 PNG 魔数,接口5 三档叠图同。 --- app.py | 23 +++++++++++++++++++---- docs/接口文档.md | 22 +++++++++++++--------- hairline/service.py | 23 ++++++++++++----------- static/integration.html | 8 +++++--- static/test_interface2.html | 13 ++++++++++--- static/test_interface5.html | 21 ++++++++++++++++----- tests/test_api.py | 11 ++++++----- 7 files changed, 81 insertions(+), 40 deletions(-) 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_urlstring发际线叠加预览图(曲线叠在原图上)
grown_image_urlstring生发后效果图(ComfyUI/Flux「植发3个月」)⚠ 可空
image_urlstring发际线曲线透明 PNG(仅白色曲线,透明底,需叠加原图显示)
grown_image_urlstring生发后效果图(ComfyUI/Flux「植发3个月」完整人像)⚠ 可空
hairline_typestring发际线类型 key
orderint排序(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_typeimage_middle_url/image_high_url/image_low_url 三档叠图、grown_image_url 生发图(失败为 null)、order
hairline_images[]object[]选中发型列表,每项含 hairline_typeimage_middle_url/image_high_url/image_low_url 三档透明 PNG 叠图(仅曲线,需叠加原图)、grown_image_url 生发图(完整人像,失败为 null)、order
best_hairline_center_pointobject首个选中发型 middle 档发际线中心点像素坐标 { x: number, y: number }
face_measureobject \| null复用接口1四庭七眼测量数值(不含标注图)。独立流程,测量失败时为 null,不影响发际线主结果。结构见下表
diff --git a/static/test_interface2.html b/static/test_interface2.html index d747c40..1f10e9e 100644 --- a/static/test_interface2.html +++ b/static/test_interface2.html @@ -59,6 +59,10 @@ .scheme-imgs .img-slot .thumb { background: #222; border-radius: 6px; overflow: hidden; line-height: 0; } .scheme-imgs .img-slot .thumb img { width: 100%; max-height: 220px; object-fit: contain; display: block; } .scheme-imgs .img-slot .na { color: #d1d5db; font-size: 12px; padding: 30px 0; background: #f9fafb; border-radius: 6px; } + /* 原图 + 透明 PNG 叠加显示 */ + .img-stack { position: relative; display: inline-block; line-height: 0; } + .img-stack .layer-base { display: block; width: 100%; max-height: 220px; object-fit: contain; } + .img-stack .layer-anno { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: contain; } .json-panel { max-height: 550px; overflow: auto; } .json-content { padding: 14px 16px; font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; } @@ -181,10 +185,13 @@ function renderSchemes(results) { '
📷 原图
'+ '
原图
'; - // 发际线叠加 + // 发际线叠加(原图 + 透明 PNG 叠加显示) const overlaySlot = '
'+ - '
发际线叠加
'+ - '
叠加
'; + '
原图+发际线叠加
'+ + '
'+ + '原图'+ + '叠加'+ + '
'; // 生发效果 let grownSlot; diff --git a/static/test_interface5.html b/static/test_interface5.html index 75ee160..27e1bb1 100644 --- a/static/test_interface5.html +++ b/static/test_interface5.html @@ -57,6 +57,10 @@ .level-cell .level-label { font-size: 12px; font-weight: 600; color: #fff; background: rgba(0,0,0,.55); padding: 4px 8px; } .level-cell img { width: 100%; max-height: 380px; object-fit: contain; display: block; } .level-cell .level-none { color: #9ca3af; font-size: 13px; padding: 28px 0; text-align: center; } + /* 原图 + 透明 PNG 叠加(三档叠图用) */ + .level-cell .img-stack { position: relative; line-height: 0; } + .level-cell .img-stack .layer-base { width: 100%; max-height: 380px; object-fit: contain; display: block; } + .level-cell .img-stack .layer-anno { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: contain; } @media (max-width: 700px) { .level-row { grid-template-columns: repeat(2, 1fr); } } /* 坐标高亮 */ @@ -224,7 +228,14 @@ async function submitTest() { function renderGrid() { if (!_images.length) { $('resultsGrid').innerHTML = '无数据'; return; } let h = ''; - const cell = (label, url) => url + // 叠图档:原图打底 + 透明 PNG 叠加(无原图或无叠图 → 显示占位) + const overlayCell = (label, url) => (url && _origUrl) + ? '
'+label+'
'+ + '原图'+ + ''+label+'
' + : '
'+label+'
'; + // 生发图:独立 img(已是 ComfyUI 完整人像照片) + const grownCell = (label, url) => url ? '
'+label+'
'+label+'
' : '
'+label+'
'; _images.forEach(function(it) { @@ -232,10 +243,10 @@ function renderGrid() { '
#' + (it.order||'—') + ' ' + (it.hairline_type||'') + (it.grown_image_url ? '  含生发图' : '') + '
' + '
' + - cell('middle', it.image_middle_url) + - cell('high', it.image_high_url) + - cell('low', it.image_low_url) + - cell('生发图', it.grown_image_url) + + overlayCell('middle', it.image_middle_url) + + overlayCell('high', it.image_high_url) + + overlayCell('low', it.image_low_url) + + grownCell('生发图', it.grown_image_url) + '
' + ''; }); diff --git a/tests/test_api.py b/tests/test_api.py index ee5e589..2a6e086 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -90,8 +90,8 @@ def test_grow_female_returns_5(client, monkeypatch): results = body["data"]["results"] assert [x["hairline_type"] for x in results] == ["ellipse", "flower", "heart", "straight", "wave"] assert [x["order"] for x in results] == [1, 2, 3, 4, 5] - assert base64.b64decode(results[0]["image_base64"])[:3] == b"\xff\xd8\xff" # JPEG - assert base64.b64decode(results[0]["grown_image_base64"])[:3] == b"\xff\xd8\xff" # JPEG + assert base64.b64decode(results[0]["image_base64"])[:8] == b"\x89PNG\r\n\x1a\n" # 透明 PNG(发际线曲线) + assert base64.b64decode(results[0]["grown_image_base64"])[:3] == b"\xff\xd8\xff" # JPEG(生发图) assert "image_url" not in results[0] @@ -149,9 +149,10 @@ def test_hairline_gen_female(client, monkeypatch): # 只返回选中发型,order = 发型序号 assert [x["order"] for x in imgs] == [1, 3] assert [x["hairline_type"] for x in imgs] == ["ellipse", "heart"] - # 三档叠图 + 生发图均为 JPEG(worker 返回 base64,非 url) - for k in ("image_middle_base64", "image_high_base64", "image_low_base64", "grown_image_base64"): - assert base64.b64decode(imgs[0][k])[:3] == b"\xff\xd8\xff", k + # 三档叠图为透明 PNG(发际线曲线,含 alpha),生发图为 JPEG(完整人像) + for k in ("image_middle_base64", "image_high_base64", "image_low_base64"): + assert base64.b64decode(imgs[0][k])[:8] == b"\x89PNG\r\n\x1a\n", k + assert base64.b64decode(imgs[0]["grown_image_base64"])[:3] == b"\xff\xd8\xff" assert "image_middle_url" not in imgs[0] c = d["best_hairline_center_point"] assert 0 <= c["x"] <= 682 and 0 <= c["y"] <= 811 # 落在原图范围内