Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
688472706d | ||
|
|
a3e13c804a | ||
|
|
a2243266db | ||
|
|
fe0e74ece8 | ||
|
|
52913c94fc | ||
|
|
13526cb5b8 | ||
|
|
f509fe99b4 | ||
|
|
a1d458eb20 | ||
|
|
92e628b0d5 | ||
|
|
b4714cedf3 | ||
|
|
e7b62f2b2e | ||
|
|
4291f125d4 | ||
|
|
7fc0210ce6 | ||
|
|
085ad3ced0 | ||
|
|
2993a0b948 | ||
|
|
08b31a3baa | ||
|
|
51e313e845 | ||
|
|
fbbcd48418 | ||
|
|
74ccab0ff8 | ||
|
|
659c037270 | ||
|
|
c1bb9614c7 | ||
|
|
2b1f528ddd | ||
|
|
d9e96aca87 | ||
|
|
8aed389d79 | ||
|
|
c72e3ceda9 | ||
|
|
99ce21334a | ||
|
|
5fbc03a6df | ||
|
|
0bbb15d668 | ||
|
|
632e75317b | ||
|
|
1cd4115b26 | ||
|
|
e8a2c5a8a1 |
@@ -49,3 +49,7 @@ image/test/
|
|||||||
# 批量报告输出(生成图+原图,体积大,不入 git)
|
# 批量报告输出(生成图+原图,体积大,不入 git)
|
||||||
static/report_hairline_v2/
|
static/report_hairline_v2/
|
||||||
static/report_hairline_v2.zip
|
static/report_hairline_v2.zip
|
||||||
|
|
||||||
|
# local_test 运行期日志 / pid(不入 git)
|
||||||
|
local_test/hair_service.log
|
||||||
|
local_test/hair_service.pid
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
{
|
||||||
|
"16": {
|
||||||
|
"class_type": "UNETLoader",
|
||||||
|
"inputs": {
|
||||||
|
"unet_name": "flux-2-klein-4b-fp8.safetensors",
|
||||||
|
"weight_dtype": "fp8_e4m3fn_fast"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
"class_type": "VAELoader",
|
||||||
|
"inputs": {
|
||||||
|
"vae_name": "flux2-vae.safetensors"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"61": {
|
||||||
|
"class_type": "CLIPLoader",
|
||||||
|
"inputs": {
|
||||||
|
"clip_name": "qwen_3_4b.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,8 +170,8 @@
|
|||||||
},
|
},
|
||||||
"16": {
|
"16": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"unet_name": "flux2.0/flux-2-klein-9b-fp8.safetensors",
|
"unet_name": "flux-2-klein-4b-fp8.safetensors",
|
||||||
"weight_dtype": "fp8_e4m3fn"
|
"weight_dtype": "fp8_e4m3fn_fast"
|
||||||
},
|
},
|
||||||
"class_type": "UNETLoader",
|
"class_type": "UNETLoader",
|
||||||
"_meta": {
|
"_meta": {
|
||||||
@@ -410,7 +410,7 @@
|
|||||||
},
|
},
|
||||||
"60": {
|
"60": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"text": "补充遮罩区补充遮罩区域内的头发,头发填满遮罩区域。发际线往下挡住额头"
|
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
||||||
},
|
},
|
||||||
"class_type": "JjkText",
|
"class_type": "JjkText",
|
||||||
"_meta": {
|
"_meta": {
|
||||||
@@ -419,9 +419,9 @@
|
|||||||
},
|
},
|
||||||
"61": {
|
"61": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"clip_name": "qwen_3_8b_fp8mixed.safetensors",
|
"clip_name": "qwen_3_4b.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 = "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")
|
||||||
@@ -361,20 +401,36 @@ def _run_face_measure_data(image, variant="v1"):
|
|||||||
logger.warning("头发/耳朵分割失败,回退方案A:%s", seg_e)
|
logger.warning("头发/耳朵分割失败,回退方案A:%s", seg_e)
|
||||||
|
|
||||||
result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose)
|
result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose)
|
||||||
|
discarded = result.hairline_discarded
|
||||||
data = result.to_response()
|
data = result.to_response()
|
||||||
|
vd = result.vertical
|
||||||
if variant == "v6":
|
if variant == "v6":
|
||||||
vd = result.vertical
|
if discarded:
|
||||||
base_px = vd["upper_court_px"] + vd["middle_court_px"] + vd["lower_court_px"]
|
# 发际线弃用:接口6 的上庭也依赖发际线,一并置 null;只保留中/下庭。
|
||||||
# 接口6 是三庭:去掉顶庭相关字段(top_court_cm / ratios.top_court / landmarks.hair_top)
|
base_px = vd["middle_court_px"] + vd["lower_court_px"]
|
||||||
data["four_courts"]["ratios"] = {
|
data["four_courts"]["upper_court_cm"] = None
|
||||||
"upper_court": round(vd["upper_court_px"] / base_px, 3),
|
data["four_courts"]["ratios"] = {
|
||||||
"middle_court": round(vd["middle_court_px"] / base_px, 3),
|
"upper_court": None,
|
||||||
"lower_court": round(vd["lower_court_px"] / base_px, 3),
|
"middle_court": round(vd["middle_court_px"] / base_px, 3),
|
||||||
}
|
"lower_court": round(vd["lower_court_px"] / base_px, 3),
|
||||||
data["four_courts"].pop("top_court_cm", None)
|
}
|
||||||
data["face_total_height_cm"] = round(
|
data["four_courts"].pop("top_court_cm", None)
|
||||||
result.upper_cm + result.middle_cm + result.lower_cm, 2)
|
data["face_total_height_cm"] = round(
|
||||||
data["landmarks"].pop("hair_top", None)
|
result.middle_cm + result.lower_cm, 2)
|
||||||
|
data["landmarks"]["hairline"] = None
|
||||||
|
else:
|
||||||
|
base_px = vd["upper_court_px"] + vd["middle_court_px"] + vd["lower_court_px"]
|
||||||
|
# 接口6 是三庭:去掉顶庭相关字段(top_court_cm / ratios.top_court / landmarks.hair_top)
|
||||||
|
data["four_courts"]["ratios"] = {
|
||||||
|
"upper_court": round(vd["upper_court_px"] / base_px, 3),
|
||||||
|
"middle_court": round(vd["middle_court_px"] / base_px, 3),
|
||||||
|
"lower_court": round(vd["lower_court_px"] / base_px, 3),
|
||||||
|
}
|
||||||
|
data["four_courts"].pop("top_court_cm", None)
|
||||||
|
data["face_total_height_cm"] = round(
|
||||||
|
result.upper_cm + result.middle_cm + result.lower_cm, 2)
|
||||||
|
# 注:landmarks.hair_top 保留返回(供前端/下游定位头顶),但顶庭数值、
|
||||||
|
# 占比、标注图仍按三庭处理,显示效果不变。
|
||||||
|
|
||||||
# 七眼段宽度(cm)。eye1=左耳外段 eye2=左脸颊 eye3=左眼 eye4=两眼间距 eye5=右眼 eye6=右脸颊 eye7=右耳外段。
|
# 七眼段宽度(cm)。eye1=左耳外段 eye2=左脸颊 eye3=左眼 eye4=两眼间距 eye5=右眼 eye6=右脸颊 eye7=右耳外段。
|
||||||
# eye2~eye6(5段)只用内部分点,接口1/6 共用;eye1/eye7 需耳朵分割端线,仅接口1 有。
|
# eye2~eye6(5段)只用内部分点,接口1/6 共用;eye1/eye7 需耳朵分割端线,仅接口1 有。
|
||||||
@@ -389,11 +445,14 @@ def _run_face_measure_data(image, variant="v1"):
|
|||||||
data["seven_eyes"][f"eye{i + 2}"] = (
|
data["seven_eyes"][f"eye{i + 2}"] = (
|
||||||
None if (a is None or b is None) else round((b - a) / pc, 2))
|
None if (a is None or b is None) else round((b - a) / pc, 2))
|
||||||
if variant != "v6":
|
if variant != "v6":
|
||||||
# 接口1 额外算 eye1/eye7(左/右耳外段),需耳朵分割端线
|
# 接口1 额外算 eye1/eye7(左/右耳外段),需耳朵分割端线。
|
||||||
|
# 竖向范围:发际线弃用时用眉心做上界(hair_top 不可靠),否则用头顶。
|
||||||
from face_analysis.annotation import _ear_edges_from_mask
|
from face_analysis.annotation import _ear_edges_from_mask
|
||||||
|
top_y = (vd["brow_center"][1] if discarded
|
||||||
|
else vd["hair_top"][1])
|
||||||
head_l, head_r = _ear_edges_from_mask(
|
head_l, head_r = _ear_edges_from_mask(
|
||||||
ear_mask, hair_mask,
|
ear_mask, hair_mask,
|
||||||
result.vertical["hair_top"][1], result.vertical["chin_tip"][1],
|
top_y, vd["chin_tip"][1],
|
||||||
lcx, rcx, (lcx + rcx) / 2)
|
lcx, rcx, (lcx + rcx) / 2)
|
||||||
data["seven_eyes"]["eye1"] = (
|
data["seven_eyes"]["eye1"] = (
|
||||||
None if (head_l is None) else round((lcx - head_l) / pc, 2))
|
None if (head_l is None) else round((lcx - head_l) / pc, 2))
|
||||||
@@ -468,7 +527,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 段
|
||||||
""",
|
""",
|
||||||
@@ -573,7 +632,7 @@ async def face_measure(
|
|||||||
**标注图片 UI 规范**(真实版本生效):
|
**标注图片 UI 规范**(真实版本生效):
|
||||||
- 字体/线条/箭头颜色:`#FFFFFF 100%`,透明底
|
- 字体/线条/箭头颜色:`#FFFFFF 100%`,透明底
|
||||||
- 字号/线宽/虚线/箭头按图片短边自适应缩放
|
- 字号/线宽/虚线/箭头按图片短边自适应缩放
|
||||||
- 三庭数值(名+数值两行,不带 cm)在图片**左侧**呈现,七眼段宽**上下穿插**展示,底部标「单位cm」
|
- 三庭(名 + 数值带cm + 百分比 三行)在图片**左侧**呈现,七眼段宽**上下穿插**展示(数值带cm,下方另起一行标占头宽百分比)
|
||||||
- 段宽/庭高用虚线 + 实心三角双箭头标示
|
- 段宽/庭高用虚线 + 实心三角双箭头标示
|
||||||
""",
|
""",
|
||||||
responses={
|
responses={
|
||||||
@@ -655,10 +714,11 @@ async def face_measure_v2(
|
|||||||
- 发际线类型 `hairline_type`(英文 key)
|
- 发际线类型 `hairline_type`(英文 key)
|
||||||
- 顺序 `order`(本期固定 `1..N`,不排序)
|
- 顺序 `order`(本期固定 `1..N`,不排序)
|
||||||
|
|
||||||
> **female 走「换发型」模式**:生发图 `grown_image_base64` 由换发型(change_hair)
|
> **female 走「换发型」模式**:1..5 的生发图 `grown_image_base64` 由换发型(change_hair)
|
||||||
> + Flux-2 整帧重绘(= 接口12 final 管线,整帧美颜+整帧重绘)生成,其余参数用固化默认值。
|
> + Flux-2 整帧重绘(= 接口12 final 管线,整帧美颜+整帧重绘)生成,其余参数用固化默认值。
|
||||||
> **male 仍走原生发(ComfyUI add_hair)管线**。入参与返回结构不变。
|
> **6/7(bigflower/clasicalflower)与 male 一样走原生发(ComfyUI add_hair)管线**。
|
||||||
> female 依赖 change_hair 与 ComfyUI(:8188) 均在跑。
|
> **male 全部走原生发(ComfyUI add_hair)管线**。入参与返回结构不变。
|
||||||
|
> female 1..5 依赖 change_hair 与 ComfyUI(:8188) 均在跑。
|
||||||
|
|
||||||
{_image_fields_desc}
|
{_image_fields_desc}
|
||||||
|
|
||||||
@@ -666,15 +726,15 @@ async def face_measure_v2(
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
- **gender**(必填):`male` / `female`。决定返回的贴图集合(female 5 张 / male 4 张)。
|
- **gender**(必填):`male` / `female`。决定返回的贴图集合(female 7 张 / male 6 张)。
|
||||||
非法或缺失返回 `1004`。
|
非法或缺失返回 `1004`。
|
||||||
- **hair_style**(必填):发型序号,**逗号分隔多选**(如 `1,2,3`),最多不超过该性别的预设数量。
|
- **hair_style**(必填):发型序号,**逗号分隔多选**(如 `1,2,3`),最多不超过该性别的预设数量。
|
||||||
`female`:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave;
|
`female`:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave, 6=bigflower, 7=clasicalflower;
|
||||||
`male`:1=ellipse, 2=inverse_arc, 3=m, 4=straight。越界/非法返回 `1007`。
|
`male`:1=ellipse, 2=inverse_arc, 3=m, 4=straight, 5=heart, 6=Softpetal。越界/非法返回 `1007`。
|
||||||
- **beauty_enabled**:本期保留但不生效。
|
- **beauty_enabled**:本期保留但不生效。
|
||||||
|
|
||||||
`hairline_type` 取值:`ellipse` / `flower` / `heart` / `straight` / `wave`(female),
|
`hairline_type` 取值:`ellipse` / `flower` / `heart` / `straight` / `wave` / `bigflower` / `clasicalflower`(female),
|
||||||
`ellipse` / `m` / `straight` / `inverse_arc`(male)。
|
`ellipse` / `m` / `straight` / `inverse_arc` / `heart` / `Softpetal`(male)。
|
||||||
""",
|
""",
|
||||||
responses={
|
responses={
|
||||||
200: {
|
200: {
|
||||||
@@ -713,17 +773,17 @@ async def hair_grow(
|
|||||||
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
||||||
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
||||||
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-7 male:1-6"),
|
||||||
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的文本"),
|
||||||
):
|
):
|
||||||
# 1. gender 必填校验(非法/缺失 → 1004)
|
# 1. gender 必填校验(非法/缺失 → 1004)
|
||||||
if gender not in ("male", "female"):
|
if gender not in ("male", "female"):
|
||||||
return err(1004, "gender 必填且只能为 male / female")
|
return err(1004, "gender 必填且只能为 male / female")
|
||||||
|
|
||||||
# 2. hair_style 必填校验(解析逗号分隔,越界 → 1007)
|
# 2. hair_style 必填校验(解析逗号分隔,越界 → 1007)
|
||||||
max_styles = {"female": 5, "male": 4}[gender]
|
max_styles = {"female": 7, "male": 6}[gender]
|
||||||
hair_styles = _parse_hair_styles(hair_style, max_styles)
|
hair_styles = _parse_hair_styles(hair_style, max_styles)
|
||||||
if hair_styles is None:
|
if hair_styles is None:
|
||||||
return err(1007, f"hair_style 必填且为 1..{max_styles} 的整数(逗号分隔),收到 {hair_style!r}")
|
return err(1007, f"hair_style 必填且为 1..{max_styles} 的整数(逗号分隔),收到 {hair_style!r}")
|
||||||
@@ -745,7 +805,8 @@ 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,
|
||||||
|
prompt=prompt)
|
||||||
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(
|
||||||
@@ -769,119 +830,160 @@ async def hair_grow(
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 接口 7:C 端生发 v2(add_hair2.json 工作流)
|
# 调试接口:接口2 女性生发 分步计时
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_WORKFLOW2_PATH = os.path.join(os.path.dirname(__file__), "add_hair2.json")
|
|
||||||
|
|
||||||
|
|
||||||
@app.post(
|
@app.post(
|
||||||
"/api/v1/hair/grow-v2",
|
"/api/v1/debug/grow-timing",
|
||||||
summary="接口7 C端生发 v2(add_hair2 工作流)",
|
summary="调试-接口2女性生发分步计时",
|
||||||
tags=["生发"],
|
tags=["调试"],
|
||||||
description=f"""
|
include_in_schema=False,
|
||||||
输入用户正面照 + **性别** + **发型序号**,使用 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(
|
async def debug_grow_timing(
|
||||||
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG)"),
|
image_file: Optional[UploadFile] = File(default=None),
|
||||||
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
image_url: Optional[str] = Form(default=None),
|
||||||
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
image_base64: Optional[str] = Form(default=None),
|
||||||
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
|
hair_style: str = Form(default="2", description="发型序号(花瓣=2),逗号分隔多选"),
|
||||||
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填),如 1,2,3。female:1-5 male:1-4"),
|
webui_steps: Optional[int] = Form(default=None, description="swapHair webui img2img 采样步数,None=服务端默认(15),可填10/15/20/25对比"),
|
||||||
beauty_enabled: bool = Form(default=False, description="是否开启美颜(本期不生效)"),
|
redraw_max_side: Optional[int] = Form(default=None, description="ComfyUI重绘分辨率(长边像素)。None=默认896;0=原图不缩;其他如640/768/1024"),
|
||||||
use_mask: bool = Form(default=True, description="是否启用 inpaint 遮罩(测试对比用)。false 时用干净原图生成(空遮罩,不烧模板线)"),
|
redraw_prompt: Optional[str] = Form(default=None, description="ComfyUI重绘提示词,None=默认'填充遮罩区域的头发'"),
|
||||||
prompt: str = Form(default="补充遮罩区域的头发,加一点美颜", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
|
||||||
):
|
):
|
||||||
# 1. gender 必填校验(非法/缺失 → 1004)
|
"""单图跑接口2女性生发,返回每个步骤的耗时 + 结果图,用于定位性能瓶颈。
|
||||||
if gender not in ("male", "female"):
|
|
||||||
return err(1004, "gender 必填且只能为 male / female")
|
|
||||||
|
|
||||||
# 2. hair_style 必填校验(解析逗号分隔,越界 → 1007)
|
步骤拆分:
|
||||||
max_styles = {"female": 5, "male": 4}[gender]
|
1. extract_context:人脸关键点检测 + 头发分割 + 发际线几何
|
||||||
hair_styles = _parse_hair_styles(hair_style, max_styles)
|
2. [每个发型] generate_hairline_redraw:
|
||||||
if hair_styles is None:
|
2a. compute_mask:发际线遮罩计算
|
||||||
return err(1007, f"hair_style 必填且为 1..{max_styles} 的整数(逗号分隔),收到 {hair_style!r}")
|
2b. _call_swap:调 change_hair 换发型(内含 webui SD1.5 推理,远程或本机)
|
||||||
|
2c. _composite:接缝融合(多频段/羽化)
|
||||||
|
3. [每个发型] _call_local_redraw:调本机 ComfyUI 用 Flux.2 重绘
|
||||||
|
"""
|
||||||
|
import time as _time
|
||||||
|
from fastapi.concurrency import run_in_threadpool
|
||||||
|
|
||||||
# 3. 三选一取图
|
|
||||||
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
|
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
|
||||||
if e is not None:
|
if e is not None:
|
||||||
return e
|
return e
|
||||||
|
|
||||||
image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
|
image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
|
||||||
if image is None:
|
if image is None:
|
||||||
return err(1008, "图片格式不支持(仅 JPG / PNG)")
|
return err(1008, "图片格式不支持(仅 JPG / PNG)")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from fastapi.concurrency import run_in_threadpool
|
max_styles = 7
|
||||||
from hairline.service import generate_grow_results
|
hair_styles = _parse_hair_styles(hair_style, max_styles)
|
||||||
|
if hair_styles is None:
|
||||||
|
return err(1007, f"hair_style 必须为 1..{max_styles}")
|
||||||
|
|
||||||
# 预览 + 生发(ComfyUI) 都是阻塞且较慢,放线程池避免卡住事件循环
|
t_total0 = _time.perf_counter()
|
||||||
items = await run_in_threadpool(generate_grow_results, image, gender, use_mask, prompt, hair_styles, _WORKFLOW2_PATH)
|
timings = {"total_ms": 0, "extract_context_ms": 0, "per_hairstyle": []}
|
||||||
if items is None:
|
|
||||||
|
# 步骤1: extract_context
|
||||||
|
t0 = _time.perf_counter()
|
||||||
|
from hairline.service import extract_context as _ec, _call_local_redraw, _REDRAW_MAX_SIDE # noqa
|
||||||
|
from face_analysis.hairline_grow import generate_hairline_redraw, NoFaceError # noqa
|
||||||
|
from face_analysis.head_mask import SEGFORMER_HAIR # noqa
|
||||||
|
ctx = await run_in_threadpool(_ec, image)
|
||||||
|
timings["extract_context_ms"] = int((_time.perf_counter() - t0) * 1000)
|
||||||
|
if ctx is None:
|
||||||
return err(1001, "无法识别人像")
|
return err(1001, "无法识别人像")
|
||||||
|
|
||||||
results = []
|
hair_mask_reuse = (ctx["parse_map"] == SEGFORMER_HAIR)
|
||||||
for p in items:
|
h, w = image.shape[:2]
|
||||||
results.append({
|
eff_side = _REDRAW_MAX_SIDE if redraw_max_side is None else redraw_max_side
|
||||||
"image_base64": _jpg_b64(p["image_bgr"]), # 预览图 JPG
|
redraw_img, hair_mask_redraw = image, hair_mask_reuse
|
||||||
"grown_image_base64": (_png_to_jpg_b64(p["grown_png"]) # 生发图 JPG
|
downscale_info = None
|
||||||
if p["grown_png"] else None),
|
if eff_side > 0 and max(h, w) > eff_side:
|
||||||
"hairline_type": p["hairline_type"],
|
from hairline.service import _downscale_max_side
|
||||||
"order": p["order"],
|
redraw_img, _rs = _downscale_max_side(image, eff_side)
|
||||||
})
|
_nh, _nw = redraw_img.shape[:2]
|
||||||
return ok({"results": results})
|
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)
|
||||||
|
downscale_info = {"from": f"{w}x{h}", "to": f"{_nw}x{_nh}", "max_side": eff_side}
|
||||||
|
|
||||||
|
textures_map = None
|
||||||
|
from hairline.service import get_texture_map, _FEMALE_KEY_TO_CHANG, load_texture_rgba, build_overlay_layer, load_ext_mesh
|
||||||
|
textures = get_texture_map()["female"]
|
||||||
|
items = [(s, textures[s - 1]) for s in hair_styles]
|
||||||
|
|
||||||
|
for order, (key, white_path) in items:
|
||||||
|
hs_t0 = _time.perf_counter()
|
||||||
|
entry = {"hairline_type": key, "order": order}
|
||||||
|
chang_id = _FEMALE_KEY_TO_CHANG.get(key)
|
||||||
|
entry["chang_id"] = chang_id
|
||||||
|
entry["ok"] = False
|
||||||
|
entry["error"] = None
|
||||||
|
entry["grown_b64"] = None
|
||||||
|
if chang_id is None:
|
||||||
|
entry["error"] = f"无对应 chang_id"
|
||||||
|
timings["per_hairstyle"].append(entry)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
# 2a/2b/2c: generate_hairline_redraw (内部含 mask+swap+blend)
|
||||||
|
t0 = _time.perf_counter()
|
||||||
|
data = await run_in_threadpool(
|
||||||
|
generate_hairline_redraw, redraw_img, chang_id,
|
||||||
|
hair_mask=hair_mask_redraw, webui_steps=webui_steps, **_V2_FINAL_DEFAULTS)
|
||||||
|
t_redraw_pipeline = _time.perf_counter() - t0
|
||||||
|
_tm = data.get("timings_ms") or {}
|
||||||
|
entry["mask_ms"] = _tm.get("mask", 0)
|
||||||
|
entry["swap_ms"] = _tm.get("swap", 0)
|
||||||
|
entry["blend_ms"] = _tm.get("blend", 0)
|
||||||
|
entry["redraw_pipeline_ms"] = int(t_redraw_pipeline * 1000)
|
||||||
|
|
||||||
|
steps = data.get("steps") or {}
|
||||||
|
final_b64 = steps.get("final_base64") or ""
|
||||||
|
mask_b64 = steps.get("redraw_band_mask_base64") or ""
|
||||||
|
if not final_b64 or not mask_b64:
|
||||||
|
entry["error"] = f"final/遮罩缺失(final={len(final_b64)} mask={len(mask_b64)})"
|
||||||
|
timings["per_hairstyle"].append(entry)
|
||||||
|
continue
|
||||||
|
if final_b64.startswith("data:"):
|
||||||
|
final_b64 = final_b64.split(",", 1)[1]
|
||||||
|
if mask_b64.startswith("data:"):
|
||||||
|
mask_b64 = mask_b64.split(",", 1)[1]
|
||||||
|
|
||||||
|
# 3: ComfyUI 重绘
|
||||||
|
t0 = _time.perf_counter()
|
||||||
|
# max_side: 0 或 None 都让 _call_local_redraw 用默认逻辑(外层已控制分辨率)
|
||||||
|
_ms = redraw_max_side if redraw_max_side is not None and redraw_max_side > 0 else None
|
||||||
|
grown_png = await run_in_threadpool(
|
||||||
|
_call_local_redraw,
|
||||||
|
base64.b64decode(final_b64), base64.b64decode(mask_b64),
|
||||||
|
max_side=_ms, prompt=redraw_prompt)
|
||||||
|
entry["comfyui_redraw_ms"] = int((_time.perf_counter() - t0) * 1000)
|
||||||
|
if grown_png:
|
||||||
|
entry["grown_b64"] = "data:image/jpeg;base64," + _png_to_jpg_b64(grown_png)
|
||||||
|
entry["ok"] = True
|
||||||
|
else:
|
||||||
|
entry["error"] = "ComfyUI 重绘返回空"
|
||||||
|
except NoFaceError:
|
||||||
|
entry["error"] = "未检出人脸"
|
||||||
|
except Exception as ex: # noqa: BLE001
|
||||||
|
entry["error"] = str(ex)[:150]
|
||||||
|
entry["hairstyle_total_ms"] = int((_time.perf_counter() - hs_t0) * 1000)
|
||||||
|
timings["per_hairstyle"].append(entry)
|
||||||
|
|
||||||
|
timings["total_ms"] = int((_time.perf_counter() - t_total0) * 1000)
|
||||||
|
timings["downscale"] = downscale_info
|
||||||
|
timings["image_size"] = f"{w}x{h}"
|
||||||
|
return ok(timings)
|
||||||
except Exception as ex: # noqa: BLE001
|
except Exception as ex: # noqa: BLE001
|
||||||
logger.exception("接口7 处理异常")
|
logger.exception("debug/grow-timing 异常")
|
||||||
return err(1007, f"处理失败:{ex}")
|
return err(1007, f"处理失败:{ex}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 接口 7:C 端生发 v2 —— 已弃用(add_hair2.json 用 Klein-9b 大模型,会把常驻的
|
||||||
|
# Klein-4b/Flux 挤出显存,导致接口2/3/5 耗时抖动;且业务已不再调用)。
|
||||||
|
# 保留路由返回明确错误,避免老客户端拿到裸 404。
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app.post("/api/v1/hair/grow-v2", include_in_schema=False, deprecated=True)
|
||||||
|
async def hair_grow_v2():
|
||||||
|
"""接口7 已弃用:请改用 /api/v1/hair/grow(接口2)。"""
|
||||||
|
return err(1007, "接口7(/api/v1/hair/grow-v2)已弃用,请使用 /api/v1/hair/grow")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 接口 3:B 端生发
|
# 接口 3:B 端生发
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -929,7 +1031,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)
|
||||||
@@ -1055,11 +1157,14 @@ async def face_features(
|
|||||||
---
|
---
|
||||||
|
|
||||||
**入参**(同接口2:先选性别,再多选发型):
|
**入参**(同接口2:先选性别,再多选发型):
|
||||||
- 必填 `gender`(`male`/`female`),决定发型集合(female 5 / male 4)。
|
- 必填 `gender`(`male`/`female`),决定发型集合(female 7 / male 6)。
|
||||||
- 必填 `hair_style`(发型序号,逗号分隔如 `1,2,3`),决定返回哪些发际线类型。缺失/越界/非法返回 `1007`。
|
- 必填 `hair_style`(发型序号,逗号分隔如 `1,2,3`),决定返回哪些发际线类型。缺失/越界/非法返回 `1007`。
|
||||||
`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,6=bigflower,7=clasicalflower;
|
||||||
|
`male`:1=ellipse,2=inverse_arc,3=m,4=straight,5=heart,6=Softpetal。
|
||||||
- 可选 `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`,仅返回三档发际线叠图与中心点,大幅降低耗时。
|
||||||
|
|
||||||
**返回说明**:
|
**返回说明**:
|
||||||
|
|
||||||
@@ -1135,15 +1240,16 @@ async def hairline_generate(
|
|||||||
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
||||||
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
||||||
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-7 male:1-6"),
|
||||||
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")
|
||||||
|
|
||||||
# hair_style 必填(同接口2):解析逗号分隔,缺失/越界/非法 → 1007
|
# hair_style 必填(同接口2):解析逗号分隔,缺失/越界/非法 → 1007
|
||||||
max_styles = {"female": 5, "male": 4}[gender]
|
max_styles = {"female": 7, "male": 6}[gender]
|
||||||
hair_styles = _parse_hair_styles(hair_style, max_styles)
|
hair_styles = _parse_hair_styles(hair_style, max_styles)
|
||||||
if hair_styles is None:
|
if hair_styles is None:
|
||||||
return err(1007, f"hair_style 必填且为 1..{max_styles} 的整数(逗号分隔),收到 {hair_style!r}")
|
return err(1007, f"hair_style 必填且为 1..{max_styles} 的整数(逗号分隔),收到 {hair_style!r}")
|
||||||
@@ -1161,7 +1267,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, "无法识别人像")
|
||||||
|
|
||||||
@@ -1529,7 +1636,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"),
|
||||||
@@ -1676,6 +1783,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,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)(需手动在图上画发际线后作为划线图上传)
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
按 `face_ext.obj` 的 UV 把发际线贴图渲染到额头(预览)。生发:黑贴图渲染遮罩 → 调本机 **ComfyUI 8182** 的
|
按 `face_ext.obj` 的 UV 把发际线贴图渲染到额头(预览)。生发:黑贴图渲染遮罩 → 调本机 **ComfyUI 8182** 的
|
||||||
`add_hair.json`(Flux-2) 出图。**关键坑**:obj 是重排序,需 `INDEX_MAP_468` 把 MP 序→OBJ 序。
|
`add_hair.json`(Flux-2) 出图。**关键坑**:obj 是重排序,需 `INDEX_MAP_468` 把 MP 序→OBJ 序。
|
||||||
返回 `results[].image_base64` + `grown_image_base64`。
|
返回 `results[].image_base64` + `grown_image_base64`。
|
||||||
- `hair_style` 映射:female 1=ellipse 2=flower 3=heart 4=straight 5=wave;male 1=ellipse 2=inverse_arc 3=m 4=straight。
|
- `hair_style` 映射:female 1=ellipse 2=flower 3=heart 4=straight 5=wave 6=bigflower 7=clasicalflower;male 1=ellipse 2=inverse_arc 3=m 4=straight 5=heart 6=Softpetal。female 1..5 走「换发型(change_hair)」+Flux-2 重绘管线;female 6/7 与 male 全部走原生发(ComfyUI add_hair)管线。
|
||||||
|
|
||||||
### 接口7 C端生发 v2 `/api/v1/hair/grow-v2`(worker)—— 接口2同款,add_hair2 工作流
|
### 接口7 C端生发 v2 `/api/v1/hair/grow-v2`(worker)—— 接口2同款,add_hair2 工作流
|
||||||
- **做什么**:与接口 2 完全一致(正面照 + `gender` + `hair_style` 逗号分隔多选 → N 组预览+生发图)。
|
- **做什么**:与接口 2 完全一致(正面照 + `gender` + `hair_style` 逗号分隔多选 → N 组预览+生发图)。
|
||||||
|
|||||||
@@ -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 有)。
|
||||||
|
|
||||||
@@ -261,7 +265,7 @@
|
|||||||
| 参数 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| gender | string | **是** | 性别:`male` / `female`。决定使用的发际线贴图集合 |
|
| 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` |
|
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),最多不超过该性别的预设数。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave, 6=bigflower, 7=clasicalflower;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight, 5=heart, 6=Softpetal。越界/非法返回 `1007` |
|
||||||
| beauty_enabled | bool | 否 | 生发图是否带美颜效果,默认 false(当前阶段不生效) |
|
| beauty_enabled | bool | 否 | 生发图是否带美颜效果,默认 false(当前阶段不生效) |
|
||||||
| use_mask | bool | 否 | 是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
|
| use_mask | bool | 否 | 是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
|
||||||
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
|
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
|
||||||
@@ -274,7 +278,7 @@
|
|||||||
|------|------|------|
|
|------|------|------|
|
||||||
| image_url | string | 发际线曲线**透明 PNG** URL(仅白色发际线曲线,透明底,**不含人物**,需前端叠加原图显示) |
|
| image_url | string | 发际线曲线**透明 PNG** URL(仅白色发际线曲线,透明底,**不含人物**,需前端叠加原图显示) |
|
||||||
| grown_image_url | string | **生发后图片** URL(ComfyUI/Flux「植发 3 个月」效果图,完整人像照片) |
|
| grown_image_url | string | **生发后图片** URL(ComfyUI/Flux「植发 3 个月」效果图,完整人像照片) |
|
||||||
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`(female),`ellipse`/`m`/`straight`/`inverse_arc`(male) |
|
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`/`bigflower`/`clasicalflower`(female),`ellipse`/`m`/`straight`/`inverse_arc`/`heart`/`Softpetal`(male) |
|
||||||
| order | int | 排序序号(当前阶段固定 `1..N`,按贴图顺序,暂不计算合适度) |
|
| order | int | 排序序号(当前阶段固定 `1..N`,按贴图顺序,暂不计算合适度) |
|
||||||
|
|
||||||
> ⚠️ 生发图由本机 ComfyUI(Flux-2,端口 8182)生成,**一次请求生成指定发型的 1 张、同步返回**。
|
> ⚠️ 生发图由本机 ComfyUI(Flux-2,端口 8182)生成,**一次请求生成指定发型的 1 张、同步返回**。
|
||||||
@@ -401,10 +405,11 @@
|
|||||||
|
|
||||||
| 参数 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| gender | string | **是** | 性别:`male` / `female`。决定发型集合(female 5 / male 4)。缺失/非法返回 `1004` |
|
| gender | string | **是** | 性别:`male` / `female`。决定发型集合(female 7 / male 6)。缺失/非法返回 `1004` |
|
||||||
| 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, 6=bigflower, 7=clasicalflower;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight, 5=heart, 6=Softpetal。缺失/越界/非法返回 `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 张生发图。
|
||||||
|
|
||||||
@@ -422,11 +427,11 @@
|
|||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`(female),`ellipse`/`m`/`straight`/`inverse_arc`(male) |
|
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`/`bigflower`/`clasicalflower`(female),`ellipse`/`m`/`straight`/`inverse_arc`/`heart`/`Softpetal`(male) |
|
||||||
| 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(9, round(s * 0.020)) # 字号上调一档
|
||||||
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)) # 虚线更稠密(间隙<划线)
|
||||||
@@ -213,7 +212,11 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
|
|||||||
|
|
||||||
buf = np.zeros((h, w, 4), dtype=np.uint8)
|
buf = np.zeros((h, w, 4), dtype=np.uint8)
|
||||||
|
|
||||||
if variant == "v6":
|
# 发际线弃用(hairline_discarded):保留头顶横线,去掉发际线横线,
|
||||||
|
# 也不标顶/上庭(缺发际线作边界,算不出)。横线 = 头顶/眉心/鼻翼下缘/下巴尖。
|
||||||
|
if getattr(measure_result, "hairline_discarded", False):
|
||||||
|
order = ["hair_top", "brow_center", "nose_bottom", "chin_tip"]
|
||||||
|
elif variant == "v6":
|
||||||
order = ["hairline", "brow_center", "nose_bottom", "chin_tip"]
|
order = ["hairline", "brow_center", "nose_bottom", "chin_tip"]
|
||||||
else:
|
else:
|
||||||
order = ["hair_top", "hairline", "brow_center", "nose_bottom", "chin_tip"]
|
order = ["hair_top", "hairline", "brow_center", "nose_bottom", "chin_tip"]
|
||||||
@@ -240,7 +243,7 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
|
|||||||
face_cx = (fx0 + fx1) / 2
|
face_cx = (fx0 + fx1) / 2
|
||||||
over = max(6, round(s * 0.030)) # 线超出包围盒的长度(参考图风格)
|
over = max(6, round(s * 0.030)) # 线超出包围盒的长度(参考图风格)
|
||||||
face_half = (fx1 - fx0) / 2 + over # 横线超出最外侧竖线一点
|
face_half = (fx1 - fx0) / 2 + over # 横线超出最外侧竖线一点
|
||||||
# v6 竖线纵向范围 = 发际线→下巴尖(不超出);v1 = 头顶→下巴尖并两端超出一点
|
# 竖线纵向范围:v6 = 发际线→下巴尖(不超出);v1(含发际线弃用)= 头顶→下巴尖并两端超出一点
|
||||||
v_top = fy0 if variant == "v6" else fy0 - over
|
v_top = fy0 if variant == "v6" else fy0 - over
|
||||||
v_bot = fy1 if variant == "v6" else fy1 + over
|
v_bot = fy1 if variant == "v6" else fy1 + over
|
||||||
|
|
||||||
@@ -256,70 +259,97 @@ 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. 横线右侧:线名(头顶/发际线/眉心/鼻翼下缘/下巴尖),文字在线上方 ---
|
# --- 2b. 每条横线在「中间线段」(两内眼角之间)中点画原点突出 ---
|
||||||
name_x = fx1 + pad
|
# 注意:原点不在整条线的中点 face_cx,而在被竖线切出的中间段(左内眼角↔右内眼角)
|
||||||
name_gap = max(2, round(pad * 1.6)) # 文字底部到线的间距(再上移)
|
# 的正中,即脸的竖直中轴附近、两内眼角连线中点。
|
||||||
|
li_x = pts["left_inner"][0]
|
||||||
|
ri_x = pts["right_inner"][0]
|
||||||
|
dot_cx = (li_x + ri_x) / 2
|
||||||
|
dot_r = max(2, round(s * 0.0045)) # 原点半径,与 arrow_size 同档自适应
|
||||||
|
for cy in ys:
|
||||||
|
x0, y0 = dot_cx - dot_r, cy - dot_r
|
||||||
|
x1, y1 = dot_cx + dot_r, cy + dot_r
|
||||||
|
draw.ellipse((x0, y0, x1, y1), fill=LINE_COLOR)
|
||||||
|
|
||||||
|
# --- 3a. 横线右侧:线名(头顶/发际线/眉心/鼻翼下缘/下巴尖),文字纵向居中对齐到线 ---
|
||||||
|
name_x = fx1 + over + pad # 移到横线右端外侧一点(往右)
|
||||||
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":
|
# court_start:庭段在 order 里的起始索引。发际线弃用时 order 首位是头顶(无下界发际线,
|
||||||
|
# 顶/上庭不标),中庭从眉心开始 → 跳过 order[0]。
|
||||||
|
if getattr(measure_result, "hairline_discarded", False):
|
||||||
|
court_cm = [measure_result.middle_cm, measure_result.lower_cm]
|
||||||
|
court_name = ["中庭", "下庭"]
|
||||||
|
n_court = 2
|
||||||
|
court_start = 1
|
||||||
|
elif variant == "v6":
|
||||||
court_cm = [measure_result.upper_cm, measure_result.middle_cm, measure_result.lower_cm]
|
court_cm = [measure_result.upper_cm, measure_result.middle_cm, measure_result.lower_cm]
|
||||||
court_name = ["上庭", "中庭", "下庭"]
|
court_name = ["上庭", "中庭", "下庭"]
|
||||||
n_court = 3
|
n_court = 3
|
||||||
|
court_start = 0
|
||||||
else:
|
else:
|
||||||
court_cm = [measure_result.top_cm, measure_result.upper_cm,
|
court_cm = [measure_result.top_cm, measure_result.upper_cm,
|
||||||
measure_result.middle_cm, measure_result.lower_cm]
|
measure_result.middle_cm, measure_result.lower_cm]
|
||||||
court_name = ["顶庭", "上庭", "中庭", "下庭"]
|
court_name = ["顶庭", "上庭", "中庭", "下庭"]
|
||||||
n_court = 4
|
n_court = 4
|
||||||
|
court_start = 0
|
||||||
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[court_start + i], ys[court_start + i + 1]
|
||||||
# 竖向虚线双箭头,覆盖该庭高度(略收一点避免压到横线)
|
# 竖向虚线双箭头,覆盖该庭高度(略收一点避免压到横线)
|
||||||
inset = min(arrow_size, (y_b - y_a) * 0.12)
|
inset = min(arrow_size, (y_b - y_a) * 0.12)
|
||||||
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 的版本)
|
||||||
|
|||||||
@@ -102,11 +102,26 @@ def _png_b64(bgr_or_gray):
|
|||||||
|
|
||||||
|
|
||||||
def _gray_b64(gray_float):
|
def _gray_b64(gray_float):
|
||||||
"""0~1 的浮点图 → 灰度 PNG data URI。"""
|
"""0~1 的浮字图 → 灰度 PNG data URI。"""
|
||||||
g = np.clip(gray_float * 255.0, 0, 255).astype(np.uint8)
|
g = np.clip(gray_float * 255.0, 0, 255).astype(np.uint8)
|
||||||
return _png_b64(g)
|
return _png_b64(g)
|
||||||
|
|
||||||
|
|
||||||
|
def _red_mask_b64(mask_bool, h, w):
|
||||||
|
"""布尔遮罩 → 纯红 alpha PNG data URI。
|
||||||
|
遮罩区域 RGBA=(255,0,0,255),其余区域 RGBA=(0,0,0,0)。
|
||||||
|
供 ComfyUI 重绘接口(/api/v1/redraw)按 alpha 通道识别重绘区。
|
||||||
|
|
||||||
|
注意:cv2.imencode 写 PNG 用的是 **BGRA** 顺序(B,G,R,A),所以要得到
|
||||||
|
浏览器显示的红色 R=255,需赋值 (B=0,G=0,R=255,A=255)。
|
||||||
|
"""
|
||||||
|
m = (mask_bool.astype(np.uint8)) * 255 if mask_bool is not None else np.zeros((h, w), np.uint8)
|
||||||
|
rgba = np.zeros((h, w, 4), np.uint8)
|
||||||
|
rgba[m > 0] = (0, 0, 255, 255) # BGRA: B=0,G=0,R=255 → PNG 读出为红色 + 不透明
|
||||||
|
ok, buf = cv2.imencode(".png", rgba)
|
||||||
|
return "data:image/png;base64," + base64.b64encode(buf.tobytes()).decode() if ok else ""
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 步骤1:接口9 头发遮罩(复用 head_mask 构件)
|
# 步骤1:接口9 头发遮罩(复用 head_mask 构件)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -342,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=""):
|
hairline_push_cm=0.0, hairline_edge="column", rid="", render_viz=True,
|
||||||
|
hair_mask=None):
|
||||||
"""算出布尔遮罩 + 可视化。
|
"""算出布尔遮罩 + 可视化。
|
||||||
|
|
||||||
seg_model: bisenet | segformer。
|
seg_model: bisenet | segformer。
|
||||||
@@ -350,6 +366,10 @@ def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm
|
|||||||
hairline_push_cm: 仅 pushed 模式——发际线往头发方向外推的厘米数(进入现有头发)。
|
hairline_push_cm: 仅 pushed 模式——发际线往头发方向外推的厘米数(进入现有头发)。
|
||||||
hairline_edge: 仅 pushed 模式——发际线提取方式 column(逐列最低点) | contour(形态学轮廓)。
|
hairline_edge: 仅 pushed 模式——发际线提取方式 column(逐列最低点) | contour(形态学轮廓)。
|
||||||
rid: 调用方的 request id,用于日志关联。
|
rid: 调用方的 request id,用于日志关联。
|
||||||
|
render_viz: 是否生成各阶段叠图 overlay JPG(接口11 调试页用)。接口2/12 路径传 False
|
||||||
|
可跳过 6+ 张 base64 编码,省 ~80ms;数据字段(_inner_pts/_outer_pts/_upper_mask/
|
||||||
|
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
|
||||||
@@ -363,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) # 闭合区域:头发+额头,底=基线
|
||||||
@@ -410,47 +433,49 @@ def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm
|
|||||||
# 遮罩计算过程可视化:
|
# 遮罩计算过程可视化:
|
||||||
# eroded/closed 走 top_fill→closed/eroded 流程;
|
# eroded/closed 走 top_fill→closed/eroded 流程;
|
||||||
# pushed 走 baseline→头发分割→发际线→外推 流程,与 top_fill/closed 无关,故置空。
|
# pushed 走 baseline→头发分割→发际线→外推 流程,与 top_fill/closed 无关,故置空。
|
||||||
|
# render_viz=False(接口2/12 路径)时跳过 overlay JPG 编码,只保留数据字段。
|
||||||
viz = {
|
viz = {
|
||||||
"erode_px": r,
|
"erode_px": r,
|
||||||
"hair_pixels": int(hair_mask.sum()),
|
"hair_pixels": int(hair_mask.sum()),
|
||||||
"closed_pixels": int(closed.sum()),
|
"closed_pixels": int(closed.sum()),
|
||||||
"mask_pixels": int(mask_bool.sum()),
|
"mask_pixels": int(mask_bool.sum()),
|
||||||
# 1. 发际线分割线(baseline):151 中心点标红,其余点标绿,黄线含左右延长线
|
# 1. 发际线分割线(baseline):151 中心点标红,其余点标绿,黄线含左右延长线
|
||||||
"baseline_overlay_base64": _jpg_b64(_draw_baseline(image_bgr, baseline_pts, w)),
|
"baseline_overlay_base64": _jpg_b64(_draw_baseline(image_bgr, baseline_pts, w)) if render_viz else "",
|
||||||
# 2. 分割线以上区域(upper 半区):青色叠加
|
# 2. 分割线以上区域(upper 半区):青色叠加
|
||||||
"upper_overlay_base64": _jpg_b64(_overlay(image_bgr, upper, (0, 255, 255))),
|
"upper_overlay_base64": _jpg_b64(_overlay(image_bgr, upper, (0, 255, 255))) if render_viz else "",
|
||||||
# 3. 头发分割原始结果(hair_mask):绿色叠加在原图上
|
# 3. 头发分割原始结果(hair_mask):绿色叠加在原图上
|
||||||
"hair_seg_overlay_base64": _jpg_b64(_overlay(image_bgr, hair_mask, (0, 255, 0))),
|
"hair_seg_overlay_base64": _jpg_b64(_overlay(image_bgr, hair_mask, (0, 255, 0))) if render_viz else "",
|
||||||
# 4. top_fill / closed —— 仅 eroded/closed 流程用;pushed 流程无关,留空
|
# 4. top_fill / closed —— 仅 eroded/closed 流程用;pushed 流程无关,留空
|
||||||
"top_fill_overlay_base64": "" if mask_type == "pushed"
|
"top_fill_overlay_base64": "" if (mask_type == "pushed" or not render_viz)
|
||||||
else _jpg_b64(_overlay(image_bgr, top_fill, (255, 0, 0))),
|
else _jpg_b64(_overlay(image_bgr, top_fill, (255, 0, 0))),
|
||||||
"closed_overlay_base64": "" if mask_type == "pushed"
|
"closed_overlay_base64": "" if (mask_type == "pushed" or not render_viz)
|
||||||
else _jpg_b64(_overlay(image_bgr, closed, (255, 0, 255))),
|
else _jpg_b64(_overlay(image_bgr, closed, (255, 0, 255))),
|
||||||
# 5. pushed 模式专有(发际线提取/外推)—— 非 pushed 留空
|
# 5. pushed 模式专有(发际线提取/外推)—— 非 pushed 留空
|
||||||
"hairline_overlay_base64": "",
|
"hairline_overlay_base64": "",
|
||||||
"pushed_overlay_base64": "",
|
"pushed_overlay_base64": "",
|
||||||
# —— 最终遮罩 ——
|
# —— 最终遮罩 ——
|
||||||
"mask_overlay_base64": _jpg_b64(_overlay(image_bgr, mask_bool, (0, 0, 255))),
|
"mask_overlay_base64": _jpg_b64(_overlay(image_bgr, mask_bool, (0, 0, 255))) if render_viz else "",
|
||||||
"mask_base64": _png_b64((mask_bool.astype(np.uint8)) * 255),
|
"mask_base64": _png_b64((mask_bool.astype(np.uint8)) * 255) if render_viz else "",
|
||||||
}
|
}
|
||||||
# pushed 模式:补充内轮廓提取 + 外推线可视化
|
# pushed 模式:补充内轮廓提取 + 外推线可视化
|
||||||
if pushed_info is not None:
|
if pushed_info is not None:
|
||||||
inner_pts, outer_pts, push_px = pushed_info
|
inner_pts, outer_pts, push_px = pushed_info
|
||||||
# ①-f 提取内轮廓:绿=头发内轮廓线(额头弧+两侧到下颌),黄=baseline 折线
|
if render_viz:
|
||||||
hl_img = _draw_baseline(image_bgr, baseline_pts, w) # 画 baseline(黄线+关键点)
|
# ①-f 提取内轮廓:绿=头发内轮廓线(额头弧+两侧到下颌),黄=baseline 折线
|
||||||
hl_img = _draw_polyline(hl_img, inner_pts, (0, 255, 0), 3)
|
hl_img = _draw_baseline(image_bgr, baseline_pts, w) # 画 baseline(黄线+关键点)
|
||||||
viz["hairline_overlay_base64"] = _jpg_b64(hl_img)
|
hl_img = _draw_polyline(hl_img, inner_pts, (0, 255, 0), 3)
|
||||||
# ①-g 外推:圆心红点(151) + 内轮廓(绿)+ 外推线(青)+ 遮罩(红半透明)
|
viz["hairline_overlay_base64"] = _jpg_b64(hl_img)
|
||||||
ps_img = _draw_polyline(image_bgr.copy(), inner_pts, (0, 255, 0), 2)
|
# ①-g 外推:圆心红点(151) + 内轮廓(绿)+ 外推线(青)+ 遮罩(红半透明)
|
||||||
ps_img = _draw_polyline(ps_img, outer_pts, (0, 255, 255), 3)
|
ps_img = _draw_polyline(image_bgr.copy(), inner_pts, (0, 255, 0), 2)
|
||||||
# 画圆心(151 点)红点,标示径向外推的中心(_idx151 上方已按值查到)
|
ps_img = _draw_polyline(ps_img, outer_pts, (0, 255, 255), 3)
|
||||||
if center is not None:
|
# 画圆心(151 点)红点,标示径向外推的中心(_idx151 上方已按值查到)
|
||||||
cx151, cy151 = center
|
if center is not None:
|
||||||
cv2.circle(ps_img, (cx151, cy151), 6, (0, 0, 255), -1, cv2.LINE_AA)
|
cx151, cy151 = center
|
||||||
cv2.putText(ps_img, "151", (cx151 + 8, cy151 - 8),
|
cv2.circle(ps_img, (cx151, cy151), 6, (0, 0, 255), -1, cv2.LINE_AA)
|
||||||
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 1, cv2.LINE_AA)
|
cv2.putText(ps_img, "151", (cx151 + 8, cy151 - 8),
|
||||||
ps_img = _overlay(ps_img, mask_bool, (0, 0, 255), 0.3)
|
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 1, cv2.LINE_AA)
|
||||||
viz["pushed_overlay_base64"] = _jpg_b64(ps_img)
|
ps_img = _overlay(ps_img, mask_bool, (0, 0, 255), 0.3)
|
||||||
|
viz["pushed_overlay_base64"] = _jpg_b64(ps_img)
|
||||||
viz["push_px"] = push_px
|
viz["push_px"] = push_px
|
||||||
# 重绘带用原始数据:内轮廓点 + 外推点(供 _redraw_band_mask 连端点成带)
|
# 重绘带用原始数据:内轮廓点 + 外推点(供 _redraw_band_mask 连端点成带)
|
||||||
viz["_inner_pts"] = inner_pts
|
viz["_inner_pts"] = inner_pts
|
||||||
@@ -578,7 +603,7 @@ _REPAINT_WORKFLOW = os.path.join(os.path.dirname(os.path.dirname(__file__)), "ha
|
|||||||
|
|
||||||
|
|
||||||
def _call_comfyui(image_bgr, mask_bool, prompt=None):
|
def _call_comfyui(image_bgr, mask_bool, prompt=None):
|
||||||
"""调本机 ComfyUI 的 Flux-2 inpaint 工作流(hair_repaint.json),返回与输入同分辨率的 BGR。
|
"""调远端 ComfyUI 的 Flux-2 inpaint 工作流(hair_repaint.json),返回与输入同分辨率的 BGR。
|
||||||
|
|
||||||
与 swapHair 的区别:ComfyUI 把「原图 VAE 编码作 reference latent + ColorMatch」双重保色,
|
与 swapHair 的区别:ComfyUI 把「原图 VAE 编码作 reference latent + ColorMatch」双重保色,
|
||||||
天生不易染色;提示词自由可调(中文)。mask 经 RGBA alpha 通道传入(透明=重绘区)。
|
天生不易染色;提示词自由可调(中文)。mask 经 RGBA alpha 通道传入(透明=重绘区)。
|
||||||
@@ -586,10 +611,10 @@ def _call_comfyui(image_bgr, mask_bool, prompt=None):
|
|||||||
"""
|
"""
|
||||||
import io
|
import io
|
||||||
from hairline.mask import compose_comfy_rgba
|
from hairline.mask import compose_comfy_rgba
|
||||||
from hairline.comfyui import run as comfyui_run, ping
|
from hairline.comfyui import COMFYUI_URL, run as comfyui_run, ping
|
||||||
|
|
||||||
if not ping():
|
if not ping():
|
||||||
raise SwapError("ComfyUI 不可达(http://127.0.0.1:8188),redraw Flux-2 路跳过")
|
raise SwapError(f"ComfyUI 不可达({COMFYUI_URL}),redraw Flux-2 路跳过")
|
||||||
mask_u8 = (mask_bool.astype(np.uint8)) * 255
|
mask_u8 = (mask_bool.astype(np.uint8)) * 255
|
||||||
rgba_img = compose_comfy_rgba(image_bgr, mask_u8) # alpha=255-mask:透明=重绘区
|
rgba_img = compose_comfy_rgba(image_bgr, mask_u8) # alpha=255-mask:透明=重绘区
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
@@ -829,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):
|
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+重绘带用):
|
||||||
@@ -858,7 +884,8 @@ def _grow_core(image_bgr, hairline_id, *, is_hr, seg_model, erode_cm, swap_mode,
|
|||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
mask_bool, mask_viz = compute_mask(
|
mask_bool, mask_viz = compute_mask(
|
||||||
image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm,
|
image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm,
|
||||||
hairline_push_cm=hairline_push_cm, hairline_edge=hairline_edge, rid=rid)
|
hairline_push_cm=hairline_push_cm, hairline_edge=hairline_edge, rid=rid,
|
||||||
|
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()))
|
||||||
|
|
||||||
@@ -1007,21 +1034,20 @@ 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 截断只留上部)作遮罩,用 Flux-2(ComfyUI,hair_repaint.json)
|
(外推↔内推之间、经 baseline 截断只留上部)作遮罩。
|
||||||
重绘(band 经 alpha 送进 ComfyUI 决定加发位置,ComfyUI 输出为整帧重绘+美颜图)。
|
|
||||||
|
|
||||||
**同时产出两版结果供对比**:
|
**本接口不再做 Flux-2 重绘**:只产出 `final`(接缝融合基底)+ 纯红遮罩
|
||||||
- `redraw_full`:ComfyUI 整帧输出(全脸美颜 + 全脸重绘),与手动跑 ComfyUI 一致。
|
`redraw_band_mask`(RGBA,遮罩区=(255,0,0,255)、其余全透明),重绘交给后端
|
||||||
- `redraw_band`:加发只在发际线带、美颜保留全脸。band 内完全用 ComfyUI 重绘(加发),
|
ComfyUI 重绘接口(/api/v1/redraw)完成。旧的 `redraw_full` / `redraw_band`
|
||||||
band 外用 `final` 结构 + 按 `beauty_alpha` 融入 ComfyUI 的全脸美颜。
|
字段保留为空,仅作结构兼容。
|
||||||
|
|
||||||
返回可直接进 ok() 的 data dict。未检出人脸抛 NoFaceError。
|
返回可直接进 ok() 的 data dict。未检出人脸抛 NoFaceError。
|
||||||
|
|
||||||
comfyui_prompt:Flux-2 提示词,None 用默认「补充遮罩区域的头发,加一点美颜」。
|
comfyui_prompt:保留入参,但本接口不再使用(重绘提示词由外部服务自行决定)。
|
||||||
beauty_alpha:redraw_band 版 band 外的全脸美颜融入强度(0=完全保留 final 无美颜,
|
beauty_alpha:保留入参,但本接口不再使用(美颜由外部服务控制)。
|
||||||
1=band 外也完全用 ComfyUI 输出≈redraw_full),默认 0.6。
|
|
||||||
band_lo_mult / band_hi_mult:重绘带外推倍率(相对 hairline_push_cm),带位于
|
band_lo_mult / band_hi_mult:重绘带外推倍率(相对 hairline_push_cm),带位于
|
||||||
lo×push ~ hi×push 之间(内轮廓=0×、原外推线=1.0×),默认 0.5 / 1.5。
|
lo×push ~ hi×push 之间(内轮廓=0×、原外推线=1.0×),默认 0.5 / 1.5。
|
||||||
其余参数含义与接口11 相同(用于内部生成 final 与重绘带)。
|
其余参数含义与接口11 相同(用于内部生成 final 与重绘带)。
|
||||||
@@ -1037,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)
|
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"]
|
||||||
@@ -1050,8 +1077,7 @@ def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="seg
|
|||||||
upper_mask = mask_viz.get("_upper_mask")
|
upper_mask = mask_viz.get("_upper_mask")
|
||||||
push_px = int(round(max(0.0, hairline_push_cm) * px_per_cm))
|
push_px = int(round(max(0.0, hairline_push_cm) * px_per_cm))
|
||||||
redraw_band_overlay_b64 = ""
|
redraw_band_overlay_b64 = ""
|
||||||
redraw_full_b64 = "" # A:ComfyUI 整帧(全脸美颜+全脸重绘)
|
redraw_band_mask_b64 = "" # 纯红 alpha PNG(遮罩区=(255,0,0,255),其余全透明)
|
||||||
redraw_band_b64 = "" # B:加发只在发际线带、美颜保留全脸
|
|
||||||
redraw_info = {"enabled": False}
|
redraw_info = {"enabled": False}
|
||||||
band_mask = None
|
band_mask = None
|
||||||
try:
|
try:
|
||||||
@@ -1062,44 +1088,26 @@ def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="seg
|
|||||||
logger.info("[%s] 重绘带 push_px=%d lo_mult=%s hi_mult=%s band_pixels=%d",
|
logger.info("[%s] 重绘带 push_px=%d lo_mult=%s hi_mult=%s band_pixels=%d",
|
||||||
rid, push_px, band_lo_mult, band_hi_mult, int(band_mask.sum()))
|
rid, push_px, band_lo_mult, band_hi_mult, int(band_mask.sum()))
|
||||||
redraw_band_overlay_b64 = _jpg_b64(_overlay(final, band_mask, (255, 0, 255)))
|
redraw_band_overlay_b64 = _jpg_b64(_overlay(final, band_mask, (255, 0, 255)))
|
||||||
|
# 纯红遮罩 PNG(供外部重绘服务按 alpha 识别重绘区)
|
||||||
|
redraw_band_mask_b64 = _red_mask_b64(band_mask, h, w)
|
||||||
redraw_info = {"enabled": True, "band_pixels": int(band_mask.sum()), "push_px": push_px,
|
redraw_info = {"enabled": True, "band_pixels": int(band_mask.sum()), "push_px": push_px,
|
||||||
"band_lo_mult": float(band_lo_mult), "band_hi_mult": float(band_hi_mult)}
|
"band_lo_mult": float(band_lo_mult), "band_hi_mult": float(band_hi_mult)}
|
||||||
except Exception as ex: # noqa: BLE001
|
except Exception as ex: # noqa: BLE001
|
||||||
logger.exception("[%s] 重绘带计算失败,整个重绘跳过", rid)
|
logger.exception("[%s] 重绘带计算失败,整个重绘跳过", rid)
|
||||||
redraw_info = {"enabled": False, "error": f"band: {ex}"}
|
redraw_info = {"enabled": False, "error": f"band: {ex}"}
|
||||||
|
|
||||||
# ② Flux-2 路:final + band 调 ComfyUI(hair_repaint.json 整图重绘 + reference latent
|
# ② Flux-2 重绘已下线:本接口现在只产出 final(接缝融合基底)+ 纯红重绘带遮罩,
|
||||||
# 保色 + ColorMatch + 美颜)。band 经 alpha 送进 ComfyUI 决定加发位置。
|
# 重绘交给后端 ComfyUI 重绘接口(/api/v1/redraw)完成。
|
||||||
beauty_alpha = float(min(max(beauty_alpha, 0.0), 1.0))
|
# 下面保留 redraw_full_b64 / redraw_band_b64 为空,保持返回结构兼容(旧字段)。
|
||||||
if redraw_info.get("enabled"):
|
redraw_full_b64 = ""
|
||||||
try:
|
redraw_band_b64 = ""
|
||||||
prompt = comfyui_prompt if comfyui_prompt else "补充遮罩区域的头发,加一点美颜"
|
|
||||||
redraw_c_raw = _call_comfyui(final, band_mask, prompt=prompt)
|
|
||||||
# A:整帧输出(与手动跑 ComfyUI 一致)
|
|
||||||
redraw_full_b64 = _jpg_b64(redraw_c_raw)
|
|
||||||
# B:加发只在 band、美颜保留全脸。
|
|
||||||
# alpha = band 内 1(羽化边缘);band 外 = beauty_alpha。
|
|
||||||
# band 内完全用 ComfyUI(加发);band 外用 final 结构 + beauty_alpha 融入全脸美颜。
|
|
||||||
feather_px = max(8, int(round(push_px * 0.6)))
|
|
||||||
band_a = _feather_alpha(band_mask, "feather", feather_px, 0) # 0..1,band 外=0
|
|
||||||
a = band_a + (1.0 - band_a) * beauty_alpha
|
|
||||||
a3 = a[:, :, None]
|
|
||||||
band_mix = (final.astype(np.float32) * (1.0 - a3)
|
|
||||||
+ redraw_c_raw.astype(np.float32) * a3)
|
|
||||||
redraw_band_b64 = _jpg_b64(np.clip(band_mix, 0, 255).astype(np.uint8))
|
|
||||||
redraw_info["beauty_alpha"] = beauty_alpha
|
|
||||||
logger.info("[%s] Flux-2 重绘完成:redraw_full(整帧) + redraw_band(局部加发+全脸美颜 beauty_alpha=%.2f)",
|
|
||||||
rid, beauty_alpha)
|
|
||||||
except Exception as ex: # noqa: BLE001
|
|
||||||
logger.warning("[%s] Flux-2 路重绘失败,跳过: %s", rid, ex)
|
|
||||||
redraw_info["c_error"] = str(ex)
|
|
||||||
t_redraw = time.time() - t0
|
t_redraw = time.time() - t0
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"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"],
|
||||||
@@ -1116,9 +1124,11 @@ def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="seg
|
|||||||
"final_base64": _jpg_b64(final),
|
"final_base64": _jpg_b64(final),
|
||||||
# ⑤-① 发际线重绘带(紫,已按 baseline 截断只留上部)
|
# ⑤-① 发际线重绘带(紫,已按 baseline 截断只留上部)
|
||||||
"redraw_band_overlay_base64": redraw_band_overlay_b64,
|
"redraw_band_overlay_base64": redraw_band_overlay_b64,
|
||||||
# A:ComfyUI 整帧重绘+美颜(与手动跑 ComfyUI 一致)
|
# ⑤-② 发际线重绘带遮罩(纯红 alpha PNG,遮罩区=(255,0,0,255))
|
||||||
|
"redraw_band_mask_base64": redraw_band_mask_b64,
|
||||||
|
# A:ComfyUI 整帧重绘+美颜(已下线,保留空字段兼容旧前端)
|
||||||
"redraw_full_base64": redraw_full_b64,
|
"redraw_full_base64": redraw_full_b64,
|
||||||
# B:加发只在发际线带、美颜保留全脸
|
# B:加发只在发际线带、美颜保留全脸(已下线,保留空字段兼容旧前端)
|
||||||
"redraw_band_base64": redraw_band_b64,
|
"redraw_band_base64": redraw_band_b64,
|
||||||
# 兼容旧字段:指向 A(整帧版)
|
# 兼容旧字段:指向 A(整帧版)
|
||||||
"redraw_c_base64": redraw_full_b64,
|
"redraw_c_base64": redraw_full_b64,
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ from face_analysis.calibration import (
|
|||||||
estimate_scale_factor, normalized_to_pixel, pixel_distance, _lm_list,
|
estimate_scale_factor, normalized_to_pixel, pixel_distance, _lm_list,
|
||||||
)
|
)
|
||||||
from face_analysis.face_mesh_landmarks import (
|
from face_analysis.face_mesh_landmarks import (
|
||||||
GLABELLA_9, GLABELLA_151, NOSE_BOTTOM, CHIN_TIP,
|
GLABELLA_9, 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
|
||||||
|
|
||||||
@@ -24,10 +24,8 @@ _TOP_RATIO = 0.22 / 0.28 # 顶庭 ÷ 中庭(≈ 0.786)
|
|||||||
|
|
||||||
|
|
||||||
def _brow_center(lm, w, h):
|
def _brow_center(lm, w, h):
|
||||||
"""眉心 = 索引 9 / 151 中点。"""
|
"""眉心 = 索引 9(眉间上点)。"""
|
||||||
g9 = normalized_to_pixel(lm[GLABELLA_9], w, h)
|
return normalized_to_pixel(lm[GLABELLA_9], w, h)
|
||||||
g151 = normalized_to_pixel(lm[GLABELLA_151], w, h)
|
|
||||||
return (g9[0] + g151[0]) / 2, (g9[1] + g151[1]) / 2
|
|
||||||
|
|
||||||
|
|
||||||
def estimate_vertical_landmarks(landmarks, image_width, image_height):
|
def estimate_vertical_landmarks(landmarks, image_width, image_height):
|
||||||
@@ -144,22 +142,47 @@ def measure_seven_eyes(landmarks, image_width, image_height):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def pt_or_none(vertical, name):
|
||||||
|
"""vertical dict 的点 → {"x","y"},值为 None 时返回 None。"""
|
||||||
|
v = vertical.get(name)
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
return {"x": int(round(v[0])), "y": int(round(v[1]))}
|
||||||
|
|
||||||
|
|
||||||
class MeasureResult:
|
class MeasureResult:
|
||||||
"""测量结果,提供 to_response() 输出与接口文档同构的 data 字段。"""
|
"""测量结果,提供 to_response() 输出与接口文档同构的 data 字段。"""
|
||||||
|
|
||||||
def __init__(self, vertical, eyes, px_per_cm, hairline_source, head_pose):
|
# 发际线弃用阈值:发际线离头顶(顶庭)< 此值时判定分割不可靠,弃用发际线。
|
||||||
|
# hairline 与 hair_top 几乎重合(如稀疏头发中轴漏检只剩一小撮),说明发际线
|
||||||
|
# 定位无意义 → 顶/上庭置 null、标注图不画头顶/发际线。
|
||||||
|
HAIRLINE_DISCARD_TOP_CM = 0.7
|
||||||
|
|
||||||
|
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
|
||||||
self.upper_cm = vertical["upper_court_px"] / px_per_cm
|
self.upper_cm = vertical["upper_court_px"] / px_per_cm
|
||||||
self.middle_cm = vertical["middle_court_px"] / px_per_cm
|
self.middle_cm = vertical["middle_court_px"] / px_per_cm
|
||||||
self.lower_cm = vertical["lower_court_px"] / px_per_cm
|
self.lower_cm = vertical["lower_court_px"] / px_per_cm
|
||||||
self.face_total_cm = self.top_cm + self.upper_cm + self.middle_cm + self.lower_cm
|
# 发际线弃用判定:顶庭(头顶→发际线)过小视为发际线贴近头顶、不可靠。
|
||||||
|
# 弃用时 hairline_source 改为 "discarded",face_total 只算中庭+下庭。
|
||||||
|
self.hairline_discarded = self.top_cm < self.HAIRLINE_DISCARD_TOP_CM
|
||||||
|
if self.hairline_discarded:
|
||||||
|
self.hairline_source = "discarded"
|
||||||
|
self.face_total_cm = self.middle_cm + self.lower_cm
|
||||||
|
else:
|
||||||
|
self.face_total_cm = self.top_cm + self.upper_cm + self.middle_cm + self.lower_cm
|
||||||
|
|
||||||
# 七眼厘米
|
# 七眼厘米
|
||||||
self.eye_width_cm = eyes["eye_width_px"] / px_per_cm
|
self.eye_width_cm = eyes["eye_width_px"] / px_per_cm
|
||||||
@@ -167,46 +190,88 @@ class MeasureResult:
|
|||||||
self.inter_eye_cm = eyes["inter_eye_distance_px"] / px_per_cm
|
self.inter_eye_cm = eyes["inter_eye_distance_px"] / px_per_cm
|
||||||
|
|
||||||
def to_response(self):
|
def to_response(self):
|
||||||
total_px = (self.vertical["top_court_px"] + self.vertical["upper_court_px"]
|
# 发际线弃用:顶/上庭相关字段置 null(保留键),ratio 分母只算中下庭;
|
||||||
+ self.vertical["middle_court_px"] + self.vertical["lower_court_px"])
|
# landmarks.hair_top/hairline 置 null。否则按四庭正常输出。
|
||||||
fw_px = self.eyes["face_width_px"]
|
if self.hairline_discarded:
|
||||||
|
base_px = (self.vertical["middle_court_px"] + self.vertical["lower_court_px"])
|
||||||
def pt(name):
|
data = {
|
||||||
x, y = self.vertical[name]
|
"face_total_height_cm": round(self.face_total_cm, 2),
|
||||||
return {"x": int(round(x)), "y": int(round(y))}
|
"four_courts": {
|
||||||
|
"top_court_cm": None,
|
||||||
data = {
|
"upper_court_cm": None,
|
||||||
"face_total_height_cm": round(self.face_total_cm, 2),
|
"middle_court_cm": round(self.middle_cm, 2),
|
||||||
"four_courts": {
|
"lower_court_cm": round(self.lower_cm, 2),
|
||||||
"top_court_cm": round(self.top_cm, 2),
|
"ratios": {
|
||||||
"upper_court_cm": round(self.upper_cm, 2),
|
"top_court": None,
|
||||||
"middle_court_cm": round(self.middle_cm, 2),
|
"upper_court": None,
|
||||||
"lower_court_cm": round(self.lower_cm, 2),
|
"middle_court": round(self.vertical["middle_court_px"] / base_px, 3),
|
||||||
"ratios": {
|
"lower_court": round(self.vertical["lower_court_px"] / base_px, 3),
|
||||||
"top_court": round(self.vertical["top_court_px"] / total_px, 3),
|
},
|
||||||
"upper_court": round(self.vertical["upper_court_px"] / total_px, 3),
|
|
||||||
"middle_court": round(self.vertical["middle_court_px"] / total_px, 3),
|
|
||||||
"lower_court": round(self.vertical["lower_court_px"] / total_px, 3),
|
|
||||||
},
|
},
|
||||||
},
|
"seven_eyes": {
|
||||||
"seven_eyes": {
|
"eye_width_cm": round(self.eye_width_cm, 2),
|
||||||
"eye_width_cm": round(self.eye_width_cm, 2),
|
"face_width_cm": round(self.face_width_cm, 2),
|
||||||
"face_width_cm": round(self.face_width_cm, 2),
|
"inter_eye_distance_cm": round(self.inter_eye_cm, 2),
|
||||||
"inter_eye_distance_cm": round(self.inter_eye_cm, 2),
|
"ratios": {
|
||||||
"ratios": {
|
"eye_width": round(self.eyes["eye_width_px"] / self.eyes["face_width_px"], 3),
|
||||||
"eye_width": round(self.eyes["eye_width_px"] / fw_px, 3),
|
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / self.eyes["face_width_px"], 3),
|
||||||
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / fw_px, 3),
|
},
|
||||||
},
|
},
|
||||||
},
|
"landmarks": {
|
||||||
"landmarks": {
|
"hair_top": None,
|
||||||
"hair_top": pt("hair_top"),
|
"hairline": None,
|
||||||
"hairline": pt("hairline"),
|
"brow_center": pt_or_none(self.vertical, "brow_center"),
|
||||||
"brow_center": pt("brow_center"),
|
"nose_bottom": pt_or_none(self.vertical, "nose_bottom"),
|
||||||
"nose_bottom": pt("nose_bottom"),
|
"chin_tip": pt_or_none(self.vertical, "chin_tip"),
|
||||||
"chin_tip": pt("chin_tip"),
|
},
|
||||||
},
|
"hairline_source": self.hairline_source,
|
||||||
"hairline_source": self.hairline_source,
|
}
|
||||||
}
|
else:
|
||||||
|
total_px = (self.vertical["top_court_px"] + self.vertical["upper_court_px"]
|
||||||
|
+ self.vertical["middle_court_px"] + self.vertical["lower_court_px"])
|
||||||
|
data = {
|
||||||
|
"face_total_height_cm": round(self.face_total_cm, 2),
|
||||||
|
"four_courts": {
|
||||||
|
"top_court_cm": round(self.top_cm, 2),
|
||||||
|
"upper_court_cm": round(self.upper_cm, 2),
|
||||||
|
"middle_court_cm": round(self.middle_cm, 2),
|
||||||
|
"lower_court_cm": round(self.lower_cm, 2),
|
||||||
|
"ratios": {
|
||||||
|
"top_court": round(self.vertical["top_court_px"] / total_px, 3),
|
||||||
|
"upper_court": round(self.vertical["upper_court_px"] / total_px, 3),
|
||||||
|
"middle_court": round(self.vertical["middle_court_px"] / total_px, 3),
|
||||||
|
"lower_court": round(self.vertical["lower_court_px"] / total_px, 3),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"seven_eyes": {
|
||||||
|
"eye_width_cm": round(self.eye_width_cm, 2),
|
||||||
|
"face_width_cm": round(self.face_width_cm, 2),
|
||||||
|
"inter_eye_distance_cm": round(self.inter_eye_cm, 2),
|
||||||
|
"ratios": {
|
||||||
|
"eye_width": round(self.eyes["eye_width_px"] / self.eyes["face_width_px"], 3),
|
||||||
|
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / self.eyes["face_width_px"], 3),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"landmarks": {
|
||||||
|
"hair_top": pt_or_none(self.vertical, "hair_top"),
|
||||||
|
"hairline": pt_or_none(self.vertical, "hairline"),
|
||||||
|
"brow_center": pt_or_none(self.vertical, "brow_center"),
|
||||||
|
"nose_bottom": pt_or_none(self.vertical, "nose_bottom"),
|
||||||
|
"chin_tip": pt_or_none(self.vertical, "chin_tip"),
|
||||||
|
},
|
||||||
|
"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 +285,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,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
|
||||||
|
|||||||
@@ -410,7 +410,7 @@
|
|||||||
},
|
},
|
||||||
"60": {
|
"60": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"text": "补充遮罩区域的头发,加一点美颜"
|
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
||||||
},
|
},
|
||||||
"class_type": "JjkText",
|
"class_type": "JjkText",
|
||||||
"_meta": {
|
"_meta": {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""ComfyUI 客户端:用 add_hair.json / add_hair2.json 工作流跑生发图(Flux-2 inpaint)。
|
"""ComfyUI 客户端:用 add_hair.json / add_hair2.json 工作流跑生发图(Flux-2 inpaint)。
|
||||||
|
|
||||||
worker 不跑 Flux,只把「划线图 + 遮罩」的 RGBA 上传到本机 ComfyUI(默认 8188),
|
worker 不跑 Flux,只把「划线图 + 遮罩」的 RGBA 上传到远端 ComfyUI
|
||||||
|
(默认 http://10.60.74.221:8188,可用环境变量 COMFYUI_URL 覆盖),
|
||||||
替换工作流节点 26 的输入图、随机 seed,提交 /prompt,轮询 /history,取回 /view 输出。
|
替换工作流节点 26 的输入图、随机 seed,提交 /prompt,轮询 /history,取回 /view 输出。
|
||||||
ComfyUI 开启了 HTTP Basic Auth(user `admin` + 密码),所有请求都带凭据。
|
ComfyUI 若开启了 HTTP Basic Auth(user `admin` + 密码),所有请求都带凭据。
|
||||||
|
|
||||||
支持多工作流:run() 可通过 workflow_path 指定不同工作流 JSON,自动检测 SaveImage 输出节点。
|
支持多工作流:run() 可通过 workflow_path 指定不同工作流 JSON,自动检测 SaveImage 输出节点。
|
||||||
"""
|
"""
|
||||||
@@ -10,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
|
||||||
@@ -84,11 +86,13 @@ 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) -> 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。
|
||||||
"""
|
"""
|
||||||
path = workflow_path or _WORKFLOW_DEFAULT
|
path = workflow_path or _WORKFLOW_DEFAULT
|
||||||
output_node = _get_output_node(path)
|
output_node = _get_output_node(path)
|
||||||
@@ -103,6 +107,14 @@ 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)
|
||||||
@@ -124,8 +136,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"]
|
||||||
|
|
||||||
@@ -144,7 +159,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,78 @@
|
|||||||
|
"""直接调 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) -> bytes:
|
||||||
|
"""直接调 ComfyUI 重绘 — 替代 local_test /api/generate。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_bytes: 人物图片字节(JPG/PNG)
|
||||||
|
mask_bytes: 遮罩图片字节(支持红/白/alpha 遮罩格式)
|
||||||
|
prompt: 提示词,None 用默认 "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
||||||
|
timeout: ComfyUI 超时秒数
|
||||||
|
front: True 时任务插到 ComfyUI 队列最前(接口2 时延敏感路径用)
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -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
|
||||||
@@ -27,7 +29,8 @@ import logging
|
|||||||
logger = logging.getLogger("hair.worker")
|
logger = logging.getLogger("hair.worker")
|
||||||
|
|
||||||
# 接口2 女性发型 key → change_hair hair_id(chang_*)映射:换发型+Flux-2 整帧重绘用。
|
# 接口2 女性发型 key → change_hair hair_id(chang_*)映射:换发型+Flux-2 整帧重绘用。
|
||||||
# 与接口12 final 的 5 型一一对应。
|
# 与接口12 final 的 5 型一一对应。female 6/7(bigflower/clasicalflower)无对应 LoRA,
|
||||||
|
# 走与男性一致的原生生发(ComfyUI add_hair)管线,故不在本表。
|
||||||
_FEMALE_KEY_TO_CHANG = {
|
_FEMALE_KEY_TO_CHANG = {
|
||||||
"ellipse": "chang_tuoyuan", # 椭圆
|
"ellipse": "chang_tuoyuan", # 椭圆
|
||||||
"flower": "chang_huaban", # 花瓣
|
"flower": "chang_huaban", # 花瓣
|
||||||
@@ -36,10 +39,62 @@ _FEMALE_KEY_TO_CHANG = {
|
|||||||
"wave": "chang_bolang", # 波浪
|
"wave": "chang_bolang", # 波浪
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 发际线贴图显式顺序表:决定 hair_style 序号(1-indexed)。
|
||||||
|
# 不再依赖文件名字母序——字母序会因新增/重命名文件而错位,破坏现有前端/客户端取值。
|
||||||
|
# key 须与 _gender_key 派生结果一致(已去空格):如 "inverse_arc"(源 man_ inverse_arc.png)、
|
||||||
|
# "Softpetal"(源 man_Soft petal.png,大写 S 保留)。表外未知 key 兜底排到末尾。
|
||||||
|
_HAIRSTYLE_ORDER = {
|
||||||
|
"female": ["ellipse", "flower", "heart", "straight", "wave",
|
||||||
|
"bigflower", "clasicalflower"], # 1..7
|
||||||
|
"male": ["ellipse", "inverse_arc", "m", "straight", "heart", "Softpetal"], # 1..6
|
||||||
|
}
|
||||||
|
|
||||||
_REPO = os.path.dirname(os.path.dirname(__file__))
|
_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")
|
||||||
|
|
||||||
|
# 三接口(接口2女重绘 / 接口2男 / 接口3)统一的 ComfyUI 重绘 prompt。
|
||||||
|
# 关键:ComfyUI 单卡显存装不下 Flux(7.7G)+qwen CLIP(3.9G) 同驻,靠缓存 CLIP 文本条件避免重载。
|
||||||
|
# prompt 不同会使缓存失效 → 重载 CLIP 并挤出 Flux(每次 +4s)。三接口用同一字符串即可全程命中。
|
||||||
|
# 与 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):
|
||||||
|
"""直接调 ComfyUI 重绘(替代原 local_test HTTP 服务)。
|
||||||
|
|
||||||
|
传 final 图 + 纯红遮罩 PNG,返回重绘后的 PNG bytes。
|
||||||
|
失败抛异常(调用方负责 try/except 跳过)。
|
||||||
|
"""
|
||||||
|
from .redraw import run_redraw
|
||||||
|
img = cv2.imdecode(np.frombuffer(image_png_bytes, np.uint8), cv2.IMREAD_UNCHANGED)
|
||||||
|
scale = 1.0
|
||||||
|
orig_w = orig_h = 0
|
||||||
|
if img is not None:
|
||||||
|
orig_h, orig_w = img.shape[:2]
|
||||||
|
m = max(orig_h, orig_w)
|
||||||
|
if _REDRAW_MAX_SIDE > 0 and m > _REDRAW_MAX_SIDE:
|
||||||
|
scale = _REDRAW_MAX_SIDE / float(m)
|
||||||
|
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, _REDRAW_MAX_SIDE)
|
||||||
|
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前,避免排在接口3/5 的批量任务后面
|
||||||
|
out = run_redraw(image_png_bytes, mask_png_bytes, timeout=timeout,
|
||||||
|
prompt=_REDRAW_PROMPT, front=True)
|
||||||
|
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 = {
|
||||||
"middle": _TEXTURE_DIR,
|
"middle": _TEXTURE_DIR,
|
||||||
@@ -47,9 +102,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
|
||||||
@@ -80,10 +134,13 @@ def _gender_key(stem: str):
|
|||||||
|
|
||||||
|
|
||||||
def get_texture_map(level: str = "middle") -> dict:
|
def get_texture_map(level: str = "middle") -> dict:
|
||||||
"""扫描指定档位贴图目录建 {gender: [(key, path)]},按 key 排序、按档位缓存。
|
"""扫描指定档位贴图目录建 {gender: [(key, path)]},按显式顺序表排序、按档位缓存。
|
||||||
|
|
||||||
level:middle(默认) / high / low,分别对应 hairline_texture[/_high|/_low]。
|
level:middle(默认) / high / low,分别对应 hairline_texture[/_high|/_low]。
|
||||||
文件名规范化去空格(如 `man_ inverse_arc.png` → key `inverse_arc`)。
|
文件名规范化去空格(如 `man_ inverse_arc.png` → key `inverse_arc`)。
|
||||||
|
|
||||||
|
排序依据 _HAIRSTYLE_ORDER:表内 key 按表序、表外未知 key 兜底排到末尾(再按字母序),
|
||||||
|
保证新增/重命名文件不会打乱现有 hair_style 序号。
|
||||||
"""
|
"""
|
||||||
if level not in _TEXTURE_DIRS:
|
if level not in _TEXTURE_DIRS:
|
||||||
raise ValueError(f"hairline_level 必须是 middle/high/low,收到 {level!r}")
|
raise ValueError(f"hairline_level 必须是 middle/high/low,收到 {level!r}")
|
||||||
@@ -97,7 +154,9 @@ def get_texture_map(level: str = "middle") -> dict:
|
|||||||
if gender:
|
if gender:
|
||||||
mapping[gender].append((key, path))
|
mapping[gender].append((key, path))
|
||||||
for g in mapping:
|
for g in mapping:
|
||||||
mapping[g].sort(key=lambda kp: kp[0])
|
order = _HAIRSTYLE_ORDER.get(g, [])
|
||||||
|
idx = {k: i for i, k in enumerate(order)}
|
||||||
|
mapping[g].sort(key=lambda kp: (idx.get(kp[0], len(idx)), kp[0]))
|
||||||
_texture_maps[level] = mapping
|
_texture_maps[level] = mapping
|
||||||
return mapping
|
return mapping
|
||||||
|
|
||||||
@@ -111,13 +170,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)
|
||||||
@@ -154,7 +219,7 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
|
|||||||
workflow_path: str | None = None):
|
workflow_path: str | None = None):
|
||||||
"""指定发际线类型:发际线透明叠图(白线 RGBA) + 生发图(ComfyUI)。
|
"""指定发际线类型:发际线透明叠图(白线 RGBA) + 生发图(ComfyUI)。
|
||||||
|
|
||||||
hair_styles(1-indexed 列表):指定生成哪几张发际线(按贴图排序)。female: 1..5,male: 1..4。
|
hair_styles(1-indexed 列表):指定生成哪几张发际线(按贴图排序)。female: 1..7,male: 1..6。
|
||||||
为 None 时生成全部(兼容旧调用)。
|
为 None 时生成全部(兼容旧调用)。
|
||||||
use_mask(默认 True):是否启用 inpaint 遮罩,用于测试对比(同接口3)。
|
use_mask(默认 True):是否启用 inpaint 遮罩,用于测试对比(同接口3)。
|
||||||
False 时用**干净原图 + 空遮罩**送 ComfyUI(不烧黑色模板线)。
|
False 时用**干净原图 + 空遮罩**送 ComfyUI(不烧黑色模板线)。
|
||||||
@@ -182,9 +247,14 @@ 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)
|
||||||
|
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)
|
||||||
|
|
||||||
@@ -202,9 +272,14 @@ 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)
|
||||||
|
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)
|
||||||
|
|
||||||
@@ -214,24 +289,36 @@ 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,
|
||||||
"""接口2 女性专用:发际线透明叠图(同 generate_grow_results)+ 换发型+Flux-2 整帧重绘图。
|
redraw_max_side: int | None = None,
|
||||||
|
unet_name: str | None = None,
|
||||||
|
prompt: str | None = None):
|
||||||
|
"""接口2 女性专用:发际线透明叠图(同 generate_grow_results)+ 生发图。
|
||||||
|
|
||||||
与 generate_grow_results 的差异仅在 grown 图的来源:这里对每个选中发型把 female key 映射到
|
生发图来源按发型分两路:
|
||||||
change_hair 的 chang_* hair_id,调 face_analysis.hairline_grow.generate_hairline_redraw
|
- 换发型(chang_* 表内,1..5):female key → change_hair 的 chang_* hair_id,调
|
||||||
(= 接口12 final 管线,参数用 redraw_defaults),取 `redraw_full`(整帧重绘)作为生发图。
|
face_analysis.hairline_grow.generate_hairline_redraw(= 接口12 final 管线,参数用
|
||||||
|
redraw_defaults)拿到 ④ final(接缝融合基底)+ ⑤-② 纯红遮罩 PNG,再**后端直接调
|
||||||
|
ComfyUI**(0716add-hair-api.json 工作流)完成发际线带重绘。
|
||||||
|
- 原生生发(表外,6/7 bigflower/clasicalflower 等):无对应 change_hair LoRA,改走与男性
|
||||||
|
一致的原生生发(ComfyUI add_hair),由 _grow_native_one 完成(黑模板 + inpaint 遮罩)。
|
||||||
|
|
||||||
overlay 仍是发际线曲线透明层(与 generate_grow_results 完全一致)。
|
overlay 仍是发际线曲线透明层(与 generate_grow_results 完全一致)。
|
||||||
|
prompt 仅用于原生生发分支(换发型分支的提示词由 redraw 流程内部固定)。
|
||||||
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]
|
||||||
@@ -240,6 +327,20 @@ 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 最后放大回原尺寸。
|
||||||
|
redraw_img = image_bgr
|
||||||
|
hair_mask_redraw = hair_mask_reuse
|
||||||
|
if _REDRAW_MAX_SIDE > 0 and max(h, w) > _REDRAW_MAX_SIDE:
|
||||||
|
redraw_img, _rs = _downscale_max_side(image_bgr, _REDRAW_MAX_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, _REDRAW_MAX_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)
|
||||||
@@ -247,16 +348,45 @@ def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | N
|
|||||||
grown_png = None
|
grown_png = None
|
||||||
chang_id = _FEMALE_KEY_TO_CHANG.get(key)
|
chang_id = _FEMALE_KEY_TO_CHANG.get(key)
|
||||||
if chang_id is None:
|
if chang_id is None:
|
||||||
logger.warning("接口2 换发型:female key=%s 无对应 chang_id,跳过生发图", key)
|
# 无对应 change_hair LoRA(如 bigflower/clasicalflower)→ 走与男性一致的原生生发
|
||||||
|
logger.info("接口2 女 key=%s 无 chang_id,走原生生发(ComfyUI add_hair)", key)
|
||||||
|
grown_png = _grow_native_one(image_bgr, ctx, white_path,
|
||||||
|
prompt=prompt, unet_name=unet_name)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
data = generate_hairline_redraw(image_bgr, chang_id, **redraw_defaults)
|
import time as _t
|
||||||
b64 = (data.get("steps") or {}).get("redraw_full_base64") or ""
|
_ts0 = _t.perf_counter()
|
||||||
if b64.startswith("data:"):
|
data = generate_hairline_redraw(redraw_img, chang_id, hair_mask=hair_mask_redraw, **redraw_defaults)
|
||||||
b64 = b64.split(",", 1)[1]
|
_ts1 = _t.perf_counter()
|
||||||
grown_png = base64.b64decode(b64) if b64 else None
|
steps = data.get("steps") or {}
|
||||||
if grown_png is None:
|
# ④ final(接缝融合基底)+ ⑤-② 纯红遮罩 PNG
|
||||||
logger.warning("接口2 换发型:type=%s 整帧重绘为空(可能 ComfyUI 未生效)", key)
|
final_b64 = steps.get("final_base64") or ""
|
||||||
|
mask_b64 = steps.get("redraw_band_mask_base64") or ""
|
||||||
|
if not final_b64 or not mask_b64:
|
||||||
|
logger.warning("接口2 换发型:type=%s final/遮罩缺失(final=%d mask=%d)",
|
||||||
|
key, len(final_b64), len(mask_b64))
|
||||||
|
else:
|
||||||
|
# 去掉 data URI 前缀
|
||||||
|
if final_b64.startswith("data:"):
|
||||||
|
final_b64 = final_b64.split(",", 1)[1]
|
||||||
|
if mask_b64.startswith("data:"):
|
||||||
|
mask_b64 = mask_b64.split(",", 1)[1]
|
||||||
|
final_bytes = base64.b64decode(final_b64)
|
||||||
|
mask_bytes = base64.b64decode(mask_b64)
|
||||||
|
# 后端直接调 ComfyUI 重绘,返回重绘后的 PNG
|
||||||
|
_tr0 = _t.perf_counter()
|
||||||
|
grown_png = _call_local_redraw(final_bytes, mask_bytes)
|
||||||
|
_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:
|
||||||
|
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 单张失败不拖垮整请求
|
||||||
@@ -283,22 +413,51 @@ 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)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _grow_native_one(image_bgr: np.ndarray, ctx: dict, white_path: str,
|
||||||
|
prompt: str | None = None, unet_name: str | None = None):
|
||||||
|
"""对单个发际线做原生生发(ComfyUI add_hair),与男性 generate_grow_results 一致。
|
||||||
|
|
||||||
|
接口2 女性新发型(bigflower/clasicalflower 等)无对应 change_hair LoRA,改走此路径:
|
||||||
|
黑模板 → build_inpaint_mask → 限边(_prep_comfy_input)→ comfyui.run(front=True)。
|
||||||
|
失败返回 None,不抛异常。结果按限边前原图尺寸放大回原尺寸(仅展示对齐)。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
h, w = image_bgr.shape[:2]
|
||||||
|
black = load_texture_rgba(_black_texture_path(white_path))
|
||||||
|
marked, mask = build_inpaint_mask(
|
||||||
|
image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black)
|
||||||
|
m_s, msk_s, gsc = _prep_comfy_input(marked, mask)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
compose_comfy_rgba(m_s, msk_s).save(buf, format="PNG", compress_level=1)
|
||||||
|
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前
|
||||||
|
grown_png = comfyui.run(buf.getvalue(), prompt=prompt, front=True, unet_name=unet_name)
|
||||||
|
if gsc < 1.0 and grown_png:
|
||||||
|
grown_png = _upscale_png_to(grown_png, w, h)
|
||||||
|
return grown_png
|
||||||
|
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
|
||||||
|
logger.warning("接口2 女原生生发图失败:%s", e)
|
||||||
|
return 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 取首个选中发型三档各自的发际线中点。
|
||||||
@@ -320,8 +479,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):
|
||||||
@@ -340,9 +500,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)发际线中点
|
||||||
@@ -351,18 +515,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)
|
||||||
@@ -380,8 +599,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: 7.0 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1,174 @@
|
|||||||
|
# 发型补全服务 API 文档
|
||||||
|
|
||||||
|
## 服务概述
|
||||||
|
|
||||||
|
本服务提供基于 ComfyUI 的发型补全(局部重绘)能力。通过传入人物图片和遮罩图片,调用 ComfyUI 工作流(`0716add-hair.json`)生成补全后的图片。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **框架**: Flask
|
||||||
|
- **依赖**: requests, Pillow, numpy
|
||||||
|
- **后端**: ComfyUI (http://127.0.0.1:8188)
|
||||||
|
|
||||||
|
## 服务地址
|
||||||
|
|
||||||
|
- **HTTP**: `http://127.0.0.1:8899`
|
||||||
|
- **前端页面**: `http://127.0.0.1:8899/`
|
||||||
|
- **API接口**: `http://127.0.0.1:8899/api/generate`
|
||||||
|
|
||||||
|
## 启动方式
|
||||||
|
|
||||||
|
### 使用脚本(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动服务
|
||||||
|
cd /home/ubuntu/hair/local_test
|
||||||
|
./start.sh
|
||||||
|
|
||||||
|
# 停止服务
|
||||||
|
./stop.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 直接运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/ubuntu/hair/local_test
|
||||||
|
/home/ubuntu/ComfyUI/venv/bin/python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
### POST /api/generate
|
||||||
|
|
||||||
|
调用 ComfyUI 工作流,传入图片和遮罩,返回生成结果。
|
||||||
|
|
||||||
|
#### 请求参数
|
||||||
|
|
||||||
|
| 参数名 | 类型 | 必填 | 说明 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| image | File | 是 | 人物图片(支持 jpg, png 等常见格式) |
|
||||||
|
| mask | File | 是 | 遮罩图片(支持 jpg, png,遮罩区域可用红色/白色/alpha 通道标识) |
|
||||||
|
| prompt | String | 否 | 提示词,默认值:"填充遮罩区域的头发,皮肤加一点磨皮" |
|
||||||
|
|
||||||
|
#### 遮罩图片格式说明
|
||||||
|
|
||||||
|
服务支持多种遮罩格式,自动提取遮罩区域:
|
||||||
|
|
||||||
|
| 格式类型 | 示例 | 遮罩区域标识 |
|
||||||
|
|----------|------|--------------|
|
||||||
|
| 红色遮罩 | 红色画笔绘制 | R=255 的像素 |
|
||||||
|
| 白色遮罩 | 白色画笔绘制 | R=G=B=255 的像素 |
|
||||||
|
| Alpha 遮罩 | 透明背景 | A=255 的像素 |
|
||||||
|
|
||||||
|
服务会取所有通道的最大值作为遮罩强度,因此以上格式均可混用。
|
||||||
|
|
||||||
|
**注意**: 遮罩区域表示需要重绘的部分,非遮罩区域保持原图不变。
|
||||||
|
|
||||||
|
#### 请求示例(curl)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8899/api/generate \
|
||||||
|
-F "image=@/path/to/person.jpg" \
|
||||||
|
-F "mask=@/path/to/mask.png" \
|
||||||
|
-F "prompt=填充遮罩区域的头发,皮肤加一点磨皮" \
|
||||||
|
--output result.png
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 请求示例(Python)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
url = "http://127.0.0.1:8899/api/generate"
|
||||||
|
files = {
|
||||||
|
"image": open("person.jpg", "rb"),
|
||||||
|
"mask": open("mask.png", "rb"),
|
||||||
|
}
|
||||||
|
data = {
|
||||||
|
"prompt": "填充遮罩区域的头发,皮肤加一点磨皮"
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = requests.post(url, files=files, data=data, timeout=600)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
with open("result.png", "wb") as f:
|
||||||
|
f.write(resp.content)
|
||||||
|
else:
|
||||||
|
print(f"Error: {resp.json()}")
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 响应
|
||||||
|
|
||||||
|
**成功 (HTTP 200)**:
|
||||||
|
|
||||||
|
返回 PNG 图片二进制数据,Content-Type: `image/png`。
|
||||||
|
|
||||||
|
**失败 (HTTP 4xx/5xx)**:
|
||||||
|
|
||||||
|
返回 JSON 格式错误信息:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "错误描述"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 错误码
|
||||||
|
|
||||||
|
| 状态码 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| 500 | 内部错误(文件处理失败、ComfyUI 返回错误等) |
|
||||||
|
| 503 | 无法连接到 ComfyUI(服务未启动或端口错误) |
|
||||||
|
| 500 | 超时(工作流执行超过 5 分钟) |
|
||||||
|
|
||||||
|
## 工作流说明
|
||||||
|
|
||||||
|
服务使用的工作流 `0716add-hair.json` 包含以下处理步骤:
|
||||||
|
|
||||||
|
1. **加载模型**: Flux 2 Klein 9B (FP8) + Qwen 3.8B CLIP
|
||||||
|
2. **图片上传**: 将原图与遮罩合成为 RGBA 格式上传至 ComfyUI
|
||||||
|
3. **遮罩处理**: 填充孔洞 → 转换为图像 → 缩放 → 转换回遮罩
|
||||||
|
4. **图像缩放**: 按比例缩放至合适尺寸(最大边长 1024,8 的倍数)
|
||||||
|
5. **VAE 编码**: 将图像编码为 latent
|
||||||
|
6. **采样生成**: 使用 Flux 模型 + ReferenceLatent 进行局部重绘
|
||||||
|
7. **VAE 解码**: 将 latent 解码为图像
|
||||||
|
8. **颜色匹配**: 使用 ColorMatch 保持颜色一致
|
||||||
|
9. **保存结果**: 返回生成的图片
|
||||||
|
|
||||||
|
## 前置依赖
|
||||||
|
|
||||||
|
启动服务前需确保:
|
||||||
|
|
||||||
|
1. **ComfyUI 已启动**: `http://127.0.0.1:8188` 可访问
|
||||||
|
2. **模型文件存在**:
|
||||||
|
- `models/unet/flux2.0/flux-2-klein-9b-fp8.safetensors`
|
||||||
|
- `models/vae/flux2-vae.safetensors`
|
||||||
|
- `models/clip/qwen_3_8b_fp8mixed.safetensors`
|
||||||
|
3. **虚拟环境已激活**: 使用 `/home/ubuntu/ComfyUI/venv/bin/python`
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
/home/ubuntu/hair/local_test/
|
||||||
|
├── app.py # Flask 后端服务
|
||||||
|
├── index.html # 前端测试页面
|
||||||
|
├── test_api.py # API 测试脚本
|
||||||
|
├── README.md # 本文档
|
||||||
|
├── output/ # 测试结果输出目录
|
||||||
|
├── 用来重绘.jpg # 测试人物图片
|
||||||
|
└── 用来重绘.png # 测试遮罩图片
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用流程
|
||||||
|
|
||||||
|
1. 启动 ComfyUI(`python main.py --listen`)
|
||||||
|
2. 启动本服务(`python app.py`)
|
||||||
|
3. 调用 API 或访问前端页面上传图片和遮罩
|
||||||
|
4. 等待生成完成(通常 30-60 秒)
|
||||||
|
5. 获取返回的 PNG 图片
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 请求超时时间为 5 分钟,生成复杂图片可能需要较长时间
|
||||||
|
- 遮罩图片尺寸需与人物图片一致,服务会自动缩放对齐
|
||||||
|
- 建议使用红色或白色绘制遮罩,确保遮罩强度足够
|
||||||
|
- 服务会自动对遮罩边缘进行高斯模糊(radius=4),避免硬边
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Hair inpainting service - calls ComfyUI workflow with image + mask."""
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import random
|
||||||
|
import logging
|
||||||
|
import traceback
|
||||||
|
import requests
|
||||||
|
from flask import Flask, request, jsonify, send_file
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image, ImageFilter
|
||||||
|
|
||||||
|
# 让 PIL 支持 iPhone 的 HEIC/HEIF 照片(浏览器 accept="image/*" 会允许选中它们)。
|
||||||
|
try:
|
||||||
|
from pillow_heif import register_heif_opener
|
||||||
|
register_heif_opener()
|
||||||
|
_HEIF_OK = True
|
||||||
|
except Exception:
|
||||||
|
_HEIF_OK = False
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
)
|
||||||
|
log = logging.getLogger("hair")
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
COMFYUI_URL = "http://127.0.0.1:8188"
|
||||||
|
|
||||||
|
# 允许浏览器跨域直连本服务(如 hair 项目的测试页)。
|
||||||
|
# 不引入 flask-cors 依赖,直接在响应头 + OPTIONS 预检里处理。
|
||||||
|
_CORS_HEADERS = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type",
|
||||||
|
"Access-Control-Expose-Headers": "X-Generate-Time",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def _add_cors(resp):
|
||||||
|
for k, v in _CORS_HEADERS.items():
|
||||||
|
resp.headers[k] = v
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/generate", methods=["OPTIONS"])
|
||||||
|
def _generate_preflight():
|
||||||
|
"""CORS 预检:浏览器 POST 前会先发 OPTIONS。"""
|
||||||
|
return ("", 204)
|
||||||
|
|
||||||
|
|
||||||
|
def build_workflow(image_filename, prompt_text, seed=None):
|
||||||
|
"""Build ComfyUI API workflow from the 0716add-hair.json structure."""
|
||||||
|
if seed is None:
|
||||||
|
seed = random.randint(0, 2**53)
|
||||||
|
|
||||||
|
return {
|
||||||
|
# Loaders
|
||||||
|
"16": {"class_type": "UNETLoader", "inputs": {
|
||||||
|
"unet_name": "flux2.0/flux-2-klein-9b-fp8.safetensors",
|
||||||
|
"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": "default"}},
|
||||||
|
|
||||||
|
# Input image (with mask in alpha channel)
|
||||||
|
"26": {"class_type": "LoadImage", "inputs": {
|
||||||
|
"image": image_filename}},
|
||||||
|
|
||||||
|
# Prompt
|
||||||
|
"60": {"class_type": "JjkText", "inputs": {
|
||||||
|
"text": prompt_text}},
|
||||||
|
"22": {"class_type": "CLIPTextEncode", "inputs": {
|
||||||
|
"clip": ["61", 0],
|
||||||
|
"text": ["60", 0]}},
|
||||||
|
|
||||||
|
# Image size
|
||||||
|
"31": {"class_type": "easy imageSize", "inputs": {
|
||||||
|
"image": ["26", 0]}},
|
||||||
|
|
||||||
|
# Mask processing: fill holes -> convert to image -> scale -> back to mask
|
||||||
|
"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"}},
|
||||||
|
|
||||||
|
# Scale image+mask by aspect ratio
|
||||||
|
"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"}},
|
||||||
|
|
||||||
|
# Preview (pass_through=true, just passes the image through)
|
||||||
|
"44": {"class_type": "ImageAndMaskPreview", "inputs": {
|
||||||
|
"image": ["32", 0],
|
||||||
|
"mask": ["32", 1],
|
||||||
|
"mask_opacity": 1,
|
||||||
|
"mask_color": "FFFF00",
|
||||||
|
"pass_through": True}},
|
||||||
|
|
||||||
|
# Get size of scaled image
|
||||||
|
"14": {"class_type": "GetImageSize+", "inputs": {
|
||||||
|
"image": ["44", 0]}},
|
||||||
|
|
||||||
|
# VAE encode the image
|
||||||
|
"13": {"class_type": "VAEEncode", "inputs": {
|
||||||
|
"pixels": ["44", 0],
|
||||||
|
"vae": ["3", 0]}},
|
||||||
|
|
||||||
|
# Flux model setup
|
||||||
|
"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]}},
|
||||||
|
|
||||||
|
# Empty latent for sampling
|
||||||
|
"7": {"class_type": "EmptySD3LatentImage", "inputs": {
|
||||||
|
"width": ["14", 0],
|
||||||
|
"height": ["14", 1],
|
||||||
|
"batch_size": 1}},
|
||||||
|
|
||||||
|
# Scheduler & guider
|
||||||
|
"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]}},
|
||||||
|
|
||||||
|
# Noise & sampler
|
||||||
|
"6": {"class_type": "RandomNoise", "inputs": {
|
||||||
|
"noise_seed": seed}},
|
||||||
|
"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]}},
|
||||||
|
|
||||||
|
# VAE decode
|
||||||
|
"10": {"class_type": "VAEDecode", "inputs": {
|
||||||
|
"samples": ["9", 0],
|
||||||
|
"vae": ["3", 0]}},
|
||||||
|
|
||||||
|
# Color match with original image
|
||||||
|
"62": {"class_type": "ColorMatch", "inputs": {
|
||||||
|
"image_ref": ["26", 0],
|
||||||
|
"image_target": ["10", 0],
|
||||||
|
"method": "mkl",
|
||||||
|
"strength": 1,
|
||||||
|
"multithread": True}},
|
||||||
|
|
||||||
|
# Save result
|
||||||
|
"17": {"class_type": "SaveImage", "inputs": {
|
||||||
|
"images": ["62", 0],
|
||||||
|
"filename_prefix": "hair_inpaint"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
return send_file("index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/generate", methods=["POST"])
|
||||||
|
def generate():
|
||||||
|
t_start = time.time()
|
||||||
|
try:
|
||||||
|
if "image" not in request.files or "mask" not in request.files:
|
||||||
|
msg = f"缺少上传文件: files={list(request.files.keys())}"
|
||||||
|
log.warning(msg)
|
||||||
|
return jsonify({"error": msg}), 400
|
||||||
|
image_file = request.files["image"]
|
||||||
|
mask_file = request.files["mask"]
|
||||||
|
prompt_text = request.form.get("prompt", "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
|
||||||
|
log.info(
|
||||||
|
"收到请求: image=%s mask=%s prompt=%r",
|
||||||
|
image_file.filename, mask_file.filename, prompt_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load original image as RGB
|
||||||
|
try:
|
||||||
|
image = Image.open(image_file).convert("RGB")
|
||||||
|
except Exception as e:
|
||||||
|
log.error("无法解码人物图片 %s: %s", image_file.filename, e)
|
||||||
|
hint = "" if _HEIF_OK else "(当前不支持 HEIC)"
|
||||||
|
return jsonify({
|
||||||
|
"error": f"无法识别人物图片格式{hint},请改用 JPG/PNG: {e}"
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Load mask and extract mask data from ALL channels (R, G, B, A)
|
||||||
|
# This handles different mask formats:
|
||||||
|
# - Red mask (R=255 where drawn): user-provided PNG
|
||||||
|
# - White mask (R=G=B=255 where drawn): frontend canvas
|
||||||
|
# - Alpha mask (A=255 where drawn): transparent brush
|
||||||
|
try:
|
||||||
|
mask_img = Image.open(mask_file).convert("RGBA")
|
||||||
|
except Exception as e:
|
||||||
|
log.error("无法解码遮罩图片 %s: %s", mask_file.filename, e)
|
||||||
|
return jsonify({
|
||||||
|
"error": f"无法识别遮罩图片格式,请改用 JPG/PNG: {e}"
|
||||||
|
}), 400
|
||||||
|
mask_arr = np.array(mask_img)
|
||||||
|
# Use max of all channels: 255 where any color/alpha is drawn, 0 where empty
|
||||||
|
mask_data = np.max(mask_arr, axis=2) # (H, W) uint8
|
||||||
|
|
||||||
|
# Ensure mask matches image size
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Apply slight blur for soft edges (similar to ComfyUI's brush)
|
||||||
|
mask_data_img = mask_data_img.filter(ImageFilter.GaussianBlur(radius=4))
|
||||||
|
|
||||||
|
# ComfyUI LoadImage: mask = 1.0 - (alpha/255)
|
||||||
|
# So alpha=0 -> mask=1.0 (inpaint), alpha=255 -> mask=0.0 (keep)
|
||||||
|
# We want: drawn area (mask_data=255) -> inpaint -> alpha=0
|
||||||
|
# undrawn area (mask_data=0) -> keep -> alpha=255
|
||||||
|
comfyui_alpha = Image.eval(mask_data_img, lambda x: 255 - x)
|
||||||
|
|
||||||
|
# Combine into RGBA (split RGB into separate channels first)
|
||||||
|
r, g, b = image.split()
|
||||||
|
rgba = Image.merge("RGBA", (r, g, b, comfyui_alpha))
|
||||||
|
|
||||||
|
# Upload to ComfyUI
|
||||||
|
img_bytes = io.BytesIO()
|
||||||
|
rgba.save(img_bytes, format="PNG")
|
||||||
|
img_bytes.seek(0)
|
||||||
|
|
||||||
|
upload_resp = requests.post(
|
||||||
|
f"{COMFYUI_URL}/upload/image",
|
||||||
|
files={"image": ("hair_input.png", img_bytes, "image/png")},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
upload_data = upload_resp.json()
|
||||||
|
if "name" not in upload_data:
|
||||||
|
log.error("ComfyUI 上传图片失败: %s", upload_data)
|
||||||
|
return jsonify({"error": f"Upload failed: {upload_data}"}), 500
|
||||||
|
image_filename = upload_data["name"]
|
||||||
|
|
||||||
|
# Build and queue workflow
|
||||||
|
workflow = build_workflow(image_filename, prompt_text)
|
||||||
|
prompt_resp = requests.post(
|
||||||
|
f"{COMFYUI_URL}/prompt",
|
||||||
|
json={"prompt": workflow},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
prompt_data = prompt_resp.json()
|
||||||
|
if "error" in prompt_data:
|
||||||
|
log.error(
|
||||||
|
"ComfyUI /prompt 校验失败: error=%s node_errors=%s",
|
||||||
|
prompt_data.get("error"), prompt_data.get("node_errors"),
|
||||||
|
)
|
||||||
|
return jsonify({"error": json.dumps(prompt_data["error"], ensure_ascii=False)}), 500
|
||||||
|
prompt_id = prompt_data["prompt_id"]
|
||||||
|
|
||||||
|
# Poll for completion (5 min timeout, 0.1s interval)
|
||||||
|
for _ in range(3000):
|
||||||
|
time.sleep(0.1)
|
||||||
|
history_resp = requests.get(
|
||||||
|
f"{COMFYUI_URL}/history/{prompt_id}", timeout=10
|
||||||
|
)
|
||||||
|
history_data = history_resp.json()
|
||||||
|
if prompt_id in history_data:
|
||||||
|
status = history_data[prompt_id].get("status", {})
|
||||||
|
if status.get("status_str") == "error":
|
||||||
|
log.error(
|
||||||
|
"ComfyUI 工作流执行失败: %s",
|
||||||
|
json.dumps(status, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
return jsonify({
|
||||||
|
"error": "Workflow execution failed",
|
||||||
|
"detail": status.get("messages", status),
|
||||||
|
}), 500
|
||||||
|
outputs = history_data[prompt_id].get("outputs", {})
|
||||||
|
if "17" in outputs: # SaveImage node
|
||||||
|
image_info = outputs["17"]["images"][0]
|
||||||
|
filename = image_info["filename"]
|
||||||
|
subfolder = image_info.get("subfolder", "")
|
||||||
|
img_type = image_info.get("type", "output")
|
||||||
|
view_resp = requests.get(
|
||||||
|
f"{COMFYUI_URL}/view",
|
||||||
|
params={"filename": filename, "subfolder": subfolder, "type": img_type},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
elapsed = time.time() - t_start
|
||||||
|
log.info("重绘完成,服务端耗时 %.2fs", elapsed)
|
||||||
|
resp = send_file(
|
||||||
|
io.BytesIO(view_resp.content), mimetype="image/png"
|
||||||
|
)
|
||||||
|
resp.headers["X-Generate-Time"] = f"{elapsed:.2f}"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
return jsonify({"error": "Timeout: workflow did not complete in 5 minutes"}), 500
|
||||||
|
|
||||||
|
except requests.ConnectionError:
|
||||||
|
log.error("无法连接 ComfyUI @ %s", COMFYUI_URL)
|
||||||
|
return jsonify({"error": "Cannot connect to ComfyUI at " + COMFYUI_URL + ". Is it running?"}), 503
|
||||||
|
except Exception as e:
|
||||||
|
log.error("生成失败,未捕获异常:\n%s", traceback.format_exc())
|
||||||
|
return jsonify({"error": f"{type(e).__name__}: {e}"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=8899, debug=False)
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Benchmark ComfyUI hair-inpaint workflow across model / dtype / steps."""
|
||||||
|
import io, time, sys
|
||||||
|
import requests
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image, ImageFilter
|
||||||
|
import app as A
|
||||||
|
|
||||||
|
COMFY = "http://127.0.0.1:8188"
|
||||||
|
|
||||||
|
|
||||||
|
def prep_and_upload():
|
||||||
|
image = Image.open("用来重绘.jpg").convert("RGB")
|
||||||
|
mask_img = Image.open("用来重绘.png").convert("RGBA")
|
||||||
|
mask_data = np.max(np.array(mask_img), axis=2)
|
||||||
|
m = Image.fromarray(mask_data, mode="L")
|
||||||
|
if m.size != image.size:
|
||||||
|
m = m.resize(image.size, Image.LANCZOS)
|
||||||
|
m = m.filter(ImageFilter.GaussianBlur(radius=4))
|
||||||
|
alpha = Image.eval(m, lambda x: 255 - x)
|
||||||
|
r, g, b = image.split()
|
||||||
|
rgba = Image.merge("RGBA", (r, g, b, alpha))
|
||||||
|
buf = io.BytesIO(); rgba.save(buf, format="PNG"); buf.seek(0)
|
||||||
|
up = requests.post(f"{COMFY}/upload/image",
|
||||||
|
files={"image": ("hair_input.png", buf, "image/png")}).json()
|
||||||
|
return up["name"]
|
||||||
|
|
||||||
|
|
||||||
|
def run_once(fname, model, dtype, steps):
|
||||||
|
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
|
||||||
|
wf["16"]["inputs"]["unet_name"] = model
|
||||||
|
wf["16"]["inputs"]["weight_dtype"] = dtype
|
||||||
|
wf["1"]["inputs"]["steps"] = steps
|
||||||
|
r = requests.post(f"{COMFY}/prompt", json={"prompt": wf}).json()
|
||||||
|
if "prompt_id" not in r:
|
||||||
|
raise RuntimeError(f"submit failed: {str(r)[:300]}")
|
||||||
|
pid = r["prompt_id"]
|
||||||
|
deadline = time.time() + 180
|
||||||
|
while time.time() < deadline:
|
||||||
|
time.sleep(0.1)
|
||||||
|
h = requests.get(f"{COMFY}/history/{pid}").json()
|
||||||
|
if pid not in h:
|
||||||
|
continue
|
||||||
|
st = h[pid].get("status", {})
|
||||||
|
if st.get("status_str") == "error":
|
||||||
|
for m in st.get("messages", []):
|
||||||
|
if m[0] == "execution_error":
|
||||||
|
raise RuntimeError(str(m[1])[:300])
|
||||||
|
raise RuntimeError("execution error")
|
||||||
|
if "17" in h[pid].get("outputs", {}):
|
||||||
|
ts = {mm[0]: mm[1].get("timestamp") for mm in st["messages"]}
|
||||||
|
return (ts["execution_success"] - ts["execution_start"]) / 1000.0
|
||||||
|
raise TimeoutError("run exceeded 180s")
|
||||||
|
|
||||||
|
|
||||||
|
CONFIGS = [
|
||||||
|
("flux2.0/flux-2-klein-9b-fp8.safetensors", "fp8_e4m3fn", 6, "9B fp8 (当前)"),
|
||||||
|
("flux2.0/flux-2-klein-9b-fp8.safetensors", "fp8_e4m3fn_fast", 6, "9B fp8-fast"),
|
||||||
|
("flux2.0/flux-2-klein-9b-fp8.safetensors", "fp8_e4m3fn_fast", 4, "9B fp8-fast s4"),
|
||||||
|
("flux-2-klein-4b-fp8.safetensors", "fp8_e4m3fn", 6, "4B fp8"),
|
||||||
|
("flux-2-klein-4b-fp8.safetensors", "fp8_e4m3fn_fast", 6, "4B fp8-fast"),
|
||||||
|
("flux-2-klein-4b-fp8.safetensors", "fp8_e4m3fn_fast", 4, "4B fp8-fast s4"),
|
||||||
|
]
|
||||||
|
|
||||||
|
fname = prep_and_upload()
|
||||||
|
print("input uploaded:", fname)
|
||||||
|
print(f"{'配置':<22}{'warmup':>10}{'run1':>10}{'run2':>10}{'best':>10}")
|
||||||
|
for model, dtype, steps, label in CONFIGS:
|
||||||
|
times = []
|
||||||
|
for i in range(3): # 1 warmup + 2 measured
|
||||||
|
try:
|
||||||
|
t = run_once(fname, model, dtype, steps)
|
||||||
|
except Exception as e:
|
||||||
|
t = float('nan'); print("ERR", label, e)
|
||||||
|
times.append(t)
|
||||||
|
best = min(times[1:])
|
||||||
|
print(f"{label:<22}{times[0]:>9.2f}s{times[1]:>9.2f}s{times[2]:>9.2f}s{best:>9.2f}s")
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Steps sweep + resolution test on the working 9B fp8-fast config."""
|
||||||
|
import io, time
|
||||||
|
import requests
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image, ImageFilter
|
||||||
|
import app as A
|
||||||
|
|
||||||
|
COMFY = "http://127.0.0.1:8188"
|
||||||
|
MODEL = "flux2.0/flux-2-klein-9b-fp8.safetensors"
|
||||||
|
DTYPE = "fp8_e4m3fn_fast"
|
||||||
|
|
||||||
|
|
||||||
|
def upload(scale=1.0):
|
||||||
|
image = Image.open("用来重绘.jpg").convert("RGB")
|
||||||
|
mask_img = Image.open("用来重绘.png").convert("RGBA")
|
||||||
|
if scale != 1.0:
|
||||||
|
w, h = image.size
|
||||||
|
w, h = int(w * scale) // 8 * 8, int(h * scale) // 8 * 8
|
||||||
|
image = image.resize((w, h), Image.LANCZOS)
|
||||||
|
mask_data = np.max(np.array(mask_img), axis=2)
|
||||||
|
m = Image.fromarray(mask_data, mode="L")
|
||||||
|
if m.size != image.size:
|
||||||
|
m = m.resize(image.size, Image.LANCZOS)
|
||||||
|
m = m.filter(ImageFilter.GaussianBlur(radius=4))
|
||||||
|
alpha = Image.eval(m, lambda x: 255 - x)
|
||||||
|
r, g, b = image.split()
|
||||||
|
rgba = Image.merge("RGBA", (r, g, b, alpha))
|
||||||
|
buf = io.BytesIO(); rgba.save(buf, format="PNG"); buf.seek(0)
|
||||||
|
up = requests.post(f"{COMFY}/upload/image",
|
||||||
|
files={"image": ("hair_input.png", buf, "image/png")}).json()
|
||||||
|
return up["name"], image.size
|
||||||
|
|
||||||
|
|
||||||
|
def run(fname, steps):
|
||||||
|
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
|
||||||
|
wf["16"]["inputs"]["unet_name"] = MODEL
|
||||||
|
wf["16"]["inputs"]["weight_dtype"] = DTYPE
|
||||||
|
wf["1"]["inputs"]["steps"] = steps
|
||||||
|
pid = requests.post(f"{COMFY}/prompt", json={"prompt": wf}).json()["prompt_id"]
|
||||||
|
dl = time.time() + 120
|
||||||
|
while time.time() < dl:
|
||||||
|
time.sleep(0.1)
|
||||||
|
h = requests.get(f"{COMFY}/history/{pid}").json()
|
||||||
|
if pid in h and "17" in h[pid].get("outputs", {}):
|
||||||
|
ts = {m[0]: m[1].get("timestamp") for m in h[pid]["status"]["messages"]}
|
||||||
|
return (ts["execution_success"] - ts["execution_start"]) / 1000.0
|
||||||
|
return float("nan")
|
||||||
|
|
||||||
|
|
||||||
|
print("=== 步数扫描 (9B fp8-fast, 原分辨率 1024x775) ===", flush=True)
|
||||||
|
fname, sz = upload(1.0)
|
||||||
|
run(fname, 6) # warmup
|
||||||
|
res = {}
|
||||||
|
for s in [2, 3, 4, 6, 8]:
|
||||||
|
t = min(run(fname, s), run(fname, s))
|
||||||
|
res[s] = t
|
||||||
|
print(f" steps={s}: {t:.2f}s", flush=True)
|
||||||
|
# derive per-step cost & fixed overhead via two points
|
||||||
|
per = (res[8] - res[2]) / 6
|
||||||
|
fixed = res[2] - per * 2
|
||||||
|
print(f" -> 每步 ~{per:.3f}s, 固定开销(VAE/编码/colormatch/加载) ~{fixed:.2f}s", flush=True)
|
||||||
|
|
||||||
|
print("\n=== 分辨率影响 (steps=4) ===", flush=True)
|
||||||
|
for scale in [1.0, 0.75, 0.6]:
|
||||||
|
fn, s2 = upload(scale)
|
||||||
|
run(fn, 4) # warmup
|
||||||
|
t = min(run(fn, 4), run(fn, 4))
|
||||||
|
print(f" {s2[0]}x{s2[1]} ({s2[0]*s2[1]/1e6:.2f}MP): {t:.2f}s", flush=True)
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate result images at different step counts for quality comparison."""
|
||||||
|
import io, time
|
||||||
|
import requests
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image, ImageFilter
|
||||||
|
import app as A
|
||||||
|
|
||||||
|
COMFY = "http://127.0.0.1:8188"
|
||||||
|
MODEL = "flux2.0/flux-2-klein-9b-fp8.safetensors"
|
||||||
|
DTYPE = "fp8_e4m3fn_fast"
|
||||||
|
OUT = "output"
|
||||||
|
|
||||||
|
image = Image.open("用来重绘.jpg").convert("RGB")
|
||||||
|
mask_img = Image.open("用来重绘.png").convert("RGBA")
|
||||||
|
mask_data = np.max(np.array(mask_img), axis=2)
|
||||||
|
m = Image.fromarray(mask_data, mode="L")
|
||||||
|
if m.size != image.size:
|
||||||
|
m = m.resize(image.size, Image.LANCZOS)
|
||||||
|
m = m.filter(ImageFilter.GaussianBlur(radius=4))
|
||||||
|
alpha = Image.eval(m, lambda x: 255 - x)
|
||||||
|
r, g, b = image.split()
|
||||||
|
rgba = Image.merge("RGBA", (r, g, b, alpha))
|
||||||
|
buf = io.BytesIO(); rgba.save(buf, format="PNG"); buf.seek(0)
|
||||||
|
fname = requests.post(f"{COMFY}/upload/image",
|
||||||
|
files={"image": ("hair_input.png", buf, "image/png")}).json()["name"]
|
||||||
|
|
||||||
|
# fixed seed for fair comparison
|
||||||
|
SEED = 123456789
|
||||||
|
imgs = []
|
||||||
|
labels = []
|
||||||
|
for steps in [2, 3, 4, 6]:
|
||||||
|
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", seed=SEED)
|
||||||
|
wf["16"]["inputs"]["unet_name"] = MODEL
|
||||||
|
wf["16"]["inputs"]["weight_dtype"] = DTYPE
|
||||||
|
wf["1"]["inputs"]["steps"] = steps
|
||||||
|
pid = requests.post(f"{COMFY}/prompt", json={"prompt": wf}).json()["prompt_id"]
|
||||||
|
t0 = time.time()
|
||||||
|
while True:
|
||||||
|
time.sleep(0.1)
|
||||||
|
h = requests.get(f"{COMFY}/history/{pid}").json()
|
||||||
|
if pid in h and "17" in h[pid].get("outputs", {}):
|
||||||
|
ts = {mm[0]: mm[1].get("timestamp") for mm in h[pid]["status"]["messages"]}
|
||||||
|
dur = (ts["execution_success"] - ts["execution_start"]) / 1000.0
|
||||||
|
info = h[pid]["outputs"]["17"]["images"][0]
|
||||||
|
data = requests.get(f"{COMFY}/view", params={
|
||||||
|
"filename": info["filename"], "subfolder": info.get("subfolder", ""),
|
||||||
|
"type": info.get("type", "output")}).content
|
||||||
|
im = Image.open(io.BytesIO(data)).convert("RGB")
|
||||||
|
imgs.append(im)
|
||||||
|
labels.append(f"steps={steps} {dur:.2f}s")
|
||||||
|
print(f"steps={steps}: {dur:.2f}s", flush=True)
|
||||||
|
break
|
||||||
|
|
||||||
|
# build side-by-side contact sheet
|
||||||
|
from PIL import ImageDraw
|
||||||
|
h0 = imgs[0].height
|
||||||
|
w0 = imgs[0].width
|
||||||
|
pad = 10
|
||||||
|
bar = 28
|
||||||
|
sheet = Image.new("RGB", (w0 * len(imgs) + pad * (len(imgs) + 1),
|
||||||
|
h0 + bar + pad * 2), (26, 26, 46))
|
||||||
|
d = ImageDraw.Draw(sheet)
|
||||||
|
for i, (im, lb) in enumerate(zip(imgs, labels)):
|
||||||
|
x = pad + i * (w0 + pad)
|
||||||
|
sheet.paste(im, (x, bar + pad))
|
||||||
|
d.text((x + 4, 6), lb, fill=(233, 69, 96))
|
||||||
|
sheet.save(f"{OUT}/compare_steps.png")
|
||||||
|
print("saved:", f"{OUT}/compare_steps.png", flush=True)
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>发型补全工具</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, sans-serif; background: #1a1a2e; color: #eee; min-height: 100vh; padding: 20px; }
|
||||||
|
h1 { text-align: center; margin-bottom: 20px; color: #e94560; font-size: 28px; }
|
||||||
|
.container { max-width: 1400px; margin: 0 auto; display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
|
||||||
|
.panel { background: #16213e; border-radius: 12px; padding: 20px; }
|
||||||
|
.panel h2 { margin-bottom: 16px; font-size: 18px; color: #e94560; }
|
||||||
|
.controls { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 16px; align-items: center; }
|
||||||
|
.controls label { font-size: 14px; color: #aaa; }
|
||||||
|
input[type="file"] { color: #ddd; }
|
||||||
|
input[type="text"] { flex: 1; min-width: 200px; padding: 8px 12px; border-radius: 6px; border: 1px solid #444; background: #0f3460; color: #eee; font-size: 14px; }
|
||||||
|
button { padding: 10px 24px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600; transition: all 0.2s; }
|
||||||
|
.btn-upload { background: #0f3460; color: #eee; border: 1px solid #e94560; }
|
||||||
|
.btn-upload:hover { background: #1a4a7a; }
|
||||||
|
.btn-generate { background: #e94560; color: #fff; font-size: 16px; padding: 12px 36px; }
|
||||||
|
.btn-generate:hover { background: #c73650; }
|
||||||
|
.btn-generate:disabled { background: #555; cursor: not-allowed; }
|
||||||
|
.image-wrapper { display: flex; gap: 12px; flex-wrap: wrap; border: 2px dashed #444; border-radius: 8px; padding: 12px; min-height: 300px; background: #0f3460; }
|
||||||
|
.image-item { flex: 1; min-width: 200px; }
|
||||||
|
.image-item img { max-width: 100%; border-radius: 6px; }
|
||||||
|
.image-item h4 { font-size: 12px; color: #aaa; margin-bottom: 6px; }
|
||||||
|
.placeholder { color: #666; font-size: 16px; text-align: center; padding: 60px 20px; width: 100%; }
|
||||||
|
.result-area { display: flex; gap: 16px; flex-wrap: wrap; }
|
||||||
|
.result-area img { max-width: 100%; border-radius: 8px; }
|
||||||
|
.result-item { flex: 1; min-width: 250px; }
|
||||||
|
.result-item h3 { font-size: 14px; color: #aaa; margin-bottom: 8px; text-align: center; }
|
||||||
|
.loading { text-align: center; padding: 40px; color: #e94560; font-size: 18px; }
|
||||||
|
.loading .spinner { display: inline-block; width: 40px; height: 40px; border: 4px solid #333; border-top-color: #e94560; border-radius: 50%; animation: spin 1s linear infinite; margin-bottom: 12px; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.error { color: #ff6b6b; padding: 16px; background: #2a1a1a; border-radius: 8px; margin-top: 12px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>💇 发型补全工具</h1>
|
||||||
|
<div class="container">
|
||||||
|
<!-- Left: Input -->
|
||||||
|
<div class="panel">
|
||||||
|
<h2>1. 上传图片 & 遮罩</h2>
|
||||||
|
<div class="controls">
|
||||||
|
<label>人物图片:</label>
|
||||||
|
<input type="file" id="imageInput" accept="image/*" class="btn-upload">
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<label>遮罩图片:</label>
|
||||||
|
<input type="file" id="maskInput" accept="image/*" class="btn-upload">
|
||||||
|
</div>
|
||||||
|
<div class="image-wrapper" id="imageWrapper">
|
||||||
|
<div class="placeholder" id="placeholder">请上传人物图片和遮罩图片</div>
|
||||||
|
</div>
|
||||||
|
<div class="controls" style="margin-top:16px">
|
||||||
|
<label>提示词:</label>
|
||||||
|
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜">
|
||||||
|
</div>
|
||||||
|
<div style="text-align:center; margin-top:16px">
|
||||||
|
<button class="btn-generate" id="generateBtn" disabled>🚀 生成</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Result -->
|
||||||
|
<div class="panel">
|
||||||
|
<h2>2. 对比结果</h2>
|
||||||
|
<div id="resultArea">
|
||||||
|
<div class="placeholder">生成结果将显示在这里</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const imageInput = document.getElementById('imageInput');
|
||||||
|
const maskInput = document.getElementById('maskInput');
|
||||||
|
const imageWrapper = document.getElementById('imageWrapper');
|
||||||
|
const placeholder = document.getElementById('placeholder');
|
||||||
|
const promptInput = document.getElementById('promptInput');
|
||||||
|
const generateBtn = document.getElementById('generateBtn');
|
||||||
|
const resultArea = document.getElementById('resultArea');
|
||||||
|
|
||||||
|
let originalImage = null;
|
||||||
|
let maskImage = null;
|
||||||
|
|
||||||
|
imageInput.addEventListener('change', (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (ev) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
originalImage = { img: img, file: file };
|
||||||
|
updatePreview();
|
||||||
|
checkReady();
|
||||||
|
};
|
||||||
|
img.src = ev.target.result;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
maskInput.addEventListener('change', (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (ev) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
maskImage = { img: img, file: file };
|
||||||
|
updatePreview();
|
||||||
|
checkReady();
|
||||||
|
};
|
||||||
|
img.src = ev.target.result;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
function updatePreview() {
|
||||||
|
placeholder.style.display = 'none';
|
||||||
|
let html = '';
|
||||||
|
if (originalImage) {
|
||||||
|
html += `<div class="image-item"><h4>人物图片</h4><img src="${originalImage.img.src}" alt="原图"></div>`;
|
||||||
|
}
|
||||||
|
if (maskImage) {
|
||||||
|
html += `<div class="image-item"><h4>遮罩图片</h4><img src="${maskImage.img.src}" alt="遮罩"></div>`;
|
||||||
|
}
|
||||||
|
imageWrapper.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkReady() {
|
||||||
|
generateBtn.disabled = !(originalImage && maskImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
generateBtn.addEventListener('click', async () => {
|
||||||
|
if (!originalImage || !maskImage) return;
|
||||||
|
|
||||||
|
generateBtn.disabled = true;
|
||||||
|
generateBtn.textContent = '⏳ 生成中...';
|
||||||
|
|
||||||
|
const startTime = performance.now();
|
||||||
|
resultArea.innerHTML = '<div class="loading"><div class="spinner"></div><br>正在调用 ComfyUI 生成,请耐心等待...<div id="liveTimer" style="margin-top:8px;font-size:15px;color:#aaa">已用时 0.0s</div></div>';
|
||||||
|
const liveTimer = document.getElementById('liveTimer');
|
||||||
|
const timerId = setInterval(() => {
|
||||||
|
if (liveTimer) liveTimer.textContent = '已用时 ' + ((performance.now() - startTime) / 1000).toFixed(1) + 's';
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', originalImage.file, 'original.' + originalImage.file.name.split('.').pop());
|
||||||
|
formData.append('mask', maskImage.file, 'mask.' + maskImage.file.name.split('.').pop());
|
||||||
|
formData.append('prompt', promptInput.value);
|
||||||
|
|
||||||
|
const resp = await fetch('/api/generate', { method: 'POST', body: formData });
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json();
|
||||||
|
throw new Error(err.error || 'Generation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultBlob = await resp.blob();
|
||||||
|
const resultUrl = URL.createObjectURL(resultBlob);
|
||||||
|
const elapsed = ((performance.now() - startTime) / 1000).toFixed(1);
|
||||||
|
// 服务端纯推理耗时(若返回该响应头)
|
||||||
|
const serverTime = resp.headers.get('X-Generate-Time');
|
||||||
|
const serverInfo = serverTime ? `,服务端推理 ${parseFloat(serverTime).toFixed(1)}s` : '';
|
||||||
|
|
||||||
|
resultArea.innerHTML = `
|
||||||
|
<div style="text-align:center;margin-bottom:12px;color:#4ade80;font-size:16px;font-weight:600">
|
||||||
|
⏱️ 本次重绘耗时 ${elapsed}s${serverInfo}
|
||||||
|
</div>
|
||||||
|
<div class="result-area">
|
||||||
|
<div class="result-item">
|
||||||
|
<h3>原图</h3>
|
||||||
|
<img src="${originalImage.img.src}" alt="原图">
|
||||||
|
</div>
|
||||||
|
<div class="result-item">
|
||||||
|
<h3>生成结果</h3>
|
||||||
|
<img src="${resultUrl}" alt="生成结果">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} catch (err) {
|
||||||
|
const elapsed = ((performance.now() - startTime) / 1000).toFixed(1);
|
||||||
|
resultArea.innerHTML = `<div class="error">❌ ${err.message}<br><span style="color:#aaa;font-size:13px">(耗时 ${elapsed}s)</span></div>`;
|
||||||
|
} finally {
|
||||||
|
clearInterval(timerId);
|
||||||
|
generateBtn.disabled = false;
|
||||||
|
generateBtn.textContent = '🚀 生成';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 3.3 MiB |
|
After Width: | Height: | Size: 899 KiB |
@@ -0,0 +1,52 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 启动发型补全服务
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
PID_FILE="hair_service.pid"
|
||||||
|
LOG_FILE="hair_service.log"
|
||||||
|
|
||||||
|
# 检查是否已在运行
|
||||||
|
if [ -f "$PID_FILE" ]; then
|
||||||
|
PID=$(cat "$PID_FILE")
|
||||||
|
if kill -0 "$PID" 2>/dev/null; then
|
||||||
|
echo "服务已在运行 (PID: $PID)"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo "清理无效的 PID 文件..."
|
||||||
|
rm "$PID_FILE"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查 ComfyUI 是否运行
|
||||||
|
if ! curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8188/ >/dev/null 2>&1; then
|
||||||
|
echo "警告: ComfyUI 未运行 (http://127.0.0.1:8188)"
|
||||||
|
echo "请先启动 ComfyUI: python /home/ubuntu/ComfyUI/main.py --listen"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 启动服务
|
||||||
|
echo "启动发型补全服务..."
|
||||||
|
/home/ubuntu/ComfyUI/venv/bin/python app.py >> "$LOG_FILE" 2>&1 &
|
||||||
|
PID=$!
|
||||||
|
echo "$PID" > "$PID_FILE"
|
||||||
|
|
||||||
|
# 等待启动
|
||||||
|
sleep 2
|
||||||
|
if curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8899/ | grep -q "200"; then
|
||||||
|
echo "服务启动成功!"
|
||||||
|
echo "本机: http://127.0.0.1:8899"
|
||||||
|
# 打印局域网 IP,方便其他机器访问(app.py 已绑定 0.0.0.0)
|
||||||
|
LAN_IPS=$(hostname -I 2>/dev/null | tr ' ' '\n' | grep -v '^$' || true)
|
||||||
|
if [ -n "$LAN_IPS" ]; then
|
||||||
|
echo "外网/局域网访问:"
|
||||||
|
for ip in $LAN_IPS; do
|
||||||
|
echo " http://${ip}:8899"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
echo "PID: $PID"
|
||||||
|
echo "日志: $LOG_FILE"
|
||||||
|
else
|
||||||
|
echo "服务启动失败,请检查日志: $LOG_FILE"
|
||||||
|
rm "$PID_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 停止发型补全服务
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
PID_FILE="hair_service.pid"
|
||||||
|
|
||||||
|
if [ ! -f "$PID_FILE" ]; then
|
||||||
|
echo "服务未运行"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
PID=$(cat "$PID_FILE")
|
||||||
|
|
||||||
|
if kill -0 "$PID" 2>/dev/null; then
|
||||||
|
echo "正在停止服务 (PID: $PID)..."
|
||||||
|
kill "$PID"
|
||||||
|
|
||||||
|
# 等待进程退出
|
||||||
|
for i in {1..10}; do
|
||||||
|
if ! kill -0 "$PID" 2>/dev/null; then
|
||||||
|
echo "服务已停止"
|
||||||
|
rm "$PID_FILE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# 强制终止
|
||||||
|
echo "强制终止进程..."
|
||||||
|
kill -9 "$PID"
|
||||||
|
rm "$PID_FILE"
|
||||||
|
echo "服务已停止"
|
||||||
|
else
|
||||||
|
echo "进程已不存在,清理 PID 文件..."
|
||||||
|
rm "$PID_FILE"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Test script: send image+mask to the local service and save result."""
|
||||||
|
import requests
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
SERVICE_URL = "http://127.0.0.1:8899"
|
||||||
|
IMAGE_PATH = "/home/ubuntu/hair/local_test/用来重绘.jpg"
|
||||||
|
MASK_PATH = "/home/ubuntu/hair/local_test/用来重绘.png"
|
||||||
|
OUTPUT_DIR = "/home/ubuntu/hair/local_test/output"
|
||||||
|
|
||||||
|
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
print(f"Sending image: {IMAGE_PATH}")
|
||||||
|
print(f"Sending mask: {MASK_PATH}")
|
||||||
|
|
||||||
|
with open(IMAGE_PATH, "rb") as f:
|
||||||
|
img_data = f.read()
|
||||||
|
with open(MASK_PATH, "rb") as f:
|
||||||
|
mask_data = f.read()
|
||||||
|
|
||||||
|
resp = requests.post(
|
||||||
|
f"{SERVICE_URL}/api/generate",
|
||||||
|
files={
|
||||||
|
"image": ("original.jpg", img_data, "image/jpeg"),
|
||||||
|
"mask": ("mask.png", mask_data, "image/png"),
|
||||||
|
},
|
||||||
|
data={"prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"},
|
||||||
|
timeout=600,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Status: {resp.status_code}")
|
||||||
|
print(f"Content-Type: {resp.headers.get('Content-Type')}")
|
||||||
|
|
||||||
|
if resp.status_code == 200 and "image" in resp.headers.get("Content-Type", ""):
|
||||||
|
out_path = os.path.join(OUTPUT_DIR, "result.png")
|
||||||
|
with open(out_path, "wb") as f:
|
||||||
|
f.write(resp.content)
|
||||||
|
print(f"SUCCESS! Result saved to: {out_path}")
|
||||||
|
else:
|
||||||
|
print(f"FAILED! Response: {resp.text[:2000]}")
|
||||||
|
sys.exit(1)
|
||||||
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
@@ -0,0 +1,86 @@
|
|||||||
|
// 上传图片自动降采样:当总像素 > MAX_PIXELS 时,等比例缩小到 <= TARGET_PIXELS。
|
||||||
|
// 用法:在提交前 file = await window.downscaleImageFile(file);
|
||||||
|
(function () {
|
||||||
|
var MAX_PIXELS = 1536000; // 触发阈值:超过则缩小
|
||||||
|
var TARGET_PIXELS = 786432; // 缩小后总像素上限
|
||||||
|
|
||||||
|
function loadImage(url) {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
var img = new Image();
|
||||||
|
img.onload = function () { resolve(img); };
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downscaleImageFile(file) {
|
||||||
|
// 非图片、GIF、SVG 不处理,原样返回
|
||||||
|
if (!file || !file.type || file.type.indexOf('image/') !== 0) return file;
|
||||||
|
if (file.type === 'image/gif' || file.type === 'image/svg+xml') return file;
|
||||||
|
|
||||||
|
var url = URL.createObjectURL(file);
|
||||||
|
try {
|
||||||
|
var img = await loadImage(url);
|
||||||
|
var w = img.naturalWidth, h = img.naturalHeight;
|
||||||
|
var total = w * h;
|
||||||
|
if (!total || total <= MAX_PIXELS) return file; // 未超阈值,原图直接用
|
||||||
|
|
||||||
|
var scale = Math.sqrt(TARGET_PIXELS / total);
|
||||||
|
var nw = Math.max(1, Math.round(w * scale));
|
||||||
|
var nh = Math.max(1, Math.round(h * scale));
|
||||||
|
|
||||||
|
var canvas = document.createElement('canvas');
|
||||||
|
canvas.width = nw;
|
||||||
|
canvas.height = nh;
|
||||||
|
var ctx = canvas.getContext('2d');
|
||||||
|
ctx.drawImage(img, 0, 0, nw, nh);
|
||||||
|
|
||||||
|
var outType = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
|
||||||
|
var quality = outType === 'image/jpeg' ? 0.92 : undefined;
|
||||||
|
var blob = await new Promise(function (res) { canvas.toBlob(res, outType, quality); });
|
||||||
|
if (!blob) return file;
|
||||||
|
|
||||||
|
var base = (file.name || 'image').replace(/\.(jpe?g|png|webp|bmp)$/i, '');
|
||||||
|
var name = base + (outType === 'image/png' ? '.png' : '.jpg');
|
||||||
|
return new File([blob], name, { type: outType, lastModified: Date.now() });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('downscaleImageFile 失败,使用原图', e);
|
||||||
|
return file;
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.downscaleImageFile = downscaleImageFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将接口返回的图片字段规范成 <img src> 可用值。
|
||||||
|
* 同时兼容:
|
||||||
|
* - 网关改写后的 *_url(http/https/相对路径)
|
||||||
|
* - worker 直连的 *_base64(原始 base64,或已带 data:image/...;base64, 前缀)
|
||||||
|
* @param {string|null|undefined} url
|
||||||
|
* @param {string|null|undefined} b64
|
||||||
|
* @param {string} [mimeHint='image/jpeg'] 原始 base64 时的 MIME 提示(会按魔数再嗅探)
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function resolveImgSrc(url, b64, mimeHint) {
|
||||||
|
var v = url || b64 || '';
|
||||||
|
if (!v || typeof v !== 'string') return '';
|
||||||
|
v = v.trim();
|
||||||
|
if (!v) return '';
|
||||||
|
// 已是可用 URL / data URI / blob / 协议相对 URL
|
||||||
|
if (/^(https?:|blob:|data:|\/\/)/i.test(v)) return v;
|
||||||
|
// 同站相对路径(仅匹配明确的 /static/...;勿把 JPEG base64 的 /9j/ 前缀当路径)
|
||||||
|
if (/^\/static\//.test(v)) return v;
|
||||||
|
// 原始 base64:去掉可能残留的 data URI 前缀后再拼,避免双重前缀导致无法显示
|
||||||
|
var raw = v.replace(/^data:image\/[a-zA-Z0-9+.-]+;base64,/i, '');
|
||||||
|
if (!raw) return '';
|
||||||
|
var mime = mimeHint || 'image/jpeg';
|
||||||
|
if (raw.indexOf('iVBOR') === 0) mime = 'image/png';
|
||||||
|
else if (raw.indexOf('/9j/') === 0) mime = 'image/jpeg';
|
||||||
|
else if (raw.indexOf('R0lGOD') === 0) mime = 'image/gif';
|
||||||
|
else if (raw.indexOf('UklGR') === 0) mime = 'image/webp';
|
||||||
|
return 'data:' + mime + ';base64,' + raw;
|
||||||
|
}
|
||||||
|
window.resolveImgSrc = resolveImgSrc;
|
||||||
|
})();
|
||||||
@@ -57,7 +57,6 @@
|
|||||||
<a href="#if4">接口4</a>
|
<a href="#if4">接口4</a>
|
||||||
<a href="#if5">接口5</a>
|
<a href="#if5">接口5</a>
|
||||||
<a href="#if6">接口6</a>
|
<a href="#if6">接口6</a>
|
||||||
<a href="#if7">接口7</a>
|
|
||||||
<a href="#errors">错误码</a>
|
<a href="#errors">错误码</a>
|
||||||
<a href="#test">在线测试</a>
|
<a href="#test">在线测试</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,6 +108,8 @@
|
|||||||
<tr><td><code>landmarks</code></td><td>object</td><td>5 个关键点像素坐标:hair_top/hairline/brow_center/nose_bottom/chin_tip</td></tr>
|
<tr><td><code>landmarks</code></td><td>object</td><td>5 个关键点像素坐标:hair_top/hairline/brow_center/nose_bottom/chin_tip</td></tr>
|
||||||
<tr><td><code>hairline_source</code></td><td>string</td><td>发际线来源:<code>"segmentation"</code>(真实分割,可信度高)/ <code>"estimated"</code>(比例估算,可信度低)</td></tr>
|
<tr><td><code>hairline_source</code></td><td>string</td><td>发际线来源:<code>"segmentation"</code>(真实分割,可信度高)/ <code>"estimated"</code>(比例估算,可信度低)</td></tr>
|
||||||
<tr><td><code>head_pose</code></td><td>object</td><td>头部姿态角度:<code>{ yaw, pitch, roll }</code>(度),接近 0 表示正面照</td></tr>
|
<tr><td><code>head_pose</code></td><td>object</td><td>头部姿态角度:<code>{ yaw, pitch, roll }</code>(度),接近 0 表示正面照</td></tr>
|
||||||
|
<tr><td><code>left_position</code></td><td>object</td><td>MediaPipe 21 号关键点坐标(左脸定位点),原图像素:<code>{ x: number, y: number }</code></td></tr>
|
||||||
|
<tr><td><code>right_position</code></td><td>object</td><td>MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:<code>{ x: number, y: number }</code></td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<p style="margin-top:12px;font-size:12px;color:#64748b">💡 前端把标注图叠加到原图上即可呈现测量效果(标注图白色线条 #FFFFFF,透明底)。</p>
|
<p style="margin-top:12px;font-size:12px;color:#64748b">💡 前端把标注图叠加到原图上即可呈现测量效果(标注图白色线条 #FFFFFF,透明底)。</p>
|
||||||
@@ -189,17 +190,17 @@ const { code, data } = await res.json();
|
|||||||
<div class="card" id="if4">
|
<div class="card" id="if4">
|
||||||
<h2>4. 用户特征分析 <span class="badge post">POST</span> <code>/api/v1/face/features</code></h2>
|
<h2>4. 用户特征分析 <span class="badge post">POST</span> <code>/api/v1/face/features</code></h2>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="desc">上传照片 → 火山方舟豆包视觉模型分析 → 返回几十项面部特征(脸型/眉形/肤色/四季色彩…)。</p>
|
<p class="desc">上传照片 → 火山方舟豆包视觉模型分析 → 返回固定 6 项面部特征(脸型/眉形/面部年龄/动静类型/性别/基因风格)。</p>
|
||||||
|
|
||||||
<p><strong>入参</strong>:image_file / image_url / image_base64 三选一。无其他参数。</p>
|
<p><strong>入参</strong>:image_file / image_url / image_base64 三选一。无其他参数。</p>
|
||||||
|
|
||||||
<p style="margin-top:12px"><strong>data 字段</strong></p>
|
<p style="margin-top:12px"><strong>data 字段</strong></p>
|
||||||
<table>
|
<table>
|
||||||
<tr><th>字段</th><th>类型</th><th>说明</th></tr>
|
<tr><th>字段</th><th>类型</th><th>说明</th></tr>
|
||||||
<tr><td><code>features</code></td><td>string</td><td><strong>JSON 字符串</strong>(不是对象!客户端需 <code>JSON.parse()</code>)</td></tr>
|
<tr><td><code>features</code></td><td>string</td><td><strong>JSON 字符串</strong>(不是对象!客户端需 <code>JSON.parse()</code>)。解析后得到<strong>固定 6 个英文字段</strong></td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<p style="margin-top:8px"><strong>features 英文优先字段</strong>(其余中文字段同时返回,共~42个):</p>
|
<p style="margin-top:8px"><strong>features 字段</strong>(固定返回 6 个):</p>
|
||||||
<table>
|
<table>
|
||||||
<tr><th>字段</th><th>说明</th><th>字段</th><th>说明</th></tr>
|
<tr><th>字段</th><th>说明</th><th>字段</th><th>说明</th></tr>
|
||||||
<tr><td>face_shape</td><td>脸型</td><td>eyebrow_shape</td><td>眉形</td></tr>
|
<tr><td>face_shape</td><td>脸型</td><td>eyebrow_shape</td><td>眉形</td></tr>
|
||||||
@@ -214,7 +215,7 @@ const { code, data } = await res.json();
|
|||||||
const { code, data } = await res.json();
|
const { code, data } = await res.json();
|
||||||
const features = JSON.parse(data.features); // ← 注意:data.features 是字符串!
|
const features = JSON.parse(data.features); // ← 注意:data.features 是字符串!
|
||||||
console.log(features.face_shape); // "鹅蛋脸"
|
console.log(features.face_shape); // "鹅蛋脸"
|
||||||
console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保留)</pre>
|
console.log(features.gene_style); // "自然型"</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -230,12 +231,13 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保
|
|||||||
<tr><td>image_file / image_url / image_base64</td><td>—</td><td>三选一</td><td>用户正面照</td></tr>
|
<tr><td>image_file / image_url / image_base64</td><td>—</td><td>三选一</td><td>用户正面照</td></tr>
|
||||||
<tr><td>gender</td><td>string</td><td>✅ 必填</td><td><code>"male"</code> / <code>"female"</code></td></tr>
|
<tr><td>gender</td><td>string</td><td>✅ 必填</td><td><code>"male"</code> / <code>"female"</code></td></tr>
|
||||||
<tr><td>hair_style</td><td>string</td><td>✅ 必填</td><td>发型序号,逗号分隔多选(如 <code>1,2,3</code>)。缺失/越界返回 1007</td></tr>
|
<tr><td>hair_style</td><td>string</td><td>✅ 必填</td><td>发型序号,逗号分隔多选(如 <code>1,2,3</code>)。缺失/越界返回 1007</td></tr>
|
||||||
|
<tr><td>generate_grow_image</td><td>bool</td><td>否</td><td>是否生成生发效果图(ComfyUI 生发,全流程最耗时),默认 <code>true</code>。传 <code>false</code> 时跳过生发,各发型 <code>grown_image_url</code> 恒为 <code>null</code>,仅返回三档发际线叠图与中心点,大幅降低耗时</td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<p style="margin-top:12px"><strong>data 字段</strong></p>
|
<p style="margin-top:12px"><strong>data 字段</strong></p>
|
||||||
<table>
|
<table>
|
||||||
<tr><th>字段</th><th>类型</th><th>说明</th></tr>
|
<tr><th>字段</th><th>类型</th><th>说明</th></tr>
|
||||||
<tr><td><code>hairline_images[]</code></td><td>object[]</td><td>选中发型列表,每项含 <code>hairline_type</code>、<code>image_middle_url</code>/<code>image_high_url</code>/<code>image_low_url</code> 三档<strong>透明 PNG 叠图</strong>(仅曲线,需叠加原图)、<code>grown_image_url</code> 生发图(完整人像,失败为 null)、<code>order</code></td></tr>
|
<tr><td><code>hairline_images[]</code></td><td>object[]</td><td>选中发型列表,每项含 <code>hairline_type</code>、<code>image_middle_url</code>/<code>image_high_url</code>/<code>image_low_url</code> 三档<strong>透明 PNG 叠图</strong>(仅曲线,需叠加原图)、<code>grown_image_url</code> 生发图(完整人像,失败或 <code>generate_grow_image=false</code> 时为 null)、<code>order</code></td></tr>
|
||||||
<tr><td><code>best_hairline_center_point</code></td><td>object \| null</td><td>首个选中发型 <strong>middle 档</strong>发际线中心点像素坐标 <code>{ x: number, y: number }</code></td></tr>
|
<tr><td><code>best_hairline_center_point</code></td><td>object \| null</td><td>首个选中发型 <strong>middle 档</strong>发际线中心点像素坐标 <code>{ x: number, y: number }</code></td></tr>
|
||||||
<tr><td><code>high_hairline_center_point</code></td><td>object \| null</td><td>同上,<strong>high 档</strong>发际线中点(发际线偏高,y 更小)</td></tr>
|
<tr><td><code>high_hairline_center_point</code></td><td>object \| null</td><td>同上,<strong>high 档</strong>发际线中点(发际线偏高,y 更小)</td></tr>
|
||||||
<tr><td><code>low_hairline_center_point</code></td><td>object \| null</td><td>同上,<strong>low 档</strong>发际线中点(发际线偏低,y 更大)</td></tr>
|
<tr><td><code>low_hairline_center_point</code></td><td>object \| null</td><td>同上,<strong>low 档</strong>发际线中点(发际线偏低,y 更大)</td></tr>
|
||||||
@@ -251,6 +253,8 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保
|
|||||||
<tr><td><code>landmarks</code></td><td>object</td><td>5 个关键点像素坐标:hair_top/hairline/brow_center/nose_bottom/chin_tip</td></tr>
|
<tr><td><code>landmarks</code></td><td>object</td><td>5 个关键点像素坐标:hair_top/hairline/brow_center/nose_bottom/chin_tip</td></tr>
|
||||||
<tr><td><code>hairline_source</code></td><td>string</td><td>发际线来源:<code>"segmentation"</code>(真实分割)/ <code>"estimated"</code>(比例估算)</td></tr>
|
<tr><td><code>hairline_source</code></td><td>string</td><td>发际线来源:<code>"segmentation"</code>(真实分割)/ <code>"estimated"</code>(比例估算)</td></tr>
|
||||||
<tr><td><code>head_pose</code></td><td>object</td><td>头部姿态角度:<code>{ yaw, pitch, roll }</code>(度)</td></tr>
|
<tr><td><code>head_pose</code></td><td>object</td><td>头部姿态角度:<code>{ yaw, pitch, roll }</code>(度)</td></tr>
|
||||||
|
<tr><td><code>left_position</code></td><td>object</td><td>MediaPipe 21 号关键点坐标(左脸定位点),原图像素:<code>{ x: number, y: number }</code></td></tr>
|
||||||
|
<tr><td><code>right_position</code></td><td>object</td><td>MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:<code>{ x: number, y: number }</code></td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<p style="margin-top:12px;font-size:12px;color:#64748b">💡 前端无需额外请求接口1 即可拿到四庭七眼测量数值;<code>face_measure</code> 为 <code>null</code> 时(角度过大/无人脸等)仅隐藏测量区块,发际线结果照常展示。</p>
|
<p style="margin-top:12px;font-size:12px;color:#64748b">💡 前端无需额外请求接口1 即可拿到四庭七眼测量数值;<code>face_measure</code> 为 <code>null</code> 时(角度过大/无人脸等)仅隐藏测量区块,发际线结果照常展示。</p>
|
||||||
@@ -282,41 +286,12 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保
|
|||||||
<tr><td><code>four_courts</code></td><td>object</td><td>三庭:upper/middle/lower,各含 _cm 和 ratios(<strong>无 top_court</strong>)</td></tr>
|
<tr><td><code>four_courts</code></td><td>object</td><td>三庭:upper/middle/lower,各含 _cm 和 ratios(<strong>无 top_court</strong>)</td></tr>
|
||||||
<tr><td><code>seven_eyes</code></td><td>object</td><td>七眼:<code>eye_width_cm</code>/<code>face_width_cm</code>/<code>inter_eye_distance_cm</code> + <code>ratios</code> + <strong><code>eye2</code>~<code>eye6</code></strong>(左脸颊/左眼/两眼间距/右眼/右脸颊,5 段宽度 cm;<strong>无 eye1/eye7</strong>)</td></tr>
|
<tr><td><code>seven_eyes</code></td><td>object</td><td>七眼:<code>eye_width_cm</code>/<code>face_width_cm</code>/<code>inter_eye_distance_cm</code> + <code>ratios</code> + <strong><code>eye2</code>~<code>eye6</code></strong>(左脸颊/左眼/两眼间距/右眼/右脸颊,5 段宽度 cm;<strong>无 eye1/eye7</strong>)</td></tr>
|
||||||
<tr><td><code>landmarks</code></td><td>object</td><td>4 个关键点:hairline/brow_center/nose_bottom/chin_tip(<strong>无 hair_top</strong>)</td></tr>
|
<tr><td><code>landmarks</code></td><td>object</td><td>4 个关键点:hairline/brow_center/nose_bottom/chin_tip(<strong>无 hair_top</strong>)</td></tr>
|
||||||
|
<tr><td><code>left_position</code></td><td>object</td><td>MediaPipe 21 号关键点坐标(左脸定位点),原图像素:<code>{ x: number, y: number }</code></td></tr>
|
||||||
|
<tr><td><code>right_position</code></td><td>object</td><td>MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:<code>{ x: number, y: number }</code></td></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ======== 接口7 ======== -->
|
|
||||||
<div class="card" id="if7">
|
|
||||||
<h2>7. C端生发 v2 <span class="badge post">POST</span> <code>/api/v1/hair/grow-v2</code> <span class="badge warn">v2</span></h2>
|
|
||||||
<div class="card-body">
|
|
||||||
<p class="desc">功能与<a href="#if2">接口2</a>完全一致,仅 ComfyUI 工作流不同——使用 <code>add_hair2.json</code> 替代 <code>add_hair.json</code>(Flux-2 Klein 9b)。</p>
|
|
||||||
|
|
||||||
<p><strong>入参</strong></p>
|
|
||||||
<table>
|
|
||||||
<tr><th>参数</th><th>类型</th><th>必填</th><th>说明</th></tr>
|
|
||||||
<tr><td>image_file / image_url / image_base64</td><td>—</td><td>三选一</td><td>用户正面照</td></tr>
|
|
||||||
<tr><td>gender</td><td>string</td><td>✅ 必填</td><td><code>"male"</code> / <code>"female"</code></td></tr>
|
|
||||||
<tr><td>hair_style</td><td>int</td><td>✅ 必填</td><td>发型序号。female: 1~5,male: 1~4</td></tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<p style="margin-top:12px"><strong>data.results[] 元素</strong>(同接口2)</p>
|
|
||||||
<table>
|
|
||||||
<tr><th>字段</th><th>类型</th><th>说明</th></tr>
|
|
||||||
<tr><td><code>image_url</code></td><td>string</td><td>发际线曲线<strong>透明 PNG</strong>(仅曲线,需叠加原图显示,同接口2)</td></tr>
|
|
||||||
<tr><td><code>grown_image_url</code></td><td>string</td><td>生发后效果图(完整人像)⚠ 可空</td></tr>
|
|
||||||
<tr><td><code>hairline_type</code></td><td>string</td><td>发际线类型 key</td></tr>
|
|
||||||
<tr><td><code>order</code></td><td>int</td><td>排序</td></tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<p style="margin-top:8px;font-size:12px;color:#64748b">
|
|
||||||
Female 5 种:ellipse/flower/heart/straight/wave |
|
|
||||||
Male 4 种:ellipse/m/straight/inverse_arc<br>
|
|
||||||
⚠ 工作流: add_hair2.json(Flux-2 Klein 9b),输入节点 26,输出节点 75。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ======== 错误码 ======== -->
|
<!-- ======== 错误码 ======== -->
|
||||||
<div class="card" id="errors">
|
<div class="card" id="errors">
|
||||||
<h2>⚠ 错误码</h2>
|
<h2>⚠ 错误码</h2>
|
||||||
@@ -326,12 +301,14 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保
|
|||||||
<tr><td>1001</td><td>无法识别人像</td><td>未检测到人脸</td></tr>
|
<tr><td>1001</td><td>无法识别人像</td><td>未检测到人脸</td></tr>
|
||||||
|
|
||||||
<tr><td>1003</td><td>角度问题,非正面照</td><td>非正面 / 角度过大</td></tr>
|
<tr><td>1003</td><td>角度问题,非正面照</td><td>非正面 / 角度过大</td></tr>
|
||||||
|
<tr><td>1004</td><td>gender 必填且只能为 male / female</td><td>接口2/5 的 <code>gender</code> 缺失或非法</td></tr>
|
||||||
<tr><td>1005</td><td>检测到多张人脸</td><td>仅支持单人</td></tr>
|
<tr><td>1005</td><td>检测到多张人脸</td><td>仅支持单人</td></tr>
|
||||||
|
|
||||||
<tr><td>1007</td><td>图片参数错误 / 后端不可用</td><td>参数传错 / 服务繁忙请稍后重试</td></tr>
|
<tr><td>1007</td><td>图片参数错误 / 后端不可用</td><td>参数传错 / 服务繁忙请稍后重试</td></tr>
|
||||||
<tr><td>1008</td><td>图片格式不支持</td><td>非 JPG/PNG / base64 解码失败</td></tr>
|
<tr><td>1008</td><td>图片格式不支持</td><td>非 JPG/PNG / base64 解码失败</td></tr>
|
||||||
|
<tr><td>1009</td><td>未授权</td><td>缺少或错误的 <code>X-Internal-Token</code>(<code>/api/*</code> 路径鉴权)</td></tr>
|
||||||
</table>
|
</table>
|
||||||
<p style="font-size:12px;color:#94a3b8;margin-top:8px">1004 已废弃(接口2 不再自动判性别,改由客户端传 gender 参数)。</p>
|
<p style="font-size:12px;color:#94a3b8;margin-top:8px">注:1004 仍在使用(接口2/5 的 gender 校验);接口7(grow-v2)已弃用,请改用接口2。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -344,10 +321,9 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保
|
|||||||
<tr><td>1. 四庭七眼</td><td><a href="/static/test_interface1.html" class="link">/static/test_interface1.html</a></td><td>上传照片 → 原图+标注叠加,底图/标注开关,指标卡片</td></tr>
|
<tr><td>1. 四庭七眼</td><td><a href="/static/test_interface1.html" class="link">/static/test_interface1.html</a></td><td>上传照片 → 原图+标注叠加,底图/标注开关,指标卡片</td></tr>
|
||||||
<tr><td>2. C端生发</td><td><a href="/static/test_interface2.html" class="link">/static/test_interface2.html</a></td><td>上传+性别 → 方案一覧(原图/叠加/生发),双图对比</td></tr>
|
<tr><td>2. C端生发</td><td><a href="/static/test_interface2.html" class="link">/static/test_interface2.html</a></td><td>上传+性别 → 方案一覧(原图/叠加/生发),双图对比</td></tr>
|
||||||
<tr><td>3. B端生发</td><td><a href="/static/test_interface3.html" class="link">/static/test_interface3.html</a></td><td>划线图上传 → 生发效果图</td></tr>
|
<tr><td>3. B端生发</td><td><a href="/static/test_interface3.html" class="link">/static/test_interface3.html</a></td><td>划线图上传 → 生发效果图</td></tr>
|
||||||
<tr><td>4. 用户特征</td><td><a href="/static/test_interface4.html" class="link">/static/test_interface4.html</a></td><td>上传照片 → 42项面部特征表格 + 原始JSON</td></tr>
|
<tr><td>4. 用户特征</td><td><a href="/static/test_interface4.html" class="link">/static/test_interface4.html</a></td><td>上传照片 → 6项面部特征 + 原始JSON</td></tr>
|
||||||
<tr><td>5. 发际线PNG</td><td><a href="/static/test_interface5.html" class="link">/static/test_interface5.html</a></td><td>上传+性别 → 发际线方案+中心点坐标</td></tr>
|
<tr><td>5. 发际线PNG</td><td><a href="/static/test_interface5.html" class="link">/static/test_interface5.html</a></td><td>上传+性别 → 发际线方案+中心点坐标</td></tr>
|
||||||
<tr><td>6. 四庭七眼 v2</td><td><a href="/static/test_interface6.html" class="link">/static/test_interface6.html</a></td><td>同接口1,去顶庭 · 竖线发际线→下巴 · 无头部端线</td></tr>
|
<tr><td>6. 四庭七眼 v2</td><td><a href="/static/test_interface6.html" class="link">/static/test_interface6.html</a></td><td>同接口1,去顶庭 · 竖线发际线→下巴 · 无头部端线</td></tr>
|
||||||
<tr><td>7. C端生发 v2</td><td><a href="/static/test_interface7.html" class="link">/static/test_interface7.html</a></td><td>同接口2,使用 add_hair2.json 工作流(Flux-2 Klein 9b)</td></tr>
|
|
||||||
</table>
|
</table>
|
||||||
<p style="font-size:12px;color:#94a3b8;margin-top:12px">
|
<p style="font-size:12px;color:#94a3b8;margin-top:12px">
|
||||||
完整 API 文档:<a href="/docs" class="link">/docs</a>(Swagger UI)
|
完整 API 文档:<a href="/docs" class="link">/docs</a>(Swagger UI)
|
||||||
|
|||||||
@@ -62,6 +62,7 @@
|
|||||||
@media (max-width: 768px) { .results { flex-direction: column; } }
|
@media (max-width: 768px) { .results { flex-direction: column; } }
|
||||||
.hidden { display: none !important; }
|
.hidden { display: none !important; }
|
||||||
</style>
|
</style>
|
||||||
|
<script src="/static/img_downscale.js?v=2"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -127,8 +128,9 @@ function setStatus(text, type) {
|
|||||||
|
|
||||||
async function submitTest() {
|
async function submitTest() {
|
||||||
const fileInput = $('imageFile');
|
const fileInput = $('imageFile');
|
||||||
const file = fileInput.files[0];
|
let file = fileInput.files[0];
|
||||||
if (!file) { setStatus('请先选择一张图片', 'error'); return; }
|
if (!file) { setStatus('请先选择一张图片', 'error'); return; }
|
||||||
|
file = await window.downscaleImageFile(file);
|
||||||
|
|
||||||
const _reqStart = performance.now();
|
const _reqStart = performance.now();
|
||||||
|
|
||||||
@@ -148,7 +150,7 @@ async function submitTest() {
|
|||||||
form.append('image_file', file);
|
form.append('image_file', file);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(API_BASE + '/api/v1/face/measure', { method: 'POST', body: form });
|
const resp = await fetch(API_BASE + '/api/v1/face/measure', { method: 'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body: form });
|
||||||
const json = await resp.json();
|
const json = await resp.json();
|
||||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||||
|
|
||||||
@@ -157,7 +159,10 @@ async function submitTest() {
|
|||||||
|
|
||||||
if (json.code === 0) {
|
if (json.code === 0) {
|
||||||
setStatus('✅ 请求成功 (' + _elapsed + 's) — request_id: ' + json.request_id, 'success');
|
setStatus('✅ 请求成功 (' + _elapsed + 's) — request_id: ' + json.request_id, 'success');
|
||||||
showOverlay(json.data.annotated_image_url);
|
// 兼容本地直连 worker(*_base64)与网关(*_url)
|
||||||
|
const _d = json.data || {};
|
||||||
|
const annoUrl = resolveImgSrc(_d.annotated_image_url, _d.annotated_image_base64, 'image/png');
|
||||||
|
showOverlay(annoUrl);
|
||||||
renderMetrics(json.data);
|
renderMetrics(json.data);
|
||||||
$('metricsBar').classList.remove('hidden');
|
$('metricsBar').classList.remove('hidden');
|
||||||
} else {
|
} else {
|
||||||
@@ -182,6 +187,10 @@ function showOverlay(annoUrl) {
|
|||||||
setTimeout(() => showOverlay(annoUrl), 200);
|
setTimeout(() => showOverlay(annoUrl), 200);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!annoUrl) {
|
||||||
|
$('imgPanel').innerHTML = '<span class="placeholder">后端未返回标注图(annotated_image_base64 为空)</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const checked = $('showAnno').checked ? '' : 'display:none';
|
const checked = $('showAnno').checked ? '' : 'display:none';
|
||||||
const opacity = ($('annoOpacity').value / 100).toFixed(2);
|
const opacity = ($('annoOpacity').value / 100).toFixed(2);
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.85); display: none; align-items: center; justify-content: center; z-index: 50; cursor: zoom-out; }
|
.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.85); display: none; align-items: center; justify-content: center; z-index: 50; cursor: zoom-out; }
|
||||||
.lightbox img { max-width: 95%; max-height: 95%; }
|
.lightbox img { max-width: 95%; max-height: 95%; }
|
||||||
</style>
|
</style>
|
||||||
|
<script src="/static/img_downscale.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -197,8 +198,9 @@ function renderMetrics(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitTest() {
|
async function submitTest() {
|
||||||
const file = $('imageFile').files[0];
|
let file = $('imageFile').files[0];
|
||||||
if (!file) { setStatus('请先选择一张图片', 'error'); return; }
|
if (!file) { setStatus('请先选择一张图片', 'error'); return; }
|
||||||
|
file = await window.downscaleImageFile(file);
|
||||||
|
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
const btn = $('submitBtn');
|
const btn = $('submitBtn');
|
||||||
@@ -212,7 +214,7 @@ async function submitTest() {
|
|||||||
form.append('dilate_cm', $('dilateCm').value || '2.0');
|
form.append('dilate_cm', $('dilateCm').value || '2.0');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(API_BASE + ENDPOINT, { method: 'POST', body: form });
|
const resp = await fetch(API_BASE + ENDPOINT, { method: 'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body: form });
|
||||||
const json = await resp.json();
|
const json = await resp.json();
|
||||||
const dt = ((performance.now() - t0) / 1000).toFixed(2);
|
const dt = ((performance.now() - t0) / 1000).toFixed(2);
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.85); display: none; align-items: center; justify-content: center; z-index: 50; cursor: zoom-out; }
|
.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.85); display: none; align-items: center; justify-content: center; z-index: 50; cursor: zoom-out; }
|
||||||
.lightbox img { max-width: 95%; max-height: 95%; }
|
.lightbox img { max-width: 95%; max-height: 95%; }
|
||||||
</style>
|
</style>
|
||||||
|
<script src="/static/img_downscale.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -292,8 +293,9 @@ function renderResult(d) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitTest() {
|
async function submitTest() {
|
||||||
const file = $('imageFile').files[0];
|
let file = $('imageFile').files[0];
|
||||||
if (!file) { setStatus('请先选择一张图片', 'error'); return; }
|
if (!file) { setStatus('请先选择一张图片', 'error'); return; }
|
||||||
|
file = await window.downscaleImageFile(file);
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
const btn = $('submitBtn');
|
const btn = $('submitBtn');
|
||||||
btn.disabled = true; btn.textContent = '⏳ 请求中...';
|
btn.disabled = true; btn.textContent = '⏳ 请求中...';
|
||||||
|
|||||||
@@ -34,8 +34,12 @@
|
|||||||
.status.success { background: #d1fae5; color: #065f46; display: block; }
|
.status.success { background: #d1fae5; color: #065f46; display: block; }
|
||||||
.steps { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
|
.steps { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
|
||||||
.step { background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
|
.step { background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
|
||||||
.step .cap { font-size: 13px; font-weight: 600; padding: 8px 12px; background: #fafafa; border-bottom: 1px solid #f0f0f0; }
|
.step .cap { font-size: 13px; font-weight: 600; padding: 8px 12px; background: #fafafa; border-bottom: 1px solid #f0f0f0; display:flex; align-items:center; gap:8px; }
|
||||||
|
.step .cap .badge { flex-shrink:0; display:inline-flex; align-items:center; justify-content:center; min-width:24px; height:24px; padding:0 6px; border-radius:6px; background:#2563eb; color:#fff; font-size:13px; font-weight:700; }
|
||||||
|
.step .cap .ttext { flex:1; }
|
||||||
.step .cap small { color: #999; font-weight: 400; display:block; margin-top:2px; }
|
.step .cap small { color: #999; font-weight: 400; display:block; margin-top:2px; }
|
||||||
|
.step .desc { font-size: 12px; line-height: 1.7; color: #4b5563; padding: 10px 12px; background: #f9fafb; border-bottom: 1px solid #f0f0f0; }
|
||||||
|
.step .desc b { color:#1f2937; }
|
||||||
.step img { width: 100%; display: block; background: #eee; cursor: zoom-in; }
|
.step img { width: 100%; display: block; background: #eee; cursor: zoom-in; }
|
||||||
.step .noimg { padding: 30px; text-align: center; color: #ccc; font-size: 13px; }
|
.step .noimg { padding: 30px; text-align: center; color: #ccc; font-size: 13px; }
|
||||||
.big img { max-height: 520px; object-fit: contain; }
|
.big img { max-height: 520px; object-fit: contain; }
|
||||||
@@ -49,6 +53,7 @@
|
|||||||
.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.85); display: none; align-items: center; justify-content: center; z-index: 50; cursor: zoom-out; }
|
.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.85); display: none; align-items: center; justify-content: center; z-index: 50; cursor: zoom-out; }
|
||||||
.lightbox img { max-width: 95%; max-height: 95%; }
|
.lightbox img { max-width: 95%; max-height: 95%; }
|
||||||
</style>
|
</style>
|
||||||
|
<script src="/static/img_downscale.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -147,8 +152,8 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 style="margin-top:0">🎯 最终结果</h2>
|
<h2 style="margin-top:0">🎯 最终结果</h2>
|
||||||
<div class="steps" style="grid-template-columns: repeat(auto-fill, minmax(320px, 1fr))">
|
<div class="steps" style="grid-template-columns: repeat(auto-fill, minmax(320px, 1fr))">
|
||||||
<div class="step big"><div class="cap">输入原图</div><img id="finalInput"></div>
|
<div class="step big"><div class="cap"><span class="badge" style="background:#9ca3af">输入</span><span class="ttext">输入原图</span></div><img id="finalInput"></div>
|
||||||
<div class="step big"><div class="cap">最终结果</div><img id="finalOut"></div>
|
<div class="step big"><div class="cap"><span class="badge">⑪</span><span class="ttext">最终结果<small>接口11 最终输出 = ⑩接缝融合结果</small></span></div><img id="finalOut"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -192,19 +197,55 @@ function flog(msg, level) {
|
|||||||
function clearLog() { FE_LOGS.length = 0; document.getElementById('logPanel').innerHTML = ''; }
|
function clearLog() { FE_LOGS.length = 0; document.getElementById('logPanel').innerHTML = ''; }
|
||||||
|
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
{ key: 'baseline_overlay', title: '①-a 发际线分割线', sub: '黄线=baseline' },
|
{ no: '①', key: 'baseline_overlay', title: '发际线分割线 baseline', sub: '黄线=baseline(眉峰水平连线)',
|
||||||
{ key: 'upper_overlay', title: '①-b 上半区', sub: '青=baseline以上' },
|
desc: '用 468 点人脸关键点定位两侧眉峰,连成一条水平线作为「上半区」的底界。'
|
||||||
{ key: 'hair_seg_overlay', title: '①-c 头发分割', sub: '绿=头发像素' },
|
+ '这条线把画面分成上下两半:以上是额头+头发(要处理),以下是五官(保持不动)。'
|
||||||
{ key: 'top_fill_overlay', title: '①-d 填充到基线', sub: '蓝=top_fill(仅eroded/closed)' },
|
+ '它是后续所有遮罩、外推、贴回的坐标基准线。图中的黄线就是 baseline,红点为 151 号中心点(径向外推的圆心)。' },
|
||||||
{ key: 'closed_overlay', title: '①-e 闭合区域', sub: '紫=closed(仅eroded/closed)' },
|
{ no: '②', key: 'upper_overlay', title: '上半区 upper', sub: '青色=baseline 以上区域',
|
||||||
{ key: 'hairline_overlay', title: '①-f 头发内轮廓线', sub: '绿=头发内轮廓(额头弧+两侧到下颌),黄=baseline折线(仅pushed)' },
|
desc: '把 baseline 以上到画面顶部的整片区域标为「上半区」。后续的头发分割、发际线外推、'
|
||||||
{ key: 'pushed_overlay', title: '①-g 外推发际线', sub: '青=外推线(进头发push_cm),红=遮罩,绿=内轮廓(仅pushed)' },
|
+ '最终遮罩都只会在这个上半区内计算,确保下半张脸(眉、眼、鼻、嘴)永远不被改动。'
|
||||||
{ key: 'mask_overlay', title: '① 最终遮罩(叠加)', sub: '红=最终遮罩区' },
|
+ '青色覆盖的就是上半区范围。' },
|
||||||
{ key: 'mask', title: '① 纯遮罩', sub: '白=贴回区' },
|
{ no: '③', key: 'hair_seg_overlay', title: '头发分割', sub: '绿色=头发像素',
|
||||||
{ key: 'swap_raw', title: '② 生成全帧', sub: '换发型结果' },
|
desc: '用语义分割模型(默认 SegFormer,可选 BiSeNet)逐像素判断哪些是「头发」。'
|
||||||
{ key: 'hard_paste', title: '③ 严格贴回', sub: '遮罩内=生成,外=原图' },
|
+ '绿色覆盖的就是被识别为头发的像素。这一步的目的是找到现有头发的边界,'
|
||||||
{ key: 'alpha', title: '④ 融合权重', sub: '白=用生成图' },
|
+ '为下一步「发际线内轮廓」提供输入——发际线就长在头发区域的内边缘上。' },
|
||||||
{ key: 'final', title: '④ 接缝融合(最终)', sub: '最终输出' },
|
{ no: '④', key: 'hairline_overlay', title: '头发内轮廓线', sub: '绿=内轮廓(额头弧+两侧),黄=baseline',
|
||||||
|
desc: '从头发分割结果里提取出「头发的内轮廓」:即头发与皮肤交界的那条线。'
|
||||||
|
+ '它包含额头弧线(发际线本体)和两侧向下的鬓角轮廓。'
|
||||||
|
+ '提取方式 hairline_edge=column 时按逐列(竖向)找头发最低点连成线;=contour 时用形态学轮廓。'
|
||||||
|
+ '绿色折线就是提取出的内轮廓,黄色折线是 ① 的 baseline。这条内轮廓是外推发际线的起点。' },
|
||||||
|
{ no: '⑤', key: 'pushed_overlay', title: '外推发际线 + 遮罩', sub: '青=外推线,红=遮罩,绿=内轮廓,红点=圆心',
|
||||||
|
desc: '把 ④ 的发际线内轮廓「往头发方向(向头顶)推进 hairline_push_cm 厘米」得到一条新的外推线(青色)。'
|
||||||
|
+ '外推方式:以眉心(151 点)为圆心做径向外推,推过的这段就是「要新长出头发的区域」。'
|
||||||
|
+ '然后用【外推线(上界)到 baseline(下界)】之间的闭合区域作为最终遮罩(红色半透明)。'
|
||||||
|
+ 'push_cm 越大,新发际线越靠上、生发区越大(默认 0.8cm)。' },
|
||||||
|
{ no: '⑥', key: 'mask_overlay', title: '最终遮罩(叠加图)', sub: '红色=要重绘/贴回的区域',
|
||||||
|
desc: '把 ⑤ 算出的遮罩叠回原图看效果。红色区域 = 需要被新生成的头发覆盖的位置(遮罩内),'
|
||||||
|
+ '红色以外 = 保持原样不动(遮罩外)。这张图用来直观确认遮罩范围是否合理——'
|
||||||
|
+ '理想情况是红色正好覆盖额头该生发的区域,不越界到眉毛或脸颊。' },
|
||||||
|
{ no: '⑦', key: 'mask', title: '纯遮罩', sub: '白色=贴回区,黑色=保留区',
|
||||||
|
desc: '同一张遮罩的纯黑白版本(无原图背景)。白色=贴回区,黑色=保留区。'
|
||||||
|
+ '这张纯遮罩会作为 ext_mask 传给换发型服务,让 webui 精确地只在这个区域内重绘画头发。'
|
||||||
|
+ '它的好处是不受背景图干扰,便于检查遮罩形状是否干净(无噪点、无破洞)。' },
|
||||||
|
{ no: '⑧', key: 'swap_raw', title: '换发型生成', sub: 'change_hair 换该发际线类型后的整帧图',
|
||||||
|
desc: '调用 change_hair 换发型服务(POST :8801/api/swapHair/v1),传入原图 + 发际线类型 ID(hairline_id)。'
|
||||||
|
+ '服务用对应发型的 LoRA 模型(webui img2img,denoising_strength 控制生发强度)生成一张'
|
||||||
|
+ '「同一个人、换成该发际线类型发型」的完整图。注意:这张图是整帧都变了,'
|
||||||
|
+ '下一步会严格按遮罩只取额头那块,其余丢掉,保证五官不动。inpainting_fill/mask_blur 等参数控制这里的重绘方式。' },
|
||||||
|
{ no: '⑨', key: 'hard_paste', title: '严格贴回(无融合)', sub: '遮罩内=生成图,遮罩外=原图',
|
||||||
|
desc: '把 ⑧ 的生成图按 ⑥/⑦ 的遮罩「硬贴」回原图:遮罩内用生成图,遮罩外完全保留原图。'
|
||||||
|
+ '这是没有做任何接缝处理的版本,因此遮罩边缘通常能看到明显的接缝/色差。'
|
||||||
|
+ '它的作用是让你对比看出「融合前后的差别」——边缘接缝要靠下一步的融合来消除。' },
|
||||||
|
{ no: '⑩', key: 'alpha', title: '融合权重 alpha', sub: '白=用生成图,黑=保留原图,灰=过渡',
|
||||||
|
desc: '决定每个像素最终取多少比例的生成图。纯白(=1)完全用生成图,纯黑(=0)完全保留原图,'
|
||||||
|
+ '灰色是两者按比例过渡。blend_method=multiband/two_stage 时是多层金字塔权重(过渡带较宽、自然);'
|
||||||
|
+ '=feather/alpha_gradient 时是单层羽化(硬边缘软过渡)。这张图用来理解融合是怎么"渐变"地把新头发融进去的。' },
|
||||||
|
{ no: '⑪', key: 'final', title: '接缝融合(最终输出)', sub: '接口11 最终结果',
|
||||||
|
desc: '按 ⑩ 的权重,把生成图和原图加权融合,得到无接缝的最终图。这就是接口11 的最终返回结果。'
|
||||||
|
+ 'blend_method 选择融合算法:multiband=多频段金字塔(分频段融合,大色差场景用 two_stage 先泊松调色再多频段);'
|
||||||
|
+ 'seamless=泊松无缝克隆(梯度域自动调色);feather/alpha_gradient=简单羽化。'
|
||||||
|
+ 'color_match=true 时融合前还做一次 Reinhard 颜色迁移消除整体色差(seamless/two_stage 自带调色故跳过)。'
|
||||||
|
+ '本接口不含重绘,需要重绘(美颜/补发丝)见接口12。' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function $(id) { return document.getElementById(id); }
|
function $(id) { return document.getElementById(id); }
|
||||||
@@ -212,13 +253,16 @@ function setStatus(text, type) { const b = $('statusBar'); b.textContent = text;
|
|||||||
function pick(obj, name) { if (!obj) return null; return obj[name + '_url'] || obj[name + '_base64'] || null; }
|
function pick(obj, name) { if (!obj) return null; return obj[name + '_url'] || obj[name + '_base64'] || null; }
|
||||||
function zoom(src) { $('lightboxImg').src = src; $('lightbox').style.display = 'flex'; }
|
function zoom(src) { $('lightboxImg').src = src; $('lightbox').style.display = 'flex'; }
|
||||||
|
|
||||||
function stepCard(title, sub, src) {
|
function stepCard(st, src) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'step';
|
div.className = 'step';
|
||||||
const hasImg = src && src.length > 50;
|
const hasImg = src && src.length > 50;
|
||||||
const img = hasImg ? '<img src="' + src + '" onclick="zoom(this.src)">'
|
const img = hasImg ? '<img src="' + src + '" onclick="zoom(this.src)">'
|
||||||
: '<div class="noimg">无图(后端返回空)</div>';
|
: '<div class="noimg">无图(后端返回空)</div>';
|
||||||
div.innerHTML = '<div class="cap">' + title + '<small>' + (sub||'') + (hasImg ? ' ('+src.length+'字符)' : '') + '</small></div>' + img;
|
const badge = st.no ? '<span class="badge">' + st.no + '</span>' : '';
|
||||||
|
const ttext = '<span class="ttext">' + (st.title||'') + '<small>' + (st.sub||'') + (hasImg ? ' ('+src.length+'字符)' : '') + '</small></span>';
|
||||||
|
const desc = st.desc ? '<div class="desc">' + st.desc + '</div>' : '';
|
||||||
|
div.innerHTML = '<div class="cap">' + badge + ttext + '</div>' + desc + img;
|
||||||
return div;
|
return div;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,14 +282,15 @@ function renderResult(d) {
|
|||||||
const src = pick(s, st.key);
|
const src = pick(s, st.key);
|
||||||
const hasImg = src && src.length > 50;
|
const hasImg = src && src.length > 50;
|
||||||
flog(' 渲染 ' + st.key + ': ' + (hasImg ? '有图(' + src.length + '字符)' : '无图'), hasImg ? 'info' : 'warn');
|
flog(' 渲染 ' + st.key + ': ' + (hasImg ? '有图(' + src.length + '字符)' : '无图'), hasImg ? 'info' : 'warn');
|
||||||
grid.appendChild(stepCard(st.title, st.sub, src));
|
grid.appendChild(stepCard(st, src));
|
||||||
});
|
});
|
||||||
flog('renderResult 完成', 'info');
|
flog('renderResult 完成', 'info');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitTest() {
|
async function submitTest() {
|
||||||
const file = $('imageFile').files[0];
|
let file = $('imageFile').files[0];
|
||||||
if (!file) { setStatus('请先选择图片', 'error'); return; }
|
if (!file) { setStatus('请先选择图片', 'error'); return; }
|
||||||
|
file = await window.downscaleImageFile(file);
|
||||||
// 页面隐藏但仍提交的固定默认值
|
// 页面隐藏但仍提交的固定默认值
|
||||||
const HIDDEN = {
|
const HIDDEN = {
|
||||||
seg_model: 'segformer',
|
seg_model: 'segformer',
|
||||||
@@ -364,6 +409,10 @@ async function downloadBackendLog() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 步骤序号 → 对应英文 key 的映射,供按序号定位步骤
|
||||||
|
const STEP_BY_NO = {};
|
||||||
|
STEPS.forEach(s => { if (s.no) STEP_BY_NO[s.no] = s.key; });
|
||||||
|
|
||||||
// 参数联动(遮罩/融合已固定,无下拉联动)
|
// 参数联动(遮罩/融合已固定,无下拉联动)
|
||||||
$('imageFile').addEventListener('change', function(){ if(this.files.length) flog('选择图片: ' + this.files[0].name, 'info'); });
|
$('imageFile').addEventListener('change', function(){ if(this.files.length) flog('选择图片: ' + this.files[0].name, 'info'); });
|
||||||
|
|
||||||
|
|||||||
@@ -47,22 +47,29 @@
|
|||||||
.log-panel .lvl-err { color: #f48771; }
|
.log-panel .lvl-err { color: #f48771; }
|
||||||
.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.85); display: none; align-items: center; justify-content: center; z-index: 50; cursor: zoom-out; }
|
.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.85); display: none; align-items: center; justify-content: center; z-index: 50; cursor: zoom-out; }
|
||||||
.lightbox img { max-width: 95%; max-height: 95%; }
|
.lightbox img { max-width: 95%; max-height: 95%; }
|
||||||
|
.hidden { display: none; }
|
||||||
</style>
|
</style>
|
||||||
|
<script src="/static/img_downscale.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>接口12 — 发际线带重绘 <span style="font-size:13px;color:#888">(Flux-2 保色重绘)</span></h1>
|
<h1>接口12 — 发际线带重绘 <span style="font-size:13px;color:#888">(final + 纯红遮罩 → ComfyUI 重绘)</span></h1>
|
||||||
<div class="subtitle">
|
<div class="subtitle">
|
||||||
内部先跑<b>接口11</b>拿到 ④接缝融合最终图(final),再取 <b>⑤-① 发际线重绘带</b>(发际线外推 band_lo_mult×push ~ band_hi_mult×push、经 baseline 截断只留上部)作遮罩,
|
内部先跑<b>接口11</b>拿到 ④接缝融合最终图(final),再取 <b>⑤-① 发际线重绘带</b>(发际线外推 band_lo_mult×push ~ band_hi_mult×push、经 baseline 截断只留上部)生成
|
||||||
调 <b>Flux-2(ComfyUI)</b> 保色重绘,重绘结果与 final 融合。<br>
|
<b>纯红遮罩 PNG</b>(遮罩区=(255,0,0,255),其余全透明)。<br>
|
||||||
接口11 参数用于内部生成 final 与重绘带;<b>comfyui_prompt</b> 控制 Flux-2 提示词。⚠️ 需 ComfyUI(:8188) 在跑。
|
前端拿到 final + 遮罩后,调 <b>后端重绘接口(/api/v1/redraw)</b>完成重绘。⚠️ 需 ComfyUI(:8188) 在跑。
|
||||||
对照仅生成不重绘:<a href="/static/test_interface11_debug.html">接口11 调试页</a>。
|
对照仅生成不重绘:<a href="/static/test_interface11_debug.html">接口11 调试页</a>。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="upload-row">
|
<div class="upload-row">
|
||||||
<input type="file" id="imageFile" accept="image/*">
|
<input type="file" id="imageFile" accept="image/*">
|
||||||
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🎨 提交重绘</button>
|
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🎨 生成 final+遮罩</button>
|
||||||
|
<button class="btn btn-green" id="redrawBtn" onclick="runLocalRedraw()" disabled>🧪 重绘</button>
|
||||||
|
</div>
|
||||||
|
<div class="upload-row" style="margin-top:10px">
|
||||||
|
<label style="font-size:13px;font-weight:600;white-space:nowrap">重绘提示词</label>
|
||||||
|
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||||
</div>
|
</div>
|
||||||
<div class="upload-row" style="margin-top:10px">
|
<div class="upload-row" style="margin-top:10px">
|
||||||
<label style="font-size:13px;font-weight:600;white-space:nowrap">X-Internal-Token</label>
|
<label style="font-size:13px;font-weight:600;white-space:nowrap">X-Internal-Token</label>
|
||||||
@@ -99,7 +106,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="pf">
|
<div class="pf">
|
||||||
<label>color_match <span class="desc">融合前颜色迁移(消除色差)</span></label>
|
<label>color_match <span class="desc">融合前颜色迁移(消除色差)</span></label>
|
||||||
<div class="row"><input type="checkbox" id="colorMatch" checked style="width:18px;height:18px"><span class="desc">multiband/feather 生效;seamless/two_stage 自带调色</span></div>
|
<div class="row"><input type="checkbox" id="colorMatch" style="width:18px;height:18px"><span class="desc">multiband/feather 生效;seamless/two_stage 自带调色</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pf">
|
<div class="pf">
|
||||||
<label>color_match_strength <span class="desc">颜色迁移强度(0~1)</span></label>
|
<label>color_match_strength <span class="desc">颜色迁移强度(0~1)</span></label>
|
||||||
@@ -119,23 +126,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pf" style="margin-top:14px">
|
<div class="pf" style="margin-top:14px">
|
||||||
<label>comfyui_prompt <span class="desc">Flux-2 提示词,留空=默认「补充遮罩区域的头发,加一点美颜」</span></label>
|
<label>comfyui_prompt <span class="desc">已下线(后端不再做 Flux-2 重绘);重绘提示词改用上方「重绘提示词」输入框</span></label>
|
||||||
<textarea id="comfyuiPrompt" rows="2" style="width:100%;padding:8px;border:1px solid #ddd;border-radius:8px;font-size:13px;font-family:inherit;resize:vertical" placeholder="留空用默认提示词"></textarea>
|
<textarea id="comfyuiPrompt" rows="2" style="width:100%;padding:8px;border:1px solid #ddd;border-radius:8px;font-size:13px;font-family:inherit;resize:vertical" placeholder="已下线,留空即可"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:12px;color:#888;margin-top:8px">遮罩固定 pushed,重绘固定 Flux-2;隐藏参数(seg_model/hairline_edge/is_hr/edge_erode_px/mb_feather_px/transition_band_px/inpainting_fill/mask_blur/mask_dilate_scale)按原默认值随请求提交。换发型走 GPU + Flux-2 重绘,单次约 15~30s。</div>
|
<div style="font-size:12px;color:#888;margin-top:8px">遮罩固定 pushed;后端只产出 final+纯红遮罩(不做重绘)。隐藏参数(seg_model/hairline_edge/is_hr/edge_erode_px/mb_feather_px/transition_band_px/inpainting_fill/mask_blur/mask_dilate_scale)按默认值随请求提交。beauty_alpha/comfyui_prompt 已不再生效。</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="status hidden" id="statusBar"></div>
|
<div class="status hidden" id="statusBar"></div>
|
||||||
|
|
||||||
<div id="resultsArea" class="hidden">
|
<div id="resultsArea" class="hidden">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 style="margin-top:0">🎯 两版重绘对比</h2>
|
<h2 style="margin-top:0">🎯 final + 纯红遮罩 → 重绘结果</h2>
|
||||||
<div class="steps" style="grid-template-columns: repeat(auto-fill, minmax(300px, 1fr))">
|
<div class="steps" style="grid-template-columns: repeat(auto-fill, minmax(300px, 1fr))">
|
||||||
<div class="step big"><div class="cap">接口11 final <small>④ 接缝融合(重绘输入基底,无美颜)</small></div><img id="finalBase"></div>
|
<div class="step big"><div class="cap">接口11 final <small>④ 接缝融合(重绘输入基底,无美颜)</small></div><img id="finalBase"></div>
|
||||||
<div class="step big"><div class="cap">A · 整帧重绘 <small>全脸美颜 + 全脸重绘(=手动 ComfyUI)</small></div><img id="outFull"></div>
|
<div class="step big"><div class="cap">⑤-① 发际线重绘带遮罩 <small>纯红 alpha PNG(遮罩区=红+不透明,其余全透明)</small></div><img id="maskPng"></div>
|
||||||
<div class="step big"><div class="cap">B · 局部加发+全脸美颜 <small>加发只在发际线带、美颜保留全脸</small></div><img id="outBand"></div>
|
<div class="step big"><div class="cap">🧪 重绘结果 <small>final + 遮罩 → ComfyUI(0716add-hair)</small></div><img id="localResult"></div>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:12px;color:#888;margin-top:10px">对比要点:A 会重绘整张脸(五官/发型都可能变);B 只在发际线带加发、其余区域保留 final 结构并叠加全脸美颜(强度由 beauty_alpha 控制)。</div>
|
<div style="font-size:12px;color:#888;margin-top:10px">流程:后端产出 final(接缝融合基底)+ 纯红遮罩 PNG,再调后端 /api/v1/redraw 完成发际线补发。</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -180,10 +187,13 @@ const STEPS = [
|
|||||||
{ key: 'input', title: '原图', sub: '接口输入' },
|
{ key: 'input', title: '原图', sub: '接口输入' },
|
||||||
{ key: 'final', title: '接口11 ④ final', sub: '接缝融合最终图(重绘输入基底,无美颜)' },
|
{ key: 'final', title: '接口11 ④ final', sub: '接缝融合最终图(重绘输入基底,无美颜)' },
|
||||||
{ key: 'redraw_band_overlay', title: '⑤-① 发际线重绘带', sub: '紫=lo×push↔hi×push之间、经 baseline 截断只留上部' },
|
{ key: 'redraw_band_overlay', title: '⑤-① 发际线重绘带', sub: '紫=lo×push↔hi×push之间、经 baseline 截断只留上部' },
|
||||||
{ key: 'redraw_full', title: 'A · 整帧重绘(Flux-2)', sub: '全脸美颜+全脸重绘(=手动 ComfyUI)' },
|
{ key: 'redraw_band_mask', title: '⑤-② 纯红遮罩 PNG', sub: '遮罩区=(255,0,0,255)、其余全透明;交给后端重绘' },
|
||||||
{ key: 'redraw_band', title: 'B · 局部加发+全脸美颜', sub: 'band内=ComfyUI加发;band外=final+beauty_alpha美颜' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 缓存最近一次后端返回的 final(JPG data URI)和纯红遮罩(PNG data URI),供重绘使用
|
||||||
|
let _finalDataUri = '';
|
||||||
|
let _maskDataUri = '';
|
||||||
|
|
||||||
function $(id) { return document.getElementById(id); }
|
function $(id) { return document.getElementById(id); }
|
||||||
function setStatus(text, type) { const b = $('statusBar'); b.textContent = text; b.className = 'status ' + type; }
|
function setStatus(text, type) { const b = $('statusBar'); b.textContent = text; b.className = 'status ' + type; }
|
||||||
function pick(obj, name) { if (!obj) return null; return obj[name + '_url'] || obj[name + '_base64'] || null; }
|
function pick(obj, name) { if (!obj) return null; return obj[name + '_url'] || obj[name + '_base64'] || null; }
|
||||||
@@ -200,19 +210,20 @@ function stepCard(title, sub, src) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderResult(d) {
|
function renderResult(d) {
|
||||||
flog('renderResult 开始 (blend=' + (d.blend_method || '?') + ', beauty_alpha=' + d.beauty_alpha + ')', 'info');
|
flog('renderResult 开始 (blend=' + (d.blend_method || '?') + ')', 'info');
|
||||||
const s = d.steps || {};
|
const s = d.steps || {};
|
||||||
$('finalBase').src = pick(s, 'final') || '';
|
const finalSrc = pick(s, 'final') || '';
|
||||||
|
const maskSrc = pick(s, 'redraw_band_mask') || '';
|
||||||
|
_finalDataUri = finalSrc;
|
||||||
|
_maskDataUri = maskSrc;
|
||||||
|
$('finalBase').src = finalSrc;
|
||||||
$('finalBase').onclick = function(){ zoom(this.src); };
|
$('finalBase').onclick = function(){ zoom(this.src); };
|
||||||
$('outFull').src = pick(s, 'redraw_full') || pick(s, 'redraw_c') || '';
|
$('maskPng').src = maskSrc;
|
||||||
$('outFull').onclick = function(){ zoom(this.src); };
|
$('maskPng').onclick = function(){ zoom(this.src); };
|
||||||
$('outBand').src = pick(s, 'redraw_band') || '';
|
|
||||||
$('outBand').onclick = function(){ zoom(this.src); };
|
|
||||||
const grid = $('stepsGrid'); grid.innerHTML = '';
|
const grid = $('stepsGrid'); grid.innerHTML = '';
|
||||||
$('stepsInfo').textContent = '(mask=pushed, blend=' + (d.blend_method || '?') + ', redraw=Flux-2, beauty_alpha=' + d.beauty_alpha + ')';
|
$('stepsInfo').textContent = '(mask=pushed, blend=' + (d.blend_method || '?') + ')';
|
||||||
if (d.redraw && d.redraw.enabled) {
|
if (d.redraw && d.redraw.enabled) {
|
||||||
flog('重绘带 band_pixels=' + d.redraw.band_pixels + ' push_px=' + d.redraw.push_px, 'info');
|
flog('重绘带 band_pixels=' + d.redraw.band_pixels + ' push_px=' + d.redraw.push_px, 'info');
|
||||||
if (d.redraw.c_error) flog('Flux-2 重绘失败: ' + d.redraw.c_error, 'warn');
|
|
||||||
} else if (d.redraw && d.redraw.error) {
|
} else if (d.redraw && d.redraw.error) {
|
||||||
flog('重绘带计算失败: ' + d.redraw.error, 'warn');
|
flog('重绘带计算失败: ' + d.redraw.error, 'warn');
|
||||||
}
|
}
|
||||||
@@ -222,12 +233,16 @@ function renderResult(d) {
|
|||||||
flog(' 渲染 ' + st.key + ': ' + (hasImg ? '有图(' + src.length + '字符)' : '无图'), hasImg ? 'info' : 'warn');
|
flog(' 渲染 ' + st.key + ': ' + (hasImg ? '有图(' + src.length + '字符)' : '无图'), hasImg ? 'info' : 'warn');
|
||||||
grid.appendChild(stepCard(st.title, st.sub, src));
|
grid.appendChild(stepCard(st.title, st.sub, src));
|
||||||
});
|
});
|
||||||
flog('renderResult 完成', 'info');
|
// final + 遮罩 都有 → 允许调后端重绘
|
||||||
|
const canRedraw = !!(finalSrc && maskSrc);
|
||||||
|
$('redrawBtn').disabled = !canRedraw;
|
||||||
|
flog('renderResult 完成 canRedraw=' + canRedraw, 'info');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitTest() {
|
async function submitTest() {
|
||||||
const file = $('imageFile').files[0];
|
let file = $('imageFile').files[0];
|
||||||
if (!file) { setStatus('请先选择图片', 'error'); return; }
|
if (!file) { setStatus('请先选择图片', 'error'); return; }
|
||||||
|
file = await window.downscaleImageFile(file);
|
||||||
// 页面隐藏但仍提交的固定默认值
|
// 页面隐藏但仍提交的固定默认值
|
||||||
const HIDDEN = {
|
const HIDDEN = {
|
||||||
seg_model: 'segformer',
|
seg_model: 'segformer',
|
||||||
@@ -254,8 +269,8 @@ async function submitTest() {
|
|||||||
flog('隐藏参数固定: ' + JSON.stringify(HIDDEN), 'info');
|
flog('隐藏参数固定: ' + JSON.stringify(HIDDEN), 'info');
|
||||||
|
|
||||||
const btn = $('submitBtn');
|
const btn = $('submitBtn');
|
||||||
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
|
btn.disabled = true; btn.textContent = '⏳ 生成中...';
|
||||||
setStatus('正在请求(内部先跑接口11,再 Flux-2 重绘,约 15~30s)...', 'info');
|
setStatus('正在请求(内部跑接口11 生成 final + 纯红遮罩,约 10~15s)...', 'info');
|
||||||
$('resultsArea').classList.remove('hidden');
|
$('resultsArea').classList.remove('hidden');
|
||||||
|
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
@@ -305,15 +320,15 @@ async function submitTest() {
|
|||||||
if (json.code === 0) {
|
if (json.code === 0) {
|
||||||
const d = json.data;
|
const d = json.data;
|
||||||
flog('后端返回 blend=' + d.blend_method + ' hairline_push_cm=' + d.hairline_push_cm + ' mask_pixels=' + d.mask_pixels, 'info');
|
flog('后端返回 blend=' + d.blend_method + ' hairline_push_cm=' + d.hairline_push_cm + ' mask_pixels=' + d.mask_pixels, 'info');
|
||||||
flog('后端返回 comfyui_prompt=' + d.comfyui_prompt, 'info');
|
flog('后端返回 _rid=' + d._rid, 'info');
|
||||||
flog('后端 _rid=' + d._rid, 'info');
|
|
||||||
const s = d.steps || {};
|
const s = d.steps || {};
|
||||||
Object.keys(s).filter(k => k.endsWith('_base64')).forEach(k => {
|
Object.keys(s).filter(k => k.endsWith('_base64')).forEach(k => {
|
||||||
const len = s[k] ? s[k].length : 0;
|
const len = s[k] ? s[k].length : 0;
|
||||||
flog(' steps.' + k + ' = ' + (len > 0 ? len + '字符' : '空'), len > 0 ? 'info' : 'warn');
|
flog(' steps.' + k + ' = ' + (len > 0 ? len + '字符' : '空'), len > 0 ? 'info' : 'warn');
|
||||||
});
|
});
|
||||||
const okRedraw = d.redraw && d.redraw.enabled && !d.redraw.c_error && (pick(s,'redraw_full'));
|
const hasFinal = !!(pick(s, 'final'));
|
||||||
setStatus((okRedraw ? '✅ 重绘成功(A整帧 / B局部+美颜)' : '⚠️ 已返回(重绘可能未生效,见日志)') + ' (' + dt + 's) _rid=' + d._rid, okRedraw ? 'success' : 'error');
|
const hasMask = !!(pick(s, 'redraw_band_mask'));
|
||||||
|
setStatus((hasFinal && hasMask ? '✅ 已生成 final + 纯红遮罩' : '⚠️ 已返回(final/遮罩缺失,见日志)') + ' (' + dt + 's) _rid=' + d._rid, (hasFinal && hasMask) ? 'success' : 'error');
|
||||||
renderResult(d);
|
renderResult(d);
|
||||||
} else {
|
} else {
|
||||||
setStatus('❌ 业务错误 code=' + json.code + ':' + json.message, 'error');
|
setStatus('❌ 业务错误 code=' + json.code + ':' + json.message, 'error');
|
||||||
@@ -323,7 +338,55 @@ async function submitTest() {
|
|||||||
setStatus('❌ 网络错误: ' + err.message, 'error');
|
setStatus('❌ 网络错误: ' + err.message, 'error');
|
||||||
flog('网络错误: ' + err.message, 'error');
|
flog('网络错误: ' + err.message, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false; btn.textContent = '🎨 提交重绘';
|
btn.disabled = false; btn.textContent = '🎨 生成 final+遮罩';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// data URI → Blob,用于把后端返回的 final/遮罩图作为文件 POST 给后端重绘
|
||||||
|
function dataUriToBlob(dataUri) {
|
||||||
|
const [meta, b64] = dataUri.split(',');
|
||||||
|
const mime = (meta.match(/data:([^;]+)/) || [, 'application/octet-stream'])[1];
|
||||||
|
const bin = atob(b64);
|
||||||
|
const arr = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
||||||
|
return new Blob([arr], { type: mime });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runLocalRedraw() {
|
||||||
|
if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; }
|
||||||
|
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜';
|
||||||
|
const btn = $('redrawBtn');
|
||||||
|
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
|
||||||
|
flog('===== 调后端重绘 =====', 'info');
|
||||||
|
flog('提示词=' + prompt, 'info');
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('image_file', dataUriToBlob(_finalDataUri), 'final.jpg');
|
||||||
|
form.append('mask_file', dataUriToBlob(_maskDataUri), 'mask.png');
|
||||||
|
form.append('prompt', prompt);
|
||||||
|
|
||||||
|
const t0 = performance.now();
|
||||||
|
try {
|
||||||
|
flog('fetch POST ' + API_BASE + '/api/v1/redraw', 'info');
|
||||||
|
const resp = await fetch(API_BASE + '/api/v1/redraw', { method: 'POST', body: form });
|
||||||
|
flog('收到响应 http=' + resp.status, resp.ok ? 'info' : 'error');
|
||||||
|
const dt = ((performance.now() - t0) / 1000).toFixed(2);
|
||||||
|
const json = await resp.json();
|
||||||
|
if (json.code === 0 && json.data && json.data.image_base64) {
|
||||||
|
$('localResult').src = json.data.image_base64;
|
||||||
|
$('localResult').onclick = function(){ zoom(this.src); };
|
||||||
|
setStatus('✅ 重绘完成 (' + dt + 's)', 'success');
|
||||||
|
flog('重绘完成 耗时=' + dt + 's', 'info');
|
||||||
|
} else {
|
||||||
|
const msg = json.message || JSON.stringify(json);
|
||||||
|
setStatus('❌ 重绘失败:' + msg, 'error');
|
||||||
|
flog('重绘失败: ' + msg, 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setStatus('❌ 重绘网络错误:' + err.message, 'error');
|
||||||
|
flog('重绘网络错误: ' + err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false; btn.textContent = '🧪 重绘';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,8 +417,8 @@ async function downloadBackendLog() {
|
|||||||
|
|
||||||
$('imageFile').addEventListener('change', function(){ if(this.files.length) flog('选择图片: ' + this.files[0].name, 'info'); });
|
$('imageFile').addEventListener('change', function(){ if(this.files.length) flog('选择图片: ' + this.files[0].name, 'info'); });
|
||||||
|
|
||||||
flog('接口12 重绘测试页加载完成(两版对比:A整帧 / B局部加发+全脸美颜)', 'info');
|
flog('接口12 重绘测试页加载完成(后端产出 final+纯红遮罩 → 后端 /api/v1/redraw 重绘)', 'info');
|
||||||
flog('默认: hairline=chang_bolang, push=0.8, blend=two_stage, cm_strength=0.4, beauty_alpha=0.6;需 ComfyUI(:8188) 在跑', 'info');
|
flog('默认: hairline=chang_bolang, push=0.8, blend=two_stage, cm_strength=0.4;需 ComfyUI(:8188) 在跑', 'info');
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -37,20 +37,26 @@
|
|||||||
.lightbox img { max-width: 95%; max-height: 95%; }
|
.lightbox img { max-width: 95%; max-height: 95%; }
|
||||||
.hidden { display: none; }
|
.hidden { display: none; }
|
||||||
</style>
|
</style>
|
||||||
|
<script src="/static/img_downscale.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>接口12 final — 发际线带重绘 <span style="font-size:13px;color:#888">(精简版)</span></h1>
|
<h1>接口12 final — 发际线带重绘 <span style="font-size:13px;color:#888">(精简版:final + 纯红遮罩 → ComfyUI 重绘)</span></h1>
|
||||||
<div class="subtitle">
|
<div class="subtitle">
|
||||||
只需上传图片 + 选择发型,其余参数全部用当前调优默认值(<code>/api/v1/hairline/grow_v2_final</code>)。<br>
|
只需上传图片 + 选择发型,其余参数全部用当前调优默认值(<code>/api/v1/hairline/grow_v2_final</code>)。<br>
|
||||||
重绘输出为<b>整帧重绘</b>(全脸美颜 + 全脸重绘,=手动 ComfyUI)。⚠️ 需 ComfyUI(:8188) 在跑。
|
后端产出 <b>④ final(接缝融合基底)</b> + <b>⑤-② 纯红遮罩 PNG</b>,再调 <b>后端重绘接口(/api/v1/redraw)</b>完成发际线带重绘。⚠️ 需 ComfyUI(:8188) 在跑。
|
||||||
局部加发版见 <a href="/static/test_interface12_final_v2.html">接口12 final v2</a>;完整参数调试见 <a href="/static/test_interface12.html">接口12 调试页</a>。
|
局部加发版见 <a href="/static/test_interface12_final_v2.html">接口12 final v2</a>;完整参数调试见 <a href="/static/test_interface12.html">接口12 调试页</a>。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="upload-row">
|
<div class="upload-row">
|
||||||
<input type="file" id="imageFile" accept="image/*">
|
<input type="file" id="imageFile" accept="image/*">
|
||||||
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🎨 提交重绘</button>
|
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🎨 生成 final+遮罩</button>
|
||||||
|
<button class="btn btn-green" id="redrawBtn" onclick="runLocalRedraw()" disabled>🧪 重绘</button>
|
||||||
|
</div>
|
||||||
|
<div class="upload-row" style="margin-top:10px">
|
||||||
|
<label style="font-size:13px;font-weight:600;white-space:nowrap">重绘提示词</label>
|
||||||
|
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||||
</div>
|
</div>
|
||||||
<div class="params">
|
<div class="params">
|
||||||
<div class="pf">
|
<div class="pf">
|
||||||
@@ -70,11 +76,13 @@
|
|||||||
|
|
||||||
<div id="resultsArea" class="hidden">
|
<div id="resultsArea" class="hidden">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 style="margin-top:0">🎯 整帧重绘结果</h2>
|
<h2 style="margin-top:0">🎯 final + 纯红遮罩 → 重绘结果</h2>
|
||||||
<div class="steps" style="grid-template-columns: repeat(auto-fill, minmax(300px, 1fr))">
|
<div class="steps" style="grid-template-columns: repeat(auto-fill, minmax(300px, 1fr))">
|
||||||
<div class="step big"><div class="cap">接口11 final <small>④ 接缝融合(重绘输入基底,无美颜)</small></div><img id="finalBase"></div>
|
<div class="step big"><div class="cap">接口11 final <small>④ 接缝融合(重绘输入基底,无美颜)</small></div><img id="finalBase"></div>
|
||||||
<div class="step big"><div class="cap">整帧重绘 <small>全脸美颜 + 全脸重绘(=手动 ComfyUI)</small></div><img id="outFull"></div>
|
<div class="step big"><div class="cap">⑤-① 发际线重绘带遮罩 <small>纯红 alpha PNG(遮罩区=红+不透明,其余全透明)</small></div><img id="maskPng"></div>
|
||||||
|
<div class="step big"><div class="cap">🧪 重绘结果 <small>final + 遮罩 → ComfyUI(0716add-hair)</small></div><img id="localResult"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="font-size:12px;color:#888;margin-top:10px">流程:后端产出 final(接缝融合基底)+ 纯红遮罩 PNG,再调后端 /api/v1/redraw 完成发际线补发。</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,6 +94,10 @@ const API_BASE = window.location.origin;
|
|||||||
const ENDPOINT = '/api/v1/hairline/grow_v2_final';
|
const ENDPOINT = '/api/v1/hairline/grow_v2_final';
|
||||||
const TOKEN = 'dev-shared-secret-2026';
|
const TOKEN = 'dev-shared-secret-2026';
|
||||||
|
|
||||||
|
// 缓存最近一次后端返回的 final(data URI)和纯红遮罩(data URI),供重绘使用
|
||||||
|
let _finalDataUri = '';
|
||||||
|
let _maskDataUri = '';
|
||||||
|
|
||||||
function $(id) { return document.getElementById(id); }
|
function $(id) { return document.getElementById(id); }
|
||||||
function setStatus(text, type) { const b = $('statusBar'); b.textContent = text; b.className = 'status ' + type; }
|
function setStatus(text, type) { const b = $('statusBar'); b.textContent = text; b.className = 'status ' + type; }
|
||||||
function pick(obj, name) { if (!obj) return null; return obj[name + '_url'] || obj[name + '_base64'] || null; }
|
function pick(obj, name) { if (!obj) return null; return obj[name + '_url'] || obj[name + '_base64'] || null; }
|
||||||
@@ -93,19 +105,36 @@ function zoom(src) { $('lightboxImg').src = src; $('lightbox').style.display = '
|
|||||||
|
|
||||||
function renderResult(d) {
|
function renderResult(d) {
|
||||||
const s = d.steps || {};
|
const s = d.steps || {};
|
||||||
$('finalBase').src = pick(s, 'final') || '';
|
const finalSrc = pick(s, 'final') || '';
|
||||||
|
const maskSrc = pick(s, 'redraw_band_mask') || '';
|
||||||
|
_finalDataUri = finalSrc;
|
||||||
|
_maskDataUri = maskSrc;
|
||||||
|
$('finalBase').src = finalSrc;
|
||||||
$('finalBase').onclick = function(){ zoom(this.src); };
|
$('finalBase').onclick = function(){ zoom(this.src); };
|
||||||
$('outFull').src = pick(s, 'redraw_full') || pick(s, 'redraw_c') || '';
|
$('maskPng').src = maskSrc;
|
||||||
$('outFull').onclick = function(){ zoom(this.src); };
|
$('maskPng').onclick = function(){ zoom(this.src); };
|
||||||
|
const canRedraw = !!(finalSrc && maskSrc);
|
||||||
|
$('redrawBtn').disabled = !canRedraw;
|
||||||
|
}
|
||||||
|
|
||||||
|
// data URI → Blob,用于把后端返回的 final/遮罩图作为文件 POST 给后端重绘
|
||||||
|
function dataUriToBlob(dataUri) {
|
||||||
|
const [meta, b64] = dataUri.split(',');
|
||||||
|
const mime = (meta.match(/data:([^;]+)/) || [, 'application/octet-stream'])[1];
|
||||||
|
const bin = atob(b64);
|
||||||
|
const arr = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
||||||
|
return new Blob([arr], { type: mime });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitTest() {
|
async function submitTest() {
|
||||||
const file = $('imageFile').files[0];
|
let file = $('imageFile').files[0];
|
||||||
if (!file) { setStatus('请先选择图片', 'error'); return; }
|
if (!file) { setStatus('请先选择图片', 'error'); return; }
|
||||||
|
file = await window.downscaleImageFile(file);
|
||||||
|
|
||||||
const btn = $('submitBtn');
|
const btn = $('submitBtn');
|
||||||
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
|
btn.disabled = true; btn.textContent = '⏳ 生成中...';
|
||||||
setStatus('正在请求(内部先跑接口11,再 Flux-2 重绘,约 15~30s)...', 'info');
|
setStatus('正在请求(内部跑接口11 生成 final + 纯红遮罩,约 10~15s)...', 'info');
|
||||||
$('resultsArea').classList.remove('hidden');
|
$('resultsArea').classList.remove('hidden');
|
||||||
|
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
@@ -124,8 +153,9 @@ async function submitTest() {
|
|||||||
|
|
||||||
if (json.code === 0) {
|
if (json.code === 0) {
|
||||||
const d = json.data;
|
const d = json.data;
|
||||||
const okRedraw = d.redraw && d.redraw.enabled && !d.redraw.c_error && pick(d.steps, 'redraw_full');
|
const hasFinal = !!(pick(d.steps, 'final'));
|
||||||
setStatus((okRedraw ? '✅ 整帧重绘成功' : '⚠️ 已返回(重绘可能未生效)') + ' (' + dt + 's)', okRedraw ? 'success' : 'error');
|
const hasMask = !!(pick(d.steps, 'redraw_band_mask'));
|
||||||
|
setStatus((hasFinal && hasMask ? '✅ 已生成 final + 纯红遮罩' : '⚠️ 已返回(final/遮罩缺失,见日志)') + ' (' + dt + 's)', (hasFinal && hasMask) ? 'success' : 'error');
|
||||||
renderResult(d);
|
renderResult(d);
|
||||||
} else {
|
} else {
|
||||||
setStatus('❌ 业务错误 code=' + json.code + ':' + json.message, 'error');
|
setStatus('❌ 业务错误 code=' + json.code + ':' + json.message, 'error');
|
||||||
@@ -133,7 +163,40 @@ async function submitTest() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus('❌ 网络错误: ' + err.message, 'error');
|
setStatus('❌ 网络错误: ' + err.message, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false; btn.textContent = '🎨 提交重绘';
|
btn.disabled = false; btn.textContent = '🎨 生成 final+遮罩';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runLocalRedraw() {
|
||||||
|
if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; }
|
||||||
|
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜';
|
||||||
|
const btn = $('redrawBtn');
|
||||||
|
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('image_file', dataUriToBlob(_finalDataUri), 'final.jpg');
|
||||||
|
form.append('mask_file', dataUriToBlob(_maskDataUri), 'mask.png');
|
||||||
|
form.append('prompt', prompt);
|
||||||
|
|
||||||
|
const t0 = performance.now();
|
||||||
|
try {
|
||||||
|
const resp = await fetch(API_BASE + '/api/v1/redraw', {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
const dt = ((performance.now() - t0) / 1000).toFixed(2);
|
||||||
|
const json = await resp.json();
|
||||||
|
if (json.code === 0 && json.data && json.data.image_base64) {
|
||||||
|
$('localResult').src = json.data.image_base64;
|
||||||
|
$('localResult').onclick = function(){ zoom(this.src); };
|
||||||
|
setStatus('✅ 重绘完成 (' + dt + 's)', 'success');
|
||||||
|
} else {
|
||||||
|
setStatus('❌ 重绘失败:' + (json.message || JSON.stringify(json)), 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setStatus('❌ 重绘网络错误:' + err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false; btn.textContent = '🧪 重绘';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
.lightbox img { max-width: 95%; max-height: 95%; }
|
.lightbox img { max-width: 95%; max-height: 95%; }
|
||||||
.hidden { display: none; }
|
.hidden { display: none; }
|
||||||
</style>
|
</style>
|
||||||
|
<script src="/static/img_downscale.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -100,8 +101,9 @@ function renderResult(d) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitTest() {
|
async function submitTest() {
|
||||||
const file = $('imageFile').files[0];
|
let file = $('imageFile').files[0];
|
||||||
if (!file) { setStatus('请先选择图片', 'error'); return; }
|
if (!file) { setStatus('请先选择图片', 'error'); return; }
|
||||||
|
file = await window.downscaleImageFile(file);
|
||||||
|
|
||||||
const btn = $('submitBtn');
|
const btn = $('submitBtn');
|
||||||
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
|
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
|
||||||
|
|||||||
@@ -69,6 +69,7 @@
|
|||||||
|
|
||||||
.hidden { display: none !important; }
|
.hidden { display: none !important; }
|
||||||
</style>
|
</style>
|
||||||
|
<script src="/static/img_downscale.js?v=2"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -106,7 +107,7 @@
|
|||||||
<div class="hint">JPG/PNG | 生发图生成较慢(数十秒~数分钟),请耐心等待</div>
|
<div class="hint">JPG/PNG | 生发图生成较慢(数十秒~数分钟),请耐心等待</div>
|
||||||
<div style="margin-top:10px;display:flex;align-items:center;gap:8px">
|
<div style="margin-top:10px;display:flex;align-items:center;gap:8px">
|
||||||
<label style="font-size:13px;font-weight:600;color:#374151;white-space:nowrap">💬 提示词</label>
|
<label style="font-size:13px;font-weight:600;color:#374151;white-space:nowrap">💬 提示词</label>
|
||||||
<input type="text" id="promptInput" value="补充遮罩区域的头发,加一点美颜" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
|
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
|
||||||
</div>
|
</div>
|
||||||
<div id="statusBar" class="status hidden"></div>
|
<div id="statusBar" class="status hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,9 +141,9 @@ function $(id) { return document.getElementById(id); }
|
|||||||
function setStatus(t, type) { const b=$('statusBar'); b.textContent=t; b.className='status '+type; }
|
function setStatus(t, type) { const b=$('statusBar'); b.textContent=t; b.className='status '+type; }
|
||||||
|
|
||||||
async function submitTest() {
|
async function submitTest() {
|
||||||
const f = $('imageFile').files[0];
|
let f = $('imageFile').files[0];
|
||||||
if (!f) { setStatus('请选择图片', 'error'); return; }
|
if (!f) { setStatus('请选择图片', 'error'); return; }
|
||||||
|
f = await window.downscaleImageFile(f);
|
||||||
|
|
||||||
_origUrl = URL.createObjectURL(f);
|
_origUrl = URL.createObjectURL(f);
|
||||||
$('submitBtn').disabled = true; $('submitBtn').textContent = '⏳ 请求中...';
|
$('submitBtn').disabled = true; $('submitBtn').textContent = '⏳ 请求中...';
|
||||||
@@ -179,9 +180,9 @@ function renderSchemes(results) {
|
|||||||
let html = '';
|
let html = '';
|
||||||
results.forEach((r, i) => {
|
results.forEach((r, i) => {
|
||||||
const lb = TYPE_LABELS[r.hairline_type] || r.hairline_type;
|
const lb = TYPE_LABELS[r.hairline_type] || r.hairline_type;
|
||||||
// 直连 worker 时后端返回 base64(网关才会改写为 *_url),URL 缺失则回退 data URI
|
// 兼容网关 *_url 与 worker *_base64(原始或 data URI)
|
||||||
const overlaySrc = r.image_url || (r.image_base64 ? 'data:image/png;base64,' + r.image_base64 : '');
|
const overlaySrc = resolveImgSrc(r.image_url, r.image_base64, 'image/png');
|
||||||
const grownSrc = r.grown_image_url || (r.grown_image_base64 ? 'data:image/jpeg;base64,' + r.grown_image_base64 : '');
|
const grownSrc = resolveImgSrc(r.grown_image_url, r.grown_image_base64, 'image/jpeg');
|
||||||
|
|
||||||
// 原图
|
// 原图
|
||||||
const origSlot = '<div class="img-slot">'+
|
const origSlot = '<div class="img-slot">'+
|
||||||
@@ -205,7 +206,7 @@ function renderSchemes(results) {
|
|||||||
} else {
|
} else {
|
||||||
grownSlot = '<div class="img-slot">'+
|
grownSlot = '<div class="img-slot">'+
|
||||||
'<div class="label"><span class="dot grown"></span>生发效果</div>'+
|
'<div class="label"><span class="dot grown"></span>生发效果</div>'+
|
||||||
'<div class="na">⚠ 未返回<br><span style="font-size:10px;color:#9ca3af">ComfyUI 未就绪或生成失败</span></div></div>';
|
'<div class="na">⚠ 未返回<br><span style="font-size:10px;color:#9ca3af">生发失败(female=换发型/重绘,male=ComfyUI)</span></div></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
html += '<div class="scheme-card">'+
|
html += '<div class="scheme-card">'+
|
||||||
@@ -236,12 +237,16 @@ const HAIR_STYLES = {
|
|||||||
{ value:'3', label:'heart 心形' },
|
{ value:'3', label:'heart 心形' },
|
||||||
{ value:'4', label:'straight 直线形' },
|
{ value:'4', label:'straight 直线形' },
|
||||||
{ value:'5', label:'wave 波浪形' },
|
{ value:'5', label:'wave 波浪形' },
|
||||||
|
{ value:'6', label:'bigflower 大花瓣型' },
|
||||||
|
{ value:'7', label:'clasicalflower 古典花瓣型' },
|
||||||
],
|
],
|
||||||
male: [
|
male: [
|
||||||
{ value:'1', label:'ellipse 椭圆形' },
|
{ value:'1', label:'ellipse 椭圆形' },
|
||||||
{ value:'2', label:'inverse_arc 倒弧形' },
|
{ value:'2', label:'inverse_arc 倒弧形' },
|
||||||
{ value:'3', label:'m M形' },
|
{ value:'3', label:'m M形' },
|
||||||
{ value:'4', label:'straight 直线形' },
|
{ value:'4', label:'straight 直线形' },
|
||||||
|
{ value:'5', label:'heart 桃心形' },
|
||||||
|
{ value:'6', label:'Softpetal 柔和花瓣形' },
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,7 @@
|
|||||||
|
|
||||||
@media (max-width: 800px) { .results-layout, .preview-row { flex-direction: column; } }
|
@media (max-width: 800px) { .results-layout, .preview-row { flex-direction: column; } }
|
||||||
</style>
|
</style>
|
||||||
|
<script src="/static/img_downscale.js?v=2"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -93,7 +94,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="upload-group" style="margin-top:14px">
|
<div class="upload-group" style="margin-top:14px">
|
||||||
<div class="label">💬 提示词(prompt)</div>
|
<div class="label">💬 提示词(prompt)</div>
|
||||||
<input type="text" id="promptInput" value="补充遮罩区域的头发,加一点美颜" style="width:100%;padding:8px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:14px;max-width:500px">
|
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="width:100%;padding:8px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:14px;max-width:500px">
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top:14px;display:flex;gap:12px;align-items:center">
|
<div style="margin-top:14px;display:flex;gap:12px;align-items:center">
|
||||||
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交</button>
|
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交</button>
|
||||||
@@ -139,9 +140,9 @@ function $(id) { return document.getElementById(id); }
|
|||||||
function setStatus(t, type) { const b=$('statusBar'); b.textContent=t; b.className='status '+type; }
|
function setStatus(t, type) { const b=$('statusBar'); b.textContent=t; b.className='status '+type; }
|
||||||
|
|
||||||
async function submitTest() {
|
async function submitTest() {
|
||||||
const mf = $('markedFile').files[0];
|
let mf = $('markedFile').files[0];
|
||||||
if (!mf) { setStatus('请选择划线图', 'error'); return; }
|
if (!mf) { setStatus('请选择划线图', 'error'); return; }
|
||||||
|
mf = await window.downscaleImageFile(mf);
|
||||||
|
|
||||||
const markedUrl = URL.createObjectURL(mf);
|
const markedUrl = URL.createObjectURL(mf);
|
||||||
// 先显示原图
|
// 先显示原图
|
||||||
@@ -161,7 +162,7 @@ async function submitTest() {
|
|||||||
|
|
||||||
const _reqStart = performance.now();
|
const _reqStart = performance.now();
|
||||||
try {
|
try {
|
||||||
const r = await fetch(API_BASE + '/api/v1/hair/grow-b', { method:'POST', body:fd });
|
const r = await fetch(API_BASE + '/api/v1/hair/grow-b', { method:'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd });
|
||||||
const json = await r.json();
|
const json = await r.json();
|
||||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||||
$('jsonContent').textContent = JSON.stringify(json, null, 2);
|
$('jsonContent').textContent = JSON.stringify(json, null, 2);
|
||||||
@@ -174,7 +175,7 @@ async function submitTest() {
|
|||||||
$('hairlineType').style.display = 'inline-block';
|
$('hairlineType').style.display = 'inline-block';
|
||||||
}
|
}
|
||||||
|
|
||||||
const grownSrc = d.hair_growth_image_url || d.hair_growth_image_base64;
|
const grownSrc = resolveImgSrc(d.hair_growth_image_url, d.hair_growth_image_base64, 'image/jpeg');
|
||||||
if (grownSrc) {
|
if (grownSrc) {
|
||||||
$('blendTop').src = grownSrc;
|
$('blendTop').src = grownSrc;
|
||||||
$('blendTop').style.display = 'block';
|
$('blendTop').style.display = 'block';
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
|
|
||||||
@media (max-width: 800px) { .results-layout { flex-direction: column; } }
|
@media (max-width: 800px) { .results-layout { flex-direction: column; } }
|
||||||
</style>
|
</style>
|
||||||
|
<script src="/static/img_downscale.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -121,8 +122,9 @@ function $(id) { return document.getElementById(id); }
|
|||||||
function setStatus(t, type) { const b=$('statusBar'); b.textContent=t; b.className='status '+type; }
|
function setStatus(t, type) { const b=$('statusBar'); b.textContent=t; b.className='status '+type; }
|
||||||
|
|
||||||
async function submitTest() {
|
async function submitTest() {
|
||||||
const f = $('imageFile').files[0];
|
let f = $('imageFile').files[0];
|
||||||
if (!f) { setStatus('请选择图片', 'error'); return; }
|
if (!f) { setStatus('请选择图片', 'error'); return; }
|
||||||
|
f = await window.downscaleImageFile(f);
|
||||||
|
|
||||||
$('imgPreview').innerHTML = '<img src="'+URL.createObjectURL(f)+'" alt="preview">';
|
$('imgPreview').innerHTML = '<img src="'+URL.createObjectURL(f)+'" alt="preview">';
|
||||||
$('submitBtn').disabled = true; $('submitBtn').textContent = '⏳ 分析中...';
|
$('submitBtn').disabled = true; $('submitBtn').textContent = '⏳ 分析中...';
|
||||||
@@ -131,7 +133,7 @@ async function submitTest() {
|
|||||||
const fd = new FormData(); fd.append('image_file', f);
|
const fd = new FormData(); fd.append('image_file', f);
|
||||||
const _reqStart = performance.now();
|
const _reqStart = performance.now();
|
||||||
try {
|
try {
|
||||||
const r = await fetch(API_BASE + '/api/v1/face/features', { method:'POST', body:fd });
|
const r = await fetch(API_BASE + '/api/v1/face/features', { method:'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd });
|
||||||
const json = await r.json();
|
const json = await r.json();
|
||||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||||
$('jsonContent').textContent = JSON.stringify(json, null, 2);
|
$('jsonContent').textContent = JSON.stringify(json, null, 2);
|
||||||
|
|||||||
@@ -88,6 +88,7 @@
|
|||||||
.hidden { display: none !important; }
|
.hidden { display: none !important; }
|
||||||
@media (max-width: 800px) { .results-layout { flex-direction: column; } }
|
@media (max-width: 800px) { .results-layout { flex-direction: column; } }
|
||||||
</style>
|
</style>
|
||||||
|
<script src="/static/img_downscale.js?v=2"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -100,7 +101,7 @@
|
|||||||
<div class="file-input"><input type="file" id="imageFile" accept="image/jpeg,image/png,.jpg,.jpeg,.png"></div>
|
<div class="file-input"><input type="file" id="imageFile" accept="image/jpeg,image/png,.jpg,.jpeg,.png"></div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>性别</label>
|
<label>性别</label>
|
||||||
<select id="gender" onchange="onGenderChange()"><option value="female" selected>👩 Female(5种)</option><option value="male">👨 Male(4种)</option></select>
|
<select id="gender" onchange="onGenderChange()"><option value="female" selected>👩 Female(7种)</option><option value="male">👨 Male(6种)</option></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="align-items:flex-start">
|
<div class="form-group" style="align-items:flex-start">
|
||||||
<label style="padding-top:3px">发型 *</label>
|
<label style="padding-top:3px">发型 *</label>
|
||||||
@@ -112,6 +113,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>生发效果图</label>
|
||||||
|
<label class="checkbox-inline" style="font-weight:normal;display:flex;align-items:center;gap:6px">
|
||||||
|
<input type="checkbox" id="genGrowImg" checked> generate_grow_image(默认开;关闭后跳过最耗时的生发,仅返回三档叠图与中心点)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交</button>
|
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交</button>
|
||||||
<button class="btn btn-outline btn-sm" onclick="clearResults()">清除</button>
|
<button class="btn btn-outline btn-sm" onclick="clearResults()">清除</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -167,12 +174,16 @@ const HAIR_STYLES = {
|
|||||||
{ value:'3', label:'3. heart 心形' },
|
{ value:'3', label:'3. heart 心形' },
|
||||||
{ value:'4', label:'4. straight 直线' },
|
{ value:'4', label:'4. straight 直线' },
|
||||||
{ value:'5', label:'5. wave 波浪' },
|
{ value:'5', label:'5. wave 波浪' },
|
||||||
|
{ value:'6', label:'6. bigflower 大花瓣型' },
|
||||||
|
{ value:'7', label:'7. clasicalflower 古典花瓣型' },
|
||||||
],
|
],
|
||||||
male: [
|
male: [
|
||||||
{ value:'1', label:'1. ellipse 椭圆' },
|
{ value:'1', label:'1. ellipse 椭圆' },
|
||||||
{ value:'2', label:'2. inverse_arc 倒弧' },
|
{ value:'2', label:'2. inverse_arc 倒弧' },
|
||||||
{ value:'3', label:'3. m M形' },
|
{ value:'3', label:'3. m M形' },
|
||||||
{ value:'4', label:'4. straight 直线' },
|
{ value:'4', label:'4. straight 直线' },
|
||||||
|
{ value:'5', label:'5. heart 桃心形' },
|
||||||
|
{ value:'6', label:'6. Softpetal 柔和花瓣形' },
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -195,19 +206,20 @@ function selectAllHair(select) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitTest() {
|
async function submitTest() {
|
||||||
const f = $('imageFile').files[0];
|
let f = $('imageFile').files[0];
|
||||||
if (!f) { setStatus('请选择图片', 'error'); return; }
|
if (!f) { setStatus('请选择图片', 'error'); return; }
|
||||||
const checked = [...document.querySelectorAll('#hairStyleGroup input:checked')].map(cb => cb.value);
|
const checked = [...document.querySelectorAll('#hairStyleGroup input:checked')].map(cb => cb.value);
|
||||||
if (!checked.length) { setStatus('请至少选择一个发型', 'error'); return; }
|
if (!checked.length) { setStatus('请至少选择一个发型', 'error'); return; }
|
||||||
|
f = await window.downscaleImageFile(f);
|
||||||
|
|
||||||
_origUrl = URL.createObjectURL(f);
|
_origUrl = URL.createObjectURL(f);
|
||||||
$('submitBtn').disabled = true; $('submitBtn').textContent = '⏳ ...';
|
$('submitBtn').disabled = true; $('submitBtn').textContent = '⏳ ...';
|
||||||
setStatus('请求中...', 'info'); $('resultsArea').classList.add('hidden');
|
setStatus('请求中...', 'info'); $('resultsArea').classList.add('hidden');
|
||||||
|
|
||||||
const fd = new FormData(); fd.append('image_file', f); fd.append('gender', $('gender').value); fd.append('hair_style', checked.join(','));
|
const fd = new FormData(); fd.append('image_file', f); fd.append('gender', $('gender').value); fd.append('hair_style', checked.join(',')); fd.append('generate_grow_image', $('genGrowImg').checked ? 'true' : 'false');
|
||||||
const _reqStart = performance.now();
|
const _reqStart = performance.now();
|
||||||
try {
|
try {
|
||||||
const r = await fetch(API_BASE + '/api/v1/hairline/generate', { method:'POST', body:fd });
|
const r = await fetch(API_BASE + '/api/v1/hairline/generate', { method:'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd });
|
||||||
const json = await r.json();
|
const json = await r.json();
|
||||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||||
$('jsonContent').textContent = JSON.stringify(json, null, 2);
|
$('jsonContent').textContent = JSON.stringify(json, null, 2);
|
||||||
@@ -235,25 +247,30 @@ async function submitTest() {
|
|||||||
function renderGrid() {
|
function renderGrid() {
|
||||||
if (!_images.length) { $('resultsGrid').innerHTML = '<span style="color:#9ca3af">无数据</span>'; return; }
|
if (!_images.length) { $('resultsGrid').innerHTML = '<span style="color:#9ca3af">无数据</span>'; return; }
|
||||||
let h = '';
|
let h = '';
|
||||||
// 叠图档:原图打底 + 透明 PNG 叠加(无原图或无叠图 → 显示占位)
|
// 叠图档:原图打底 + 透明 PNG 图层(无原图或无叠图 → 显示占位)
|
||||||
const overlayCell = (label, url) => (url && _origUrl)
|
const overlayCell = (label, src) => (src && _origUrl)
|
||||||
? '<div class="level-cell"><div class="level-label">'+label+'</div><div class="img-stack">'+
|
? '<div class="level-cell"><div class="level-label">'+label+'</div><div class="img-stack">'+
|
||||||
'<img class="layer-base" src="'+_origUrl+'" alt="原图">'+
|
'<img class="layer-base" src="'+_origUrl+'" alt="原图">'+
|
||||||
'<img class="layer-anno" src="'+url+'" alt="'+label+'"></div></div>'
|
'<img class="layer-anno" src="'+src+'" alt="'+label+'"></div></div>'
|
||||||
: '<div class="level-cell"><div class="level-label">'+label+'</div><div class="level-none">无</div></div>';
|
: '<div class="level-cell"><div class="level-label">'+label+'</div><div class="level-none">无</div></div>';
|
||||||
// 生发图:独立 img(已是 ComfyUI 完整人像照片)
|
// 生发图:独立 img(已是 ComfyUI 完整人像照片)
|
||||||
const grownCell = (label, url) => url
|
const grownCell = (label, src) => src
|
||||||
? '<div class="level-cell"><div class="level-label">'+label+'</div><img src="'+url+'" alt="'+label+'"></div>'
|
? '<div class="level-cell"><div class="level-label">'+label+'</div><img src="'+src+'" alt="'+label+'"></div>'
|
||||||
: '<div class="level-cell"><div class="level-label">'+label+'</div><div class="level-none">无</div></div>';
|
: '<div class="level-cell"><div class="level-label">'+label+'</div><div class="level-none">无</div></div>';
|
||||||
_images.forEach(function(it) {
|
_images.forEach(function(it) {
|
||||||
|
// 兼容网关 *_url 与 worker *_base64
|
||||||
|
const mid = resolveImgSrc(it.image_middle_url, it.image_middle_base64, 'image/png');
|
||||||
|
const high = resolveImgSrc(it.image_high_url, it.image_high_base64, 'image/png');
|
||||||
|
const low = resolveImgSrc(it.image_low_url, it.image_low_base64, 'image/png');
|
||||||
|
const grown = resolveImgSrc(it.grown_image_url, it.grown_image_base64, 'image/jpeg');
|
||||||
h += '<div class="hair-block">' +
|
h += '<div class="hair-block">' +
|
||||||
'<div class="hair-block-title">#' + (it.order||'—') + ' ' + (it.hairline_type||'') +
|
'<div class="hair-block-title">#' + (it.order||'—') + ' ' + (it.hairline_type||'') +
|
||||||
(it.grown_image_url ? ' <span style="font-size:11px;color:#7c3aed">含生发图</span>' : '') + '</div>' +
|
(grown ? ' <span style="font-size:11px;color:#7c3aed">含生发图</span>' : '') + '</div>' +
|
||||||
'<div class="level-row">' +
|
'<div class="level-row">' +
|
||||||
overlayCell('middle', it.image_middle_url) +
|
overlayCell('middle', mid) +
|
||||||
overlayCell('high', it.image_high_url) +
|
overlayCell('high', high) +
|
||||||
overlayCell('low', it.image_low_url) +
|
overlayCell('low', low) +
|
||||||
grownCell('生发图', it.grown_image_url) +
|
grownCell('生发图', grown) +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
.diff-list { font-size: 12px; color: #6b7280; margin-top: 6px; line-height: 1.7; }
|
.diff-list { font-size: 12px; color: #6b7280; margin-top: 6px; line-height: 1.7; }
|
||||||
.diff-list li { margin-left: 18px; }
|
.diff-list li { margin-left: 18px; }
|
||||||
</style>
|
</style>
|
||||||
|
<script src="/static/img_downscale.js?v=2"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -129,8 +130,9 @@ function setStatus(text, type) {
|
|||||||
|
|
||||||
async function submitTest() {
|
async function submitTest() {
|
||||||
const fileInput = $('imageFile');
|
const fileInput = $('imageFile');
|
||||||
const file = fileInput.files[0];
|
let file = fileInput.files[0];
|
||||||
if (!file) { setStatus('请先选择一张图片', 'error'); return; }
|
if (!file) { setStatus('请先选择一张图片', 'error'); return; }
|
||||||
|
file = await window.downscaleImageFile(file);
|
||||||
|
|
||||||
const _reqStart = performance.now();
|
const _reqStart = performance.now();
|
||||||
|
|
||||||
@@ -150,7 +152,7 @@ async function submitTest() {
|
|||||||
form.append('image_file', file);
|
form.append('image_file', file);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(API_BASE + '/api/v1/face/measure-v2', { method: 'POST', body: form });
|
const resp = await fetch(API_BASE + '/api/v1/face/measure-v2', { method: 'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body: form });
|
||||||
const json = await resp.json();
|
const json = await resp.json();
|
||||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||||
|
|
||||||
@@ -159,7 +161,10 @@ async function submitTest() {
|
|||||||
|
|
||||||
if (json.code === 0) {
|
if (json.code === 0) {
|
||||||
setStatus('✅ 请求成功 (' + _elapsed + 's) — request_id: ' + json.request_id, 'success');
|
setStatus('✅ 请求成功 (' + _elapsed + 's) — request_id: ' + json.request_id, 'success');
|
||||||
showOverlay(json.data.annotated_image_url);
|
// 兼容本地直连 worker(*_base64)与网关(*_url)
|
||||||
|
const _d = json.data || {};
|
||||||
|
const annoUrl = resolveImgSrc(_d.annotated_image_url, _d.annotated_image_base64, 'image/png');
|
||||||
|
showOverlay(annoUrl);
|
||||||
renderMetrics(json.data);
|
renderMetrics(json.data);
|
||||||
$('metricsBar').classList.remove('hidden');
|
$('metricsBar').classList.remove('hidden');
|
||||||
} else {
|
} else {
|
||||||
@@ -183,6 +188,10 @@ function showOverlay(annoUrl) {
|
|||||||
setTimeout(() => showOverlay(annoUrl), 200);
|
setTimeout(() => showOverlay(annoUrl), 200);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!annoUrl) {
|
||||||
|
$('imgPanel').innerHTML = '<span class="placeholder">后端未返回标注图(annotated_image_base64 为空)</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const checked = $('showAnno').checked ? '' : 'display:none';
|
const checked = $('showAnno').checked ? '' : 'display:none';
|
||||||
const opacity = ($('annoOpacity').value / 100).toFixed(2);
|
const opacity = ($('annoOpacity').value / 100).toFixed(2);
|
||||||
|
|||||||