Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff4019c570 | ||
|
|
5226e23989 | ||
|
|
c462fd3634 | ||
|
|
ab6b0cb0bb | ||
|
|
94cdfd6de5 | ||
|
|
f509fe99b4 | ||
|
|
a1d458eb20 | ||
|
|
92e628b0d5 | ||
|
|
b4714cedf3 | ||
|
|
e7b62f2b2e | ||
|
|
4291f125d4 | ||
|
|
7fc0210ce6 | ||
|
|
085ad3ced0 | ||
|
|
2993a0b948 | ||
|
|
08b31a3baa | ||
|
|
51e313e845 | ||
|
|
fbbcd48418 | ||
|
|
74ccab0ff8 |
@@ -53,3 +53,15 @@ static/report_hairline_v2.zip
|
|||||||
# local_test 运行期日志 / pid(不入 git)
|
# local_test 运行期日志 / pid(不入 git)
|
||||||
local_test/hair_service.log
|
local_test/hair_service.log
|
||||||
local_test/hair_service.pid
|
local_test/hair_service.pid
|
||||||
|
|
||||||
|
# benchmark 原始输出(含结果图+原图,体积大,不入 git)
|
||||||
|
benchmark_out/
|
||||||
|
|
||||||
|
# benchmark 部署的 HTML 报告(图片 base64 内嵌,体积大,不入 git)
|
||||||
|
static/hairstyle_thumbs/
|
||||||
|
|
||||||
|
# 网关运行期日志(不入 git)
|
||||||
|
gateway.log
|
||||||
|
|
||||||
|
# 工作流备份文件(不入 git)
|
||||||
|
*.json.bak.*
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
{
|
||||||
|
"16": {
|
||||||
|
"class_type": "UnetLoaderGGUF",
|
||||||
|
"inputs": {
|
||||||
|
"unet_name": "flux-2-klein-9b-Q4_K_M.gguf",
|
||||||
|
"weight_dtype": "fp8_e4m3fn_fast"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
"class_type": "VAELoader",
|
||||||
|
"inputs": {
|
||||||
|
"vae_name": "flux2-vae.safetensors"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"61": {
|
||||||
|
"class_type": "CLIPLoader",
|
||||||
|
"inputs": {
|
||||||
|
"clip_name": "qwen_3_8b_fp8mixed.safetensors",
|
||||||
|
"type": "flux2",
|
||||||
|
"device": "cpu"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"26": {
|
||||||
|
"class_type": "LoadImage",
|
||||||
|
"inputs": {
|
||||||
|
"image": "placeholder.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"60": {
|
||||||
|
"class_type": "JjkText",
|
||||||
|
"inputs": {
|
||||||
|
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"22": {
|
||||||
|
"class_type": "CLIPTextEncode",
|
||||||
|
"inputs": {
|
||||||
|
"clip": [
|
||||||
|
"61",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"text": [
|
||||||
|
"60",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"31": {
|
||||||
|
"class_type": "easy imageSize",
|
||||||
|
"inputs": {
|
||||||
|
"image": [
|
||||||
|
"26",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"33": {
|
||||||
|
"class_type": "Mask Fill Holes",
|
||||||
|
"inputs": {
|
||||||
|
"masks": [
|
||||||
|
"26",
|
||||||
|
1
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"36": {
|
||||||
|
"class_type": "Convert Masks to Images",
|
||||||
|
"inputs": {
|
||||||
|
"masks": [
|
||||||
|
"33",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"39": {
|
||||||
|
"class_type": "ImageScale",
|
||||||
|
"inputs": {
|
||||||
|
"image": [
|
||||||
|
"36",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"upscale_method": "nearest-exact",
|
||||||
|
"width": [
|
||||||
|
"31",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"height": [
|
||||||
|
"31",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"crop": "disabled"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"37": {
|
||||||
|
"class_type": "Image To Mask",
|
||||||
|
"inputs": {
|
||||||
|
"image": [
|
||||||
|
"39",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"method": "intensity"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"32": {
|
||||||
|
"class_type": "LayerUtility: ImageScaleByAspectRatio V2",
|
||||||
|
"inputs": {
|
||||||
|
"image": [
|
||||||
|
"26",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"mask": [
|
||||||
|
"37",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"aspect_ratio": "custom",
|
||||||
|
"proportional_width": [
|
||||||
|
"31",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"proportional_height": [
|
||||||
|
"31",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"fit": "letterbox",
|
||||||
|
"method": "lanczos",
|
||||||
|
"round_to_multiple": "8",
|
||||||
|
"scale_to_side": "None",
|
||||||
|
"scale_to_length": 1024,
|
||||||
|
"background_color": "#000000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"44": {
|
||||||
|
"class_type": "ImageAndMaskPreview",
|
||||||
|
"inputs": {
|
||||||
|
"image": [
|
||||||
|
"32",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"mask": [
|
||||||
|
"32",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"mask_opacity": 1,
|
||||||
|
"mask_color": "FFFF00",
|
||||||
|
"pass_through": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"14": {
|
||||||
|
"class_type": "GetImageSize+",
|
||||||
|
"inputs": {
|
||||||
|
"image": [
|
||||||
|
"44",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"13": {
|
||||||
|
"class_type": "VAEEncode",
|
||||||
|
"inputs": {
|
||||||
|
"pixels": [
|
||||||
|
"44",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"vae": [
|
||||||
|
"3",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"class_type": "ModelSamplingFlux",
|
||||||
|
"inputs": {
|
||||||
|
"model": [
|
||||||
|
"16",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"max_shift": 1.15,
|
||||||
|
"base_shift": 0.5,
|
||||||
|
"width": [
|
||||||
|
"14",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"height": [
|
||||||
|
"14",
|
||||||
|
1
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"19": {
|
||||||
|
"class_type": "FluxGuidance",
|
||||||
|
"inputs": {
|
||||||
|
"conditioning": [
|
||||||
|
"22",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"guidance": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"5": {
|
||||||
|
"class_type": "ReferenceLatent",
|
||||||
|
"inputs": {
|
||||||
|
"conditioning": [
|
||||||
|
"19",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"latent": [
|
||||||
|
"13",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"7": {
|
||||||
|
"class_type": "EmptySD3LatentImage",
|
||||||
|
"inputs": {
|
||||||
|
"width": [
|
||||||
|
"14",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"height": [
|
||||||
|
"14",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"batch_size": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
"class_type": "BasicScheduler",
|
||||||
|
"inputs": {
|
||||||
|
"model": [
|
||||||
|
"2",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"scheduler": "simple",
|
||||||
|
"steps": 4,
|
||||||
|
"denoise": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"20": {
|
||||||
|
"class_type": "BasicGuider",
|
||||||
|
"inputs": {
|
||||||
|
"model": [
|
||||||
|
"2",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"conditioning": [
|
||||||
|
"5",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"6": {
|
||||||
|
"class_type": "RandomNoise",
|
||||||
|
"inputs": {
|
||||||
|
"noise_seed": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"8": {
|
||||||
|
"class_type": "KSamplerSelect",
|
||||||
|
"inputs": {
|
||||||
|
"sampler_name": "euler"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"9": {
|
||||||
|
"class_type": "SamplerCustomAdvanced",
|
||||||
|
"inputs": {
|
||||||
|
"noise": [
|
||||||
|
"6",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"guider": [
|
||||||
|
"20",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"sampler": [
|
||||||
|
"8",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"sigmas": [
|
||||||
|
"1",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"latent_image": [
|
||||||
|
"7",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"10": {
|
||||||
|
"class_type": "VAEDecode",
|
||||||
|
"inputs": {
|
||||||
|
"samples": [
|
||||||
|
"9",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"vae": [
|
||||||
|
"3",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"62": {
|
||||||
|
"class_type": "ColorMatch",
|
||||||
|
"inputs": {
|
||||||
|
"image_ref": [
|
||||||
|
"26",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"image_target": [
|
||||||
|
"10",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"method": "mkl",
|
||||||
|
"strength": 1,
|
||||||
|
"multithread": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"17": {
|
||||||
|
"class_type": "SaveImage",
|
||||||
|
"inputs": {
|
||||||
|
"images": [
|
||||||
|
"62",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"filename_prefix": "hair_inpaint"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"1": {
|
"1": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"scheduler": "simple",
|
"scheduler": "simple",
|
||||||
"steps": 6,
|
"steps": 4,
|
||||||
"denoise": 1,
|
"denoise": 1,
|
||||||
"model": [
|
"model": [
|
||||||
"2",
|
"2",
|
||||||
@@ -170,10 +170,10 @@
|
|||||||
},
|
},
|
||||||
"16": {
|
"16": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"unet_name": "flux2.0/flux-2-klein-9b-fp8.safetensors",
|
"unet_name": "flux-2-klein-9b-Q4_K_M.gguf",
|
||||||
"weight_dtype": "fp8_e4m3fn"
|
"weight_dtype": "fp8_e4m3fn_fast"
|
||||||
},
|
},
|
||||||
"class_type": "UNETLoader",
|
"class_type": "UnetLoaderGGUF",
|
||||||
"_meta": {
|
"_meta": {
|
||||||
"title": "UNet加载器"
|
"title": "UNet加载器"
|
||||||
}
|
}
|
||||||
@@ -410,7 +410,7 @@
|
|||||||
},
|
},
|
||||||
"60": {
|
"60": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"text": "补充遮罩区补充遮罩区域内的头发,头发填满遮罩区域。发际线往下挡住额头"
|
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
||||||
},
|
},
|
||||||
"class_type": "JjkText",
|
"class_type": "JjkText",
|
||||||
"_meta": {
|
"_meta": {
|
||||||
@@ -421,7 +421,7 @@
|
|||||||
"inputs": {
|
"inputs": {
|
||||||
"clip_name": "qwen_3_8b_fp8mixed.safetensors",
|
"clip_name": "qwen_3_8b_fp8mixed.safetensors",
|
||||||
"type": "flux2",
|
"type": "flux2",
|
||||||
"device": "default"
|
"device": "cpu"
|
||||||
},
|
},
|
||||||
"class_type": "CLIPLoader",
|
"class_type": "CLIPLoader",
|
||||||
"_meta": {
|
"_meta": {
|
||||||
|
|||||||
@@ -138,7 +138,47 @@ 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", "/api/v1/debug")
|
_AUTH_EXEMPT = ("/health", "/docs", "/openapi.json", "/redoc", "/static",
|
||||||
|
"/api/v1/debug", "/api/v1/redraw",
|
||||||
|
"/api/swapHair", "/hairColor")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# change_hair 代理路由(解决 CORS 问题)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_CHANGE_HAIR_BASE = os.getenv("CHANGE_HAIR_BASE", "http://127.0.0.1:8801")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/swapHair/v1", tags=["change_hair"])
|
||||||
|
async def proxy_swap_hair(request: Request):
|
||||||
|
"""代理转发到 change_hair /api/swapHair/v1(换发型)"""
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
body = await request.body()
|
||||||
|
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||||
|
resp = await client.post(f"{_CHANGE_HAIR_BASE}/api/swapHair/v1",
|
||||||
|
content=body,
|
||||||
|
headers={"Content-Type": "application/json"})
|
||||||
|
return JSONResponse(content=resp.json(), status_code=resp.status_code)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("代理 swapHair 失败")
|
||||||
|
return err(1007, f"换发型服务异常:{e}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/hairColor/v2", tags=["change_hair"])
|
||||||
|
async def proxy_hair_color(request: Request):
|
||||||
|
"""代理转发到 change_hair /hairColor/v2(换发色)"""
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
body = await request.body()
|
||||||
|
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||||
|
resp = await client.post(f"{_CHANGE_HAIR_BASE}/hairColor/v2",
|
||||||
|
content=body,
|
||||||
|
headers={"Content-Type": "application/json"})
|
||||||
|
return JSONResponse(content=resp.json(), status_code=resp.status_code)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("代理 hairColor 失败")
|
||||||
|
return err(1007, f"换发色服务异常:{e}")
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
@@ -469,7 +509,7 @@ async def _face_measure_impl(image_file, image_url, image_base64, variant="v1"):
|
|||||||
**标注图片 UI 规范**(真实版本生效):
|
**标注图片 UI 规范**(真实版本生效):
|
||||||
- 字体/线条/箭头颜色:`#FFFFFF 100%`,透明底
|
- 字体/线条/箭头颜色:`#FFFFFF 100%`,透明底
|
||||||
- 字号/线宽/虚线/箭头按图片短边自适应缩放
|
- 字号/线宽/虚线/箭头按图片短边自适应缩放
|
||||||
- 四庭数值(名+数值两行,不带 cm)在图片**左侧**呈现,七眼段宽**上下穿插**展示,底部标「单位cm」
|
- 四庭(名 + 数值带cm + 百分比 三行)在图片**左侧**呈现,七眼段宽**上下穿插**展示(数值带cm,下方另起一行标占头宽百分比)
|
||||||
- 横线/竖线渐变消失并略超出端点;段宽/庭高用虚线 + 实心三角双箭头标示
|
- 横线/竖线渐变消失并略超出端点;段宽/庭高用虚线 + 实心三角双箭头标示
|
||||||
- 竖线含人头最左/最右端线(取自头发分割轮廓),共 8 线 7 段
|
- 竖线含人头最左/最右端线(取自头发分割轮廓),共 8 线 7 段
|
||||||
""",
|
""",
|
||||||
@@ -574,7 +614,7 @@ async def face_measure(
|
|||||||
**标注图片 UI 规范**(真实版本生效):
|
**标注图片 UI 规范**(真实版本生效):
|
||||||
- 字体/线条/箭头颜色:`#FFFFFF 100%`,透明底
|
- 字体/线条/箭头颜色:`#FFFFFF 100%`,透明底
|
||||||
- 字号/线宽/虚线/箭头按图片短边自适应缩放
|
- 字号/线宽/虚线/箭头按图片短边自适应缩放
|
||||||
- 三庭数值(名+数值两行,不带 cm)在图片**左侧**呈现,七眼段宽**上下穿插**展示,底部标「单位cm」
|
- 三庭(名 + 数值带cm + 百分比 三行)在图片**左侧**呈现,七眼段宽**上下穿插**展示(数值带cm,下方另起一行标占头宽百分比)
|
||||||
- 段宽/庭高用虚线 + 实心三角双箭头标示
|
- 段宽/庭高用虚线 + 实心三角双箭头标示
|
||||||
""",
|
""",
|
||||||
responses={
|
responses={
|
||||||
@@ -717,7 +757,9 @@ async def hair_grow(
|
|||||||
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填),如 1,2,3。female:1-5 male:1-4"),
|
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填),如 1,2,3。female:1-5 male:1-4"),
|
||||||
beauty_enabled: bool = Form(default=False, description="是否开启美颜(本期不生效)"),
|
beauty_enabled: bool = Form(default=False, description="是否开启美颜(本期不生效)"),
|
||||||
use_mask: bool = Form(default=True, description="是否启用 inpaint 遮罩(测试对比用)。false 时用干净原图生成(空遮罩,不烧模板线)"),
|
use_mask: bool = Form(default=True, description="是否启用 inpaint 遮罩(测试对比用)。false 时用干净原图生成(空遮罩,不烧模板线)"),
|
||||||
prompt: str = Form(default="补充遮罩区域的头发,加一点美颜", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||||
|
flux_model: Optional[str] = Form(default=None, description="Flux 模型文件名(切换模型用)。None=工作流默认;如 flux-2-klein-9b-Q5_K_M.gguf / flux-2-klein-9b-Q4_K_M.gguf / flux2.0/flux-2-klein-9b-fp8.safetensors"),
|
||||||
|
redraw_max_side: Optional[int] = Form(default=None, description="重绘压图长边像素。None=默认896;0=不缩图(原图直送);其他如 768/640/1024"),
|
||||||
):
|
):
|
||||||
# 1. gender 必填校验(非法/缺失 → 1004)
|
# 1. gender 必填校验(非法/缺失 → 1004)
|
||||||
if gender not in ("male", "female"):
|
if gender not in ("male", "female"):
|
||||||
@@ -746,11 +788,13 @@ async def hair_grow(
|
|||||||
if gender == "female":
|
if gender == "female":
|
||||||
from hairline.service import generate_grow_results_swap
|
from hairline.service import generate_grow_results_swap
|
||||||
items = await run_in_threadpool(
|
items = await run_in_threadpool(
|
||||||
generate_grow_results_swap, image, hair_styles, _V2_FINAL_DEFAULTS)
|
generate_grow_results_swap, image, hair_styles, _V2_FINAL_DEFAULTS,
|
||||||
|
redraw_max_side=redraw_max_side, unet_name=flux_model)
|
||||||
else:
|
else:
|
||||||
from hairline.service import generate_grow_results
|
from hairline.service import generate_grow_results
|
||||||
items = await run_in_threadpool(
|
items = await run_in_threadpool(
|
||||||
generate_grow_results, image, gender, use_mask, prompt, hair_styles)
|
generate_grow_results, image, gender, use_mask, prompt, hair_styles,
|
||||||
|
unet_name=flux_model)
|
||||||
if items is None:
|
if items is None:
|
||||||
return err(1001, "无法识别人像")
|
return err(1001, "无法识别人像")
|
||||||
|
|
||||||
@@ -770,117 +814,15 @@ async def hair_grow(
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 接口 7:C 端生发 v2(add_hair2.json 工作流)
|
# 接口 7:C 端生发 v2 —— 已弃用(add_hair2.json 用 Klein-9b 大模型,会把常驻的
|
||||||
|
# Klein-4b/Flux 挤出显存,导致接口2/3/5 耗时抖动;且业务已不再调用)。
|
||||||
|
# 保留路由返回明确错误,避免老客户端拿到裸 404。
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_WORKFLOW2_PATH = os.path.join(os.path.dirname(__file__), "add_hair2.json")
|
@app.post("/api/v1/hair/grow-v2", include_in_schema=False, deprecated=True)
|
||||||
|
async def hair_grow_v2():
|
||||||
|
"""接口7 已弃用:请改用 /api/v1/hair/grow(接口2)。"""
|
||||||
@app.post(
|
return err(1007, "接口7(/api/v1/hair/grow-v2)已弃用,请使用 /api/v1/hair/grow")
|
||||||
"/api/v1/hair/grow-v2",
|
|
||||||
summary="接口7 C端生发 v2(add_hair2 工作流)",
|
|
||||||
tags=["生发"],
|
|
||||||
description=f"""
|
|
||||||
输入用户正面照 + **性别** + **发型序号**,使用 add_hair2.json 工作流生成指定发际线类型的预览图与生发图。
|
|
||||||
功能与接口2 完全一致,仅 ComfyUI 工作流不同。
|
|
||||||
|
|
||||||
{_image_fields_desc}
|
|
||||||
|
|
||||||
图片同时支持 `multipart/form-data` 文件上传(字段名 `image_file`)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
- **gender**(必填):`male` / `female`。决定返回的贴图集合(female 5 张 / male 4 张)。
|
|
||||||
非法或缺失返回 `1004`。
|
|
||||||
- **hair_style**(必填):`int`,发型序号。`female`:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave;
|
|
||||||
`male`:1=ellipse, 2=inverse_arc, 3=m, 4=straight。越界返回 `1007`。
|
|
||||||
- **beauty_enabled**:本期保留但不生效。
|
|
||||||
|
|
||||||
`hairline_type` 取值:`ellipse` / `flower` / `heart` / `straight` / `wave`(female),
|
|
||||||
`ellipse` / `m` / `straight` / `inverse_arc`(male)。
|
|
||||||
""",
|
|
||||||
responses={
|
|
||||||
200: {
|
|
||||||
"description": "成功",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"example": {
|
|
||||||
"code": 0,
|
|
||||||
"message": "success",
|
|
||||||
"request_id": "mock-request-id",
|
|
||||||
"data": {
|
|
||||||
"results": [
|
|
||||||
{"image_base64": "iVBORw0KGgo...", "hairline_type": "ellipse", "order": 1},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
400: {
|
|
||||||
"description": "参数错误 / 图片识别失败",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"examples": {
|
|
||||||
"图片参数错误": {"value": {"code": 1007, "message": "图片参数错误:必须且只能传 image_file / image_url / image_base64 其中一个", "request_id": "x", "data": None}},
|
|
||||||
"非正面照": {"value": {"code": 1003, "message": "角度问题,请上传正面照", "request_id": "x", "data": None}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def hair_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, 前缀)"),
|
|
||||||
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
|
|
||||||
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填),如 1,2,3。female:1-5 male:1-4"),
|
|
||||||
beauty_enabled: bool = Form(default=False, description="是否开启美颜(本期不生效)"),
|
|
||||||
use_mask: bool = Form(default=True, description="是否启用 inpaint 遮罩(测试对比用)。false 时用干净原图生成(空遮罩,不烧模板线)"),
|
|
||||||
prompt: str = Form(default="补充遮罩区域的头发,加一点美颜", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
|
||||||
):
|
|
||||||
# 1. gender 必填校验(非法/缺失 → 1004)
|
|
||||||
if gender not in ("male", "female"):
|
|
||||||
return err(1004, "gender 必填且只能为 male / female")
|
|
||||||
|
|
||||||
# 2. hair_style 必填校验(解析逗号分隔,越界 → 1007)
|
|
||||||
max_styles = {"female": 5, "male": 4}[gender]
|
|
||||||
hair_styles = _parse_hair_styles(hair_style, max_styles)
|
|
||||||
if hair_styles is None:
|
|
||||||
return err(1007, f"hair_style 必填且为 1..{max_styles} 的整数(逗号分隔),收到 {hair_style!r}")
|
|
||||||
|
|
||||||
# 3. 三选一取图
|
|
||||||
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 hairline.service import generate_grow_results
|
|
||||||
|
|
||||||
# 预览 + 生发(ComfyUI) 都是阻塞且较慢,放线程池避免卡住事件循环
|
|
||||||
items = await run_in_threadpool(generate_grow_results, image, gender, use_mask, prompt, hair_styles, _WORKFLOW2_PATH)
|
|
||||||
if items is None:
|
|
||||||
return err(1001, "无法识别人像")
|
|
||||||
|
|
||||||
results = []
|
|
||||||
for p in items:
|
|
||||||
results.append({
|
|
||||||
"image_base64": _jpg_b64(p["image_bgr"]), # 预览图 JPG
|
|
||||||
"grown_image_base64": (_png_to_jpg_b64(p["grown_png"]) # 生发图 JPG
|
|
||||||
if p["grown_png"] else None),
|
|
||||||
"hairline_type": p["hairline_type"],
|
|
||||||
"order": p["order"],
|
|
||||||
})
|
|
||||||
return ok({"results": results})
|
|
||||||
except Exception as ex: # noqa: BLE001
|
|
||||||
logger.exception("接口7 处理异常")
|
|
||||||
return err(1007, f"处理失败:{ex}")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -930,7 +872,7 @@ async def hair_grow_b(
|
|||||||
marked_image_url: Optional[str] = Form(default=None, description="划线图片 URL"),
|
marked_image_url: Optional[str] = Form(default=None, description="划线图片 URL"),
|
||||||
marked_image_base64: Optional[str] = Form(default=None, description="划线图片 base64"),
|
marked_image_base64: Optional[str] = Form(default=None, description="划线图片 base64"),
|
||||||
use_mask: bool = Form(default=True, description="是否画发际线(测试对比用)。false 时跳过划线检测、直接送划线图"),
|
use_mask: bool = Form(default=True, description="是否画发际线(测试对比用)。false 时跳过划线检测、直接送划线图"),
|
||||||
prompt: str = Form(default="补充遮罩区域的头发,加一点美颜", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||||
):
|
):
|
||||||
# 划线图三选一取图(只需这一张)
|
# 划线图三选一取图(只需这一张)
|
||||||
marked_raw, e = await resolve_image_bytes(marked_image_file, marked_image_url, marked_image_base64)
|
marked_raw, e = await resolve_image_bytes(marked_image_file, marked_image_url, marked_image_base64)
|
||||||
@@ -1061,6 +1003,8 @@ async def face_features(
|
|||||||
`female`:1=ellipse,2=flower,3=heart,4=straight,5=wave;`male`:1=ellipse,2=inverse_arc,3=m,4=straight。
|
`female`:1=ellipse,2=flower,3=heart,4=straight,5=wave;`male`:1=ellipse,2=inverse_arc,3=m,4=straight。
|
||||||
- 可选 `use_mask` / `prompt`:同接口2 的生发控制参数。
|
- 可选 `use_mask` / `prompt`:同接口2 的生发控制参数。
|
||||||
注:生发黑模板固定取 `hairline_texture_black/`(middle 档),即三档叠图分别用各自贴图、但生发目标固定 middle。
|
注:生发黑模板固定取 `hairline_texture_black/`(middle 档),即三档叠图分别用各自贴图、但生发目标固定 middle。
|
||||||
|
- 可选 `generate_grow_image`(默认 `true`):是否生成生发效果图(ComfyUI 生发,全流程最耗时)。
|
||||||
|
`false` 时跳过生发,各发型 `grown_image_*` 恒为 `null`,仅返回三档发际线叠图与中心点,大幅降低耗时。
|
||||||
|
|
||||||
**返回说明**:
|
**返回说明**:
|
||||||
|
|
||||||
@@ -1138,7 +1082,8 @@ async def hairline_generate(
|
|||||||
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
|
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
|
||||||
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填,如 1,2,3)。female:1-5 male:1-4"),
|
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填,如 1,2,3)。female:1-5 male:1-4"),
|
||||||
use_mask: bool = Form(default=True, description="生发是否启用 inpaint 遮罩(同接口2,测试对比用)"),
|
use_mask: bool = Form(default=True, description="生发是否启用 inpaint 遮罩(同接口2,测试对比用)"),
|
||||||
prompt: str = Form(default="补充遮罩区域的头发,加一点美颜", description="ComfyUI 提示词(同接口2),会替换工作流节点60的文本"),
|
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", description="ComfyUI 提示词(同接口2),会替换工作流节点60的文本"),
|
||||||
|
generate_grow_image: bool = Form(default=True, description="是否生成生发效果图(ComfyUI 生发,最耗时)。默认 true 出图;false 时跳过生发,各发型 grown_image 恒为 null,仅返回三档发际线叠图与中心点"),
|
||||||
):
|
):
|
||||||
if gender not in ("male", "female"):
|
if gender not in ("male", "female"):
|
||||||
return err(1004, "gender 必填且只能为 male / female")
|
return err(1004, "gender 必填且只能为 male / female")
|
||||||
@@ -1162,7 +1107,8 @@ async def hairline_generate(
|
|||||||
from hairline.service import generate_hairline_pngs
|
from hairline.service import generate_hairline_pngs
|
||||||
|
|
||||||
res = await run_in_threadpool(
|
res = await run_in_threadpool(
|
||||||
generate_hairline_pngs, image, gender, hair_styles, use_mask, prompt)
|
generate_hairline_pngs, image, gender, hair_styles, use_mask, prompt,
|
||||||
|
generate_grow_image=generate_grow_image)
|
||||||
if res is None:
|
if res is None:
|
||||||
return err(1001, "无法识别人像")
|
return err(1001, "无法识别人像")
|
||||||
|
|
||||||
@@ -1530,7 +1476,7 @@ async def hairline_grow_v2(
|
|||||||
inpainting_fill: int = Form(default=1, description="change_hair服务端重绘填充:0=保留原图 | 1=噪声 | 2=纯色 | 3=潜变量。默认 1"),
|
inpainting_fill: int = Form(default=1, description="change_hair服务端重绘填充:0=保留原图 | 1=噪声 | 2=纯色 | 3=潜变量。默认 1"),
|
||||||
mask_blur: int = Form(default=11, description="change_hair服务端遮罩边缘模糊像素,默认 11"),
|
mask_blur: int = Form(default=11, description="change_hair服务端遮罩边缘模糊像素,默认 11"),
|
||||||
mask_dilate_scale: float = Form(default=1.0, description="change_hair服务端遮罩膨胀缩放,默认 1.0"),
|
mask_dilate_scale: float = Form(default=1.0, description="change_hair服务端遮罩膨胀缩放,默认 1.0"),
|
||||||
comfyui_prompt: Optional[str] = Form(default=None, description="Flux-2 重绘提示词,None 用默认「补充遮罩区域的头发,加一点美颜」"),
|
comfyui_prompt: Optional[str] = Form(default=None, description="Flux-2 重绘提示词,None 用默认「填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜」"),
|
||||||
beauty_alpha: float = Form(default=0.6, description="redraw_band 版 band 外的全脸美颜融入强度(0=band外无美颜纯用final,1≈整帧版),默认 0.6"),
|
beauty_alpha: float = Form(default=0.6, description="redraw_band 版 band 外的全脸美颜融入强度(0=band外无美颜纯用final,1≈整帧版),默认 0.6"),
|
||||||
band_lo_mult: float = Form(default=0.5, description="重绘带外推倍率下限(相对 hairline_push_cm,内轮廓=0×、原外推线=1.0×),默认 0.5"),
|
band_lo_mult: float = Form(default=0.5, description="重绘带外推倍率下限(相对 hairline_push_cm,内轮廓=0×、原外推线=1.0×),默认 0.5"),
|
||||||
band_hi_mult: float = Form(default=1.5, description="重绘带外推倍率上限(相对 hairline_push_cm),默认 1.5"),
|
band_hi_mult: float = Form(default=1.5, description="重绘带外推倍率上限(相对 hairline_push_cm),默认 1.5"),
|
||||||
@@ -1677,6 +1623,42 @@ async def hairline_grow_v2_final_v2(
|
|||||||
return await _run_v2_final(image_file, image_url, image_base64, hairline_id, "接口12finalv2")
|
return await _run_v2_final(image_file, image_url, image_base64, hairline_id, "接口12finalv2")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 重绘端点(替代 local_test /api/generate)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app.post(
|
||||||
|
"/api/v1/redraw",
|
||||||
|
summary="ComfyUI 重绘",
|
||||||
|
tags=["重绘"],
|
||||||
|
description="""
|
||||||
|
传入人物图片 + 遮罩图片,直接调 ComfyUI(0716add-hair 工作流)执行局部重绘。
|
||||||
|
替代原 local_test :8899 的 /api/generate 接口。
|
||||||
|
|
||||||
|
**遮罩图片格式**:支持红色遮罩(R=255)、白色遮罩(R=G=B=255)、Alpha遮罩(A=255),服务取所有通道最大值。
|
||||||
|
**遮罩区域**表示需要重绘的部分,非遮罩区域保持原图不变。
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
async def api_redraw(
|
||||||
|
image_file: UploadFile = File(..., description="人物图片(JPG/PNG)"),
|
||||||
|
mask_file: UploadFile = File(..., description="遮罩图片(PNG,支持红/白/alpha 格式)"),
|
||||||
|
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜",
|
||||||
|
description="ComfyUI 提示词"),
|
||||||
|
):
|
||||||
|
image_bytes = await image_file.read()
|
||||||
|
mask_bytes = await mask_file.read()
|
||||||
|
from fastapi.concurrency import run_in_threadpool
|
||||||
|
from hairline.redraw import run_redraw
|
||||||
|
try:
|
||||||
|
png_bytes = await run_in_threadpool(
|
||||||
|
run_redraw, image_bytes, mask_bytes, prompt)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.warning("重绘失败: %s", e)
|
||||||
|
return err(500, f"重绘失败: {e}")
|
||||||
|
b64 = base64.b64encode(png_bytes).decode()
|
||||||
|
return ok({"image_base64": f"data:image/png;base64,{b64}"})
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 调试:下载后端日志(接口11 遮罩计算全过程)
|
# 调试:下载后端日志(接口11 遮罩计算全过程)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""发型对比矩阵测试:3图×5发型=15行,每行10张图(4b@896×1 + 9b三模型×三分辨率×9)。
|
||||||
|
按模型分组跑(减少模型切换次数、降低OOM风险),结果重组为15行存JSON+生成报告。
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
API = "http://127.0.0.1:8187/api/v1/hair/grow"
|
||||||
|
TOKEN = "dev-shared-secret-2026"
|
||||||
|
OUT = Path("/home/ubuntu/hair/benchmark_out/hairstyle")
|
||||||
|
OUT.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
IMGS = [
|
||||||
|
("asdf", "/home/ubuntu/hair/image/asdf.jpg"),
|
||||||
|
("qwer", "/home/ubuntu/hair/image/qwer.jpg"),
|
||||||
|
("girl5", "/home/ubuntu/hair/image/girl_img/girl5.jpg"),
|
||||||
|
]
|
||||||
|
HAIRSTYLES = [
|
||||||
|
(1, "ellipse", "椭圆"), (2, "flower", "花瓣"), (3, "heart", "心形"),
|
||||||
|
(4, "straight", "直线"), (5, "wave", "波浪"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 按模型分组:每个模型对应其要跑的(分辨率,列标题)
|
||||||
|
MODEL_GROUPS = [
|
||||||
|
("flux-2-klein-4b-fp8.safetensors", [("896", "4B@896")]),
|
||||||
|
("flux2.0/flux-2-klein-9b-fp8.safetensors",
|
||||||
|
[("0", "9B-fp8@原图"), ("896", "9B-fp8@896"), ("640", "9B-fp8@640")]),
|
||||||
|
("flux-2-klein-9b-Q5_K_M.gguf",
|
||||||
|
[("0", "9B-Q5@原图"), ("896", "9B-Q5@896"), ("640", "9B-Q5@640")]),
|
||||||
|
("flux-2-klein-9b-Q4_K_M.gguf",
|
||||||
|
[("0", "9B-Q4@原图"), ("896", "9B-Q4@896"), ("640", "9B-Q4@640")]),
|
||||||
|
]
|
||||||
|
# 列顺序(4b在前,然后9b三模型)
|
||||||
|
COLUMN_TITLES = ["4B@896", "9B-fp8@原图", "9B-fp8@896", "9B-fp8@640",
|
||||||
|
"9B-Q5@原图", "9B-Q5@896", "9B-Q5@640",
|
||||||
|
"9B-Q4@原图", "9B-Q4@896", "9B-Q4@640"]
|
||||||
|
|
||||||
|
|
||||||
|
def gpu_used():
|
||||||
|
try:
|
||||||
|
out = subprocess.check_output(
|
||||||
|
["nvidia-smi", "--query-gpu=memory.used", "--format=csv,noheader,nounits"], timeout=10)
|
||||||
|
return int(out.decode().strip())
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def call(img_path, hair_num, model_file, res_val):
|
||||||
|
fd = {"gender": "female", "hair_style": str(hair_num), "use_mask": "true",
|
||||||
|
"prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"}
|
||||||
|
if model_file:
|
||||||
|
fd["flux_model"] = model_file
|
||||||
|
if res_val != "":
|
||||||
|
fd["redraw_max_side"] = res_val
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
peak = gpu_used()
|
||||||
|
err = None
|
||||||
|
grown_b64 = None
|
||||||
|
try:
|
||||||
|
with open(img_path, "rb") as f:
|
||||||
|
r = requests.post(API, headers={"X-Internal-Token": TOKEN},
|
||||||
|
files={"image_file": (os.path.basename(img_path), f, "image/jpeg")},
|
||||||
|
data=fd, timeout=300)
|
||||||
|
elapsed = time.perf_counter() - t0
|
||||||
|
peak = max(peak, gpu_used())
|
||||||
|
j = r.json()
|
||||||
|
if j.get("code") != 0:
|
||||||
|
err = f"code={j.get('code')} {j.get('message', '')[:60]}"
|
||||||
|
else:
|
||||||
|
res = j.get("data", {}).get("results", [])
|
||||||
|
if res and res[0].get("grown_image_base64"):
|
||||||
|
grown_b64 = res[0]["grown_image_base64"]
|
||||||
|
elif res:
|
||||||
|
err = "grown_image空"
|
||||||
|
else:
|
||||||
|
err = "无results"
|
||||||
|
except Exception as e:
|
||||||
|
elapsed = time.perf_counter() - t0
|
||||||
|
err = str(e)[:150]
|
||||||
|
return {"elapsed": elapsed, "gpu_peak": peak, "grown_b64": grown_b64, "error": err}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# 结果字典: results[(img, hair_num, column_title)] = {grown_path, elapsed, gpu_peak, error}
|
||||||
|
results = {}
|
||||||
|
total = len(IMGS) * len(HAIRSTYLES) * len(COLUMN_TITLES)
|
||||||
|
idx = 0
|
||||||
|
for mfile, res_list in MODEL_GROUPS:
|
||||||
|
mname = os.path.basename(mfile)
|
||||||
|
print(f"\n===== 切换到模型: {mname} =====", flush=True)
|
||||||
|
# 等模型切换稳定
|
||||||
|
time.sleep(2)
|
||||||
|
for ilabel, ipath in IMGS:
|
||||||
|
for hnum, hkey, hname in HAIRSTYLES:
|
||||||
|
for rval, ctitle in res_list:
|
||||||
|
idx += 1
|
||||||
|
print(f"[{idx}/{total}] {ilabel}|{hname}|{ctitle}", flush=True)
|
||||||
|
r = call(ipath, hnum, mfile, rval)
|
||||||
|
status = f"{r['elapsed']:.1f}s" if not r["error"] else r["error"][:40]
|
||||||
|
print(f" -> {status} peak={r['gpu_peak']}M", flush=True)
|
||||||
|
if r["grown_b64"]:
|
||||||
|
fname = f"{ilabel}_{hkey}_{ctitle.replace('@','_').replace('-','')}.jpg"
|
||||||
|
with open(OUT / fname, "wb") as gf:
|
||||||
|
gf.write(base64.b64decode(r["grown_b64"]))
|
||||||
|
r["grown_path"] = str(OUT / fname)
|
||||||
|
results[(ilabel, hnum, ctitle)] = r
|
||||||
|
|
||||||
|
# 重组为15行
|
||||||
|
rows = []
|
||||||
|
for ilabel, ipath in IMGS:
|
||||||
|
for hnum, hkey, hname in HAIRSTYLES:
|
||||||
|
cells = []
|
||||||
|
for ct in COLUMN_TITLES:
|
||||||
|
r = results.get((ilabel, hnum, ct), {"error": "未跑"})
|
||||||
|
cells.append({"title": ct, **{k: v for k, v in r.items() if k != "grown_b64"}})
|
||||||
|
rows.append({"img": ilabel, "img_path": ipath,
|
||||||
|
"hair_num": hnum, "hair_key": hkey, "hair_name": hname,
|
||||||
|
"cells": cells})
|
||||||
|
with open(OUT / "results.json", "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"columns": COLUMN_TITLES, "rows": rows}, f, ensure_ascii=False, indent=2)
|
||||||
|
ok = sum(1 for row in rows for c in row["cells"] if not c.get("error"))
|
||||||
|
print(f"\n✓ 完成: {ok}/{total} 成功 -> {OUT/'results.json'}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""把发型对比测试结果生成 HTML 报告。
|
||||||
|
15行(3图×5发型) × 10列(4b@896 + 9b三模型×三分辨率),每行首列=原图。
|
||||||
|
图片 base64 内嵌,自包含单文件。
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
OUT = Path("/home/ubuntu/hair/benchmark_out/hairstyle")
|
||||||
|
RESULTS = OUT / "results.json"
|
||||||
|
HTML = OUT / "report.html"
|
||||||
|
|
||||||
|
|
||||||
|
def img_src(path):
|
||||||
|
"""把绝对路径转成报告里的相对 URL(报告在 static/,图片在 static/bench/)。"""
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
p = str(path)
|
||||||
|
if "benchmark_out/hairstyle/" in p:
|
||||||
|
return "bench/hairstyle/" + os.path.basename(p)
|
||||||
|
if "benchmark_out/matrix/" in p:
|
||||||
|
return "bench/matrix/" + os.path.basename(p)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
d = json.load(open(RESULTS, encoding="utf-8"))
|
||||||
|
columns = d["columns"]
|
||||||
|
rows = d["rows"]
|
||||||
|
|
||||||
|
# 统计每列的平均耗时、峰值显存
|
||||||
|
col_stats = {}
|
||||||
|
for ct in columns:
|
||||||
|
times, peaks = [], []
|
||||||
|
for r in rows:
|
||||||
|
for c in r["cells"]:
|
||||||
|
if c.get("title") == ct and not c.get("error"):
|
||||||
|
times.append(c["elapsed"])
|
||||||
|
peaks.append(c["gpu_peak"])
|
||||||
|
col_stats[ct] = {
|
||||||
|
"avg_t": sum(times) / len(times) if times else 0,
|
||||||
|
"max_p": max(peaks) / 1024 if peaks else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 表头:原图 + 10列
|
||||||
|
headers = ['<th class="col-label">原图</th>']
|
||||||
|
for ct in columns:
|
||||||
|
s = col_stats[ct]
|
||||||
|
headers.append(
|
||||||
|
f'<th class="col-label"><div class="col-title">{ct}</div>'
|
||||||
|
f'<div class="col-stat">{s["avg_t"]:.0f}s · {s["max_p"]:.0f}G</div></th>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 表体:15行
|
||||||
|
body_rows = []
|
||||||
|
for r in rows:
|
||||||
|
# 发型+图标签
|
||||||
|
label = f'<div class="row-label">{r["img"]}<br><b>{r["hair_name"]}</b></div>'
|
||||||
|
# 原图
|
||||||
|
ORIG_SRC = {"asdf": "bench/orig/asdf.jpg", "qwer": "bench/orig/qwer.jpg", "girl5": "bench/orig/girl5.jpg"}
|
||||||
|
orig = ORIG_SRC.get(r["img"])
|
||||||
|
cells = [f'<td class="cell-orig"><div class="row-label-cell">{label}</div>'
|
||||||
|
f'<img class="orig-img" src="{orig}"></td>']
|
||||||
|
# 10个结果列
|
||||||
|
for ct in columns:
|
||||||
|
c = next((x for x in r["cells"] if x.get("title") == ct), {})
|
||||||
|
src = img_src(c.get("grown_path")) if not c.get("error") else None
|
||||||
|
if src:
|
||||||
|
cells.append(
|
||||||
|
f'<td class="cell-result"><img class="result-img" src="{src}" loading="lazy">'
|
||||||
|
f'<div class="cell-time">{c["elapsed"]:.1f}s</div></td>')
|
||||||
|
else:
|
||||||
|
cells.append(f'<td class="cell-result"><div class="na">⚠</div></td>')
|
||||||
|
body_rows.append(f'<tr>{"".join(cells)}</tr>')
|
||||||
|
|
||||||
|
html = f"""<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>发型对比测试报告 — 4模型×3分辨率</title>
|
||||||
|
<style>
|
||||||
|
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||||
|
body {{ font-family: -apple-system, "Segoe UI", sans-serif; background: #f5f5f5; padding: 16px; }}
|
||||||
|
h1 {{ font-size: 20px; margin-bottom: 4px; }}
|
||||||
|
.subtitle {{ color: #888; font-size: 12px; margin-bottom: 12px; }}
|
||||||
|
.legend {{ background: #fff; border-radius: 8px; padding: 10px 16px; margin-bottom: 12px; font-size: 12px; color: #555; }}
|
||||||
|
.scroll-wrap {{ overflow-x: auto; }}
|
||||||
|
table {{ border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,.06); }}
|
||||||
|
th, td {{ border: 1px solid #eee; padding: 6px; vertical-align: top; text-align: center; }}
|
||||||
|
th {{ background: #f9fafb; position: sticky; top: 0; }}
|
||||||
|
.col-label {{ min-width: 110px; max-width: 130px; }}
|
||||||
|
.col-title {{ font-size: 12px; font-weight: 700; color: #374151; }}
|
||||||
|
.col-stat {{ font-size: 10px; color: #9ca3af; margin-top: 2px; }}
|
||||||
|
.row-label {{ font-size: 11px; color: #6b7280; }}
|
||||||
|
.row-label b {{ color: #1f2937; }}
|
||||||
|
.row-label-cell {{ font-size: 11px; color: #6b7280; margin-bottom: 4px; }}
|
||||||
|
.row-label-cell b {{ color: #1f2937; font-size: 13px; }}
|
||||||
|
img {{ border-radius: 4px; max-width: 120px; max-height: 150px; object-fit: contain; background: #f3f4f6; }}
|
||||||
|
.orig-img {{ border: 2px solid #d1d5db; max-height: 130px; }}
|
||||||
|
.cell-time {{ font-size: 10px; color: #9ca3af; margin-top: 2px; }}
|
||||||
|
.na {{ color: #d1d5db; font-size: 16px; padding: 40px; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>💇 发型对比测试报告</h1>
|
||||||
|
<p class="subtitle">接口2女性 · 3图×5发型=15行 · 每行: 4B@896(1) + 9B(fp8/Q5/Q4)×(原图/896/640)(9) · 150/150成功 · RTX3090</p>
|
||||||
|
<div class="legend">列标题下显示<b>平均耗时 · 峰值显存</b>。横向滚动查看更多列。原图列含图片名+发型名。</div>
|
||||||
|
<div class="scroll-wrap">
|
||||||
|
<table>
|
||||||
|
<tr>{"".join(headers)}</tr>
|
||||||
|
{"".join(body_rows)}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
with open(HTML, "w", encoding="utf-8") as f:
|
||||||
|
f.write(html)
|
||||||
|
print(f"✓ 报告: {HTML} ({HTML.stat().st_size//1024} KB)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""接口2女性 花瓣形 单发型 4模型×3分辨率×3图×3次 矩阵测试。
|
||||||
|
|
||||||
|
调用本机 hair-worker (:8187) 的 /api/v1/hair/grow,gender=female, hair_style=2(花瓣形)。
|
||||||
|
每次记录:生发图、耗时、显存峰值。结果图存到 benchmark_out/matrix/,最后生成 HTML 报告。
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
API = "http://127.0.0.1:8187/api/v1/hair/grow"
|
||||||
|
TOKEN = "dev-shared-secret-2026"
|
||||||
|
OUT = Path("/home/ubuntu/hair/benchmark_out/matrix")
|
||||||
|
OUT.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 4 模型 × 3 分辨率 × 3 图 × 3 次
|
||||||
|
MODELS = [
|
||||||
|
("4b-fp8", "flux-2-klein-4b-fp8.safetensors"),
|
||||||
|
("9b-fp8", "flux2.0/flux-2-klein-9b-fp8.safetensors"),
|
||||||
|
("9b-Q5", "flux-2-klein-9b-Q5_K_M.gguf"),
|
||||||
|
("9b-Q4", "flux-2-klein-9b-Q4_K_M.gguf"),
|
||||||
|
]
|
||||||
|
RES = [("orig", "0"), ("640", "640"), ("896", "896")]
|
||||||
|
IMGS = [
|
||||||
|
("asdf", "/home/ubuntu/hair/image/asdf.jpg"),
|
||||||
|
("qwer", "/home/ubuntu/hair/image/qwer.jpg"),
|
||||||
|
("girl5", "/home/ubuntu/hair/image/girl_img/girl5.jpg"),
|
||||||
|
]
|
||||||
|
REPEAT = 3
|
||||||
|
|
||||||
|
|
||||||
|
def gpu_used():
|
||||||
|
"""返回当前显存已用 MiB。"""
|
||||||
|
try:
|
||||||
|
out = subprocess.check_output(
|
||||||
|
["nvidia-smi", "--query-gpu=memory.used", "--format=csv,noheader,nounits"],
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
return int(out.decode().strip())
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def call(img_path, model_file, res_val):
|
||||||
|
"""调一次接口2。返回 dict: ok/elapsed/grown_path/gpu_peak/error。"""
|
||||||
|
fd = {
|
||||||
|
"gender": "female",
|
||||||
|
"hair_style": "2", # 花瓣形
|
||||||
|
"use_mask": "true",
|
||||||
|
"prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜",
|
||||||
|
}
|
||||||
|
if model_file:
|
||||||
|
fd["flux_model"] = model_file
|
||||||
|
if res_val != "":
|
||||||
|
fd["redraw_max_side"] = res_val
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
peak = gpu_used()
|
||||||
|
err = None
|
||||||
|
grown_path = None
|
||||||
|
try:
|
||||||
|
with open(img_path, "rb") as f:
|
||||||
|
r = requests.post(
|
||||||
|
API, headers={"X-Internal-Token": TOKEN},
|
||||||
|
files={"image_file": (os.path.basename(img_path), f, "image/jpeg")},
|
||||||
|
data=fd, timeout=300,
|
||||||
|
)
|
||||||
|
elapsed = time.perf_counter() - t0
|
||||||
|
# 采样峰值(推理刚结束)
|
||||||
|
peak = max(peak, gpu_used())
|
||||||
|
j = r.json()
|
||||||
|
if j.get("code") != 0:
|
||||||
|
err = f"code={j.get('code')} {j.get('message','')}"
|
||||||
|
else:
|
||||||
|
res = j.get("data", {}).get("results", [])
|
||||||
|
if res and res[0].get("grown_image_base64"):
|
||||||
|
grown_path = OUT / f"tmp_grown.jpg"
|
||||||
|
with open(grown_path, "wb") as gf:
|
||||||
|
gf.write(base64.b64decode(res[0]["grown_image_base64"]))
|
||||||
|
elif res:
|
||||||
|
err = "grown_image_base64 为空"
|
||||||
|
else:
|
||||||
|
err = "无 results"
|
||||||
|
except Exception as e:
|
||||||
|
elapsed = time.perf_counter() - t0
|
||||||
|
err = str(e)[:200]
|
||||||
|
return {"elapsed": elapsed, "gpu_peak": peak, "grown_path": str(grown_path) if grown_path else None, "error": err}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
results = [] # 每元素一个组合
|
||||||
|
total = len(MODELS) * len(RES) * len(IMGS) * REPEAT
|
||||||
|
idx = 0
|
||||||
|
for mlabel, mfile in MODELS:
|
||||||
|
for rlabel, rval in RES:
|
||||||
|
for ilabel, ipath in IMGS:
|
||||||
|
# 一个组合:3 次
|
||||||
|
runs = []
|
||||||
|
for rep in range(REPEAT):
|
||||||
|
idx += 1
|
||||||
|
print(f"[{idx}/{total}] {mlabel} | res={rlabel} | {ilabel} | rep{rep+1}", flush=True)
|
||||||
|
r = call(ipath, mfile, rval)
|
||||||
|
print(f" -> {r['elapsed']:.1f}s peak={r['gpu_peak']}MiB err={r['error']}", flush=True)
|
||||||
|
# 存每次的生发图
|
||||||
|
if r["grown_path"]:
|
||||||
|
save_to = OUT / f"{mlabel}_{rlabel}_{ilabel}_r{rep+1}.jpg"
|
||||||
|
os.replace(r["grown_path"], save_to)
|
||||||
|
r["grown_path"] = str(save_to)
|
||||||
|
runs.append(r)
|
||||||
|
results.append({
|
||||||
|
"model": mlabel, "model_file": mfile,
|
||||||
|
"res": rlabel, "res_val": rval,
|
||||||
|
"img": ilabel, "img_path": ipath,
|
||||||
|
"runs": runs,
|
||||||
|
})
|
||||||
|
# 存原始数据
|
||||||
|
with open(OUT / "results.json", "w", encoding="utf-8") as f:
|
||||||
|
json.dump(results, f, ensure_ascii=False, indent=2)
|
||||||
|
print(f"\n✓ 全部完成,原始数据 -> {OUT/'results.json'}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""把 benchmark_out/matrix/results.json 生成 HTML 报告。
|
||||||
|
每个组合一行:原图 + 3次生发图 + 耗时/显存。
|
||||||
|
图片用 base64 内嵌(自包含单文件,便于部署)。
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
OUT = Path("/home/ubuntu/hair/benchmark_out/matrix")
|
||||||
|
RESULTS = OUT / "results.json"
|
||||||
|
HTML = OUT / "report.html"
|
||||||
|
|
||||||
|
RES_LABEL = {"orig": "原图", "640": "640", "896": "896(默认)"}
|
||||||
|
MODEL_LABEL = {
|
||||||
|
"4b-fp8": "4B fp8 (3.8G)",
|
||||||
|
"9b-fp8": "9B fp8 (8.8G)",
|
||||||
|
"9b-Q5": "9B Q5_K_M (6.6G)",
|
||||||
|
"9b-Q4": "9B Q4_K_M (5.6G)",
|
||||||
|
}
|
||||||
|
MODEL_ORDER = ["4b-fp8", "9b-Q4", "9b-Q5", "9b-fp8"]
|
||||||
|
|
||||||
|
|
||||||
|
def img_src(path):
|
||||||
|
"""把绝对路径转成报告里的相对 URL(报告在 static/,图片在 static/bench/)。"""
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
p = str(path)
|
||||||
|
# benchmark_out/matrix/xxx.jpg -> bench/matrix/xxx.jpg
|
||||||
|
if "benchmark_out/matrix/" in p:
|
||||||
|
return "bench/matrix/" + os.path.basename(p)
|
||||||
|
if "benchmark_out/hairstyle/" in p:
|
||||||
|
return "bench/hairstyle/" + os.path.basename(p)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def thumb(src, alt="", cls=""):
|
||||||
|
if not src:
|
||||||
|
return f'<div class="na {cls}">⚠ 失败</div>'
|
||||||
|
return f'<img class="{cls}" src="{src}" alt="{alt}" loading="lazy">'
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
data = json.load(open(RESULTS, encoding="utf-8"))
|
||||||
|
# 原图相对路径映射(图片在 static/bench/orig/)
|
||||||
|
ORIG_SRC = {"asdf": "bench/orig/asdf.jpg", "qwer": "bench/orig/qwer.jpg", "girl5": "bench/orig/girl5.jpg"}
|
||||||
|
|
||||||
|
# 统计:每个模型的平均耗时、平均峰值显存
|
||||||
|
stats = {}
|
||||||
|
for c in data:
|
||||||
|
m = c["model"]
|
||||||
|
stats.setdefault(m, {"times": [], "peaks": []})
|
||||||
|
for r in c["runs"]:
|
||||||
|
if not r["error"]:
|
||||||
|
stats[m]["times"].append(r["elapsed"])
|
||||||
|
stats[m]["peaks"].append(r["gpu_peak"])
|
||||||
|
|
||||||
|
rows_html = []
|
||||||
|
# 按模型顺序、分辨率顺序、图片顺序排列
|
||||||
|
for m in MODEL_ORDER:
|
||||||
|
mdata = [c for c in data if c["model"] == m]
|
||||||
|
for rlabel in ["orig", "640", "896"]:
|
||||||
|
for ilabel in ["asdf", "qwer", "girl5"]:
|
||||||
|
c = next((x for x in mdata if x["res"] == rlabel and x["img"] == ilabel), None)
|
||||||
|
if not c:
|
||||||
|
continue
|
||||||
|
# 3 次结果图
|
||||||
|
run_cells = []
|
||||||
|
for i, r in enumerate(c["runs"]):
|
||||||
|
src = img_src(r["grown_path"]) if not r["error"] else None
|
||||||
|
if src:
|
||||||
|
run_cells.append(
|
||||||
|
f'<div class="run-cell"><div class="run-label">第{i+1}次 · {r["elapsed"]:.1f}s</div>'
|
||||||
|
f'{thumb(src, f"r{i+1}", "result-img")}</div>'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
run_cells.append(
|
||||||
|
f'<div class="run-cell"><div class="run-label">第{i+1}次 · 失败</div>'
|
||||||
|
f'<div class="na">⚠ {r["error"][:30] if r["error"] else ""}</div></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
orig = ORIG_SRC.get(c["img"])
|
||||||
|
rows_html.append(f'''
|
||||||
|
<div class="combo-row">
|
||||||
|
<div class="cell-model">{MODEL_LABEL.get(m, m)}<div class="cell-sub">res={RES_LABEL.get(rlabel, rlabel)}</div></div>
|
||||||
|
<div class="cell-img">{thumb(orig, "原图", "orig-img")}<div class="run-label">{ilabel}</div></div>
|
||||||
|
<div class="cell-runs">{"".join(run_cells)}</div>
|
||||||
|
</div>''')
|
||||||
|
|
||||||
|
# 模型对比汇总
|
||||||
|
summary_rows = []
|
||||||
|
for m in MODEL_ORDER:
|
||||||
|
s = stats.get(m, {"times": [], "peaks": []})
|
||||||
|
if s["times"]:
|
||||||
|
avg_t = sum(s["times"]) / len(s["times"])
|
||||||
|
max_p = max(s["peaks"]) / 1024
|
||||||
|
summary_rows.append(
|
||||||
|
f"<tr><td>{MODEL_LABEL.get(m,m)}</td><td>{avg_t:.1f}s</td>"
|
||||||
|
f"<td>{max_p:.1f} GB</td><td>{len(s['times'])} 成功</td></tr>"
|
||||||
|
)
|
||||||
|
|
||||||
|
html = f"""<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Flux 模型矩阵测试报告 — 接口2女性花瓣形</title>
|
||||||
|
<style>
|
||||||
|
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||||
|
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f5f5; color: #333; padding: 20px; }}
|
||||||
|
h1 {{ font-size: 22px; margin-bottom: 4px; }}
|
||||||
|
.subtitle {{ color: #888; font-size: 13px; margin-bottom: 16px; }}
|
||||||
|
.summary {{ background: #fff; border-radius: 10px; padding: 16px 20px; margin-bottom: 20px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }}
|
||||||
|
.summary h2 {{ font-size: 16px; margin-bottom: 10px; }}
|
||||||
|
.summary table {{ border-collapse: collapse; width: 100%; font-size: 14px; }}
|
||||||
|
.summary th, .summary td {{ border: 1px solid #e5e7eb; padding: 8px 12px; text-align: left; }}
|
||||||
|
.summary th {{ background: #f9fafb; font-weight: 600; }}
|
||||||
|
.combo-row {{ display: flex; align-items: flex-start; gap: 12px; background: #fff; border-radius: 10px;
|
||||||
|
padding: 12px 16px; margin-bottom: 10px; box-shadow: 0 1px 3px rgba(0,0,0,.05); }}
|
||||||
|
.cell-model {{ min-width: 130px; font-weight: 700; font-size: 14px; padding-top: 6px; }}
|
||||||
|
.cell-sub {{ font-weight: 400; font-size: 12px; color: #6b7280; margin-top: 2px; }}
|
||||||
|
.cell-img {{ min-width: 160px; text-align: center; }}
|
||||||
|
.cell-runs {{ display: flex; gap: 10px; flex: 1; }}
|
||||||
|
.run-cell {{ text-align: center; }}
|
||||||
|
.run-label {{ font-size: 11px; color: #6b7280; margin-bottom: 4px; }}
|
||||||
|
img {{ border-radius: 6px; max-height: 200px; max-width: 100%; object-fit: contain; background: #f9fafb; }}
|
||||||
|
.orig-img {{ max-height: 180px; border: 2px solid #e5e7eb; }}
|
||||||
|
.result-img {{ max-height: 200px; }}
|
||||||
|
.na {{ color: #d1d5db; font-size: 12px; padding: 40px 20px; background: #f9fafb; border-radius: 6px; width: 150px; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>💇 Flux 模型矩阵测试报告</h1>
|
||||||
|
<p class="subtitle">接口2女性 · 花瓣形发型 · 4模型 × 3分辨率 × 3图 × 3次 = 108 次 · RTX 3090 24GB</p>
|
||||||
|
|
||||||
|
<div class="summary">
|
||||||
|
<h2>📊 模型对比汇总</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>模型</th><th>平均耗时</th><th>峰值显存</th><th>成功次数</th></tr>
|
||||||
|
{"".join(summary_rows)}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style="font-size:16px;margin:24px 0 12px">🖼️ 各组合对比(每行:原图 + 3次生发结果)</h2>
|
||||||
|
{"".join(rows_html)}
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
with open(HTML, "w", encoding="utf-8") as f:
|
||||||
|
f.write(html)
|
||||||
|
print(f"✓ 报告已生成: {HTML} ({HTML.stat().st_size//1024} KB)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=ComfyUI (127.0.0.1:8188)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=ubuntu
|
||||||
|
WorkingDirectory=/home/ubuntu/ComfyUI
|
||||||
|
ExecStart=/home/ubuntu/ComfyUI/venv/bin/python main.py --listen 127.0.0.1 --port 8188 --cache-classic --fast
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# 接口3 B端生发 — 实现文档
|
||||||
|
|
||||||
|
> 文档日期:2026-07-18
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、接口概述
|
||||||
|
|
||||||
|
**接口3** 是 B端(医生/操作端)生发接口。医生在用户照片上手动用马克笔画出发际线后,只需上传这一张划线图,系统自动检测划线 → 生成遮罩 → 送 ComfyUI 生发,返回「植发3个月」效果图。
|
||||||
|
|
||||||
|
**与接口2 的核心区别**:
|
||||||
|
|
||||||
|
| 特性 | 接口2(C端生发) | 接口3(B端生发) |
|
||||||
|
|------|----------------|----------------|
|
||||||
|
| 输入 | 原始照片 | 划线图(含手绘线) |
|
||||||
|
| 发际线来源 | 系统按发型模板自动生成 | 医生手绘标注 |
|
||||||
|
| 发型类型 | ellipse/flower/heart/straight/wave | custom(自定义) |
|
||||||
|
| 中间步骤 | extract_context + swapHair + ComfyUI重绘 | 划线检测 + 遮罩 + ComfyUI生发 |
|
||||||
|
| 是否调 change_hair | 是(女性流程) | 否 |
|
||||||
|
| ComfyUI 工作流 | 0716add-hair-api.json(重绘) | add_hair.json(生发) |
|
||||||
|
| 典型耗时 | ~11s | ~6-8s |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、接口定义
|
||||||
|
|
||||||
|
### 路由
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/hair/grow-b
|
||||||
|
```
|
||||||
|
|
||||||
|
### 入参
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `marked_image_file` | UploadFile | 三选一 | 划线图片文件(JPG/PNG) |
|
||||||
|
| `marked_image_url` | str | 三选一 | 划线图片 URL |
|
||||||
|
| `marked_image_base64` | str | 三选一 | 划线图片 base64 |
|
||||||
|
| `use_mask` | bool | 否(默认True) | 是否自动检测划线并建遮罩。False时跳过检测,直接送划线图 |
|
||||||
|
| `prompt` | str | 否 | ComfyUI 提示词,默认"补充遮罩区域的头发,加一点美颜" |
|
||||||
|
|
||||||
|
### 返回
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"hair_growth_image_base64": "iVBORw0KGgo...(生发图 JPG base64)",
|
||||||
|
"hairline_type": "custom"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
错误码:
|
||||||
|
- `1001`: 无法识别人像 / 未检测到发际线划线
|
||||||
|
- `1007`: 处理失败
|
||||||
|
- `1008`: 图片格式不支持
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、完整调用链
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/hair/grow-b
|
||||||
|
│
|
||||||
|
├─ app.py hair_grow_b() [app.py:929]
|
||||||
|
│ ├─ resolve_image_bytes() → marked_raw 解析图片(file/url/base64三选一)
|
||||||
|
│ ├─ cv2.imdecode → marked_bgr 解码为 BGR
|
||||||
|
│ └─ run_in_threadpool(generate_grow_b, ...)
|
||||||
|
│
|
||||||
|
├─ service.py generate_grow_b(marked_bgr, use_mask, prompt) [service.py:381]
|
||||||
|
│ │
|
||||||
|
│ ├─ 步骤1:人脸检测 + 头发分割(仅 use_mask=True 时)
|
||||||
|
│ │ ├─ get_landmarker().detect(rgb) MediaPipe 478点人脸检测
|
||||||
|
│ │ │ → landmarks(无人脸返回 no_face)
|
||||||
|
│ │ ├─ get_parser().parse(rgb) SegFormer 面部分割(CPU ~0.9s)
|
||||||
|
│ │ │ → parse_map(int label map)
|
||||||
|
│ │ │
|
||||||
|
│ ├─ 步骤2:手绘发际线检测(仅 use_mask=True 时)
|
||||||
|
│ │ ├─ detect_marker_hairline(marked_bgr, landmarks, parse_map)
|
||||||
|
│ │ │ │ [marker_detect.py:41]
|
||||||
|
│ │ │ ├─ forehead_upper_region(landmarks) 额头上部 ROI
|
||||||
|
│ │ │ ├─ head_silhouette(parse_map) 头部轮廓 ROI
|
||||||
|
│ │ │ ├─ _blackhat(gray) 黑帽变换(响应比邻域暗的细结构)
|
||||||
|
│ │ │ ├─ _snap_anchor(bh, 左鬓角21) 左锚点吸附
|
||||||
|
│ │ │ ├─ _snap_anchor(bh, 右鬓角251) 右锚点吸附
|
||||||
|
│ │ │ ├─ route_through_array(cost, 左, 右) Dijkstra最小代价路径
|
||||||
|
│ │ │ └→ path (N,2) row,col(拒识返回 None → no_line)
|
||||||
|
│ │ │
|
||||||
|
│ │ ├─ path_to_curve_mask(path) 路径→曲线mask(uint8 0/255)
|
||||||
|
│ │ └─ mask_from_curve(curve_mask, landmarks, parse_map)
|
||||||
|
│ │ │ [mask.py]
|
||||||
|
│ │ ├─ _above_curve_region(curve_mask) 曲线以上区域
|
||||||
|
│ │ ├─ cv2.morphologyEx(闭运算) 填洞
|
||||||
|
│ │ ├─ 最大连通域
|
||||||
|
│ │ └─ 高斯羽化 → mask (uint8 0-255)
|
||||||
|
│ │
|
||||||
|
│ ├─ 步骤3:合成 RGBA PNG
|
||||||
|
│ │ ├─ compose_comfy_rgba(marked_bgr, mask) RGB=原图,alpha=255×(1-mask)
|
||||||
|
│ │ └─ PNG 编码 → rgba_png_bytes
|
||||||
|
│ │
|
||||||
|
│ └─ 步骤4:ComfyUI 生发
|
||||||
|
│ └─ comfyui.run(rgba_png_bytes, prompt) [comfyui.py:87]
|
||||||
|
│ ├─ 上传图片到 ComfyUI /upload/image
|
||||||
|
│ ├─ 加载工作流 add_hair.json
|
||||||
|
│ ├─ 替换节点26输入图 + 节点6随机seed + 节点60提示词
|
||||||
|
│ ├─ POST /prompt 提交工作流
|
||||||
|
│ ├─ 轮询 /history/{prompt_id}(间隔0.2s)
|
||||||
|
│ └─ GET /view 取回输出 PNG → grown_png
|
||||||
|
│
|
||||||
|
└─ 返回 {"grown_png": bytes, "status": "ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、用到的模型和外部服务
|
||||||
|
|
||||||
|
| 模型/服务 | 用途 | 位置 | 设备 |
|
||||||
|
|----------|------|------|------|
|
||||||
|
| **FaceLandmarker** (MediaPipe) | 478点人脸检测 | hairline/face_landmarks.py | CPU |
|
||||||
|
| **FaceParser** (SegFormer) | 面部分割(hair/skin/...) | hairline/face_parsing.py | CPU (5090不兼容cu121) |
|
||||||
|
| **ComfyUI** (Flux-2) | 生发图生成 | hairline/comfyui.py → :8188 | GPU |
|
||||||
|
|
||||||
|
**注意**:接口3 **不调用** change_hair 服务(:8801),不需要 swapHair。这是它与接口2女性流程的关键区别。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、核心算法:手绘发际线检测
|
||||||
|
|
||||||
|
### 5.1 为什么不用简单阈值?
|
||||||
|
|
||||||
|
手绘马克笔线条的灰度值与皮肤阴影、抬头纹等重叠,全局阈值无法区分。采用**黑帽变换 + Dijkstra最小路径**方案。
|
||||||
|
|
||||||
|
### 5.2 黑帽变换(Black Hat)
|
||||||
|
|
||||||
|
```python
|
||||||
|
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k))
|
||||||
|
bh = cv2.morphologyEx(gray, cv2.MORPH_BLACKHAT, kernel)
|
||||||
|
```
|
||||||
|
|
||||||
|
黑帽 = 闭运算 − 原图,响应"比局部邻域暗的细结构"(即马克笔线条),对抬头纹/眉毛/发丝鲁棒。
|
||||||
|
|
||||||
|
### 5.3 Dijkstra 最小代价路径
|
||||||
|
|
||||||
|
1. **ROI 限定**:额头上部 ∩ 头部轮廓(排除背景)
|
||||||
|
2. **锚点**:左鬓角(21) / 右鬓角(251) MediaPipe 关键点
|
||||||
|
3. **代价图**:`cost = (bh.max() - bh) + 1.0`,ROI外设 1e6
|
||||||
|
4. **路径**:`route_through_array(cost, 左锚, 右锚)` — skimage 的 Dijkstra 实现
|
||||||
|
|
||||||
|
### 5.4 拒识机制
|
||||||
|
|
||||||
|
路径平均黑帽响应 < 8.0 → 判定"未画线",返回 `no_line`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、与接口1、接口2 的对比
|
||||||
|
|
||||||
|
| 维度 | 接口1 | 接口2 | 接口3 |
|
||||||
|
|------|-------|-------|-------|
|
||||||
|
| 功能 | 四庭七眼测量 | C端生发(5种发际线) | B端生发(手绘线) |
|
||||||
|
| 路由 | /api/v1/face/measure | /api/v1/hair/grow | /api/v1/hair/grow-b |
|
||||||
|
| 输入 | 正面照 | 正面照 | 划线图 |
|
||||||
|
| MediaPipe | ✅ | ✅ | ✅ |
|
||||||
|
| SegFormer | ✅ | ✅ | ✅ |
|
||||||
|
| change_hair | ❌ | ✅(女性) | ❌ |
|
||||||
|
| ComfyUI | ❌ | ✅(Flux-2重绘) | ✅(Flux-2生发) |
|
||||||
|
| 典型耗时 | ~2s | ~11s | ~6-8s |
|
||||||
|
| ComfyUI工作流 | — | 0716add-hair-api.json | add_hair.json |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、测试
|
||||||
|
|
||||||
|
- **测试页面**:[static/test_interface3.html](file:///home/ubuntu/hair/static/test_interface3.html)
|
||||||
|
- **测试图片**:[image/girl_img/girl13.jpg](file:///home/ubuntu/hair/image/girl_img/girl13.jpg)(需手动在图上画发际线后作为划线图上传)
|
||||||
@@ -20,7 +20,6 @@
|
|||||||
| 3 B 端生发 | POST | `/api/v1/hair/grow-b` |
|
| 3 B 端生发 | POST | `/api/v1/hair/grow-b` |
|
||||||
| 4 用户特征 | POST | `/api/v1/face/features` |
|
| 4 用户特征 | POST | `/api/v1/face/features` |
|
||||||
| 5 发际线 PNG 生成 | POST | `/api/v1/hairline/generate` |
|
| 5 发际线 PNG 生成 | POST | `/api/v1/hairline/generate` |
|
||||||
| 7 C 端生发 v2 | POST | `/api/v1/hair/grow-v2` |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -87,6 +86,7 @@
|
|||||||
| 1006 | 文件超出大小限制 | 单文件超过 1 MB |
|
| 1006 | 文件超出大小限制 | 单文件超过 1 MB |
|
||||||
| 1007 | 图片参数错误 | file / url / base64 未传,或同时传了多个(三者严格互斥) |
|
| 1007 | 图片参数错误 | file / url / base64 未传,或同时传了多个(三者严格互斥) |
|
||||||
| 1008 | 图片格式不支持 | 非 JPG / PNG |
|
| 1008 | 图片格式不支持 | 非 JPG / PNG |
|
||||||
|
| 1009 | 未授权 | 缺少或错误的 `X-Internal-Token`(`/api/*` 路径鉴权) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -109,6 +109,8 @@
|
|||||||
| four_courts | object | 四庭数据,见下表 |
|
| four_courts | object | 四庭数据,见下表 |
|
||||||
| seven_eyes | object | 七眼数据,见下表 |
|
| seven_eyes | object | 七眼数据,见下表 |
|
||||||
| landmarks | object | 关键分界点坐标(头顶 / 发际线 / 眉心 / 鼻翼下缘 / 下巴尖),原图像素坐标 |
|
| landmarks | object | 关键分界点坐标(头顶 / 发际线 / 眉心 / 鼻翼下缘 / 下巴尖),原图像素坐标 |
|
||||||
|
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` |
|
||||||
|
| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:`{ "x": int, "y": int }` |
|
||||||
|
|
||||||
`four_courts`(四庭,自上而下):
|
`four_courts`(四庭,自上而下):
|
||||||
|
|
||||||
@@ -211,6 +213,8 @@
|
|||||||
| four_courts | object | 三庭数据(上/中/下庭,各含 cm 与 ratio;**无顶庭**) |
|
| four_courts | object | 三庭数据(上/中/下庭,各含 cm 与 ratio;**无顶庭**) |
|
||||||
| seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距 cm + 占比 ratios + **eye2~eye6** 共 5 段宽度) |
|
| seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距 cm + 占比 ratios + **eye2~eye6** 共 5 段宽度) |
|
||||||
| landmarks | object | 四个关键点像素坐标(发际线/眉心/鼻翼下缘/下巴尖) |
|
| landmarks | object | 四个关键点像素坐标(发际线/眉心/鼻翼下缘/下巴尖) |
|
||||||
|
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` |
|
||||||
|
| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:`{ "x": int, "y": int }` |
|
||||||
|
|
||||||
> 接口6 是**三庭五眼**:`four_courts`/`landmarks` 不含顶庭与头顶点(无 `top_court_cm`/`hair_top`);`seven_eyes` 只含 **eye2~eye6**(左脸颊/左眼/两眼间距/右眼/右脸颊,5 段),**无 eye1/eye7**(耳外段需头发轮廓端线,仅接口1 有)。
|
> 接口6 是**三庭五眼**:`four_courts`/`landmarks` 不含顶庭与头顶点(无 `top_court_cm`/`hair_top`);`seven_eyes` 只含 **eye2~eye6**(左脸颊/左眼/两眼间距/右眼/右脸颊,5 段),**无 eye1/eye7**(耳外段需头发轮廓端线,仅接口1 有)。
|
||||||
|
|
||||||
@@ -405,6 +409,7 @@
|
|||||||
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),决定返回哪些发际线类型。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight。缺失/越界/非法返回 `1007` |
|
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),决定返回哪些发际线类型。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight。缺失/越界/非法返回 `1007` |
|
||||||
| use_mask | bool | 否 | 生发是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
|
| use_mask | bool | 否 | 生发是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
|
||||||
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
|
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
|
||||||
|
| generate_grow_image | bool | 否 | 是否生成生发效果图(ComfyUI 生发,全流程最耗时),默认 `true`。传 `false` 时跳过生发,各发型 `grown_image_*` 恒为 `null`,仅返回三档发际线叠图与中心点,可大幅降低耗时 |
|
||||||
|
|
||||||
> ⚠️ 三档叠图分别用 `hairline_texture` / `hairline_texture_high` / `hairline_texture_low` 三套同名贴图;**生发黑模板固定取自 `hairline_texture_black/`(middle 档)**,即生发目标固定压到 middle 档,每个发型仅 1 张生发图。
|
> ⚠️ 三档叠图分别用 `hairline_texture` / `hairline_texture_high` / `hairline_texture_low` 三套同名贴图;**生发黑模板固定取自 `hairline_texture_black/`(middle 档)**,即生发目标固定压到 middle 档,每个发型仅 1 张生发图。
|
||||||
|
|
||||||
@@ -426,7 +431,7 @@
|
|||||||
| image_middle_url | string | middle 档发际线曲线**透明 PNG** URL(仅曲线,透明底,**不含人物**,需叠加原图显示) |
|
| image_middle_url | string | middle 档发际线曲线**透明 PNG** URL(仅曲线,透明底,**不含人物**,需叠加原图显示) |
|
||||||
| image_high_url | string | high 档发际线曲线**透明 PNG** URL(同上,high 档曲线) |
|
| image_high_url | string | high 档发际线曲线**透明 PNG** URL(同上,high 档曲线) |
|
||||||
| image_low_url | string | low 档发际线曲线**透明 PNG** URL(同上,low 档曲线) |
|
| image_low_url | string | low 档发际线曲线**透明 PNG** URL(同上,low 档曲线) |
|
||||||
| grown_image_url | string \| null | **生发后图片** URL(ComfyUI「植发」效果图,完整人像照片,生发失败时为 `null`) |
|
| grown_image_url | string \| null | **生发后图片** URL(ComfyUI「植发」效果图,完整人像照片,生发失败或 `generate_grow_image=false` 时为 `null`) |
|
||||||
| order | int | 发型序号(= 传入的 hair_style 值) |
|
| order | int | 发型序号(= 传入的 hair_style 值) |
|
||||||
|
|
||||||
> worker 侧返回 `image_middle_base64` / `image_high_base64` / `image_low_base64` / `grown_image_base64`,网关落盘后改写为上表对应的 `*_url`。
|
> worker 侧返回 `image_middle_base64` / `image_high_base64` / `image_low_base64` / `grown_image_base64`,网关落盘后改写为上表对应的 `*_url`。
|
||||||
@@ -443,6 +448,8 @@
|
|||||||
| landmarks | object | 5 个纵向关键点像素坐标(hair_top/hairline/brow_center/nose_bottom/chin_tip),结构同接口1 |
|
| landmarks | object | 5 个纵向关键点像素坐标(hair_top/hairline/brow_center/nose_bottom/chin_tip),结构同接口1 |
|
||||||
| hairline_source | string | 发际线来源:`segmentation`(真实分割)/ `estimated`(比例估算) |
|
| hairline_source | string | 发际线来源:`segmentation`(真实分割)/ `estimated`(比例估算) |
|
||||||
| head_pose | object | 头部姿态角度(yaw/pitch/roll,单位:度) |
|
| head_pose | object | 头部姿态角度(yaw/pitch/roll,单位:度) |
|
||||||
|
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` |
|
||||||
|
| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:`{ "x": int, "y": int }` |
|
||||||
|
|
||||||
> `eye1`~`eye7` 为从左到右共 7 段宽度,eye1=左耳外段、eye7=右耳外段,某侧耳朵不可见时对应段为 `null`。详见接口1说明。
|
> `eye1`~`eye7` 为从左到右共 7 段宽度,eye1=左耳外段、eye7=右耳外段,某侧耳朵不可见时对应段为 `null`。详见接口1说明。
|
||||||
|
|
||||||
@@ -508,60 +515,6 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 接口 7:C 端生发 v2 接口
|
|
||||||
|
|
||||||
**说明**:功能与[接口 2](#接口-2c-端生发接口)完全一致,仅 ComfyUI 工作流不同——使用 `add_hair2.json` 替代 `add_hair.json`。
|
|
||||||
|
|
||||||
**请求**:`POST /api/v1/hair/grow-v2`
|
|
||||||
|
|
||||||
### 输入
|
|
||||||
|
|
||||||
与接口 2 完全相同。图片参数见「通用约定 → 图片传参字段」。专属参数:
|
|
||||||
|
|
||||||
| 参数 | 类型 | 必填 | 说明 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| gender | string | **是** | 性别:`male` / `female`。决定使用的发际线贴图集合 |
|
|
||||||
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`)。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight。越界/非法返回 `1007` |
|
|
||||||
| beauty_enabled | bool | 否 | 生发图是否带美颜效果,默认 false(当前阶段不生效) |
|
|
||||||
| use_mask | bool | 否 | 是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线) |
|
|
||||||
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
|
|
||||||
|
|
||||||
### 输出(data)
|
|
||||||
|
|
||||||
与接口 2 完全相同。`results`:发际线方案数组,**数量 = 所选发型数**。每个元素:
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| image_url | string | 发际线曲线**透明 PNG** URL(仅曲线,透明底,**不含人物**,需叠加原图显示) |
|
|
||||||
| grown_image_url | string | **生发后图片** URL(ComfyUI/Flux「植发 3 个月」效果图,完整人像照片) |
|
|
||||||
| hairline_type | string | 发际线类型 key |
|
|
||||||
| order | int | 排序序号 |
|
|
||||||
|
|
||||||
> ⚠️ 与接口 2 的区别:本接口使用 `add_hair2.json` 工作流(Flux-2 Klein 9b),输入/遮罩节点同为 26,
|
|
||||||
> SaveImage 输出节点为 75。
|
|
||||||
|
|
||||||
### 响应示例
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 0,
|
|
||||||
"message": "success",
|
|
||||||
"request_id": "mock-request-id",
|
|
||||||
"data": {
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"image_url": "https://hair.xiangsilian.com/static/sample.jpg",
|
|
||||||
"grown_image_url": "https://hair.xiangsilian.com/static/sample.jpg",
|
|
||||||
"hairline_type": "ellipse",
|
|
||||||
"order": 1
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 汇总:输入输出一览
|
## 汇总:输入输出一览
|
||||||
|
|
||||||
| 接口 | 输入 | 主要输出 |
|
| 接口 | 输入 | 主要输出 |
|
||||||
@@ -572,7 +525,6 @@
|
|||||||
| 3 B 端生发 | 划线图片 | 最合适发际线图片 + 生发后图片 |
|
| 3 B 端生发 | 划线图片 | 最合适发际线图片 + 生发后图片 |
|
||||||
| 4 用户特征 | 用户照片 | 6 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格) |
|
| 4 用户特征 | 用户照片 | 6 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格) |
|
||||||
| 5 发际线 PNG | 用户照片 + gender + hair_style(多选) | 每个选中发型 middle/high/low 三档发际线叠图 + 生发图 + 最合适发际线面部中间点坐标 |
|
| 5 发际线 PNG | 用户照片 + gender + hair_style(多选) | 每个选中发型 middle/high/low 三档发际线叠图 + 生发图 + 最合适发际线面部中间点坐标 |
|
||||||
| 7 C 端生发 v2 | 用户照片 + gender + hair_style | 同接口2,使用 add_hair2.json 工作流 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
- 纵向竖线 8 条:人头最左 + 左脸颊/左眼外/内角/右眼内/外角/右脸颊 + 人头最右,
|
- 纵向竖线 8 条:人头最左 + 左脸颊/左眼外/内角/右眼内/外角/右脸颊 + 人头最右,
|
||||||
把头宽切 7 段(七眼),段宽数值上下交替(上 3 / 下 4),带虚线双箭头。
|
把头宽切 7 段(七眼),段宽数值上下交替(上 3 / 下 4),带虚线双箭头。
|
||||||
人头最左/最右取自耳朵分割外缘,看不到耳朵则省略该侧(最少 6 点 5 段)。
|
人头最左/最右取自耳朵分割外缘,看不到耳朵则省略该侧(最少 6 点 5 段)。
|
||||||
- 四庭:图片左侧,「名」上「数值」下两行换行(不带 cm),带竖向虚线双箭头。
|
- 四庭:图片左侧,「名」「数值(带 cm)」「百分比」三行换行,带竖向虚线双箭头。
|
||||||
- 五条横线右侧标名:头顶/发际线/眉心/鼻翼下缘/下巴尖。
|
- 五条横线右侧标名:头顶/发际线/眉心/鼻翼下缘/下巴尖。
|
||||||
- 单位 cm 统一标在底部「单位cm」。
|
- 每段数值直接带 cm 后缀,下方另起一行标百分比(不再单独标底部「单位cm」)。
|
||||||
中文字体用打包的思源黑体绝对路径加载,缺字体直接抛错(不静默降级成方块)。
|
中文字体用打包的思源黑体绝对路径加载,缺字体直接抛错(不静默降级成方块)。
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
@@ -189,9 +189,8 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
|
|||||||
取自耳朵分割掩膜的外缘(方案 B,BiSeNet 类 7/8);耳朵不可见(被头发/侧脸
|
取自耳朵分割掩膜的外缘(方案 B,BiSeNet 类 7/8);耳朵不可见(被头发/侧脸
|
||||||
遮挡 → 掩膜空)或无掩膜时省略该侧端线,只画对应脸颊线。
|
遮挡 → 掩膜空)或无掩膜时省略该侧端线,只画对应脸颊线。
|
||||||
- 横向 5 条分界线:头顶/发际线/眉心/鼻翼下缘/下巴尖,右侧标名。
|
- 横向 5 条分界线:头顶/发际线/眉心/鼻翼下缘/下巴尖,右侧标名。
|
||||||
- 四庭(顶/上/中/下庭)在左侧:名 + 数值两行换行(无 cm),竖向虚线双箭头。
|
- 四庭(顶/上/中/下庭)在左侧:名 + 数值(带 cm) + 百分比三行换行,竖向虚线双箭头。
|
||||||
- 七眼段宽数值上下交替(上 3 / 下 4,无 cm),横向虚线双箭头。
|
- 七眼段宽上下交替(上 3 / 下 4):数值(带 cm) 上、百分比(占头宽比)下,横向虚线双箭头。
|
||||||
- 底部统一标「单位cm」。
|
|
||||||
|
|
||||||
variant="v6"(接口6):去掉头顶横线与顶庭(只画发际线/眉心/鼻翼下缘/下巴尖 4 条
|
variant="v6"(接口6):去掉头顶横线与顶庭(只画发际线/眉心/鼻翼下缘/下巴尖 4 条
|
||||||
横线 + 上/中/下庭),竖线纵向范围改为发际线→下巴尖,且不画人头最左/最右端线
|
横线 + 上/中/下庭),竖线纵向范围改为发际线→下巴尖,且不画人头最左/最右端线
|
||||||
@@ -203,7 +202,7 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
|
|||||||
|
|
||||||
# --- 自适应尺寸:字号/线宽/虚线/箭头按短边缩放 ---
|
# --- 自适应尺寸:字号/线宽/虚线/箭头按短边缩放 ---
|
||||||
s = min(w, h)
|
s = min(w, h)
|
||||||
font_size = max(11, round(s * 0.026)) # 字体更小
|
font_size = max(8, round(s * 0.017)) # 字体更小
|
||||||
line_w = max(1, round(s * 0.0022))
|
line_w = max(1, round(s * 0.0022))
|
||||||
dash_len = max(4, round(s * 0.008))
|
dash_len = max(4, round(s * 0.008))
|
||||||
gap_len = max(2, round(dash_len * 0.7)) # 虚线更稠密(间隙<划线)
|
gap_len = max(2, round(dash_len * 0.7)) # 虚线更稠密(间隙<划线)
|
||||||
@@ -256,14 +255,14 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
|
|||||||
draw = ImageDraw.Draw(canvas)
|
draw = ImageDraw.Draw(canvas)
|
||||||
font = _load_font(font_size)
|
font = _load_font(font_size)
|
||||||
|
|
||||||
# --- 3a. 横线右侧:线名(头顶/发际线/眉心/鼻翼下缘/下巴尖),文字在线上方 ---
|
# --- 3a. 横线右侧:线名(头顶/发际线/眉心/鼻翼下缘/下巴尖),文字纵向居中对齐到线 ---
|
||||||
name_x = fx1 + pad
|
name_x = fx1 + over + pad # 移到横线右端外侧一点(往右)
|
||||||
name_gap = max(2, round(pad * 1.6)) # 文字底部到线的间距(再上移)
|
|
||||||
for i, name in enumerate(order):
|
for i, name in enumerate(order):
|
||||||
text = _LINE_NAMES[name]
|
text = _LINE_NAMES[name]
|
||||||
tw, th = _text_size(draw, text, font)
|
tw, _ = _text_size(draw, text, font)
|
||||||
x = min(name_x, w - 2 - tw) # 右侧越界时回收
|
x = min(name_x, w - 2 - tw) # 右侧越界时回收
|
||||||
draw.text((x, max(2, ys[i] - th - name_gap)), text, fill=LINE_COLOR, font=font)
|
# anchor="lm":x 为左、y 为竖直中点 → 文字中线正好压在横线上(与线对齐)
|
||||||
|
draw.text((x, ys[i]), text, fill=LINE_COLOR, font=font, anchor="lm")
|
||||||
|
|
||||||
# --- 3b. 左侧四庭:名 + 数值两行(无 cm)+ 竖向虚线双箭头 ---
|
# --- 3b. 左侧四庭:名 + 数值两行(无 cm)+ 竖向虚线双箭头 ---
|
||||||
if variant == "v6":
|
if variant == "v6":
|
||||||
@@ -276,6 +275,7 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
|
|||||||
court_name = ["顶庭", "上庭", "中庭", "下庭"]
|
court_name = ["顶庭", "上庭", "中庭", "下庭"]
|
||||||
n_court = 4
|
n_court = 4
|
||||||
arrow_x = max(arrow_size + 1, fx0 - pad) # 竖箭头所在 x(脸左侧,贴近最左竖线)
|
arrow_x = max(arrow_size + 1, fx0 - pad) # 竖箭头所在 x(脸左侧,贴近最左竖线)
|
||||||
|
court_total = sum(court_cm) or 1.0 # 各庭占比分母 = 四庭(v6 三庭)之和
|
||||||
for i in range(n_court):
|
for i in range(n_court):
|
||||||
y_a, y_b = ys[i], ys[i + 1]
|
y_a, y_b = ys[i], ys[i + 1]
|
||||||
# 竖向虚线双箭头,覆盖该庭高度(略收一点避免压到横线)
|
# 竖向虚线双箭头,覆盖该庭高度(略收一点避免压到横线)
|
||||||
@@ -283,43 +283,48 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
|
|||||||
draw_dashed_line_with_arrows(
|
draw_dashed_line_with_arrows(
|
||||||
draw, arrow_x, y_a + inset, arrow_x, y_b - inset,
|
draw, arrow_x, y_a + inset, arrow_x, y_b - inset,
|
||||||
dash_len=dash_len, gap_len=gap_len, arrow_size=arrow_size, width=line_w)
|
dash_len=dash_len, gap_len=gap_len, arrow_size=arrow_size, width=line_w)
|
||||||
# 名 + 数值两行,右对齐到箭头左侧
|
# 名 + 数值(带 cm) + 百分比三行,右对齐到箭头左侧
|
||||||
name = court_name[i]
|
name = court_name[i]
|
||||||
val = f"{court_cm[i]:.2f}"
|
val = f"{court_cm[i]:.2f}cm"
|
||||||
|
pct = f"{court_cm[i] / court_total * 100:.1f}%"
|
||||||
nw, _ = _text_size(draw, name, font)
|
nw, _ = _text_size(draw, name, font)
|
||||||
vw, _ = _text_size(draw, val, font)
|
vw, _ = _text_size(draw, val, font)
|
||||||
|
pw, _ = _text_size(draw, pct, font)
|
||||||
label_right = arrow_x - pad
|
label_right = arrow_x - pad
|
||||||
y_mid = (y_a + y_b) / 2
|
y_mid = (y_a + y_b) / 2
|
||||||
y_top = y_mid - line_h
|
y_top = y_mid - 1.5 * line_h
|
||||||
draw.text((max(2, label_right - nw), y_top), name, fill=LINE_COLOR, font=font)
|
draw.text((max(2, label_right - nw), y_top), name, fill=LINE_COLOR, font=font)
|
||||||
draw.text((max(2, label_right - vw), y_top + line_h), val, fill=LINE_COLOR, font=font)
|
draw.text((max(2, label_right - vw), y_top + line_h), val, fill=LINE_COLOR, font=font)
|
||||||
|
draw.text((max(2, label_right - pw), y_top + 2 * line_h), pct, fill=LINE_COLOR, font=font)
|
||||||
|
|
||||||
# --- 4. 七眼每段宽度:上下交替(上 3 / 下 4),横向虚线双箭头 + 数值(无 cm) ---
|
# --- 4. 七眼每段宽度:上下交替(上 3 / 下 4),横向虚线双箭头 + 数值(带 cm) + 百分比 ---
|
||||||
# 文字与箭头间留更大间距,避免文字压住箭头
|
# 每段两行:数值(带 cm) 上、百分比 下;百分比分母 = 整个头宽(七段之和)
|
||||||
txt_off = arrow_size + pad * 2
|
txt_off = arrow_size + pad * 2
|
||||||
y_arrow_top = max(txt_off + font_size + 2, fy0 - pad - arrow_size)
|
txt_block = 2 * line_h # 两行文字总高(数值 + 百分比)
|
||||||
y_arrow_bot = min(h - txt_off - font_size - 2, fy1 + pad + arrow_size)
|
y_arrow_top = max(txt_off + txt_block + 2, fy0 - pad - arrow_size)
|
||||||
|
y_arrow_bot = min(h - txt_off - txt_block - 2, fy1 + pad + arrow_size)
|
||||||
|
head_w = (xs[-1] - xs[0]) or 1.0 # 头宽(像素)= 百分比分母
|
||||||
for i in range(len(xs) - 1):
|
for i in range(len(xs) - 1):
|
||||||
x_a, x_b = xs[i], xs[i + 1]
|
x_a, x_b = xs[i], xs[i + 1]
|
||||||
if x_b - x_a < 1:
|
if x_b - x_a < 1:
|
||||||
continue
|
continue
|
||||||
seg_cm = (x_b - x_a) / pc
|
seg_cm = (x_b - x_a) / pc
|
||||||
|
seg_pct = (x_b - x_a) / head_w * 100
|
||||||
cx_seg = (x_a + x_b) / 2
|
cx_seg = (x_a + x_b) / 2
|
||||||
text = f"{seg_cm:.2f}"
|
val = f"{seg_cm:.2f}cm"
|
||||||
tw, th = _text_size(draw, text, font)
|
pct = f"{seg_pct:.1f}%"
|
||||||
|
vw, _ = _text_size(draw, val, font)
|
||||||
|
pw, _ = _text_size(draw, pct, font)
|
||||||
inset = min(arrow_size, (x_b - x_a) * 0.12)
|
inset = min(arrow_size, (x_b - x_a) * 0.12)
|
||||||
on_top = (i % 2 == 1) # 奇数段在上 → 上 3 / 下 4
|
on_top = (i % 2 == 1) # 奇数段在上 → 上 3 / 下 4
|
||||||
y_arrow = y_arrow_top if on_top else y_arrow_bot
|
y_arrow = y_arrow_top if on_top else y_arrow_bot
|
||||||
draw_dashed_line_with_arrows(
|
draw_dashed_line_with_arrows(
|
||||||
draw, x_a + inset, y_arrow, x_b - inset, y_arrow,
|
draw, x_a + inset, y_arrow, x_b - inset, y_arrow,
|
||||||
dash_len=dash_len, gap_len=gap_len, arrow_size=arrow_size, width=line_w)
|
dash_len=dash_len, gap_len=gap_len, arrow_size=arrow_size, width=line_w)
|
||||||
ty = (y_arrow - th - txt_off) if on_top else (y_arrow + txt_off)
|
# 数值行在上、百分比行在下;on_top 时整块置于箭头上方,否则下方
|
||||||
draw.text((cx_seg - tw / 2, ty), text, fill=LINE_COLOR, font=font)
|
text_top = (y_arrow - txt_off - txt_block) if on_top else (y_arrow + txt_off)
|
||||||
|
draw.text((cx_seg - vw / 2, text_top), val, fill=LINE_COLOR, font=font)
|
||||||
# --- 5. 底部统一单位 ---
|
draw.text((cx_seg - pw / 2, text_top + line_h), pct, fill=LINE_COLOR, font=font)
|
||||||
unit = "单位cm"
|
|
||||||
uw, uh = _text_size(draw, unit, font)
|
|
||||||
draw.text(((w - uw) / 2, h - uh - max(2, pad)), unit, fill=LINE_COLOR, font=font)
|
|
||||||
|
|
||||||
return canvas
|
return canvas
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ RIGHT_EYE_INNER = 362 # 右眼内角
|
|||||||
RIGHT_EYE_OUTER = 263 # 右眼外角
|
RIGHT_EYE_OUTER = 263 # 右眼外角
|
||||||
LEFT_CHEEK = 234 # 左脸颧弓(脸宽左端)
|
LEFT_CHEEK = 234 # 左脸颧弓(脸宽左端)
|
||||||
RIGHT_CHEEK = 454 # 右脸颧弓(脸宽右端)
|
RIGHT_CHEEK = 454 # 右脸颧弓(脸宽右端)
|
||||||
|
LEFT_POSITION = 21 # 左脸前侧定位点(脸颊/耳前区域,与 251 镜像)
|
||||||
|
RIGHT_POSITION = 251 # 右脸前侧定位点(与 21 镜像)
|
||||||
|
|
||||||
# --- 鼻尖(solvePnP 用,可选) ---
|
# --- 鼻尖(solvePnP 用,可选) ---
|
||||||
NOSE_TIP = 1 # 鼻尖(也有用 4 的版本)
|
NOSE_TIP = 1 # 鼻尖(也有用 4 的版本)
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ def _gray_b64(gray_float):
|
|||||||
def _red_mask_b64(mask_bool, h, w):
|
def _red_mask_b64(mask_bool, h, w):
|
||||||
"""布尔遮罩 → 纯红 alpha PNG data URI。
|
"""布尔遮罩 → 纯红 alpha PNG data URI。
|
||||||
遮罩区域 RGBA=(255,0,0,255),其余区域 RGBA=(0,0,0,0)。
|
遮罩区域 RGBA=(255,0,0,255),其余区域 RGBA=(0,0,0,0)。
|
||||||
供外部 ComfyUI 重绘服务(如 local_test)按 alpha 通道识别重绘区。
|
供 ComfyUI 重绘接口(/api/v1/redraw)按 alpha 通道识别重绘区。
|
||||||
|
|
||||||
注意:cv2.imencode 写 PNG 用的是 **BGRA** 顺序(B,G,R,A),所以要得到
|
注意:cv2.imencode 写 PNG 用的是 **BGRA** 顺序(B,G,R,A),所以要得到
|
||||||
浏览器显示的红色 R=255,需赋值 (B=0,G=0,R=255,A=255)。
|
浏览器显示的红色 R=255,需赋值 (B=0,G=0,R=255,A=255)。
|
||||||
@@ -357,7 +357,8 @@ def _redraw_band_mask(inner_pts, outer_pts, h, w, rid="", upper=None,
|
|||||||
|
|
||||||
|
|
||||||
def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm,
|
def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm,
|
||||||
hairline_push_cm=0.0, hairline_edge="column", rid="", render_viz=True):
|
hairline_push_cm=0.0, hairline_edge="column", rid="", render_viz=True,
|
||||||
|
hair_mask=None):
|
||||||
"""算出布尔遮罩 + 可视化。
|
"""算出布尔遮罩 + 可视化。
|
||||||
|
|
||||||
seg_model: bisenet | segformer。
|
seg_model: bisenet | segformer。
|
||||||
@@ -368,6 +369,7 @@ def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm
|
|||||||
render_viz: 是否生成各阶段叠图 overlay JPG(接口11 调试页用)。接口2/12 路径传 False
|
render_viz: 是否生成各阶段叠图 overlay JPG(接口11 调试页用)。接口2/12 路径传 False
|
||||||
可跳过 6+ 张 base64 编码,省 ~80ms;数据字段(_inner_pts/_outer_pts/_upper_mask/
|
可跳过 6+ 张 base64 编码,省 ~80ms;数据字段(_inner_pts/_outer_pts/_upper_mask/
|
||||||
mask_pixels 等)始终返回,不受影响。
|
mask_pixels 等)始终返回,不受影响。
|
||||||
|
hair_mask: 预计算的头发布尔遮罩(来自 SegFormer parse)。传入时跳过重复分割,省 ~0.9s。
|
||||||
返回 (mask_bool, viz_dict)。
|
返回 (mask_bool, viz_dict)。
|
||||||
"""
|
"""
|
||||||
lg = lambda msg: logger.info("[%s] %s", rid, msg) if rid else None
|
lg = lambda msg: logger.info("[%s] %s", rid, msg) if rid else None
|
||||||
@@ -381,13 +383,16 @@ def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm
|
|||||||
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())}")
|
lg(f"baseline 第一点={baseline_pts[0]} 末点={baseline_pts[-1]} upper像素={int(upper.sum())}")
|
||||||
|
|
||||||
if seg_model == "bisenet":
|
if hair_mask is None:
|
||||||
hair_mask = _bisenet_hair_mask(image_bgr, landmarks, w, h)
|
if seg_model == "bisenet":
|
||||||
elif seg_model == "segformer":
|
hair_mask = _bisenet_hair_mask(image_bgr, landmarks, w, h)
|
||||||
hair_mask = _segformer_hair_mask(image_bgr)
|
elif seg_model == "segformer":
|
||||||
|
hair_mask = _segformer_hair_mask(image_bgr)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"未知 seg_model: {seg_model}")
|
||||||
|
lg(f"头发分割完成 seg_model={seg_model} hair_pixels={int(hair_mask.sum())}")
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"未知 seg_model: {seg_model}")
|
lg(f"头发分割跳过(复用外部传入) hair_pixels={int(hair_mask.sum())}")
|
||||||
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) # 闭合区域:头发+额头,底=基线
|
||||||
@@ -849,7 +854,8 @@ def _grow_core(image_bgr, hairline_id, *, is_hr, seg_model, erode_cm, swap_mode,
|
|||||||
edge_erode_px, denoising_strength, gen_backend, hairgrow_strength,
|
edge_erode_px, denoising_strength, gen_backend, hairgrow_strength,
|
||||||
mb_levels, hairline_push_cm, hairline_edge, blend_method, color_match,
|
mb_levels, hairline_push_cm, hairline_edge, blend_method, color_match,
|
||||||
color_match_strength, mb_feather_px, transition_band_px,
|
color_match_strength, mb_feather_px, transition_band_px,
|
||||||
inpainting_fill, mask_blur, mask_dilate_scale, rid, render_viz=True):
|
inpainting_fill, mask_blur, mask_dilate_scale, rid, render_viz=True,
|
||||||
|
hair_mask=None):
|
||||||
"""接口11 共享核心:遮罩(pushed)→生成→硬贴回→接缝融合,产出 ④ final。
|
"""接口11 共享核心:遮罩(pushed)→生成→硬贴回→接缝融合,产出 ④ final。
|
||||||
|
|
||||||
不做任何重绘。返回中间产物 dict(供接口11 构造响应、接口12 取 final+重绘带用):
|
不做任何重绘。返回中间产物 dict(供接口11 构造响应、接口12 取 final+重绘带用):
|
||||||
@@ -879,7 +885,7 @@ def _grow_core(image_bgr, hairline_id, *, is_hr, seg_model, erode_cm, swap_mode,
|
|||||||
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,
|
hairline_push_cm=hairline_push_cm, hairline_edge=hairline_edge, rid=rid,
|
||||||
render_viz=render_viz)
|
render_viz=render_viz, hair_mask=hair_mask)
|
||||||
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()))
|
logger.info("[%s] 步骤1 遮罩完成 耗时=%dms mask_pixels=%d", rid, int(t_mask*1000), int(mask_bool.sum()))
|
||||||
|
|
||||||
@@ -1028,13 +1034,14 @@ def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="seg
|
|||||||
transition_band_px=-1,
|
transition_band_px=-1,
|
||||||
inpainting_fill=1, mask_blur=11, mask_dilate_scale=1.0,
|
inpainting_fill=1, mask_blur=11, mask_dilate_scale=1.0,
|
||||||
comfyui_prompt=None, beauty_alpha=0.6,
|
comfyui_prompt=None, beauty_alpha=0.6,
|
||||||
band_lo_mult=0.5, band_hi_mult=1.5, rid=None):
|
band_lo_mult=0.5, band_hi_mult=1.5, rid=None,
|
||||||
|
hair_mask=None):
|
||||||
"""接口12 发际线带重绘。内部先跑接口11 核心拿到 ④ final,再取 ⑤-① 发际线重绘带
|
"""接口12 发际线带重绘。内部先跑接口11 核心拿到 ④ final,再取 ⑤-① 发际线重绘带
|
||||||
(外推↔内推之间、经 baseline 截断只留上部)作遮罩。
|
(外推↔内推之间、经 baseline 截断只留上部)作遮罩。
|
||||||
|
|
||||||
**本接口不再做 Flux-2 重绘**:只产出 `final`(接缝融合基底)+ 纯红遮罩
|
**本接口不再做 Flux-2 重绘**:只产出 `final`(接缝融合基底)+ 纯红遮罩
|
||||||
`redraw_band_mask`(RGBA,遮罩区=(255,0,0,255)、其余全透明),重绘交给前端调
|
`redraw_band_mask`(RGBA,遮罩区=(255,0,0,255)、其余全透明),重绘交给后端
|
||||||
外部 ComfyUI 重绘服务(见 local_test)完成。旧的 `redraw_full` / `redraw_band`
|
ComfyUI 重绘接口(/api/v1/redraw)完成。旧的 `redraw_full` / `redraw_band`
|
||||||
字段保留为空,仅作结构兼容。
|
字段保留为空,仅作结构兼容。
|
||||||
|
|
||||||
返回可直接进 ok() 的 data dict。未检出人脸抛 NoFaceError。
|
返回可直接进 ok() 的 data dict。未检出人脸抛 NoFaceError。
|
||||||
@@ -1056,7 +1063,8 @@ def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="seg
|
|||||||
color_match=color_match, color_match_strength=color_match_strength,
|
color_match=color_match, color_match_strength=color_match_strength,
|
||||||
mb_feather_px=mb_feather_px, transition_band_px=transition_band_px,
|
mb_feather_px=mb_feather_px, transition_band_px=transition_band_px,
|
||||||
inpainting_fill=inpainting_fill, mask_blur=mask_blur,
|
inpainting_fill=inpainting_fill, mask_blur=mask_blur,
|
||||||
mask_dilate_scale=mask_dilate_scale, rid=rid, render_viz=False)
|
mask_dilate_scale=mask_dilate_scale, rid=rid, render_viz=False,
|
||||||
|
hair_mask=hair_mask)
|
||||||
final = core["final"]
|
final = core["final"]
|
||||||
mask_viz = core["mask_viz"]
|
mask_viz = core["mask_viz"]
|
||||||
w, h = core["w"], core["h"]
|
w, h = core["w"], core["h"]
|
||||||
@@ -1089,7 +1097,7 @@ def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="seg
|
|||||||
redraw_info = {"enabled": False, "error": f"band: {ex}"}
|
redraw_info = {"enabled": False, "error": f"band: {ex}"}
|
||||||
|
|
||||||
# ② Flux-2 重绘已下线:本接口现在只产出 final(接缝融合基底)+ 纯红重绘带遮罩,
|
# ② Flux-2 重绘已下线:本接口现在只产出 final(接缝融合基底)+ 纯红重绘带遮罩,
|
||||||
# 重绘交给前端调外部 ComfyUI 重绘服务(见 local_test)完成。
|
# 重绘交给后端 ComfyUI 重绘接口(/api/v1/redraw)完成。
|
||||||
# 下面保留 redraw_full_b64 / redraw_band_b64 为空,保持返回结构兼容(旧字段)。
|
# 下面保留 redraw_full_b64 / redraw_band_b64 为空,保持返回结构兼容(旧字段)。
|
||||||
redraw_full_b64 = ""
|
redraw_full_b64 = ""
|
||||||
redraw_band_b64 = ""
|
redraw_band_b64 = ""
|
||||||
@@ -1099,7 +1107,7 @@ def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="seg
|
|||||||
"hairline_id": hairline_id,
|
"hairline_id": hairline_id,
|
||||||
"blend_method": blend_method,
|
"blend_method": blend_method,
|
||||||
"hairline_push_cm": round(float(hairline_push_cm), 2),
|
"hairline_push_cm": round(float(hairline_push_cm), 2),
|
||||||
"comfyui_prompt": comfyui_prompt or "补充遮罩区域的头发,加一点美颜",
|
"comfyui_prompt": comfyui_prompt or "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜",
|
||||||
"beauty_alpha": beauty_alpha,
|
"beauty_alpha": beauty_alpha,
|
||||||
"px_per_cm": round(float(px_per_cm), 4),
|
"px_per_cm": round(float(px_per_cm), 4),
|
||||||
"mask_pixels": mask_viz["mask_pixels"],
|
"mask_pixels": mask_viz["mask_pixels"],
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from face_analysis.calibration import (
|
|||||||
from face_analysis.face_mesh_landmarks import (
|
from face_analysis.face_mesh_landmarks import (
|
||||||
GLABELLA_9, GLABELLA_151, NOSE_BOTTOM, CHIN_TIP,
|
GLABELLA_9, GLABELLA_151, NOSE_BOTTOM, CHIN_TIP,
|
||||||
LEFT_EYE_OUTER, LEFT_EYE_INNER, RIGHT_EYE_INNER, RIGHT_EYE_OUTER,
|
LEFT_EYE_OUTER, LEFT_EYE_INNER, RIGHT_EYE_INNER, RIGHT_EYE_OUTER,
|
||||||
LEFT_CHEEK, RIGHT_CHEEK,
|
LEFT_CHEEK, RIGHT_CHEEK, LEFT_POSITION, RIGHT_POSITION,
|
||||||
)
|
)
|
||||||
from face_analysis.hair_segmenter import locate_hairline_by_segmentation
|
from face_analysis.hair_segmenter import locate_hairline_by_segmentation
|
||||||
|
|
||||||
@@ -147,12 +147,17 @@ def measure_seven_eyes(landmarks, image_width, image_height):
|
|||||||
class MeasureResult:
|
class MeasureResult:
|
||||||
"""测量结果,提供 to_response() 输出与接口文档同构的 data 字段。"""
|
"""测量结果,提供 to_response() 输出与接口文档同构的 data 字段。"""
|
||||||
|
|
||||||
def __init__(self, vertical, eyes, px_per_cm, hairline_source, head_pose):
|
def __init__(self, vertical, eyes, px_per_cm, hairline_source, head_pose,
|
||||||
|
landmarks=None, image_width=None, image_height=None):
|
||||||
self.vertical = vertical
|
self.vertical = vertical
|
||||||
self.eyes = eyes
|
self.eyes = eyes
|
||||||
self.px_per_cm = px_per_cm
|
self.px_per_cm = px_per_cm
|
||||||
self.hairline_source = hairline_source
|
self.hairline_source = hairline_source
|
||||||
self.head_pose = head_pose # (yaw, pitch, roll) 或 None
|
self.head_pose = head_pose # (yaw, pitch, roll) 或 None
|
||||||
|
# 原始 mediapipe 点集 + 图像尺寸,供 to_response 输出 21/251 号定位点
|
||||||
|
self.landmarks = landmarks
|
||||||
|
self.w = image_width
|
||||||
|
self.h = image_height
|
||||||
|
|
||||||
# 各庭厘米
|
# 各庭厘米
|
||||||
self.top_cm = vertical["top_court_px"] / px_per_cm
|
self.top_cm = vertical["top_court_px"] / px_per_cm
|
||||||
@@ -207,6 +212,17 @@ class MeasureResult:
|
|||||||
},
|
},
|
||||||
"hairline_source": self.hairline_source,
|
"hairline_source": self.hairline_source,
|
||||||
}
|
}
|
||||||
|
# left/right_position:mediapipe 21/251 号定位点(原图像素,与 landmarks 同坐标系)。
|
||||||
|
# landmarks 缺省(如测试直构 MeasureResult)时不输出,保持向后兼容。
|
||||||
|
if self.landmarks is not None and self.w and self.h:
|
||||||
|
lm = _lm_list(self.landmarks)
|
||||||
|
|
||||||
|
def _pt_lm(idx):
|
||||||
|
px, py = normalized_to_pixel(lm[idx], self.w, self.h)
|
||||||
|
return {"x": int(round(px)), "y": int(round(py))}
|
||||||
|
|
||||||
|
data["left_position"] = _pt_lm(LEFT_POSITION)
|
||||||
|
data["right_position"] = _pt_lm(RIGHT_POSITION)
|
||||||
if self.head_pose is not None:
|
if self.head_pose is not None:
|
||||||
yaw, pitch, roll = self.head_pose
|
yaw, pitch, roll = self.head_pose
|
||||||
data["head_pose"] = {
|
data["head_pose"] = {
|
||||||
@@ -220,7 +236,8 @@ def measure_face(landmarks, hair_mask, image_width, image_height, head_pose=None
|
|||||||
vertical, source = decide_vertical(landmarks, image_width, image_height, hair_mask)
|
vertical, source = decide_vertical(landmarks, image_width, image_height, hair_mask)
|
||||||
eyes = measure_seven_eyes(landmarks, image_width, image_height)
|
eyes = measure_seven_eyes(landmarks, image_width, image_height)
|
||||||
px_per_cm = estimate_scale_factor(landmarks, image_width, image_height)
|
px_per_cm = estimate_scale_factor(landmarks, image_width, image_height)
|
||||||
return MeasureResult(vertical, eyes, px_per_cm, source, head_pose)
|
return MeasureResult(vertical, eyes, px_per_cm, source, head_pose,
|
||||||
|
landmarks, image_width, image_height)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -50,12 +50,21 @@ def estimate_head_pose(landmarks, image_width, image_height):
|
|||||||
[0, 0, 1]], dtype=np.float64)
|
[0, 0, 1]], dtype=np.float64)
|
||||||
dist = np.zeros((4, 1)) # 假设无畸变
|
dist = np.zeros((4, 1)) # 假设无畸变
|
||||||
|
|
||||||
success, rvec, _tvec = cv2.solvePnP(
|
success, rvec, tvec = cv2.solvePnP(
|
||||||
_MODEL_POINTS, image_points, cam_matrix, dist,
|
_MODEL_POINTS, image_points, cam_matrix, dist,
|
||||||
flags=cv2.SOLVEPNP_ITERATIVE,
|
flags=cv2.SOLVEPNP_ITERATIVE,
|
||||||
)
|
)
|
||||||
if not success:
|
if not success:
|
||||||
return None
|
return None
|
||||||
|
# ITERATIVE 偶发收敛到相机后方的翻转解(tz<0),此时 roll 落在 ±180° 附近,
|
||||||
|
# 会把真正的正面照误判为 1003。改用 SQPNP 重解正深度解。
|
||||||
|
if float(tvec[2, 0]) < 0:
|
||||||
|
ok2, rvec2, tvec2 = cv2.solvePnP(
|
||||||
|
_MODEL_POINTS, image_points, cam_matrix, dist,
|
||||||
|
flags=cv2.SOLVEPNP_SQPNP,
|
||||||
|
)
|
||||||
|
if ok2 and float(tvec2[2, 0]) > 0:
|
||||||
|
rvec = rvec2
|
||||||
rot, _ = cv2.Rodrigues(rvec)
|
rot, _ = cv2.Rodrigues(rvec)
|
||||||
# 在「相机坐标系」(x右 y下 z内) 下抽取 Tait-Bryan 欧拉角,物理含义对齐:
|
# 在「相机坐标系」(x右 y下 z内) 下抽取 Tait-Bryan 欧拉角,物理含义对齐:
|
||||||
# yaw = 绕 Y(竖轴)转 → 左右扭头
|
# yaw = 绕 Y(竖轴)转 → 左右扭头
|
||||||
|
|||||||
@@ -1,339 +0,0 @@
|
|||||||
INFO: Started server process [26440]
|
|
||||||
INFO: Waiting for application startup.
|
|
||||||
2026-07-01 23:35:15 [INFO] gateway.config: 配置加载完成 | workers=['http://127.0.0.1:8187'] | public_base_url=http://127.0.0.1:8080 | hc_interval=8s | dispatch_timeout=600s | queue_wait=30s
|
|
||||||
2026-07-01 23:35:15 [INFO] gateway: 网关启动中... workers=['http://127.0.0.1:8187']
|
|
||||||
2026-07-01 23:35:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:35:16 [INFO] gateway.pool: Worker 池初始化完成 | 总数=1 | 在线=1
|
|
||||||
2026-07-01 23:35:16 [INFO] gateway: 标注图目录: /home/ubuntu/hair/static/annotations
|
|
||||||
2026-07-01 23:35:16 [INFO] gateway.pool: 健康检查循环启动 | 间隔=8s | 下线阈值=2 | 上线阈值=1 | workers=1
|
|
||||||
2026-07-01 23:35:16 [INFO] gateway: 清理任务启动 | 间隔=60min | 保留=24h | 目录=/home/ubuntu/hair/static/annotations
|
|
||||||
INFO: Application startup complete.
|
|
||||||
INFO: Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit)
|
|
||||||
2026-07-01 23:35:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
INFO: 127.0.0.1:35872 - "GET /gateway-health HTTP/1.1" 200 OK
|
|
||||||
2026-07-01 23:35:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:35:27 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/face/measure "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:35:27 [INFO] gateway.forward: base64→URL: annotated_image_base64 → http://127.0.0.1:8080/static/annotations/422b7786adc4486989d7f8770df40693.png (21864 bytes)
|
|
||||||
INFO: 127.0.0.1:57220 - "POST /api/v1/face/measure HTTP/1.1" 200 OK
|
|
||||||
2026-07-01 23:35:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
INFO: 127.0.0.1:57232 - "GET /static/annotations/422b7786adc4486989d7f8770df40693.png HTTP/1.1" 200 OK
|
|
||||||
INFO: 127.0.0.1:57240 - "GET /gateway-health HTTP/1.1" 200 OK
|
|
||||||
2026-07-01 23:35:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:35:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:35:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:36:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:36:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:36:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:36:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:36:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:36:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:36:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:37:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:37:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:37:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:37:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:37:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:37:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:37:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:37:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:38:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:38:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:38:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:38:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:38:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:38:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:38:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:39:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:39:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:39:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:39:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:39:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:39:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:39:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:39:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:40:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:40:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:40:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:40:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:40:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:40:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:40:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:41:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:41:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:41:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:41:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:41:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:41:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:41:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:41:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:42:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:42:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:42:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:42:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:42:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:42:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:42:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:43:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:43:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:43:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:43:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:43:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:43:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:43:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:43:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:44:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:44:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:44:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:44:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:44:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:44:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:44:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:45:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:45:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:45:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:45:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:45:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:45:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:45:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:45:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:46:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:46:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:46:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:46:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:46:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:46:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:46:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:47:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:47:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:47:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:47:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:47:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:47:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:47:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:47:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:48:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:48:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:48:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:48:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:48:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:48:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:48:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:49:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:49:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:49:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:49:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:49:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:49:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:49:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:49:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:50:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:50:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:50:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:50:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:50:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:50:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:50:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:51:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:51:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:51:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:51:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:51:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:51:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:51:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:51:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:52:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
INFO: 127.0.0.1:44470 - "GET /gateway-health HTTP/1.1" 200 OK
|
|
||||||
2026-07-01 23:52:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:52:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:52:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:52:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:52:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:52:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:53:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:53:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:53:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:53:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:53:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:53:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:53:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
INFO: 111.192.98.24:6017 - "GET / HTTP/1.1" 200 OK
|
|
||||||
INFO: 111.192.98.24:6017 - "GET /favicon.ico HTTP/1.1" 404 Not Found
|
|
||||||
2026-07-01 23:53:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:54:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:54:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:54:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:54:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:54:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:54:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:54:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
INFO: 127.0.0.1:39090 - "GET /docs HTTP/1.1" 200 OK
|
|
||||||
INFO: 127.0.0.1:39092 - "GET / HTTP/1.1" 200 OK
|
|
||||||
2026-07-01 23:55:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:55:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
INFO: 127.0.0.1:56172 - "GET /static/test_interface1.html HTTP/1.1" 200 OK
|
|
||||||
INFO: 127.0.0.1:56174 - "GET /static/test_interface1.html HTTP/1.1" 200 OK
|
|
||||||
INFO: 127.0.0.1:56184 - "GET /static/test_interface2.html HTTP/1.1" 200 OK
|
|
||||||
INFO: 127.0.0.1:56196 - "GET /static/test_interface2.html HTTP/1.1" 200 OK
|
|
||||||
INFO: 127.0.0.1:56202 - "GET /static/test_interface3.html HTTP/1.1" 200 OK
|
|
||||||
INFO: 127.0.0.1:56206 - "GET /static/test_interface3.html HTTP/1.1" 200 OK
|
|
||||||
INFO: 127.0.0.1:56212 - "GET /static/test_interface4.html HTTP/1.1" 200 OK
|
|
||||||
INFO: 127.0.0.1:56214 - "GET /static/test_interface4.html HTTP/1.1" 200 OK
|
|
||||||
INFO: 127.0.0.1:56218 - "GET /static/test_interface5.html HTTP/1.1" 200 OK
|
|
||||||
INFO: 127.0.0.1:56230 - "GET /static/test_interface5.html HTTP/1.1" 200 OK
|
|
||||||
INFO: 127.0.0.1:56242 - "GET /static/test_interface6.html HTTP/1.1" 200 OK
|
|
||||||
INFO: 127.0.0.1:56256 - "GET /static/test_interface6.html HTTP/1.1" 200 OK
|
|
||||||
INFO: 127.0.0.1:56272 - "GET /static/test_interface7.html HTTP/1.1" 200 OK
|
|
||||||
INFO: 127.0.0.1:56278 - "GET /static/test_interface7.html HTTP/1.1" 200 OK
|
|
||||||
INFO: 127.0.0.1:56282 - "GET /static/integration.html HTTP/1.1" 200 OK
|
|
||||||
INFO: 127.0.0.1:56284 - "GET /static/integration.html HTTP/1.1" 200 OK
|
|
||||||
2026-07-01 23:55:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:55:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:55:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:55:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:55:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:55:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
INFO: 111.192.98.24:6434 - "GET /static/test_interface1.html HTTP/1.1" 200 OK
|
|
||||||
2026-07-01 23:56:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:56:08 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/face/measure "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:56:08 [INFO] gateway.forward: base64→URL: annotated_image_base64 → http://127.0.0.1:8080/static/annotations/8a70c12c19ef4868af555ef84eaeafae.png (35965 bytes)
|
|
||||||
INFO: 111.192.98.24:6435 - "POST /api/v1/face/measure HTTP/1.1" 200 OK
|
|
||||||
2026-07-01 23:56:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:56:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:56:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:56:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:56:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:56:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:57:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:57:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:57:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:57:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:57:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:57:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:57:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:57:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:58:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
INFO: Shutting down
|
|
||||||
INFO: Waiting for application shutdown.
|
|
||||||
2026-07-01 23:58:08 [INFO] gateway: 网关关闭中...
|
|
||||||
2026-07-01 23:58:08 [INFO] gateway: 清理任务已停止
|
|
||||||
2026-07-01 23:58:08 [INFO] gateway.pool: 健康检查循环已停止
|
|
||||||
2026-07-01 23:58:08 [INFO] gateway.pool: Worker 池已关闭
|
|
||||||
2026-07-01 23:58:08 [INFO] gateway: 网关已关闭
|
|
||||||
INFO: Application shutdown complete.
|
|
||||||
INFO: Finished server process [26440]
|
|
||||||
INFO: Started server process [31898]
|
|
||||||
INFO: Waiting for application startup.
|
|
||||||
2026-07-01 23:58:10 [INFO] gateway.config: 配置加载完成 | workers=['http://127.0.0.1:8187'] | public_base_url=http://117.50.213.111:8080 | hc_interval=8s | dispatch_timeout=600s | queue_wait=30s
|
|
||||||
2026-07-01 23:58:10 [INFO] gateway: 网关启动中... workers=['http://127.0.0.1:8187']
|
|
||||||
2026-07-01 23:58:10 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:58:10 [INFO] gateway.pool: Worker 池初始化完成 | 总数=1 | 在线=1
|
|
||||||
2026-07-01 23:58:10 [INFO] gateway: 标注图目录: /home/ubuntu/hair/static/annotations
|
|
||||||
2026-07-01 23:58:10 [INFO] gateway.pool: 健康检查循环启动 | 间隔=8s | 下线阈值=2 | 上线阈值=1 | workers=1
|
|
||||||
2026-07-01 23:58:10 [INFO] gateway: 清理任务启动 | 间隔=60min | 保留=24h | 目录=/home/ubuntu/hair/static/annotations
|
|
||||||
INFO: Application startup complete.
|
|
||||||
INFO: Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit)
|
|
||||||
2026-07-01 23:58:10 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
INFO: 127.0.0.1:33732 - "GET /gateway-health HTTP/1.1" 200 OK
|
|
||||||
2026-07-01 23:58:18 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:58:26 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:58:28 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/face/measure "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:58:28 [INFO] gateway.forward: base64→URL: annotated_image_base64 → http://117.50.213.111:8080/static/annotations/478efab4566a46c3af0065ad0ffafb67.png (21864 bytes)
|
|
||||||
INFO: 127.0.0.1:32982 - "POST /api/v1/face/measure HTTP/1.1" 200 OK
|
|
||||||
INFO: 117.50.213.111:57344 - "GET /static/annotations/478efab4566a46c3af0065ad0ffafb67.png HTTP/1.1" 200 OK
|
|
||||||
2026-07-01 23:58:34 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:58:42 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:58:50 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:58:54 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/face/measure "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:58:54 [INFO] gateway.forward: base64→URL: annotated_image_base64 → http://117.50.213.111:8080/static/annotations/88d5eec74def44fdad5dd6f7b22e7be4.png (35965 bytes)
|
|
||||||
INFO: 111.192.98.24:7030 - "POST /api/v1/face/measure HTTP/1.1" 200 OK
|
|
||||||
INFO: 111.192.98.24:7030 - "GET /static/annotations/88d5eec74def44fdad5dd6f7b22e7be4.png HTTP/1.1" 200 OK
|
|
||||||
2026-07-01 23:58:58 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
INFO: 111.192.98.24:7031 - "GET /static/test_interface2.html HTTP/1.1" 200 OK
|
|
||||||
2026-07-01 23:59:06 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:59:14 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:59:22 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:59:27 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/hair/grow "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:59:27 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/8f770fef8bc14827b419698c90ebd6fa.jpg (105970 bytes)
|
|
||||||
2026-07-01 23:59:27 [INFO] gateway.forward: base64→URL: grown_image_base64 → http://117.50.213.111:8080/static/annotations/6f592882d9c84c1b916ac327f4e0b2af.jpg (113320 bytes)
|
|
||||||
INFO: 111.192.98.24:7062 - "POST /api/v1/hair/grow HTTP/1.1" 200 OK
|
|
||||||
INFO: 111.192.98.24:7062 - "GET /static/annotations/8f770fef8bc14827b419698c90ebd6fa.jpg HTTP/1.1" 200 OK
|
|
||||||
INFO: 111.192.98.24:7166 - "GET /static/annotations/6f592882d9c84c1b916ac327f4e0b2af.jpg HTTP/1.1" 200 OK
|
|
||||||
2026-07-01 23:59:30 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:59:38 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:59:46 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:59:48 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/hair/grow "HTTP/1.1 200 OK"
|
|
||||||
2026-07-01 23:59:48 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/ed790d4888f742caa6625b3cf4704a77.jpg (105641 bytes)
|
|
||||||
2026-07-01 23:59:48 [INFO] gateway.forward: base64→URL: grown_image_base64 → http://117.50.213.111:8080/static/annotations/e4aed9d67e864d31909b30a58e08c16e.jpg (111495 bytes)
|
|
||||||
INFO: 111.192.98.24:5164 - "POST /api/v1/hair/grow HTTP/1.1" 200 OK
|
|
||||||
INFO: 111.192.98.24:5164 - "GET /static/annotations/ed790d4888f742caa6625b3cf4704a77.jpg HTTP/1.1" 200 OK
|
|
||||||
INFO: 111.192.98.24:5188 - "GET /static/annotations/e4aed9d67e864d31909b30a58e08c16e.jpg HTTP/1.1" 200 OK
|
|
||||||
2026-07-01 23:59:54 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
INFO: 111.192.98.24:5213 - "GET /static/test_interface3.html HTTP/1.1" 200 OK
|
|
||||||
2026-07-02 00:00:02 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-02 00:00:10 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-02 00:00:16 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/hair/grow-b "HTTP/1.1 200 OK"
|
|
||||||
2026-07-02 00:00:16 [INFO] gateway.forward: base64→URL: hair_growth_image_base64 → http://117.50.213.111:8080/static/annotations/0891a4b9375e4f58814502e893b12bf6.jpg (152092 bytes)
|
|
||||||
INFO: 111.192.98.24:5212 - "POST /api/v1/hair/grow-b HTTP/1.1" 200 OK
|
|
||||||
INFO: 111.192.98.24:5212 - "GET /static/annotations/0891a4b9375e4f58814502e893b12bf6.jpg HTTP/1.1" 200 OK
|
|
||||||
2026-07-02 00:00:18 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
INFO: 111.192.98.24:5332 - "GET /static/test_interface4.html HTTP/1.1" 200 OK
|
|
||||||
2026-07-02 00:00:26 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-02 00:00:34 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-02 00:00:39 [ERROR] gateway: 接口4 豆包调用失败
|
|
||||||
Traceback (most recent call last):
|
|
||||||
File "/home/ubuntu/hair/gateway/app.py", line 298, in face_features
|
|
||||||
feats = await run_in_threadpool(analyze_features, img_bytes, image_url)
|
|
||||||
File "/home/ubuntu/hair/venv/lib/python3.10/site-packages/starlette/concurrency.py", line 37, in run_in_threadpool
|
|
||||||
return await anyio.to_thread.run_sync(func)
|
|
||||||
File "/home/ubuntu/hair/venv/lib/python3.10/site-packages/anyio/to_thread.py", line 63, in run_sync
|
|
||||||
return await get_async_backend().run_sync_in_worker_thread(
|
|
||||||
File "/home/ubuntu/hair/venv/lib/python3.10/site-packages/anyio/_backends/_asyncio.py", line 2596, in run_sync_in_worker_thread
|
|
||||||
return await future
|
|
||||||
File "/home/ubuntu/hair/venv/lib/python3.10/site-packages/anyio/_backends/_asyncio.py", line 1029, in run
|
|
||||||
result = context.run(func, *args)
|
|
||||||
File "/home/ubuntu/hair/face_features.py", line 98, in analyze_features
|
|
||||||
resp = get_client().chat.completions.create(
|
|
||||||
File "/home/ubuntu/hair/face_features.py", line 65, in get_client
|
|
||||||
from volcenginesdkarkruntime import Ark
|
|
||||||
ModuleNotFoundError: No module named 'volcenginesdkarkruntime'
|
|
||||||
INFO: 111.192.98.24:5331 - "POST /api/v1/face/features HTTP/1.1" 200 OK
|
|
||||||
2026-07-02 00:00:42 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
INFO: 111.192.98.24:5436 - "GET /static/test_interface5.html HTTP/1.1" 200 OK
|
|
||||||
2026-07-02 00:00:50 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-02 00:00:58 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-02 00:01:00 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/hairline/generate "HTTP/1.1 200 OK"
|
|
||||||
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/4516a9491a1c41f4af048020f6709870.jpg (105674 bytes)
|
|
||||||
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/ad51e082bb5540ea843259cdc1e76e6b.jpg (105970 bytes)
|
|
||||||
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/12a6f7745c9c461ca963f4d49c5267ef.jpg (105735 bytes)
|
|
||||||
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/316d8352062848e4b28337ef233d820e.jpg (105641 bytes)
|
|
||||||
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/2ae45e3579b0419ab8bff5e3129ab26e.jpg (105705 bytes)
|
|
||||||
INFO: 111.192.98.24:5435 - "POST /api/v1/hairline/generate HTTP/1.1" 200 OK
|
|
||||||
INFO: 111.192.98.24:5435 - "GET /static/annotations/4516a9491a1c41f4af048020f6709870.jpg HTTP/1.1" 200 OK
|
|
||||||
INFO: 111.192.98.24:5466 - "GET /static/annotations/ad51e082bb5540ea843259cdc1e76e6b.jpg HTTP/1.1" 200 OK
|
|
||||||
INFO: 111.192.98.24:5467 - "GET /static/annotations/12a6f7745c9c461ca963f4d49c5267ef.jpg HTTP/1.1" 200 OK
|
|
||||||
INFO: 111.192.98.24:5469 - "GET /static/annotations/316d8352062848e4b28337ef233d820e.jpg HTTP/1.1" 200 OK
|
|
||||||
INFO: 111.192.98.24:5471 - "GET /static/annotations/2ae45e3579b0419ab8bff5e3129ab26e.jpg HTTP/1.1" 200 OK
|
|
||||||
2026-07-02 00:01:06 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-02 00:01:14 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-02 00:01:22 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-02 00:01:30 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-02 00:01:38 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-02 00:01:46 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-02 00:01:54 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-02 00:02:02 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-02 00:02:10 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-02 00:02:18 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-02 00:02:26 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-02 00:02:34 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-02 00:02:42 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-02 00:02:50 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
2026-07-02 00:02:58 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
|
||||||
INFO: Shutting down
|
|
||||||
INFO: Waiting for application shutdown.
|
|
||||||
2026-07-02 00:02:59 [INFO] gateway: 网关关闭中...
|
|
||||||
2026-07-02 00:02:59 [INFO] gateway: 清理任务已停止
|
|
||||||
2026-07-02 00:02:59 [INFO] gateway.pool: 健康检查循环已停止
|
|
||||||
2026-07-02 00:02:59 [INFO] gateway.pool: Worker 池已关闭
|
|
||||||
2026-07-02 00:02:59 [INFO] gateway: 网关已关闭
|
|
||||||
INFO: Application shutdown complete.
|
|
||||||
INFO: Finished server process [31898]
|
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Hair Worker (GPU) - 四庭七眼测量 接口1
|
Description=hair GPU worker FastAPI (0.0.0.0:8187)
|
||||||
After=network.target
|
After=network-online.target comfyui.service change_hair-hair.service
|
||||||
|
Wants=comfyui.service change_hair-hair.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=xsl
|
User=ubuntu
|
||||||
WorkingDirectory=/home/xsl/hair
|
WorkingDirectory=/home/ubuntu/hair
|
||||||
# 鉴权密码:优先 worker_config.json;也可在此用环境变量覆盖
|
ExecStart=/home/ubuntu/hair/venv/bin/uvicorn app:app --host 0.0.0.0 --port 8187
|
||||||
# Environment=WORKER_ACCEPT_PASSWORDS=your-strong-secret
|
Restart=on-failure
|
||||||
ExecStart=/home/xsl/hair/venv/bin/uvicorn app:app --host 0.0.0.0 --port 8187
|
RestartSec=5
|
||||||
Restart=always
|
|
||||||
RestartSec=3
|
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
@@ -170,10 +170,10 @@
|
|||||||
},
|
},
|
||||||
"16": {
|
"16": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"unet_name": "flux2.0/flux-2-klein-9b-fp8.safetensors",
|
"unet_name": "flux-2-klein-9b-Q4_K_M.gguf",
|
||||||
"weight_dtype": "fp8_e4m3fn"
|
"weight_dtype": "fp8_e4m3fn"
|
||||||
},
|
},
|
||||||
"class_type": "UNETLoader",
|
"class_type": "UnetLoaderGGUF",
|
||||||
"_meta": {
|
"_meta": {
|
||||||
"title": "UNet加载器"
|
"title": "UNet加载器"
|
||||||
}
|
}
|
||||||
@@ -410,7 +410,7 @@
|
|||||||
},
|
},
|
||||||
"60": {
|
"60": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"text": "补充遮罩区域的头发,加一点美颜"
|
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
||||||
},
|
},
|
||||||
"class_type": "JjkText",
|
"class_type": "JjkText",
|
||||||
"_meta": {
|
"_meta": {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
@@ -18,7 +19,7 @@ import uuid
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
COMFYUI_URL = os.getenv("COMFYUI_URL", "http://10.60.74.221:8188").rstrip("/")
|
COMFYUI_URL = os.getenv("COMFYUI_URL", "http://127.0.0.1:8188").rstrip("/")
|
||||||
_WORKFLOW_DEFAULT = os.getenv(
|
_WORKFLOW_DEFAULT = os.getenv(
|
||||||
"ADD_HAIR_WORKFLOW",
|
"ADD_HAIR_WORKFLOW",
|
||||||
os.path.join(os.path.dirname(os.path.dirname(__file__)), "add_hair.json"),
|
os.path.join(os.path.dirname(os.path.dirname(__file__)), "add_hair.json"),
|
||||||
@@ -29,6 +30,19 @@ _REPO = os.path.dirname(os.path.dirname(__file__))
|
|||||||
_INPUT_NODE = "26" # LoadImage:外部输入图(含 alpha 遮罩)
|
_INPUT_NODE = "26" # LoadImage:外部输入图(含 alpha 遮罩)
|
||||||
_SEED_NODE = "6" # RandomNoise
|
_SEED_NODE = "6" # RandomNoise
|
||||||
_PROMPT_NODE = "60" # JjkText:提示词
|
_PROMPT_NODE = "60" # JjkText:提示词
|
||||||
|
_UNET_NODE = "16" # UNETLoader / UnetLoaderGGUF:Flux 模型加载
|
||||||
|
_CLIP_NODE = "61" # CLIPLoader:qwen 文本编码器
|
||||||
|
|
||||||
|
# Flux 模型 → 配套文本编码器映射。切换 unet 时自动同步编码器,避免维度不匹配。
|
||||||
|
# 规则:4b 系列配 qwen_3_4b,9b 系列(fp8/GGUF)配 qwen_3_8b_fp8mixed。
|
||||||
|
def _clip_for_unet(unet_name: str) -> str | None:
|
||||||
|
"""根据 unet 文件名推断配套的文本编码器文件名;无法推断返回 None。"""
|
||||||
|
low = unet_name.lower()
|
||||||
|
if "4b" in low and "9b" not in low:
|
||||||
|
return "qwen_3_4b.safetensors"
|
||||||
|
if "9b" in low:
|
||||||
|
return "qwen_3_8b_fp8mixed.safetensors"
|
||||||
|
return None
|
||||||
|
|
||||||
_wf_cache: dict[str, dict] = {} # path → workflow JSON
|
_wf_cache: dict[str, dict] = {} # path → workflow JSON
|
||||||
_wf_output_node: dict[str, str] = {} # path → SaveImage 节点 ID
|
_wf_output_node: dict[str, str] = {} # path → SaveImage 节点 ID
|
||||||
@@ -85,11 +99,18 @@ def _get_output_node(workflow_path: str | None = None) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = None,
|
def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = None,
|
||||||
workflow_path: str | None = None) -> bytes:
|
workflow_path: str | None = None, front: bool = False,
|
||||||
|
unet_name: str | None = None) -> bytes:
|
||||||
"""提交一次生发任务,返回输出 PNG 字节。失败抛异常。
|
"""提交一次生发任务,返回输出 PNG 字节。失败抛异常。
|
||||||
|
|
||||||
prompt:非 None 时替换工作流节点60(JjkText)的文本;None 时用工作流内置默认提示词。
|
prompt:非 None 时替换工作流节点60(JjkText)的文本;None 时用工作流内置默认提示词。
|
||||||
workflow_path:工作流 JSON 路径,None 则用默认 add_hair.json。
|
workflow_path:工作流 JSON 路径,None 则用默认 add_hair.json。
|
||||||
|
front:True 时任务插到 ComfyUI 队列最前(server 端 "front" 字段,队列号取负)。
|
||||||
|
接口2 对时延敏感用 True,避免排在接口3/5 的批量任务后面;其余接口保持 False。
|
||||||
|
unet_name:非 None 时改写工作流里的模型加载节点(节点16),动态切换 Flux 模型。
|
||||||
|
.safetensors → 保持 UNETLoader 节点类型不变,只替换 unet_name;
|
||||||
|
.gguf → 自动把节点类型改成 UnetLoaderGGUF(需装 ComfyUI-GGUF 插件)。
|
||||||
|
None 时用工作流内置默认模型。
|
||||||
"""
|
"""
|
||||||
path = workflow_path or _WORKFLOW_DEFAULT
|
path = workflow_path or _WORKFLOW_DEFAULT
|
||||||
output_node = _get_output_node(path)
|
output_node = _get_output_node(path)
|
||||||
@@ -104,11 +125,34 @@ def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = Non
|
|||||||
name = (up.get("subfolder") + "/" if up.get("subfolder") else "") + up["name"]
|
name = (up.get("subfolder") + "/" if up.get("subfolder") else "") + up["name"]
|
||||||
|
|
||||||
# 2. 改工作流:节点26 输入图 + 随机 seed
|
# 2. 改工作流:节点26 输入图 + 随机 seed
|
||||||
|
try:
|
||||||
|
import io as _io
|
||||||
|
from PIL import Image as _Img
|
||||||
|
_sz = _Img.open(_io.BytesIO(rgba_png_bytes)).size
|
||||||
|
logging.getLogger("hair.worker").info(
|
||||||
|
"ComfyUI 输入尺寸 %dx%d workflow=%s", _sz[0], _sz[1], os.path.basename(path))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
wf = copy.deepcopy(_load_workflow(path))
|
wf = copy.deepcopy(_load_workflow(path))
|
||||||
wf[_INPUT_NODE]["inputs"]["image"] = name
|
wf[_INPUT_NODE]["inputs"]["image"] = name
|
||||||
wf[_SEED_NODE]["inputs"]["noise_seed"] = random.randint(0, 2**63 - 1)
|
wf[_SEED_NODE]["inputs"]["noise_seed"] = random.randint(0, 2**63 - 1)
|
||||||
if prompt is not None:
|
if prompt is not None:
|
||||||
wf[_PROMPT_NODE]["inputs"]["text"] = prompt
|
wf[_PROMPT_NODE]["inputs"]["text"] = prompt
|
||||||
|
if unet_name is not None:
|
||||||
|
node = wf.get(_UNET_NODE)
|
||||||
|
if node is not None:
|
||||||
|
# .gguf 需切换到 ComfyUI-GGUF 插件的 UnetLoaderGGUF 节点;
|
||||||
|
# .safetensors/.ckpt 保持原 UNETLoader 节点类型不变
|
||||||
|
if unet_name.lower().endswith(".gguf"):
|
||||||
|
node["class_type"] = "UnetLoaderGGUF"
|
||||||
|
else:
|
||||||
|
node["class_type"] = "UNETLoader"
|
||||||
|
node["inputs"]["unet_name"] = unet_name
|
||||||
|
# 同步切换配套文本编码器(4b→qwen_3_4b, 9b→qwen_3_8b),避免维度不匹配
|
||||||
|
clip_node = wf.get(_CLIP_NODE)
|
||||||
|
clip_name = _clip_for_unet(unet_name)
|
||||||
|
if clip_node is not None and clip_name is not None:
|
||||||
|
clip_node["inputs"]["clip_name"] = clip_name
|
||||||
|
|
||||||
# 诊断:落盘实际提交的工作流 + 输入图,便于和手动 ComfyUI 跑的对比
|
# 诊断:落盘实际提交的工作流 + 输入图,便于和手动 ComfyUI 跑的对比
|
||||||
try:
|
try:
|
||||||
@@ -125,8 +169,11 @@ def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = Non
|
|||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 3. 提交
|
# 3. 提交(front=True 时插队到队列最前)
|
||||||
r = cli.post("/prompt", json={"prompt": wf, "client_id": client_id})
|
payload = {"prompt": wf, "client_id": client_id}
|
||||||
|
if front:
|
||||||
|
payload["front"] = True
|
||||||
|
r = cli.post("/prompt", json=payload)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
prompt_id = r.json()["prompt_id"]
|
prompt_id = r.json()["prompt_id"]
|
||||||
|
|
||||||
@@ -145,7 +192,7 @@ def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = Non
|
|||||||
outputs = entry.get("outputs")
|
outputs = entry.get("outputs")
|
||||||
if outputs and output_node in outputs:
|
if outputs and output_node in outputs:
|
||||||
break
|
break
|
||||||
time.sleep(1.0)
|
time.sleep(0.05)
|
||||||
if not outputs or output_node not in outputs:
|
if not outputs or output_node not in outputs:
|
||||||
raise TimeoutError(f"ComfyUI 出图超时({timeout}s) prompt_id={prompt_id}")
|
raise TimeoutError(f"ComfyUI 出图超时({timeout}s) prompt_id={prompt_id}")
|
||||||
|
|
||||||
|
|||||||
@@ -186,6 +186,53 @@ def smooth_hairline_corner_aware(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def clamp_hairline_to_silhouette(
|
||||||
|
hairline_norm: np.ndarray,
|
||||||
|
parse_map: np.ndarray,
|
||||||
|
margin_px: float = 2.0,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""把发际线点的 y 钳制在 (skin∪hair) silhouette 上沿之下(不含 margin 以上)。
|
||||||
|
|
||||||
|
根因(见 issue:男性 ellipse 发际线贴到头部外面):`sample_hairline` 对射线
|
||||||
|
未命中 hair 像素的锚点会 fallback 成「锚点 + 固定 0.18 归一化偏移」,与头部实际
|
||||||
|
大小/位置无关 —— 短发/剃光头场景下这个偏移量常常把点顶到头部轮廓外面的背景里,
|
||||||
|
在有效/失效锚点交界处形成尖角,被贴图上的不透明像素蒙到就会露出戳出头部的线条。
|
||||||
|
|
||||||
|
本函数在几何检测之后追加一步「安全网」:对每个点按其 x 所在列,取 silhouette
|
||||||
|
(SegFormer skin∪hair 类,近似头部实际轮廓)上沿 y,若点比这个上沿还高(y 更
|
||||||
|
小),直接钳制到 上沿 + margin_px —— 保证曲线永远不会跑到头部轮廓外面的背景。
|
||||||
|
"""
|
||||||
|
h, w = parse_map.shape
|
||||||
|
cols_with_head, top_y = _head_top_y_per_column(parse_map, use_full_hair=True)
|
||||||
|
if cols_with_head.size == 0:
|
||||||
|
return hairline_norm
|
||||||
|
out = hairline_norm.copy()
|
||||||
|
for i in range(out.shape[0]):
|
||||||
|
x_px = float(out[i, 0]) * w
|
||||||
|
idx = int(np.searchsorted(cols_with_head, x_px))
|
||||||
|
idx = min(max(idx, 0), cols_with_head.size - 1)
|
||||||
|
sil_y = float(top_y[idx]) + margin_px
|
||||||
|
y_px = float(out[i, 1]) * h
|
||||||
|
if y_px < sil_y:
|
||||||
|
out[i, 1] = sil_y / h
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def sample_hairline_clamped(
|
||||||
|
landmarks_norm: np.ndarray,
|
||||||
|
parse_map: np.ndarray,
|
||||||
|
fallback_extrapolation: float = 0.18,
|
||||||
|
) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""策略 A(baseline + 头部轮廓钳制):与默认 `sample_hairline` 完全一致的检测,
|
||||||
|
额外用 `clamp_hairline_to_silhouette` 兜底 —— 检测失效 fallback 出的点不再可能
|
||||||
|
跑到头部外面的背景,而是贴着头部实际轮廓顶部。改动小、风险低,只在检测失效/
|
||||||
|
fallback 越界时才生效,正常长发照片的结果与 baseline 完全一致。
|
||||||
|
"""
|
||||||
|
hairline, valid = sample_hairline(landmarks_norm, parse_map, fallback_extrapolation)
|
||||||
|
hairline = clamp_hairline_to_silhouette(hairline, parse_map)
|
||||||
|
return hairline, valid
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Alternative hairline sampling strategies.
|
# Alternative hairline sampling strategies.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""直接调 ComfyUI 重绘 — 替代 local_test HTTP 服务。
|
||||||
|
|
||||||
|
将 local_test/app.py 的核心逻辑(遮罩处理 + ComfyUI 调用)提取为 Python 函数,
|
||||||
|
不再需要独立 Flask 服务。使用 0716add-hair-api.json 工作流(steps=4)。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image, ImageFilter
|
||||||
|
|
||||||
|
from . import comfyui
|
||||||
|
|
||||||
|
logger = logging.getLogger("hair.worker")
|
||||||
|
|
||||||
|
_DEFAULT_PROMPT = "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
||||||
|
_REPO = os.path.dirname(os.path.dirname(__file__))
|
||||||
|
_REPAINT_WORKFLOW = os.path.join(_REPO, "0716add-hair-api.json")
|
||||||
|
|
||||||
|
|
||||||
|
def _process_mask_to_rgba(image_bytes: bytes, mask_bytes: bytes) -> bytes:
|
||||||
|
"""将分开的 image + mask 处理为 ComfyUI 用的 RGBA PNG bytes。
|
||||||
|
|
||||||
|
复制 local_test/app.py 的遮罩处理逻辑:
|
||||||
|
1. 加载 image 为 RGB
|
||||||
|
2. 加载 mask 为 RGBA,取所有通道 max 值(支持红/白/alpha 遮罩)
|
||||||
|
3. resize mask 到与 image 一致
|
||||||
|
4. 高斯模糊(radius=4) 柔化边缘
|
||||||
|
5. alpha = 255 - mask(绘制区=255 → alpha=0 → 重绘区)
|
||||||
|
6. 合成 RGBA PNG
|
||||||
|
"""
|
||||||
|
image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
|
||||||
|
mask_img = Image.open(io.BytesIO(mask_bytes)).convert("RGBA")
|
||||||
|
mask_arr = np.array(mask_img)
|
||||||
|
mask_data = np.max(mask_arr, axis=2) # (H, W) uint8
|
||||||
|
|
||||||
|
mask_data_img = Image.fromarray(mask_data, mode="L")
|
||||||
|
if mask_data_img.size != image.size:
|
||||||
|
mask_data_img = mask_data_img.resize(image.size, Image.LANCZOS)
|
||||||
|
mask_data_img = mask_data_img.filter(ImageFilter.GaussianBlur(radius=4))
|
||||||
|
|
||||||
|
# ComfyUI LoadImage: mask = 1.0 - (alpha/255)
|
||||||
|
# alpha=0 -> mask=1.0 (inpaint), alpha=255 -> mask=0.0 (keep)
|
||||||
|
comfyui_alpha = Image.eval(mask_data_img, lambda x: 255 - x)
|
||||||
|
|
||||||
|
r, g, b = image.split()
|
||||||
|
rgba = Image.merge("RGBA", (r, g, b, comfyui_alpha))
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
rgba.save(buf, format="PNG")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def run_redraw(image_bytes: bytes, mask_bytes: bytes,
|
||||||
|
prompt: str | None = None, timeout: float = 300.0,
|
||||||
|
front: bool = False, unet_name: str | None = None) -> bytes:
|
||||||
|
"""直接调 ComfyUI 重绘 — 替代 local_test /api/generate。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_bytes: 人物图片字节(JPG/PNG)
|
||||||
|
mask_bytes: 遮罩图片字节(支持红/白/alpha 遮罩格式)
|
||||||
|
prompt: 提示词,None 用默认 "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
||||||
|
timeout: ComfyUI 超时秒数
|
||||||
|
front: True 时任务插到 ComfyUI 队列最前(接口2 时延敏感路径用)
|
||||||
|
unet_name: 非 None 时切换 Flux 模型(如 flux-2-klein-9b-Q5_K_M.gguf),None 用工作流默认
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
重绘后的 PNG 图片字节
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: ComfyUI 执行失败
|
||||||
|
TimeoutError: ComfyUI 超时
|
||||||
|
"""
|
||||||
|
rgba_png = _process_mask_to_rgba(image_bytes, mask_bytes)
|
||||||
|
return comfyui.run(rgba_png, timeout=timeout, prompt=prompt,
|
||||||
|
workflow_path=_REPAINT_WORKFLOW, front=front,
|
||||||
|
unet_name=unet_name)
|
||||||
@@ -14,7 +14,9 @@ from . import constants as C
|
|||||||
from . import comfyui
|
from . import comfyui
|
||||||
from .face_landmarks import FaceLandmarker
|
from .face_landmarks import FaceLandmarker
|
||||||
from .face_parsing import FaceParser
|
from .face_parsing import FaceParser
|
||||||
from .hairline_2d import sample_hairline, smooth_hairline
|
from .hairline_2d import (
|
||||||
|
smooth_hairline, sample_hairline_clamped,
|
||||||
|
)
|
||||||
from .lift_3d import lift_hairline_to_3d, build_middle_row, assemble_full
|
from .lift_3d import lift_hairline_to_3d, build_middle_row, assemble_full
|
||||||
from .render import load_ext_mesh, load_texture_rgba, render_hairline_overlay, build_overlay_layer
|
from .render import load_ext_mesh, load_texture_rgba, render_hairline_overlay, build_overlay_layer
|
||||||
from .mask import build_inpaint_mask, compose_comfy_rgba, mask_from_curve
|
from .mask import build_inpaint_mask, compose_comfy_rgba, mask_from_curve
|
||||||
@@ -40,27 +42,52 @@ _REPO = os.path.dirname(os.path.dirname(__file__))
|
|||||||
_TEXTURE_DIR = os.path.join(_REPO, "hairline_texture")
|
_TEXTURE_DIR = os.path.join(_REPO, "hairline_texture")
|
||||||
_BLACK_TEXTURE_DIR = os.path.join(_REPO, "hairline_texture_black")
|
_BLACK_TEXTURE_DIR = os.path.join(_REPO, "hairline_texture_black")
|
||||||
|
|
||||||
# 外部重绘服务(local_test,0716add-hair.json 工作流)。接口2 female 用它替代原 Flux-2 重绘。
|
# 三接口(接口2女重绘 / 接口2男 / 接口3)统一的 ComfyUI 重绘 prompt。
|
||||||
# 重绘服务已独立到远程机器;可用 HAIR_LOCAL_REDRAW_URL 覆盖。
|
# 关键:ComfyUI 单卡显存装不下 Flux(7.7G)+qwen CLIP(3.9G) 同驻,靠缓存 CLIP 文本条件避免重载。
|
||||||
# gpu_worker 同内网走 10.60.74.221,外网(本地开发机)需走公网 117.50.183.232。
|
# prompt 不同会使缓存失效 → 重载 CLIP 并挤出 Flux(每次 +4s)。三接口用同一字符串即可全程命中。
|
||||||
_LOCAL_REDRAW_URL = os.getenv("HAIR_LOCAL_REDRAW_URL", "http://10.60.74.221:8899").rstrip("/")
|
# 与 app.py 接口2/接口3 的默认 prompt 保持一致;可用 REDRAW_PROMPT 覆盖。
|
||||||
|
_REDRAW_PROMPT = os.getenv("REDRAW_PROMPT", "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
|
||||||
|
|
||||||
|
# 接口2 女重绘整条管线(swapHair + ComfyUI)送模型前限边。真实照片常达 1257x1495:
|
||||||
|
# 全分辨率 ComfyUI 重绘要 13~21s 且激活显存把模型挤出。女性路径含 swapHair(SD WebUI ~5.3s
|
||||||
|
# 固定地板) + ComfyUI 两段串行。1024 档画质更好但部分大图会踩 12s 线,
|
||||||
|
# 默认压到 896 兜底(ComfyUI ~4s,女性总耗时 9~11s);追画质可设 REDRAW_MAX_SIDE=1024。
|
||||||
|
_REDRAW_MAX_SIDE = int(os.getenv("REDRAW_MAX_SIDE", "896"))
|
||||||
|
|
||||||
def _call_local_redraw(image_png_bytes, mask_png_bytes, timeout=300.0):
|
def _call_local_redraw(image_png_bytes, mask_png_bytes, timeout=300.0,
|
||||||
"""调外部重绘服务(local_test /api/generate):传 final 图 + 纯红遮罩 PNG,
|
max_side=None, unet_name=None):
|
||||||
返回重绘后的 PNG bytes。失败抛异常(调用方负责 try/except 跳过)。
|
"""直接调 ComfyUI 重绘(替代原 local_test HTTP 服务)。
|
||||||
|
|
||||||
|
传 final 图 + 纯红遮罩 PNG,返回重绘后的 PNG bytes。
|
||||||
|
失败抛异常(调用方负责 try/except 跳过)。
|
||||||
|
|
||||||
|
max_side:送 ComfyUI 前长边压到多少像素,None 用全局默认 _REDRAW_MAX_SIDE。
|
||||||
|
unet_name:非 None 时切换 Flux 模型,None 用工作流内置默认。
|
||||||
"""
|
"""
|
||||||
import requests
|
from .redraw import run_redraw
|
||||||
files = {
|
eff_side = _REDRAW_MAX_SIDE if max_side is None else max_side
|
||||||
"image": ("final.jpg", image_png_bytes, "image/jpeg"),
|
img = cv2.imdecode(np.frombuffer(image_png_bytes, np.uint8), cv2.IMREAD_UNCHANGED)
|
||||||
"mask": ("mask.png", mask_png_bytes, "image/png"),
|
scale = 1.0
|
||||||
}
|
orig_w = orig_h = 0
|
||||||
resp = requests.post(_LOCAL_REDRAW_URL + "/api/generate", files=files, timeout=timeout)
|
if img is not None:
|
||||||
resp.raise_for_status()
|
orig_h, orig_w = img.shape[:2]
|
||||||
if "image/" not in resp.headers.get("Content-Type", ""):
|
m = max(orig_h, orig_w)
|
||||||
# 服务返回了 JSON 错误
|
if eff_side > 0 and m > eff_side:
|
||||||
raise RuntimeError(f"local_test 返回非图片: {resp.text[:200]}")
|
scale = eff_side / float(m)
|
||||||
return resp.content
|
nw, nh = max(1, round(orig_w * scale)), max(1, round(orig_h * scale))
|
||||||
|
msk = cv2.imdecode(np.frombuffer(mask_png_bytes, np.uint8), cv2.IMREAD_UNCHANGED)
|
||||||
|
img_s = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_AREA)
|
||||||
|
msk_s = cv2.resize(msk, (nw, nh), interpolation=cv2.INTER_NEAREST)
|
||||||
|
image_png_bytes = cv2.imencode(".png", img_s)[1].tobytes()
|
||||||
|
mask_png_bytes = cv2.imencode(".png", msk_s)[1].tobytes()
|
||||||
|
logger.info("接口2女 缩图送 Comfy: %dx%d → %dx%d (max_side=%d)",
|
||||||
|
orig_w, orig_h, nw, nh, eff_side)
|
||||||
|
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前,避免排在接口3/5 的批量任务后面
|
||||||
|
out = run_redraw(image_png_bytes, mask_png_bytes, timeout=timeout,
|
||||||
|
prompt=_REDRAW_PROMPT, front=True, unet_name=unet_name)
|
||||||
|
if scale < 1.0 and out:
|
||||||
|
out = _upscale_png_to(out, orig_w, orig_h)
|
||||||
|
return out
|
||||||
|
|
||||||
# 发际线贴图档位:middle=默认(hairline_texture/),high/low 各自独立文件夹。
|
# 发际线贴图档位:middle=默认(hairline_texture/),high/low 各自独立文件夹。
|
||||||
_TEXTURE_DIRS = {
|
_TEXTURE_DIRS = {
|
||||||
@@ -69,9 +96,8 @@ _TEXTURE_DIRS = {
|
|||||||
"low": os.path.join(_REPO, "hairline_texture_low"),
|
"low": os.path.join(_REPO, "hairline_texture_low"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# ⚠️ 本 worker 是 RTX 5090(sm_120),torch 2.2.2(cu121) 只编到 sm_90,CUDA 跑算子会报
|
# torch 2.7.1+cu128 已支持 RTX 5090 (sm_120),SegFormer 走 GPU(~0.05s/张)
|
||||||
# "no kernel image"。SegFormer 默认走 CPU(~2.5s/张)。换 torch cu128 后可设 SEG_DEVICE=cuda。
|
_SEG_DEVICE = os.getenv("SEG_DEVICE", "cuda")
|
||||||
_SEG_DEVICE = os.getenv("SEG_DEVICE", "cpu")
|
|
||||||
|
|
||||||
_landmarker = None
|
_landmarker = None
|
||||||
_parser = None
|
_parser = None
|
||||||
@@ -133,13 +159,19 @@ def extract_502(image_bgr: np.ndarray):
|
|||||||
|
|
||||||
|
|
||||||
def extract_context(image_bgr: np.ndarray):
|
def extract_context(image_bgr: np.ndarray):
|
||||||
"""照片(BGR) → {landmarks, parse_map, points, valid}。无人脸返回 None。"""
|
"""照片(BGR) → {landmarks, parse_map, points, valid}。无人脸返回 None。
|
||||||
|
|
||||||
|
发际线几何检测固定用 `sample_hairline_clamped`(射线检测 + 头部轮廓钳制):
|
||||||
|
短发/剃光头照片(如 man_test.jpg)中间锚点检测失效时,纯射线检测的固定 fallback
|
||||||
|
偏移会把点顶到头部轮廓外面的背景,产生"发际线贴到头部外面"的视觉 bug;钳制兜底后
|
||||||
|
fallback 点不会再跑出头部轮廓,正常长发照片结果与旧行为一致。
|
||||||
|
"""
|
||||||
rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
|
rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
|
||||||
landmarks = get_landmarker().detect(rgb)
|
landmarks = get_landmarker().detect(rgb)
|
||||||
if landmarks is None:
|
if landmarks is None:
|
||||||
return None
|
return None
|
||||||
parse_map = get_parser().parse(rgb)
|
parse_map = get_parser().parse(rgb)
|
||||||
hairline_2d, valid = sample_hairline(landmarks, parse_map)
|
hairline_2d, valid = sample_hairline_clamped(landmarks, parse_map)
|
||||||
hairline_2d = smooth_hairline(hairline_2d, valid)
|
hairline_2d = smooth_hairline(hairline_2d, valid)
|
||||||
hairline_3d = lift_hairline_to_3d(landmarks, hairline_2d)
|
hairline_3d = lift_hairline_to_3d(landmarks, hairline_2d)
|
||||||
middle_3d = build_middle_row(landmarks, hairline_3d)
|
middle_3d = build_middle_row(landmarks, hairline_3d)
|
||||||
@@ -173,7 +205,8 @@ def generate_previews(image_bgr: np.ndarray, gender: str):
|
|||||||
|
|
||||||
def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = True,
|
def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = True,
|
||||||
prompt: str = None, hair_styles: list[int] | None = None,
|
prompt: str = None, hair_styles: list[int] | None = None,
|
||||||
workflow_path: str | None = None):
|
workflow_path: str | None = None,
|
||||||
|
unet_name: str | None = None):
|
||||||
"""指定发际线类型:发际线透明叠图(白线 RGBA) + 生发图(ComfyUI)。
|
"""指定发际线类型:发际线透明叠图(白线 RGBA) + 生发图(ComfyUI)。
|
||||||
|
|
||||||
hair_styles(1-indexed 列表):指定生成哪几张发际线(按贴图排序)。female: 1..5,male: 1..4。
|
hair_styles(1-indexed 列表):指定生成哪几张发际线(按贴图排序)。female: 1..5,male: 1..4。
|
||||||
@@ -204,9 +237,15 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
|
|||||||
if not use_mask:
|
if not use_mask:
|
||||||
try:
|
try:
|
||||||
h, w = image_bgr.shape[:2]
|
h, w = image_bgr.shape[:2]
|
||||||
|
img_s, msk_s, gsc = _prep_comfy_input(image_bgr, np.zeros((h, w), np.uint8))
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
compose_comfy_rgba(image_bgr, np.zeros((h, w), np.uint8)).save(buf, format="PNG")
|
compose_comfy_rgba(img_s, msk_s).save(buf, format="PNG", compress_level=1)
|
||||||
shared_grown = comfyui.run(buf.getvalue(), prompt=prompt, workflow_path=workflow_path)
|
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前
|
||||||
|
shared_grown = comfyui.run(buf.getvalue(), prompt=prompt,
|
||||||
|
workflow_path=workflow_path, front=True,
|
||||||
|
unet_name=unet_name)
|
||||||
|
if gsc < 1.0 and shared_grown:
|
||||||
|
shared_grown = _upscale_png_to(shared_grown, w, h)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
logger.warning("接口2 生发图失败(无遮罩):%s", e)
|
logger.warning("接口2 生发图失败(无遮罩):%s", e)
|
||||||
|
|
||||||
@@ -224,9 +263,15 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
|
|||||||
black = load_texture_rgba(_black_texture_path(white_path))
|
black = load_texture_rgba(_black_texture_path(white_path))
|
||||||
marked, mask = build_inpaint_mask(
|
marked, mask = build_inpaint_mask(
|
||||||
image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black)
|
image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black)
|
||||||
|
m_s, msk_s, gsc = _prep_comfy_input(marked, mask)
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
compose_comfy_rgba(marked, mask).save(buf, format="PNG")
|
compose_comfy_rgba(m_s, msk_s).save(buf, format="PNG", compress_level=1)
|
||||||
grown_png = comfyui.run(buf.getvalue(), prompt=prompt, workflow_path=workflow_path)
|
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前
|
||||||
|
grown_png = comfyui.run(buf.getvalue(), prompt=prompt,
|
||||||
|
workflow_path=workflow_path, front=True,
|
||||||
|
unet_name=unet_name)
|
||||||
|
if gsc < 1.0 and grown_png:
|
||||||
|
grown_png = _upscale_png_to(grown_png, w, h)
|
||||||
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
|
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
|
||||||
logger.warning("接口2 生发图失败 type=%s:%s", key, e)
|
logger.warning("接口2 生发图失败 type=%s:%s", key, e)
|
||||||
|
|
||||||
@@ -236,25 +281,31 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
|
|||||||
|
|
||||||
|
|
||||||
def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | None,
|
def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | None,
|
||||||
redraw_defaults: dict):
|
redraw_defaults: dict,
|
||||||
|
redraw_max_side: int | None = None,
|
||||||
|
unet_name: str | None = None):
|
||||||
"""接口2 女性专用:发际线透明叠图(同 generate_grow_results)+ 换发型重绘图。
|
"""接口2 女性专用:发际线透明叠图(同 generate_grow_results)+ 换发型重绘图。
|
||||||
|
|
||||||
grown 图来源(新流程):对每个选中发型把 female key 映射到 change_hair 的 chang_* hair_id,
|
grown 图来源(新流程):对每个选中发型把 female key 映射到 change_hair 的 chang_* hair_id,
|
||||||
调 face_analysis.hairline_grow.generate_hairline_redraw(= 接口12 final 管线,参数用
|
调 face_analysis.hairline_grow.generate_hairline_redraw(= 接口12 final 管线,参数用
|
||||||
redraw_defaults)拿到 ④ final(接缝融合基底)+ ⑤-② 纯红遮罩 PNG,再**后端调外部重绘服务
|
redraw_defaults)拿到 ④ final(接缝融合基底)+ ⑤-② 纯红遮罩 PNG,再**后端直接调
|
||||||
local_test**(0716add-hair.json 工作流)完成发际线带重绘,重绘结果作为生发图。
|
ComfyUI**(0716add-hair-api.json 工作流)完成发际线带重绘,重绘结果作为生发图。
|
||||||
|
|
||||||
overlay 仍是发际线曲线透明层(与 generate_grow_results 完全一致)。
|
overlay 仍是发际线曲线透明层(与 generate_grow_results 完全一致)。
|
||||||
Returns: list[dict] {"hairline_type","order","overlay","grown_png"(jpg bytes 或 None)};
|
Returns: list[dict] {"hairline_type","order","overlay","grown_png"(jpg bytes 或 None)};
|
||||||
无人脸返回 None。单个发型换发型/重绘失败时 grown_png=None,不抛异常。
|
无人脸返回 None。单个发型换发型/重绘失败时 grown_png=None,不抛异常。
|
||||||
"""
|
"""
|
||||||
from face_analysis.hairline_grow import generate_hairline_redraw, NoFaceError
|
from face_analysis.hairline_grow import generate_hairline_redraw, NoFaceError
|
||||||
|
from face_analysis.head_mask import SEGFORMER_HAIR
|
||||||
|
|
||||||
ctx = extract_context(image_bgr)
|
ctx = extract_context(image_bgr)
|
||||||
if ctx is None:
|
if ctx is None:
|
||||||
return None
|
return None
|
||||||
uv, ext_faces = load_ext_mesh()
|
uv, ext_faces = load_ext_mesh()
|
||||||
|
|
||||||
|
# 复用 extract_context 已算好的 SegFormer parse_map,避免 generate_hairline_redraw 内部重复分割
|
||||||
|
hair_mask_reuse = (ctx["parse_map"] == SEGFORMER_HAIR)
|
||||||
|
|
||||||
textures = get_texture_map()["female"] # [(key, path), ...] 已排序
|
textures = get_texture_map()["female"] # [(key, path), ...] 已排序
|
||||||
if hair_styles is not None:
|
if hair_styles is not None:
|
||||||
items = [(s, textures[s - 1]) for s in hair_styles]
|
items = [(s, textures[s - 1]) for s in hair_styles]
|
||||||
@@ -263,6 +314,21 @@ def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | N
|
|||||||
|
|
||||||
results = []
|
results = []
|
||||||
h, w = image_bgr.shape[:2]
|
h, w = image_bgr.shape[:2]
|
||||||
|
|
||||||
|
# 重绘管线(swapHair + ComfyUI)统一降分辨率:真实照片 swap(SD WebUI)~5s、blend、ComfyUI
|
||||||
|
# 均随分辨率线性下降。overlay 预览仍用全分辨率;grown_png 最后放大回原尺寸。
|
||||||
|
eff_side = _REDRAW_MAX_SIDE if redraw_max_side is None else redraw_max_side
|
||||||
|
redraw_img = image_bgr
|
||||||
|
hair_mask_redraw = hair_mask_reuse
|
||||||
|
if eff_side > 0 and max(h, w) > eff_side:
|
||||||
|
redraw_img, _rs = _downscale_max_side(image_bgr, eff_side)
|
||||||
|
_nh, _nw = redraw_img.shape[:2]
|
||||||
|
if hair_mask_redraw is not None:
|
||||||
|
hair_mask_redraw = cv2.resize(hair_mask_reuse.astype(np.uint8), (_nw, _nh),
|
||||||
|
interpolation=cv2.INTER_NEAREST).astype(bool)
|
||||||
|
logger.info("接口2女 管线降分辨率: %dx%d → %dx%d (max_side=%d)",
|
||||||
|
w, h, _nw, _nh, eff_side)
|
||||||
|
|
||||||
for order, (key, white_path) in items:
|
for order, (key, white_path) in items:
|
||||||
white = load_texture_rgba(white_path)
|
white = load_texture_rgba(white_path)
|
||||||
overlay = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)
|
overlay = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)
|
||||||
@@ -273,7 +339,10 @@ def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | N
|
|||||||
logger.warning("接口2 换发型:female key=%s 无对应 chang_id,跳过生发图", key)
|
logger.warning("接口2 换发型:female key=%s 无对应 chang_id,跳过生发图", key)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
data = generate_hairline_redraw(image_bgr, chang_id, **redraw_defaults)
|
import time as _t
|
||||||
|
_ts0 = _t.perf_counter()
|
||||||
|
data = generate_hairline_redraw(redraw_img, chang_id, hair_mask=hair_mask_redraw, **redraw_defaults)
|
||||||
|
_ts1 = _t.perf_counter()
|
||||||
steps = data.get("steps") or {}
|
steps = data.get("steps") or {}
|
||||||
# ④ final(接缝融合基底)+ ⑤-② 纯红遮罩 PNG
|
# ④ final(接缝融合基底)+ ⑤-② 纯红遮罩 PNG
|
||||||
final_b64 = steps.get("final_base64") or ""
|
final_b64 = steps.get("final_base64") or ""
|
||||||
@@ -289,10 +358,22 @@ def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | N
|
|||||||
mask_b64 = mask_b64.split(",", 1)[1]
|
mask_b64 = mask_b64.split(",", 1)[1]
|
||||||
final_bytes = base64.b64decode(final_b64)
|
final_bytes = base64.b64decode(final_b64)
|
||||||
mask_bytes = base64.b64decode(mask_b64)
|
mask_bytes = base64.b64decode(mask_b64)
|
||||||
# 后端调外部重绘服务(local_test),返回重绘后的 PNG
|
# 后端直接调 ComfyUI 重绘,返回重绘后的 PNG
|
||||||
grown_png = _call_local_redraw(final_bytes, mask_bytes)
|
_tr0 = _t.perf_counter()
|
||||||
|
grown_png = _call_local_redraw(final_bytes, mask_bytes,
|
||||||
|
max_side=redraw_max_side,
|
||||||
|
unet_name=unet_name)
|
||||||
|
_tr1 = _t.perf_counter()
|
||||||
|
_tm = data.get("timings_ms") or {}
|
||||||
|
logger.info("接口2女 分段计时 type=%s: swapHair管线=%.2fs (mask=%dms swap=%dms blend=%dms), ComfyUI重绘=%.2fs",
|
||||||
|
key, _ts1 - _ts0,
|
||||||
|
_tm.get("mask", 0), _tm.get("swap", 0), _tm.get("blend", 0),
|
||||||
|
_tr1 - _tr0)
|
||||||
if grown_png is None:
|
if grown_png is None:
|
||||||
logger.warning("接口2 换发型:type=%s 重绘结果为空", key)
|
logger.warning("接口2 换发型:type=%s 重绘结果为空", key)
|
||||||
|
elif redraw_img is not image_bgr:
|
||||||
|
# 管线在降分辨率图上跑,结果放大回原尺寸
|
||||||
|
grown_png = _upscale_png_to(grown_png, w, h)
|
||||||
except NoFaceError:
|
except NoFaceError:
|
||||||
logger.warning("接口2 换发型:type=%s 未检出人脸", key)
|
logger.warning("接口2 换发型:type=%s 未检出人脸", key)
|
||||||
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
|
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
|
||||||
@@ -319,7 +400,7 @@ def _grow_from_texture(image_bgr: np.ndarray, ctx: dict, white_path: str | None,
|
|||||||
h, w = image_bgr.shape[:2]
|
h, w = image_bgr.shape[:2]
|
||||||
marked, mask = image_bgr, np.zeros((h, w), np.uint8)
|
marked, mask = image_bgr, np.zeros((h, w), np.uint8)
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
compose_comfy_rgba(marked, mask).save(buf, format="PNG")
|
compose_comfy_rgba(marked, mask).save(buf, format="PNG", compress_level=1)
|
||||||
return comfyui.run(buf.getvalue(), prompt=prompt)
|
return comfyui.run(buf.getvalue(), prompt=prompt)
|
||||||
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
|
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
|
||||||
logger.warning("接口5 生发图失败:%s", e)
|
logger.warning("接口5 生发图失败:%s", e)
|
||||||
@@ -328,13 +409,16 @@ def _grow_from_texture(image_bgr: np.ndarray, ctx: dict, white_path: str | None,
|
|||||||
|
|
||||||
def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
|
def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
|
||||||
hair_styles: list[int], use_mask: bool = True,
|
hair_styles: list[int], use_mask: bool = True,
|
||||||
prompt: str | None = None):
|
prompt: str | None = None,
|
||||||
|
generate_grow_image: bool = True):
|
||||||
"""接口5:对选中发型返回 middle/high/low 三档发际线透明叠图 + 生发图(同接口2)。
|
"""接口5:对选中发型返回 middle/high/low 三档发际线透明叠图 + 生发图(同接口2)。
|
||||||
|
|
||||||
入参同接口2:先选 gender,再多选 hair_styles(必填,1-indexed 按贴图排序)。
|
入参同接口2:先选 gender,再多选 hair_styles(必填,1-indexed 按贴图排序)。
|
||||||
每个选中发型返回三档叠图(middle/high/low,RGBA 透明层只含发际线曲线)与一张生发图;
|
每个选中发型返回三档叠图(middle/high/low,RGBA 透明层只含发际线曲线)与一张生发图;
|
||||||
三档贴图同名,生发黑模板固定取自 hairline_texture_black/(middle),故生发目标固定 middle 档。
|
三档贴图同名,生发黑模板固定取自 hairline_texture_black/(middle),故生发目标固定 middle 档。
|
||||||
use_mask/prompt:同接口2 的生发参数。
|
use_mask/prompt:同接口2 的生发参数。
|
||||||
|
generate_grow_image(默认 True):是否生成生发图(ComfyUI,最耗时)。False 时跳过生发,
|
||||||
|
各发型 grown_png 恒为 None,可大幅降低耗时(仅留三档发际线叠图与中心点)。
|
||||||
Returns: {"images":[{hairline_type,order,overlays:{middle,high,low}((H,W,4) RGBA 透明层),grown_png}],
|
Returns: {"images":[{hairline_type,order,overlays:{middle,high,low}((H,W,4) RGBA 透明层),grown_png}],
|
||||||
"best_centers":{"middle":(x,y),"high":(x,y),"low":(x,y)}};无人脸 None。
|
"best_centers":{"middle":(x,y),"high":(x,y),"low":(x,y)}};无人脸 None。
|
||||||
best_centers 取首个选中发型三档各自的发际线中点。
|
best_centers 取首个选中发型三档各自的发际线中点。
|
||||||
@@ -356,8 +440,9 @@ def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
|
|||||||
tex_by_level = {lv: get_texture_map(lv)[gender] for lv in _TEXTURE_DIRS}
|
tex_by_level = {lv: get_texture_map(lv)[gender] for lv in _TEXTURE_DIRS}
|
||||||
|
|
||||||
# use_mask=False:干净原图+空遮罩与贴图无关,只跑一次 ComfyUI,选中项复用
|
# use_mask=False:干净原图+空遮罩与贴图无关,只跑一次 ComfyUI,选中项复用
|
||||||
|
# generate_grow_image=False:完全跳过生发(最耗时),grown_png 恒为 None
|
||||||
shared_grown = None
|
shared_grown = None
|
||||||
if not use_mask:
|
if generate_grow_image and not use_mask:
|
||||||
shared_grown = _grow_from_texture(image_bgr, ctx, None, use_mask=False, prompt=prompt)
|
shared_grown = _grow_from_texture(image_bgr, ctx, None, use_mask=False, prompt=prompt)
|
||||||
|
|
||||||
def _center_of(overlay):
|
def _center_of(overlay):
|
||||||
@@ -376,9 +461,13 @@ def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
|
|||||||
for lv in _TEXTURE_DIRS:
|
for lv in _TEXTURE_DIRS:
|
||||||
white = load_texture_rgba(tex_by_level[lv][s - 1][1])
|
white = load_texture_rgba(tex_by_level[lv][s - 1][1])
|
||||||
overlays[lv] = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)
|
overlays[lv] = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)
|
||||||
# 生发:固定 middle 黑模板
|
# 生发:固定 middle 黑模板(generate_grow_image=False 时跳过,恒 None)
|
||||||
grown_png = shared_grown if not use_mask else \
|
if not generate_grow_image:
|
||||||
_grow_from_texture(image_bgr, ctx, mid_path, use_mask=True, prompt=prompt)
|
grown_png = None
|
||||||
|
elif not use_mask:
|
||||||
|
grown_png = shared_grown
|
||||||
|
else:
|
||||||
|
grown_png = _grow_from_texture(image_bgr, ctx, mid_path, use_mask=True, prompt=prompt)
|
||||||
images.append({"hairline_type": key, "order": s,
|
images.append({"hairline_type": key, "order": s,
|
||||||
"overlays": overlays, "grown_png": grown_png})
|
"overlays": overlays, "grown_png": grown_png})
|
||||||
# best_centers:首个选中发型三档(middle/high/low)发际线中点
|
# best_centers:首个选中发型三档(middle/high/low)发际线中点
|
||||||
@@ -387,18 +476,73 @@ def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
|
|||||||
return {"images": images, "best_centers": best_centers}
|
return {"images": images, "best_centers": best_centers}
|
||||||
|
|
||||||
|
|
||||||
|
# 接口3 送 ComfyUI 前限边,降低峰值显存,避免与接口2 切换时把 Flux 挤出。
|
||||||
|
# 统一 prompt 后 Flux 不再被 CLIP 挤出,接口3 可用较高分辨率。可用 GROW_B_MAX_SIDE 覆盖。
|
||||||
|
_GROW_B_MAX_SIDE = int(os.getenv("GROW_B_MAX_SIDE", "1024"))
|
||||||
|
|
||||||
|
def _downscale_max_side(img_bgr: np.ndarray, max_side: int) -> tuple[np.ndarray, float]:
|
||||||
|
"""长边超过 max_side 时等比例缩小;返回 (图, scale),scale=新/旧。"""
|
||||||
|
h, w = img_bgr.shape[:2]
|
||||||
|
m = max(h, w)
|
||||||
|
if max_side <= 0 or m <= max_side:
|
||||||
|
return img_bgr, 1.0
|
||||||
|
scale = max_side / float(m)
|
||||||
|
nw = max(1, int(round(w * scale)))
|
||||||
|
nh = max(1, int(round(h * scale)))
|
||||||
|
out = cv2.resize(img_bgr, (nw, nh), interpolation=cv2.INTER_AREA)
|
||||||
|
return out, scale
|
||||||
|
|
||||||
|
|
||||||
|
def _upscale_png_to(png_bytes: bytes, out_w: int, out_h: int) -> bytes:
|
||||||
|
"""把 Comfy 输出 PNG 双线性拉回原图尺寸(仅展示对齐,不增加推理细节)。"""
|
||||||
|
arr = np.frombuffer(png_bytes, np.uint8)
|
||||||
|
img = cv2.imdecode(arr, cv2.IMREAD_UNCHANGED)
|
||||||
|
if img is None:
|
||||||
|
return png_bytes
|
||||||
|
if img.shape[1] == out_w and img.shape[0] == out_h:
|
||||||
|
return png_bytes
|
||||||
|
resized = cv2.resize(img, (out_w, out_h), interpolation=cv2.INTER_LINEAR)
|
||||||
|
ok, buf = cv2.imencode(".png", resized)
|
||||||
|
return buf.tobytes() if ok else png_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def _prep_comfy_input(img_bgr: np.ndarray, mask: np.ndarray) -> tuple[np.ndarray, np.ndarray, float]:
|
||||||
|
"""单段 ComfyUI 生发(接口2男 / 接口3)送图前限边到 GROW_B_MAX_SIDE。
|
||||||
|
返回 (缩后图, 缩后遮罩, scale);scale<1 时调用方需把结果放大回原尺寸。"""
|
||||||
|
h, w = img_bgr.shape[:2]
|
||||||
|
if _GROW_B_MAX_SIDE <= 0 or max(h, w) <= _GROW_B_MAX_SIDE:
|
||||||
|
return img_bgr, mask, 1.0
|
||||||
|
out, scale = _downscale_max_side(img_bgr, _GROW_B_MAX_SIDE)
|
||||||
|
nh, nw = out.shape[:2]
|
||||||
|
msk = cv2.resize(mask, (nw, nh), interpolation=cv2.INTER_NEAREST)
|
||||||
|
logger.info("接口2男/接口3 缩图送 Comfy: %dx%d → %dx%d (max_side=%d)",
|
||||||
|
w, h, nw, nh, _GROW_B_MAX_SIDE)
|
||||||
|
return out, msk, scale
|
||||||
|
|
||||||
|
|
||||||
def generate_grow_b(marked_bgr: np.ndarray, use_mask: bool = True, prompt: str = None):
|
def generate_grow_b(marked_bgr: np.ndarray, use_mask: bool = True, prompt: str = None):
|
||||||
"""接口3:检测医生手绘发际线 → 遮罩 → 送 ComfyUI 生发(仅需划线图一张)。
|
"""接口3:检测医生手绘发际线 → 遮罩 → 送 ComfyUI 生发(仅需划线图一张)。
|
||||||
|
|
||||||
检测路径只用来**建遮罩**;ComfyUI 输入图用 **marked 原图**(含医生手绘线,
|
检测路径只用来**建遮罩**;ComfyUI 输入图用 **marked 原图**(含医生手绘线,
|
||||||
工作流提示词会清除黑线再生发)。
|
工作流提示词会清除黑线再生发)。
|
||||||
|
|
||||||
|
进 Comfy 前若长边 > GROW_B_MAX_SIDE(默认 896)会先等比例缩小,降低峰值显存;
|
||||||
|
输出再拉回原图尺寸。
|
||||||
|
|
||||||
use_mask(默认 True):是否启用自动检测的遮罩,用于测试对比。
|
use_mask(默认 True):是否启用自动检测的遮罩,用于测试对比。
|
||||||
- True:检测手绘线 → 建遮罩 → alpha=255−mask(透明区=重绘区,节点44 画黄色参考区)。
|
- True:检测手绘线 → 建遮罩 → alpha=255−mask(透明区=重绘区,节点44 画黄色参考区)。
|
||||||
- False:跳过检测,直接送划线图,alpha 全 255(空遮罩,节点26 mask 为空),
|
- False:跳过检测,直接送划线图,alpha 全 255(空遮罩,节点26 mask 为空),
|
||||||
模型仅凭医生黑线参考生发。无需改工作流,唯一变量是遮罩。
|
模型仅凭医生黑线参考生发。无需改工作流,唯一变量是遮罩。
|
||||||
Returns: {"grown_png": bytes 或 None, "status": "ok"|"no_face"|"no_line"}。
|
Returns: {"grown_png": bytes 或 None, "status": "ok"|"no_face"|"no_line"}。
|
||||||
"""
|
"""
|
||||||
|
orig_h, orig_w = marked_bgr.shape[:2]
|
||||||
|
marked_bgr, _scale = _downscale_max_side(marked_bgr, _GROW_B_MAX_SIDE)
|
||||||
|
if _scale < 1.0:
|
||||||
|
logger.info(
|
||||||
|
"接口3 缩图送 Comfy: %dx%d → %dx%d (max_side=%d)",
|
||||||
|
orig_w, orig_h, marked_bgr.shape[1], marked_bgr.shape[0], _GROW_B_MAX_SIDE,
|
||||||
|
)
|
||||||
|
|
||||||
h, w = marked_bgr.shape[:2]
|
h, w = marked_bgr.shape[:2]
|
||||||
if use_mask:
|
if use_mask:
|
||||||
rgb = cv2.cvtColor(marked_bgr, cv2.COLOR_BGR2RGB)
|
rgb = cv2.cvtColor(marked_bgr, cv2.COLOR_BGR2RGB)
|
||||||
@@ -416,8 +560,10 @@ def generate_grow_b(marked_bgr: np.ndarray, use_mask: bool = True, prompt: str =
|
|||||||
mask = np.zeros((h, w), np.uint8) # 空遮罩:alpha 全 255,跳过检测
|
mask = np.zeros((h, w), np.uint8) # 空遮罩:alpha 全 255,跳过检测
|
||||||
|
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
compose_comfy_rgba(marked_bgr, mask).save(buf, format="PNG") # marked 原图 + 遮罩
|
compose_comfy_rgba(marked_bgr, mask).save(buf, format="PNG", compress_level=1) # marked + 遮罩
|
||||||
grown_png = comfyui.run(buf.getvalue(), prompt=prompt)
|
grown_png = comfyui.run(buf.getvalue(), prompt=prompt)
|
||||||
|
if _scale < 1.0 and grown_png:
|
||||||
|
grown_png = _upscale_png_to(grown_png, orig_w, orig_h)
|
||||||
return {"grown_png": grown_png, "status": "ok"}
|
return {"grown_png": grown_png, "status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 440 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 459 KiB |
@@ -206,7 +206,7 @@ def generate():
|
|||||||
return jsonify({"error": msg}), 400
|
return jsonify({"error": msg}), 400
|
||||||
image_file = request.files["image"]
|
image_file = request.files["image"]
|
||||||
mask_file = request.files["mask"]
|
mask_file = request.files["mask"]
|
||||||
prompt_text = request.form.get("prompt", "填充遮罩区域的头发,皮肤加一点磨皮")
|
prompt_text = request.form.get("prompt", "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
|
||||||
log.info(
|
log.info(
|
||||||
"收到请求: image=%s mask=%s prompt=%r",
|
"收到请求: image=%s mask=%s prompt=%r",
|
||||||
image_file.filename, mask_file.filename, prompt_text,
|
image_file.filename, mask_file.filename, prompt_text,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ def prep_and_upload():
|
|||||||
|
|
||||||
|
|
||||||
def run_once(fname, model, dtype, steps):
|
def run_once(fname, model, dtype, steps):
|
||||||
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮")
|
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
|
||||||
wf["16"]["inputs"]["unet_name"] = model
|
wf["16"]["inputs"]["unet_name"] = model
|
||||||
wf["16"]["inputs"]["weight_dtype"] = dtype
|
wf["16"]["inputs"]["weight_dtype"] = dtype
|
||||||
wf["1"]["inputs"]["steps"] = steps
|
wf["1"]["inputs"]["steps"] = steps
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ def upload(scale=1.0):
|
|||||||
|
|
||||||
|
|
||||||
def run(fname, steps):
|
def run(fname, steps):
|
||||||
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮")
|
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
|
||||||
wf["16"]["inputs"]["unet_name"] = MODEL
|
wf["16"]["inputs"]["unet_name"] = MODEL
|
||||||
wf["16"]["inputs"]["weight_dtype"] = DTYPE
|
wf["16"]["inputs"]["weight_dtype"] = DTYPE
|
||||||
wf["1"]["inputs"]["steps"] = steps
|
wf["1"]["inputs"]["steps"] = steps
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ SEED = 123456789
|
|||||||
imgs = []
|
imgs = []
|
||||||
labels = []
|
labels = []
|
||||||
for steps in [2, 3, 4, 6]:
|
for steps in [2, 3, 4, 6]:
|
||||||
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮", seed=SEED)
|
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", seed=SEED)
|
||||||
wf["16"]["inputs"]["unet_name"] = MODEL
|
wf["16"]["inputs"]["unet_name"] = MODEL
|
||||||
wf["16"]["inputs"]["weight_dtype"] = DTYPE
|
wf["16"]["inputs"]["weight_dtype"] = DTYPE
|
||||||
wf["1"]["inputs"]["steps"] = steps
|
wf["1"]["inputs"]["steps"] = steps
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ button { padding: 10px 24px; border: none; border-radius: 6px; cursor: pointer;
|
|||||||
</div>
|
</div>
|
||||||
<div class="controls" style="margin-top:16px">
|
<div class="controls" style="margin-top:16px">
|
||||||
<label>提示词:</label>
|
<label>提示词:</label>
|
||||||
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮">
|
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜">
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align:center; margin-top:16px">
|
<div style="text-align:center; margin-top:16px">
|
||||||
<button class="btn-generate" id="generateBtn" disabled>🚀 生成</button>
|
<button class="btn-generate" id="generateBtn" disabled>🚀 生成</button>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ resp = requests.post(
|
|||||||
"image": ("original.jpg", img_data, "image/jpeg"),
|
"image": ("original.jpg", img_data, "image/jpeg"),
|
||||||
"mask": ("mask.png", mask_data, "image/png"),
|
"mask": ("mask.png", mask_data, "image/png"),
|
||||||
},
|
},
|
||||||
data={"prompt": "填充遮罩区域的头发,皮肤加一点磨皮"},
|
data={"prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"},
|
||||||
timeout=600,
|
timeout=600,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 296 KiB |
|
After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 277 KiB |
|
After Width: | Height: | Size: 309 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 287 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 277 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 314 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 275 KiB |
|
After Width: | Height: | Size: 307 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 291 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 335 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 263 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 292 KiB |