feat: 接口2/5 发际线叠图改为透明 PNG(仅曲线),生发图不变

接口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 三档叠图同。
This commit is contained in:
xsl
2026-07-13 23:38:55 +08:00
parent 28255ef7c2
commit 95c6a2d929
7 changed files with 81 additions and 40 deletions
+19 -4
View File
@@ -14,6 +14,7 @@ from typing import Any, List, Optional
import cv2 import cv2
import numpy as np import numpy as np
from PIL import Image
from fastapi import FastAPI, File, Form, Request, UploadFile from fastapi import FastAPI, File, Form, Request, UploadFile
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@@ -229,6 +230,20 @@ def _jpg_b64(bgr) -> str:
return base64.b64encode(buf.tobytes()).decode() 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: def _png_to_jpg_b64(png_bytes) -> str:
"""ComfyUI 返回的 PNG 字节 → 重编码为 JPG base64;无法解码则原样透传。""" """ComfyUI 返回的 PNG 字节 → 重编码为 JPG base64;无法解码则原样透传。"""
img = cv2.imdecode(np.frombuffer(png_bytes, np.uint8), cv2.IMREAD_COLOR) img = cv2.imdecode(np.frombuffer(png_bytes, np.uint8), cv2.IMREAD_COLOR)
@@ -723,7 +738,7 @@ async def hair_grow(
results = [] results = []
for p in items: for p in items:
results.append({ 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 "grown_image_base64": (_png_to_jpg_b64(p["grown_png"]) # 生发图 JPG
if p["grown_png"] else None), if p["grown_png"] else None),
"hairline_type": p["hairline_type"], "hairline_type": p["hairline_type"],
@@ -1133,9 +1148,9 @@ async def hairline_generate(
ov = it["overlays"] ov = it["overlays"]
hairline_images.append({ hairline_images.append({
"hairline_type": it["hairline_type"], "hairline_type": it["hairline_type"],
"image_middle_base64": _jpg_b64(ov["middle"]), # 发际线叠图 middle 档 JPG "image_middle_base64": _rgba_png_b64(ov["middle"]), # 发际线曲线透明 PNG middle 档
"image_high_base64": _jpg_b64(ov["high"]), # 发际线叠图 high 档 JPG "image_high_base64": _rgba_png_b64(ov["high"]), # 发际线曲线透明 PNG high 档
"image_low_base64": _jpg_b64(ov["low"]), # 发际线叠图 low 档 JPG "image_low_base64": _rgba_png_b64(ov["low"]), # 发际线曲线透明 PNG low 档
"grown_image_base64": (_png_to_jpg_b64(it["grown_png"]) # 生发图 JPG(失败为 null "grown_image_base64": (_png_to_jpg_b64(it["grown_png"]) # 生发图 JPG(失败为 null
if it.get("grown_png") else None), if it.get("grown_png") else None),
"order": it["order"], "order": it["order"],
+13 -9
View File
@@ -244,10 +244,10 @@
## 接口 2C 端生发接口 ## 接口 2C 端生发接口
**说明**:输入用户正面照 + 性别 + 发型序号(可多选),按指定发际线类型渲染预览图 + 生发图。 **说明**:输入用户正面照 + 性别 + 发型序号(可多选),按指定发际线类型渲染发际线曲线透明 PNG + 生发图。
> **每个方案返回两张图**`image_url`=「原照片 + 发际线曲线叠加的**预览图**」;`grown_image_url`= > **每个方案返回两张图**`image_url`=「发际线曲线**透明 PNG**(仅白色曲线,透明底,需叠加原图显示)」;`grown_image_url`=
> 经 ComfyUI/Flux 的「植发 3 个月**生发后图片**」。两者均已实现,实现简述见 [`实现说明.md`](实现说明.md)。 > 经 ComfyUI/Flux 的「植发 3 个月**生发后图片**」(完整人像照片)。两者均已实现,实现简述见 [`实现说明.md`](实现说明.md)。
**请求**`POST /api/v1/hair/grow` **请求**`POST /api/v1/hair/grow`
@@ -268,14 +268,16 @@
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|------|------|------| |------|------|------|
| image_url | string | 方案**预览图** URL(发际线曲线叠加图 | | image_url | string | 发际线曲线**透明 PNG** URL仅白色发际线曲线,透明底,**不含人物**,需前端叠加原图显示 |
| grown_image_url | string | **生发后图片** URLComfyUI/Flux「植发 3 个月」效果图) | | grown_image_url | string | **生发后图片** URLComfyUI/Flux「植发 3 个月」效果图,完整人像照片 |
| hairline_type | string | 发际线类型 key`ellipse`/`flower`/`heart`/`straight`/`wave`female),`ellipse`/`m`/`straight`/`inverse_arc`male | | hairline_type | string | 发际线类型 key`ellipse`/`flower`/`heart`/`straight`/`wave`female),`ellipse`/`m`/`straight`/`inverse_arc`male |
| order | int | 排序序号(当前阶段固定 `1..N`,按贴图顺序,暂不计算合适度) | | order | int | 排序序号(当前阶段固定 `1..N`,按贴图顺序,暂不计算合适度) |
> ⚠️ 生发图由本机 ComfyUIFlux-2,端口 8182)生成,**一次请求生成指定发型的 1 张、同步返回**。 > ⚠️ 生发图由本机 ComfyUIFlux-2,端口 8182)生成,**一次请求生成指定发型的 1 张、同步返回**。
> worker 侧返回 `image_base64` / `grown_image_base64` > worker 侧返回 `image_base64` / `grown_image_base64`
> 网关落盘后改写为上表的 `image_url` / `grown_image_url`。 > 网关落盘后改写为上表的 `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 | | hairline_type | string | 发际线类型 key`ellipse`/`flower`/`heart`/`straight`/`wave`female),`ellipse`/`m`/`straight`/`inverse_arc`male |
| image_middle_url | string | middle 档发际线叠加图 URL | | image_middle_url | string | middle 档发际线曲线**透明 PNG** URL(仅曲线,透明底,**不含人物**,需叠加原图显示) |
| image_high_url | string | high 档发际线叠加图 URL | | image_high_url | string | high 档发际线曲线**透明 PNG** URL(同上,high 档曲线) |
| image_low_url | string | low 档发际线叠加图 URL | | image_low_url | string | low 档发际线曲线**透明 PNG** URL(同上,low 档曲线) |
| grown_image_url | string \| null | **生发后图片** URL(ComfyUI「植发」效果图,生发失败时为 `null` | | grown_image_url | string \| null | **生发后图片** URLComfyUI「植发」效果图,完整人像照片,生发失败时为 `null` |
| order | int | 发型序号(= 传入的 hair_style 值) | | order | int | 发型序号(= 传入的 hair_style 值) |
> worker 侧返回 `image_middle_base64` / `image_high_base64` / `image_low_base64` / `grown_image_base64`,网关落盘后改写为上表对应的 `*_url`。 > 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_*` 标注图字段): `face_measure` 元素(与[接口1](#接口-1四庭七眼测量标注接口)的 `data` 同构,**不含** `annotated_image_*` 标注图字段):
+12 -11
View File
@@ -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, def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = True,
prompt: str = None, hair_styles: list[int] | None = None, prompt: str = None, hair_styles: list[int] | None = None,
workflow_path: str | None = None): workflow_path: str | None = None):
"""指定发际线类型:预览图(白线) + 生发图(ComfyUI)。 """指定发际线类型:发际线透明叠图(白线 RGBA) + 生发图(ComfyUI)。
hair_styles1-indexed 列表):指定生成哪几张发际线(按贴图排序)。female: 1..5male: 1..4。 hair_styles1-indexed 列表):指定生成哪几张发际线(按贴图排序)。female: 1..5male: 1..4。
为 None 时生成全部(兼容旧调用)。 为 None 时生成全部(兼容旧调用)。
@@ -149,7 +149,8 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
False 时用**干净原图 + 空遮罩**送 ComfyUI(不烧黑色模板线)。 False 时用**干净原图 + 空遮罩**送 ComfyUI(不烧黑色模板线)。
prompt(默认 None):ComfyUI 提示词,非 None 时替换工作流节点60文本。 prompt(默认 None):ComfyUI 提示词,非 None 时替换工作流节点60文本。
workflow_path(默认 None):ComfyUI 工作流 JSON 路径,None 用默认 add_hair.json。 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,不抛异常。 无人脸返回 None。某张 ComfyUI 失败时该项 grown_png=None,不抛异常。
""" """
if gender not in ("male", "female"): 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) logger.warning("接口2 生发图失败(无遮罩)%s", e)
results = [] results = []
h, w = image_bgr.shape[:2]
for order, (key, white_path) in items: for order, (key, white_path) in items:
white = load_texture_rgba(white_path) 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: if not use_mask:
grown_png = shared_grown 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) logger.warning("接口2 生发图失败 type=%s%s", key, e)
results.append({"hairline_type": key, "order": order, results.append({"hairline_type": key, "order": order,
"image_bgr": preview, "grown_png": grown_png}) "overlay": overlay, "grown_png": grown_png})
return results 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, def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
hair_styles: list[int], use_mask: bool = True, hair_styles: list[int], use_mask: bool = True,
prompt: str | None = None): prompt: str | None = None):
"""接口5:对选中发型返回 middle/high/low 三档发际线叠图 + 生发图(同接口2)。 """接口5:对选中发型返回 middle/high/low 三档发际线透明叠图 + 生发图(同接口2)。
入参同接口2:先选 gender,再多选 hair_styles(必填,1-indexed 按贴图排序)。 入参同接口2:先选 gender,再多选 hair_styles(必填,1-indexed 按贴图排序)。
每个选中发型返回三档叠图(middle/high/low)与一张生发图;三档贴图同名, 每个选中发型返回三档叠图(middle/high/lowRGBA 透明层只含发际线曲线)与一张生发图;
生发黑模板固定取自 hairline_texture_black/middle),故生发目标固定 middle 档。 三档贴图同名,生发黑模板固定取自 hairline_texture_black/middle),故生发目标固定 middle 档。
use_mask/prompt:同接口2 的生发参数。 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 档。 "best_center":(x,y)};无人脸 None。best_center 取首个选中发型的 middle 档。
""" """
if gender not in ("male", "female"): if gender not in ("male", "female"):
@@ -262,7 +264,7 @@ def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
overlays = {} overlays = {}
for lv in _TEXTURE_DIRS: for lv in _TEXTURE_DIRS:
white = load_texture_rgba(tex_by_level[lv][s - 1][1]) 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 黑模板 # 生发:固定 middle 黑模板
grown_png = shared_grown if not use_mask else \ grown_png = shared_grown if not use_mask else \
_grow_from_texture(image_bgr, ctx, mid_path, use_mask=True, prompt=prompt) _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}) "overlays": overlays, "grown_png": grown_png})
# best_center:首个选中发型的 middle 档发际线中点(面部中轴处的发际线 y) # best_center:首个选中发型的 middle 档发际线中点(面部中轴处的发际线 y)
if best_center is None: if best_center is None:
white = load_texture_rgba(mid_path) overlay = overlays["middle"] # 复用已渲染的 middle 档透明层
overlay = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)
ys, xs = np.where(overlay[:, :, 3] > 40) ys, xs = np.where(overlay[:, :, 3] > 40)
if xs.size: if xs.size:
near = np.abs(xs - face_cx) <= max(2, int(w * 0.02)) near = np.abs(xs - face_cx) <= max(2, int(w * 0.02))
+5 -3
View File
@@ -146,12 +146,14 @@ const { code, data } = await res.json();
<p style="margin-top:12px"><strong>data.results[] 元素</strong></p> <p style="margin-top:12px"><strong>data.results[] 元素</strong></p>
<table> <table>
<tr><th>字段</th><th>类型</th><th>说明</th></tr> <tr><th>字段</th><th>类型</th><th>说明</th></tr>
<tr><td><code>image_url</code></td><td>string</td><td>发际线叠加预览图(曲线叠在原图上</td></tr> <tr><td><code>image_url</code></td><td>string</td><td>发际线曲线<strong>透明 PNG</strong>(仅白色曲线,透明底,需叠加原图显示</td></tr>
<tr><td><code>grown_image_url</code></td><td>string</td><td>生发后效果图(ComfyUI/Flux「植发3个月」)⚠ 可空</td></tr> <tr><td><code>grown_image_url</code></td><td>string</td><td>生发后效果图(ComfyUI/Flux「植发3个月」完整人像)⚠ 可空</td></tr>
<tr><td><code>hairline_type</code></td><td>string</td><td>发际线类型 key</td></tr> <tr><td><code>hairline_type</code></td><td>string</td><td>发际线类型 key</td></tr>
<tr><td><code>order</code></td><td>int</td><td>排序(1=最佳,当前按贴图顺序)</td></tr> <tr><td><code>order</code></td><td>int</td><td>排序(1=最佳,当前按贴图顺序)</td></tr>
</table> </table>
<p style="margin-top:12px;font-size:12px;color:#64748b">💡 <code>image_url</code> 是透明底 PNG,前端需用绝对定位叠加到原图上显示(参考 <a href="/static/test_interface2.html">测试页</a><code>.img-stack</code> 叠加结构)。</p>
<p style="margin-top:8px;font-size:12px;color:#64748b"> <p style="margin-top:8px;font-size:12px;color:#64748b">
Female 5 种:ellipse/flower/heart/straight/wave &nbsp;|&nbsp; Female 5 种:ellipse/flower/heart/straight/wave &nbsp;|&nbsp;
Male 4 种:ellipse/m/straight/inverse_arc<br> Male 4 种:ellipse/m/straight/inverse_arc<br>
@@ -233,7 +235,7 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保
<p style="margin-top:12px"><strong>data 字段</strong></p> <p style="margin-top:12px"><strong>data 字段</strong></p>
<table> <table>
<tr><th>字段</th><th>类型</th><th>说明</th></tr> <tr><th>字段</th><th>类型</th><th>说明</th></tr>
<tr><td><code>hairline_images[]</code></td><td>object[]</td><td>选中发型列表,每项含 <code>hairline_type</code><code>image_middle_url</code>/<code>image_high_url</code>/<code>image_low_url</code> 三档叠图<code>grown_image_url</code> 生发图(失败为 null)、<code>order</code></td></tr> <tr><td><code>hairline_images[]</code></td><td>object[]</td><td>选中发型列表,每项含 <code>hairline_type</code><code>image_middle_url</code>/<code>image_high_url</code>/<code>image_low_url</code> 三档<strong>透明 PNG 叠图</strong>(仅曲线,需叠加原图)<code>grown_image_url</code> 生发图(完整人像,失败为 null)、<code>order</code></td></tr>
<tr><td><code>best_hairline_center_point</code></td><td>object</td><td>首个选中发型 middle 档发际线中心点像素坐标 <code>{ x: number, y: number }</code></td></tr> <tr><td><code>best_hairline_center_point</code></td><td>object</td><td>首个选中发型 middle 档发际线中心点像素坐标 <code>{ x: number, y: number }</code></td></tr>
<tr><td><code>face_measure</code></td><td>object \| null</td><td>复用<a href="#if1">接口1</a><strong>四庭七眼测量数值</strong>(不含标注图)。独立流程,测量失败时为 <code>null</code>,不影响发际线主结果。结构见下表</td></tr> <tr><td><code>face_measure</code></td><td>object \| null</td><td>复用<a href="#if1">接口1</a><strong>四庭七眼测量数值</strong>(不含标注图)。独立流程,测量失败时为 <code>null</code>,不影响发际线主结果。结构见下表</td></tr>
</table> </table>
+10 -3
View File
@@ -59,6 +59,10 @@
.scheme-imgs .img-slot .thumb { background: #222; border-radius: 6px; overflow: hidden; line-height: 0; } .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 .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; } .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-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; } .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) {
'<div class="label">📷 原图</div>'+ '<div class="label">📷 原图</div>'+
'<div class="thumb"><img src="'+_origUrl+'" alt="原图"></div></div>'; '<div class="thumb"><img src="'+_origUrl+'" alt="原图"></div></div>';
// 发际线叠加 // 发际线叠加(原图 + 透明 PNG 叠加显示)
const overlaySlot = '<div class="img-slot">'+ const overlaySlot = '<div class="img-slot">'+
'<div class="label"><span class="dot preview"></span>发际线叠加</div>'+ '<div class="label"><span class="dot preview"></span>原图+发际线叠加</div>'+
'<div class="thumb"><img src="'+r.image_url+'" alt="叠加"></div></div>'; '<div class="thumb"><div class="img-stack">'+
'<img class="layer-base" src="'+_origUrl+'" alt="原图">'+
'<img class="layer-anno" src="'+r.image_url+'" alt="叠加">'+
'</div></div></div>';
// 生发效果 // 生发效果
let grownSlot; let grownSlot;
+16 -5
View File
@@ -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 .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 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; } .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); } } @media (max-width: 700px) { .level-row { grid-template-columns: repeat(2, 1fr); } }
/* 坐标高亮 */ /* 坐标高亮 */
@@ -224,7 +228,14 @@ async function submitTest() {
function renderGrid() { function renderGrid() {
if (!_images.length) { $('resultsGrid').innerHTML = '<span style="color:#9ca3af">无数据</span>'; return; } if (!_images.length) { $('resultsGrid').innerHTML = '<span style="color:#9ca3af">无数据</span>'; return; }
let h = ''; let h = '';
const cell = (label, url) => url // 叠图档:原图打底 + 透明 PNG 叠加(无原图或无叠图 → 显示占位)
const overlayCell = (label, url) => (url && _origUrl)
? '<div class="level-cell"><div class="level-label">'+label+'</div><div class="img-stack">'+
'<img class="layer-base" src="'+_origUrl+'" alt="原图">'+
'<img class="layer-anno" src="'+url+'" alt="'+label+'"></div></div>'
: '<div class="level-cell"><div class="level-label">'+label+'</div><div class="level-none">无</div></div>';
// 生发图:独立 img(已是 ComfyUI 完整人像照片)
const grownCell = (label, url) => url
? '<div class="level-cell"><div class="level-label">'+label+'</div><img src="'+url+'" alt="'+label+'"></div>' ? '<div class="level-cell"><div class="level-label">'+label+'</div><img src="'+url+'" alt="'+label+'"></div>'
: '<div class="level-cell"><div class="level-label">'+label+'</div><div class="level-none">无</div></div>'; : '<div class="level-cell"><div class="level-label">'+label+'</div><div class="level-none">无</div></div>';
_images.forEach(function(it) { _images.forEach(function(it) {
@@ -232,10 +243,10 @@ function renderGrid() {
'<div class="hair-block-title">#' + (it.order||'—') + ' ' + (it.hairline_type||'') + '<div class="hair-block-title">#' + (it.order||'—') + ' ' + (it.hairline_type||'') +
(it.grown_image_url ? ' &nbsp;<span style="font-size:11px;color:#7c3aed">含生发图</span>' : '') + '</div>' + (it.grown_image_url ? ' &nbsp;<span style="font-size:11px;color:#7c3aed">含生发图</span>' : '') + '</div>' +
'<div class="level-row">' + '<div class="level-row">' +
cell('middle', it.image_middle_url) + overlayCell('middle', it.image_middle_url) +
cell('high', it.image_high_url) + overlayCell('high', it.image_high_url) +
cell('low', it.image_low_url) + overlayCell('low', it.image_low_url) +
cell('生发图', it.grown_image_url) + grownCell('生发图', it.grown_image_url) +
'</div>' + '</div>' +
'</div>'; '</div>';
}); });
+6 -5
View File
@@ -90,8 +90,8 @@ def test_grow_female_returns_5(client, monkeypatch):
results = body["data"]["results"] results = body["data"]["results"]
assert [x["hairline_type"] for x in results] == ["ellipse", "flower", "heart", "straight", "wave"] 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 [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]["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 base64.b64decode(results[0]["grown_image_base64"])[:3] == b"\xff\xd8\xff" # JPEG(生发图)
assert "image_url" not in results[0] assert "image_url" not in results[0]
@@ -149,9 +149,10 @@ def test_hairline_gen_female(client, monkeypatch):
# 只返回选中发型,order = 发型序号 # 只返回选中发型,order = 发型序号
assert [x["order"] for x in imgs] == [1, 3] assert [x["order"] for x in imgs] == [1, 3]
assert [x["hairline_type"] for x in imgs] == ["ellipse", "heart"] assert [x["hairline_type"] for x in imgs] == ["ellipse", "heart"]
# 三档叠图 + 生发图为 JPEGworker 返回 base64,非 url # 三档叠图为透明 PNG(发际线曲线,含 alpha),生发图为 JPEG完整人像
for k in ("image_middle_base64", "image_high_base64", "image_low_base64", "grown_image_base64"): for k in ("image_middle_base64", "image_high_base64", "image_low_base64"):
assert base64.b64decode(imgs[0][k])[:3] == b"\xff\xd8\xff", k 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] assert "image_middle_url" not in imgs[0]
c = d["best_hairline_center_point"] c = d["best_hairline_center_point"]
assert 0 <= c["x"] <= 682 and 0 <= c["y"] <= 811 # 落在原图范围内 assert 0 <= c["x"] <= 682 and 0 <= c["y"] <= 811 # 落在原图范围内