feat: 拆分接口11/12,新增接口12 final / final v2 精简重绘端点
- 接口11 移除重绘,仅生成 final;接口12 (grow_v2) 负责发际线带 Flux-2 重绘 - 接口12 重绘带改为发际线外推 band_lo_mult~band_hi_mult 倍 push(默认 0.5~1.5),页面可调 - 接口12 同时产出 A 整帧重绘 与 B 局部加发+全脸美颜(beauty_alpha 可调) - 新增 grow_v2_final(整帧重绘)/ grow_v2_final_v2(B 局部+美颜)端点:仅需图片+发型 ID,其余用固化默认值(color_match 关闭) - 配套精简测试页 test_interface12_final.html / test_interface12_final_v2.html / test_interface12.html - 恢复接口11 调试页多频段与换发型可调参数、color_match 默认不勾选 - 删除旧脚本 batch_grow_v2.py / gen_report_hairline_v2.py / test_simple.html Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -1414,13 +1414,11 @@ async def hairline_grow(
|
|||||||
color_match_strength: float = Form(default=1.0, description="颜色迁移强度(0~1,1=全迁移,<1 只迁移部分防过度改色),默认 1.0"),
|
color_match_strength: float = Form(default=1.0, description="颜色迁移强度(0~1,1=全迁移,<1 只迁移部分防过度改色),默认 1.0"),
|
||||||
mb_feather_px: int = Form(default=1, description="多频段最细层掩码轻羽化像素(0=不羽化,消除发丝边缘锯齿),默认 1"),
|
mb_feather_px: int = Form(default=1, description="多频段最细层掩码轻羽化像素(0=不羽化,消除发丝边缘锯齿),默认 1"),
|
||||||
transition_band_px: int = Form(default=-1, description="keep-region 过渡带边距(-1=自动按层数 2**n,>=0 用绝对像素与层数解耦),默认 -1"),
|
transition_band_px: int = Form(default=-1, description="keep-region 过渡带边距(-1=自动按层数 2**n,>=0 用绝对像素与层数解耦),默认 -1"),
|
||||||
redraw: bool = Form(default=False, description="发际线带重绘开关:开启后额外跑一条分支——外推↔发际线带重绘,final输入,swapHair/Flux-2两路对比,结果单独展示(不替换final),默认 False"),
|
|
||||||
inpainting_fill: int = Form(default=1, description="change_hair服务端重绘填充:0=保留原图(治染绿) | 1=填充噪声(默认/原始) | 2=纯色 | 3=潜变量噪声。默认 1"),
|
inpainting_fill: int = Form(default=1, description="change_hair服务端重绘填充:0=保留原图(治染绿) | 1=填充噪声(默认/原始) | 2=纯色 | 3=潜变量噪声。默认 1"),
|
||||||
mask_blur: int = Form(default=11, description="change_hair服务端遮罩边缘模糊像素(原始11,越大颜色越易从边缘渗透),默认 11"),
|
mask_blur: int = Form(default=11, description="change_hair服务端遮罩边缘模糊像素(原始11,越大颜色越易从边缘渗透),默认 11"),
|
||||||
mask_dilate_scale: float = Form(default=1.0, description="change_hair服务端遮罩膨胀缩放(1.0=原始核尺寸,<1收缩防越界),默认 1.0"),
|
mask_dilate_scale: float = Form(default=1.0, description="change_hair服务端遮罩膨胀缩放(1.0=原始核尺寸,<1收缩防越界),默认 1.0"),
|
||||||
comfyui_prompt: Optional[str] = Form(default=None, description="redraw Flux-2路提示词,None用默认「补充遮罩区域的头发,加一点美颜」"),
|
|
||||||
):
|
):
|
||||||
"""接口11:发际线生发 + 分步可视化。遮罩固定 pushed,融合默认 multiband(可选 seamless/two_stage/feather)。"""
|
"""接口11:发际线生发 + 分步可视化(**不含重绘**,重绘见接口12)。遮罩固定 pushed,融合默认 multiband(可选 seamless/two_stage/feather)。"""
|
||||||
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
|
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
|
||||||
if e is not None:
|
if e is not None:
|
||||||
return e
|
return e
|
||||||
@@ -1435,10 +1433,10 @@ async def hairline_grow(
|
|||||||
from uuid import uuid4 as _uuid4
|
from uuid import uuid4 as _uuid4
|
||||||
rid = _uuid4().hex[:8]
|
rid = _uuid4().hex[:8]
|
||||||
logger.info("[%s] 接口11 收到请求: hairline_push_cm=%s hairline_edge=%s mb_levels=%s "
|
logger.info("[%s] 接口11 收到请求: hairline_push_cm=%s hairline_edge=%s mb_levels=%s "
|
||||||
"blend=%s color_match=%s cm_strength=%s mb_feather_px=%s transition_band_px=%s redraw=%s "
|
"blend=%s color_match=%s cm_strength=%s mb_feather_px=%s transition_band_px=%s "
|
||||||
"inpainting_fill=%s mask_blur=%s mask_dilate_scale=%s",
|
"inpainting_fill=%s mask_blur=%s mask_dilate_scale=%s",
|
||||||
rid, hairline_push_cm, hairline_edge, mb_levels, blend_method,
|
rid, hairline_push_cm, hairline_edge, mb_levels, blend_method,
|
||||||
color_match, color_match_strength, mb_feather_px, transition_band_px, redraw,
|
color_match, color_match_strength, mb_feather_px, transition_band_px,
|
||||||
inpainting_fill, mask_blur, mask_dilate_scale)
|
inpainting_fill, mask_blur, mask_dilate_scale)
|
||||||
try:
|
try:
|
||||||
data = await run_in_threadpool(
|
data = await run_in_threadpool(
|
||||||
@@ -1450,8 +1448,8 @@ async def hairline_grow(
|
|||||||
hairline_edge=hairline_edge, blend_method=blend_method,
|
hairline_edge=hairline_edge, blend_method=blend_method,
|
||||||
color_match=color_match, color_match_strength=color_match_strength,
|
color_match=color_match, color_match_strength=color_match_strength,
|
||||||
mb_feather_px=mb_feather_px, transition_band_px=transition_band_px,
|
mb_feather_px=mb_feather_px, transition_band_px=transition_band_px,
|
||||||
redraw=redraw, inpainting_fill=inpainting_fill, mask_blur=mask_blur,
|
inpainting_fill=inpainting_fill, mask_blur=mask_blur,
|
||||||
mask_dilate_scale=mask_dilate_scale, comfyui_prompt=comfyui_prompt, rid=rid)
|
mask_dilate_scale=mask_dilate_scale, rid=rid)
|
||||||
except NoFaceError:
|
except NoFaceError:
|
||||||
return err(1001, "无法识别人像")
|
return err(1001, "无法识别人像")
|
||||||
except SwapError as se:
|
except SwapError as se:
|
||||||
@@ -1464,20 +1462,33 @@ async def hairline_grow(
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 接口 12:发际线生发(接口11 固定参数精简版)
|
# 接口 12:发际线带重绘(调用接口11 的 ④final 作输入 + ⑤-①重绘带作遮罩,Flux-2 重绘)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@app.post(
|
@app.post(
|
||||||
"/api/v1/hairline/grow_v2",
|
"/api/v1/hairline/grow_v2",
|
||||||
summary="接口12 发际线生发(固定 pushed 遮罩 + 多频段融合,仅返回最终图)",
|
summary="接口12 发际线带重绘(接口11 final + 发际线重绘带 → Flux-2 保色重绘)",
|
||||||
tags=["生发"],
|
tags=["生发"],
|
||||||
description=f"""
|
description=f"""
|
||||||
接口11 的固定参数精简版,适合生产直调。与接口11 共用同一管线,遮罩固定 pushed(发际线外推),
|
接口12 是接口11 的**下游重绘阶段**。内部先跑接口11 核心管线拿到 **④ 接缝融合最终图(final)**,
|
||||||
融合固定 multiband(多频段金字塔)。本接口固定 `erode_cm=0.6`、`mb_levels=5` 不暴露,
|
再取 **⑤-① 发际线重绘带**(发际线沿外推方向 `band_lo_mult×push` ~ `band_hi_mult×push`
|
||||||
**只返回 `final_base64`**(最终合成图),不附带分步可视化。
|
之间、经 ①-a baseline 截断只留上部的带状区域,默认 0.5×~1.5×push)
|
||||||
|
作为遮罩,用 **Flux-2(ComfyUI)** 做 reference-latent 保色重绘(不易染绿),重绘结果再与 final 融合。
|
||||||
|
|
||||||
其余参数(hairline_id、seg_model、gen_backend、is_hr、denoising_strength、edge_erode_px、
|
与接口11 的关系:接口11 只负责生成 final(不再含重绘);接口12 负责在 final 上做发际线带重绘。
|
||||||
hairline_push_cm、hairline_edge 等)保留为可选 Form,调用方可按需覆盖。
|
接口11 的可调参数(seg_model/gen_backend/hairline_push_cm/blend_method/color_match 等)
|
||||||
|
在本接口同样暴露,用于内部生成 final 与重绘带;另有 `comfyui_prompt` 控制 Flux-2 提示词。
|
||||||
|
|
||||||
|
⚠️ 依赖 ComfyUI(默认 :8188)在跑,否则重绘失败会在 `data.redraw.c_error` 报告。
|
||||||
|
|
||||||
|
**同时产出两版结果供对比**:
|
||||||
|
- `redraw_full`:ComfyUI 整帧输出(全脸美颜 + 全脸重绘),与手动跑 ComfyUI 一致。
|
||||||
|
- `redraw_band`:加发只在发际线带、美颜保留全脸(band 内用 ComfyUI 重绘,band 外 = final 结构
|
||||||
|
+ 按 `beauty_alpha` 融入全脸美颜)。
|
||||||
|
|
||||||
|
返回 `data.steps`:`input`(原图)/ `final`(接口11 的 ④,重绘输入基底)/
|
||||||
|
`redraw_band_overlay`(⑤-① 重绘带可视化)/ `redraw_full`(A 整帧)/ `redraw_band`(B 局部加发+全脸美颜)/
|
||||||
|
`redraw_c`(兼容旧字段,=redraw_full)。
|
||||||
|
|
||||||
{_image_fields_desc}
|
{_image_fields_desc}
|
||||||
""",
|
""",
|
||||||
@@ -1491,13 +1502,27 @@ async def hairline_grow_v2(
|
|||||||
hairgrow_strength: float = Form(default=0.75, description="区域生发强度(仅 hairgrow 后端),默认 0.75"),
|
hairgrow_strength: float = Form(default=0.75, description="区域生发强度(仅 hairgrow 后端),默认 0.75"),
|
||||||
is_hr: bool = Form(default=False, description="高清模式(换发型输出 1152×1536,否则 576×768)"),
|
is_hr: bool = Form(default=False, description="高清模式(换发型输出 1152×1536,否则 576×768)"),
|
||||||
seg_model: str = Form(default="segformer", description="头发分割模型:bisenet | segformer(默认 segformer)"),
|
seg_model: str = Form(default="segformer", description="头发分割模型:bisenet | segformer(默认 segformer)"),
|
||||||
|
erode_cm: float = Form(default=0.6, description="baseline 参考内缩距离(厘米),默认 0.6"),
|
||||||
swap_mode: str = Form(default="ext_mask", description="换发型取图模式:ext_mask | as_is(默认 ext_mask)"),
|
swap_mode: str = Form(default="ext_mask", description="换发型取图模式:ext_mask | as_is(默认 ext_mask)"),
|
||||||
edge_erode_px: int = Form(default=3, description="贴图前遮罩内缩像素(防边缘露皮/光晕),默认 3"),
|
edge_erode_px: int = Form(default=3, description="贴图前遮罩内缩像素(防边缘露皮/光晕),默认 3"),
|
||||||
denoising_strength: float = Form(default=0.6, description="换发型 webui 重绘强度(越大生发越激进),默认 0.6"),
|
denoising_strength: float = Form(default=0.6, description="换发型 webui 重绘强度(越大生发越激进),默认 0.6"),
|
||||||
|
mb_levels: int = Form(default=5, description="多频段金字塔层数(2~6),默认 5"),
|
||||||
hairline_push_cm: float = Form(default=1.0, description="发际线外推距离(厘米),默认 1.0"),
|
hairline_push_cm: float = Form(default=1.0, description="发际线外推距离(厘米),默认 1.0"),
|
||||||
hairline_edge: str = Form(default="column", description="发际线提取方式:column | contour,默认 column"),
|
hairline_edge: str = Form(default="column", description="发际线提取方式:column | contour,默认 column"),
|
||||||
|
blend_method: str = Form(default="multiband", description="接缝融合方法:multiband | seamless | two_stage | feather | alpha_gradient"),
|
||||||
|
color_match: bool = Form(default=True, description="融合前 Reinhard 颜色迁移消除整体色差,默认 True"),
|
||||||
|
color_match_strength: float = Form(default=1.0, description="颜色迁移强度(0~1),默认 1.0"),
|
||||||
|
mb_feather_px: int = Form(default=1, description="多频段最细层掩码轻羽化像素,默认 1"),
|
||||||
|
transition_band_px: int = Form(default=-1, description="keep-region 过渡带边距(-1=自动),默认 -1"),
|
||||||
|
inpainting_fill: int = Form(default=1, description="change_hair服务端重绘填充:0=保留原图 | 1=噪声 | 2=纯色 | 3=潜变量。默认 1"),
|
||||||
|
mask_blur: int = Form(default=11, description="change_hair服务端遮罩边缘模糊像素,默认 11"),
|
||||||
|
mask_dilate_scale: float = Form(default=1.0, description="change_hair服务端遮罩膨胀缩放,默认 1.0"),
|
||||||
|
comfyui_prompt: Optional[str] = Form(default=None, description="Flux-2 重绘提示词,None 用默认「补充遮罩区域的头发,加一点美颜」"),
|
||||||
|
beauty_alpha: float = Form(default=0.6, description="redraw_band 版 band 外的全脸美颜融入强度(0=band外无美颜纯用final,1≈整帧版),默认 0.6"),
|
||||||
|
band_lo_mult: float = Form(default=0.5, description="重绘带外推倍率下限(相对 hairline_push_cm,内轮廓=0×、原外推线=1.0×),默认 0.5"),
|
||||||
|
band_hi_mult: float = Form(default=1.5, description="重绘带外推倍率上限(相对 hairline_push_cm),默认 1.5"),
|
||||||
):
|
):
|
||||||
"""接口12:发际线生发(固定 pushed 遮罩 + multiband 融合,仅返回最终图)。"""
|
"""接口12:发际线带重绘。同时产出 redraw_full(整帧美颜) 与 redraw_band(局部加发+全脸美颜) 两版对比。"""
|
||||||
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
|
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
|
||||||
if e is not None:
|
if e is not None:
|
||||||
return e
|
return e
|
||||||
@@ -1508,30 +1533,137 @@ async def hairline_grow_v2(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from fastapi.concurrency import run_in_threadpool
|
from fastapi.concurrency import run_in_threadpool
|
||||||
from face_analysis.hairline_grow import generate_hairline_grow, NoFaceError, SwapError
|
from face_analysis.hairline_grow import generate_hairline_redraw, NoFaceError, SwapError
|
||||||
|
from uuid import uuid4 as _uuid4
|
||||||
|
rid = _uuid4().hex[:8]
|
||||||
|
logger.info("[%s] 接口12 收到请求: hairline_id=%s hairline_push_cm=%s blend=%s comfyui_prompt=%r",
|
||||||
|
rid, hairline_id, hairline_push_cm, blend_method, comfyui_prompt)
|
||||||
try:
|
try:
|
||||||
# 固定:mask_type=pushed、blend_method=multiband、erode_cm=0.6、mb_levels=5
|
|
||||||
data = await run_in_threadpool(
|
data = await run_in_threadpool(
|
||||||
generate_hairline_grow, image, hairline_id,
|
generate_hairline_redraw, image, hairline_id,
|
||||||
is_hr=is_hr, seg_model=seg_model, erode_cm=0.6, swap_mode=swap_mode,
|
is_hr=is_hr, seg_model=seg_model, erode_cm=erode_cm, swap_mode=swap_mode,
|
||||||
edge_erode_px=edge_erode_px, denoising_strength=denoising_strength,
|
edge_erode_px=edge_erode_px, denoising_strength=denoising_strength,
|
||||||
gen_backend=gen_backend, hairgrow_strength=hairgrow_strength,
|
gen_backend=gen_backend, hairgrow_strength=hairgrow_strength,
|
||||||
mb_levels=5, hairline_push_cm=hairline_push_cm, hairline_edge=hairline_edge)
|
mb_levels=mb_levels, hairline_push_cm=hairline_push_cm,
|
||||||
|
hairline_edge=hairline_edge, blend_method=blend_method,
|
||||||
|
color_match=color_match, color_match_strength=color_match_strength,
|
||||||
|
mb_feather_px=mb_feather_px, transition_band_px=transition_band_px,
|
||||||
|
inpainting_fill=inpainting_fill, mask_blur=mask_blur,
|
||||||
|
mask_dilate_scale=mask_dilate_scale, comfyui_prompt=comfyui_prompt,
|
||||||
|
beauty_alpha=beauty_alpha, band_lo_mult=band_lo_mult,
|
||||||
|
band_hi_mult=band_hi_mult, rid=rid)
|
||||||
except NoFaceError:
|
except NoFaceError:
|
||||||
return err(1001, "无法识别人像")
|
return err(1001, "无法识别人像")
|
||||||
except SwapError as se:
|
except SwapError as se:
|
||||||
return err(1007, f"换发型失败:{se}")
|
return err(1007, f"换发型失败:{se}")
|
||||||
# 精简返回:只取最终合成图,丢掉接口11 的全部分步可视化
|
logger.info("[%s] 接口12 成功返回", rid)
|
||||||
return ok({
|
return ok(data)
|
||||||
"hairline_id": data["hairline_id"],
|
|
||||||
"image_size": data["image_size"],
|
|
||||||
"final_base64": data["steps"]["final_base64"],
|
|
||||||
})
|
|
||||||
except Exception as ex: # noqa: BLE001
|
except Exception as ex: # noqa: BLE001
|
||||||
logger.exception("接口12 处理异常")
|
logger.exception("接口12 处理异常")
|
||||||
return err(1007, f"处理失败:{ex}")
|
return err(1007, f"处理失败:{ex}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 接口 12 final:精简版发际线带重绘(仅需图片 + 发型 ID,其余参数全用默认值)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# 接口12 final 固化的默认参数(= test_interface12.html 当前默认值,color_match 关闭)
|
||||||
|
_V2_FINAL_DEFAULTS = dict(
|
||||||
|
gen_backend="swaphair", hairgrow_strength=0.75, is_hr=False, seg_model="segformer",
|
||||||
|
erode_cm=0.6, swap_mode="ext_mask", edge_erode_px=3, denoising_strength=0.6,
|
||||||
|
mb_levels=5, hairline_push_cm=0.8, hairline_edge="column", blend_method="two_stage",
|
||||||
|
color_match=False, color_match_strength=0.4, mb_feather_px=1, transition_band_px=-1,
|
||||||
|
inpainting_fill=1, mask_blur=11, mask_dilate_scale=1.0, comfyui_prompt=None,
|
||||||
|
beauty_alpha=0.6, band_lo_mult=0.5, band_hi_mult=1.5,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_v2_final(image_file, image_url, image_base64, hairline_id, tag):
|
||||||
|
"""接口12 final / final v2 共用:仅需图片 + hairline_id,其余用固化默认值。
|
||||||
|
两者后端计算完全一致(同一次 ComfyUI 输出同时含 A 整帧与 B 局部+美颜),
|
||||||
|
差异仅在配套测试页展示哪一版。"""
|
||||||
|
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
|
||||||
|
if e is not None:
|
||||||
|
return e
|
||||||
|
|
||||||
|
image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
|
||||||
|
if image is None:
|
||||||
|
return err(1008, "图片格式不支持(仅 JPG / PNG)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from fastapi.concurrency import run_in_threadpool
|
||||||
|
from face_analysis.hairline_grow import generate_hairline_redraw, NoFaceError, SwapError
|
||||||
|
from uuid import uuid4 as _uuid4
|
||||||
|
rid = _uuid4().hex[:8]
|
||||||
|
logger.info("[%s] %s 收到请求: hairline_id=%s(其余用默认值)", rid, tag, hairline_id)
|
||||||
|
try:
|
||||||
|
data = await run_in_threadpool(
|
||||||
|
generate_hairline_redraw, image, hairline_id,
|
||||||
|
rid=rid, **_V2_FINAL_DEFAULTS)
|
||||||
|
except NoFaceError:
|
||||||
|
return err(1001, "无法识别人像")
|
||||||
|
except SwapError as se:
|
||||||
|
return err(1007, f"换发型失败:{se}")
|
||||||
|
logger.info("[%s] %s 成功返回", rid, tag)
|
||||||
|
return ok(data)
|
||||||
|
except Exception as ex: # noqa: BLE001
|
||||||
|
logger.exception("%s 处理异常", tag)
|
||||||
|
return err(1007, f"处理失败:{ex}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post(
|
||||||
|
"/api/v1/hairline/grow_v2_final",
|
||||||
|
summary="接口12 final 精简重绘(整帧重绘;仅图片 + 发型 ID)",
|
||||||
|
tags=["生发"],
|
||||||
|
description=f"""
|
||||||
|
接口12 的**精简/生产版**:只需上传图片 + 选择 `hairline_id`,其余所有参数固化为当前调优默认值
|
||||||
|
(hairline_push_cm=0.8 / blend_method=two_stage / **color_match=关闭** / color_match_strength=0.4 /
|
||||||
|
beauty_alpha=0.6 / band_lo_mult=0.5 / band_hi_mult=1.5 等)。
|
||||||
|
|
||||||
|
**最终重绘取整帧重绘**(`data.steps.redraw_full`,全脸美颜 + 全脸重绘)。
|
||||||
|
返回结构与接口12 一致(`data.steps` 同时含 `redraw_full`(A 整帧)/ `redraw_band`(B 局部+美颜))。
|
||||||
|
|
||||||
|
⚠️ 依赖 ComfyUI(默认 :8188)在跑。
|
||||||
|
|
||||||
|
{_image_fields_desc}
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
async def hairline_grow_v2_final(
|
||||||
|
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG)"),
|
||||||
|
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
||||||
|
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
||||||
|
hairline_id: str = Form(..., description="发际线类型 ID(= change_hair hair_id,如 chang_bolang)"),
|
||||||
|
):
|
||||||
|
"""接口12 final:整帧重绘版,仅需图片 + hairline_id。"""
|
||||||
|
return await _run_v2_final(image_file, image_url, image_base64, hairline_id, "接口12final")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post(
|
||||||
|
"/api/v1/hairline/grow_v2_final_v2",
|
||||||
|
summary="接口12 final v2 精简重绘(B 局部加发+全脸美颜;仅图片 + 发型 ID)",
|
||||||
|
tags=["生发"],
|
||||||
|
description=f"""
|
||||||
|
接口12 final 的**局部加发版**:参数与 `grow_v2_final` 完全相同(color_match 关闭等),
|
||||||
|
唯一区别是**最终重绘取 B 局部加发+全脸美颜**(`data.steps.redraw_band`:
|
||||||
|
加发只在发际线带内、band 外保留 final 结构并按 beauty_alpha 融入全脸美颜)。
|
||||||
|
|
||||||
|
返回结构与接口12 一致(`data.steps` 同时含 `redraw_full`(A 整帧)/ `redraw_band`(B 局部+美颜))。
|
||||||
|
|
||||||
|
⚠️ 依赖 ComfyUI(默认 :8188)在跑。
|
||||||
|
|
||||||
|
{_image_fields_desc}
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
async def hairline_grow_v2_final_v2(
|
||||||
|
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG)"),
|
||||||
|
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
||||||
|
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
||||||
|
hairline_id: str = Form(..., description="发际线类型 ID(= change_hair hair_id,如 chang_bolang)"),
|
||||||
|
):
|
||||||
|
"""接口12 final v2:B 局部加发+全脸美颜版,仅需图片 + hairline_id。"""
|
||||||
|
return await _run_v2_final(image_file, image_url, image_base64, hairline_id, "接口12finalv2")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 调试:下载后端日志(接口11 遮罩计算全过程)
|
# 调试:下载后端日志(接口11 遮罩计算全过程)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
|
After Width: | Height: | Size: 304 KiB |
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"request_id": "mock-request-id",
|
||||||
|
"data": {
|
||||||
|
"hairline_id": "chang_zhixian",
|
||||||
|
"gen_backend": "swaphair",
|
||||||
|
"hairgrow_strength": 0.75,
|
||||||
|
"is_hr": false,
|
||||||
|
"seg_model": "segformer",
|
||||||
|
"mask_type": "pushed",
|
||||||
|
"erode_cm": 0.6,
|
||||||
|
"swap_mode": "ext_mask",
|
||||||
|
"blend_method": "multiband",
|
||||||
|
"edge_erode_px": 3,
|
||||||
|
"mb_levels": 5,
|
||||||
|
"hairline_push_cm": 1.0,
|
||||||
|
"hairline_edge": "column",
|
||||||
|
"denoising_strength": 0.6,
|
||||||
|
"color_match": true,
|
||||||
|
"color_match_strength": 1.0,
|
||||||
|
"mb_feather_px": 1,
|
||||||
|
"transition_band_px": -1,
|
||||||
|
"inpainting_fill": 1,
|
||||||
|
"mask_blur": 11,
|
||||||
|
"mask_dilate_scale": 1.0,
|
||||||
|
"px_per_cm": 47.5311,
|
||||||
|
"erode_px": 29,
|
||||||
|
"hair_pixels": 186798,
|
||||||
|
"closed_pixels": 191712,
|
||||||
|
"mask_pixels": 140299,
|
||||||
|
"image_size": {
|
||||||
|
"width": 1257,
|
||||||
|
"height": 1495
|
||||||
|
},
|
||||||
|
"timings_ms": {
|
||||||
|
"mask": 1462,
|
||||||
|
"swap": 5596,
|
||||||
|
"blend": 220
|
||||||
|
},
|
||||||
|
"redraw": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"_rid": "bc7205a4",
|
||||||
|
"steps": {
|
||||||
|
"input_base64": "<omitted 427407 chars>",
|
||||||
|
"baseline_overlay_base64": "<omitted 435895 chars>",
|
||||||
|
"upper_overlay_base64": "<omitted 384479 chars>",
|
||||||
|
"hair_seg_overlay_base64": "<omitted 428039 chars>",
|
||||||
|
"top_fill_overlay_base64": "",
|
||||||
|
"closed_overlay_base64": "",
|
||||||
|
"hairline_overlay_base64": "<omitted 445467 chars>",
|
||||||
|
"pushed_overlay_base64": "<omitted 452803 chars>",
|
||||||
|
"mask_overlay_base64": "<omitted 420375 chars>",
|
||||||
|
"mask_base64": "<omitted 8026 chars>",
|
||||||
|
"swap_raw_base64": "<omitted 340791 chars>",
|
||||||
|
"hard_paste_base64": "<omitted 420679 chars>",
|
||||||
|
"alpha_base64": "<omitted 7762 chars>",
|
||||||
|
"final_base64": "<omitted 415811 chars>",
|
||||||
|
"redraw_band_overlay_base64": "",
|
||||||
|
"redraw_a_base64": "",
|
||||||
|
"redraw_c_base64": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 304 KiB |
|
After Width: | Height: | Size: 314 KiB |
|
After Width: | Height: | Size: 326 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 313 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 332 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 282 KiB |
@@ -0,0 +1,182 @@
|
|||||||
|
# 接口11 运行记录 — `image/hair_test.jpg`
|
||||||
|
|
||||||
|
> 实测时间:2026-07-15
|
||||||
|
> 调用:`POST http://127.0.0.1:8187/api/v1/hairline/grow`
|
||||||
|
> 鉴权:`X-Internal-Token: dev-shared-secret-2026`
|
||||||
|
> 输入图:`image/hair_test.jpg`(1257×1495)
|
||||||
|
> `hairline_id`:`chang_zhixian`(直线);其余全部走接口默认值
|
||||||
|
> 业务结果:`code=0`,`rid=bc7205a4`
|
||||||
|
> 产物目录:`docs/iface11_hair_test_run/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 本次调用用到的全部默认参数
|
||||||
|
|
||||||
|
未在 Form 里显式传的参数均取 `app.py` / `generate_hairline_grow` 默认值;下表即本次实际生效值。
|
||||||
|
|
||||||
|
| 参数 | 本次值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| `hairline_id` | `chang_zhixian` | **必填**。发际线类型 = change_hair 的 `hair_id`(直线) |
|
||||||
|
| `gen_backend` | `swaphair` | 生成后端:换发型 LoRA |
|
||||||
|
| `hairgrow_strength` | `0.75` | 仅 `hairgrow` 后端用;本次未走该路径 |
|
||||||
|
| `is_hr` | `false` | 高清关闭(576×768 档,非 1152×1536) |
|
||||||
|
| `seg_model` | `segformer` | 头发分割模型 |
|
||||||
|
| `erode_cm` | `0.6` | baseline 参考内缩(cm);pushed 下影响很小 |
|
||||||
|
| `hairline_push_cm` | `1.0` | 发际线内轮廓径向外推距离(cm) |
|
||||||
|
| `hairline_edge` | `column` | 兼容入参;当前内轮廓提取不再按它分支 |
|
||||||
|
| `swap_mode` | `ext_mask` | 把 pushed 遮罩作为 `ext_mask` 传给 swapHair |
|
||||||
|
| `edge_erode_px` | `3` | 贴图前遮罩内缩像素 |
|
||||||
|
| `denoising_strength` | `0.6` | 换发型 webui 重绘强度 |
|
||||||
|
| `mb_levels` | `5` | 多频段金字塔层数 |
|
||||||
|
| `blend_method` | `multiband` | 接缝融合:多频段金字塔 |
|
||||||
|
| `color_match` | `true` | 融合前 Reinhard 颜色迁移 |
|
||||||
|
| `color_match_strength` | `1.0` | 颜色迁移强度(全迁移) |
|
||||||
|
| `mb_feather_px` | `1` | 多频段最细层掩码轻羽化 |
|
||||||
|
| `transition_band_px` | `-1` | keep-region 过渡带:自动按层数 `2**n` |
|
||||||
|
| `redraw` | `false` | 发际线带重绘关闭 |
|
||||||
|
| `inpainting_fill` | `1` | change_hair 填充噪声 |
|
||||||
|
| `mask_blur` | `11` | change_hair 遮罩边缘模糊像素 |
|
||||||
|
| `mask_dilate_scale` | `1.0` | change_hair 遮罩膨胀缩放 |
|
||||||
|
| `comfyui_prompt` | `null` | 仅 `redraw`+Flux-2 路用;本次未用 |
|
||||||
|
| `mask_type` | `pushed`(固定) | 代码写死,不可选 |
|
||||||
|
|
||||||
|
图片入参:仅传了 `image_file`(三选一中的文件上传)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 返回元数据(无 base64)
|
||||||
|
|
||||||
|
| 字段 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| `px_per_cm` | 47.5311(虹膜直径标定) |
|
||||||
|
| `erode_px` | 29(≈ 0.6cm × px_per_cm) |
|
||||||
|
| `hair_pixels` | 186798 |
|
||||||
|
| `closed_pixels` | 191712 |
|
||||||
|
| `mask_pixels` | 140299 |
|
||||||
|
| `image_size` | 1257 × 1495 |
|
||||||
|
| `timings_ms.mask` | 1462 |
|
||||||
|
| `timings_ms.swap` | 5596 |
|
||||||
|
| `timings_ms.blend` | 220 |
|
||||||
|
| `redraw.enabled` | false |
|
||||||
|
| 总耗时(curl) | ≈ 7.4 s |
|
||||||
|
|
||||||
|
完整精简 JSON:`docs/iface11_hair_test_run/response_meta.json`
|
||||||
|
完整原始响应(含 base64):`docs/iface11_hair_test_run/response.json`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 管线分步说明与产物
|
||||||
|
|
||||||
|
管线:① pushed 遮罩 → ② swapHair 生成 → ③ 硬贴回 → ④ multiband 融合。
|
||||||
|
各步图保存在 `docs/iface11_hair_test_run/steps/`。
|
||||||
|
|
||||||
|
### ①-a 发际线分割线(baseline)
|
||||||
|
|
||||||
|
- **做什么**:MediaPipe 关键点连成眉骨折线(中心为 151 眉心),并向左右边缘水平延长。
|
||||||
|
- **图**:[`steps/baseline_overlay.jpg`](iface11_hair_test_run/steps/baseline_overlay.jpg)
|
||||||
|
- **含义**:黄线 = baseline;151 中心点为后续径向外推圆心。
|
||||||
|
|
||||||
|
### ①-b 分割线上半区(upper)
|
||||||
|
|
||||||
|
- **做什么**:baseline 折线以上的多边形区域,作为后续裁剪范围。
|
||||||
|
- **图**:[`steps/upper_overlay.jpg`](iface11_hair_test_run/steps/upper_overlay.jpg)
|
||||||
|
- **含义**:青 = 上半区。
|
||||||
|
|
||||||
|
### ①-c 头发分割(hair_seg)
|
||||||
|
|
||||||
|
- **做什么**:SegFormer 得到头发二值掩码。
|
||||||
|
- **图**:[`steps/hair_seg_overlay.jpg`](iface11_hair_test_run/steps/hair_seg_overlay.jpg)
|
||||||
|
- **含义**:绿 = 原始头发像素(本次 `hair_pixels=186798`)。
|
||||||
|
|
||||||
|
### ①-d / ①-e(旧 eroded/closed 中间步)
|
||||||
|
|
||||||
|
- pushed 模式**不走**这两步;返回字段为空字符串。
|
||||||
|
- `top_fill_overlay` / `closed_overlay`:本次无图。
|
||||||
|
|
||||||
|
### ①-f 头发内轮廓线(hairline)
|
||||||
|
|
||||||
|
- **做什么**:取头发朝脸一侧的内轮廓(额头弧 + 两侧到下颌),有序折线。
|
||||||
|
- **图**:[`steps/hairline_overlay.jpg`](iface11_hair_test_run/steps/hairline_overlay.jpg)
|
||||||
|
- **含义**:绿 = 内轮廓;黄 = baseline。
|
||||||
|
|
||||||
|
### ①-g 外推发际线(pushed)
|
||||||
|
|
||||||
|
- **做什么**:以眉心 151 为圆心,内轮廓逐点向外推 `hairline_push_cm=1.0`(≈ 47.5 px),与 baseline 组闭合区域。
|
||||||
|
- **图**:[`steps/pushed_overlay.jpg`](iface11_hair_test_run/steps/pushed_overlay.jpg)
|
||||||
|
- **含义**:青 = 外推线;红 = 外推遮罩区域。
|
||||||
|
|
||||||
|
### ① 最终遮罩
|
||||||
|
|
||||||
|
- **叠加图**:[`steps/mask_overlay.jpg`](iface11_hair_test_run/steps/mask_overlay.jpg) — 红 = 遮罩区(贴回/生成区)
|
||||||
|
- **纯遮罩**:[`steps/mask.png`](iface11_hair_test_run/steps/mask.png) — 白 = 生成/贴回区
|
||||||
|
- 本次 `mask_pixels=140299`;贴图前再内缩 `edge_erode_px=3`。
|
||||||
|
|
||||||
|
### ② 生成全帧(swap_raw)
|
||||||
|
|
||||||
|
- **做什么**:`gen_backend=swaphair` + `swap_mode=ext_mask`,把遮罩交给 change_hair(`:8801`),LoRA=`chang_zhixian`,`denoising_strength=0.6`。
|
||||||
|
- **图**:[`steps/swap_raw.jpg`](iface11_hair_test_run/steps/swap_raw.jpg)
|
||||||
|
- **含义**:生成结果已与原图同分辨率对齐;耗时约 5.6 s。
|
||||||
|
|
||||||
|
### ③ 严格按遮罩贴回(hard_paste)
|
||||||
|
|
||||||
|
- **做什么**:遮罩内用生成图,遮罩外保持原图,无融合。
|
||||||
|
- **图**:[`steps/hard_paste.jpg`](iface11_hair_test_run/steps/hard_paste.jpg)
|
||||||
|
- **含义**:用于对比接缝融合前后差异。
|
||||||
|
|
||||||
|
### ④ 融合权重 alpha + 最终结果
|
||||||
|
|
||||||
|
- **做法**:`blend_method=multiband`,`mb_levels=5`,`color_match=true`(强度 1.0),`mb_feather_px=1`。
|
||||||
|
- **alpha**:[`steps/alpha.png`](iface11_hair_test_run/steps/alpha.png) — 白 = 更多采用生成图
|
||||||
|
- **最终输出**:[`steps/final.jpg`](iface11_hair_test_run/steps/final.jpg)(副本:[`final.jpg`](iface11_hair_test_run/final.jpg))
|
||||||
|
- **输入对照**:[`steps/input.jpg`](iface11_hair_test_run/steps/input.jpg)
|
||||||
|
|
||||||
|
### ⑤ 发际线带重绘(本次关闭)
|
||||||
|
|
||||||
|
`redraw=false`,故 `redraw_band_overlay` / `redraw_a` / `redraw_c` 均为空。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 最终输出
|
||||||
|
|
||||||
|
**主结果文件**:[`docs/iface11_hair_test_run/final.jpg`](iface11_hair_test_run/final.jpg)
|
||||||
|
|
||||||
|
含义:同一人、同一发型观感下,按直线发际线类型(`chang_zhixian`)压低发际线后的合成图;遮罩外像素保持原图不动。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 复现命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST "http://127.0.0.1:8187/api/v1/hairline/grow" \
|
||||||
|
-H "X-Internal-Token: dev-shared-secret-2026" \
|
||||||
|
-F "image_file=@image/hair_test.jpg" \
|
||||||
|
-F "hairline_id=chang_zhixian" \
|
||||||
|
-o docs/iface11_hair_test_run/response.json
|
||||||
|
```
|
||||||
|
|
||||||
|
(其余参数全部省略即可走默认值。)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 产物清单
|
||||||
|
|
||||||
|
```
|
||||||
|
docs/接口11_hair_test运行记录.md ← 本文档
|
||||||
|
docs/iface11_hair_test_run/
|
||||||
|
final.jpg ← 最终结果
|
||||||
|
response.json ← 完整 API 响应(含 base64)
|
||||||
|
response_meta.json ← 去掉大图的元数据
|
||||||
|
steps/
|
||||||
|
input.jpg
|
||||||
|
baseline_overlay.jpg
|
||||||
|
upper_overlay.jpg
|
||||||
|
hair_seg_overlay.jpg
|
||||||
|
hairline_overlay.jpg
|
||||||
|
pushed_overlay.jpg
|
||||||
|
mask_overlay.jpg
|
||||||
|
mask.png
|
||||||
|
swap_raw.jpg
|
||||||
|
hard_paste.jpg
|
||||||
|
alpha.png
|
||||||
|
final.jpg
|
||||||
|
```
|
||||||
@@ -299,26 +299,42 @@ def _pushed_mask(hair_mask, upper, baseline_pts, push_px, rid="",
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _redraw_band_mask(inner_pts, outer_pts, h, w, rid=""):
|
def _redraw_band_mask(inner_pts, outer_pts, h, w, rid="", upper=None,
|
||||||
"""重绘带遮罩:把发际线(①-f 内轮廓 inner_pts)和外推发际线(①-g outer_pts)
|
lo_mult=0.5, hi_mult=1.5):
|
||||||
两条折线的端点连接成闭合多边形,填充得到带状区域,作为重绘 mask。
|
"""重绘带遮罩:由发际线(①-f 内轮廓 inner_pts)沿径向外推方向,取
|
||||||
|
`lo_mult × push` 与 `hi_mult × push` 两条外推线之间的带状区域作为重绘 mask。
|
||||||
|
|
||||||
inner_pts / outer_pts 是一一对应的有序点列(outer = inner 径向外推 push_px),
|
inner_pts / outer_pts 是一一对应的有序点列(outer = inner 径向外推 1.0×push_px),
|
||||||
故闭合环 = inner_pts(正向)+ outer_pts(反向)首尾相接。带只覆盖「现有头发下沿
|
故每点的 1.0× 位移向量 = outer - inner;下界线 = inner + lo_mult×位移,
|
||||||
到外推线」这一段(在头发一侧),正好是发际线交界处需要重绘融合的窄带。
|
上界线 = inner + hi_mult×位移。闭合环 = 下界线(正向)+ 上界线(反向)首尾相接。
|
||||||
|
|
||||||
|
lo_mult / hi_mult:外推倍率(相对 hairline_push_cm)。默认 0.5 / 1.5,即带位于
|
||||||
|
0.5×push ~ 1.5×push 之间(以内轮廓为 0×、原外推线为 1.0×)。
|
||||||
|
|
||||||
|
upper:①-a baseline 以上区域布尔掩码。传入时把重绘带与它求交集,只保留 baseline
|
||||||
|
以上的部分(两侧鬓角落到 baseline 以下的段会被截掉)。
|
||||||
|
|
||||||
返回 band_bool。
|
返回 band_bool。
|
||||||
"""
|
"""
|
||||||
lg = lambda msg: logger.info("[%s] %s", rid, msg) if rid else None
|
lg = lambda msg: logger.info("[%s] %s", rid, msg) if rid else None
|
||||||
if len(inner_pts) < 2 or len(outer_pts) < 2:
|
if len(inner_pts) < 2 or len(outer_pts) < 2:
|
||||||
return np.zeros((h, w), dtype=bool)
|
return np.zeros((h, w), dtype=bool)
|
||||||
# 闭合多边形:内轮廓正向 + 外推线反向,端点自然相连
|
inner_f = np.asarray(inner_pts, dtype=np.float32)
|
||||||
ring = np.vstack([inner_pts.astype(np.int32), outer_pts[::-1].astype(np.int32)])
|
outer_f = np.asarray(outer_pts, dtype=np.float32)
|
||||||
|
disp = outer_f - inner_f # 每点 1.0×push 的径向位移向量
|
||||||
|
lo_line = inner_f + float(lo_mult) * disp # 下界外推线(lo_mult×push)
|
||||||
|
hi_line = inner_f + float(hi_mult) * disp # 上界外推线(hi_mult×push)
|
||||||
|
# 闭合多边形:下界线正向 + 上界线反向,端点自然相连
|
||||||
|
ring = np.vstack([lo_line.astype(np.int32), hi_line[::-1].astype(np.int32)])
|
||||||
band_u8 = np.zeros((h, w), dtype=np.uint8)
|
band_u8 = np.zeros((h, w), dtype=np.uint8)
|
||||||
cv2.fillPoly(band_u8, [ring], 255)
|
cv2.fillPoly(band_u8, [ring], 255)
|
||||||
band = band_u8 > 0
|
band = band_u8 > 0
|
||||||
lg(f"_redraw_band_mask: 内轮廓点={len(inner_pts)} 外推点={len(outer_pts)} "
|
raw_px = int(band.sum())
|
||||||
f"band像素={int(band.sum())}")
|
# ①-a baseline 截断:只保留 baseline 以上的重绘带
|
||||||
|
if upper is not None:
|
||||||
|
band = band & upper
|
||||||
|
lg(f"_redraw_band_mask: 内轮廓点={len(inner_pts)} lo_mult={lo_mult} hi_mult={hi_mult} "
|
||||||
|
f"band像素(截断前)={raw_px} band像素(截断后)={int(band.sum())}")
|
||||||
return band
|
return band
|
||||||
|
|
||||||
|
|
||||||
@@ -436,6 +452,8 @@ def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm
|
|||||||
# 重绘带用原始数据:内轮廓点 + 外推点(供 _redraw_band_mask 连端点成带)
|
# 重绘带用原始数据:内轮廓点 + 外推点(供 _redraw_band_mask 连端点成带)
|
||||||
viz["_inner_pts"] = inner_pts
|
viz["_inner_pts"] = inner_pts
|
||||||
viz["_outer_pts"] = outer_pts
|
viz["_outer_pts"] = outer_pts
|
||||||
|
# baseline 以上区域,供重绘带按 ①-a baseline 截断(只留上面)
|
||||||
|
viz["_upper_mask"] = upper
|
||||||
# 记录 viz 各字段是否非空(长度),便于排查前端取不到图的问题
|
# 记录 viz 各字段是否非空(长度),便于排查前端取不到图的问题
|
||||||
viz_summary = {k: (len(v) if isinstance(v, str) and v else 0)
|
viz_summary = {k: (len(v) if isinstance(v, str) and v else 0)
|
||||||
for k, v in viz.items() if k.endswith("_base64")}
|
for k, v in viz.items() if k.endswith("_base64")}
|
||||||
@@ -804,47 +822,27 @@ def _composite(orig, swap_result, mask_bool, blend_method, feather_px, edge_erod
|
|||||||
# 主入口
|
# 主入口
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segformer",
|
def _grow_core(image_bgr, hairline_id, *, is_hr, seg_model, erode_cm, swap_mode,
|
||||||
erode_cm=0.6, swap_mode="ext_mask",
|
edge_erode_px, denoising_strength, gen_backend, hairgrow_strength,
|
||||||
edge_erode_px=3,
|
mb_levels, hairline_push_cm, hairline_edge, blend_method, color_match,
|
||||||
denoising_strength=0.6, gen_backend="swaphair",
|
color_match_strength, mb_feather_px, transition_band_px,
|
||||||
hairgrow_strength=0.75, mb_levels=5,
|
inpainting_fill, mask_blur, mask_dilate_scale, rid):
|
||||||
hairline_push_cm=1.0, hairline_edge="column",
|
"""接口11 共享核心:遮罩(pushed)→生成→硬贴回→接缝融合,产出 ④ final。
|
||||||
blend_method="multiband", color_match=True,
|
|
||||||
color_match_strength=1.0, mb_feather_px=1,
|
|
||||||
transition_band_px=-1, redraw=False,
|
|
||||||
inpainting_fill=1, mask_blur=11, mask_dilate_scale=1.0,
|
|
||||||
comfyui_prompt=None, rid=None):
|
|
||||||
"""接口11 完整管线。返回可直接进 ok() 的 data dict。未检出人脸抛 NoFaceError。
|
|
||||||
|
|
||||||
遮罩算法固定为 pushed(发际线外推)。
|
不做任何重绘。返回中间产物 dict(供接口11 构造响应、接口12 取 final+重绘带用):
|
||||||
融合算法 blend_method 默认 multiband(多频段金字塔),可选 seamless(泊松)/
|
final / swap_result / hard_paste / alpha / mask_bool / mask_viz /
|
||||||
two_stage(泊松→多频段两段式)/feather(羽化)/alpha_gradient(距离变换)。
|
px_per_cm / t_mask / t_swap / t_blend / h / w
|
||||||
color_match 默认开启 Reinhard 颜色迁移消除整体色差(对 multiband/feather 有效)。
|
未检出人脸抛 NoFaceError。
|
||||||
redraw=True 时额外跑一条「发际线带重绘」分支:
|
|
||||||
重绘区域 = 外推发际线↔内推发际线之间的带(以原内轮廓为中心,向头发/脸各推 push_cm)。
|
|
||||||
输入图 + 融合基底都用 final(④接缝融合最终图)。两路后端对比:
|
|
||||||
② swapHair 路(final+band 重绘→final 融合)
|
|
||||||
③ Flux-2 路(final+band 调 ComfyUI 保色重绘→final 融合)
|
|
||||||
结果在 steps.redraw_a/redraw_c 单独展示,不替换 final。
|
|
||||||
comfyui_prompt:Flux-2 路提示词,None 用默认「补充遮罩区域的头发,加一点美颜」。
|
|
||||||
inpainting_fill/mask_blur/mask_dilate_scale:透传 change_hair 服务端重绘参数(默认值
|
|
||||||
=服务端原始硬编码值,未传行为不变)。inpainting_fill=0 保留原图可治"染绿"。
|
|
||||||
rid: 调用方的 request id,用于日志关联。为 None 时自动生成。
|
|
||||||
"""
|
"""
|
||||||
mask_type = "pushed" # 固定:只支持 pushed 遮罩算法
|
mask_type = "pushed" # 固定:只支持 pushed 遮罩算法
|
||||||
# blend_method 由参数传入(默认 multiband,接口11 可覆盖)
|
logger.info("[%s] _grow_core 参数(固定 mask=pushed): erode_cm=%s hairline_push_cm=%s "
|
||||||
if rid is None:
|
"hairline_edge=%r mb_levels=%s seg=%s gen_backend=%s swap_mode=%s blend=%s "
|
||||||
rid = uuid4().hex[:8]
|
"color_match=%s cm_strength=%s mb_feather_px=%s transition_band_px=%s "
|
||||||
logger.info("[%s] ===== generate_hairline_grow 开始 =====", rid)
|
"inpainting_fill=%s mask_blur=%s mask_dilate_scale=%s",
|
||||||
logger.info("[%s] 参数(固定 mask=pushed): erode_cm=%s hairline_push_cm=%s hairline_edge=%r "
|
|
||||||
"mb_levels=%s seg=%s gen_backend=%s swap_mode=%s blend=%s color_match=%s "
|
|
||||||
"cm_strength=%s mb_feather_px=%s transition_band_px=%s redraw=%s "
|
|
||||||
"inpainting_fill=%s mask_blur=%s mask_dilate_scale=%s comfyui_prompt=%r",
|
|
||||||
rid, erode_cm, hairline_push_cm, hairline_edge, mb_levels,
|
rid, erode_cm, hairline_push_cm, hairline_edge, mb_levels,
|
||||||
seg_model, gen_backend, swap_mode, blend_method, color_match,
|
seg_model, gen_backend, swap_mode, blend_method, color_match,
|
||||||
color_match_strength, mb_feather_px, transition_band_px, redraw,
|
color_match_strength, mb_feather_px, transition_band_px,
|
||||||
inpainting_fill, mask_blur, mask_dilate_scale, comfyui_prompt)
|
inpainting_fill, mask_blur, mask_dilate_scale)
|
||||||
h, w = image_bgr.shape[:2]
|
h, w = image_bgr.shape[:2]
|
||||||
landmarks = detector.detect(image_bgr)
|
landmarks = detector.detect(image_bgr)
|
||||||
if landmarks is None:
|
if landmarks is None:
|
||||||
@@ -876,7 +874,7 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
|
|||||||
hard_paste = image_bgr.copy()
|
hard_paste = image_bgr.copy()
|
||||||
hard_paste[mask_bool] = swap_result[mask_bool]
|
hard_paste[mask_bool] = swap_result[mask_bool]
|
||||||
|
|
||||||
# 步骤4:接缝融合(默认 multiband)
|
# 步骤4:接缝融合(默认 multiband)→ ④ final
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
final, alpha = _composite(
|
final, alpha = _composite(
|
||||||
image_bgr, swap_result, mask_bool, blend_method, 0, edge_erode_px,
|
image_bgr, swap_result, mask_bool, blend_method, 0, edge_erode_px,
|
||||||
@@ -885,69 +883,54 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
|
|||||||
mb_feather_px=mb_feather_px, transition_band_px=transition_band_px)
|
mb_feather_px=mb_feather_px, transition_band_px=transition_band_px)
|
||||||
t_blend = time.time() - t0
|
t_blend = time.time() - t0
|
||||||
|
|
||||||
# 步骤5(可选):发际线带重绘分支 —— 开关 redraw=True 时执行,结果单独展示。
|
return {
|
||||||
# 重绘区域 = 外推发际线↔内推发际线之间的带(以原内轮廓为中心,向头发/脸各推 push_cm)。
|
"final": final, "swap_result": swap_result, "hard_paste": hard_paste,
|
||||||
# 输入图 + 融合基底都用 final(④接缝融合最终图)。
|
"alpha": alpha, "mask_bool": mask_bool, "mask_viz": mask_viz,
|
||||||
redraw_viz = {
|
"px_per_cm": px_per_cm, "t_mask": t_mask, "t_swap": t_swap, "t_blend": t_blend,
|
||||||
"redraw_band_overlay_base64": "",
|
"h": h, "w": w,
|
||||||
"redraw_a_base64": "",
|
|
||||||
"redraw_c_base64": "",
|
|
||||||
}
|
}
|
||||||
redraw_info = {"enabled": False}
|
|
||||||
if redraw:
|
|
||||||
t0 = time.time()
|
|
||||||
logger.info("[%s] 步骤5 发际线带重绘 开始", rid)
|
|
||||||
# ① 算重绘带:发际线(内轮廓)↔外推发际线 两条折线端点相连组成的带
|
|
||||||
inner_pts = mask_viz.get("_inner_pts")
|
|
||||||
outer_pts = mask_viz.get("_outer_pts")
|
|
||||||
push_px = int(round(max(0.0, hairline_push_cm) * px_per_cm))
|
|
||||||
try:
|
|
||||||
band_mask = _redraw_band_mask(inner_pts, outer_pts, h, w, rid=rid)
|
|
||||||
if band_mask.sum() < 30:
|
|
||||||
raise RuntimeError("重绘带像素过少,可能内轮廓/外推线缺失")
|
|
||||||
logger.info("[%s] 步骤5 重绘带 push_px=%d band_pixels=%d",
|
|
||||||
rid, push_px, int(band_mask.sum()))
|
|
||||||
redraw_viz["redraw_band_overlay_base64"] = _jpg_b64(
|
|
||||||
_overlay(final, band_mask, (255, 0, 255)))
|
|
||||||
redraw_info = {"enabled": True, "band_pixels": int(band_mask.sum()),
|
|
||||||
"push_px": push_px}
|
|
||||||
except Exception as ex: # noqa: BLE001
|
|
||||||
logger.exception("[%s] 步骤5 重绘带计算失败,整个重绘跳过", rid)
|
|
||||||
redraw_info = {"enabled": False, "error": f"band: {ex}"}
|
|
||||||
|
|
||||||
# ② swapHair 路:final + band 作 ext_mask 重绘 → final 作基底融合
|
|
||||||
if redraw_info.get("enabled"):
|
|
||||||
try:
|
|
||||||
redraw_a_raw = _call_swap(final, hairline_id, is_hr, band_mask, denoising_strength,
|
|
||||||
inpainting_fill=inpainting_fill, mask_blur=mask_blur,
|
|
||||||
mask_dilate_scale=mask_dilate_scale)
|
|
||||||
final_a, _ = _composite(
|
|
||||||
final, redraw_a_raw, band_mask, blend_method, 0, edge_erode_px,
|
|
||||||
color_match=color_match, mb_levels=mb_levels,
|
|
||||||
color_match_strength=color_match_strength,
|
|
||||||
mb_feather_px=mb_feather_px, transition_band_px=transition_band_px)
|
|
||||||
redraw_viz["redraw_a_base64"] = _jpg_b64(final_a)
|
|
||||||
logger.info("[%s] 步骤5 swapHair路完成", rid)
|
|
||||||
except Exception as ex: # noqa: BLE001
|
|
||||||
logger.warning("[%s] 步骤5 swapHair路失败,跳过: %s", rid, ex)
|
|
||||||
redraw_info["swap_error"] = str(ex)
|
|
||||||
|
|
||||||
# ③ Flux-2 路:final + band 调 ComfyUI(reference latent 保色,不染绿)→ final 融合
|
# ---------------------------------------------------------------------------
|
||||||
try:
|
# 主入口
|
||||||
prompt = comfyui_prompt if comfyui_prompt else "补充遮罩区域的头发,加一点美颜"
|
# ---------------------------------------------------------------------------
|
||||||
redraw_c_raw = _call_comfyui(final, band_mask, prompt=prompt)
|
|
||||||
final_c, _ = _composite(
|
|
||||||
final, redraw_c_raw, band_mask, blend_method, 0, edge_erode_px,
|
|
||||||
color_match=color_match, mb_levels=mb_levels,
|
|
||||||
color_match_strength=color_match_strength,
|
|
||||||
mb_feather_px=mb_feather_px, transition_band_px=transition_band_px)
|
|
||||||
redraw_viz["redraw_c_base64"] = _jpg_b64(final_c)
|
|
||||||
logger.info("[%s] 步骤5 Flux-2路完成", rid)
|
|
||||||
except Exception as ex: # noqa: BLE001
|
|
||||||
logger.warning("[%s] 步骤5 Flux-2路失败,跳过: %s", rid, ex)
|
|
||||||
redraw_info["c_error"] = str(ex)
|
|
||||||
|
|
||||||
logger.info("[%s] 步骤5 发际线带重绘完成 耗时=%dms", rid, int((time.time()-t0)*1000))
|
def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segformer",
|
||||||
|
erode_cm=0.6, swap_mode="ext_mask",
|
||||||
|
edge_erode_px=3,
|
||||||
|
denoising_strength=0.6, gen_backend="swaphair",
|
||||||
|
hairgrow_strength=0.75, mb_levels=5,
|
||||||
|
hairline_push_cm=1.0, hairline_edge="column",
|
||||||
|
blend_method="multiband", color_match=True,
|
||||||
|
color_match_strength=1.0, mb_feather_px=1,
|
||||||
|
transition_band_px=-1,
|
||||||
|
inpainting_fill=1, mask_blur=11, mask_dilate_scale=1.0,
|
||||||
|
rid=None):
|
||||||
|
"""接口11 完整管线(**不含重绘**,重绘见接口12 generate_hairline_redraw)。
|
||||||
|
返回可直接进 ok() 的 data dict。未检出人脸抛 NoFaceError。
|
||||||
|
|
||||||
|
遮罩算法固定为 pushed(发际线外推)。
|
||||||
|
融合算法 blend_method 默认 multiband(多频段金字塔),可选 seamless(泊松)/
|
||||||
|
two_stage(泊松→多频段两段式)/feather(羽化)/alpha_gradient(距离变换)。
|
||||||
|
color_match 默认开启 Reinhard 颜色迁移消除整体色差(对 multiband/feather 有效)。
|
||||||
|
inpainting_fill/mask_blur/mask_dilate_scale:透传 change_hair 服务端换发型重绘参数。
|
||||||
|
rid: 调用方的 request id,用于日志关联。为 None 时自动生成。
|
||||||
|
"""
|
||||||
|
if rid is None:
|
||||||
|
rid = uuid4().hex[:8]
|
||||||
|
logger.info("[%s] ===== generate_hairline_grow 开始 =====", rid)
|
||||||
|
core = _grow_core(
|
||||||
|
image_bgr, hairline_id, is_hr=is_hr, seg_model=seg_model, erode_cm=erode_cm,
|
||||||
|
swap_mode=swap_mode, edge_erode_px=edge_erode_px, denoising_strength=denoising_strength,
|
||||||
|
gen_backend=gen_backend, hairgrow_strength=hairgrow_strength, mb_levels=mb_levels,
|
||||||
|
hairline_push_cm=hairline_push_cm, hairline_edge=hairline_edge, blend_method=blend_method,
|
||||||
|
color_match=color_match, color_match_strength=color_match_strength,
|
||||||
|
mb_feather_px=mb_feather_px, transition_band_px=transition_band_px,
|
||||||
|
inpainting_fill=inpainting_fill, mask_blur=mask_blur,
|
||||||
|
mask_dilate_scale=mask_dilate_scale, rid=rid)
|
||||||
|
mask_viz = core["mask_viz"]
|
||||||
|
alpha = core["alpha"]
|
||||||
|
w, h = core["w"], core["h"]
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"hairline_id": hairline_id,
|
"hairline_id": hairline_id,
|
||||||
@@ -955,7 +938,7 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
|
|||||||
"hairgrow_strength": round(float(hairgrow_strength), 3),
|
"hairgrow_strength": round(float(hairgrow_strength), 3),
|
||||||
"is_hr": is_hr,
|
"is_hr": is_hr,
|
||||||
"seg_model": seg_model,
|
"seg_model": seg_model,
|
||||||
"mask_type": mask_type,
|
"mask_type": "pushed",
|
||||||
"erode_cm": round(float(erode_cm), 2),
|
"erode_cm": round(float(erode_cm), 2),
|
||||||
"swap_mode": swap_mode,
|
"swap_mode": swap_mode,
|
||||||
"blend_method": blend_method,
|
"blend_method": blend_method,
|
||||||
@@ -971,16 +954,16 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
|
|||||||
"inpainting_fill": int(inpainting_fill),
|
"inpainting_fill": int(inpainting_fill),
|
||||||
"mask_blur": int(mask_blur),
|
"mask_blur": int(mask_blur),
|
||||||
"mask_dilate_scale": round(float(mask_dilate_scale), 3),
|
"mask_dilate_scale": round(float(mask_dilate_scale), 3),
|
||||||
"px_per_cm": round(float(px_per_cm), 4),
|
"px_per_cm": round(float(core["px_per_cm"]), 4),
|
||||||
"erode_px": mask_viz["erode_px"],
|
"erode_px": mask_viz["erode_px"],
|
||||||
"hair_pixels": mask_viz["hair_pixels"],
|
"hair_pixels": mask_viz["hair_pixels"],
|
||||||
"closed_pixels": mask_viz["closed_pixels"],
|
"closed_pixels": mask_viz["closed_pixels"],
|
||||||
"mask_pixels": mask_viz["mask_pixels"],
|
"mask_pixels": mask_viz["mask_pixels"],
|
||||||
"image_size": {"width": w, "height": h},
|
"image_size": {"width": w, "height": h},
|
||||||
"timings_ms": {
|
"timings_ms": {
|
||||||
"mask": int(t_mask * 1000),
|
"mask": int(core["t_mask"] * 1000),
|
||||||
"swap": int(t_swap * 1000),
|
"swap": int(core["t_swap"] * 1000),
|
||||||
"blend": int(t_blend * 1000),
|
"blend": int(core["t_blend"] * 1000),
|
||||||
},
|
},
|
||||||
"steps": {
|
"steps": {
|
||||||
"input_base64": _jpg_b64(image_bgr),
|
"input_base64": _jpg_b64(image_bgr),
|
||||||
@@ -996,21 +979,152 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
|
|||||||
# 最终遮罩
|
# 最终遮罩
|
||||||
"mask_overlay_base64": mask_viz["mask_overlay_base64"],
|
"mask_overlay_base64": mask_viz["mask_overlay_base64"],
|
||||||
"mask_base64": mask_viz["mask_base64"],
|
"mask_base64": mask_viz["mask_base64"],
|
||||||
"swap_raw_base64": _jpg_b64(swap_result),
|
"swap_raw_base64": _jpg_b64(core["swap_result"]),
|
||||||
"hard_paste_base64": _jpg_b64(hard_paste),
|
"hard_paste_base64": _jpg_b64(core["hard_paste"]),
|
||||||
"alpha_base64": _gray_b64(alpha) if alpha is not None else mask_viz["mask_base64"],
|
"alpha_base64": _gray_b64(alpha) if alpha is not None else mask_viz["mask_base64"],
|
||||||
"final_base64": _jpg_b64(final),
|
"final_base64": _jpg_b64(core["final"]),
|
||||||
# 步骤5(可选):发际线带重绘分支 —— redraw=False 时为空串
|
|
||||||
"redraw_band_overlay_base64": redraw_viz["redraw_band_overlay_base64"],
|
|
||||||
"redraw_a_base64": redraw_viz["redraw_a_base64"],
|
|
||||||
"redraw_c_base64": redraw_viz["redraw_c_base64"],
|
|
||||||
},
|
},
|
||||||
"redraw": redraw_info,
|
"_rid": rid,
|
||||||
"_rid": rid, # 调用方的 request id,用于日志关联。为 None 时自动生成。
|
|
||||||
}
|
}
|
||||||
# 记录 steps 各图字段是否非空,供排查前端取图问题
|
|
||||||
steps_summary = {k: (len(v) if isinstance(v, str) and v else 0)
|
steps_summary = {k: (len(v) if isinstance(v, str) and v else 0)
|
||||||
for k, v in data["steps"].items() if k.endswith("_base64")}
|
for k, v in data["steps"].items() if k.endswith("_base64")}
|
||||||
logger.info("[%s] 返回 steps 字段长度: %s", rid, steps_summary)
|
logger.info("[%s] 返回 steps 字段长度: %s", rid, steps_summary)
|
||||||
logger.info("[%s] ===== generate_hairline_grow 完成 =====", rid)
|
logger.info("[%s] ===== generate_hairline_grow 完成 =====", rid)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="segformer",
|
||||||
|
erode_cm=0.6, swap_mode="ext_mask",
|
||||||
|
edge_erode_px=3,
|
||||||
|
denoising_strength=0.6, gen_backend="swaphair",
|
||||||
|
hairgrow_strength=0.75, mb_levels=5,
|
||||||
|
hairline_push_cm=1.0, hairline_edge="column",
|
||||||
|
blend_method="multiband", color_match=True,
|
||||||
|
color_match_strength=1.0, mb_feather_px=1,
|
||||||
|
transition_band_px=-1,
|
||||||
|
inpainting_fill=1, mask_blur=11, mask_dilate_scale=1.0,
|
||||||
|
comfyui_prompt=None, beauty_alpha=0.6,
|
||||||
|
band_lo_mult=0.5, band_hi_mult=1.5, rid=None):
|
||||||
|
"""接口12 发际线带重绘。内部先跑接口11 核心拿到 ④ final,再取 ⑤-① 发际线重绘带
|
||||||
|
(外推↔内推之间、经 baseline 截断只留上部)作遮罩,用 Flux-2(ComfyUI,hair_repaint.json)
|
||||||
|
重绘(band 经 alpha 送进 ComfyUI 决定加发位置,ComfyUI 输出为整帧重绘+美颜图)。
|
||||||
|
|
||||||
|
**同时产出两版结果供对比**:
|
||||||
|
- `redraw_full`:ComfyUI 整帧输出(全脸美颜 + 全脸重绘),与手动跑 ComfyUI 一致。
|
||||||
|
- `redraw_band`:加发只在发际线带、美颜保留全脸。band 内完全用 ComfyUI 重绘(加发),
|
||||||
|
band 外用 `final` 结构 + 按 `beauty_alpha` 融入 ComfyUI 的全脸美颜。
|
||||||
|
|
||||||
|
返回可直接进 ok() 的 data dict。未检出人脸抛 NoFaceError。
|
||||||
|
|
||||||
|
comfyui_prompt:Flux-2 提示词,None 用默认「补充遮罩区域的头发,加一点美颜」。
|
||||||
|
beauty_alpha:redraw_band 版 band 外的全脸美颜融入强度(0=完全保留 final 无美颜,
|
||||||
|
1=band 外也完全用 ComfyUI 输出≈redraw_full),默认 0.6。
|
||||||
|
band_lo_mult / band_hi_mult:重绘带外推倍率(相对 hairline_push_cm),带位于
|
||||||
|
lo×push ~ hi×push 之间(内轮廓=0×、原外推线=1.0×),默认 0.5 / 1.5。
|
||||||
|
其余参数含义与接口11 相同(用于内部生成 final 与重绘带)。
|
||||||
|
"""
|
||||||
|
if rid is None:
|
||||||
|
rid = uuid4().hex[:8]
|
||||||
|
logger.info("[%s] ===== generate_hairline_redraw 开始 =====", rid)
|
||||||
|
core = _grow_core(
|
||||||
|
image_bgr, hairline_id, is_hr=is_hr, seg_model=seg_model, erode_cm=erode_cm,
|
||||||
|
swap_mode=swap_mode, edge_erode_px=edge_erode_px, denoising_strength=denoising_strength,
|
||||||
|
gen_backend=gen_backend, hairgrow_strength=hairgrow_strength, mb_levels=mb_levels,
|
||||||
|
hairline_push_cm=hairline_push_cm, hairline_edge=hairline_edge, blend_method=blend_method,
|
||||||
|
color_match=color_match, color_match_strength=color_match_strength,
|
||||||
|
mb_feather_px=mb_feather_px, transition_band_px=transition_band_px,
|
||||||
|
inpainting_fill=inpainting_fill, mask_blur=mask_blur,
|
||||||
|
mask_dilate_scale=mask_dilate_scale, rid=rid)
|
||||||
|
final = core["final"]
|
||||||
|
mask_viz = core["mask_viz"]
|
||||||
|
w, h = core["w"], core["h"]
|
||||||
|
px_per_cm = core["px_per_cm"]
|
||||||
|
|
||||||
|
# ① 算重绘带(⑤-①):发际线(内轮廓)↔外推发际线成带,经 baseline 截断只留上部
|
||||||
|
t0 = time.time()
|
||||||
|
inner_pts = mask_viz.get("_inner_pts")
|
||||||
|
outer_pts = mask_viz.get("_outer_pts")
|
||||||
|
upper_mask = mask_viz.get("_upper_mask")
|
||||||
|
push_px = int(round(max(0.0, hairline_push_cm) * px_per_cm))
|
||||||
|
redraw_band_overlay_b64 = ""
|
||||||
|
redraw_full_b64 = "" # A:ComfyUI 整帧(全脸美颜+全脸重绘)
|
||||||
|
redraw_band_b64 = "" # B:加发只在发际线带、美颜保留全脸
|
||||||
|
redraw_info = {"enabled": False}
|
||||||
|
band_mask = None
|
||||||
|
try:
|
||||||
|
band_mask = _redraw_band_mask(inner_pts, outer_pts, h, w, rid=rid, upper=upper_mask,
|
||||||
|
lo_mult=band_lo_mult, hi_mult=band_hi_mult)
|
||||||
|
if band_mask.sum() < 30:
|
||||||
|
raise RuntimeError("重绘带像素过少,可能内轮廓/外推线缺失")
|
||||||
|
logger.info("[%s] 重绘带 push_px=%d lo_mult=%s hi_mult=%s band_pixels=%d",
|
||||||
|
rid, push_px, band_lo_mult, band_hi_mult, int(band_mask.sum()))
|
||||||
|
redraw_band_overlay_b64 = _jpg_b64(_overlay(final, band_mask, (255, 0, 255)))
|
||||||
|
redraw_info = {"enabled": True, "band_pixels": int(band_mask.sum()), "push_px": push_px,
|
||||||
|
"band_lo_mult": float(band_lo_mult), "band_hi_mult": float(band_hi_mult)}
|
||||||
|
except Exception as ex: # noqa: BLE001
|
||||||
|
logger.exception("[%s] 重绘带计算失败,整个重绘跳过", rid)
|
||||||
|
redraw_info = {"enabled": False, "error": f"band: {ex}"}
|
||||||
|
|
||||||
|
# ② Flux-2 路:final + band 调 ComfyUI(hair_repaint.json 整图重绘 + reference latent
|
||||||
|
# 保色 + ColorMatch + 美颜)。band 经 alpha 送进 ComfyUI 决定加发位置。
|
||||||
|
beauty_alpha = float(min(max(beauty_alpha, 0.0), 1.0))
|
||||||
|
if redraw_info.get("enabled"):
|
||||||
|
try:
|
||||||
|
prompt = comfyui_prompt if comfyui_prompt else "补充遮罩区域的头发,加一点美颜"
|
||||||
|
redraw_c_raw = _call_comfyui(final, band_mask, prompt=prompt)
|
||||||
|
# A:整帧输出(与手动跑 ComfyUI 一致)
|
||||||
|
redraw_full_b64 = _jpg_b64(redraw_c_raw)
|
||||||
|
# B:加发只在 band、美颜保留全脸。
|
||||||
|
# alpha = band 内 1(羽化边缘);band 外 = beauty_alpha。
|
||||||
|
# band 内完全用 ComfyUI(加发);band 外用 final 结构 + beauty_alpha 融入全脸美颜。
|
||||||
|
feather_px = max(8, int(round(push_px * 0.6)))
|
||||||
|
band_a = _feather_alpha(band_mask, "feather", feather_px, 0) # 0..1,band 外=0
|
||||||
|
a = band_a + (1.0 - band_a) * beauty_alpha
|
||||||
|
a3 = a[:, :, None]
|
||||||
|
band_mix = (final.astype(np.float32) * (1.0 - a3)
|
||||||
|
+ redraw_c_raw.astype(np.float32) * a3)
|
||||||
|
redraw_band_b64 = _jpg_b64(np.clip(band_mix, 0, 255).astype(np.uint8))
|
||||||
|
redraw_info["beauty_alpha"] = beauty_alpha
|
||||||
|
logger.info("[%s] Flux-2 重绘完成:redraw_full(整帧) + redraw_band(局部加发+全脸美颜 beauty_alpha=%.2f)",
|
||||||
|
rid, beauty_alpha)
|
||||||
|
except Exception as ex: # noqa: BLE001
|
||||||
|
logger.warning("[%s] Flux-2 路重绘失败,跳过: %s", rid, ex)
|
||||||
|
redraw_info["c_error"] = str(ex)
|
||||||
|
t_redraw = time.time() - t0
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"hairline_id": hairline_id,
|
||||||
|
"blend_method": blend_method,
|
||||||
|
"hairline_push_cm": round(float(hairline_push_cm), 2),
|
||||||
|
"comfyui_prompt": comfyui_prompt or "补充遮罩区域的头发,加一点美颜",
|
||||||
|
"beauty_alpha": beauty_alpha,
|
||||||
|
"px_per_cm": round(float(px_per_cm), 4),
|
||||||
|
"mask_pixels": mask_viz["mask_pixels"],
|
||||||
|
"image_size": {"width": w, "height": h},
|
||||||
|
"timings_ms": {
|
||||||
|
"mask": int(core["t_mask"] * 1000),
|
||||||
|
"swap": int(core["t_swap"] * 1000),
|
||||||
|
"blend": int(core["t_blend"] * 1000),
|
||||||
|
"redraw": int(t_redraw * 1000),
|
||||||
|
},
|
||||||
|
"steps": {
|
||||||
|
"input_base64": _jpg_b64(image_bgr),
|
||||||
|
# 接口11 的 ④ final —— 作为本接口的重绘输入基底
|
||||||
|
"final_base64": _jpg_b64(final),
|
||||||
|
# ⑤-① 发际线重绘带(紫,已按 baseline 截断只留上部)
|
||||||
|
"redraw_band_overlay_base64": redraw_band_overlay_b64,
|
||||||
|
# A:ComfyUI 整帧重绘+美颜(与手动跑 ComfyUI 一致)
|
||||||
|
"redraw_full_base64": redraw_full_b64,
|
||||||
|
# B:加发只在发际线带、美颜保留全脸
|
||||||
|
"redraw_band_base64": redraw_band_b64,
|
||||||
|
# 兼容旧字段:指向 A(整帧版)
|
||||||
|
"redraw_c_base64": redraw_full_b64,
|
||||||
|
},
|
||||||
|
"redraw": redraw_info,
|
||||||
|
"_rid": rid,
|
||||||
|
}
|
||||||
|
steps_summary = {k: (len(v) if isinstance(v, str) and v else 0)
|
||||||
|
for k, v in data["steps"].items() if k.endswith("_base64")}
|
||||||
|
logger.info("[%s] 返回 steps 字段长度: %s", rid, steps_summary)
|
||||||
|
logger.info("[%s] ===== generate_hairline_redraw 完成 =====", rid)
|
||||||
|
return data
|
||||||
|
|||||||
@@ -24,8 +24,9 @@ from face_analysis.calibration import estimate_scale_factor, normalized_to_pixel
|
|||||||
|
|
||||||
# 底部分割线关键点(图像上从左到右,眉骨弧线 → 中心 151 → 右侧对称)
|
# 底部分割线关键点(图像上从左到右,眉骨弧线 → 中心 151 → 右侧对称)
|
||||||
# 左端 104 → 中心 151 → 右端 333;首末点向图片左右边缘水平延长
|
# 左端 104 → 中心 151 → 右端 333;首末点向图片左右边缘水平延长
|
||||||
BASELINE_IDX = [104, 69, 108, 151, 337, 299, 333]
|
# BASELINE_IDX = [104, 69, 108, 151, 337, 299, 333]
|
||||||
# BASELINE_IDX = [123, 116, 143, 71, 68, 104, 69, 108, 151, 337, 299, 333, 298, 301, 372, 345, 352,]
|
# BASELINE_IDX = [34, 139, 71, 68, 104, 69, 108, 151, 337, 299, 333, 298, 301, 368, 264]
|
||||||
|
BASELINE_IDX = [71, 68, 104, 69, 108, 151, 337, 299, 333, 298, 301]
|
||||||
CENTER_IDX = 151 # 内缩方向的目标点(额头中心)
|
CENTER_IDX = 151 # 内缩方向的目标点(额头中心)
|
||||||
ERODE_CM = 1.2 # 外缘内缩距离(厘米,默认;可由入参覆盖)
|
ERODE_CM = 1.2 # 外缘内缩距离(厘米,默认;可由入参覆盖)
|
||||||
SEGFORMER_HAIR = 13 # jonathandinu/face-parsing 中 hair 类索引
|
SEGFORMER_HAIR = 13 # jonathandinu/face-parsing 中 hair 类索引
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
"""批量调用接口12(/api/v1/hairline/grow_v2)生成对比素材。
|
|
||||||
|
|
||||||
20 张女生照片 × 5 种发际线发型(仅非高清)= 100 张输出。
|
|
||||||
并发 2,失败的跳过并记录原因。结果图落盘到 static/report_hairline_v2/img/,
|
|
||||||
元数据落盘 static/report_hairline_v2/results.json,供生成报告用。
|
|
||||||
|
|
||||||
用法: python scripts/batch_grow_v2.py
|
|
||||||
"""
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
API = "http://127.0.0.1:8187/api/v1/hairline/grow_v2"
|
|
||||||
TOKEN = "dev-shared-secret-2026"
|
|
||||||
CONCURRENCY = 2
|
|
||||||
|
|
||||||
INPUT_DIR = "/home/xsl/hair/image/test"
|
|
||||||
OUT_DIR = "/home/xsl/hair/static/report_hairline_v2"
|
|
||||||
IMG_DIR = os.path.join(OUT_DIR, "img")
|
|
||||||
ORIG_DIR = os.path.join(OUT_DIR, "orig")
|
|
||||||
|
|
||||||
# 5 种发际线发型(= change_hair hair_id)
|
|
||||||
HAIRSTYLES = [
|
|
||||||
("chang_zhixian", "直线"),
|
|
||||||
("chang_tuoyuan", "椭圆"),
|
|
||||||
("chang_bolang", "波浪"),
|
|
||||||
("chang_xinxing", "心形"),
|
|
||||||
("chang_huaban", "花瓣"),
|
|
||||||
]
|
|
||||||
HR_OPTIONS = [(False, "nohr")] # 仅非高清
|
|
||||||
|
|
||||||
|
|
||||||
def list_inputs():
|
|
||||||
files = sorted(f for f in os.listdir(INPUT_DIR) if f.lower().endswith((".jpg", ".png")))
|
|
||||||
return files
|
|
||||||
|
|
||||||
|
|
||||||
def one_call(stem, face_file, hair_id, hair_cn, is_hr, hr_tag):
|
|
||||||
"""调用一次接口,落盘结果图。返回结果 dict。"""
|
|
||||||
src = os.path.join(INPUT_DIR, face_file)
|
|
||||||
out_name = f"{stem}__{hair_id}__{hr_tag}.jpg"
|
|
||||||
out_path = os.path.join(IMG_DIR, out_name)
|
|
||||||
# 断点续跑:已存在的图直接跳过,不重复调用
|
|
||||||
if os.path.exists(out_path) and os.path.getsize(out_path) > 1024:
|
|
||||||
return {
|
|
||||||
"stem": stem, "face_file": face_file, "hair_id": hair_id, "hair_cn": hair_cn,
|
|
||||||
"is_hr": is_hr, "hr_tag": hr_tag, "ok": True,
|
|
||||||
"out": f"img/{out_name}", "size": None,
|
|
||||||
"ms": 0, "error": None, "skipped": True,
|
|
||||||
}
|
|
||||||
t0 = time.time()
|
|
||||||
try:
|
|
||||||
with open(src, "rb") as fh:
|
|
||||||
files = {"image_file": (face_file, fh.read(), "image/jpeg")}
|
|
||||||
data = {"hairline_id": hair_id, "is_hr": str(is_hr).lower()}
|
|
||||||
with httpx.Client(timeout=180.0) as c:
|
|
||||||
resp = c.post(API, headers={"X-Internal-Token": TOKEN}, files=files, data=data)
|
|
||||||
j = resp.json()
|
|
||||||
if j.get("code") != 0 or not j.get("data"):
|
|
||||||
raise RuntimeError(f"code={j.get('code')} msg={j.get('message')}")
|
|
||||||
b64 = j["data"]["final_base64"].split(",", 1)[1]
|
|
||||||
raw = base64.b64decode(b64)
|
|
||||||
with open(out_path, "wb") as fh:
|
|
||||||
fh.write(raw)
|
|
||||||
return {
|
|
||||||
"stem": stem, "face_file": face_file, "hair_id": hair_id, "hair_cn": hair_cn,
|
|
||||||
"is_hr": is_hr, "hr_tag": hr_tag, "ok": True,
|
|
||||||
"out": f"img/{out_name}", "size": j["data"].get("image_size"),
|
|
||||||
"ms": int((time.time() - t0) * 1000), "error": None,
|
|
||||||
}
|
|
||||||
except Exception as ex: # noqa: BLE001
|
|
||||||
return {
|
|
||||||
"stem": stem, "face_file": face_file, "hair_id": hair_id, "hair_cn": hair_cn,
|
|
||||||
"is_hr": is_hr, "hr_tag": hr_tag, "ok": False,
|
|
||||||
"out": None, "size": None, "ms": int((time.time() - t0) * 1000),
|
|
||||||
"error": str(ex)[:200],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
os.makedirs(IMG_DIR, exist_ok=True)
|
|
||||||
os.makedirs(ORIG_DIR, exist_ok=True)
|
|
||||||
faces = list_inputs()
|
|
||||||
print(f"输入 {len(faces)} 张脸 × {len(HAIRSTYLES)} 发型 × {len(HR_OPTIONS)} = "
|
|
||||||
f"{len(faces)*len(HAIRSTYLES)*len(HR_OPTIONS)} 次调用,并发 {CONCURRENCY}")
|
|
||||||
|
|
||||||
# 1. 先把原图拷一份到 orig/(报告要用)
|
|
||||||
import shutil
|
|
||||||
for f in faces:
|
|
||||||
stem = os.path.splitext(f)[0]
|
|
||||||
dst = os.path.join(ORIG_DIR, f"{stem}.jpg")
|
|
||||||
if not os.path.exists(dst):
|
|
||||||
shutil.copy2(os.path.join(INPUT_DIR, f), dst)
|
|
||||||
|
|
||||||
# 2. 构造全部任务
|
|
||||||
tasks = []
|
|
||||||
for f in faces:
|
|
||||||
stem = os.path.splitext(f)[0]
|
|
||||||
for hair_id, hair_cn in HAIRSTYLES:
|
|
||||||
for is_hr, hr_tag in HR_OPTIONS:
|
|
||||||
tasks.append((stem, f, hair_id, hair_cn, is_hr, hr_tag))
|
|
||||||
|
|
||||||
results = []
|
|
||||||
done = 0
|
|
||||||
total = len(tasks)
|
|
||||||
t_start = time.time()
|
|
||||||
with ThreadPoolExecutor(max_workers=CONCURRENCY) as ex:
|
|
||||||
futs = {ex.submit(one_call, *t): t for t in tasks}
|
|
||||||
for fut in as_completed(futs):
|
|
||||||
r = fut.result()
|
|
||||||
results.append(r)
|
|
||||||
done += 1
|
|
||||||
status = "OK " if r["ok"] else "FAIL"
|
|
||||||
if r.get("skipped"):
|
|
||||||
print(f"[{done}/{total}] SKIP {r['stem']} {r['hair_cn']} {r['hr_tag']}")
|
|
||||||
elif r["ok"]:
|
|
||||||
print(f"[{done}/{total}] {status} {r['stem']} {r['hair_cn']} {r['hr_tag']} "
|
|
||||||
f"({r['ms']}ms)", flush=True)
|
|
||||||
else:
|
|
||||||
print(f"[{done}/{total}] {status} {r['stem']} {r['hair_cn']} {r['hr_tag']} "
|
|
||||||
f"-> {r['error']}")
|
|
||||||
|
|
||||||
elapsed = time.time() - t_start
|
|
||||||
ok = sum(1 for r in results if r["ok"])
|
|
||||||
fail = len(results) - ok
|
|
||||||
# 按稳定顺序排序,报告好看
|
|
||||||
order = {s: i for i, s in enumerate(HAIRSTYLES)}
|
|
||||||
hr_order = {True: 0, False: 1}
|
|
||||||
face_order = {os.path.splitext(f)[0]: i for i, f in enumerate(faces)}
|
|
||||||
results.sort(key=lambda r: (face_order.get(r["stem"], 0),
|
|
||||||
order.get((r["hair_id"], r["hair_cn"]), 0),
|
|
||||||
hr_order.get(r["is_hr"], 0)))
|
|
||||||
|
|
||||||
meta = {
|
|
||||||
"total": total, "ok": ok, "fail": fail,
|
|
||||||
"elapsed_sec": round(elapsed, 1), "concurrency": CONCURRENCY,
|
|
||||||
"hairstyles": [{"id": h, "cn": c} for h, c in HAIRSTYLES],
|
|
||||||
"hr_options": [{"is_hr": is_hr, "tag": tag} for is_hr, tag in HR_OPTIONS],
|
|
||||||
"faces": [os.path.splitext(f)[0] for f in faces],
|
|
||||||
"generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
||||||
}
|
|
||||||
out = {"meta": meta, "results": results}
|
|
||||||
with open(os.path.join(OUT_DIR, "results.json"), "w", encoding="utf-8") as fh:
|
|
||||||
json.dump(out, fh, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
print(f"\n完成:{ok}/{total} 成功,{fail} 失败,耗时 {elapsed:.1f}s")
|
|
||||||
print(f"结果图 -> {IMG_DIR}")
|
|
||||||
print(f"元数据 -> {OUT_DIR}/results.json")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
"""根据 results.json 生成发际线生发对比报告 HTML。
|
|
||||||
|
|
||||||
报告布局(原图 vs 结果对比):
|
|
||||||
- 顶部:总览统计(成功/失败数、耗时、参数)
|
|
||||||
- 按脸分组,每张脸一个区块:
|
|
||||||
- 左:原图
|
|
||||||
- 右:5 发型 × {高清, 非高清} 网格(共 10 张),失败的格子标注原因
|
|
||||||
- 失败用例汇总表
|
|
||||||
|
|
||||||
输出:static/report_hairline_v2/index.html
|
|
||||||
"""
|
|
||||||
import html
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
OUT_DIR = "/home/xsl/hair/static/report_hairline_v2"
|
|
||||||
RESULTS = os.path.join(OUT_DIR, "results.json")
|
|
||||||
TARGET = os.path.join(OUT_DIR, "index.html")
|
|
||||||
|
|
||||||
|
|
||||||
def url(path):
|
|
||||||
"""把相对路径里的中文做 URL 编码,分隔符 '/' 保留。
|
|
||||||
Starlette StaticFiles 对未编码中文路径返回 400,编码后所有浏览器稳定可加载。
|
|
||||||
"""
|
|
||||||
return "/".join(quote(seg) for seg in path.split("/"))
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
with open(RESULTS, encoding="utf-8") as fh:
|
|
||||||
data = json.load(fh)
|
|
||||||
meta = data["meta"]
|
|
||||||
results = data["results"]
|
|
||||||
|
|
||||||
hairstyles = meta["hairstyles"] # [{id, cn}]
|
|
||||||
faces = meta["faces"] # [stem, ...]
|
|
||||||
hr_opts = meta["hr_options"] # [{is_hr, tag}]
|
|
||||||
|
|
||||||
# 索引:(stem, hair_id, hr_tag) -> result
|
|
||||||
idx = {}
|
|
||||||
for r in results:
|
|
||||||
idx[(r["stem"], r["hair_id"], r["hr_tag"])] = r
|
|
||||||
|
|
||||||
# 统计
|
|
||||||
ok = sum(1 for r in results if r["ok"])
|
|
||||||
fail = len(results) - ok
|
|
||||||
|
|
||||||
parts = []
|
|
||||||
parts.append(f"""<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>发际线生发对比报告 · 接口12 grow_v2</title>
|
|
||||||
<style>
|
|
||||||
:root {{
|
|
||||||
--bg:#0f1115; --card:#1a1d24; --border:#2a2f3a; --txt:#e6e6e6;
|
|
||||||
--muted:#8a93a3; --accent:#6ea8fe; --ok:#4ade80; --fail:#f87171;
|
|
||||||
}}
|
|
||||||
* {{ box-sizing:border-box; }}
|
|
||||||
body {{ margin:0; background:var(--bg); color:var(--txt);
|
|
||||||
font-family:-apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
|
|
||||||
line-height:1.5; }}
|
|
||||||
header {{ padding:24px 32px; border-bottom:1px solid var(--border); }}
|
|
||||||
header h1 {{ margin:0 0 8px; font-size:22px; }}
|
|
||||||
header .sub {{ color:var(--muted); font-size:14px; }}
|
|
||||||
.stats {{ display:flex; gap:16px; flex-wrap:wrap; margin-top:16px; }}
|
|
||||||
.stat {{ background:var(--card); border:1px solid var(--border); border-radius:8px;
|
|
||||||
padding:12px 16px; min-width:120px; }}
|
|
||||||
.stat .n {{ font-size:24px; font-weight:600; }}
|
|
||||||
.stat .l {{ font-size:12px; color:var(--muted); }}
|
|
||||||
.stat.ok .n {{ color:var(--ok); }}
|
|
||||||
.stat.fail .n {{ color:var(--fail); }}
|
|
||||||
main {{ padding:24px 32px; }}
|
|
||||||
.face-block {{ background:var(--card); border:1px solid var(--border);
|
|
||||||
border-radius:12px; padding:20px; margin-bottom:24px; }}
|
|
||||||
.face-head {{ display:flex; align-items:center; gap:12px; margin-bottom:16px; }}
|
|
||||||
.face-head h2 {{ margin:0; font-size:18px; }}
|
|
||||||
.face-head .orig-thumb {{ width:64px; height:64px; object-fit:cover;
|
|
||||||
border-radius:8px; border:1px solid var(--border); }}
|
|
||||||
.grid {{ display:grid; grid-template-columns:repeat({len(hairstyles)+1}, 1fr);
|
|
||||||
gap:10px; align-items:start; }}
|
|
||||||
.col-hdr {{ font-size:12px; color:var(--muted); text-align:center; padding:6px 4px;
|
|
||||||
border-bottom:1px solid var(--border); }}
|
|
||||||
.col-hdr.orig {{ color:var(--accent); }}
|
|
||||||
.cell {{ position:relative; }}
|
|
||||||
.cell img {{ width:100%; border-radius:6px; display:block;
|
|
||||||
border:1px solid var(--border); }}
|
|
||||||
.cell .cap {{ font-size:11px; color:var(--muted); margin-top:4px; text-align:center; }}
|
|
||||||
.cell.fail .failbox {{ aspect-ratio:3/4; background:#2a1518; border:1px solid #5c2a30;
|
|
||||||
border-radius:6px; display:flex; align-items:center;
|
|
||||||
justify-content:center; padding:8px; text-align:center;
|
|
||||||
font-size:11px; color:var(--fail); }}
|
|
||||||
.tag {{ display:inline-block; font-size:11px; padding:1px 6px; border-radius:4px;
|
|
||||||
background:#243044; color:var(--accent); margin-left:6px; }}
|
|
||||||
.tag.no {{ background:#3a3526; color:#e0c97a; }}
|
|
||||||
.legend {{ font-size:13px; color:var(--muted); margin-bottom:16px; }}
|
|
||||||
.fail-table {{ width:100%; border-collapse:collapse; font-size:13px; margin-top:8px; }}
|
|
||||||
.fail-table th, .fail-table td {{ border:1px solid var(--border); padding:6px 10px; text-align:left; }}
|
|
||||||
.fail-table th {{ background:#1f232c; color:var(--muted); }}
|
|
||||||
.anchor {{ display:block; height:0; overflow:hidden; }}
|
|
||||||
footer {{ padding:24px 32px; color:var(--muted); font-size:12px; border-top:1px solid var(--border); }}
|
|
||||||
/* 点击放大 */
|
|
||||||
img.zoomable {{ cursor:zoom-in; transition:opacity .12s; }}
|
|
||||||
img.zoomable:hover {{ opacity:.85; }}
|
|
||||||
#lightbox {{ position:fixed; inset:0; background:rgba(0,0,0,.92); display:none;
|
|
||||||
align-items:center; justify-content:center; z-index:9999; padding:24px;
|
|
||||||
cursor:zoom-out; }}
|
|
||||||
#lightbox.open {{ display:flex; }}
|
|
||||||
#lightbox img {{ max-width:100%; max-height:100%; object-fit:contain;
|
|
||||||
border-radius:8px; box-shadow:0 8px 40px rgba(0,0,0,.6); }}
|
|
||||||
#lightbox .lb-cap {{ position:absolute; bottom:16px; left:0; right:0; text-align:center;
|
|
||||||
color:var(--muted); font-size:13px; }}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<h1>发际线生发对比报告 <span class="tag">接口12 · grow_v2</span></h1>
|
|
||||||
<div class="sub">固定:pushed 遮罩 + multiband 融合 · mb_levels=5 · erode_cm=0.6 · 非高清</div>
|
|
||||||
<div class="sub">生成时间:{html.escape(meta['generated_at'])} · 并发 {meta['concurrency']} · 耗时 {meta['elapsed_sec']}s</div>
|
|
||||||
<div class="stats">
|
|
||||||
<div class="stat"><div class="n">{meta['total']}</div><div class="l">总调用</div></div>
|
|
||||||
<div class="stat ok"><div class="n">{ok}</div><div class="l">成功</div></div>
|
|
||||||
<div class="stat fail"><div class="n">{fail}</div><div class="l">失败</div></div>
|
|
||||||
<div class="stat"><div class="n">{len(faces)}</div><div class="l">人脸数</div></div>
|
|
||||||
<div class="stat"><div class="n">{len(hairstyles)}</div><div class="l">发型数</div></div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main>
|
|
||||||
""")
|
|
||||||
|
|
||||||
# 图例
|
|
||||||
parts.append('<div class="legend">每张脸:第一列为原图,其余 5 列为发际线生发结果(非高清)。点击任意图片可放大。</div>')
|
|
||||||
|
|
||||||
# 每张脸一个区块:原图 + 5 发型横向并排
|
|
||||||
for stem in faces:
|
|
||||||
orig_rel = f"orig/{stem}.jpg"
|
|
||||||
parts.append('<div class="face-block">')
|
|
||||||
parts.append(f' <div class="face-head"><h2>{html.escape(stem)}</h2></div>')
|
|
||||||
# 网格:第一列原图,其余 5 列发型结果
|
|
||||||
parts.append('<div class="grid">')
|
|
||||||
# 表头
|
|
||||||
parts.append('<div class="col-hdr orig">原图</div>')
|
|
||||||
for h in hairstyles:
|
|
||||||
parts.append(f'<div class="col-hdr">{html.escape(h["cn"])}<br><span style="opacity:.6">{html.escape(h["id"])}</span></div>')
|
|
||||||
# 第一列:原图大图(可点击放大)
|
|
||||||
parts.append(
|
|
||||||
f'<div class="cell"><img class="zoomable" src="{url(orig_rel)}" '
|
|
||||||
f'data-full="{url(orig_rel)}" loading="lazy" alt="原图 {html.escape(stem)}">'
|
|
||||||
f'<div class="cap">原图</div></div>')
|
|
||||||
# 5 发型结果(hr_opts 现在只有 nohr 一档,直接取)
|
|
||||||
hr_tag = hr_opts[0]["tag"] if hr_opts else "nohr"
|
|
||||||
for h in hairstyles:
|
|
||||||
r = idx.get((stem, h["id"], hr_tag))
|
|
||||||
if r and r["ok"]:
|
|
||||||
ms = r["ms"]
|
|
||||||
parts.append(
|
|
||||||
f'<div class="cell"><img class="zoomable" src="{url(r["out"])}" '
|
|
||||||
f'data-full="{url(r["out"])}" loading="lazy" '
|
|
||||||
f'alt="{html.escape(stem)} {html.escape(h["cn"])}">'
|
|
||||||
f'<div class="cap">{ms}ms</div></div>')
|
|
||||||
else:
|
|
||||||
err = (r or {}).get("error", "未执行")
|
|
||||||
parts.append(
|
|
||||||
f'<div class="cell fail"><div class="failbox">{html.escape(err)}</div></div>')
|
|
||||||
parts.append('</div>') # grid
|
|
||||||
parts.append('</div>') # face-block
|
|
||||||
|
|
||||||
# 失败汇总
|
|
||||||
fails = [r for r in results if not r["ok"]]
|
|
||||||
if fails:
|
|
||||||
parts.append('<div class="face-block">')
|
|
||||||
parts.append(f'<h2>失败用例({len(fails)})</h2>')
|
|
||||||
parts.append('<table class="fail-table"><thead><tr>'
|
|
||||||
'<th>人脸</th><th>发型</th><th>耗时(ms)</th><th>原因</th>'
|
|
||||||
'</tr></thead><tbody>')
|
|
||||||
for r in fails:
|
|
||||||
parts.append(
|
|
||||||
f'<tr><td>{html.escape(r["stem"])}</td>'
|
|
||||||
f'<td>{html.escape(r["hair_cn"])}</td>'
|
|
||||||
f'<td>{r["ms"]}</td>'
|
|
||||||
f'<td>{html.escape(r["error"] or "")}</td></tr>')
|
|
||||||
parts.append('</tbody></table></div>')
|
|
||||||
|
|
||||||
parts.append(f"""
|
|
||||||
<footer>
|
|
||||||
接口:<code>POST /api/v1/hairline/grow_v2</code> · 固定 pushed 遮罩 + multiband 融合 ·
|
|
||||||
数据源 results.json · 点击任意图片可放大查看
|
|
||||||
</footer>
|
|
||||||
</main>
|
|
||||||
<div id="lightbox"><img><div class="lb-cap"></div></div>
|
|
||||||
<script>
|
|
||||||
(function(){{
|
|
||||||
var lb=document.getElementById('lightbox'),lbImg=lb.querySelector('img'),
|
|
||||||
lbCap=lb.querySelector('.lb-cap');
|
|
||||||
function open(src,cap){{
|
|
||||||
lbImg.src=src; lbCap.textContent=cap||''; lb.classList.add('open');
|
|
||||||
}}
|
|
||||||
function close(){{ lb.classList.remove('open'); lbImg.src=''; }}
|
|
||||||
document.addEventListener('click',function(e){{
|
|
||||||
var t=e.target.closest('img.zoomable');
|
|
||||||
if(t){{ open(t.dataset.full||t.src, t.alt||''); }}
|
|
||||||
else if(e.target===lb||e.target===lbImg){{ close(); }}
|
|
||||||
}});
|
|
||||||
document.addEventListener('keydown',function(e){{
|
|
||||||
if(e.key==='Escape') close();
|
|
||||||
}});
|
|
||||||
}})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>""")
|
|
||||||
|
|
||||||
with open(TARGET, "w", encoding="utf-8") as fh:
|
|
||||||
fh.write("".join(parts))
|
|
||||||
print(f"报告已生成:{TARGET}")
|
|
||||||
print(f"成功 {ok}/{meta['total']},失败 {fail}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
<h1>接口11 调试页 <span style="font-size:13px;color:#888">(带前后端日志)</span></h1>
|
<h1>接口11 调试页 <span style="font-size:13px;color:#888">(带前后端日志)</span></h1>
|
||||||
<div class="subtitle">
|
<div class="subtitle">
|
||||||
独立调试页,每一步都记录日志。提交后展开"前端日志"和"后端日志",点按钮可下载。<br>
|
独立调试页,每一步都记录日志。提交后展开"前端日志"和"后端日志",点按钮可下载。<br>
|
||||||
重点排查:遮罩是否走 pushed、①-f/①-g 是否有图、最终遮罩是否正确。遮罩固定 pushed,融合默认 multiband(可调,含两段式)。
|
重点排查:遮罩是否走 pushed、①-f/①-g 是否有图、最终遮罩是否正确。遮罩固定 pushed;默认 blend=two_stage、redraw 开、发际线波浪。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -71,48 +71,26 @@
|
|||||||
<div class="pf">
|
<div class="pf">
|
||||||
<label>发际线类型 ID</label>
|
<label>发际线类型 ID</label>
|
||||||
<select id="hairlineId">
|
<select id="hairlineId">
|
||||||
|
<option value="chang_bolang" selected>chang_bolang(波浪)</option>
|
||||||
<option value="chang_zhixian">chang_zhixian(直线)</option>
|
<option value="chang_zhixian">chang_zhixian(直线)</option>
|
||||||
<option value="chang_tuoyuan">chang_tuoyuan(椭圆)</option>
|
<option value="chang_tuoyuan">chang_tuoyuan(椭圆)</option>
|
||||||
<option value="chang_bolang">chang_bolang(波浪)</option>
|
|
||||||
<option value="chang_xinxing">chang_xinxing(心形)</option>
|
<option value="chang_xinxing">chang_xinxing(心形)</option>
|
||||||
<option value="chang_huaban">chang_huaban(花瓣)</option>
|
<option value="chang_huaban">chang_huaban(花瓣)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="pf">
|
|
||||||
<label>seg_model <span class="desc">分割模型</span></label>
|
|
||||||
<select id="segModel">
|
|
||||||
<option value="segformer">segformer</option>
|
|
||||||
<option value="bisenet">bisenet</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="pf">
|
<div class="pf">
|
||||||
<label>hairline_push_cm <span class="desc">发际线外推(cm)</span></label>
|
<label>hairline_push_cm <span class="desc">发际线外推(cm)</span></label>
|
||||||
<div class="row"><input type="number" id="hairlinePushCm" min="0" max="3" step="0.1" value="1.0"></div>
|
<div class="row"><input type="number" id="hairlinePushCm" min="0" max="3" step="0.1" value="0.8"></div>
|
||||||
</div>
|
|
||||||
<div class="pf">
|
|
||||||
<label>hairline_edge <span class="desc">发际线提取</span></label>
|
|
||||||
<select id="hairlineEdge">
|
|
||||||
<option value="column">column(逐列最低点)</option>
|
|
||||||
<option value="contour">contour(形态学轮廓)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="pf">
|
|
||||||
<label>is_hr <span class="desc">高清模式(换发型输出1152×1536)</span></label>
|
|
||||||
<div class="row"><input type="checkbox" id="isHr" style="width:18px;height:18px"><span class="desc">仅 swaphair 后端;关=576×768</span></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="pf">
|
<div class="pf">
|
||||||
<label>mb_levels <span class="desc">多频段金字塔层数</span></label>
|
<label>mb_levels <span class="desc">多频段金字塔层数</span></label>
|
||||||
<div class="row"><input type="number" id="mbLevels" min="2" max="6" step="1" value="5"></div>
|
<div class="row"><input type="number" id="mbLevels" min="2" max="6" step="1" value="5"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pf">
|
|
||||||
<label>edge_erode_px <span class="desc">贴图前遮罩内缩(px)</span></label>
|
|
||||||
<div class="row"><input type="number" id="edgeErodePx" min="0" max="40" step="1" value="3"></div>
|
|
||||||
</div>
|
|
||||||
<div class="pf">
|
<div class="pf">
|
||||||
<label>blend_method <span class="desc">接缝融合方法</span></label>
|
<label>blend_method <span class="desc">接缝融合方法</span></label>
|
||||||
<select id="blendMethod">
|
<select id="blendMethod">
|
||||||
<option value="multiband">multiband(多频段金字塔,默认)</option>
|
<option value="two_stage" selected>two_stage(泊松→多频段,大色差)</option>
|
||||||
<option value="two_stage">two_stage(泊松→多频段,大色差)</option>
|
<option value="multiband">multiband(多频段金字塔)</option>
|
||||||
<option value="seamless">seamless(泊松无缝克隆)</option>
|
<option value="seamless">seamless(泊松无缝克隆)</option>
|
||||||
<option value="feather">feather(高斯羽化)</option>
|
<option value="feather">feather(高斯羽化)</option>
|
||||||
<option value="alpha_gradient">alpha_gradient(距离变换)</option>
|
<option value="alpha_gradient">alpha_gradient(距离变换)</option>
|
||||||
@@ -120,26 +98,30 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="pf">
|
<div class="pf">
|
||||||
<label>color_match <span class="desc">融合前颜色迁移(消除色差)</span></label>
|
<label>color_match <span class="desc">融合前颜色迁移(消除色差)</span></label>
|
||||||
<div class="row"><input type="checkbox" id="colorMatch" checked style="width:18px;height:18px"><span class="desc" id="cmNote">multiband/feather 生效;seamless/two_stage 自带调色</span></div>
|
<div class="row"><input type="checkbox" id="colorMatch" style="width:18px;height:18px"><span class="desc" id="cmNote">multiband/feather 生效;seamless/two_stage 自带调色</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pf">
|
<div class="pf">
|
||||||
<label>color_match_strength <span class="desc">颜色迁移强度(0~1)</span></label>
|
<label>color_match_strength <span class="desc">颜色迁移强度(0~1)</span></label>
|
||||||
<div class="row"><input type="number" id="cmStrength" min="0" max="1" step="0.05" value="1.0"></div>
|
<div class="row"><input type="number" id="cmStrength" min="0" max="1" step="0.05" value="0.4"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 多频带融合参数 -->
|
||||||
|
<div class="pf">
|
||||||
|
<label>edge_erode_px <span class="desc">融合·贴图前遮罩内缩(px)</span></label>
|
||||||
|
<div class="row"><input type="number" id="edgeErodePx" min="0" max="40" step="1" value="3"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pf">
|
<div class="pf">
|
||||||
<label>mb_feather_px <span class="desc">最细层掩码羽化(px)</span></label>
|
<label>mb_feather_px <span class="desc">融合·最细层掩码羽化(px)</span></label>
|
||||||
<div class="row"><input type="number" id="mbFeatherPx" min="0" max="5" step="1" value="1"></div>
|
<div class="row"><input type="number" id="mbFeatherPx" min="0" max="5" step="1" value="1"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pf">
|
<div class="pf">
|
||||||
<label>transition_band_px <span class="desc">过渡带边距(-1=自动)</span></label>
|
<label>transition_band_px <span class="desc">融合·过渡带边距(-1=自动)</span></label>
|
||||||
<div class="row"><input type="number" id="transitionBandPx" min="-1" max="128" step="1" value="-1"></div>
|
<div class="row"><input type="number" id="transitionBandPx" min="-1" max="128" step="1" value="-1"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- change_hair 换发型参数 -->
|
||||||
<div class="pf">
|
<div class="pf">
|
||||||
<label>redraw <span class="desc">发际线带重绘开关</span></label>
|
<label>inpainting_fill <span class="desc">换发型·重绘填充(治染绿)</span></label>
|
||||||
<div class="row"><input type="checkbox" id="redraw" style="width:18px;height:18px"><span class="desc">开:发际线带(外推↔内推)重绘,final输入,swapHair/Flux-2两路对比</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="pf">
|
|
||||||
<label>inpainting_fill <span class="desc">重绘填充(治染绿)</span></label>
|
|
||||||
<select id="inpaintingFill">
|
<select id="inpaintingFill">
|
||||||
<option value="1">1=填充噪声(默认/原始)</option>
|
<option value="1">1=填充噪声(默认/原始)</option>
|
||||||
<option value="0">0=保留原图(治染绿)</option>
|
<option value="0">0=保留原图(治染绿)</option>
|
||||||
@@ -148,19 +130,15 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="pf">
|
<div class="pf">
|
||||||
<label>mask_blur <span class="desc">重绘遮罩边缘模糊(px)</span></label>
|
<label>mask_blur <span class="desc">换发型·重绘遮罩边缘模糊(px)</span></label>
|
||||||
<div class="row"><input type="number" id="maskBlur" min="0" max="64" step="1" value="11"></div>
|
<div class="row"><input type="number" id="maskBlur" min="0" max="64" step="1" value="11"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pf">
|
<div class="pf">
|
||||||
<label>mask_dilate_scale <span class="desc">重绘遮罩膨胀缩放</span></label>
|
<label>mask_dilate_scale <span class="desc">换发型·重绘遮罩膨胀缩放</span></label>
|
||||||
<div class="row"><input type="number" id="maskDilateScale" min="0" max="4" step="0.1" value="1.0"></div>
|
<div class="row"><input type="number" id="maskDilateScale" min="0" max="4" step="0.1" value="1.0"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pf" style="margin-top:14px">
|
<div style="font-size:12px;color:#888;margin-top:8px">遮罩算法固定 pushed。默认 blend=two_stage;多频带融合参数与 change_hair 换发型参数已在上方可调,其余隐藏参数按原默认值随请求提交。<b>本接口不含重绘</b>,发际线带重绘见 <a href="/static/test_interface12.html">接口12 测试页</a>。</div>
|
||||||
<label>comfyui_prompt <span class="desc">redraw C路(Flux-2)提示词,留空=默认「补充遮罩区域的头发,加一点美颜」</span></label>
|
|
||||||
<textarea id="comfyuiPrompt" rows="2" style="width:100%;padding:8px;border:1px solid #ddd;border-radius:8px;font-size:13px;font-family:inherit;resize:vertical" placeholder="留空用默认提示词"></textarea>
|
|
||||||
</div>
|
|
||||||
<div style="font-size:12px;color:#888;margin-top:8px">遮罩算法固定 pushed。融合默认 multiband,可在上方 blend_method 切换对比。勾选 redraw 开启发际线带重绘(swapHair/Flux-2 两路对比)。</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="status hidden" id="statusBar"></div>
|
<div class="status hidden" id="statusBar"></div>
|
||||||
@@ -227,9 +205,6 @@ const STEPS = [
|
|||||||
{ key: 'hard_paste', title: '③ 严格贴回', sub: '遮罩内=生成,外=原图' },
|
{ key: 'hard_paste', title: '③ 严格贴回', sub: '遮罩内=生成,外=原图' },
|
||||||
{ key: 'alpha', title: '④ 融合权重', sub: '白=用生成图' },
|
{ key: 'alpha', title: '④ 融合权重', sub: '白=用生成图' },
|
||||||
{ key: 'final', title: '④ 接缝融合(最终)', sub: '最终输出' },
|
{ key: 'final', title: '④ 接缝融合(最终)', sub: '最终输出' },
|
||||||
{ key: 'redraw_band_overlay', title: '⑤-① 发际线重绘带', sub: '紫=外推↔内推之间的带(以内轮廓为中心,两侧各push_cm,仅redraw)' },
|
|
||||||
{ key: 'redraw_a', title: '⑤-② 带重绘 swapHair路', sub: 'final+带重绘(swapHair)→final融合(仅redraw)' },
|
|
||||||
{ key: 'redraw_c', title: '⑤-③ 带重绘 Flux-2路', sub: 'final+带重绘(Flux-2,保色不染绿)→final融合(仅redraw)' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function $(id) { return document.getElementById(id); }
|
function $(id) { return document.getElementById(id); }
|
||||||
@@ -255,23 +230,13 @@ function renderResult(d) {
|
|||||||
$('finalOut').src = pick(s, 'final') || '';
|
$('finalOut').src = pick(s, 'final') || '';
|
||||||
$('finalOut').onclick = function(){ zoom(this.src); };
|
$('finalOut').onclick = function(){ zoom(this.src); };
|
||||||
const grid = $('stepsGrid'); grid.innerHTML = '';
|
const grid = $('stepsGrid'); grid.innerHTML = '';
|
||||||
// 固定 pushed:只展示 pushed 相关步骤(不展示 top_fill/closed)
|
// 固定 pushed:只展示 pushed 相关步骤(不展示 top_fill/closed;重绘已移到接口12)
|
||||||
// redraw 步骤(redraw_band/redraw_a/redraw_c)只在开启 redraw 且有图时才渲染
|
|
||||||
const showKeys = new Set(['baseline_overlay','upper_overlay','hair_seg_overlay',
|
const showKeys = new Set(['baseline_overlay','upper_overlay','hair_seg_overlay',
|
||||||
'hairline_overlay','pushed_overlay','mask_overlay','mask','swap_raw','hard_paste','alpha','final',
|
'hairline_overlay','pushed_overlay','mask_overlay','mask','swap_raw','hard_paste','alpha','final']);
|
||||||
'redraw_band_overlay','redraw_a','redraw_c']);
|
|
||||||
const redrawKeys = new Set(['redraw_band_overlay','redraw_a','redraw_c']);
|
|
||||||
$('stepsInfo').textContent = '(mask=pushed, blend=' + (d.blend_method || '?') + ')';
|
$('stepsInfo').textContent = '(mask=pushed, blend=' + (d.blend_method || '?') + ')';
|
||||||
if (d.redraw && d.redraw.enabled) {
|
|
||||||
flog('发际线带重绘已开启 band_pixels=' + d.redraw.band_pixels, 'info');
|
|
||||||
} else if (d.redraw && d.redraw.error) {
|
|
||||||
flog('发际线带重绘失败: ' + d.redraw.error, 'warn');
|
|
||||||
}
|
|
||||||
STEPS.filter(st => showKeys.has(st.key)).forEach(st => {
|
STEPS.filter(st => showKeys.has(st.key)).forEach(st => {
|
||||||
const src = pick(s, st.key);
|
const src = pick(s, st.key);
|
||||||
const hasImg = src && src.length > 50;
|
const hasImg = src && src.length > 50;
|
||||||
// redraw 步骤无图(未开启或失败)时跳过,不占位
|
|
||||||
if (redrawKeys.has(st.key) && !hasImg) return;
|
|
||||||
flog(' 渲染 ' + st.key + ': ' + (hasImg ? '有图(' + src.length + '字符)' : '无图'), hasImg ? 'info' : 'warn');
|
flog(' 渲染 ' + st.key + ': ' + (hasImg ? '有图(' + src.length + '字符)' : '无图'), hasImg ? 'info' : 'warn');
|
||||||
grid.appendChild(stepCard(st.title, st.sub, src));
|
grid.appendChild(stepCard(st.title, st.sub, src));
|
||||||
});
|
});
|
||||||
@@ -281,24 +246,27 @@ function renderResult(d) {
|
|||||||
async function submitTest() {
|
async function submitTest() {
|
||||||
const file = $('imageFile').files[0];
|
const file = $('imageFile').files[0];
|
||||||
if (!file) { setStatus('请先选择图片', 'error'); return; }
|
if (!file) { setStatus('请先选择图片', 'error'); return; }
|
||||||
|
// 页面隐藏但仍提交的固定默认值
|
||||||
|
const HIDDEN = {
|
||||||
|
seg_model: 'segformer',
|
||||||
|
hairline_edge: 'column',
|
||||||
|
is_hr: 'false',
|
||||||
|
};
|
||||||
|
|
||||||
flog('===== 提交测试 =====', 'info');
|
flog('===== 提交测试 =====', 'info');
|
||||||
// 记录前端实际读取的每个参数值(关键诊断)
|
|
||||||
flog('前端读取 hairline_push_cm=' + ($('hairlinePushCm').value || '(默认)'), 'info');
|
|
||||||
flog('前端读取 hairline_edge=' + ($('hairlineEdge').value || '(默认)'), 'info');
|
|
||||||
flog('前端读取 hairline_id=' + $('hairlineId').value, 'info');
|
flog('前端读取 hairline_id=' + $('hairlineId').value, 'info');
|
||||||
flog('前端读取 seg_model=' + $('segModel').value, 'info');
|
flog('前端读取 hairline_push_cm=' + ($('hairlinePushCm').value || '(默认)'), 'info');
|
||||||
flog('前端读取 is_hr=' + $('isHr').checked, 'info');
|
|
||||||
flog('前端读取 mb_levels=' + ($('mbLevels').value || '(默认)'), 'info');
|
flog('前端读取 mb_levels=' + ($('mbLevels').value || '(默认)'), 'info');
|
||||||
flog('前端读取 blend_method=' + $('blendMethod').value, 'info');
|
flog('前端读取 blend_method=' + $('blendMethod').value, 'info');
|
||||||
flog('前端读取 color_match=' + $('colorMatch').checked, 'info');
|
flog('前端读取 color_match=' + $('colorMatch').checked, 'info');
|
||||||
flog('前端读取 color_match_strength=' + ($('cmStrength').value || '(默认)'), 'info');
|
flog('前端读取 color_match_strength=' + ($('cmStrength').value || '(默认)'), 'info');
|
||||||
flog('前端读取 mb_feather_px=' + ($('mbFeatherPx').value || '(默认)'), 'info');
|
flog('前端读取 [融合] edge_erode_px=' + ($('edgeErodePx').value || '(默认)')
|
||||||
flog('前端读取 transition_band_px=' + ($('transitionBandPx').value || '(默认)'), 'info');
|
+ ' mb_feather_px=' + ($('mbFeatherPx').value || '(默认)')
|
||||||
flog('前端读取 redraw=' + $('redraw').checked, 'info');
|
+ ' transition_band_px=' + ($('transitionBandPx').value || '(默认)'), 'info');
|
||||||
flog('前端读取 inpainting_fill=' + $('inpaintingFill').value, 'info');
|
flog('前端读取 [换发型] inpainting_fill=' + $('inpaintingFill').value
|
||||||
flog('前端读取 mask_blur=' + ($('maskBlur').value || '(默认)'), 'info');
|
+ ' mask_blur=' + ($('maskBlur').value || '(默认)')
|
||||||
flog('前端读取 mask_dilate_scale=' + ($('maskDilateScale').value || '(默认)'), 'info');
|
+ ' mask_dilate_scale=' + ($('maskDilateScale').value || '(默认)'), 'info');
|
||||||
flog('前端读取 comfyui_prompt=' + ($('comfyuiPrompt').value.trim() || '(默认)'), 'info');
|
flog('隐藏参数固定: ' + JSON.stringify(HIDDEN), 'info');
|
||||||
|
|
||||||
const btn = $('submitBtn');
|
const btn = $('submitBtn');
|
||||||
btn.disabled = true; btn.textContent = '⏳ 请求中...';
|
btn.disabled = true; btn.textContent = '⏳ 请求中...';
|
||||||
@@ -310,25 +278,22 @@ async function submitTest() {
|
|||||||
form.append('hairline_id', $('hairlineId').value);
|
form.append('hairline_id', $('hairlineId').value);
|
||||||
form.append('gen_backend', 'swaphair');
|
form.append('gen_backend', 'swaphair');
|
||||||
form.append('hairgrow_strength', '0.75');
|
form.append('hairgrow_strength', '0.75');
|
||||||
form.append('is_hr', $('isHr').checked ? 'true' : 'false');
|
form.append('is_hr', HIDDEN.is_hr);
|
||||||
form.append('seg_model', $('segModel').value);
|
form.append('seg_model', HIDDEN.seg_model);
|
||||||
form.append('swap_mode', 'ext_mask');
|
form.append('swap_mode', 'ext_mask');
|
||||||
form.append('edge_erode_px', $('edgeErodePx').value || '3');
|
form.append('edge_erode_px', $('edgeErodePx').value || '3');
|
||||||
form.append('denoising_strength', '0.6');
|
form.append('denoising_strength', '0.6');
|
||||||
form.append('mb_levels', $('mbLevels').value || '5');
|
form.append('mb_levels', $('mbLevels').value || '5');
|
||||||
form.append('hairline_push_cm', $('hairlinePushCm').value || '1.0');
|
form.append('hairline_push_cm', $('hairlinePushCm').value || '0.8');
|
||||||
form.append('hairline_edge', $('hairlineEdge').value);
|
form.append('hairline_edge', HIDDEN.hairline_edge);
|
||||||
form.append('blend_method', $('blendMethod').value);
|
form.append('blend_method', $('blendMethod').value);
|
||||||
form.append('color_match', $('colorMatch').checked ? 'true' : 'false');
|
form.append('color_match', $('colorMatch').checked ? 'true' : 'false');
|
||||||
form.append('color_match_strength', $('cmStrength').value || '1.0');
|
form.append('color_match_strength', $('cmStrength').value || '0.4');
|
||||||
form.append('mb_feather_px', $('mbFeatherPx').value || '1');
|
form.append('mb_feather_px', $('mbFeatherPx').value || '1');
|
||||||
form.append('transition_band_px', $('transitionBandPx').value || '-1');
|
form.append('transition_band_px', $('transitionBandPx').value || '-1');
|
||||||
form.append('redraw', $('redraw').checked ? 'true' : 'false');
|
|
||||||
form.append('inpainting_fill', $('inpaintingFill').value || '1');
|
form.append('inpainting_fill', $('inpaintingFill').value || '1');
|
||||||
form.append('mask_blur', $('maskBlur').value || '11');
|
form.append('mask_blur', $('maskBlur').value || '11');
|
||||||
form.append('mask_dilate_scale', $('maskDilateScale').value || '1.0');
|
form.append('mask_dilate_scale', $('maskDilateScale').value || '1.0');
|
||||||
const cp = $('comfyuiPrompt').value.trim();
|
|
||||||
if (cp) form.append('comfyui_prompt', cp);
|
|
||||||
|
|
||||||
// 记录发出去的 form 字段
|
// 记录发出去的 form 字段
|
||||||
const sentFields = {};
|
const sentFields = {};
|
||||||
@@ -402,8 +367,8 @@ async function downloadBackendLog() {
|
|||||||
// 参数联动(遮罩/融合已固定,无下拉联动)
|
// 参数联动(遮罩/融合已固定,无下拉联动)
|
||||||
$('imageFile').addEventListener('change', function(){ if(this.files.length) flog('选择图片: ' + this.files[0].name, 'info'); });
|
$('imageFile').addEventListener('change', function(){ if(this.files.length) flog('选择图片: ' + this.files[0].name, 'info'); });
|
||||||
|
|
||||||
flog('调试页加载完成', 'info');
|
flog('调试页加载完成(接口11,不含重绘)', 'info');
|
||||||
flog('遮罩固定 pushed,融合默认 multiband(可调,含两段式 two_stage),选好图片直接提交', 'info');
|
flog('默认: hairline=chang_bolang, push=0.8, blend=two_stage, cm_strength=0.4;多频带融合(edge_erode/mb_feather/transition)与 change_hair 换发型(inpainting_fill/mask_blur/mask_dilate)参数已在页面可调。重绘见接口12 测试页', 'info');
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,361 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>接口12 — 发际线带重绘(Flux-2)测试页</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f5f5; color: #333; }
|
||||||
|
.container { max-width: 1240px; margin: 0 auto; padding: 24px; }
|
||||||
|
h1 { font-size: 22px; margin-bottom: 4px; }
|
||||||
|
h2 { font-size: 16px; margin: 20px 0 12px; }
|
||||||
|
.subtitle { color: #888; font-size: 13px; margin-bottom: 16px; line-height: 1.6; }
|
||||||
|
.card { background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 20px; }
|
||||||
|
.upload-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||||
|
input[type=file] { flex: 1; min-width: 200px; padding: 8px; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; }
|
||||||
|
.btn { padding: 10px 20px; border: none; border-radius: 8px; font-size: 14px; cursor: pointer; font-weight: 600; }
|
||||||
|
.btn-primary { background: #7c3aed; color: #fff; }
|
||||||
|
.btn-primary:disabled { background: #c4b5fd; cursor: not-allowed; }
|
||||||
|
.btn-outline { background: #fff; border: 1px solid #d1d5db; color: #374151; }
|
||||||
|
.btn-green { background: #059669; color: #fff; }
|
||||||
|
.btn-orange { background: #ea580c; color: #fff; }
|
||||||
|
.params { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; margin-top: 16px; }
|
||||||
|
.pf { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.pf label { font-size: 13px; font-weight: 600; }
|
||||||
|
.pf .desc { font-size: 11px; color: #9ca3af; font-weight: 400; }
|
||||||
|
.pf select, .pf input[type=number] { padding: 8px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; }
|
||||||
|
.pf .row { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.pf .row input[type=number] { width: 80px; }
|
||||||
|
.status { padding: 10px 16px; border-radius: 8px; font-size: 14px; margin-bottom: 16px; display: none; font-family: monospace; }
|
||||||
|
.status.info { background: #ede9fe; color: #5b21b6; display: block; }
|
||||||
|
.status.error { background: #fee2e2; color: #991b1b; display: block; }
|
||||||
|
.status.success { background: #d1fae5; color: #065f46; display: block; }
|
||||||
|
.steps { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
|
||||||
|
.step { background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
|
||||||
|
.step .cap { font-size: 13px; font-weight: 600; padding: 8px 12px; background: #fafafa; border-bottom: 1px solid #f0f0f0; }
|
||||||
|
.step .cap small { color: #999; font-weight: 400; display:block; margin-top:2px; }
|
||||||
|
.step img { width: 100%; display: block; background: #eee; cursor: zoom-in; }
|
||||||
|
.step .noimg { padding: 30px; text-align: center; color: #ccc; font-size: 13px; }
|
||||||
|
.big img { max-height: 520px; object-fit: contain; }
|
||||||
|
.log-panel { background: #1e1e1e; color: #d4d4d4; padding: 14px; border-radius: 8px;
|
||||||
|
font: 12px/1.6 Consolas, Monaco, monospace; white-space: pre-wrap; word-break: break-all;
|
||||||
|
max-height: 400px; overflow: auto; }
|
||||||
|
.log-panel .ts { color: #569cd6; }
|
||||||
|
.log-panel .lvl-info { color: #4ec9b0; }
|
||||||
|
.log-panel .lvl-warn { color: #dcdcaa; }
|
||||||
|
.log-panel .lvl-err { color: #f48771; }
|
||||||
|
.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.85); display: none; align-items: center; justify-content: center; z-index: 50; cursor: zoom-out; }
|
||||||
|
.lightbox img { max-width: 95%; max-height: 95%; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>接口12 — 发际线带重绘 <span style="font-size:13px;color:#888">(Flux-2 保色重绘)</span></h1>
|
||||||
|
<div class="subtitle">
|
||||||
|
内部先跑<b>接口11</b>拿到 ④接缝融合最终图(final),再取 <b>⑤-① 发际线重绘带</b>(发际线外推 band_lo_mult×push ~ band_hi_mult×push、经 baseline 截断只留上部)作遮罩,
|
||||||
|
调 <b>Flux-2(ComfyUI)</b> 保色重绘,重绘结果与 final 融合。<br>
|
||||||
|
接口11 参数用于内部生成 final 与重绘带;<b>comfyui_prompt</b> 控制 Flux-2 提示词。⚠️ 需 ComfyUI(:8188) 在跑。
|
||||||
|
对照仅生成不重绘:<a href="/static/test_interface11_debug.html">接口11 调试页</a>。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="upload-row">
|
||||||
|
<input type="file" id="imageFile" accept="image/*">
|
||||||
|
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🎨 提交重绘</button>
|
||||||
|
</div>
|
||||||
|
<div class="upload-row" style="margin-top:10px">
|
||||||
|
<label style="font-size:13px;font-weight:600;white-space:nowrap">X-Internal-Token</label>
|
||||||
|
<input type="text" id="token" value="dev-shared-secret-2026" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||||
|
</div>
|
||||||
|
<div class="params">
|
||||||
|
<div class="pf">
|
||||||
|
<label>发际线类型 ID</label>
|
||||||
|
<select id="hairlineId">
|
||||||
|
<option value="chang_bolang" selected>chang_bolang(波浪)</option>
|
||||||
|
<option value="chang_zhixian">chang_zhixian(直线)</option>
|
||||||
|
<option value="chang_tuoyuan">chang_tuoyuan(椭圆)</option>
|
||||||
|
<option value="chang_xinxing">chang_xinxing(心形)</option>
|
||||||
|
<option value="chang_huaban">chang_huaban(花瓣)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="pf">
|
||||||
|
<label>hairline_push_cm <span class="desc">发际线外推(cm),也决定重绘带宽度</span></label>
|
||||||
|
<div class="row"><input type="number" id="hairlinePushCm" min="0" max="3" step="0.1" value="0.8"></div>
|
||||||
|
</div>
|
||||||
|
<div class="pf">
|
||||||
|
<label>mb_levels <span class="desc">多频段金字塔层数</span></label>
|
||||||
|
<div class="row"><input type="number" id="mbLevels" min="2" max="6" step="1" value="5"></div>
|
||||||
|
</div>
|
||||||
|
<div class="pf">
|
||||||
|
<label>blend_method <span class="desc">接缝融合方法</span></label>
|
||||||
|
<select id="blendMethod">
|
||||||
|
<option value="two_stage" selected>two_stage(泊松→多频段,大色差)</option>
|
||||||
|
<option value="multiband">multiband(多频段金字塔)</option>
|
||||||
|
<option value="seamless">seamless(泊松无缝克隆)</option>
|
||||||
|
<option value="feather">feather(高斯羽化)</option>
|
||||||
|
<option value="alpha_gradient">alpha_gradient(距离变换)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="pf">
|
||||||
|
<label>color_match <span class="desc">融合前颜色迁移(消除色差)</span></label>
|
||||||
|
<div class="row"><input type="checkbox" id="colorMatch" checked style="width:18px;height:18px"><span class="desc">multiband/feather 生效;seamless/two_stage 自带调色</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="pf">
|
||||||
|
<label>color_match_strength <span class="desc">颜色迁移强度(0~1)</span></label>
|
||||||
|
<div class="row"><input type="number" id="cmStrength" min="0" max="1" step="0.05" value="0.4"></div>
|
||||||
|
</div>
|
||||||
|
<div class="pf">
|
||||||
|
<label>beauty_alpha <span class="desc">B版 band外美颜强度(0~1)</span></label>
|
||||||
|
<div class="row"><input type="number" id="beautyAlpha" min="0" max="1" step="0.05" value="0.6"></div>
|
||||||
|
</div>
|
||||||
|
<div class="pf">
|
||||||
|
<label>band_lo_mult <span class="desc">重绘带外推倍率下限(×push)</span></label>
|
||||||
|
<div class="row"><input type="number" id="bandLoMult" min="0" max="3" step="0.1" value="0.5"></div>
|
||||||
|
</div>
|
||||||
|
<div class="pf">
|
||||||
|
<label>band_hi_mult <span class="desc">重绘带外推倍率上限(×push)</span></label>
|
||||||
|
<div class="row"><input type="number" id="bandHiMult" min="0" max="3" step="0.1" value="1.5"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pf" style="margin-top:14px">
|
||||||
|
<label>comfyui_prompt <span class="desc">Flux-2 提示词,留空=默认「补充遮罩区域的头发,加一点美颜」</span></label>
|
||||||
|
<textarea id="comfyuiPrompt" rows="2" style="width:100%;padding:8px;border:1px solid #ddd;border-radius:8px;font-size:13px;font-family:inherit;resize:vertical" placeholder="留空用默认提示词"></textarea>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:#888;margin-top:8px">遮罩固定 pushed,重绘固定 Flux-2;隐藏参数(seg_model/hairline_edge/is_hr/edge_erode_px/mb_feather_px/transition_band_px/inpainting_fill/mask_blur/mask_dilate_scale)按原默认值随请求提交。换发型走 GPU + Flux-2 重绘,单次约 15~30s。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status hidden" id="statusBar"></div>
|
||||||
|
|
||||||
|
<div id="resultsArea" class="hidden">
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-top:0">🎯 两版重绘对比</h2>
|
||||||
|
<div class="steps" style="grid-template-columns: repeat(auto-fill, minmax(300px, 1fr))">
|
||||||
|
<div class="step big"><div class="cap">接口11 final <small>④ 接缝融合(重绘输入基底,无美颜)</small></div><img id="finalBase"></div>
|
||||||
|
<div class="step big"><div class="cap">A · 整帧重绘 <small>全脸美颜 + 全脸重绘(=手动 ComfyUI)</small></div><img id="outFull"></div>
|
||||||
|
<div class="step big"><div class="cap">B · 局部加发+全脸美颜 <small>加发只在发际线带、美颜保留全脸</small></div><img id="outBand"></div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:#888;margin-top:10px">对比要点:A 会重绘整张脸(五官/发型都可能变);B 只在发际线带加发、其余区域保留 final 结构并叠加全脸美颜(强度由 beauty_alpha 控制)。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-top:0">🪜 分步可视化 <span style="font-size:12px;color:#888" id="stepsInfo"></span></h2>
|
||||||
|
<div class="steps" id="stepsGrid"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-top:0; display:flex; justify-content:space-between; align-items:center">
|
||||||
|
<span>📋 前端日志</span>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-green" style="padding:6px 14px;font-size:13px" onclick="downloadFrontendLog()">下载前端日志</button>
|
||||||
|
<button class="btn btn-orange" style="padding:6px 14px;font-size:13px" onclick="downloadBackendLog()">下载后端日志</button>
|
||||||
|
<button class="btn btn-outline" style="padding:6px 14px;font-size:13px" onclick="clearLog()">清空</button>
|
||||||
|
</div>
|
||||||
|
</h2>
|
||||||
|
<div class="log-panel" id="logPanel"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lightbox" id="lightbox" onclick="this.style.display='none'"><img id="lightboxImg" alt=""></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API_BASE = window.location.origin;
|
||||||
|
const ENDPOINT = '/api/v1/hairline/grow_v2';
|
||||||
|
const LOG_API = '/api/v1/debug/hairline_log';
|
||||||
|
|
||||||
|
const FE_LOGS = [];
|
||||||
|
function flog(msg, level) {
|
||||||
|
const ts = new Date().toLocaleTimeString('zh-CN', {hour12:false}) + '.' + String(Date.now()%1000).padStart(3,'0');
|
||||||
|
const line = { ts, level: level||'info', msg };
|
||||||
|
FE_LOGS.push(line);
|
||||||
|
const panel = document.getElementById('logPanel');
|
||||||
|
const cls = level === 'warn' ? 'lvl-warn' : level === 'error' ? 'lvl-err' : 'lvl-info';
|
||||||
|
panel.innerHTML += '<span class="ts">[' + ts + ']</span> <span class="' + cls + '">' + line.msg.replace(/</g,'<') + '</span>\n';
|
||||||
|
panel.scrollTop = panel.scrollHeight;
|
||||||
|
}
|
||||||
|
function clearLog() { FE_LOGS.length = 0; document.getElementById('logPanel').innerHTML = ''; }
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ key: 'input', title: '原图', sub: '接口输入' },
|
||||||
|
{ key: 'final', title: '接口11 ④ final', sub: '接缝融合最终图(重绘输入基底,无美颜)' },
|
||||||
|
{ key: 'redraw_band_overlay', title: '⑤-① 发际线重绘带', sub: '紫=lo×push↔hi×push之间、经 baseline 截断只留上部' },
|
||||||
|
{ key: 'redraw_full', title: 'A · 整帧重绘(Flux-2)', sub: '全脸美颜+全脸重绘(=手动 ComfyUI)' },
|
||||||
|
{ key: 'redraw_band', title: 'B · 局部加发+全脸美颜', sub: 'band内=ComfyUI加发;band外=final+beauty_alpha美颜' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function $(id) { return document.getElementById(id); }
|
||||||
|
function setStatus(text, type) { const b = $('statusBar'); b.textContent = text; b.className = 'status ' + type; }
|
||||||
|
function pick(obj, name) { if (!obj) return null; return obj[name + '_url'] || obj[name + '_base64'] || null; }
|
||||||
|
function zoom(src) { $('lightboxImg').src = src; $('lightbox').style.display = 'flex'; }
|
||||||
|
|
||||||
|
function stepCard(title, sub, src) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'step';
|
||||||
|
const hasImg = src && src.length > 50;
|
||||||
|
const img = hasImg ? '<img src="' + src + '" onclick="zoom(this.src)">'
|
||||||
|
: '<div class="noimg">无图(后端返回空)</div>';
|
||||||
|
div.innerHTML = '<div class="cap">' + title + '<small>' + (sub||'') + (hasImg ? ' ('+src.length+'字符)' : '') + '</small></div>' + img;
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResult(d) {
|
||||||
|
flog('renderResult 开始 (blend=' + (d.blend_method || '?') + ', beauty_alpha=' + d.beauty_alpha + ')', 'info');
|
||||||
|
const s = d.steps || {};
|
||||||
|
$('finalBase').src = pick(s, 'final') || '';
|
||||||
|
$('finalBase').onclick = function(){ zoom(this.src); };
|
||||||
|
$('outFull').src = pick(s, 'redraw_full') || pick(s, 'redraw_c') || '';
|
||||||
|
$('outFull').onclick = function(){ zoom(this.src); };
|
||||||
|
$('outBand').src = pick(s, 'redraw_band') || '';
|
||||||
|
$('outBand').onclick = function(){ zoom(this.src); };
|
||||||
|
const grid = $('stepsGrid'); grid.innerHTML = '';
|
||||||
|
$('stepsInfo').textContent = '(mask=pushed, blend=' + (d.blend_method || '?') + ', redraw=Flux-2, beauty_alpha=' + d.beauty_alpha + ')';
|
||||||
|
if (d.redraw && d.redraw.enabled) {
|
||||||
|
flog('重绘带 band_pixels=' + d.redraw.band_pixels + ' push_px=' + d.redraw.push_px, 'info');
|
||||||
|
if (d.redraw.c_error) flog('Flux-2 重绘失败: ' + d.redraw.c_error, 'warn');
|
||||||
|
} else if (d.redraw && d.redraw.error) {
|
||||||
|
flog('重绘带计算失败: ' + d.redraw.error, 'warn');
|
||||||
|
}
|
||||||
|
STEPS.forEach(st => {
|
||||||
|
const src = pick(s, st.key);
|
||||||
|
const hasImg = src && src.length > 50;
|
||||||
|
flog(' 渲染 ' + st.key + ': ' + (hasImg ? '有图(' + src.length + '字符)' : '无图'), hasImg ? 'info' : 'warn');
|
||||||
|
grid.appendChild(stepCard(st.title, st.sub, src));
|
||||||
|
});
|
||||||
|
flog('renderResult 完成', 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitTest() {
|
||||||
|
const file = $('imageFile').files[0];
|
||||||
|
if (!file) { setStatus('请先选择图片', 'error'); return; }
|
||||||
|
// 页面隐藏但仍提交的固定默认值
|
||||||
|
const HIDDEN = {
|
||||||
|
seg_model: 'segformer',
|
||||||
|
hairline_edge: 'column',
|
||||||
|
is_hr: 'false',
|
||||||
|
edge_erode_px: '3',
|
||||||
|
mb_feather_px: '1',
|
||||||
|
transition_band_px: '-1',
|
||||||
|
inpainting_fill: '1',
|
||||||
|
mask_blur: '11',
|
||||||
|
mask_dilate_scale: '1.0',
|
||||||
|
};
|
||||||
|
|
||||||
|
flog('===== 提交重绘(接口12) =====', 'info');
|
||||||
|
flog('前端读取 hairline_id=' + $('hairlineId').value, 'info');
|
||||||
|
flog('前端读取 hairline_push_cm=' + ($('hairlinePushCm').value || '(默认)'), 'info');
|
||||||
|
flog('前端读取 mb_levels=' + ($('mbLevels').value || '(默认)'), 'info');
|
||||||
|
flog('前端读取 blend_method=' + $('blendMethod').value, 'info');
|
||||||
|
flog('前端读取 color_match=' + $('colorMatch').checked, 'info');
|
||||||
|
flog('前端读取 color_match_strength=' + ($('cmStrength').value || '(默认)'), 'info');
|
||||||
|
flog('前端读取 beauty_alpha=' + ($('beautyAlpha').value || '(默认)'), 'info');
|
||||||
|
flog('前端读取 重绘带倍率 lo=' + ($('bandLoMult').value || '0.5') + ' hi=' + ($('bandHiMult').value || '1.5'), 'info');
|
||||||
|
flog('前端读取 comfyui_prompt=' + ($('comfyuiPrompt').value.trim() || '(默认)'), 'info');
|
||||||
|
flog('隐藏参数固定: ' + JSON.stringify(HIDDEN), 'info');
|
||||||
|
|
||||||
|
const btn = $('submitBtn');
|
||||||
|
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
|
||||||
|
setStatus('正在请求(内部先跑接口11,再 Flux-2 重绘,约 15~30s)...', 'info');
|
||||||
|
$('resultsArea').classList.remove('hidden');
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('image_file', file);
|
||||||
|
form.append('hairline_id', $('hairlineId').value);
|
||||||
|
form.append('gen_backend', 'swaphair');
|
||||||
|
form.append('hairgrow_strength', '0.75');
|
||||||
|
form.append('is_hr', HIDDEN.is_hr);
|
||||||
|
form.append('seg_model', HIDDEN.seg_model);
|
||||||
|
form.append('erode_cm', '0.6');
|
||||||
|
form.append('swap_mode', 'ext_mask');
|
||||||
|
form.append('edge_erode_px', HIDDEN.edge_erode_px);
|
||||||
|
form.append('denoising_strength', '0.6');
|
||||||
|
form.append('mb_levels', $('mbLevels').value || '5');
|
||||||
|
form.append('hairline_push_cm', $('hairlinePushCm').value || '0.8');
|
||||||
|
form.append('hairline_edge', HIDDEN.hairline_edge);
|
||||||
|
form.append('blend_method', $('blendMethod').value);
|
||||||
|
form.append('color_match', $('colorMatch').checked ? 'true' : 'false');
|
||||||
|
form.append('color_match_strength', $('cmStrength').value || '0.4');
|
||||||
|
form.append('beauty_alpha', $('beautyAlpha').value || '0.6');
|
||||||
|
form.append('band_lo_mult', $('bandLoMult').value || '0.5');
|
||||||
|
form.append('band_hi_mult', $('bandHiMult').value || '1.5');
|
||||||
|
form.append('mb_feather_px', HIDDEN.mb_feather_px);
|
||||||
|
form.append('transition_band_px', HIDDEN.transition_band_px);
|
||||||
|
form.append('inpainting_fill', HIDDEN.inpainting_fill);
|
||||||
|
form.append('mask_blur', HIDDEN.mask_blur);
|
||||||
|
form.append('mask_dilate_scale', HIDDEN.mask_dilate_scale);
|
||||||
|
const cp = $('comfyuiPrompt').value.trim();
|
||||||
|
if (cp) form.append('comfyui_prompt', cp);
|
||||||
|
|
||||||
|
const sentFields = {};
|
||||||
|
form.forEach((v, k) => { sentFields[k] = (k === 'image_file') ? '[文件]' : v; });
|
||||||
|
flog('实际发送的 FormData: ' + JSON.stringify(sentFields), 'info');
|
||||||
|
|
||||||
|
const t0 = performance.now();
|
||||||
|
try {
|
||||||
|
const headers = {};
|
||||||
|
const tok = $('token').value.trim();
|
||||||
|
if (tok) headers['X-Internal-Token'] = tok;
|
||||||
|
flog('fetch POST ' + ENDPOINT, 'info');
|
||||||
|
const resp = await fetch(API_BASE + ENDPOINT, { method: 'POST', headers, body: form });
|
||||||
|
flog('收到响应 http=' + resp.status, resp.ok ? 'info' : 'error');
|
||||||
|
const json = await resp.json();
|
||||||
|
const dt = ((performance.now() - t0) / 1000).toFixed(2);
|
||||||
|
flog('JSON 解析完成 code=' + json.code + ' 耗时=' + dt + 's', json.code === 0 ? 'info' : 'error');
|
||||||
|
|
||||||
|
if (json.code === 0) {
|
||||||
|
const d = json.data;
|
||||||
|
flog('后端返回 blend=' + d.blend_method + ' hairline_push_cm=' + d.hairline_push_cm + ' mask_pixels=' + d.mask_pixels, 'info');
|
||||||
|
flog('后端返回 comfyui_prompt=' + d.comfyui_prompt, 'info');
|
||||||
|
flog('后端 _rid=' + d._rid, 'info');
|
||||||
|
const s = d.steps || {};
|
||||||
|
Object.keys(s).filter(k => k.endsWith('_base64')).forEach(k => {
|
||||||
|
const len = s[k] ? s[k].length : 0;
|
||||||
|
flog(' steps.' + k + ' = ' + (len > 0 ? len + '字符' : '空'), len > 0 ? 'info' : 'warn');
|
||||||
|
});
|
||||||
|
const okRedraw = d.redraw && d.redraw.enabled && !d.redraw.c_error && (pick(s,'redraw_full'));
|
||||||
|
setStatus((okRedraw ? '✅ 重绘成功(A整帧 / B局部+美颜)' : '⚠️ 已返回(重绘可能未生效,见日志)') + ' (' + dt + 's) _rid=' + d._rid, okRedraw ? 'success' : 'error');
|
||||||
|
renderResult(d);
|
||||||
|
} else {
|
||||||
|
setStatus('❌ 业务错误 code=' + json.code + ':' + json.message, 'error');
|
||||||
|
flog('业务错误: ' + json.message, 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setStatus('❌ 网络错误: ' + err.message, 'error');
|
||||||
|
flog('网络错误: ' + err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false; btn.textContent = '🎨 提交重绘';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadFrontendLog() {
|
||||||
|
const text = FE_LOGS.map(l => '[' + l.ts + '] [' + l.level.toUpperCase() + '] ' + l.msg).join('\n');
|
||||||
|
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = 'iface12_frontend_log_' + Date.now() + '.txt';
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadBackendLog() {
|
||||||
|
flog('下载后端日志...', 'info');
|
||||||
|
try {
|
||||||
|
const resp = await fetch(API_BASE + LOG_API + '?tail=1000');
|
||||||
|
const text = await resp.text();
|
||||||
|
flog('后端日志获取成功 ' + text.length + ' 字符', 'info');
|
||||||
|
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = 'iface12_backend_log_' + Date.now() + '.txt';
|
||||||
|
a.click();
|
||||||
|
} catch (err) {
|
||||||
|
flog('下载后端日志失败: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$('imageFile').addEventListener('change', function(){ if(this.files.length) flog('选择图片: ' + this.files[0].name, 'info'); });
|
||||||
|
|
||||||
|
flog('接口12 重绘测试页加载完成(两版对比:A整帧 / B局部加发+全脸美颜)', 'info');
|
||||||
|
flog('默认: hairline=chang_bolang, push=0.8, blend=two_stage, cm_strength=0.4, beauty_alpha=0.6;需 ComfyUI(:8188) 在跑', 'info');
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>接口12 final — 发际线带重绘(精简版)</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f5f5; color: #333; }
|
||||||
|
.container { max-width: 1240px; margin: 0 auto; padding: 24px; }
|
||||||
|
h1 { font-size: 22px; margin-bottom: 4px; }
|
||||||
|
h2 { font-size: 16px; margin: 20px 0 12px; }
|
||||||
|
.subtitle { color: #888; font-size: 13px; margin-bottom: 16px; line-height: 1.6; }
|
||||||
|
.card { background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 20px; }
|
||||||
|
.upload-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||||
|
input[type=file] { flex: 1; min-width: 200px; padding: 8px; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; }
|
||||||
|
.btn { padding: 10px 20px; border: none; border-radius: 8px; font-size: 14px; cursor: pointer; font-weight: 600; }
|
||||||
|
.btn-primary { background: #7c3aed; color: #fff; }
|
||||||
|
.btn-primary:disabled { background: #c4b5fd; cursor: not-allowed; }
|
||||||
|
.btn-outline { background: #fff; border: 1px solid #d1d5db; color: #374151; }
|
||||||
|
.btn-green { background: #059669; color: #fff; }
|
||||||
|
.params { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; margin-top: 16px; }
|
||||||
|
.pf { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.pf label { font-size: 13px; font-weight: 600; }
|
||||||
|
.pf select { padding: 8px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; }
|
||||||
|
.status { padding: 10px 16px; border-radius: 8px; font-size: 14px; margin-bottom: 16px; display: none; font-family: monospace; }
|
||||||
|
.status.info { background: #ede9fe; color: #5b21b6; display: block; }
|
||||||
|
.status.error { background: #fee2e2; color: #991b1b; display: block; }
|
||||||
|
.status.success { background: #d1fae5; color: #065f46; display: block; }
|
||||||
|
.steps { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
|
||||||
|
.step { background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
|
||||||
|
.step .cap { font-size: 13px; font-weight: 600; padding: 8px 12px; background: #fafafa; border-bottom: 1px solid #f0f0f0; }
|
||||||
|
.step .cap small { color: #999; font-weight: 400; display:block; margin-top:2px; }
|
||||||
|
.step img { width: 100%; display: block; background: #eee; cursor: zoom-in; }
|
||||||
|
.big img { max-height: 520px; object-fit: contain; }
|
||||||
|
.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.85); display: none; align-items: center; justify-content: center; z-index: 50; cursor: zoom-out; }
|
||||||
|
.lightbox img { max-width: 95%; max-height: 95%; }
|
||||||
|
.hidden { display: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>接口12 final — 发际线带重绘 <span style="font-size:13px;color:#888">(精简版)</span></h1>
|
||||||
|
<div class="subtitle">
|
||||||
|
只需上传图片 + 选择发型,其余参数全部用当前调优默认值(<code>/api/v1/hairline/grow_v2_final</code>)。<br>
|
||||||
|
重绘输出为<b>整帧重绘</b>(全脸美颜 + 全脸重绘,=手动 ComfyUI)。⚠️ 需 ComfyUI(:8188) 在跑。
|
||||||
|
局部加发版见 <a href="/static/test_interface12_final_v2.html">接口12 final v2</a>;完整参数调试见 <a href="/static/test_interface12.html">接口12 调试页</a>。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="upload-row">
|
||||||
|
<input type="file" id="imageFile" accept="image/*">
|
||||||
|
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🎨 提交重绘</button>
|
||||||
|
</div>
|
||||||
|
<div class="params">
|
||||||
|
<div class="pf">
|
||||||
|
<label>发际线类型 ID</label>
|
||||||
|
<select id="hairlineId">
|
||||||
|
<option value="chang_bolang" selected>chang_bolang(波浪)</option>
|
||||||
|
<option value="chang_zhixian">chang_zhixian(直线)</option>
|
||||||
|
<option value="chang_tuoyuan">chang_tuoyuan(椭圆)</option>
|
||||||
|
<option value="chang_xinxing">chang_xinxing(心形)</option>
|
||||||
|
<option value="chang_huaban">chang_huaban(花瓣)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status" id="statusBar"></div>
|
||||||
|
|
||||||
|
<div id="resultsArea" class="hidden">
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-top:0">🎯 整帧重绘结果</h2>
|
||||||
|
<div class="steps" style="grid-template-columns: repeat(auto-fill, minmax(300px, 1fr))">
|
||||||
|
<div class="step big"><div class="cap">接口11 final <small>④ 接缝融合(重绘输入基底,无美颜)</small></div><img id="finalBase"></div>
|
||||||
|
<div class="step big"><div class="cap">整帧重绘 <small>全脸美颜 + 全脸重绘(=手动 ComfyUI)</small></div><img id="outFull"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lightbox" id="lightbox" onclick="this.style.display='none'"><img id="lightboxImg" alt=""></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API_BASE = window.location.origin;
|
||||||
|
const ENDPOINT = '/api/v1/hairline/grow_v2_final';
|
||||||
|
const TOKEN = 'dev-shared-secret-2026';
|
||||||
|
|
||||||
|
function $(id) { return document.getElementById(id); }
|
||||||
|
function setStatus(text, type) { const b = $('statusBar'); b.textContent = text; b.className = 'status ' + type; }
|
||||||
|
function pick(obj, name) { if (!obj) return null; return obj[name + '_url'] || obj[name + '_base64'] || null; }
|
||||||
|
function zoom(src) { $('lightboxImg').src = src; $('lightbox').style.display = 'flex'; }
|
||||||
|
|
||||||
|
function renderResult(d) {
|
||||||
|
const s = d.steps || {};
|
||||||
|
$('finalBase').src = pick(s, 'final') || '';
|
||||||
|
$('finalBase').onclick = function(){ zoom(this.src); };
|
||||||
|
$('outFull').src = pick(s, 'redraw_full') || pick(s, 'redraw_c') || '';
|
||||||
|
$('outFull').onclick = function(){ zoom(this.src); };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitTest() {
|
||||||
|
const file = $('imageFile').files[0];
|
||||||
|
if (!file) { setStatus('请先选择图片', 'error'); return; }
|
||||||
|
|
||||||
|
const btn = $('submitBtn');
|
||||||
|
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
|
||||||
|
setStatus('正在请求(内部先跑接口11,再 Flux-2 重绘,约 15~30s)...', 'info');
|
||||||
|
$('resultsArea').classList.remove('hidden');
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('image_file', file);
|
||||||
|
form.append('hairline_id', $('hairlineId').value);
|
||||||
|
|
||||||
|
const t0 = performance.now();
|
||||||
|
try {
|
||||||
|
const resp = await fetch(API_BASE + ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Internal-Token': TOKEN },
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
const json = await resp.json();
|
||||||
|
const dt = ((performance.now() - t0) / 1000).toFixed(2);
|
||||||
|
|
||||||
|
if (json.code === 0) {
|
||||||
|
const d = json.data;
|
||||||
|
const okRedraw = d.redraw && d.redraw.enabled && !d.redraw.c_error && pick(d.steps, 'redraw_full');
|
||||||
|
setStatus((okRedraw ? '✅ 整帧重绘成功' : '⚠️ 已返回(重绘可能未生效)') + ' (' + dt + 's)', okRedraw ? 'success' : 'error');
|
||||||
|
renderResult(d);
|
||||||
|
} else {
|
||||||
|
setStatus('❌ 业务错误 code=' + json.code + ':' + json.message, 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setStatus('❌ 网络错误: ' + err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false; btn.textContent = '🎨 提交重绘';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>接口12 final v2 — 发际线带重绘(局部加发+全脸美颜)</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f5f5; color: #333; }
|
||||||
|
.container { max-width: 1240px; margin: 0 auto; padding: 24px; }
|
||||||
|
h1 { font-size: 22px; margin-bottom: 4px; }
|
||||||
|
h2 { font-size: 16px; margin: 20px 0 12px; }
|
||||||
|
.subtitle { color: #888; font-size: 13px; margin-bottom: 16px; line-height: 1.6; }
|
||||||
|
.card { background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 20px; }
|
||||||
|
.upload-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||||
|
input[type=file] { flex: 1; min-width: 200px; padding: 8px; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; }
|
||||||
|
.btn { padding: 10px 20px; border: none; border-radius: 8px; font-size: 14px; cursor: pointer; font-weight: 600; }
|
||||||
|
.btn-primary { background: #7c3aed; color: #fff; }
|
||||||
|
.btn-primary:disabled { background: #c4b5fd; cursor: not-allowed; }
|
||||||
|
.btn-outline { background: #fff; border: 1px solid #d1d5db; color: #374151; }
|
||||||
|
.btn-green { background: #059669; color: #fff; }
|
||||||
|
.params { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; margin-top: 16px; }
|
||||||
|
.pf { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.pf label { font-size: 13px; font-weight: 600; }
|
||||||
|
.pf select { padding: 8px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; }
|
||||||
|
.status { padding: 10px 16px; border-radius: 8px; font-size: 14px; margin-bottom: 16px; display: none; font-family: monospace; }
|
||||||
|
.status.info { background: #ede9fe; color: #5b21b6; display: block; }
|
||||||
|
.status.error { background: #fee2e2; color: #991b1b; display: block; }
|
||||||
|
.status.success { background: #d1fae5; color: #065f46; display: block; }
|
||||||
|
.steps { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
|
||||||
|
.step { background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
|
||||||
|
.step .cap { font-size: 13px; font-weight: 600; padding: 8px 12px; background: #fafafa; border-bottom: 1px solid #f0f0f0; }
|
||||||
|
.step .cap small { color: #999; font-weight: 400; display:block; margin-top:2px; }
|
||||||
|
.step img { width: 100%; display: block; background: #eee; cursor: zoom-in; }
|
||||||
|
.big img { max-height: 520px; object-fit: contain; }
|
||||||
|
.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.85); display: none; align-items: center; justify-content: center; z-index: 50; cursor: zoom-out; }
|
||||||
|
.lightbox img { max-width: 95%; max-height: 95%; }
|
||||||
|
.hidden { display: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>接口12 final v2 — 发际线带重绘 <span style="font-size:13px;color:#888">(局部加发+全脸美颜)</span></h1>
|
||||||
|
<div class="subtitle">
|
||||||
|
只需上传图片 + 选择发型,其余参数全部用当前调优默认值(<code>/api/v1/hairline/grow_v2_final_v2</code>)。<br>
|
||||||
|
重绘输出为 <b>B 局部加发+全脸美颜</b>(加发只在发际线带、band 外保留 final 结构并叠加全脸美颜)。⚠️ 需 ComfyUI(:8188) 在跑。
|
||||||
|
整帧重绘版见 <a href="/static/test_interface12_final.html">接口12 final</a>;完整参数调试见 <a href="/static/test_interface12.html">接口12 调试页</a>。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="upload-row">
|
||||||
|
<input type="file" id="imageFile" accept="image/*">
|
||||||
|
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🎨 提交重绘</button>
|
||||||
|
</div>
|
||||||
|
<div class="params">
|
||||||
|
<div class="pf">
|
||||||
|
<label>发际线类型 ID</label>
|
||||||
|
<select id="hairlineId">
|
||||||
|
<option value="chang_bolang" selected>chang_bolang(波浪)</option>
|
||||||
|
<option value="chang_zhixian">chang_zhixian(直线)</option>
|
||||||
|
<option value="chang_tuoyuan">chang_tuoyuan(椭圆)</option>
|
||||||
|
<option value="chang_xinxing">chang_xinxing(心形)</option>
|
||||||
|
<option value="chang_huaban">chang_huaban(花瓣)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status" id="statusBar"></div>
|
||||||
|
|
||||||
|
<div id="resultsArea" class="hidden">
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-top:0">🎯 局部加发+全脸美颜结果</h2>
|
||||||
|
<div class="steps" style="grid-template-columns: repeat(auto-fill, minmax(300px, 1fr))">
|
||||||
|
<div class="step big"><div class="cap">接口11 final <small>④ 接缝融合(重绘输入基底,无美颜)</small></div><img id="finalBase"></div>
|
||||||
|
<div class="step big"><div class="cap">B · 局部加发+全脸美颜 <small>加发只在发际线带、美颜保留全脸</small></div><img id="outBand"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lightbox" id="lightbox" onclick="this.style.display='none'"><img id="lightboxImg" alt=""></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API_BASE = window.location.origin;
|
||||||
|
const ENDPOINT = '/api/v1/hairline/grow_v2_final_v2';
|
||||||
|
const TOKEN = 'dev-shared-secret-2026';
|
||||||
|
|
||||||
|
function $(id) { return document.getElementById(id); }
|
||||||
|
function setStatus(text, type) { const b = $('statusBar'); b.textContent = text; b.className = 'status ' + type; }
|
||||||
|
function pick(obj, name) { if (!obj) return null; return obj[name + '_url'] || obj[name + '_base64'] || null; }
|
||||||
|
function zoom(src) { $('lightboxImg').src = src; $('lightbox').style.display = 'flex'; }
|
||||||
|
|
||||||
|
function renderResult(d) {
|
||||||
|
const s = d.steps || {};
|
||||||
|
$('finalBase').src = pick(s, 'final') || '';
|
||||||
|
$('finalBase').onclick = function(){ zoom(this.src); };
|
||||||
|
$('outBand').src = pick(s, 'redraw_band') || '';
|
||||||
|
$('outBand').onclick = function(){ zoom(this.src); };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitTest() {
|
||||||
|
const file = $('imageFile').files[0];
|
||||||
|
if (!file) { setStatus('请先选择图片', 'error'); return; }
|
||||||
|
|
||||||
|
const btn = $('submitBtn');
|
||||||
|
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
|
||||||
|
setStatus('正在请求(内部先跑接口11,再 Flux-2 重绘,约 15~30s)...', 'info');
|
||||||
|
$('resultsArea').classList.remove('hidden');
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('image_file', file);
|
||||||
|
form.append('hairline_id', $('hairlineId').value);
|
||||||
|
|
||||||
|
const t0 = performance.now();
|
||||||
|
try {
|
||||||
|
const resp = await fetch(API_BASE + ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Internal-Token': TOKEN },
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
const json = await resp.json();
|
||||||
|
const dt = ((performance.now() - t0) / 1000).toFixed(2);
|
||||||
|
|
||||||
|
if (json.code === 0) {
|
||||||
|
const d = json.data;
|
||||||
|
const okRedraw = d.redraw && d.redraw.enabled && !d.redraw.c_error && pick(d.steps, 'redraw_band');
|
||||||
|
setStatus((okRedraw ? '✅ 局部加发+全脸美颜成功' : '⚠️ 已返回(重绘可能未生效)') + ' (' + dt + 's)', okRedraw ? 'success' : 'error');
|
||||||
|
renderResult(d);
|
||||||
|
} else {
|
||||||
|
setStatus('❌ 业务错误 code=' + json.code + ':' + json.message, 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setStatus('❌ 网络错误: ' + err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false; btn.textContent = '🎨 提交重绘';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>发际线生发</title>
|
|
||||||
<style>
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f5f5; color: #333; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
|
|
||||||
.wrap { max-width: 720px; width: 100%; }
|
|
||||||
h1 { font-size: 20px; margin-bottom: 16px; text-align: center; }
|
|
||||||
.card { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 1px 4px rgba(0,0,0,.08); margin-bottom: 20px; }
|
|
||||||
.row { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; }
|
|
||||||
.field { flex: 1; min-width: 180px; display: flex; flex-direction: column; gap: 6px; }
|
|
||||||
label { font-size: 13px; font-weight: 600; color: #555; }
|
|
||||||
input[type=file] { padding: 8px; border: 2px dashed #ddd; border-radius: 8px; font-size: 14px; cursor: pointer; }
|
|
||||||
select { padding: 8px 10px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; background: #fff; }
|
|
||||||
.btn { padding: 10px 28px; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; font-weight: 600; background: #2563eb; color: #fff; white-space: nowrap; }
|
|
||||||
.btn:disabled { background: #93c5fd; cursor: not-allowed; }
|
|
||||||
.status { padding: 10px 14px; border-radius: 8px; font-size: 14px; margin-bottom: 16px; display: none; }
|
|
||||||
.status.info { background: #dbeafe; color: #1e40af; display: block; }
|
|
||||||
.status.error { background: #fee2e2; color: #991b1b; display: block; }
|
|
||||||
.status.success { background: #d1fae5; color: #065f46; display: block; }
|
|
||||||
.result { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
||||||
.result .col { text-align: center; }
|
|
||||||
.result .col .cap { font-size: 13px; color: #888; margin-bottom: 8px; font-weight: 600; }
|
|
||||||
.result img { width: 100%; border-radius: 8px; box-shadow: 0 1px 6px rgba(0,0,0,.12); cursor: zoom-in; display: block; }
|
|
||||||
.result .empty { color: #bbb; padding: 60px 0; font-size: 14px; grid-column: 1 / -1; text-align: center; }
|
|
||||||
.hint { font-size: 12px; color: #999; margin-top: 8px; text-align: center; }
|
|
||||||
.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.88); display: none; align-items: center; justify-content: center; z-index: 50; cursor: zoom-out; padding: 24px; }
|
|
||||||
.lightbox img { max-width: 100%; max-height: 100%; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="wrap">
|
|
||||||
<h1>发际线生发</h1>
|
|
||||||
<div class="card">
|
|
||||||
<div class="row">
|
|
||||||
<div class="field">
|
|
||||||
<label>选择图片</label>
|
|
||||||
<input type="file" id="imageFile" accept="image/*">
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>发型</label>
|
|
||||||
<select id="hairlineId">
|
|
||||||
<option value="chang_zhixian">直线</option>
|
|
||||||
<option value="chang_tuoyuan">椭圆</option>
|
|
||||||
<option value="chang_bolang">波浪</option>
|
|
||||||
<option value="chang_xinxing">心形</option>
|
|
||||||
<option value="chang_huaban">花瓣</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button class="btn" id="submitBtn" onclick="submit()">生成</button>
|
|
||||||
</div>
|
|
||||||
<div class="hint">其余参数走默认值(pushed 遮罩 + multiband 融合)</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status hidden" id="statusBar"></div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="result" id="resultArea">
|
|
||||||
<div class="empty">上传图片、选发型、点生成</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="lightbox" id="lightbox" onclick="this.style.display='none'"><img id="lightboxImg"></div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const API = window.location.origin + '/api/v1/hairline/grow_v2';
|
|
||||||
const TOKEN = 'dev-shared-secret-2026';
|
|
||||||
|
|
||||||
function $(id) { return document.getElementById(id); }
|
|
||||||
function setStatus(text, type) { const b = $('statusBar'); b.textContent = text; b.className = 'status ' + type; }
|
|
||||||
function zoom(src) { $('lightboxImg').src = src; $('lightbox').style.display = 'flex'; }
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
const file = $('imageFile').files[0];
|
|
||||||
if (!file) { setStatus('请先选择图片', 'error'); return; }
|
|
||||||
const btn = $('submitBtn');
|
|
||||||
btn.disabled = true; btn.textContent = '生成中...';
|
|
||||||
setStatus('正在生成(约 5~15 秒)...', 'info');
|
|
||||||
// 先读原图用于对比
|
|
||||||
const origSrc = await new Promise(r => { const fr = new FileReader(); fr.onload = () => r(fr.result); fr.readAsDataURL(file); });
|
|
||||||
$('resultArea').innerHTML =
|
|
||||||
'<div class="col"><div class="cap">原图</div><img src="' + origSrc + '"></div>'
|
|
||||||
+ '<div class="col"><div class="cap">⏳ 生成中...</div><div class="empty" style="padding:40px 0">处理中</div></div>';
|
|
||||||
|
|
||||||
const form = new FormData();
|
|
||||||
form.append('image_file', file);
|
|
||||||
form.append('hairline_id', $('hairlineId').value);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await fetch(API, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'X-Internal-Token': TOKEN },
|
|
||||||
body: form,
|
|
||||||
});
|
|
||||||
const json = await resp.json();
|
|
||||||
if (json.code === 0 && json.data && json.data.final_base64) {
|
|
||||||
const src = json.data.final_base64;
|
|
||||||
setStatus('✅ 生成成功', 'success');
|
|
||||||
$('resultArea').innerHTML =
|
|
||||||
'<div class="col"><div class="cap">原图</div><img src="' + origSrc + '" onclick="zoom(this.src)"></div>'
|
|
||||||
+ '<div class="col"><div class="cap">生成结果</div><img src="' + src + '" onclick="zoom(this.src)"></div>';
|
|
||||||
} else {
|
|
||||||
setStatus('❌ 失败:' + (json.message || '未知错误'), 'error');
|
|
||||||
$('resultArea').innerHTML = '<div class="empty">生成失败:' + (json.message||'') + '</div>';
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setStatus('❌ 网络错误:' + err.message, 'error');
|
|
||||||
$('resultArea').innerHTML = '<div class="empty">请求失败</div>';
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false; btn.textContent = '生成';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$('imageFile').addEventListener('change', function() {
|
|
||||||
if (this.files.length) setStatus('已选择:' + this.files[0].name, 'info');
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||