接口11/12:新增发际线外推遮罩(pushed)模式 + multiband融合修复 + 调试日志 + 对比报告脚本
发际线生发遮罩算法(mask_type=pushed): - _extract_hairline:提取头发/皮肤交界线(逐列头发下沿),用 baseline 水平 y 线截断(无竖线) - _pushed_mask:以眉心(151点)为圆心逐点径向外推 push_cm,与 baseline 组闭合区域 - 径向归并锯齿用插值填补,避免遮罩碎裂 - pushed 模式过程可视化(①-f 交界线 / ①-g 外推+遮罩),eroded/closed 不展示无关步骤 multiband 金字塔融合修复(hairline_grow.py): - mb_levels 按层数膨胀外缘 keep 区,让过渡带随层数变宽(旧硬二值钳回导致 mb_levels 形同虚设) 接口12 grow_v2(固定参数精简版): - 固定 multiband/mb_levels=5/erode_cm=0.6,仅返回 final_base64 - 支持 mask_type=pushed + hairline_push_cm/hairline_edge 调试支持: - 调试页 test_interface11_debug.html(前后端日志面板 + 下载日志按钮) - hairline_grow.log 全链路日志(按 rid 关联),/api/v1/debug/hairline_log 下载接口 - 遮罩计算过程可视化(baseline/upper/头发分割/交界线/外推/最终遮罩) 文档与脚本: - docs/发际线生发遮罩算法_pushed模式.md 算法说明 - scripts/batch_grow_v2.py 批量调用、gen_report_hairline_v2.py 对比报告生成
This commit is contained in:
+17
@@ -31,3 +31,20 @@ tests/output/
|
|||||||
|
|
||||||
# 本地临时遮罩测试页(不入 git)
|
# 本地临时遮罩测试页(不入 git)
|
||||||
test_local.py
|
test_local.py
|
||||||
|
|
||||||
|
# 运行期日志 / uvicorn 日志(不入 git)
|
||||||
|
log/
|
||||||
|
uvicorn.log
|
||||||
|
uvicorn*.log
|
||||||
|
|
||||||
|
# ZCode 工具目录(不入 git)
|
||||||
|
.zcode/
|
||||||
|
|
||||||
|
# 临时响应文件(不入 git)
|
||||||
|
_grow*_resp.json
|
||||||
|
|
||||||
|
# 测试素材图(体积大,不入 git)
|
||||||
|
image/test/
|
||||||
|
|
||||||
|
# 批量报告输出(200张生成图+原图,体积大,不入 git)
|
||||||
|
static/report_hairline_v2/
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ app = FastAPI(
|
|||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
|
||||||
# 不校验鉴权的路径前缀(供网关探测 / 文档 / 静态)
|
# 不校验鉴权的路径前缀(供网关探测 / 文档 / 静态)
|
||||||
_AUTH_EXEMPT = ("/health", "/docs", "/openapi.json", "/redoc", "/static")
|
_AUTH_EXEMPT = ("/health", "/docs", "/openapi.json", "/redoc", "/static", "/api/v1/debug")
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
@@ -1277,7 +1277,10 @@ async def head_band(
|
|||||||
(忠于算法文档);`swap_mode=as_is`:不改换发型,贴回时再裁到接口9 遮罩。
|
(忠于算法文档);`swap_mode=as_is`:不改换发型,贴回时再裁到接口9 遮罩。
|
||||||
3. 严格按接口9 遮罩把生成图贴回原图(遮罩外=原图不动)。
|
3. 严格按接口9 遮罩把生成图贴回原图(遮罩外=原图不动)。
|
||||||
4. 接缝融合:`blend_method` 选 feather(高斯羽化)/alpha_gradient(距离变换内渐变)/
|
4. 接缝融合:`blend_method` 选 feather(高斯羽化)/alpha_gradient(距离变换内渐变)/
|
||||||
seamless(泊松无缝克隆);`feather_px`、`edge_erode_px` 控制过渡细节。
|
seamless(泊松无缝克隆)/multiband(多频段金字塔融合);`feather_px`、`edge_erode_px` 控制过渡细节
|
||||||
|
(feather_px 仅 feather/alpha_gradient 用;multiband 用 `mb_levels` 控制金字塔层数 2~6)。
|
||||||
|
`color_match=true` 时先在遮罩区做 Reinhard 颜色统计迁移,消除生成图与原图的整体色差
|
||||||
|
(对 feather/alpha_gradient/multiband 有效;seamless 自带色彩调和,自动跳过)。
|
||||||
|
|
||||||
{_image_fields_desc}
|
{_image_fields_desc}
|
||||||
|
|
||||||
@@ -1294,13 +1297,17 @@ async def hairline_grow(
|
|||||||
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)"),
|
||||||
mask_type: str = Form(default="eroded", description="遮罩类型:eroded(内缩) | closed(闭合区域)(默认 eroded)"),
|
mask_type: str = Form(default="eroded", description="遮罩类型:eroded(内缩) | closed(闭合区域) | pushed(发际线外推)(默认 eroded)"),
|
||||||
erode_cm: float = Form(default=1.2, description="遮罩外缘朝中心151内缩距离(厘米,同接口9),默认 1.2"),
|
erode_cm: float = Form(default=1.2, description="遮罩外缘朝中心151内缩距离(厘米,同接口9),默认 1.2"),
|
||||||
|
hairline_push_cm: float = Form(default=1.0, description="发际线外推距离(厘米,往头发方向推进;仅 mask_type=pushed 生效),默认 1.0"),
|
||||||
|
hairline_edge: str = Form(default="column", description="发际线提取方式:column(逐列最低点) | contour(形态学轮廓)(仅 pushed 生效),默认 column"),
|
||||||
swap_mode: str = Form(default="ext_mask", description="换发型取图模式:ext_mask(改造换发型用接口9遮罩) | as_is(不改换发型,贴回再裁)(默认 ext_mask)"),
|
swap_mode: str = Form(default="ext_mask", description="换发型取图模式:ext_mask(改造换发型用接口9遮罩) | as_is(不改换发型,贴回再裁)(默认 ext_mask)"),
|
||||||
blend_method: str = Form(default="feather", description="接缝融合:feather(高斯羽化) | alpha_gradient(距离渐变) | seamless(泊松无缝)(默认 feather)"),
|
blend_method: str = Form(default="feather", description="接缝融合:feather(高斯羽化) | alpha_gradient(距离渐变) | seamless(泊松无缝) | multiband(多频段金字塔)(默认 feather)"),
|
||||||
feather_px: int = Form(default=15, description="羽化/渐变过渡宽度(像素),默认 15"),
|
feather_px: int = Form(default=15, description="羽化/渐变过渡宽度(像素),默认 15"),
|
||||||
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"),
|
||||||
|
color_match: bool = Form(default=False, description="融合前对生成图做 Reinhard 颜色校正(消除整体色差,seamless 下自动跳过),默认 false"),
|
||||||
|
mb_levels: int = Form(default=5, description="multiband 多频段金字塔层数(2~6,越大低频色差抹得越宽,仅 multiband 生效),默认 5"),
|
||||||
):
|
):
|
||||||
"""接口11:发际线生发 + 分步可视化"""
|
"""接口11:发际线生发 + 分步可视化"""
|
||||||
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
|
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
|
||||||
@@ -1314,21 +1321,124 @@ async def hairline_grow(
|
|||||||
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_grow, NoFaceError, SwapError
|
||||||
|
from uuid import uuid4 as _uuid4
|
||||||
|
rid = _uuid4().hex[:8]
|
||||||
|
logger.info("[%s] 接口11 收到请求: mask_type=%s hairline_push_cm=%s hairline_edge=%s",
|
||||||
|
rid, mask_type, hairline_push_cm, hairline_edge)
|
||||||
try:
|
try:
|
||||||
data = await run_in_threadpool(
|
data = await run_in_threadpool(
|
||||||
generate_hairline_grow, image, hairline_id, is_hr, seg_model,
|
generate_hairline_grow, image, hairline_id, is_hr, seg_model,
|
||||||
mask_type, erode_cm, swap_mode, blend_method, feather_px, edge_erode_px,
|
mask_type, erode_cm, swap_mode, blend_method, feather_px, edge_erode_px,
|
||||||
denoising_strength, gen_backend, hairgrow_strength)
|
denoising_strength, gen_backend, hairgrow_strength, color_match, mb_levels,
|
||||||
|
hairline_push_cm=hairline_push_cm, hairline_edge=hairline_edge, 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}")
|
||||||
|
logger.info("[%s] 接口11 成功返回", rid)
|
||||||
return ok(data)
|
return ok(data)
|
||||||
except Exception as ex: # noqa: BLE001
|
except Exception as ex: # noqa: BLE001
|
||||||
logger.exception("接口11 处理异常")
|
logger.exception("接口11 处理异常")
|
||||||
return err(1007, f"处理失败:{ex}")
|
return err(1007, f"处理失败:{ex}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 接口 12:发际线生发(接口11 固定参数精简版)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app.post(
|
||||||
|
"/api/v1/hairline/grow_v2",
|
||||||
|
summary="接口12 发际线生发(固定金字塔融合,仅返回最终图)",
|
||||||
|
tags=["生发"],
|
||||||
|
description=f"""
|
||||||
|
接口11 的固定参数精简版,适合生产直调。与接口11 共用同一管线,区别仅在于:
|
||||||
|
|
||||||
|
- **固定** `blend_method=multiband`(多频段金字塔融合)、`mb_levels=5`、`erode_cm=0.6`
|
||||||
|
(外缘朝中心151 内缩 0.6cm)。这三项不可调,故本接口不暴露。
|
||||||
|
- **返回值精简**:只返回 `final_base64`(最终合成图),不再附带接口11 的分步可视化。
|
||||||
|
|
||||||
|
其余参数(hairline_id、seg_model、gen_backend、is_hr、denoising_strength、color_match、
|
||||||
|
edge_erode_px 等)仍保留为可选 Form,调用方可按需覆盖,未传则用接口11 同款默认值。
|
||||||
|
|
||||||
|
{_image_fields_desc}
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
async def hairline_grow_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_tuoyuan)"),
|
||||||
|
gen_backend: str = Form(default="swaphair", description="生成后端:swaphair(换发型LoRA) | hairgrow(区域生发inpaint,压低发际线)(默认 swaphair)"),
|
||||||
|
hairgrow_strength: float = Form(default=0.75, description="区域生发强度(仅 hairgrow 后端),默认 0.75"),
|
||||||
|
is_hr: bool = Form(default=False, description="高清模式(换发型输出 1152×1536,否则 576×768)"),
|
||||||
|
seg_model: str = Form(default="segformer", description="头发分割模型:bisenet | segformer(默认 segformer)"),
|
||||||
|
mask_type: str = Form(default="eroded", description="遮罩类型:eroded(内缩) | closed(闭合区域) | pushed(发际线外推)(默认 eroded)"),
|
||||||
|
swap_mode: str = Form(default="ext_mask", description="换发型取图模式:ext_mask(改造换发型用接口9遮罩) | as_is(不改换发型,贴回再裁)(默认 ext_mask)"),
|
||||||
|
feather_px: int = Form(default=15, description="羽化/渐变过渡宽度(像素,本接口固定 multiband 故不生效,仅留作兼容)"),
|
||||||
|
edge_erode_px: int = Form(default=3, description="贴图前遮罩内缩像素(防边缘露皮/光晕),默认 3"),
|
||||||
|
denoising_strength: float = Form(default=0.6, description="换发型 webui 重绘强度(越大生发越激进),默认 0.6"),
|
||||||
|
color_match: bool = Form(default=False, description="融合前对生成图做 Reinhard 颜色校正(消除整体色差),默认 false"),
|
||||||
|
hairline_push_cm: float = Form(default=1.0, description="发际线外推距离(厘米,仅 mask_type=pushed 生效),默认 1.0"),
|
||||||
|
hairline_edge: str = Form(default="column", description="发际线提取方式:column | contour(仅 pushed 生效),默认 column"),
|
||||||
|
):
|
||||||
|
"""接口12:发际线生发(固定 multiband/mb_levels=5/erode_cm=0.6,仅返回最终图)。"""
|
||||||
|
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_grow, NoFaceError, SwapError
|
||||||
|
try:
|
||||||
|
# 固定三项:blend_method=multiband、mb_levels=5、erode_cm=0.6
|
||||||
|
data = await run_in_threadpool(
|
||||||
|
generate_hairline_grow, image, hairline_id, is_hr, seg_model,
|
||||||
|
mask_type, 0.6, swap_mode, "multiband", feather_px, edge_erode_px,
|
||||||
|
denoising_strength, gen_backend, hairgrow_strength, color_match, 5,
|
||||||
|
hairline_push_cm=hairline_push_cm, hairline_edge=hairline_edge)
|
||||||
|
except NoFaceError:
|
||||||
|
return err(1001, "无法识别人像")
|
||||||
|
except SwapError as se:
|
||||||
|
return err(1007, f"换发型失败:{se}")
|
||||||
|
# 精简返回:只取最终合成图,丢掉接口11 的全部分步可视化
|
||||||
|
return ok({
|
||||||
|
"hairline_id": data["hairline_id"],
|
||||||
|
"image_size": data["image_size"],
|
||||||
|
"final_base64": data["steps"]["final_base64"],
|
||||||
|
})
|
||||||
|
except Exception as ex: # noqa: BLE001
|
||||||
|
logger.exception("接口12 处理异常")
|
||||||
|
return err(1007, f"处理失败:{ex}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 调试:下载后端日志(接口11 遮罩计算全过程)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app.get("/api/v1/debug/hairline_log", include_in_schema=False)
|
||||||
|
async def download_hairline_log(rid: Optional[str] = None, tail: int = 500):
|
||||||
|
"""返回 /home/xsl/hair/log/hairline_grow.log 的内容。
|
||||||
|
|
||||||
|
rid 非空时只返回该 request id 相关的行;tail 限制返回最后 N 行(默认 500)。
|
||||||
|
供调试页"下载日志"按钮调用。
|
||||||
|
"""
|
||||||
|
from fastapi.responses import PlainTextResponse
|
||||||
|
log_path = "/home/xsl/hair/log/hairline_grow.log"
|
||||||
|
try:
|
||||||
|
with open(log_path, encoding="utf-8") as fh:
|
||||||
|
lines = fh.readlines()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return PlainTextResponse("(日志文件不存在,可能服务还没处理过请求)", media_type="text/plain")
|
||||||
|
if rid:
|
||||||
|
lines = [l for l in lines if f"[{rid}]" in l]
|
||||||
|
lines = lines[-tail:] if tail > 0 else lines
|
||||||
|
return PlainTextResponse("".join(lines), media_type="text/plain; charset=utf-8")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 健康检查
|
# 健康检查
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# 发际线生发遮罩算法(pushed 模式)
|
||||||
|
|
||||||
|
> 对应接口11 `/api/v1/hairline/grow`、接口12 `/api/v1/hairline/grow_v2`,`mask_type=pushed`。
|
||||||
|
> 代码:`face_analysis/hairline_grow.py`(`_extract_hairline` / `_pushed_mask` / `compute_mask`)。
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
pushed 模式是发际线生发的默认遮罩算法(接口11/12 的 `mask_type` 三选一:`eroded` / `closed` / `pushed`,当前只用 pushed)。它从头发分割结果中提取「头发/皮肤交界线」(发际线),以眉心为圆心逐点径向外推一段距离,与 baseline 组成闭合区域作为最终遮罩。这样遮罩顶部会覆盖现有头发下沿一小段,贴回生发结果时顶部与真头发重叠、过渡自然。
|
||||||
|
|
||||||
|
## 算法流程(5 步)
|
||||||
|
|
||||||
|
```
|
||||||
|
①-a baseline 分割线 ← 眉骨/glabella 关键点折线(含 151 中心点)
|
||||||
|
①-b 上半区 upper ← baseline 以上的区域(裁剪范围)
|
||||||
|
①-c 头发分割 hair_mask ← segformer/bisenet 的原始头发像素
|
||||||
|
①-f 头发/皮肤交界线 ← 逐列取头发下沿,用 baseline 水平 y 线截断
|
||||||
|
①-g 径向外推 + 闭合 ← 以 151 点为圆心逐点外推,与 baseline 组闭合区域 = 最终遮罩
|
||||||
|
```
|
||||||
|
|
||||||
|
> ①-d(填充到基线 top_fill)、①-e(闭合区域 closed)是旧 eroded/closed 模式的中间产物,pushed 模式不走这条流程,前端不展示。
|
||||||
|
|
||||||
|
### ①-a baseline 分割线
|
||||||
|
|
||||||
|
MediaPipe 人脸关键点 `[21,68,104,69,108,151,337,299,333,298,251]` 连成折线(左端 21 → 中心 151 → 右端 251),再向左右边缘水平延长。151 点(glabella/眉心)是后续径向外推的圆心。代码 `_baseline_points` / `_draw_baseline`。
|
||||||
|
|
||||||
|
### ①-b 上半区 upper
|
||||||
|
|
||||||
|
baseline 折线以上的多边形区域(`_upper_region_mask`)。作为后续裁剪范围,保证遮罩不越界到下半脸。
|
||||||
|
|
||||||
|
### ①-c 头发分割 hair_mask
|
||||||
|
|
||||||
|
segformer(默认)或 bisenet 得到的头发二值掩码。
|
||||||
|
|
||||||
|
### ①-f 头发/皮肤交界线(核心改动)
|
||||||
|
|
||||||
|
代码 `_extract_hairline`:
|
||||||
|
|
||||||
|
1. **逐列取头发下沿**:对每个 x 列,取 `hair_mask` 中最靠下的头发像素 y 坐标(`column` 模式)。
|
||||||
|
2. **baseline 水平截断**:逐列计算 baseline 折线的 y 值 `baseline_y[x]`,只保留「发际线 y < baseline_y」的列(baseline 线以上 = 额头+发际线区域;baseline 以下 = 脸下半部,丢弃)。
|
||||||
|
3. 结果 `hairline_y[x]`:长度 = 图宽的数组,baseline 以下或无头发处为 NaN。
|
||||||
|
|
||||||
|
**关键点**:截断是**水平方向**用 baseline 的 y 值切割,不是竖线、不是 baseline 的 x 范围。这样得到的是真实的头发/皮肤交界弧线。
|
||||||
|
|
||||||
|
> `mode=contour` 用 `cv2.findContours` 取轮廓代替逐列下沿,但实测它会混入头顶边缘(y 异常偏小),**推荐用 `column`(默认)**。
|
||||||
|
|
||||||
|
### ①-g 径向外推 + 闭合区域(最终遮罩)
|
||||||
|
|
||||||
|
代码 `_pushed_mask`:
|
||||||
|
|
||||||
|
1. **逐点径向外推**:圆心 = 151 点 (cx, cy)。对每个发际线有效点 (x, y),计算从圆心指向它的单位向量 `(ux, uy)`,外推后新位置 `(x + ux·push_px, y + uy·push_px)`。额头正上方的点往上推,两侧的点斜向外上推。`push_px = hairline_push_cm × px_per_cm`(默认 1cm)。
|
||||||
|
2. **按列归并 + 插值填补锯齿**:外推后新 x 坐标可能落在相邻列,逐列取最靠上的 y 作为遮罩顶界;径向归并产生的空列用线性插值填补,保证遮罩顶界连续(否则会被连通域分析切成碎片)。
|
||||||
|
3. **与 baseline 组闭合区域**:逐列从外推后发际线 `pushed_y[x]` 填充到 `baseline_y[x]`,得到遮罩。
|
||||||
|
4. **后处理**:`& upper` 去掉越界部分,`_largest_cc` 保留最大连通域。
|
||||||
|
|
||||||
|
最终遮罩 = `[径向外推发际线 → baseline]` 之间的闭合区域,顶部含现有头发下沿约 push_cm,底部到 baseline。
|
||||||
|
|
||||||
|
## 关键参数
|
||||||
|
|
||||||
|
| 参数 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `mask_type` | `eroded`(接口默认)/ `pushed`(当前推荐) | pushed 走上述流程;eroded/closed 走旧的 top_fill→closed/eroded 流程 |
|
||||||
|
| `hairline_push_cm` | 1.0 | 发际线径向外推距离(厘米),= push_px / px_per_cm。`px_per_cm` 由虹膜直径标定 |
|
||||||
|
| `hairline_edge` | `column` | 发际线提取方式:`column`(逐列下沿,推荐)/ `contour`(轮廓,易混入头顶) |
|
||||||
|
|
||||||
|
## 与旧模式(eroded/closed)的区别
|
||||||
|
|
||||||
|
| | eroded/closed | pushed(当前) |
|
||||||
|
|---|---|---|
|
||||||
|
| 遮罩顶界 | top_fill(头发向下填充含额头)外缘内缩 | 头发/皮肤交界线 径向外推 |
|
||||||
|
| 是否用 baseline 截断 | 用 baseline 组上半区 upper | 用 baseline 水平 y 线截断发际线 + 作遮罩底界 |
|
||||||
|
| 遮罩形状 | 整个额头闭合区域 | 发际线附近一带(顶部覆盖现有头发 push_cm) |
|
||||||
|
|
||||||
|
## 调试
|
||||||
|
|
||||||
|
- 调试页:`http://<host>:8187/static/test_interface11_debug.html`(带前后端日志面板、下载日志按钮)
|
||||||
|
- 后端日志:`/home/xsl/hair/log/hairline_grow.log`(按 `[rid]` 关联一次请求),下载接口 `/api/v1/debug/hairline_log?rid=<id>&tail=500`
|
||||||
|
- 可视化步骤:①-a baseline / ①-b upper / ①-c 头发分割 / ①-f 交界线 / ①-g 外推+遮罩 / 最终遮罩 / 生成 / 贴回 / 融合
|
||||||
+419
-13
@@ -14,12 +14,16 @@
|
|||||||
- as_is:不改 change_hair,swapHair 用它自己的内部遮罩,贴回时再裁到接口9 遮罩。
|
- as_is:不改 change_hair,swapHair 用它自己的内部遮罩,贴回时再裁到接口9 遮罩。
|
||||||
3. 严格按接口9 遮罩把生成图贴回原图(遮罩外=原图,纹丝不动)。
|
3. 严格按接口9 遮罩把生成图贴回原图(遮罩外=原图,纹丝不动)。
|
||||||
4. 融合接缝:blend_method 选 feather(高斯羽化) / alpha_gradient(距离变换内渐变) /
|
4. 融合接缝:blend_method 选 feather(高斯羽化) / alpha_gradient(距离变换内渐变) /
|
||||||
seamless(泊松无缝克隆);feather_px、edge_erode_px 控制过渡细节。
|
seamless(泊松无缝克隆) / multiband(多频段金字塔融合);feather_px、edge_erode_px
|
||||||
|
控制过渡细节(feather_px 仅 feather/alpha_gradient 用;multiband 用 mb_levels 控制金字塔层数)。
|
||||||
|
可选 color_match=True 先在遮罩区做 Reinhard 颜色统计迁移,消除生成图与原图
|
||||||
|
的整体色差(对 feather/alpha_gradient/multiband 有效;seamless 自带色彩调和,自动跳过)。
|
||||||
|
|
||||||
对外返回每一步可视化(base64,data URI),供测试页逐步展示。经网关时 *_base64 字段会被
|
对外返回每一步可视化(base64,data URI),供测试页逐步展示。经网关时 *_base64 字段会被
|
||||||
落盘改写为 *_url。
|
落盘改写为 *_url。
|
||||||
"""
|
"""
|
||||||
import base64
|
import base64
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -39,8 +43,18 @@ from face_analysis.head_mask import (
|
|||||||
_erode,
|
_erode,
|
||||||
_largest_cc,
|
_largest_cc,
|
||||||
_overlay,
|
_overlay,
|
||||||
|
_draw_baseline,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 调试日志:写 /home/xsl/hair/log/hairline_grow.log,每个步骤详细记录
|
||||||
|
_LOG_DIR = "/home/xsl/hair/log"
|
||||||
|
os.makedirs(_LOG_DIR, exist_ok=True)
|
||||||
|
logger = logging.getLogger("hairline_grow")
|
||||||
|
_log_fh = logging.FileHandler(os.path.join(_LOG_DIR, "hairline_grow.log"), encoding="utf-8")
|
||||||
|
_log_fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
|
||||||
|
logger.addHandler(_log_fh)
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
# change_hair 服务地址(可用环境变量覆盖)
|
# change_hair 服务地址(可用环境变量覆盖)
|
||||||
SWAP_URL = os.getenv("SWAP_HAIR_URL", "http://127.0.0.1:8801/api/swapHair/v1")
|
SWAP_URL = os.getenv("SWAP_HAIR_URL", "http://127.0.0.1:8801/api/swapHair/v1")
|
||||||
HAIRGROW_URL = os.getenv("HAIR_GROW_URL", "http://127.0.0.1:8801/api/hairGrow/v1")
|
HAIRGROW_URL = os.getenv("HAIR_GROW_URL", "http://127.0.0.1:8801/api/hairGrow/v1")
|
||||||
@@ -55,9 +69,11 @@ DEFAULTS = {
|
|||||||
"swap_mode": "ext_mask", # ext_mask | as_is(仅 swaphair)
|
"swap_mode": "ext_mask", # ext_mask | as_is(仅 swaphair)
|
||||||
"denoising_strength": 0.6, # 仅 swaphair
|
"denoising_strength": 0.6, # 仅 swaphair
|
||||||
"hairgrow_strength": 0.75, # 仅 hairgrow
|
"hairgrow_strength": 0.75, # 仅 hairgrow
|
||||||
"blend_method": "feather", # feather | alpha_gradient | seamless
|
"blend_method": "feather", # feather | alpha_gradient | seamless | multiband
|
||||||
"feather_px": 15,
|
"feather_px": 15,
|
||||||
"edge_erode_px": 3,
|
"edge_erode_px": 3,
|
||||||
|
"color_match": False, # True 时对生成图做 Reinhard 颜色校正(seamless 下自动跳过)
|
||||||
|
"mb_levels": 5, # multiband 金字塔层数(2~6,越大色差抹得越宽)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -89,16 +105,196 @@ def _gray_b64(gray_float):
|
|||||||
# 步骤1:接口9 头发遮罩(复用 head_mask 构件)
|
# 步骤1:接口9 头发遮罩(复用 head_mask 构件)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm):
|
def _extract_hairline(hair_mask, upper=None, mode="column", rid="",
|
||||||
|
baseline_pts=None, band_expand_px=0):
|
||||||
|
"""提取头发/皮肤交界线(头发区域内轮廓朝脸一侧),用 baseline 水平 y 线截断。
|
||||||
|
|
||||||
|
直接取头发区域的最大轮廓(头发/皮肤交界),逐列取最靠下的边界点作为该列发际线 y。
|
||||||
|
然后用 baseline 的 y 值做水平截断:只保留每列发际线 y < baseline_y 的部分
|
||||||
|
(baseline 线以上=额头+发际线区域;baseline 以下=脸下半部,丢弃)。无任何竖线。
|
||||||
|
|
||||||
|
upper:兼容旧签名保留,不参与计算。
|
||||||
|
baseline_pts:baseline 关键点,其折线 y 值用于水平截断。
|
||||||
|
band_expand_px:兼容签名,不再使用(已不竖向截断)。
|
||||||
|
mode:contour(轮廓,推荐)| column(逐列下沿,兜底)。
|
||||||
|
返回 (hairline_y, lo, hi) —— hairline_y: 长度=w 的 y 数组(baseline 以下或无轮廓处置 NaN)。
|
||||||
|
lo/hi: 发际线有效范围的首末列(用于 _pushed_mask 限定填充范围)。
|
||||||
|
"""
|
||||||
|
lg = lambda msg: logger.info("[%s] %s", rid, msg) if rid else None
|
||||||
|
h, w = hair_mask.shape
|
||||||
|
col_down = np.full(w, -1, dtype=np.int32)
|
||||||
|
|
||||||
|
if mode == "contour":
|
||||||
|
mask_u8 = hair_mask.astype(np.uint8)
|
||||||
|
if mask_u8.sum() > 0:
|
||||||
|
cnts, _ = cv2.findContours(mask_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
lg(f"_extract_hairline contour: 轮廓数={len(cnts) if cnts else 0}")
|
||||||
|
if cnts:
|
||||||
|
big = max(cnts, key=cv2.contourArea)
|
||||||
|
lg(f" 最大轮廓面积={cv2.contourArea(big):.0f}")
|
||||||
|
for pt in big[:, 0]:
|
||||||
|
x, y = int(pt[0]), int(pt[1])
|
||||||
|
if y > col_down[x]:
|
||||||
|
col_down[x] = y
|
||||||
|
else: # column(兜底:逐列下沿,与 contour 结果接近)
|
||||||
|
cols = np.where(hair_mask.any(axis=0))[0]
|
||||||
|
lg(f"_extract_hairline column: 有头发的列数={len(cols)}/{w}")
|
||||||
|
for x in cols:
|
||||||
|
col_down[x] = int(np.where(hair_mask[:, x])[0].max())
|
||||||
|
|
||||||
|
valid = col_down >= 0
|
||||||
|
if valid.sum() < 2:
|
||||||
|
lg(f" 警告: 有效列<2,退化为空发际线")
|
||||||
|
nan = np.full(w, np.nan)
|
||||||
|
return nan, 0, w - 1
|
||||||
|
xs = np.where(valid)[0]
|
||||||
|
ys = col_down[valid].astype(np.float64)
|
||||||
|
raw = np.interp(np.arange(w), xs, ys)
|
||||||
|
raw = np.clip(raw, 0, h - 1)
|
||||||
|
|
||||||
|
# baseline 每列的 y(水平截断线:发际线 y 必须 < baseline_y 才保留)
|
||||||
|
if baseline_pts is not None and len(baseline_pts) >= 2:
|
||||||
|
x0, y0 = baseline_pts[0]
|
||||||
|
x1, y1 = baseline_pts[-1]
|
||||||
|
chain_x = np.array([0] + [p[0] for p in baseline_pts] + [w - 1])
|
||||||
|
chain_y = np.array([y0] + [p[1] for p in baseline_pts] + [y1])
|
||||||
|
baseline_y = np.interp(np.arange(w), chain_x, chain_y)
|
||||||
|
# 水平截断:只保留发际线在 baseline 以上(y 更小)的列
|
||||||
|
keep = raw < baseline_y
|
||||||
|
out = np.where(keep, raw, np.nan)
|
||||||
|
valid_cols = np.where(keep)[0]
|
||||||
|
lo = int(valid_cols.min()) if len(valid_cols) else 0
|
||||||
|
hi = int(valid_cols.max()) if len(valid_cols) else w - 1
|
||||||
|
lg(f" baseline 水平截断: baseline_y范围[{int(baseline_y.min())},{int(baseline_y.max())}] "
|
||||||
|
f"保留{keep.sum()}列 → 发际线范围[{lo},{hi}]")
|
||||||
|
else:
|
||||||
|
out = raw
|
||||||
|
lo, hi = 0, w - 1
|
||||||
|
lg(f" 无 baseline,取全宽")
|
||||||
|
|
||||||
|
return out, lo, hi
|
||||||
|
|
||||||
|
|
||||||
|
def _pushed_mask(hair_mask, upper, baseline_pts, push_px, mode, rid="",
|
||||||
|
center=None, band_expand_px=0):
|
||||||
|
"""发际线外推遮罩:发际线(仅额带凹处)以眉心为圆心逐点径向外推 push_px,
|
||||||
|
与 baseline 组闭合区域。
|
||||||
|
|
||||||
|
center: 圆心 (cx, cy),一般取 151 点(眉心)。
|
||||||
|
band_expand_px: 额带边界(黄竖线)两侧再外扩的像素数。
|
||||||
|
返回 (mask_bool, hairline_y, pushed_y, lo, hi):
|
||||||
|
hairline_y/pushed_y —— 长度=w 的 y 数组(额带外 NaN),每列发际线下沿/外推后下沿。
|
||||||
|
注:径向外推后,同一列可能出现多个外推点,这里按列取最靠上的作为遮罩顶界。
|
||||||
|
lo/hi —— 扩展后的额带左右边界列。
|
||||||
|
"""
|
||||||
|
lg = lambda msg: logger.info("[%s] %s", rid, msg) if rid else None
|
||||||
|
h, w = hair_mask.shape
|
||||||
|
# 截断完全交给 _extract_hairline(用 baseline 范围 + 两侧外扩)
|
||||||
|
hairline_y, lo, hi = _extract_hairline(hair_mask, upper, mode, rid=rid,
|
||||||
|
baseline_pts=baseline_pts, band_expand_px=band_expand_px)
|
||||||
|
valid = ~np.isnan(hairline_y)
|
||||||
|
hairline_y = np.where(valid, np.clip(hairline_y, 0, h - 1), np.nan).astype(np.float64)
|
||||||
|
valid = ~np.isnan(hairline_y)
|
||||||
|
|
||||||
|
lg(f"_pushed_mask: push_px={push_px} 额带(baseline截断)[{lo},{hi}] 圆心={center}")
|
||||||
|
|
||||||
|
# 逐点径向外推:每个发际线点沿「从圆心指向它」的方向往外推 push_px。
|
||||||
|
# 外推后新位置可能不在原列,按列收集所有外推点取最靠上的 y 作为该列遮罩顶界。
|
||||||
|
pushed_y = np.full(w, np.nan)
|
||||||
|
if center is not None and valid.any():
|
||||||
|
cx, cy = float(center[0]), float(center[1])
|
||||||
|
xs = np.where(valid)[0]
|
||||||
|
ys = hairline_y[valid]
|
||||||
|
# 每个点的径向方向(从圆心指向该点),单位向量
|
||||||
|
dx = xs - cx
|
||||||
|
dy = ys - cy
|
||||||
|
dist = np.sqrt(dx * dx + dy * dy)
|
||||||
|
dist = np.where(dist < 1e-3, 1.0, dist) # 圆心点本身防除零
|
||||||
|
ux, uy = dx / dist, dy / dist
|
||||||
|
# 外推后的新坐标
|
||||||
|
nx = xs + ux * push_px
|
||||||
|
ny = ys + uy * push_px
|
||||||
|
ny = np.clip(ny, 0, h - 1)
|
||||||
|
# 按新 x 做最近邻归并到整数列,取每列最小 ny(最靠上=遮罩顶界)
|
||||||
|
nx_int = np.clip(np.round(nx).astype(int), 0, w - 1)
|
||||||
|
for xi, yi in zip(nx_int, ny):
|
||||||
|
if np.isnan(pushed_y[xi]) or yi < pushed_y[xi]:
|
||||||
|
pushed_y[xi] = yi
|
||||||
|
lg(f" 径向外推: 推前y范围[{int(np.nanmin(ys))},{int(np.nanmax(ys))}] "
|
||||||
|
f"推后y范围[{int(np.nanmin(ny))},{int(np.nanmax(ny))}]")
|
||||||
|
# 径向归并会产生空列(锯齿),在额带 [lo,hi] 内插值填补,保证遮罩顶界连续
|
||||||
|
v3 = ~np.isnan(pushed_y)
|
||||||
|
if v3.any():
|
||||||
|
xv = np.where(v3)[0]
|
||||||
|
yv = pushed_y[v3]
|
||||||
|
pushed_y[lo:hi + 1] = np.interp(np.arange(lo, hi + 1), xv, yv)
|
||||||
|
else:
|
||||||
|
# 无圆心:退化为统一往上推
|
||||||
|
pushed_y = hairline_y - int(push_px)
|
||||||
|
v2 = ~np.isnan(pushed_y)
|
||||||
|
pushed_y = np.where(v2, np.clip(pushed_y, 0, h - 1), np.nan)
|
||||||
|
|
||||||
|
# baseline 每列的 y
|
||||||
|
x0, y0 = baseline_pts[0]
|
||||||
|
x1, y1 = baseline_pts[-1]
|
||||||
|
chain_x = np.array([0] + [p[0] for p in baseline_pts] + [w - 1])
|
||||||
|
chain_y = np.array([y0] + [p[1] for p in baseline_pts] + [y1])
|
||||||
|
baseline_y = np.interp(np.arange(w), chain_x, chain_y).astype(np.int32)
|
||||||
|
|
||||||
|
# 逐列填充:仅额带内、且 pushed_y < baseline_y 的列
|
||||||
|
mask = np.zeros((h, w), dtype=bool)
|
||||||
|
fill_valid = ~np.isnan(pushed_y)
|
||||||
|
cols = np.where(fill_valid & (pushed_y.astype(int) < baseline_y))[0]
|
||||||
|
lg(f" 有效填充列数={len(cols)} (额带内且 pushed_y<baseline_y)")
|
||||||
|
for x in cols:
|
||||||
|
mask[int(pushed_y[x]):baseline_y[x] + 1, x] = True
|
||||||
|
lg(f" 填充后 mask 像素={int(mask.sum())}")
|
||||||
|
mask = _largest_cc(mask & upper)
|
||||||
|
return mask, hairline_y, pushed_y, lo, hi
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_curve(image, y_per_col, color, thickness=3):
|
||||||
|
"""把"每列一个 y"的曲线画到图上(用于发际线/外推线可视化)。NaN 列跳过。"""
|
||||||
|
out = image.copy()
|
||||||
|
pts = []
|
||||||
|
segs = []
|
||||||
|
for x in range(len(y_per_col)):
|
||||||
|
v = y_per_col[x]
|
||||||
|
if np.isnan(v):
|
||||||
|
if len(pts) >= 2:
|
||||||
|
segs.append(np.array(pts, dtype=np.int32))
|
||||||
|
pts = []
|
||||||
|
else:
|
||||||
|
pts.append([x, int(v)])
|
||||||
|
if len(pts) >= 2:
|
||||||
|
segs.append(np.array(pts, dtype=np.int32))
|
||||||
|
for seg in segs:
|
||||||
|
cv2.polylines(out, [seg], isClosed=False, color=color, thickness=thickness, lineType=cv2.LINE_AA)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm,
|
||||||
|
hairline_push_cm=0.0, hairline_edge="column", rid=""):
|
||||||
"""算出布尔遮罩 + 可视化。
|
"""算出布尔遮罩 + 可视化。
|
||||||
|
|
||||||
seg_model: bisenet | segformer;mask_type: eroded(内缩) | closed(闭合区域未内缩)。
|
seg_model: bisenet | segformer。
|
||||||
|
mask_type: eroded(外缘内缩) | closed(闭合区域未内缩) | pushed(发际线外推)。
|
||||||
|
hairline_push_cm: 仅 pushed 模式——发际线往头发方向外推的厘米数(进入现有头发)。
|
||||||
|
hairline_edge: 仅 pushed 模式——发际线提取方式 column(逐列最低点) | contour(形态学轮廓)。
|
||||||
|
rid: 调用方的 request id,用于日志关联。
|
||||||
返回 (mask_bool, viz_dict)。
|
返回 (mask_bool, viz_dict)。
|
||||||
"""
|
"""
|
||||||
|
lg = lambda msg: logger.info("[%s] %s", rid, msg) if rid else None
|
||||||
|
lg(f"compute_mask 入参: mask_type={mask_type!r} erode_cm={erode_cm} "
|
||||||
|
f"px_per_cm={px_per_cm:.3f} hairline_push_cm={hairline_push_cm} hairline_edge={hairline_edge!r}")
|
||||||
|
|
||||||
h, w = image_bgr.shape[:2]
|
h, w = image_bgr.shape[:2]
|
||||||
r = int(round(max(0.0, erode_cm) * px_per_cm))
|
r = int(round(max(0.0, erode_cm) * px_per_cm))
|
||||||
|
lg(f"图像尺寸 {w}x{h}, erode_px={r}")
|
||||||
baseline_pts = _baseline_points(landmarks, w, h)
|
baseline_pts = _baseline_points(landmarks, w, h)
|
||||||
upper = _upper_region_mask(baseline_pts, w, h)
|
upper = _upper_region_mask(baseline_pts, w, h)
|
||||||
|
lg(f"baseline 第一点={baseline_pts[0]} 末点={baseline_pts[-1]} upper像素={int(upper.sum())}")
|
||||||
|
|
||||||
if seg_model == "bisenet":
|
if seg_model == "bisenet":
|
||||||
hair_mask = _bisenet_hair_mask(image_bgr, landmarks, w, h)
|
hair_mask = _bisenet_hair_mask(image_bgr, landmarks, w, h)
|
||||||
@@ -106,20 +302,86 @@ def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm
|
|||||||
hair_mask = _segformer_hair_mask(image_bgr)
|
hair_mask = _segformer_hair_mask(image_bgr)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"未知 seg_model: {seg_model}")
|
raise ValueError(f"未知 seg_model: {seg_model}")
|
||||||
|
lg(f"头发分割完成 seg_model={seg_model} hair_pixels={int(hair_mask.sum())}")
|
||||||
|
|
||||||
top_fill = _fill_to_baseline(hair_mask, upper) # 含额头,延伸到图底
|
top_fill = _fill_to_baseline(hair_mask, upper) # 含额头,延伸到图底
|
||||||
closed = _largest_cc(top_fill & upper) # 闭合区域:头发+额头,底=基线
|
closed = _largest_cc(top_fill & upper) # 闭合区域:头发+额头,底=基线
|
||||||
eroded = _largest_cc(_erode(top_fill, r) & upper) # 外缘内缩 r、底线不动
|
eroded = _largest_cc(_erode(top_fill, r) & upper) # 外缘内缩 r、底线不动
|
||||||
mask_bool = eroded if mask_type == "eroded" else closed
|
lg(f"旧流程: top_fill像素={int(top_fill.sum())} closed像素={int(closed.sum())} eroded像素={int(eroded.sum())}")
|
||||||
|
|
||||||
# 只输出最终遮罩(叠加图 + 纯遮罩),不展开接口9 内部子步骤
|
# pushed 模式:发际线外推遮罩(额外保留 hairline_y/pushed_y/额带边界 供可视化)
|
||||||
|
pushed_info = None
|
||||||
|
if mask_type == "pushed":
|
||||||
|
push_px = int(round(max(0.0, hairline_push_cm) * px_per_cm))
|
||||||
|
# 圆心 = 151 点(眉心)完整坐标,用于额带搜索起点 + 径向外推圆心
|
||||||
|
center = baseline_pts[5] if len(baseline_pts) > 5 else None
|
||||||
|
# 额带边界两侧各外扩 1cm(像素)
|
||||||
|
band_expand_px = int(round(1.0 * px_per_cm))
|
||||||
|
lg(f"进入 PUSHED 分支: push_px={push_px} 圆心(151)={center} band_expand={band_expand_px}px "
|
||||||
|
f"edge={hairline_edge}")
|
||||||
|
mask_bool, hairline_y, pushed_y, lo, hi = _pushed_mask(
|
||||||
|
hair_mask, upper, baseline_pts, push_px, hairline_edge, rid=rid,
|
||||||
|
center=center, band_expand_px=band_expand_px)
|
||||||
|
pushed_info = (hairline_y, pushed_y, push_px, lo, hi)
|
||||||
|
lg(f"PUSHED 结果: 额带[{lo},{hi}] mask_pixels={int(mask_bool.sum())}")
|
||||||
|
elif mask_type == "eroded":
|
||||||
|
mask_bool = eroded
|
||||||
|
lg(f"进入 ERODED 分支: 用 eroded 遮罩 pixels={int(eroded.sum())}")
|
||||||
|
else:
|
||||||
|
mask_bool = closed
|
||||||
|
lg(f"进入 CLOSED 分支: 用 closed 遮罩 pixels={int(closed.sum())}")
|
||||||
|
|
||||||
|
lg(f"最终遮罩 mask_type={mask_type} mask_pixels={int(mask_bool.sum())}")
|
||||||
|
|
||||||
|
# 遮罩计算过程可视化:
|
||||||
|
# eroded/closed 走 top_fill→closed/eroded 流程;
|
||||||
|
# pushed 走 baseline→头发分割→发际线→外推 流程,与 top_fill/closed 无关,故置空。
|
||||||
viz = {
|
viz = {
|
||||||
"erode_px": r,
|
"erode_px": r,
|
||||||
"hair_pixels": int(hair_mask.sum()),
|
"hair_pixels": int(hair_mask.sum()),
|
||||||
|
"closed_pixels": int(closed.sum()),
|
||||||
"mask_pixels": int(mask_bool.sum()),
|
"mask_pixels": int(mask_bool.sum()),
|
||||||
|
# 1. 发际线分割线(baseline):151 中心点标红,其余点标绿,黄线含左右延长线
|
||||||
|
"baseline_overlay_base64": _jpg_b64(_draw_baseline(image_bgr, baseline_pts, w)),
|
||||||
|
# 2. 分割线以上区域(upper 半区):青色叠加
|
||||||
|
"upper_overlay_base64": _jpg_b64(_overlay(image_bgr, upper, (0, 255, 255))),
|
||||||
|
# 3. 头发分割原始结果(hair_mask):绿色叠加在原图上
|
||||||
|
"hair_seg_overlay_base64": _jpg_b64(_overlay(image_bgr, hair_mask, (0, 255, 0))),
|
||||||
|
# 4. top_fill / closed —— 仅 eroded/closed 流程用;pushed 流程无关,留空
|
||||||
|
"top_fill_overlay_base64": "" if mask_type == "pushed"
|
||||||
|
else _jpg_b64(_overlay(image_bgr, top_fill, (255, 0, 0))),
|
||||||
|
"closed_overlay_base64": "" if mask_type == "pushed"
|
||||||
|
else _jpg_b64(_overlay(image_bgr, closed, (255, 0, 255))),
|
||||||
|
# 5. pushed 模式专有(发际线提取/外推)—— 非 pushed 留空
|
||||||
|
"hairline_overlay_base64": "",
|
||||||
|
"pushed_overlay_base64": "",
|
||||||
|
# —— 最终遮罩 ——
|
||||||
"mask_overlay_base64": _jpg_b64(_overlay(image_bgr, mask_bool, (0, 0, 255))),
|
"mask_overlay_base64": _jpg_b64(_overlay(image_bgr, mask_bool, (0, 0, 255))),
|
||||||
"mask_base64": _png_b64((mask_bool.astype(np.uint8)) * 255),
|
"mask_base64": _png_b64((mask_bool.astype(np.uint8)) * 255),
|
||||||
}
|
}
|
||||||
|
# pushed 模式:补充发际线提取 + 外推线可视化
|
||||||
|
if pushed_info is not None:
|
||||||
|
hairline_y, pushed_y, push_px, lo, hi = pushed_info
|
||||||
|
# ①-f 提取发际线:绿=头发下沿发际线(已用 baseline 范围截断),黄=baseline 折线(截断依据)
|
||||||
|
hl_img = _draw_baseline(image_bgr, baseline_pts, w) # 画 baseline(黄线+关键点)
|
||||||
|
hl_img = _draw_curve(hl_img, hairline_y, (0, 255, 0), 3)
|
||||||
|
viz["hairline_overlay_base64"] = _jpg_b64(hl_img)
|
||||||
|
# ①-g 外推发际线:圆心红点(151) + 原发际线(绿)+ 外推线(青)+ 遮罩(红半透明)
|
||||||
|
ps_img = _draw_curve(image_bgr.copy(), hairline_y, (0, 255, 0), 2)
|
||||||
|
ps_img = _draw_curve(ps_img, pushed_y, (0, 255, 255), 3)
|
||||||
|
# 画圆心(151 点)红点,标示径向外推的中心
|
||||||
|
if baseline_pts is not None and len(baseline_pts) > 5:
|
||||||
|
cx151, cy151 = baseline_pts[5]
|
||||||
|
cv2.circle(ps_img, (cx151, cy151), 6, (0, 0, 255), -1, cv2.LINE_AA)
|
||||||
|
cv2.putText(ps_img, "151", (cx151 + 8, cy151 - 8),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 1, cv2.LINE_AA)
|
||||||
|
ps_img = _overlay(ps_img, mask_bool, (0, 0, 255), 0.3)
|
||||||
|
viz["pushed_overlay_base64"] = _jpg_b64(ps_img)
|
||||||
|
viz["push_px"] = push_px
|
||||||
|
# 记录 viz 各字段是否非空(长度),便于排查前端取不到图的问题
|
||||||
|
viz_summary = {k: (len(v) if isinstance(v, str) and v else 0)
|
||||||
|
for k, v in viz.items() if k.endswith("_base64")}
|
||||||
|
lg(f"viz 生成完毕,各图字节长度: {viz_summary}")
|
||||||
return mask_bool, viz
|
return mask_bool, viz
|
||||||
|
|
||||||
|
|
||||||
@@ -217,6 +479,24 @@ def _call_hairgrow(image_bgr, mask_bool, strength):
|
|||||||
# 步骤3+4:按遮罩贴回 + 接缝融合
|
# 步骤3+4:按遮罩贴回 + 接缝融合
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _color_match_to_orig(swap_result, orig, mask_bool):
|
||||||
|
"""在 mask_bool 区域内做 Reinhard 颜色迁移:逐通道把 swap_result 的均值/方差对齐 orig。
|
||||||
|
|
||||||
|
遮罩外保持 swap_result 原样(不会越界污染)。返回 uint8 BGR。
|
||||||
|
"""
|
||||||
|
m = mask_bool.astype(bool)
|
||||||
|
out = swap_result.astype(np.float32).copy()
|
||||||
|
if m.sum() < 30:
|
||||||
|
return swap_result.copy()
|
||||||
|
for c in range(3):
|
||||||
|
src_pix = swap_result[..., c][m].astype(np.float32)
|
||||||
|
dst_pix = orig[..., c][m].astype(np.float32)
|
||||||
|
s_mean, s_std = src_pix.mean(), src_pix.std() + 1e-6
|
||||||
|
d_mean, d_std = dst_pix.mean(), dst_pix.std() + 1e-6
|
||||||
|
out[..., c] = (out[..., c] - s_mean) * (d_std / s_std) + d_mean
|
||||||
|
return np.clip(out, 0, 255).astype(np.uint8)
|
||||||
|
|
||||||
|
|
||||||
def _feather_alpha(mask_bool, blend_method, feather_px, edge_erode_px):
|
def _feather_alpha(mask_bool, blend_method, feather_px, edge_erode_px):
|
||||||
"""由布尔遮罩生成 0~1 的 alpha(贴图权重)。遮罩外恒为 0(原图纹丝不动)。"""
|
"""由布尔遮罩生成 0~1 的 alpha(贴图权重)。遮罩外恒为 0(原图纹丝不动)。"""
|
||||||
m = mask_bool.astype(np.uint8)
|
m = mask_bool.astype(np.uint8)
|
||||||
@@ -236,7 +516,92 @@ def _feather_alpha(mask_bool, blend_method, feather_px, edge_erode_px):
|
|||||||
return alpha
|
return alpha
|
||||||
|
|
||||||
|
|
||||||
def _composite(orig, swap_result, mask_bool, blend_method, feather_px, edge_erode_px):
|
def _multiband_alpha(mask_bool, edge_erode_px):
|
||||||
|
"""多频段融合用的二值掩码:先内缩、保证最小边距,否则最小一层金字塔会塌缩。
|
||||||
|
|
||||||
|
返回 uint8 二值 {0,255}(拉普拉斯金字塔融合要求起始掩码为二值,否则粗层会把整图混色)。
|
||||||
|
"""
|
||||||
|
m = mask_bool.astype(np.uint8) * 255
|
||||||
|
if edge_erode_px > 0:
|
||||||
|
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * edge_erode_px + 1,) * 2)
|
||||||
|
m = cv2.erode(m, k)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def _multiband_blend(orig, swap_result, mask_bool, levels, edge_erode_px):
|
||||||
|
"""多频段(拉普拉斯金字塔)融合:低频用宽窗抹色差,高频用窄窗保发丝。
|
||||||
|
|
||||||
|
levels:金字塔层数(2~6),越大则低频色差在越宽范围被抹平。
|
||||||
|
返回 uint8 BGR。
|
||||||
|
"""
|
||||||
|
m = _multiband_alpha(mask_bool, edge_erode_px)
|
||||||
|
if m.sum() < 255:
|
||||||
|
return orig.copy()
|
||||||
|
|
||||||
|
# 层数受分辨率上限约束:每层尺寸减半,最小一层至少 4px,否则金字塔塌缩
|
||||||
|
min_dim = min(orig.shape[:2])
|
||||||
|
max_by_res = int(np.floor(np.log2(min_dim / 4))) if min_dim >= 16 else 1
|
||||||
|
n = int(max(1, min(levels, max_by_res)))
|
||||||
|
if n < 2:
|
||||||
|
# 极小图退化:直接按内缩遮罩硬贴,避免单层金字塔无意义
|
||||||
|
out = orig.copy()
|
||||||
|
m_bool = _multiband_alpha(mask_bool, edge_erode_px) > 127
|
||||||
|
out[m_bool] = swap_result[m_bool]
|
||||||
|
return out
|
||||||
|
|
||||||
|
def lap_pyr(img, n):
|
||||||
|
pyr = [img.astype(np.float32)]
|
||||||
|
cur = img.astype(np.float32)
|
||||||
|
for _ in range(n):
|
||||||
|
cur = cv2.pyrDown(cur)
|
||||||
|
pyr.append(cur)
|
||||||
|
laps = [pyr[-1]]
|
||||||
|
for i in range(n, 0, -1):
|
||||||
|
size = (pyr[i - 1].shape[1], pyr[i - 1].shape[0])
|
||||||
|
up = cv2.pyrUp(pyr[i], dstsize=size)
|
||||||
|
laps.append(pyr[i - 1] - up)
|
||||||
|
return laps # [最粗层, 细节层L1, ..., 最细层Ln]
|
||||||
|
|
||||||
|
def mask_pyr(mask_u8, n):
|
||||||
|
# 起始必须二值;逐层 pyrDown 后自动变软(金字塔天然多频段软掩码)。
|
||||||
|
# 返回顺序与 lap_pyr 一致:粗 → 细。
|
||||||
|
pyr = [mask_u8.astype(np.float32) / 255.0]
|
||||||
|
cur = mask_u8.astype(np.float32) / 255.0
|
||||||
|
for _ in range(n):
|
||||||
|
cur = cv2.pyrDown(cur)
|
||||||
|
pyr.append(cur)
|
||||||
|
return list(reversed(pyr)) # 与 lap_pyr 同尺度(最粗层在前)
|
||||||
|
|
||||||
|
la = lap_pyr(orig, n)
|
||||||
|
lb = lap_pyr(swap_result, n)
|
||||||
|
ma = mask_pyr(m, n)
|
||||||
|
|
||||||
|
merged = []
|
||||||
|
for a, b, mk in zip(la, lb, ma):
|
||||||
|
m3 = mk[:, :, None]
|
||||||
|
merged.append(a * (1 - m3) + b * m3)
|
||||||
|
|
||||||
|
out = merged[0]
|
||||||
|
for i in range(1, len(merged)):
|
||||||
|
size = (merged[i].shape[1], merged[i].shape[0])
|
||||||
|
out = cv2.pyrUp(out, dstsize=size)
|
||||||
|
out = out + merged[i]
|
||||||
|
out = np.clip(out, 0, 255).astype(np.uint8)
|
||||||
|
|
||||||
|
# 契约:遮罩远区纹丝不动,但保留多频段的过渡带。多频段融合的意义就在于低频层
|
||||||
|
# (粗层)的掩码在 pyrDown/pyrUp 后向外扩散变软,形成一条随层数变宽的过渡带——
|
||||||
|
# 这条带正是 mb_levels 要控制的东西。若像旧实现那样用原始硬二值遮罩钳回,
|
||||||
|
# 过渡带会被整条抹掉(实测 levels 2↔6 边界差恒为 0),mb_levels 形同虚设。
|
||||||
|
# 故按层数膨胀出一个外缘 keep 区:keep 内允许过渡,keep 外才强制还原原图。
|
||||||
|
margin = 2 ** n # n=2→4px … n=6→64px,与粗层掩码的自然扩散宽度匹配
|
||||||
|
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * margin + 1, 2 * margin + 1))
|
||||||
|
keep = cv2.dilate(mask_bool.astype(np.uint8), k).astype(bool)
|
||||||
|
out[~keep] = orig[~keep]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _composite(orig, swap_result, mask_bool, blend_method, feather_px, edge_erode_px,
|
||||||
|
color_match=False, mb_levels=5):
|
||||||
"""把 swap_result 按遮罩贴回 orig,返回 (final_bgr, alpha_float or None)。"""
|
"""把 swap_result 按遮罩贴回 orig,返回 (final_bgr, alpha_float or None)。"""
|
||||||
if blend_method == "seamless":
|
if blend_method == "seamless":
|
||||||
m = mask_bool.astype(np.uint8)
|
m = mask_bool.astype(np.uint8)
|
||||||
@@ -250,9 +615,18 @@ def _composite(orig, swap_result, mask_bool, blend_method, feather_px, edge_erod
|
|||||||
final = cv2.seamlessClone(swap_result, orig, m * 255, center, cv2.NORMAL_CLONE)
|
final = cv2.seamlessClone(swap_result, orig, m * 255, center, cv2.NORMAL_CLONE)
|
||||||
return final, None
|
return final, None
|
||||||
|
|
||||||
|
# 颜色校正前置(seamless 自带色彩调和,已在上面提前返回;其余分支在此生效)
|
||||||
|
src = _color_match_to_orig(swap_result, orig, mask_bool) if color_match else swap_result
|
||||||
|
|
||||||
|
if blend_method == "multiband":
|
||||||
|
final = _multiband_blend(orig, src, mask_bool, mb_levels, edge_erode_px)
|
||||||
|
# 可视化用:用多频段的二值掩码做一层 alpha 标记(展示实际合成区)
|
||||||
|
alpha = (_multiband_alpha(mask_bool, edge_erode_px).astype(np.float32)) / 255.0
|
||||||
|
return final, alpha
|
||||||
|
|
||||||
alpha = _feather_alpha(mask_bool, blend_method, feather_px, edge_erode_px)
|
alpha = _feather_alpha(mask_bool, blend_method, feather_px, edge_erode_px)
|
||||||
a3 = alpha[:, :, None]
|
a3 = alpha[:, :, None]
|
||||||
final = (orig.astype(np.float32) * (1 - a3) + swap_result.astype(np.float32) * a3)
|
final = (orig.astype(np.float32) * (1 - a3) + src.astype(np.float32) * a3)
|
||||||
return np.clip(final, 0, 255).astype(np.uint8), alpha
|
return np.clip(final, 0, 255).astype(np.uint8), alpha
|
||||||
|
|
||||||
|
|
||||||
@@ -264,23 +638,33 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
|
|||||||
mask_type="eroded", erode_cm=1.2, swap_mode="ext_mask",
|
mask_type="eroded", erode_cm=1.2, swap_mode="ext_mask",
|
||||||
blend_method="feather", feather_px=15, edge_erode_px=3,
|
blend_method="feather", feather_px=15, edge_erode_px=3,
|
||||||
denoising_strength=0.6, gen_backend="swaphair",
|
denoising_strength=0.6, gen_backend="swaphair",
|
||||||
hairgrow_strength=0.75):
|
hairgrow_strength=0.75, color_match=False, mb_levels=5,
|
||||||
|
hairline_push_cm=0.0, hairline_edge="column", rid=None):
|
||||||
"""接口11 完整管线。返回可直接进 ok() 的 data dict。未检出人脸抛 NoFaceError。
|
"""接口11 完整管线。返回可直接进 ok() 的 data dict。未检出人脸抛 NoFaceError。
|
||||||
|
|
||||||
gen_backend:生成后端。swaphair=换发型LoRA(应用发际线类型);
|
rid: 调用方的 request id,用于日志关联。为 None 时自动生成。
|
||||||
hairgrow=区域生发inpaint(在遮罩内长出头发、压低发际线)。
|
|
||||||
"""
|
"""
|
||||||
|
if rid is None:
|
||||||
|
rid = uuid4().hex[:8]
|
||||||
|
logger.info("[%s] ===== generate_hairline_grow 开始 =====", rid)
|
||||||
|
logger.info("[%s] 参数: mask_type=%r erode_cm=%s blend=%s hairline_push_cm=%s hairline_edge=%r "
|
||||||
|
"seg=%s gen_backend=%s swap_mode=%s", rid, mask_type, erode_cm, blend_method,
|
||||||
|
hairline_push_cm, hairline_edge, seg_model, gen_backend, swap_mode)
|
||||||
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:
|
||||||
|
logger.warning("[%s] 未检出人脸", rid)
|
||||||
raise NoFaceError()
|
raise NoFaceError()
|
||||||
px_per_cm = estimate_scale_factor(landmarks, w, h)
|
px_per_cm = estimate_scale_factor(landmarks, w, h)
|
||||||
|
logger.info("[%s] 人脸检出 px_per_cm=%.3f 图尺寸=%dx%d", rid, px_per_cm, w, h)
|
||||||
|
|
||||||
# 步骤1:接口9 遮罩
|
# 步骤1:接口9 遮罩
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
mask_bool, mask_viz = compute_mask(
|
mask_bool, mask_viz = compute_mask(
|
||||||
image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm)
|
image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm,
|
||||||
|
hairline_push_cm=hairline_push_cm, hairline_edge=hairline_edge, rid=rid)
|
||||||
t_mask = time.time() - t0
|
t_mask = time.time() - t0
|
||||||
|
logger.info("[%s] 步骤1 遮罩完成 耗时=%dms mask_pixels=%d", rid, int(t_mask*1000), int(mask_bool.sum()))
|
||||||
|
|
||||||
# 步骤2:生成(按后端)
|
# 步骤2:生成(按后端)
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
@@ -298,7 +682,8 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
|
|||||||
# 步骤4:接缝融合
|
# 步骤4:接缝融合
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
final, alpha = _composite(
|
final, alpha = _composite(
|
||||||
image_bgr, swap_result, mask_bool, blend_method, feather_px, edge_erode_px)
|
image_bgr, swap_result, mask_bool, blend_method, feather_px, edge_erode_px,
|
||||||
|
color_match=color_match, mb_levels=mb_levels)
|
||||||
t_blend = time.time() - t0
|
t_blend = time.time() - t0
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@@ -313,10 +698,15 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
|
|||||||
"blend_method": blend_method,
|
"blend_method": blend_method,
|
||||||
"feather_px": int(feather_px),
|
"feather_px": int(feather_px),
|
||||||
"edge_erode_px": int(edge_erode_px),
|
"edge_erode_px": int(edge_erode_px),
|
||||||
|
"color_match": bool(color_match) and blend_method != "seamless",
|
||||||
|
"mb_levels": int(mb_levels),
|
||||||
|
"hairline_push_cm": round(float(hairline_push_cm), 2),
|
||||||
|
"hairline_edge": hairline_edge,
|
||||||
"denoising_strength": round(float(denoising_strength), 3),
|
"denoising_strength": round(float(denoising_strength), 3),
|
||||||
"px_per_cm": round(float(px_per_cm), 4),
|
"px_per_cm": round(float(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"],
|
||||||
"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": {
|
||||||
@@ -326,6 +716,16 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
|
|||||||
},
|
},
|
||||||
"steps": {
|
"steps": {
|
||||||
"input_base64": _jpg_b64(image_bgr),
|
"input_base64": _jpg_b64(image_bgr),
|
||||||
|
# 遮罩计算全过程(接口9 子步骤)
|
||||||
|
"baseline_overlay_base64": mask_viz["baseline_overlay_base64"],
|
||||||
|
"upper_overlay_base64": mask_viz["upper_overlay_base64"],
|
||||||
|
"hair_seg_overlay_base64": mask_viz["hair_seg_overlay_base64"],
|
||||||
|
"top_fill_overlay_base64": mask_viz["top_fill_overlay_base64"],
|
||||||
|
"closed_overlay_base64": mask_viz["closed_overlay_base64"],
|
||||||
|
# pushed 模式专有(非 pushed 时为空串)
|
||||||
|
"hairline_overlay_base64": mask_viz["hairline_overlay_base64"],
|
||||||
|
"pushed_overlay_base64": mask_viz["pushed_overlay_base64"],
|
||||||
|
# 最终遮罩
|
||||||
"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(swap_result),
|
||||||
@@ -333,5 +733,11 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
|
|||||||
"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(final),
|
||||||
},
|
},
|
||||||
|
"_rid": rid, # 调试用:返回本次请求的日志关联 id
|
||||||
}
|
}
|
||||||
|
# 记录 steps 各图字段是否非空,供排查前端取图问题
|
||||||
|
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_grow 完成 =====", rid)
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""批量调用接口12(/api/v1/hairline/grow_v2)生成对比素材。
|
||||||
|
|
||||||
|
20 张女生照片 × 5 种发际线发型 × {高清, 非高清} = 200 张输出。
|
||||||
|
并发 4,失败的跳过并记录原因。结果图落盘到 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 = [(True, "hr"), (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": True, "tag": "hr"}, {"is_hr": False, "tag": "nohr"}],
|
||||||
|
"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()
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
"""根据 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:140px repeat({len(hairstyles)*2}, 1fr);
|
||||||
|
gap:8px; align-items:start; }}
|
||||||
|
.col-hdr {{ font-size:12px; color:var(--muted); text-align:center; padding:6px 4px;
|
||||||
|
border-bottom:1px solid var(--border); }}
|
||||||
|
.row-lbl {{ font-size:13px; color:var(--accent); padding:8px 4px;
|
||||||
|
border-right:1px solid var(--border); }}
|
||||||
|
.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">固定参数:blend_method=multiband · mb_levels=5 · erode_cm=0.6 · 生成后端=swaphair</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 种发际线发型,每种发型分 '
|
||||||
|
'<span class="tag">高清 is_hr=true</span>'
|
||||||
|
'<span class="tag no">非高清 is_hr=false</span> 两列。</div>')
|
||||||
|
|
||||||
|
# 每张脸一个区块
|
||||||
|
for stem in faces:
|
||||||
|
orig_rel = f"orig/{stem}.jpg"
|
||||||
|
parts.append('<div class="face-block">')
|
||||||
|
parts.append(f' <div class="face-head">'
|
||||||
|
f'<img class="orig-thumb zoomable" src="{url(orig_rel)}" '
|
||||||
|
f'data-full="{url(orig_rel)}" alt="原图 {html.escape(stem)}">'
|
||||||
|
f'<h2>{html.escape(stem)}</h2>'
|
||||||
|
f'</div>')
|
||||||
|
# 网格表头
|
||||||
|
parts.append('<div class="grid">')
|
||||||
|
parts.append('<div class="col-hdr"></div>')
|
||||||
|
for h in hairstyles:
|
||||||
|
parts.append(f'<div class="col-hdr" style="grid-column:span 2;">{html.escape(h["cn"])}<br><span style="opacity:.6">{html.escape(h["id"])}</span></div>')
|
||||||
|
# 行:原图占满左侧第一列的高度由第一行承载;每行 = 一个 hr 选项
|
||||||
|
for hro in hr_opts:
|
||||||
|
hr_tag = hro["tag"]
|
||||||
|
is_hr = hro["is_hr"]
|
||||||
|
tag_cls = "" if is_hr else " no"
|
||||||
|
tag_txt = "高清" if is_hr else "非高清"
|
||||||
|
parts.append(f'<div class="row-lbl">{tag_txt}<span class="tag{tag_cls}">{hr_tag}</span></div>')
|
||||||
|
for h in hairstyles:
|
||||||
|
r = idx.get((stem, h["id"], hr_tag))
|
||||||
|
if r and r["ok"]:
|
||||||
|
ms = r["ms"]
|
||||||
|
cap = f"{html.escape(h['cn'])} · {tag_txt} · {ms}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"])} {hr_tag}">'
|
||||||
|
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>高清</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["hr_tag"]}</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> ·
|
||||||
|
固定 multiband / mb_levels=5 / erode_cm=0.6 ·
|
||||||
|
数据源 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()
|
||||||
+100
-14
@@ -85,7 +85,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pf">
|
<div class="pf" data-show="gen:swaphair">
|
||||||
<label>发际线类型 ID <span class="desc">= change_hair hair_id(仅swaphair)</span></label>
|
<label>发际线类型 ID <span class="desc">= change_hair hair_id(仅swaphair)</span></label>
|
||||||
<select id="hairlineId">
|
<select id="hairlineId">
|
||||||
<option value="chang_zhixian">chang_zhixian(直线)</option>
|
<option value="chang_zhixian">chang_zhixian(直线)</option>
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pf">
|
<div class="pf" data-show="gen:swaphair">
|
||||||
<label>swap_mode <span class="desc">换发型取图模式</span></label>
|
<label>swap_mode <span class="desc">换发型取图模式</span></label>
|
||||||
<select id="swapMode">
|
<select id="swapMode">
|
||||||
<option value="ext_mask">ext_mask(改造换发型,用接口9遮罩重绘)</option>
|
<option value="ext_mask">ext_mask(改造换发型,用接口9遮罩重绘)</option>
|
||||||
@@ -117,6 +117,7 @@
|
|||||||
<select id="maskType">
|
<select id="maskType">
|
||||||
<option value="eroded">eroded(外缘内缩,默认)</option>
|
<option value="eroded">eroded(外缘内缩,默认)</option>
|
||||||
<option value="closed">closed(闭合区域,未内缩)</option>
|
<option value="closed">closed(闭合区域,未内缩)</option>
|
||||||
|
<option value="pushed">pushed(发际线外推)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -128,16 +129,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="pf" data-show="mask:pushed">
|
||||||
|
<label>hairline_push_cm <span class="desc">发际线外推距离(仅pushed)</span></label>
|
||||||
|
<div class="row">
|
||||||
|
<input type="number" id="hairlinePushCm" min="0" max="3" step="0.1" value="1.0">
|
||||||
|
<input type="range" id="hairlinePushRange" min="0" max="3" step="0.1" value="1.0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pf" data-show="mask:pushed">
|
||||||
|
<label>hairline_edge <span class="desc">发际线提取方式(仅pushed)</span></label>
|
||||||
|
<select id="hairlineEdge">
|
||||||
|
<option value="column">column(逐列最低点,稳定)</option>
|
||||||
|
<option value="contour">contour(形态学轮廓,精确)</option>
|
||||||
|
</select>
|
||||||
|
</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="feather">feather(高斯羽化,默认)</option>
|
<option value="feather">feather(高斯羽化,默认)</option>
|
||||||
<option value="alpha_gradient">alpha_gradient(距离变换内渐变)</option>
|
<option value="alpha_gradient">alpha_gradient(距离变换内渐变)</option>
|
||||||
<option value="seamless">seamless(泊松无缝克隆)</option>
|
<option value="seamless">seamless(泊松无缝克隆)</option>
|
||||||
|
<option value="multiband">multiband(多频段金字塔融合)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pf">
|
<div class="pf" data-show="blend:feather,alpha_gradient,multiband">
|
||||||
|
<label>color_match <span class="desc">颜色校正前置</span></label>
|
||||||
|
<div class="chk"><input type="checkbox" id="colorMatch"><span style="font-size:13px">融合前对生成图做 Reinhard 颜色校正(消除整体色差,seamless 自动跳过)</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pf" data-show="blend:feather,alpha_gradient">
|
||||||
<label>feather_px <span class="desc">羽化/渐变过渡宽度(px)</span></label>
|
<label>feather_px <span class="desc">羽化/渐变过渡宽度(px)</span></label>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<input type="number" id="featherPx" min="1" max="80" step="1" value="15">
|
<input type="number" id="featherPx" min="1" max="80" step="1" value="15">
|
||||||
@@ -145,6 +168,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="pf" data-show="blend:multiband">
|
||||||
|
<label>mb_levels <span class="desc">多频段金字塔层数(仅multiband)</span></label>
|
||||||
|
<div class="row">
|
||||||
|
<input type="number" id="mbLevels" min="2" max="6" step="1" value="5">
|
||||||
|
<input type="range" id="mbLevelsRange" min="2" max="6" step="1" value="5">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="pf">
|
<div class="pf">
|
||||||
<label>edge_erode_px <span class="desc">贴图前遮罩内缩(px)</span></label>
|
<label>edge_erode_px <span class="desc">贴图前遮罩内缩(px)</span></label>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -153,7 +184,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pf">
|
<div class="pf" data-show="gen:swaphair">
|
||||||
<label>denoising_strength <span class="desc">换发型重绘强度(仅swaphair)</span></label>
|
<label>denoising_strength <span class="desc">换发型重绘强度(仅swaphair)</span></label>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<input type="number" id="denoise" min="0.2" max="1.0" step="0.05" value="0.6">
|
<input type="number" id="denoise" min="0.2" max="1.0" step="0.05" value="0.6">
|
||||||
@@ -161,7 +192,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pf">
|
<div class="pf" data-show="gen:hairgrow">
|
||||||
<label>hairgrow_strength <span class="desc">区域生发强度(仅hairgrow)</span></label>
|
<label>hairgrow_strength <span class="desc">区域生发强度(仅hairgrow)</span></label>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<input type="number" id="hgStrength" min="0.2" max="1.0" step="0.05" value="0.75">
|
<input type="number" id="hgStrength" min="0.2" max="1.0" step="0.05" value="0.75">
|
||||||
@@ -169,7 +200,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pf">
|
<div class="pf" data-show="gen:swaphair">
|
||||||
<label>is_hr <span class="desc">高清模式</span></label>
|
<label>is_hr <span class="desc">高清模式</span></label>
|
||||||
<div class="chk"><input type="checkbox" id="isHr"><span style="font-size:13px">1152×1536(否则 576×768)</span></div>
|
<div class="chk"><input type="checkbox" id="isHr"><span style="font-size:13px">1152×1536(否则 576×768)</span></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,12 +250,22 @@ const ENDPOINT = '/api/v1/hairline/grow';
|
|||||||
|
|
||||||
// 分步展示的图(按算法文档 4 步;key 前缀、标题、副标题)
|
// 分步展示的图(按算法文档 4 步;key 前缀、标题、副标题)
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
{ key: 'mask_overlay', title: '① 接口9 最终遮罩(叠加)', sub: '红=遮罩区(含额头,外缘内缩)' },
|
// 遮罩计算全过程(接口9 子步骤,蓝→红)
|
||||||
{ key: 'mask', title: '① 纯遮罩', sub: '白=生成/贴回区(传给换发型作遮罩 & 贴回)' },
|
{ key: 'baseline_overlay', title: '①-a 发际线分割线', sub: '黄线=baseline(151中心点标红、其余标绿)' },
|
||||||
{ key: 'swap_raw', title: '② 生成全帧', sub: 'change_hair 生成,已对齐原图' },
|
{ key: 'upper_overlay', title: '①-b 分割线上半区', sub: '青=baseline 以上区域(裁剪范围)' },
|
||||||
{ key: 'hard_paste', title: '③ 严格按遮罩贴回', sub: '遮罩内=生成,遮罩外=原图,无融合' },
|
{ key: 'hair_seg_overlay', title: '①-c 头发分割', sub: '绿=分割模型原始头发像素(segformer/bisenet)' },
|
||||||
{ key: 'alpha', title: '④ 融合权重 alpha', sub: '羽化/渐变,白=用生成图' },
|
{ key: 'top_fill_overlay', title: '①-d 填充到基线', sub: '蓝=头发向下填充含额头(延伸到图底,未裁剪)' },
|
||||||
{ key: 'final', title: '④ 接缝融合(最终)', sub: '遮罩边缘自然过渡' },
|
{ key: 'closed_overlay', title: '①-e 闭合区域', sub: '紫=top_fill ∩ 上半区(头发+额头,底=基线)' },
|
||||||
|
// pushed 模式专有(非 pushed 模式无图)
|
||||||
|
{ key: 'hairline_overlay', title: '①-f 提取发际线', sub: '绿=头发下沿发际线 + 黄=baseline(仅pushed)' },
|
||||||
|
{ key: 'pushed_overlay', title: '①-g 外推发际线', sub: '黄=外推线(进头发push_cm) + 红=外推遮罩(仅pushed)' },
|
||||||
|
// 最终遮罩
|
||||||
|
{ key: 'mask_overlay', title: '① 最终遮罩(叠加)', sub: '红=遮罩区(含额头,外缘内缩)' },
|
||||||
|
{ key: 'mask', title: '① 纯遮罩', sub: '白=生成/贴回区(传给换发型作遮罩 & 贴回)' },
|
||||||
|
{ key: 'swap_raw', title: '② 生成全帧', sub: 'change_hair 生成,已对齐原图' },
|
||||||
|
{ key: 'hard_paste', title: '③ 严格按遮罩贴回', sub: '遮罩内=生成,遮罩外=原图,无融合' },
|
||||||
|
{ key: 'alpha', title: '④ 融合权重 alpha', sub: '羽化/渐变,白=用生成图' },
|
||||||
|
{ key: 'final', title: '④ 接缝融合(最终)', sub: '遮罩边缘自然过渡' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function $(id) { return document.getElementById(id); }
|
function $(id) { return document.getElementById(id); }
|
||||||
@@ -252,6 +293,10 @@ function renderMetrics(d) {
|
|||||||
{ label: 'seg_model', value: d.seg_model },
|
{ label: 'seg_model', value: d.seg_model },
|
||||||
{ label: 'mask_type', value: d.mask_type },
|
{ label: 'mask_type', value: d.mask_type },
|
||||||
{ label: 'blend', value: d.blend_method },
|
{ label: 'blend', value: d.blend_method },
|
||||||
|
{ label: 'color_match', value: d.color_match },
|
||||||
|
{ label: 'mb_levels', value: d.mb_levels },
|
||||||
|
{ label: 'hairline_push', value: d.hairline_push_cm + 'cm' },
|
||||||
|
{ label: 'hairline_edge', value: d.hairline_edge },
|
||||||
{ label: 'denoise', value: d.denoising_strength },
|
{ label: 'denoise', value: d.denoising_strength },
|
||||||
{ label: 'px_per_cm', value: d.px_per_cm },
|
{ label: 'px_per_cm', value: d.px_per_cm },
|
||||||
{ label: '内缩', value: d.erode_cm + 'cm/' + d.erode_px + 'px' },
|
{ label: '内缩', value: d.erode_cm + 'cm/' + d.erode_px + 'px' },
|
||||||
@@ -272,7 +317,14 @@ function renderResult(d) {
|
|||||||
$('finalOut').onclick = function(){ zoom(this.src); };
|
$('finalOut').onclick = function(){ zoom(this.src); };
|
||||||
renderMetrics(d);
|
renderMetrics(d);
|
||||||
const grid = $('stepsGrid'); grid.innerHTML = '';
|
const grid = $('stepsGrid'); grid.innerHTML = '';
|
||||||
STEPS.forEach(st => grid.appendChild(stepCard(st.title, st.sub, pick(s, st.key))));
|
// 按 mask_type 过滤步骤:pushed 只显示 baseline/upper/头发分割/发际线/外推;
|
||||||
|
// eroded/closed 只显示 baseline/upper/头发分割/填充/闭合,不显示发际线/外推。
|
||||||
|
const mt = d.mask_type;
|
||||||
|
const showKeys = new Set(['baseline_overlay','upper_overlay','hair_seg_overlay','mask_overlay','mask','swap_raw','hard_paste','alpha','final']);
|
||||||
|
if (mt === 'pushed') { showKeys.add('hairline_overlay'); showKeys.add('pushed_overlay'); }
|
||||||
|
else { showKeys.add('top_fill_overlay'); showKeys.add('closed_overlay'); }
|
||||||
|
STEPS.filter(st => showKeys.has(st.key)).forEach(st =>
|
||||||
|
grid.appendChild(stepCard(st.title, st.sub, pick(s, st.key))));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitTest() {
|
async function submitTest() {
|
||||||
@@ -293,11 +345,19 @@ async function submitTest() {
|
|||||||
form.append('seg_model', $('segModel').value);
|
form.append('seg_model', $('segModel').value);
|
||||||
form.append('mask_type', $('maskType').value);
|
form.append('mask_type', $('maskType').value);
|
||||||
form.append('erode_cm', $('erodeCm').value || '1.2');
|
form.append('erode_cm', $('erodeCm').value || '1.2');
|
||||||
|
setStatus('正在请求 mask_type=' + $('maskType').value
|
||||||
|
+ ' hairline_push_cm=' + ($('hairlinePushCm').value||'1.0')
|
||||||
|
+ ' hairline_edge=' + $('hairlineEdge').value
|
||||||
|
+ '(换发型走 GPU,约 10~15s)...', 'info');
|
||||||
form.append('swap_mode', $('swapMode').value);
|
form.append('swap_mode', $('swapMode').value);
|
||||||
form.append('blend_method', $('blendMethod').value);
|
form.append('blend_method', $('blendMethod').value);
|
||||||
form.append('feather_px', $('featherPx').value || '15');
|
form.append('feather_px', $('featherPx').value || '15');
|
||||||
form.append('edge_erode_px', $('edgeErodePx').value || '3');
|
form.append('edge_erode_px', $('edgeErodePx').value || '3');
|
||||||
form.append('denoising_strength', $('denoise').value || '0.6');
|
form.append('denoising_strength', $('denoise').value || '0.6');
|
||||||
|
form.append('color_match', $('colorMatch').checked ? 'true' : 'false');
|
||||||
|
form.append('mb_levels', $('mbLevels').value || '5');
|
||||||
|
form.append('hairline_push_cm', $('hairlinePushCm').value || '1.0');
|
||||||
|
form.append('hairline_edge', $('hairlineEdge').value);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const headers = {};
|
const headers = {};
|
||||||
@@ -337,7 +397,7 @@ function copyJson() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- 参数持久化 + 联动 ----
|
// ---- 参数持久化 + 联动 ----
|
||||||
const FIELDS = ['token','genBackend','hairlineId','swapMode','segModel','maskType','erodeCm','blendMethod','featherPx','edgeErodePx','denoise','hgStrength'];
|
const FIELDS = ['token','genBackend','hairlineId','swapMode','segModel','maskType','erodeCm','blendMethod','featherPx','edgeErodePx','denoise','hgStrength','mbLevels','hairlinePushCm','hairlineEdge'];
|
||||||
function saveField(id) { try { localStorage.setItem('if11_' + id, $(id).type === 'checkbox' ? ($(id).checked?'1':'0') : $(id).value); } catch(e){} }
|
function saveField(id) { try { localStorage.setItem('if11_' + id, $(id).type === 'checkbox' ? ($(id).checked?'1':'0') : $(id).value); } catch(e){} }
|
||||||
function linkNumRange(numId, rngId) {
|
function linkNumRange(numId, rngId) {
|
||||||
const num = $(numId), rng = $(rngId);
|
const num = $(numId), rng = $(rngId);
|
||||||
@@ -359,17 +419,43 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
try { $('isHr').checked = localStorage.getItem('if11_isHr') === '1'; } catch(e){}
|
try { $('isHr').checked = localStorage.getItem('if11_isHr') === '1'; } catch(e){}
|
||||||
$('isHr').addEventListener('change', () => saveField('isHr'));
|
$('isHr').addEventListener('change', () => saveField('isHr'));
|
||||||
|
try { $('colorMatch').checked = localStorage.getItem('if11_colorMatch') === '1'; } catch(e){}
|
||||||
|
$('colorMatch').addEventListener('change', () => saveField('colorMatch'));
|
||||||
linkNumRange('erodeCm','erodeRange');
|
linkNumRange('erodeCm','erodeRange');
|
||||||
linkNumRange('featherPx','featherRange');
|
linkNumRange('featherPx','featherRange');
|
||||||
linkNumRange('edgeErodePx','edgeErodeRange');
|
linkNumRange('edgeErodePx','edgeErodeRange');
|
||||||
linkNumRange('denoise','denoiseRange');
|
linkNumRange('denoise','denoiseRange');
|
||||||
linkNumRange('hgStrength','hgStrengthRange');
|
linkNumRange('hgStrength','hgStrengthRange');
|
||||||
|
linkNumRange('mbLevels','mbLevelsRange');
|
||||||
|
linkNumRange('hairlinePushCm','hairlinePushRange');
|
||||||
// 初始化滑块值
|
// 初始化滑块值
|
||||||
$('erodeRange').value = Math.min(3, parseFloat($('erodeCm').value)||1.2);
|
$('erodeRange').value = Math.min(3, parseFloat($('erodeCm').value)||1.2);
|
||||||
$('featherRange').value = Math.min(60, parseFloat($('featherPx').value)||15);
|
$('featherRange').value = Math.min(60, parseFloat($('featherPx').value)||15);
|
||||||
$('edgeErodeRange').value = Math.min(30, parseFloat($('edgeErodePx').value)||3);
|
$('edgeErodeRange').value = Math.min(30, parseFloat($('edgeErodePx').value)||3);
|
||||||
$('denoiseRange').value = Math.min(1.0, parseFloat($('denoise').value)||0.6);
|
$('denoiseRange').value = Math.min(1.0, parseFloat($('denoise').value)||0.6);
|
||||||
$('hgStrengthRange').value = Math.min(1.0, parseFloat($('hgStrength').value)||0.75);
|
$('hgStrengthRange').value = Math.min(1.0, parseFloat($('hgStrength').value)||0.75);
|
||||||
|
$('mbLevelsRange').value = Math.min(6, Math.max(2, parseFloat($('mbLevels').value)||5));
|
||||||
|
$('hairlinePushRange').value = Math.min(3, parseFloat($('hairlinePushCm').value)||1.0);
|
||||||
|
|
||||||
|
// 参数联动:按 gen_backend / blend_method 显隐无用参数(data-show 声明)
|
||||||
|
function applyVisibility() {
|
||||||
|
const gen = $('genBackend').value, bm = $('blendMethod').value, mt = $('maskType').value;
|
||||||
|
document.querySelectorAll('.pf[data-show]').forEach(el => {
|
||||||
|
const conds = el.dataset.show.split(',').map(s => s.trim());
|
||||||
|
let vis = true;
|
||||||
|
conds.forEach(c => {
|
||||||
|
const [k, v] = c.split(':');
|
||||||
|
if (k === 'gen' && v !== gen) vis = false;
|
||||||
|
if (k === 'blend' && v !== bm) vis = false;
|
||||||
|
if (k === 'mask' && v !== mt) vis = false;
|
||||||
|
});
|
||||||
|
el.style.display = vis ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$('genBackend').addEventListener('change', applyVisibility);
|
||||||
|
$('blendMethod').addEventListener('change', applyVisibility);
|
||||||
|
$('maskType').addEventListener('change', applyVisibility);
|
||||||
|
applyVisibility();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>接口11 调试页(带日志)— 发际线生发</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: #2563eb; color: #fff; }
|
||||||
|
.btn-primary:disabled { background: #93c5fd; 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; }
|
||||||
|
.pf .row input[type=range] { flex: 1; }
|
||||||
|
.status { padding: 10px 16px; border-radius: 8px; font-size: 14px; margin-bottom: 16px; display: none; font-family: monospace; }
|
||||||
|
.status.info { background: #dbeafe; color: #1e40af; 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(260px, 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>接口11 调试页 <span style="font-size:13px;color:#888">(带前后端日志)</span></h1>
|
||||||
|
<div class="subtitle">
|
||||||
|
独立调试页,每一步都记录日志。提交后展开"前端日志"和"后端日志",点按钮可下载。<br>
|
||||||
|
重点排查:mask_type 是否真的传成 pushed、①-f/①-g 是否有图、最终遮罩是否走了新算法。
|
||||||
|
</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>mask_type <span class="desc">遮罩类型(关键!)</span></label>
|
||||||
|
<select id="maskType">
|
||||||
|
<option value="eroded">eroded(外缘内缩)</option>
|
||||||
|
<option value="closed">closed(闭合区域)</option>
|
||||||
|
<option value="pushed">pushed(发际线外推)★</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="pf">
|
||||||
|
<label>发际线类型 ID</label>
|
||||||
|
<select id="hairlineId">
|
||||||
|
<option value="chang_zhixian">chang_zhixian(直线)</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_huaban">chang_huaban(花瓣)</option>
|
||||||
|
</select>
|
||||||
|
</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">
|
||||||
|
<label>erode_cm <span class="desc">外缘内缩(cm)</span></label>
|
||||||
|
<div class="row"><input type="number" id="erodeCm" min="0" max="5" step="0.1" value="1.2"></div>
|
||||||
|
</div>
|
||||||
|
<div class="pf" data-show="mask:pushed">
|
||||||
|
<label>hairline_push_cm <span class="desc">发际线外推(cm,仅pushed)</span></label>
|
||||||
|
<div class="row"><input type="number" id="hairlinePushCm" min="0" max="3" step="0.1" value="1.0"></div>
|
||||||
|
</div>
|
||||||
|
<div class="pf" data-show="mask:pushed">
|
||||||
|
<label>hairline_edge <span class="desc">发际线提取(仅pushed)</span></label>
|
||||||
|
<select id="hairlineEdge">
|
||||||
|
<option value="column">column(逐列最低点)</option>
|
||||||
|
<option value="contour">contour(形态学轮廓)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="pf">
|
||||||
|
<label>blend_method <span class="desc">融合算法</span></label>
|
||||||
|
<select id="blendMethod">
|
||||||
|
<option value="feather">feather</option>
|
||||||
|
<option value="alpha_gradient">alpha_gradient</option>
|
||||||
|
<option value="seamless">seamless</option>
|
||||||
|
<option value="multiband">multiband</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</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(320px, 1fr))">
|
||||||
|
<div class="step big"><div class="cap">输入原图</div><img id="finalInput"></div>
|
||||||
|
<div class="step big"><div class="cap">最终结果</div><img id="finalOut"></div>
|
||||||
|
</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';
|
||||||
|
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: 'baseline_overlay', title: '①-a 发际线分割线', sub: '黄线=baseline' },
|
||||||
|
{ key: 'upper_overlay', title: '①-b 上半区', sub: '青=baseline以上' },
|
||||||
|
{ key: 'hair_seg_overlay', title: '①-c 头发分割', sub: '绿=头发像素' },
|
||||||
|
{ key: 'top_fill_overlay', title: '①-d 填充到基线', sub: '蓝=top_fill(仅eroded/closed)' },
|
||||||
|
{ key: 'closed_overlay', title: '①-e 闭合区域', sub: '紫=closed(仅eroded/closed)' },
|
||||||
|
{ key: 'hairline_overlay', title: '①-f 头发/皮肤交界线', sub: '绿=头发内轮廓(baseline水平截断),黄=baseline折线(仅pushed)' },
|
||||||
|
{ key: 'pushed_overlay', title: '①-g 外推发际线', sub: '青=外推线(进头发push_cm),红=遮罩,绿=原发际线(仅pushed)' },
|
||||||
|
{ key: 'mask_overlay', title: '① 最终遮罩(叠加)', sub: '红=最终遮罩区' },
|
||||||
|
{ key: 'mask', title: '① 纯遮罩', sub: '白=贴回区' },
|
||||||
|
{ key: 'swap_raw', title: '② 生成全帧', sub: '换发型结果' },
|
||||||
|
{ key: 'hard_paste', title: '③ 严格贴回', sub: '遮罩内=生成,外=原图' },
|
||||||
|
{ key: 'alpha', title: '④ 融合权重', sub: '白=用生成图' },
|
||||||
|
{ key: 'final', title: '④ 接缝融合(最终)', sub: '最终输出' },
|
||||||
|
];
|
||||||
|
|
||||||
|
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 开始,mask_type=' + d.mask_type, 'info');
|
||||||
|
const s = d.steps || {};
|
||||||
|
$('finalInput').src = pick(s, 'input') || '';
|
||||||
|
$('finalInput').onclick = function(){ zoom(this.src); };
|
||||||
|
$('finalOut').src = pick(s, 'final') || '';
|
||||||
|
$('finalOut').onclick = function(){ zoom(this.src); };
|
||||||
|
const grid = $('stepsGrid'); grid.innerHTML = '';
|
||||||
|
// 按 mask_type 过滤步骤
|
||||||
|
const mt = d.mask_type;
|
||||||
|
const showKeys = new Set(['baseline_overlay','upper_overlay','hair_seg_overlay','mask_overlay','mask','swap_raw','hard_paste','alpha','final']);
|
||||||
|
if (mt === 'pushed') { showKeys.add('hairline_overlay'); showKeys.add('pushed_overlay'); }
|
||||||
|
else { showKeys.add('top_fill_overlay'); showKeys.add('closed_overlay'); }
|
||||||
|
$('stepsInfo').textContent = '(mask_type=' + mt + ',按此过滤步骤)';
|
||||||
|
STEPS.filter(st => showKeys.has(st.key)).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; }
|
||||||
|
flog('===== 提交测试 =====', 'info');
|
||||||
|
// 记录前端实际读取的每个参数值(关键诊断)
|
||||||
|
const mt = $('maskType').value;
|
||||||
|
flog('前端读取 mask_type=' + mt, 'info');
|
||||||
|
flog('前端读取 hairline_push_cm=' + ($('hairlinePushCm').value || '(默认)'), 'info');
|
||||||
|
flog('前端读取 hairline_edge=' + ($('hairlineEdge').value || '(默认)'), 'info');
|
||||||
|
flog('前端读取 hairline_id=' + $('hairlineId').value, 'info');
|
||||||
|
flog('前端读取 seg_model=' + $('segModel').value, 'info');
|
||||||
|
|
||||||
|
const btn = $('submitBtn');
|
||||||
|
btn.disabled = true; btn.textContent = '⏳ 请求中...';
|
||||||
|
setStatus('正在请求 mask_type=' + mt + ' ...', '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', 'false');
|
||||||
|
form.append('seg_model', $('segModel').value);
|
||||||
|
form.append('mask_type', mt);
|
||||||
|
form.append('erode_cm', $('erodeCm').value || '1.2');
|
||||||
|
form.append('swap_mode', 'ext_mask');
|
||||||
|
form.append('blend_method', $('blendMethod').value);
|
||||||
|
form.append('feather_px', '15');
|
||||||
|
form.append('edge_erode_px', '3');
|
||||||
|
form.append('denoising_strength', '0.6');
|
||||||
|
form.append('color_match', 'false');
|
||||||
|
form.append('mb_levels', '5');
|
||||||
|
form.append('hairline_push_cm', $('hairlinePushCm').value || '1.0');
|
||||||
|
form.append('hairline_edge', $('hairlineEdge').value);
|
||||||
|
|
||||||
|
// 记录发出去的 form 字段
|
||||||
|
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('后端返回 mask_type=' + d.mask_type + ' mask_pixels=' + d.mask_pixels, 'info');
|
||||||
|
flog('后端返回 hairline_push_cm=' + d.hairline_push_cm + ' hairline_edge=' + d.hairline_edge, 'info');
|
||||||
|
flog('后端 _rid=' + d._rid, 'info');
|
||||||
|
// 详细记录 steps 每个字段长度
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
setStatus('✅ 成功 (' + dt + 's) mask_type=' + d.mask_type + ' _rid=' + d._rid, 'success');
|
||||||
|
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 = '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 = 'backend_log_' + Date.now() + '.txt';
|
||||||
|
a.click();
|
||||||
|
} catch (err) {
|
||||||
|
flog('下载后端日志失败: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 参数联动
|
||||||
|
function applyVisibility() {
|
||||||
|
const mt = $('maskType').value;
|
||||||
|
document.querySelectorAll('.pf[data-show]').forEach(el => {
|
||||||
|
const [k, v] = el.dataset.show.split(':');
|
||||||
|
el.style.display = (k === 'mask' && v === mt) ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$('maskType').addEventListener('change', function(){ flog('mask_type 切换为: ' + this.value, 'info'); applyVisibility(); });
|
||||||
|
$('imageFile').addEventListener('change', function(){ if(this.files.length) flog('选择图片: ' + this.files[0].name, 'info'); });
|
||||||
|
|
||||||
|
flog('调试页加载完成', 'info');
|
||||||
|
flog('请选择图片,mask_type 切到 pushed,然后点提交', 'info');
|
||||||
|
applyVisibility();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user