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:
@@ -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
@@ -244,10 +244,10 @@
|
|||||||
|
|
||||||
## 接口 2:C 端生发接口
|
## 接口 2:C 端生发接口
|
||||||
|
|
||||||
**说明**:输入用户正面照 + 性别 + 发型序号(可多选),按指定发际线类型渲染预览图 + 生发图。
|
**说明**:输入用户正面照 + 性别 + 发型序号(可多选),按指定发际线类型渲染发际线曲线透明 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 | **生发后图片** URL(ComfyUI/Flux「植发 3 个月」效果图) |
|
| grown_image_url | string | **生发后图片** URL(ComfyUI/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`,按贴图顺序,暂不计算合适度) |
|
||||||
|
|
||||||
> ⚠️ 生发图由本机 ComfyUI(Flux-2,端口 8182)生成,**一次请求生成指定发型的 1 张、同步返回**。
|
> ⚠️ 生发图由本机 ComfyUI(Flux-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 | **生发后图片** URL(ComfyUI「植发」效果图,完整人像照片,生发失败时为 `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
@@ -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_styles(1-indexed 列表):指定生成哪几张发际线(按贴图排序)。female: 1..5,male: 1..4。
|
hair_styles(1-indexed 列表):指定生成哪几张发际线(按贴图排序)。female: 1..5,male: 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/low,RGBA 透明层只含发际线曲线)与一张生发图;
|
||||||
生发黑模板固定取自 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))
|
||||||
|
|||||||
@@ -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 |
|
Female 5 种:ellipse/flower/heart/straight/wave |
|
||||||
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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 ? ' <span style="font-size:11px;color:#7c3aed">含生发图</span>' : '') + '</div>' +
|
(it.grown_image_url ? ' <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
@@ -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"]
|
||||||
# 三档叠图 + 生发图均为 JPEG(worker 返回 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 # 落在原图范围内
|
||||||
|
|||||||
Reference in New Issue
Block a user