Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
718372dc07 | ||
|
|
3a7c3fa07b | ||
|
|
b61ea6f33b | ||
|
|
b354c8c627 | ||
|
|
fd9c41430c | ||
|
|
5101bb5f6b | ||
|
|
ff4019c570 | ||
|
|
5226e23989 | ||
|
|
c462fd3634 | ||
|
|
ab6b0cb0bb | ||
|
|
94cdfd6de5 | ||
|
|
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 | ||
|
|
0f8a7e27c0 | ||
|
|
12f34c44f2 | ||
|
|
a208fe88ec | ||
|
|
4001df2c34 | ||
|
|
9386f84c88 | ||
|
|
d5397a6d6f | ||
|
|
95c6a2d929 | ||
|
|
28255ef7c2 | ||
|
|
b163a3f34a | ||
|
|
5d9d91bc82 | ||
|
|
3fe5f6cf0d | ||
|
|
e17677158a | ||
|
|
1e6c2e54de |
@@ -46,5 +46,35 @@ _grow*_resp.json
|
||||
# 测试素材图(体积大,不入 git)
|
||||
image/test/
|
||||
|
||||
# 批量报告输出(200张生成图+原图,体积大,不入 git)
|
||||
# 批量报告输出(生成图+原图,体积大,不入 git)
|
||||
static/report_hairline_v2/
|
||||
static/report_hairline_v2.zip
|
||||
|
||||
# local_test 运行期日志 / pid(不入 git)
|
||||
local_test/hair_service.log
|
||||
local_test/hair_service.pid
|
||||
|
||||
# benchmark 原始输出(含结果图+原图,体积大,不入 git)
|
||||
benchmark_out/
|
||||
|
||||
# benchmark 部署的 HTML 报告(图片 base64 内嵌,体积大,不入 git)
|
||||
static/hairstyle_thumbs/
|
||||
|
||||
# 网关运行期日志(不入 git)
|
||||
gateway.log
|
||||
|
||||
# 工作流备份文件(不入 git)
|
||||
*.json.bak.*
|
||||
|
||||
# 分辨率对比测试产物(out/ 下结果图+原图副本+日志,体积大,不入 git)
|
||||
# 仅忽略 out/ 与运行期文件;测试脚本与 HTML 报告仍入库
|
||||
image/compare_test/out/
|
||||
image/res_test/out/
|
||||
image/wave_test/out/
|
||||
image/compare_test/progress.json
|
||||
image/res_test/progress.json
|
||||
image/wave_test/progress.json
|
||||
image/compare_test/batch_test.log
|
||||
image/res_test/batch_test.log
|
||||
image/wave_test/batch_test.log
|
||||
image/compare_test/http.log
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
{
|
||||
"16": {
|
||||
"class_type": "UnetLoaderGGUF",
|
||||
"inputs": {
|
||||
"unet_name": "flux-2-klein-9b-Q4_K_M.gguf",
|
||||
"weight_dtype": "fp8_e4m3fn_fast"
|
||||
}
|
||||
},
|
||||
"3": {
|
||||
"class_type": "VAELoader",
|
||||
"inputs": {
|
||||
"vae_name": "flux2-vae.safetensors"
|
||||
}
|
||||
},
|
||||
"61": {
|
||||
"class_type": "CLIPLoader",
|
||||
"inputs": {
|
||||
"clip_name": "qwen_3_8b_fp8mixed.safetensors",
|
||||
"type": "flux2",
|
||||
"device": "cpu"
|
||||
}
|
||||
},
|
||||
"26": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "placeholder.png"
|
||||
}
|
||||
},
|
||||
"60": {
|
||||
"class_type": "JjkText",
|
||||
"inputs": {
|
||||
"text": "填充遮罩区域的头发"
|
||||
}
|
||||
},
|
||||
"22": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"clip": [
|
||||
"61",
|
||||
0
|
||||
],
|
||||
"text": [
|
||||
"60",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"31": {
|
||||
"class_type": "easy imageSize",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"26",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"33": {
|
||||
"class_type": "Mask Fill Holes",
|
||||
"inputs": {
|
||||
"masks": [
|
||||
"26",
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
"36": {
|
||||
"class_type": "Convert Masks to Images",
|
||||
"inputs": {
|
||||
"masks": [
|
||||
"33",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"39": {
|
||||
"class_type": "ImageScale",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"36",
|
||||
0
|
||||
],
|
||||
"upscale_method": "nearest-exact",
|
||||
"width": [
|
||||
"31",
|
||||
0
|
||||
],
|
||||
"height": [
|
||||
"31",
|
||||
1
|
||||
],
|
||||
"crop": "disabled"
|
||||
}
|
||||
},
|
||||
"37": {
|
||||
"class_type": "Image To Mask",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"39",
|
||||
0
|
||||
],
|
||||
"method": "intensity"
|
||||
}
|
||||
},
|
||||
"32": {
|
||||
"class_type": "LayerUtility: ImageScaleByAspectRatio V2",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"26",
|
||||
0
|
||||
],
|
||||
"mask": [
|
||||
"37",
|
||||
0
|
||||
],
|
||||
"aspect_ratio": "custom",
|
||||
"proportional_width": [
|
||||
"31",
|
||||
0
|
||||
],
|
||||
"proportional_height": [
|
||||
"31",
|
||||
1
|
||||
],
|
||||
"fit": "letterbox",
|
||||
"method": "lanczos",
|
||||
"round_to_multiple": "8",
|
||||
"scale_to_side": "None",
|
||||
"scale_to_length": 1024,
|
||||
"background_color": "#000000"
|
||||
}
|
||||
},
|
||||
"44": {
|
||||
"class_type": "ImageAndMaskPreview",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"32",
|
||||
0
|
||||
],
|
||||
"mask": [
|
||||
"32",
|
||||
1
|
||||
],
|
||||
"mask_opacity": 1,
|
||||
"mask_color": "FFFF00",
|
||||
"pass_through": true
|
||||
}
|
||||
},
|
||||
"14": {
|
||||
"class_type": "GetImageSize+",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"44",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"13": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {
|
||||
"pixels": [
|
||||
"44",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"3",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"class_type": "ModelSamplingFlux",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"16",
|
||||
0
|
||||
],
|
||||
"max_shift": 1.15,
|
||||
"base_shift": 0.5,
|
||||
"width": [
|
||||
"14",
|
||||
0
|
||||
],
|
||||
"height": [
|
||||
"14",
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
"19": {
|
||||
"class_type": "FluxGuidance",
|
||||
"inputs": {
|
||||
"conditioning": [
|
||||
"22",
|
||||
0
|
||||
],
|
||||
"guidance": 1
|
||||
}
|
||||
},
|
||||
"5": {
|
||||
"class_type": "ReferenceLatent",
|
||||
"inputs": {
|
||||
"conditioning": [
|
||||
"19",
|
||||
0
|
||||
],
|
||||
"latent": [
|
||||
"13",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"7": {
|
||||
"class_type": "EmptySD3LatentImage",
|
||||
"inputs": {
|
||||
"width": [
|
||||
"14",
|
||||
0
|
||||
],
|
||||
"height": [
|
||||
"14",
|
||||
1
|
||||
],
|
||||
"batch_size": 1
|
||||
}
|
||||
},
|
||||
"1": {
|
||||
"class_type": "BasicScheduler",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"2",
|
||||
0
|
||||
],
|
||||
"scheduler": "simple",
|
||||
"steps": 4,
|
||||
"denoise": 1
|
||||
}
|
||||
},
|
||||
"20": {
|
||||
"class_type": "BasicGuider",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"2",
|
||||
0
|
||||
],
|
||||
"conditioning": [
|
||||
"5",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"6": {
|
||||
"class_type": "RandomNoise",
|
||||
"inputs": {
|
||||
"noise_seed": 0
|
||||
}
|
||||
},
|
||||
"8": {
|
||||
"class_type": "KSamplerSelect",
|
||||
"inputs": {
|
||||
"sampler_name": "euler"
|
||||
}
|
||||
},
|
||||
"9": {
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"inputs": {
|
||||
"noise": [
|
||||
"6",
|
||||
0
|
||||
],
|
||||
"guider": [
|
||||
"20",
|
||||
0
|
||||
],
|
||||
"sampler": [
|
||||
"8",
|
||||
0
|
||||
],
|
||||
"sigmas": [
|
||||
"1",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"7",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"10": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"9",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"3",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"62": {
|
||||
"class_type": "ColorMatch",
|
||||
"inputs": {
|
||||
"image_ref": [
|
||||
"26",
|
||||
0
|
||||
],
|
||||
"image_target": [
|
||||
"10",
|
||||
0
|
||||
],
|
||||
"method": "mkl",
|
||||
"strength": 1,
|
||||
"multithread": true
|
||||
}
|
||||
},
|
||||
"17": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"62",
|
||||
0
|
||||
],
|
||||
"filename_prefix": "hair_inpaint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"1": {
|
||||
"inputs": {
|
||||
"scheduler": "simple",
|
||||
"steps": 6,
|
||||
"steps": 4,
|
||||
"denoise": 1,
|
||||
"model": [
|
||||
"2",
|
||||
@@ -170,10 +170,10 @@
|
||||
},
|
||||
"16": {
|
||||
"inputs": {
|
||||
"unet_name": "flux2.0/flux-2-klein-9b-fp8.safetensors",
|
||||
"weight_dtype": "fp8_e4m3fn"
|
||||
"unet_name": "flux-2-klein-9b-Q4_K_M.gguf",
|
||||
"weight_dtype": "fp8_e4m3fn_fast"
|
||||
},
|
||||
"class_type": "UNETLoader",
|
||||
"class_type": "UnetLoaderGGUF",
|
||||
"_meta": {
|
||||
"title": "UNet加载器"
|
||||
}
|
||||
@@ -410,7 +410,7 @@
|
||||
},
|
||||
"60": {
|
||||
"inputs": {
|
||||
"text": "补充遮罩区补充遮罩区域内的头发,头发填满遮罩区域。发际线往下挡住额头"
|
||||
"text": "填充遮罩区域的头发"
|
||||
},
|
||||
"class_type": "JjkText",
|
||||
"_meta": {
|
||||
@@ -421,7 +421,7 @@
|
||||
"inputs": {
|
||||
"clip_name": "qwen_3_8b_fp8mixed.safetensors",
|
||||
"type": "flux2",
|
||||
"device": "default"
|
||||
"device": "cpu"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": {
|
||||
|
||||
@@ -14,6 +14,7 @@ from typing import Any, List, Optional
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from fastapi import FastAPI, File, Form, Request, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
@@ -137,7 +138,47 @@ app = FastAPI(
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
# 不校验鉴权的路径前缀(供网关探测 / 文档 / 静态)
|
||||
_AUTH_EXEMPT = ("/health", "/docs", "/openapi.json", "/redoc", "/static", "/api/v1/debug")
|
||||
_AUTH_EXEMPT = ("/health", "/docs", "/openapi.json", "/redoc", "/static",
|
||||
"/api/v1/debug", "/api/v1/redraw",
|
||||
"/api/swapHair", "/hairColor")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# change_hair 代理路由(解决 CORS 问题)
|
||||
# ---------------------------------------------------------------------------
|
||||
_CHANGE_HAIR_BASE = os.getenv("CHANGE_HAIR_BASE", "http://127.0.0.1:8801")
|
||||
|
||||
|
||||
@app.post("/api/swapHair/v1", tags=["change_hair"])
|
||||
async def proxy_swap_hair(request: Request):
|
||||
"""代理转发到 change_hair /api/swapHair/v1(换发型)"""
|
||||
try:
|
||||
import httpx
|
||||
body = await request.body()
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
resp = await client.post(f"{_CHANGE_HAIR_BASE}/api/swapHair/v1",
|
||||
content=body,
|
||||
headers={"Content-Type": "application/json"})
|
||||
return JSONResponse(content=resp.json(), status_code=resp.status_code)
|
||||
except Exception as e:
|
||||
logger.exception("代理 swapHair 失败")
|
||||
return err(1007, f"换发型服务异常:{e}")
|
||||
|
||||
|
||||
@app.post("/hairColor/v2", tags=["change_hair"])
|
||||
async def proxy_hair_color(request: Request):
|
||||
"""代理转发到 change_hair /hairColor/v2(换发色)"""
|
||||
try:
|
||||
import httpx
|
||||
body = await request.body()
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
resp = await client.post(f"{_CHANGE_HAIR_BASE}/hairColor/v2",
|
||||
content=body,
|
||||
headers={"Content-Type": "application/json"})
|
||||
return JSONResponse(content=resp.json(), status_code=resp.status_code)
|
||||
except Exception as e:
|
||||
logger.exception("代理 hairColor 失败")
|
||||
return err(1007, f"换发色服务异常:{e}")
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
@@ -229,6 +270,20 @@ def _jpg_b64(bgr) -> str:
|
||||
return base64.b64encode(buf.tobytes()).decode()
|
||||
|
||||
|
||||
def _rgba_png_b64(rgba) -> str:
|
||||
"""(H,W,4) float32/uint8 RGBA 透明层 → PNG base64(保留 alpha 通道)。
|
||||
|
||||
用于发际线叠图/预览图:只含发际线曲线像素、背景透明,前端叠加到原图上显示。
|
||||
"""
|
||||
arr = np.asarray(rgba)
|
||||
if arr.dtype != np.uint8:
|
||||
arr = np.clip(arr, 0, 255).astype(np.uint8)
|
||||
img = Image.fromarray(arr, mode="RGBA")
|
||||
buf = BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
|
||||
def _png_to_jpg_b64(png_bytes) -> str:
|
||||
"""ComfyUI 返回的 PNG 字节 → 重编码为 JPG base64;无法解码则原样透传。"""
|
||||
img = cv2.imdecode(np.frombuffer(png_bytes, np.uint8), cv2.IMREAD_COLOR)
|
||||
@@ -310,6 +365,105 @@ class ImageJsonBody(BaseModel):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run_face_measure_data(image, variant="v1"):
|
||||
"""接口1测量数值核心:detect → 姿态校验 → 头发/耳朵分割 → measure_face → eye1~eye7。
|
||||
|
||||
返回 (data_dict, result, hair_mask, ear_mask),或检测/姿态失败时返回 None。
|
||||
不含标注图(annotated_image_*),供接口5 容错复用;接口1 调用后再自行生成标注图。
|
||||
任何分割/七眼计算异常均内部吞掉(七眼相关字段不出现),不影响主测量结果。
|
||||
|
||||
variant="v1"(接口1/接口5):完整四庭七眼 + eye1~eye7(含头部端线段)。
|
||||
variant="v6"(接口6):去顶庭、不画端线、无 eye1~eye7。
|
||||
"""
|
||||
h, w = image.shape[:2]
|
||||
from face_analysis.detector import detector
|
||||
from face_analysis.pose import estimate_head_pose, check_frontal_face
|
||||
from face_analysis.measure import measure_face
|
||||
|
||||
landmarks = detector.detect(image)
|
||||
if landmarks is None:
|
||||
return None
|
||||
if not check_frontal_face(landmarks, w, h):
|
||||
return None
|
||||
head_pose = estimate_head_pose(landmarks, w, h)
|
||||
|
||||
# 头发/耳朵分割(方案 B,单次推理),失败传 None 由 measure 内部回退方案 A。
|
||||
hair_mask = None
|
||||
ear_mask = None
|
||||
try:
|
||||
from face_analysis.hair_segmenter import get_segmenter
|
||||
from face_analysis.calibration import normalized_to_pixel
|
||||
pxs = [normalized_to_pixel(p, w, h) for p in landmarks.landmark]
|
||||
face_box = (min(p[0] for p in pxs), min(p[1] for p in pxs),
|
||||
max(p[0] for p in pxs), max(p[1] for p in pxs))
|
||||
hair_mask, ear_mask = get_segmenter().segment_hair_and_ears(image, face_box=face_box)
|
||||
except Exception as seg_e: # noqa: BLE001
|
||||
logger.warning("头发/耳朵分割失败,回退方案A:%s", seg_e)
|
||||
|
||||
result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose)
|
||||
discarded = result.hairline_discarded
|
||||
data = result.to_response()
|
||||
vd = result.vertical
|
||||
if variant == "v6":
|
||||
if discarded:
|
||||
# 发际线弃用:接口6 的上庭也依赖发际线,一并置 null;只保留中/下庭。
|
||||
base_px = vd["middle_court_px"] + vd["lower_court_px"]
|
||||
data["four_courts"]["upper_court_cm"] = None
|
||||
data["four_courts"]["ratios"] = {
|
||||
"upper_court": None,
|
||||
"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.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=右耳外段。
|
||||
# eye2~eye6(5段)只用内部分点,接口1/6 共用;eye1/eye7 需耳朵分割端线,仅接口1 有。
|
||||
try:
|
||||
epts = result.eyes["points"]
|
||||
lcx, rcx = epts["left_cheek"][0], epts["right_cheek"][0]
|
||||
pc = result.px_per_cm
|
||||
inner_xs = [lcx, epts["left_outer"][0], epts["left_inner"][0],
|
||||
epts["right_inner"][0], epts["right_outer"][0], rcx]
|
||||
for i in range(5):
|
||||
a, b = inner_xs[i], inner_xs[i + 1]
|
||||
data["seven_eyes"][f"eye{i + 2}"] = (
|
||||
None if (a is None or b is None) else round((b - a) / pc, 2))
|
||||
if variant != "v6":
|
||||
# 接口1 额外算 eye1/eye7(左/右耳外段),需耳朵分割端线。
|
||||
# 竖向范围:发际线弃用时用眉心做上界(hair_top 不可靠),否则用头顶。
|
||||
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(
|
||||
ear_mask, hair_mask,
|
||||
top_y, vd["chin_tip"][1],
|
||||
lcx, rcx, (lcx + rcx) / 2)
|
||||
data["seven_eyes"]["eye1"] = (
|
||||
None if (head_l is None) else round((lcx - head_l) / pc, 2))
|
||||
data["seven_eyes"]["eye7"] = (
|
||||
None if (head_r is None) else round((head_r - rcx) / pc, 2))
|
||||
except Exception as seg_e: # noqa: BLE001
|
||||
logger.warning("七眼段宽度计算失败:%s", seg_e)
|
||||
|
||||
return data, result, hair_mask, ear_mask
|
||||
|
||||
|
||||
async def _face_measure_impl(image_file, image_url, image_base64, variant="v1"):
|
||||
"""接口1/6 共用实现:四庭七眼测量 + 标注图生成。返回 (ok_dict, err_dict)。
|
||||
|
||||
@@ -326,60 +480,24 @@ async def _face_measure_impl(image_file, image_url, image_base64, variant="v1"):
|
||||
if image is None:
|
||||
return None, err(1008, "图片格式不支持(仅 JPG / PNG)")
|
||||
|
||||
h, w = image.shape[:2]
|
||||
|
||||
try:
|
||||
from face_analysis.detector import detector
|
||||
from face_analysis.pose import estimate_head_pose, check_frontal_face
|
||||
from face_analysis.measure import measure_face
|
||||
from face_analysis.annotation import create_annotated_image
|
||||
|
||||
# 5. 人脸检测
|
||||
landmarks = detector.detect(image)
|
||||
if landmarks is None:
|
||||
return None, err(1001, "无法识别人像")
|
||||
|
||||
# 6. 姿态校验
|
||||
if not check_frontal_face(landmarks, w, h):
|
||||
ret = _run_face_measure_data(image, variant=variant)
|
||||
if ret is None:
|
||||
# 区分错误码:未检出人脸 vs 非正面。重新检测一次以判断。
|
||||
from face_analysis.detector import detector
|
||||
from face_analysis.pose import check_frontal_face
|
||||
if detector.detect(image) is None:
|
||||
return None, err(1001, "无法识别人像")
|
||||
return None, err(1003, "角度问题,请上传正面照")
|
||||
head_pose = estimate_head_pose(landmarks, w, h)
|
||||
data, result, hair_mask, ear_mask = ret
|
||||
|
||||
# 7. 头发/耳朵分割(方案 B,单次推理),失败传 None 由 measure 内部回退方案 A。
|
||||
# 传人脸包围盒 → 先按人脸裁剪再分割(全身/街拍等脸偏小的图也能稳出耳朵)。
|
||||
hair_mask = None
|
||||
ear_mask = None
|
||||
try:
|
||||
from face_analysis.hair_segmenter import get_segmenter
|
||||
from face_analysis.calibration import normalized_to_pixel
|
||||
pxs = [normalized_to_pixel(p, w, h) for p in landmarks.landmark]
|
||||
face_box = (min(p[0] for p in pxs), min(p[1] for p in pxs),
|
||||
max(p[0] for p in pxs), max(p[1] for p in pxs))
|
||||
hair_mask, ear_mask = get_segmenter().segment_hair_and_ears(image, face_box=face_box)
|
||||
except Exception as seg_e: # noqa: BLE001
|
||||
logger.warning("头发/耳朵分割失败,回退方案A:%s", seg_e)
|
||||
|
||||
# 8. 测量 + 标注图(hair_mask 定发际线/头顶;ear_mask 定人头最左/最右竖线)
|
||||
result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose)
|
||||
# 标注图(接口1/6 才需要;接口5 复用 _run_face_measure_data 时不生成)
|
||||
from face_analysis.annotation import create_annotated_image
|
||||
annotated = create_annotated_image(
|
||||
image, result, ear_mask=ear_mask, hair_mask=hair_mask, variant=variant)
|
||||
buf = BytesIO()
|
||||
annotated.save(buf, format="PNG")
|
||||
|
||||
# 9. 拆分架构:返回 base64,不落盘不拼 URL(落盘改 URL 由网关完成)
|
||||
data = result.to_response()
|
||||
if variant == "v6":
|
||||
# 接口6:数据去顶庭(重算三庭比例与总高,移除顶庭/头顶字段)
|
||||
vd = result.vertical
|
||||
base_px = vd["upper_court_px"] + vd["middle_court_px"] + vd["lower_court_px"]
|
||||
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)
|
||||
data["landmarks"].pop("hair_top", None)
|
||||
data["annotated_image_base64"] = base64.b64encode(buf.getvalue()).decode()
|
||||
return ok(data), None
|
||||
except Exception as ex: # noqa: BLE001
|
||||
@@ -409,7 +527,7 @@ async def _face_measure_impl(image_file, image_url, image_base64, variant="v1"):
|
||||
**标注图片 UI 规范**(真实版本生效):
|
||||
- 字体/线条/箭头颜色:`#FFFFFF 100%`,透明底
|
||||
- 字号/线宽/虚线/箭头按图片短边自适应缩放
|
||||
- 四庭数值(名+数值两行,不带 cm)在图片**左侧**呈现,七眼段宽**上下穿插**展示,底部标「单位cm」
|
||||
- 四庭(名 + 数值带cm + 百分比 三行)在图片**左侧**呈现,七眼段宽**上下穿插**展示(数值带cm,下方另起一行标占头宽百分比)
|
||||
- 横线/竖线渐变消失并略超出端点;段宽/庭高用虚线 + 实心三角双箭头标示
|
||||
- 竖线含人头最左/最右端线(取自头发分割轮廓),共 8 线 7 段
|
||||
""",
|
||||
@@ -442,6 +560,8 @@ async def _face_measure_impl(image_file, image_url, image_base64, variant="v1"):
|
||||
"face_width_cm": 24.08,
|
||||
"inter_eye_distance_cm": 3.44,
|
||||
"ratios": {"eye_width": 0.143, "inter_eye_distance": 0.143},
|
||||
"eye1": 3.44, "eye2": 3.44, "eye3": 3.44, "eye4": 3.44,
|
||||
"eye5": 3.44, "eye6": 3.44, "eye7": 3.44,
|
||||
},
|
||||
"landmarks": {
|
||||
"hair_top": {"x": 540, "y": 120},
|
||||
@@ -491,13 +611,13 @@ async def face_measure(
|
||||
输入用户正面照,返回:
|
||||
- 标注好四庭七眼数据的 **PNG 图片**(仅标注图层,不含人物)
|
||||
- 三庭(上庭/中庭/下庭)各段**厘米数值及占比**(**不含顶庭**)
|
||||
- 七眼(眼宽/脸宽/两眼间距)**厘米数值及占比**
|
||||
- 五眼段宽(eye2~eye6:左脸颊/左眼/两眼间距/右眼/右脸颊)**厘米数值**,另含眼宽/脸宽/两眼间距
|
||||
- 四个关键分界点的**原图像素坐标**(发际线/眉心/鼻翼下缘/下巴尖)
|
||||
|
||||
基于接口1 的变体,与接口1 的差异:
|
||||
- **去顶庭**:不画头顶横线、不返回顶庭数据;`face_total_height_cm` 为三庭之和
|
||||
- **竖线范围**:纵向竖线从发际线画到下巴尖(接口1 为头顶→下巴尖)
|
||||
- **不画人头最左/最右端线**:仅七眼 6 点共 5 段标尺,不取头发轮廓端线(接口1 为 8 线 7 段)
|
||||
- **不画人头最左/最右端线**:仅七眼 6 点共 5 段标尺(eye2~eye6),不取头发轮廓端线(接口1 为 8 线 7 段 eye1~eye7)
|
||||
|
||||
其余(箭头/虚线/字体/单位cm/七眼数据)与接口1 一致。
|
||||
|
||||
@@ -512,7 +632,7 @@ async def face_measure(
|
||||
**标注图片 UI 规范**(真实版本生效):
|
||||
- 字体/线条/箭头颜色:`#FFFFFF 100%`,透明底
|
||||
- 字号/线宽/虚线/箭头按图片短边自适应缩放
|
||||
- 三庭数值(名+数值两行,不带 cm)在图片**左侧**呈现,七眼段宽**上下穿插**展示,底部标「单位cm」
|
||||
- 三庭(名 + 数值带cm + 百分比 三行)在图片**左侧**呈现,七眼段宽**上下穿插**展示(数值带cm,下方另起一行标占头宽百分比)
|
||||
- 段宽/庭高用虚线 + 实心三角双箭头标示
|
||||
""",
|
||||
responses={
|
||||
@@ -542,6 +662,7 @@ async def face_measure(
|
||||
"face_width_cm": 24.08,
|
||||
"inter_eye_distance_cm": 3.44,
|
||||
"ratios": {"eye_width": 0.143, "inter_eye_distance": 0.143},
|
||||
"eye2": 3.44, "eye3": 3.44, "eye4": 3.44, "eye5": 3.44, "eye6": 3.44,
|
||||
},
|
||||
"landmarks": {
|
||||
"hairline": {"x": 540, "y": 430},
|
||||
@@ -593,6 +714,11 @@ async def face_measure_v2(
|
||||
- 发际线类型 `hairline_type`(英文 key)
|
||||
- 顺序 `order`(本期固定 `1..N`,不排序)
|
||||
|
||||
> **female 走「换发型」模式**:生发图 `grown_image_base64` 由换发型(change_hair)
|
||||
> + Flux-2 整帧重绘(= 接口12 final 管线,整帧美颜+整帧重绘)生成,其余参数用固化默认值。
|
||||
> **male 仍走原生发(ComfyUI add_hair)管线**。入参与返回结构不变。
|
||||
> female 依赖 change_hair 与 ComfyUI(:8188) 均在跑。
|
||||
|
||||
{_image_fields_desc}
|
||||
|
||||
图片同时支持 `multipart/form-data` 文件上传(字段名 `image_file`)。
|
||||
@@ -649,7 +775,9 @@ async def hair_grow(
|
||||
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填),如 1,2,3。female:1-5 male:1-4"),
|
||||
beauty_enabled: bool = Form(default=False, description="是否开启美颜(本期不生效)"),
|
||||
use_mask: bool = Form(default=True, description="是否启用 inpaint 遮罩(测试对比用)。false 时用干净原图生成(空遮罩,不烧模板线)"),
|
||||
prompt: str = Form(default="补充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||
prompt: str = Form(default="填充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||
flux_model: Optional[str] = Form(default=None, description="Flux 模型文件名(切换模型用)。None=工作流默认;如 flux-2-klein-9b-Q5_K_M.gguf / flux-2-klein-9b-Q4_K_M.gguf / flux2.0/flux-2-klein-9b-fp8.safetensors"),
|
||||
redraw_max_side: Optional[int] = Form(default=None, description="重绘压图长边像素。None=默认896;0=不缩图(原图直送);其他如 768/640/1024"),
|
||||
):
|
||||
# 1. gender 必填校验(非法/缺失 → 1004)
|
||||
if gender not in ("male", "female"):
|
||||
@@ -672,17 +800,26 @@ async def hair_grow(
|
||||
|
||||
try:
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from hairline.service import generate_grow_results
|
||||
|
||||
# 预览 + 生发(ComfyUI) 都是阻塞且较慢,放线程池避免卡住事件循环
|
||||
items = await run_in_threadpool(generate_grow_results, image, gender, use_mask, prompt, hair_styles)
|
||||
# 预览 + 生发/换发型 都是阻塞且较慢,放线程池避免卡住事件循环。
|
||||
# female:换发型 + Flux-2 整帧重绘(= 接口12 final 管线);male:仍走原生发管线。
|
||||
if gender == "female":
|
||||
from hairline.service import generate_grow_results_swap
|
||||
items = await run_in_threadpool(
|
||||
generate_grow_results_swap, image, hair_styles, _V2_FINAL_DEFAULTS,
|
||||
redraw_max_side=redraw_max_side, unet_name=flux_model)
|
||||
else:
|
||||
from hairline.service import generate_grow_results
|
||||
items = await run_in_threadpool(
|
||||
generate_grow_results, image, gender, use_mask, prompt, hair_styles,
|
||||
unet_name=flux_model)
|
||||
if items is None:
|
||||
return err(1001, "无法识别人像")
|
||||
|
||||
results = []
|
||||
for p in items:
|
||||
results.append({
|
||||
"image_base64": _jpg_b64(p["image_bgr"]), # 预览图 JPG
|
||||
"image_base64": _rgba_png_b64(p["overlay"]), # 发际线曲线透明 PNG
|
||||
"grown_image_base64": (_png_to_jpg_b64(p["grown_png"]) # 生发图 JPG
|
||||
if p["grown_png"] else None),
|
||||
"hairline_type": p["hairline_type"],
|
||||
@@ -695,117 +832,15 @@ async def hair_grow(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 接口 7:C 端生发 v2(add_hair2.json 工作流)
|
||||
# 接口 7:C 端生发 v2 —— 已弃用(add_hair2.json 用 Klein-9b 大模型,会把常驻的
|
||||
# Klein-4b/Flux 挤出显存,导致接口2/3/5 耗时抖动;且业务已不再调用)。
|
||||
# 保留路由返回明确错误,避免老客户端拿到裸 404。
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_WORKFLOW2_PATH = os.path.join(os.path.dirname(__file__), "add_hair2.json")
|
||||
|
||||
|
||||
@app.post(
|
||||
"/api/v1/hair/grow-v2",
|
||||
summary="接口7 C端生发 v2(add_hair2 工作流)",
|
||||
tags=["生发"],
|
||||
description=f"""
|
||||
输入用户正面照 + **性别** + **发型序号**,使用 add_hair2.json 工作流生成指定发际线类型的预览图与生发图。
|
||||
功能与接口2 完全一致,仅 ComfyUI 工作流不同。
|
||||
|
||||
{_image_fields_desc}
|
||||
|
||||
图片同时支持 `multipart/form-data` 文件上传(字段名 `image_file`)。
|
||||
|
||||
---
|
||||
|
||||
- **gender**(必填):`male` / `female`。决定返回的贴图集合(female 5 张 / male 4 张)。
|
||||
非法或缺失返回 `1004`。
|
||||
- **hair_style**(必填):`int`,发型序号。`female`:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave;
|
||||
`male`:1=ellipse, 2=inverse_arc, 3=m, 4=straight。越界返回 `1007`。
|
||||
- **beauty_enabled**:本期保留但不生效。
|
||||
|
||||
`hairline_type` 取值:`ellipse` / `flower` / `heart` / `straight` / `wave`(female),
|
||||
`ellipse` / `m` / `straight` / `inverse_arc`(male)。
|
||||
""",
|
||||
responses={
|
||||
200: {
|
||||
"description": "成功",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"request_id": "mock-request-id",
|
||||
"data": {
|
||||
"results": [
|
||||
{"image_base64": "iVBORw0KGgo...", "hairline_type": "ellipse", "order": 1},
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
400: {
|
||||
"description": "参数错误 / 图片识别失败",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"图片参数错误": {"value": {"code": 1007, "message": "图片参数错误:必须且只能传 image_file / image_url / image_base64 其中一个", "request_id": "x", "data": None}},
|
||||
"非正面照": {"value": {"code": 1003, "message": "角度问题,请上传正面照", "request_id": "x", "data": None}},
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
async def hair_grow_v2(
|
||||
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG)"),
|
||||
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
||||
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
||||
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
|
||||
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填),如 1,2,3。female:1-5 male:1-4"),
|
||||
beauty_enabled: bool = Form(default=False, description="是否开启美颜(本期不生效)"),
|
||||
use_mask: bool = Form(default=True, description="是否启用 inpaint 遮罩(测试对比用)。false 时用干净原图生成(空遮罩,不烧模板线)"),
|
||||
prompt: str = Form(default="补充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||
):
|
||||
# 1. gender 必填校验(非法/缺失 → 1004)
|
||||
if gender not in ("male", "female"):
|
||||
return err(1004, "gender 必填且只能为 male / female")
|
||||
|
||||
# 2. hair_style 必填校验(解析逗号分隔,越界 → 1007)
|
||||
max_styles = {"female": 5, "male": 4}[gender]
|
||||
hair_styles = _parse_hair_styles(hair_style, max_styles)
|
||||
if hair_styles is None:
|
||||
return err(1007, f"hair_style 必填且为 1..{max_styles} 的整数(逗号分隔),收到 {hair_style!r}")
|
||||
|
||||
# 3. 三选一取图
|
||||
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
|
||||
if e is not None:
|
||||
return e
|
||||
|
||||
image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
|
||||
if image is None:
|
||||
return err(1008, "图片格式不支持(仅 JPG / PNG)")
|
||||
|
||||
try:
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from hairline.service import generate_grow_results
|
||||
|
||||
# 预览 + 生发(ComfyUI) 都是阻塞且较慢,放线程池避免卡住事件循环
|
||||
items = await run_in_threadpool(generate_grow_results, image, gender, use_mask, prompt, hair_styles, _WORKFLOW2_PATH)
|
||||
if items is None:
|
||||
return err(1001, "无法识别人像")
|
||||
|
||||
results = []
|
||||
for p in items:
|
||||
results.append({
|
||||
"image_base64": _jpg_b64(p["image_bgr"]), # 预览图 JPG
|
||||
"grown_image_base64": (_png_to_jpg_b64(p["grown_png"]) # 生发图 JPG
|
||||
if p["grown_png"] else None),
|
||||
"hairline_type": p["hairline_type"],
|
||||
"order": p["order"],
|
||||
})
|
||||
return ok({"results": results})
|
||||
except Exception as ex: # noqa: BLE001
|
||||
logger.exception("接口7 处理异常")
|
||||
return err(1007, f"处理失败:{ex}")
|
||||
@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")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -855,7 +890,7 @@ async def hair_grow_b(
|
||||
marked_image_url: Optional[str] = Form(default=None, description="划线图片 URL"),
|
||||
marked_image_base64: Optional[str] = Form(default=None, description="划线图片 base64"),
|
||||
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)
|
||||
@@ -984,8 +1019,13 @@ async def face_features(
|
||||
- 必填 `gender`(`male`/`female`),决定发型集合(female 5 / male 4)。
|
||||
- 必填 `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。
|
||||
- 可选 `use_mask` / `prompt`:同接口2 的生发控制参数。
|
||||
注:生发黑模板固定取 `hairline_texture_black/`(middle 档),即三档叠图分别用各自贴图、但生发目标固定 middle。
|
||||
- 可选 `use_mask` / `prompt`:同接口2 的生发控制参数(仅 male 路径生效)。
|
||||
- 可选 `generate_grow_image`(默认 `true`):是否生成生发效果图(最耗时)。
|
||||
`false` 时跳过生发,各发型 `grown_image_*` 恒为 `null`,仅返回三档发际线叠图与中心点,大幅降低耗时。
|
||||
- **生发机制(同接口2,按性别分流)**:
|
||||
`female` 走「换发型 + Flux-2 整帧重绘」(依赖 change_hair:8801 与 ComfyUI:8188);
|
||||
`male` 走 ComfyUI `add_hair` 原生 inpaint。
|
||||
- 可选 `flux_model` / `redraw_max_side`:同接口2(仅 female 路径生效)。
|
||||
|
||||
**返回说明**:
|
||||
|
||||
@@ -994,8 +1034,10 @@ async def face_features(
|
||||
- `grown_image_url`:该发型的**生发图**(生发失败时为 `null`)。
|
||||
- `hairline_type`:发际线类型 key。
|
||||
worker 返回 `*_base64`,网关落盘后改写为 `*_url`。
|
||||
- `best_hairline_center_point`:**首个选中发型**的 middle 档发际线曲线**面部中间点**坐标,
|
||||
- `best_hairline_center_point`:**首个选中发型**的 **middle 档**发际线曲线**面部中间点**坐标,
|
||||
以**原图像素**为基准(左上角为原点,x 向右,y 向下)。
|
||||
- `high_hairline_center_point`:同上,**high 档**发际线中点。
|
||||
- `low_hairline_center_point`:同上,**low 档**发际线中点。
|
||||
""",
|
||||
responses={
|
||||
200: {
|
||||
@@ -1012,6 +1054,33 @@ async def face_features(
|
||||
{"hairline_type": "flower", "image_middle_base64": "iVBORw0KGgo...", "image_high_base64": "iVBORw0KGgo...", "image_low_base64": "iVBORw0KGgo...", "grown_image_base64": None, "order": 2},
|
||||
],
|
||||
"best_hairline_center_point": {"x": 540, "y": 430},
|
||||
"high_hairline_center_point": {"x": 540, "y": 380},
|
||||
"low_hairline_center_point": {"x": 540, "y": 480},
|
||||
"face_measure": {
|
||||
"face_total_height_cm": 13.76,
|
||||
"four_courts": {
|
||||
"top_court_cm": 3.44, "upper_court_cm": 3.44,
|
||||
"middle_court_cm": 3.44, "lower_court_cm": 3.44,
|
||||
"ratios": {"top_court": 0.25, "upper_court": 0.25,
|
||||
"middle_court": 0.25, "lower_court": 0.25},
|
||||
},
|
||||
"seven_eyes": {
|
||||
"eye_width_cm": 3.44, "face_width_cm": 24.08,
|
||||
"inter_eye_distance_cm": 3.44,
|
||||
"ratios": {"eye_width": 0.143, "inter_eye_distance": 0.143},
|
||||
"eye1": 3.44, "eye2": 3.44, "eye3": 3.44, "eye4": 3.44,
|
||||
"eye5": 3.44, "eye6": 3.44, "eye7": 3.44,
|
||||
},
|
||||
"landmarks": {
|
||||
"hair_top": {"x": 540, "y": 120},
|
||||
"hairline": {"x": 540, "y": 430},
|
||||
"brow_center": {"x": 540, "y": 740},
|
||||
"nose_bottom": {"x": 540, "y": 1050},
|
||||
"chin_tip": {"x": 540, "y": 1360},
|
||||
},
|
||||
"hairline_source": "segmentation",
|
||||
"head_pose": {"yaw": -1.39, "pitch": 2.49, "roll": -0.06},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1033,8 +1102,11 @@ async def hairline_generate(
|
||||
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
||||
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
|
||||
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填,如 1,2,3)。female:1-5 male:1-4"),
|
||||
use_mask: bool = Form(default=True, description="生发是否启用 inpaint 遮罩(同接口2,测试对比用)"),
|
||||
prompt: str = Form(default="补充遮罩区域的头发", description="ComfyUI 提示词(同接口2),会替换工作流节点60的文本"),
|
||||
use_mask: bool = Form(default=True, description="生发是否启用 inpaint 遮罩(同接口2,仅 male 路径生效)"),
|
||||
prompt: str = Form(default="填充遮罩区域的头发", description="ComfyUI 提示词(同接口2,仅 male 路径生效),会替换工作流节点60的文本"),
|
||||
generate_grow_image: bool = Form(default=True, description="是否生成生发效果图(最耗时)。默认 true 出图;false 时跳过生发,各发型 grown_image 恒为 null,仅返回三档发际线叠图与中心点"),
|
||||
flux_model: Optional[str] = Form(default=None, description="Flux 模型文件名(同接口2,切换模型用)。None=工作流默认"),
|
||||
redraw_max_side: Optional[int] = Form(default=None, description="重绘压图长边像素(同接口2,仅 female 路径生效)。None=默认896;0=不缩图(原图直送);其他如 768/640/1024"),
|
||||
):
|
||||
if gender not in ("male", "female"):
|
||||
return err(1004, "gender 必填且只能为 male / female")
|
||||
@@ -1058,7 +1130,10 @@ async def hairline_generate(
|
||||
from hairline.service import generate_hairline_pngs
|
||||
|
||||
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,
|
||||
redraw_max_side=redraw_max_side, unet_name=flux_model,
|
||||
v2_defaults=_V2_FINAL_DEFAULTS)
|
||||
if res is None:
|
||||
return err(1001, "无法识别人像")
|
||||
|
||||
@@ -1067,18 +1142,32 @@ async def hairline_generate(
|
||||
ov = it["overlays"]
|
||||
hairline_images.append({
|
||||
"hairline_type": it["hairline_type"],
|
||||
"image_middle_base64": _jpg_b64(ov["middle"]), # 发际线叠图 middle 档 JPG
|
||||
"image_high_base64": _jpg_b64(ov["high"]), # 发际线叠图 high 档 JPG
|
||||
"image_low_base64": _jpg_b64(ov["low"]), # 发际线叠图 low 档 JPG
|
||||
"image_middle_base64": _rgba_png_b64(ov["middle"]), # 发际线曲线透明 PNG middle 档
|
||||
"image_high_base64": _rgba_png_b64(ov["high"]), # 发际线曲线透明 PNG high 档
|
||||
"image_low_base64": _rgba_png_b64(ov["low"]), # 发际线曲线透明 PNG low 档
|
||||
"grown_image_base64": (_png_to_jpg_b64(it["grown_png"]) # 生发图 JPG(失败为 null)
|
||||
if it.get("grown_png") else None),
|
||||
"order": it["order"],
|
||||
})
|
||||
c = res["best_center"]
|
||||
c = res.get("best_centers") or {}
|
||||
def _pt(p):
|
||||
return ({"x": p[0], "y": p[1]} if p else None)
|
||||
data = {
|
||||
"hairline_images": hairline_images,
|
||||
"best_hairline_center_point": ({"x": c[0], "y": c[1]} if c else None),
|
||||
"best_hairline_center_point": _pt(c.get("middle")),
|
||||
"high_hairline_center_point": _pt(c.get("high")),
|
||||
"low_hairline_center_point": _pt(c.get("low")),
|
||||
}
|
||||
|
||||
# face_measure:复用接口1测量数值(四庭/七眼 eye1~eye7/landmarks/姿态),不含标注图。
|
||||
# 独立流程,容错:测量失败(无人脸/非正面/分割失败)→ null,不影响发际线主结果。
|
||||
try:
|
||||
fm = _run_face_measure_data(image, variant="v1")
|
||||
data["face_measure"] = fm[0] if fm is not None else None
|
||||
except Exception as fm_e: # noqa: BLE001
|
||||
logger.warning("接口5 face_measure 失败:%s", fm_e)
|
||||
data["face_measure"] = None
|
||||
|
||||
return ok(data)
|
||||
except Exception as ex: # noqa: BLE001
|
||||
logger.exception("接口5 处理异常")
|
||||
@@ -1297,19 +1386,23 @@ async def hairline_grow(
|
||||
hairgrow_strength: float = Form(default=0.75, description="区域生发强度(仅 hairgrow 后端),默认 0.75"),
|
||||
is_hr: bool = Form(default=False, description="高清模式(换发型输出 1152×1536,否则 576×768)"),
|
||||
seg_model: str = Form(default="segformer", description="头发分割模型:bisenet | segformer(默认 segformer)"),
|
||||
mask_type: str = Form(default="eroded", description="遮罩类型:eroded(内缩) | closed(闭合区域) | pushed(发际线外推)(默认 eroded)"),
|
||||
erode_cm: float = Form(default=1.2, description="遮罩外缘朝中心151内缩距离(厘米,同接口9),默认 1.2"),
|
||||
hairline_push_cm: float = Form(default=1.0, description="发际线外推距离(厘米,往头发方向推进;仅 mask_type=pushed 生效),默认 1.0"),
|
||||
hairline_edge: str = Form(default="column", description="发际线提取方式:column(逐列最低点) | contour(形态学轮廓)(仅 pushed 生效),默认 column"),
|
||||
swap_mode: str = Form(default="ext_mask", description="换发型取图模式:ext_mask(改造换发型用接口9遮罩) | as_is(不改换发型,贴回再裁)(默认 ext_mask)"),
|
||||
blend_method: str = Form(default="feather", description="接缝融合:feather(高斯羽化) | alpha_gradient(距离渐变) | seamless(泊松无缝) | multiband(多频段金字塔)(默认 feather)"),
|
||||
feather_px: int = Form(default=15, description="羽化/渐变过渡宽度(像素),默认 15"),
|
||||
erode_cm: float = Form(default=0.6, description="baseline 参考内缩距离(厘米),默认 0.6"),
|
||||
hairline_push_cm: float = Form(default=1.0, description="发际线外推距离(厘米,往头发方向推进),默认 1.0"),
|
||||
hairline_edge: str = Form(default="column", description="发际线提取方式:column | contour,默认 column"),
|
||||
swap_mode: str = Form(default="ext_mask", description="换发型取图模式:ext_mask | as_is(默认 ext_mask)"),
|
||||
edge_erode_px: int = Form(default=3, description="贴图前遮罩内缩像素(防边缘露皮/光晕),默认 3"),
|
||||
denoising_strength: float = Form(default=0.6, description="换发型 webui 重绘强度(越大生发越激进),默认 0.6"),
|
||||
color_match: bool = Form(default=False, description="融合前对生成图做 Reinhard 颜色校正(消除整体色差,seamless 下自动跳过),默认 false"),
|
||||
mb_levels: int = Form(default=5, description="multiband 多频段金字塔层数(2~6,越大低频色差抹得越宽,仅 multiband 生效),默认 5"),
|
||||
mb_levels: int = Form(default=5, description="多频段金字塔层数(2~6,越大低频色差抹得越宽),默认 5"),
|
||||
blend_method: str = Form(default="multiband", description="接缝融合方法:multiband(多频段金字塔,默认) | seamless(泊松无缝克隆) | two_stage(泊松→多频段两段式,大色差场景) | feather(高斯羽化) | alpha_gradient(距离变换内渐变)"),
|
||||
color_match: bool = Form(default=True, description="融合前 Reinhard 颜色迁移消除整体色差(对 multiband/feather/alpha_gradient 有效;seamless/two_stage 自带调色故跳过),默认 True"),
|
||||
color_match_strength: float = Form(default=1.0, description="颜色迁移强度(0~1,1=全迁移,<1 只迁移部分防过度改色),默认 1.0"),
|
||||
mb_feather_px: int = Form(default=1, description="多频段最细层掩码轻羽化像素(0=不羽化,消除发丝边缘锯齿),默认 1"),
|
||||
transition_band_px: int = Form(default=-1, description="keep-region 过渡带边距(-1=自动按层数 2**n,>=0 用绝对像素与层数解耦),默认 -1"),
|
||||
inpainting_fill: int = Form(default=1, description="change_hair服务端重绘填充:0=保留原图(治染绿) | 1=填充噪声(默认/原始) | 2=纯色 | 3=潜变量噪声。默认 1"),
|
||||
mask_blur: int = Form(default=11, description="change_hair服务端遮罩边缘模糊像素(原始11,越大颜色越易从边缘渗透),默认 11"),
|
||||
mask_dilate_scale: float = Form(default=1.0, description="change_hair服务端遮罩膨胀缩放(1.0=原始核尺寸,<1收缩防越界),默认 1.0"),
|
||||
):
|
||||
"""接口11:发际线生发 + 分步可视化"""
|
||||
"""接口11:发际线生发 + 分步可视化(**不含重绘**,重绘见接口12)。遮罩固定 pushed,融合默认 multiband(可选 seamless/two_stage/feather)。"""
|
||||
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
|
||||
if e is not None:
|
||||
return e
|
||||
@@ -1323,14 +1416,24 @@ async def hairline_grow(
|
||||
from face_analysis.hairline_grow import generate_hairline_grow, NoFaceError, SwapError
|
||||
from uuid import uuid4 as _uuid4
|
||||
rid = _uuid4().hex[:8]
|
||||
logger.info("[%s] 接口11 收到请求: mask_type=%s hairline_push_cm=%s hairline_edge=%s",
|
||||
rid, mask_type, hairline_push_cm, hairline_edge)
|
||||
logger.info("[%s] 接口11 收到请求: hairline_push_cm=%s hairline_edge=%s mb_levels=%s "
|
||||
"blend=%s color_match=%s cm_strength=%s mb_feather_px=%s transition_band_px=%s "
|
||||
"inpainting_fill=%s mask_blur=%s mask_dilate_scale=%s",
|
||||
rid, hairline_push_cm, hairline_edge, mb_levels, blend_method,
|
||||
color_match, color_match_strength, mb_feather_px, transition_band_px,
|
||||
inpainting_fill, mask_blur, mask_dilate_scale)
|
||||
try:
|
||||
data = await run_in_threadpool(
|
||||
generate_hairline_grow, image, hairline_id, is_hr, seg_model,
|
||||
mask_type, erode_cm, swap_mode, blend_method, feather_px, edge_erode_px,
|
||||
denoising_strength, gen_backend, hairgrow_strength, color_match, mb_levels,
|
||||
hairline_push_cm=hairline_push_cm, hairline_edge=hairline_edge, rid=rid)
|
||||
generate_hairline_grow, image, hairline_id,
|
||||
is_hr=is_hr, seg_model=seg_model, erode_cm=erode_cm, swap_mode=swap_mode,
|
||||
edge_erode_px=edge_erode_px, denoising_strength=denoising_strength,
|
||||
gen_backend=gen_backend, hairgrow_strength=hairgrow_strength,
|
||||
mb_levels=mb_levels, hairline_push_cm=hairline_push_cm,
|
||||
hairline_edge=hairline_edge, blend_method=blend_method,
|
||||
color_match=color_match, color_match_strength=color_match_strength,
|
||||
mb_feather_px=mb_feather_px, transition_band_px=transition_band_px,
|
||||
inpainting_fill=inpainting_fill, mask_blur=mask_blur,
|
||||
mask_dilate_scale=mask_dilate_scale, rid=rid)
|
||||
except NoFaceError:
|
||||
return err(1001, "无法识别人像")
|
||||
except SwapError as se:
|
||||
@@ -1343,22 +1446,33 @@ async def hairline_grow(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 接口 12:发际线生发(接口11 固定参数精简版)
|
||||
# 接口 12:发际线带重绘(调用接口11 的 ④final 作输入 + ⑤-①重绘带作遮罩,Flux-2 重绘)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.post(
|
||||
"/api/v1/hairline/grow_v2",
|
||||
summary="接口12 发际线生发(固定金字塔融合,仅返回最终图)",
|
||||
summary="接口12 发际线带重绘(接口11 final + 发际线重绘带 → Flux-2 保色重绘)",
|
||||
tags=["生发"],
|
||||
description=f"""
|
||||
接口11 的固定参数精简版,适合生产直调。与接口11 共用同一管线,区别仅在于:
|
||||
接口12 是接口11 的**下游重绘阶段**。内部先跑接口11 核心管线拿到 **④ 接缝融合最终图(final)**,
|
||||
再取 **⑤-① 发际线重绘带**(发际线沿外推方向 `band_lo_mult×push` ~ `band_hi_mult×push`
|
||||
之间、经 ①-a baseline 截断只留上部的带状区域,默认 0.5×~1.5×push)
|
||||
作为遮罩,用 **Flux-2(ComfyUI)** 做 reference-latent 保色重绘(不易染绿),重绘结果再与 final 融合。
|
||||
|
||||
- **固定** `blend_method=multiband`(多频段金字塔融合)、`mb_levels=5`、`erode_cm=0.6`
|
||||
(外缘朝中心151 内缩 0.6cm)。这三项不可调,故本接口不暴露。
|
||||
- **返回值精简**:只返回 `final_base64`(最终合成图),不再附带接口11 的分步可视化。
|
||||
与接口11 的关系:接口11 只负责生成 final(不再含重绘);接口12 负责在 final 上做发际线带重绘。
|
||||
接口11 的可调参数(seg_model/gen_backend/hairline_push_cm/blend_method/color_match 等)
|
||||
在本接口同样暴露,用于内部生成 final 与重绘带;另有 `comfyui_prompt` 控制 Flux-2 提示词。
|
||||
|
||||
其余参数(hairline_id、seg_model、gen_backend、is_hr、denoising_strength、color_match、
|
||||
edge_erode_px 等)仍保留为可选 Form,调用方可按需覆盖,未传则用接口11 同款默认值。
|
||||
⚠️ 依赖 ComfyUI(默认 :8188)在跑,否则重绘失败会在 `data.redraw.c_error` 报告。
|
||||
|
||||
**同时产出两版结果供对比**:
|
||||
- `redraw_full`:ComfyUI 整帧输出(全脸美颜 + 全脸重绘),与手动跑 ComfyUI 一致。
|
||||
- `redraw_band`:加发只在发际线带、美颜保留全脸(band 内用 ComfyUI 重绘,band 外 = final 结构
|
||||
+ 按 `beauty_alpha` 融入全脸美颜)。
|
||||
|
||||
返回 `data.steps`:`input`(原图)/ `final`(接口11 的 ④,重绘输入基底)/
|
||||
`redraw_band_overlay`(⑤-① 重绘带可视化)/ `redraw_full`(A 整帧)/ `redraw_band`(B 局部加发+全脸美颜)/
|
||||
`redraw_c`(兼容旧字段,=redraw_full)。
|
||||
|
||||
{_image_fields_desc}
|
||||
""",
|
||||
@@ -1372,16 +1486,27 @@ async def hairline_grow_v2(
|
||||
hairgrow_strength: float = Form(default=0.75, description="区域生发强度(仅 hairgrow 后端),默认 0.75"),
|
||||
is_hr: bool = Form(default=False, description="高清模式(换发型输出 1152×1536,否则 576×768)"),
|
||||
seg_model: str = Form(default="segformer", description="头发分割模型:bisenet | segformer(默认 segformer)"),
|
||||
mask_type: str = Form(default="eroded", description="遮罩类型:eroded(内缩) | closed(闭合区域) | pushed(发际线外推)(默认 eroded)"),
|
||||
swap_mode: str = Form(default="ext_mask", description="换发型取图模式:ext_mask(改造换发型用接口9遮罩) | as_is(不改换发型,贴回再裁)(默认 ext_mask)"),
|
||||
feather_px: int = Form(default=15, description="羽化/渐变过渡宽度(像素,本接口固定 multiband 故不生效,仅留作兼容)"),
|
||||
erode_cm: float = Form(default=0.6, description="baseline 参考内缩距离(厘米),默认 0.6"),
|
||||
swap_mode: str = Form(default="ext_mask", description="换发型取图模式:ext_mask | as_is(默认 ext_mask)"),
|
||||
edge_erode_px: int = Form(default=3, description="贴图前遮罩内缩像素(防边缘露皮/光晕),默认 3"),
|
||||
denoising_strength: float = Form(default=0.6, description="换发型 webui 重绘强度(越大生发越激进),默认 0.6"),
|
||||
color_match: bool = Form(default=False, description="融合前对生成图做 Reinhard 颜色校正(消除整体色差),默认 false"),
|
||||
hairline_push_cm: float = Form(default=1.0, description="发际线外推距离(厘米,仅 mask_type=pushed 生效),默认 1.0"),
|
||||
hairline_edge: str = Form(default="column", description="发际线提取方式:column | contour(仅 pushed 生效),默认 column"),
|
||||
mb_levels: int = Form(default=5, description="多频段金字塔层数(2~6),默认 5"),
|
||||
hairline_push_cm: float = Form(default=1.0, description="发际线外推距离(厘米),默认 1.0"),
|
||||
hairline_edge: str = Form(default="column", description="发际线提取方式:column | contour,默认 column"),
|
||||
blend_method: str = Form(default="multiband", description="接缝融合方法:multiband | seamless | two_stage | feather | alpha_gradient"),
|
||||
color_match: bool = Form(default=True, description="融合前 Reinhard 颜色迁移消除整体色差,默认 True"),
|
||||
color_match_strength: float = Form(default=1.0, description="颜色迁移强度(0~1),默认 1.0"),
|
||||
mb_feather_px: int = Form(default=1, description="多频段最细层掩码轻羽化像素,默认 1"),
|
||||
transition_band_px: int = Form(default=-1, description="keep-region 过渡带边距(-1=自动),默认 -1"),
|
||||
inpainting_fill: int = Form(default=1, description="change_hair服务端重绘填充:0=保留原图 | 1=噪声 | 2=纯色 | 3=潜变量。默认 1"),
|
||||
mask_blur: int = Form(default=11, description="change_hair服务端遮罩边缘模糊像素,默认 11"),
|
||||
mask_dilate_scale: float = Form(default=1.0, description="change_hair服务端遮罩膨胀缩放,默认 1.0"),
|
||||
comfyui_prompt: Optional[str] = Form(default=None, description="Flux-2 重绘提示词,None 用默认「填充遮罩区域的头发」"),
|
||||
beauty_alpha: float = Form(default=0.6, description="redraw_band 版 band 外的全脸美颜融入强度(0=band外无美颜纯用final,1≈整帧版),默认 0.6"),
|
||||
band_lo_mult: float = Form(default=0.5, description="重绘带外推倍率下限(相对 hairline_push_cm,内轮廓=0×、原外推线=1.0×),默认 0.5"),
|
||||
band_hi_mult: float = Form(default=1.5, description="重绘带外推倍率上限(相对 hairline_push_cm),默认 1.5"),
|
||||
):
|
||||
"""接口12:发际线生发(固定 multiband/mb_levels=5/erode_cm=0.6,仅返回最终图)。"""
|
||||
"""接口12:发际线带重绘。同时产出 redraw_full(整帧美颜) 与 redraw_band(局部加发+全脸美颜) 两版对比。"""
|
||||
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
|
||||
if e is not None:
|
||||
return e
|
||||
@@ -1392,42 +1517,190 @@ async def hairline_grow_v2(
|
||||
|
||||
try:
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from face_analysis.hairline_grow import generate_hairline_grow, NoFaceError, SwapError
|
||||
from face_analysis.hairline_grow import generate_hairline_redraw, NoFaceError, SwapError
|
||||
from uuid import uuid4 as _uuid4
|
||||
rid = _uuid4().hex[:8]
|
||||
logger.info("[%s] 接口12 收到请求: hairline_id=%s hairline_push_cm=%s blend=%s comfyui_prompt=%r",
|
||||
rid, hairline_id, hairline_push_cm, blend_method, comfyui_prompt)
|
||||
try:
|
||||
# 固定三项:blend_method=multiband、mb_levels=5、erode_cm=0.6
|
||||
data = await run_in_threadpool(
|
||||
generate_hairline_grow, image, hairline_id, is_hr, seg_model,
|
||||
mask_type, 0.6, swap_mode, "multiband", feather_px, edge_erode_px,
|
||||
denoising_strength, gen_backend, hairgrow_strength, color_match, 5,
|
||||
hairline_push_cm=hairline_push_cm, hairline_edge=hairline_edge)
|
||||
generate_hairline_redraw, image, hairline_id,
|
||||
is_hr=is_hr, seg_model=seg_model, erode_cm=erode_cm, swap_mode=swap_mode,
|
||||
edge_erode_px=edge_erode_px, denoising_strength=denoising_strength,
|
||||
gen_backend=gen_backend, hairgrow_strength=hairgrow_strength,
|
||||
mb_levels=mb_levels, hairline_push_cm=hairline_push_cm,
|
||||
hairline_edge=hairline_edge, blend_method=blend_method,
|
||||
color_match=color_match, color_match_strength=color_match_strength,
|
||||
mb_feather_px=mb_feather_px, transition_band_px=transition_band_px,
|
||||
inpainting_fill=inpainting_fill, mask_blur=mask_blur,
|
||||
mask_dilate_scale=mask_dilate_scale, comfyui_prompt=comfyui_prompt,
|
||||
beauty_alpha=beauty_alpha, band_lo_mult=band_lo_mult,
|
||||
band_hi_mult=band_hi_mult, rid=rid)
|
||||
except NoFaceError:
|
||||
return err(1001, "无法识别人像")
|
||||
except SwapError as se:
|
||||
return err(1007, f"换发型失败:{se}")
|
||||
# 精简返回:只取最终合成图,丢掉接口11 的全部分步可视化
|
||||
return ok({
|
||||
"hairline_id": data["hairline_id"],
|
||||
"image_size": data["image_size"],
|
||||
"final_base64": data["steps"]["final_base64"],
|
||||
})
|
||||
logger.info("[%s] 接口12 成功返回", rid)
|
||||
return ok(data)
|
||||
except Exception as ex: # noqa: BLE001
|
||||
logger.exception("接口12 处理异常")
|
||||
return err(1007, f"处理失败:{ex}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 接口 12 final:精简版发际线带重绘(仅需图片 + 发型 ID,其余参数全用默认值)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 接口12 final 固化的默认参数(= test_interface12.html 当前默认值,color_match 关闭)
|
||||
_V2_FINAL_DEFAULTS = dict(
|
||||
gen_backend="swaphair", hairgrow_strength=0.75, is_hr=False, seg_model="segformer",
|
||||
erode_cm=0.6, swap_mode="ext_mask", edge_erode_px=3, denoising_strength=0.6,
|
||||
mb_levels=5, hairline_push_cm=0.8, hairline_edge="column", blend_method="two_stage",
|
||||
color_match=False, color_match_strength=0.4, mb_feather_px=1, transition_band_px=-1,
|
||||
inpainting_fill=1, mask_blur=11, mask_dilate_scale=1.0, comfyui_prompt=None,
|
||||
beauty_alpha=0.6, band_lo_mult=0.5, band_hi_mult=1.5,
|
||||
)
|
||||
|
||||
|
||||
async def _run_v2_final(image_file, image_url, image_base64, hairline_id, tag):
|
||||
"""接口12 final / final v2 共用:仅需图片 + hairline_id,其余用固化默认值。
|
||||
两者后端计算完全一致(同一次 ComfyUI 输出同时含 A 整帧与 B 局部+美颜),
|
||||
差异仅在配套测试页展示哪一版。"""
|
||||
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
|
||||
if e is not None:
|
||||
return e
|
||||
|
||||
image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
|
||||
if image is None:
|
||||
return err(1008, "图片格式不支持(仅 JPG / PNG)")
|
||||
|
||||
try:
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from face_analysis.hairline_grow import generate_hairline_redraw, NoFaceError, SwapError
|
||||
from uuid import uuid4 as _uuid4
|
||||
rid = _uuid4().hex[:8]
|
||||
logger.info("[%s] %s 收到请求: hairline_id=%s(其余用默认值)", rid, tag, hairline_id)
|
||||
try:
|
||||
data = await run_in_threadpool(
|
||||
generate_hairline_redraw, image, hairline_id,
|
||||
rid=rid, **_V2_FINAL_DEFAULTS)
|
||||
except NoFaceError:
|
||||
return err(1001, "无法识别人像")
|
||||
except SwapError as se:
|
||||
return err(1007, f"换发型失败:{se}")
|
||||
logger.info("[%s] %s 成功返回", rid, tag)
|
||||
return ok(data)
|
||||
except Exception as ex: # noqa: BLE001
|
||||
logger.exception("%s 处理异常", tag)
|
||||
return err(1007, f"处理失败:{ex}")
|
||||
|
||||
|
||||
@app.post(
|
||||
"/api/v1/hairline/grow_v2_final",
|
||||
summary="接口12 final 精简重绘(整帧重绘;仅图片 + 发型 ID)",
|
||||
tags=["生发"],
|
||||
description=f"""
|
||||
接口12 的**精简/生产版**:只需上传图片 + 选择 `hairline_id`,其余所有参数固化为当前调优默认值
|
||||
(hairline_push_cm=0.8 / blend_method=two_stage / **color_match=关闭** / color_match_strength=0.4 /
|
||||
beauty_alpha=0.6 / band_lo_mult=0.5 / band_hi_mult=1.5 等)。
|
||||
|
||||
**最终重绘取整帧重绘**(`data.steps.redraw_full`,全脸美颜 + 全脸重绘)。
|
||||
返回结构与接口12 一致(`data.steps` 同时含 `redraw_full`(A 整帧)/ `redraw_band`(B 局部+美颜))。
|
||||
|
||||
⚠️ 依赖 ComfyUI(默认 :8188)在跑。
|
||||
|
||||
{_image_fields_desc}
|
||||
""",
|
||||
)
|
||||
async def hairline_grow_v2_final(
|
||||
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG)"),
|
||||
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
||||
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
||||
hairline_id: str = Form(..., description="发际线类型 ID(= change_hair hair_id,如 chang_bolang)"),
|
||||
):
|
||||
"""接口12 final:整帧重绘版,仅需图片 + hairline_id。"""
|
||||
return await _run_v2_final(image_file, image_url, image_base64, hairline_id, "接口12final")
|
||||
|
||||
|
||||
@app.post(
|
||||
"/api/v1/hairline/grow_v2_final_v2",
|
||||
summary="接口12 final v2 精简重绘(B 局部加发+全脸美颜;仅图片 + 发型 ID)",
|
||||
tags=["生发"],
|
||||
description=f"""
|
||||
接口12 final 的**局部加发版**:参数与 `grow_v2_final` 完全相同(color_match 关闭等),
|
||||
唯一区别是**最终重绘取 B 局部加发+全脸美颜**(`data.steps.redraw_band`:
|
||||
加发只在发际线带内、band 外保留 final 结构并按 beauty_alpha 融入全脸美颜)。
|
||||
|
||||
返回结构与接口12 一致(`data.steps` 同时含 `redraw_full`(A 整帧)/ `redraw_band`(B 局部+美颜))。
|
||||
|
||||
⚠️ 依赖 ComfyUI(默认 :8188)在跑。
|
||||
|
||||
{_image_fields_desc}
|
||||
""",
|
||||
)
|
||||
async def hairline_grow_v2_final_v2(
|
||||
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG)"),
|
||||
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
||||
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
||||
hairline_id: str = Form(..., description="发际线类型 ID(= change_hair hair_id,如 chang_bolang)"),
|
||||
):
|
||||
"""接口12 final v2:B 局部加发+全脸美颜版,仅需图片 + hairline_id。"""
|
||||
return await _run_v2_final(image_file, image_url, image_base64, hairline_id, "接口12finalv2")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 重绘端点(替代 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 遮罩计算全过程)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/v1/debug/hairline_log", include_in_schema=False)
|
||||
async def download_hairline_log(rid: Optional[str] = None, tail: int = 500):
|
||||
"""返回 /home/xsl/hair/log/hairline_grow.log 的内容。
|
||||
"""返回 <仓库根>/log/hairline_grow.log 的内容。
|
||||
|
||||
rid 非空时只返回该 request id 相关的行;tail 限制返回最后 N 行(默认 500)。
|
||||
供调试页"下载日志"按钮调用。
|
||||
"""
|
||||
from fastapi.responses import PlainTextResponse
|
||||
log_path = "/home/xsl/hair/log/hairline_grow.log"
|
||||
log_path = os.getenv(
|
||||
"HAIR_LOG_DIR",
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "log"),
|
||||
)
|
||||
log_path = os.path.join(log_path, "hairline_grow.log")
|
||||
try:
|
||||
with open(log_path, encoding="utf-8") as fh:
|
||||
lines = fh.readlines()
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""发型对比矩阵测试:3图×5发型=15行,每行10张图(4b@896×1 + 9b三模型×三分辨率×9)。
|
||||
按模型分组跑(减少模型切换次数、降低OOM风险),结果重组为15行存JSON+生成报告。
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
API = "http://127.0.0.1:8187/api/v1/hair/grow"
|
||||
TOKEN = "dev-shared-secret-2026"
|
||||
OUT = Path("/home/ubuntu/hair/benchmark_out/hairstyle")
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
IMGS = [
|
||||
("asdf", "/home/ubuntu/hair/image/asdf.jpg"),
|
||||
("qwer", "/home/ubuntu/hair/image/qwer.jpg"),
|
||||
("girl5", "/home/ubuntu/hair/image/girl_img/girl5.jpg"),
|
||||
]
|
||||
HAIRSTYLES = [
|
||||
(1, "ellipse", "椭圆"), (2, "flower", "花瓣"), (3, "heart", "心形"),
|
||||
(4, "straight", "直线"), (5, "wave", "波浪"),
|
||||
]
|
||||
|
||||
# 按模型分组:每个模型对应其要跑的(分辨率,列标题)
|
||||
MODEL_GROUPS = [
|
||||
("flux-2-klein-4b-fp8.safetensors", [("896", "4B@896")]),
|
||||
("flux2.0/flux-2-klein-9b-fp8.safetensors",
|
||||
[("0", "9B-fp8@原图"), ("896", "9B-fp8@896"), ("640", "9B-fp8@640")]),
|
||||
("flux-2-klein-9b-Q5_K_M.gguf",
|
||||
[("0", "9B-Q5@原图"), ("896", "9B-Q5@896"), ("640", "9B-Q5@640")]),
|
||||
("flux-2-klein-9b-Q4_K_M.gguf",
|
||||
[("0", "9B-Q4@原图"), ("896", "9B-Q4@896"), ("640", "9B-Q4@640")]),
|
||||
]
|
||||
# 列顺序(4b在前,然后9b三模型)
|
||||
COLUMN_TITLES = ["4B@896", "9B-fp8@原图", "9B-fp8@896", "9B-fp8@640",
|
||||
"9B-Q5@原图", "9B-Q5@896", "9B-Q5@640",
|
||||
"9B-Q4@原图", "9B-Q4@896", "9B-Q4@640"]
|
||||
|
||||
|
||||
def gpu_used():
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["nvidia-smi", "--query-gpu=memory.used", "--format=csv,noheader,nounits"], timeout=10)
|
||||
return int(out.decode().strip())
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def call(img_path, hair_num, model_file, res_val):
|
||||
fd = {"gender": "female", "hair_style": str(hair_num), "use_mask": "true",
|
||||
"prompt": "填充遮罩区域的头发"}
|
||||
if model_file:
|
||||
fd["flux_model"] = model_file
|
||||
if res_val != "":
|
||||
fd["redraw_max_side"] = res_val
|
||||
t0 = time.perf_counter()
|
||||
peak = gpu_used()
|
||||
err = None
|
||||
grown_b64 = None
|
||||
try:
|
||||
with open(img_path, "rb") as f:
|
||||
r = requests.post(API, headers={"X-Internal-Token": TOKEN},
|
||||
files={"image_file": (os.path.basename(img_path), f, "image/jpeg")},
|
||||
data=fd, timeout=300)
|
||||
elapsed = time.perf_counter() - t0
|
||||
peak = max(peak, gpu_used())
|
||||
j = r.json()
|
||||
if j.get("code") != 0:
|
||||
err = f"code={j.get('code')} {j.get('message', '')[:60]}"
|
||||
else:
|
||||
res = j.get("data", {}).get("results", [])
|
||||
if res and res[0].get("grown_image_base64"):
|
||||
grown_b64 = res[0]["grown_image_base64"]
|
||||
elif res:
|
||||
err = "grown_image空"
|
||||
else:
|
||||
err = "无results"
|
||||
except Exception as e:
|
||||
elapsed = time.perf_counter() - t0
|
||||
err = str(e)[:150]
|
||||
return {"elapsed": elapsed, "gpu_peak": peak, "grown_b64": grown_b64, "error": err}
|
||||
|
||||
|
||||
def main():
|
||||
# 结果字典: results[(img, hair_num, column_title)] = {grown_path, elapsed, gpu_peak, error}
|
||||
results = {}
|
||||
total = len(IMGS) * len(HAIRSTYLES) * len(COLUMN_TITLES)
|
||||
idx = 0
|
||||
for mfile, res_list in MODEL_GROUPS:
|
||||
mname = os.path.basename(mfile)
|
||||
print(f"\n===== 切换到模型: {mname} =====", flush=True)
|
||||
# 等模型切换稳定
|
||||
time.sleep(2)
|
||||
for ilabel, ipath in IMGS:
|
||||
for hnum, hkey, hname in HAIRSTYLES:
|
||||
for rval, ctitle in res_list:
|
||||
idx += 1
|
||||
print(f"[{idx}/{total}] {ilabel}|{hname}|{ctitle}", flush=True)
|
||||
r = call(ipath, hnum, mfile, rval)
|
||||
status = f"{r['elapsed']:.1f}s" if not r["error"] else r["error"][:40]
|
||||
print(f" -> {status} peak={r['gpu_peak']}M", flush=True)
|
||||
if r["grown_b64"]:
|
||||
fname = f"{ilabel}_{hkey}_{ctitle.replace('@','_').replace('-','')}.jpg"
|
||||
with open(OUT / fname, "wb") as gf:
|
||||
gf.write(base64.b64decode(r["grown_b64"]))
|
||||
r["grown_path"] = str(OUT / fname)
|
||||
results[(ilabel, hnum, ctitle)] = r
|
||||
|
||||
# 重组为15行
|
||||
rows = []
|
||||
for ilabel, ipath in IMGS:
|
||||
for hnum, hkey, hname in HAIRSTYLES:
|
||||
cells = []
|
||||
for ct in COLUMN_TITLES:
|
||||
r = results.get((ilabel, hnum, ct), {"error": "未跑"})
|
||||
cells.append({"title": ct, **{k: v for k, v in r.items() if k != "grown_b64"}})
|
||||
rows.append({"img": ilabel, "img_path": ipath,
|
||||
"hair_num": hnum, "hair_key": hkey, "hair_name": hname,
|
||||
"cells": cells})
|
||||
with open(OUT / "results.json", "w", encoding="utf-8") as f:
|
||||
json.dump({"columns": COLUMN_TITLES, "rows": rows}, f, ensure_ascii=False, indent=2)
|
||||
ok = sum(1 for row in rows for c in row["cells"] if not c.get("error"))
|
||||
print(f"\n✓ 完成: {ok}/{total} 成功 -> {OUT/'results.json'}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""把发型对比测试结果生成 HTML 报告。
|
||||
15行(3图×5发型) × 10列(4b@896 + 9b三模型×三分辨率),每行首列=原图。
|
||||
图片 base64 内嵌,自包含单文件。
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
OUT = Path("/home/ubuntu/hair/benchmark_out/hairstyle")
|
||||
RESULTS = OUT / "results.json"
|
||||
HTML = OUT / "report.html"
|
||||
|
||||
|
||||
def img_src(path):
|
||||
"""把绝对路径转成报告里的相对 URL(报告在 static/,图片在 static/bench/)。"""
|
||||
if not path:
|
||||
return None
|
||||
p = str(path)
|
||||
if "benchmark_out/hairstyle/" in p:
|
||||
return "bench/hairstyle/" + os.path.basename(p)
|
||||
if "benchmark_out/matrix/" in p:
|
||||
return "bench/matrix/" + os.path.basename(p)
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
d = json.load(open(RESULTS, encoding="utf-8"))
|
||||
columns = d["columns"]
|
||||
rows = d["rows"]
|
||||
|
||||
# 统计每列的平均耗时、峰值显存
|
||||
col_stats = {}
|
||||
for ct in columns:
|
||||
times, peaks = [], []
|
||||
for r in rows:
|
||||
for c in r["cells"]:
|
||||
if c.get("title") == ct and not c.get("error"):
|
||||
times.append(c["elapsed"])
|
||||
peaks.append(c["gpu_peak"])
|
||||
col_stats[ct] = {
|
||||
"avg_t": sum(times) / len(times) if times else 0,
|
||||
"max_p": max(peaks) / 1024 if peaks else 0,
|
||||
}
|
||||
|
||||
# 表头:原图 + 10列
|
||||
headers = ['<th class="col-label">原图</th>']
|
||||
for ct in columns:
|
||||
s = col_stats[ct]
|
||||
headers.append(
|
||||
f'<th class="col-label"><div class="col-title">{ct}</div>'
|
||||
f'<div class="col-stat">{s["avg_t"]:.0f}s · {s["max_p"]:.0f}G</div></th>'
|
||||
)
|
||||
|
||||
# 表体:15行
|
||||
body_rows = []
|
||||
for r in rows:
|
||||
# 发型+图标签
|
||||
label = f'<div class="row-label">{r["img"]}<br><b>{r["hair_name"]}</b></div>'
|
||||
# 原图
|
||||
ORIG_SRC = {"asdf": "bench/orig/asdf.jpg", "qwer": "bench/orig/qwer.jpg", "girl5": "bench/orig/girl5.jpg"}
|
||||
orig = ORIG_SRC.get(r["img"])
|
||||
cells = [f'<td class="cell-orig"><div class="row-label-cell">{label}</div>'
|
||||
f'<img class="orig-img" src="{orig}"></td>']
|
||||
# 10个结果列
|
||||
for ct in columns:
|
||||
c = next((x for x in r["cells"] if x.get("title") == ct), {})
|
||||
src = img_src(c.get("grown_path")) if not c.get("error") else None
|
||||
if src:
|
||||
cells.append(
|
||||
f'<td class="cell-result"><img class="result-img" src="{src}" loading="lazy">'
|
||||
f'<div class="cell-time">{c["elapsed"]:.1f}s</div></td>')
|
||||
else:
|
||||
cells.append(f'<td class="cell-result"><div class="na">⚠</div></td>')
|
||||
body_rows.append(f'<tr>{"".join(cells)}</tr>')
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>发型对比测试报告 — 4模型×3分辨率</title>
|
||||
<style>
|
||||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||
body {{ font-family: -apple-system, "Segoe UI", sans-serif; background: #f5f5f5; padding: 16px; }}
|
||||
h1 {{ font-size: 20px; margin-bottom: 4px; }}
|
||||
.subtitle {{ color: #888; font-size: 12px; margin-bottom: 12px; }}
|
||||
.legend {{ background: #fff; border-radius: 8px; padding: 10px 16px; margin-bottom: 12px; font-size: 12px; color: #555; }}
|
||||
.scroll-wrap {{ overflow-x: auto; }}
|
||||
table {{ border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.06); }}
|
||||
th, td {{ border: 1px solid #eee; padding: 6px; vertical-align: top; text-align: center; }}
|
||||
th {{ background: #f9fafb; position: sticky; top: 0; }}
|
||||
.col-label {{ min-width: 110px; max-width: 130px; }}
|
||||
.col-title {{ font-size: 12px; font-weight: 700; color: #374151; }}
|
||||
.col-stat {{ font-size: 10px; color: #9ca3af; margin-top: 2px; }}
|
||||
.row-label {{ font-size: 11px; color: #6b7280; }}
|
||||
.row-label b {{ color: #1f2937; }}
|
||||
.row-label-cell {{ font-size: 11px; color: #6b7280; margin-bottom: 4px; }}
|
||||
.row-label-cell b {{ color: #1f2937; font-size: 13px; }}
|
||||
img {{ border-radius: 4px; max-width: 120px; max-height: 150px; object-fit: contain; background: #f3f4f6; }}
|
||||
.orig-img {{ border: 2px solid #d1d5db; max-height: 130px; }}
|
||||
.cell-time {{ font-size: 10px; color: #9ca3af; margin-top: 2px; }}
|
||||
.na {{ color: #d1d5db; font-size: 16px; padding: 40px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>💇 发型对比测试报告</h1>
|
||||
<p class="subtitle">接口2女性 · 3图×5发型=15行 · 每行: 4B@896(1) + 9B(fp8/Q5/Q4)×(原图/896/640)(9) · 150/150成功 · RTX3090</p>
|
||||
<div class="legend">列标题下显示<b>平均耗时 · 峰值显存</b>。横向滚动查看更多列。原图列含图片名+发型名。</div>
|
||||
<div class="scroll-wrap">
|
||||
<table>
|
||||
<tr>{"".join(headers)}</tr>
|
||||
{"".join(body_rows)}
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
with open(HTML, "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
print(f"✓ 报告: {HTML} ({HTML.stat().st_size//1024} KB)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""接口2女性 花瓣形 单发型 4模型×3分辨率×3图×3次 矩阵测试。
|
||||
|
||||
调用本机 hair-worker (:8187) 的 /api/v1/hair/grow,gender=female, hair_style=2(花瓣形)。
|
||||
每次记录:生发图、耗时、显存峰值。结果图存到 benchmark_out/matrix/,最后生成 HTML 报告。
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
API = "http://127.0.0.1:8187/api/v1/hair/grow"
|
||||
TOKEN = "dev-shared-secret-2026"
|
||||
OUT = Path("/home/ubuntu/hair/benchmark_out/matrix")
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 4 模型 × 3 分辨率 × 3 图 × 3 次
|
||||
MODELS = [
|
||||
("4b-fp8", "flux-2-klein-4b-fp8.safetensors"),
|
||||
("9b-fp8", "flux2.0/flux-2-klein-9b-fp8.safetensors"),
|
||||
("9b-Q5", "flux-2-klein-9b-Q5_K_M.gguf"),
|
||||
("9b-Q4", "flux-2-klein-9b-Q4_K_M.gguf"),
|
||||
]
|
||||
RES = [("orig", "0"), ("640", "640"), ("896", "896")]
|
||||
IMGS = [
|
||||
("asdf", "/home/ubuntu/hair/image/asdf.jpg"),
|
||||
("qwer", "/home/ubuntu/hair/image/qwer.jpg"),
|
||||
("girl5", "/home/ubuntu/hair/image/girl_img/girl5.jpg"),
|
||||
]
|
||||
REPEAT = 3
|
||||
|
||||
|
||||
def gpu_used():
|
||||
"""返回当前显存已用 MiB。"""
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["nvidia-smi", "--query-gpu=memory.used", "--format=csv,noheader,nounits"],
|
||||
timeout=10,
|
||||
)
|
||||
return int(out.decode().strip())
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def call(img_path, model_file, res_val):
|
||||
"""调一次接口2。返回 dict: ok/elapsed/grown_path/gpu_peak/error。"""
|
||||
fd = {
|
||||
"gender": "female",
|
||||
"hair_style": "2", # 花瓣形
|
||||
"use_mask": "true",
|
||||
"prompt": "填充遮罩区域的头发",
|
||||
}
|
||||
if model_file:
|
||||
fd["flux_model"] = model_file
|
||||
if res_val != "":
|
||||
fd["redraw_max_side"] = res_val
|
||||
t0 = time.perf_counter()
|
||||
peak = gpu_used()
|
||||
err = None
|
||||
grown_path = None
|
||||
try:
|
||||
with open(img_path, "rb") as f:
|
||||
r = requests.post(
|
||||
API, headers={"X-Internal-Token": TOKEN},
|
||||
files={"image_file": (os.path.basename(img_path), f, "image/jpeg")},
|
||||
data=fd, timeout=300,
|
||||
)
|
||||
elapsed = time.perf_counter() - t0
|
||||
# 采样峰值(推理刚结束)
|
||||
peak = max(peak, gpu_used())
|
||||
j = r.json()
|
||||
if j.get("code") != 0:
|
||||
err = f"code={j.get('code')} {j.get('message','')}"
|
||||
else:
|
||||
res = j.get("data", {}).get("results", [])
|
||||
if res and res[0].get("grown_image_base64"):
|
||||
grown_path = OUT / f"tmp_grown.jpg"
|
||||
with open(grown_path, "wb") as gf:
|
||||
gf.write(base64.b64decode(res[0]["grown_image_base64"]))
|
||||
elif res:
|
||||
err = "grown_image_base64 为空"
|
||||
else:
|
||||
err = "无 results"
|
||||
except Exception as e:
|
||||
elapsed = time.perf_counter() - t0
|
||||
err = str(e)[:200]
|
||||
return {"elapsed": elapsed, "gpu_peak": peak, "grown_path": str(grown_path) if grown_path else None, "error": err}
|
||||
|
||||
|
||||
def main():
|
||||
results = [] # 每元素一个组合
|
||||
total = len(MODELS) * len(RES) * len(IMGS) * REPEAT
|
||||
idx = 0
|
||||
for mlabel, mfile in MODELS:
|
||||
for rlabel, rval in RES:
|
||||
for ilabel, ipath in IMGS:
|
||||
# 一个组合:3 次
|
||||
runs = []
|
||||
for rep in range(REPEAT):
|
||||
idx += 1
|
||||
print(f"[{idx}/{total}] {mlabel} | res={rlabel} | {ilabel} | rep{rep+1}", flush=True)
|
||||
r = call(ipath, mfile, rval)
|
||||
print(f" -> {r['elapsed']:.1f}s peak={r['gpu_peak']}MiB err={r['error']}", flush=True)
|
||||
# 存每次的生发图
|
||||
if r["grown_path"]:
|
||||
save_to = OUT / f"{mlabel}_{rlabel}_{ilabel}_r{rep+1}.jpg"
|
||||
os.replace(r["grown_path"], save_to)
|
||||
r["grown_path"] = str(save_to)
|
||||
runs.append(r)
|
||||
results.append({
|
||||
"model": mlabel, "model_file": mfile,
|
||||
"res": rlabel, "res_val": rval,
|
||||
"img": ilabel, "img_path": ipath,
|
||||
"runs": runs,
|
||||
})
|
||||
# 存原始数据
|
||||
with open(OUT / "results.json", "w", encoding="utf-8") as f:
|
||||
json.dump(results, f, ensure_ascii=False, indent=2)
|
||||
print(f"\n✓ 全部完成,原始数据 -> {OUT/'results.json'}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""把 benchmark_out/matrix/results.json 生成 HTML 报告。
|
||||
每个组合一行:原图 + 3次生发图 + 耗时/显存。
|
||||
图片用 base64 内嵌(自包含单文件,便于部署)。
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
OUT = Path("/home/ubuntu/hair/benchmark_out/matrix")
|
||||
RESULTS = OUT / "results.json"
|
||||
HTML = OUT / "report.html"
|
||||
|
||||
RES_LABEL = {"orig": "原图", "640": "640", "896": "896(默认)"}
|
||||
MODEL_LABEL = {
|
||||
"4b-fp8": "4B fp8 (3.8G)",
|
||||
"9b-fp8": "9B fp8 (8.8G)",
|
||||
"9b-Q5": "9B Q5_K_M (6.6G)",
|
||||
"9b-Q4": "9B Q4_K_M (5.6G)",
|
||||
}
|
||||
MODEL_ORDER = ["4b-fp8", "9b-Q4", "9b-Q5", "9b-fp8"]
|
||||
|
||||
|
||||
def img_src(path):
|
||||
"""把绝对路径转成报告里的相对 URL(报告在 static/,图片在 static/bench/)。"""
|
||||
if not path:
|
||||
return None
|
||||
p = str(path)
|
||||
# benchmark_out/matrix/xxx.jpg -> bench/matrix/xxx.jpg
|
||||
if "benchmark_out/matrix/" in p:
|
||||
return "bench/matrix/" + os.path.basename(p)
|
||||
if "benchmark_out/hairstyle/" in p:
|
||||
return "bench/hairstyle/" + os.path.basename(p)
|
||||
return None
|
||||
|
||||
|
||||
def thumb(src, alt="", cls=""):
|
||||
if not src:
|
||||
return f'<div class="na {cls}">⚠ 失败</div>'
|
||||
return f'<img class="{cls}" src="{src}" alt="{alt}" loading="lazy">'
|
||||
|
||||
|
||||
def main():
|
||||
data = json.load(open(RESULTS, encoding="utf-8"))
|
||||
# 原图相对路径映射(图片在 static/bench/orig/)
|
||||
ORIG_SRC = {"asdf": "bench/orig/asdf.jpg", "qwer": "bench/orig/qwer.jpg", "girl5": "bench/orig/girl5.jpg"}
|
||||
|
||||
# 统计:每个模型的平均耗时、平均峰值显存
|
||||
stats = {}
|
||||
for c in data:
|
||||
m = c["model"]
|
||||
stats.setdefault(m, {"times": [], "peaks": []})
|
||||
for r in c["runs"]:
|
||||
if not r["error"]:
|
||||
stats[m]["times"].append(r["elapsed"])
|
||||
stats[m]["peaks"].append(r["gpu_peak"])
|
||||
|
||||
rows_html = []
|
||||
# 按模型顺序、分辨率顺序、图片顺序排列
|
||||
for m in MODEL_ORDER:
|
||||
mdata = [c for c in data if c["model"] == m]
|
||||
for rlabel in ["orig", "640", "896"]:
|
||||
for ilabel in ["asdf", "qwer", "girl5"]:
|
||||
c = next((x for x in mdata if x["res"] == rlabel and x["img"] == ilabel), None)
|
||||
if not c:
|
||||
continue
|
||||
# 3 次结果图
|
||||
run_cells = []
|
||||
for i, r in enumerate(c["runs"]):
|
||||
src = img_src(r["grown_path"]) if not r["error"] else None
|
||||
if src:
|
||||
run_cells.append(
|
||||
f'<div class="run-cell"><div class="run-label">第{i+1}次 · {r["elapsed"]:.1f}s</div>'
|
||||
f'{thumb(src, f"r{i+1}", "result-img")}</div>'
|
||||
)
|
||||
else:
|
||||
run_cells.append(
|
||||
f'<div class="run-cell"><div class="run-label">第{i+1}次 · 失败</div>'
|
||||
f'<div class="na">⚠ {r["error"][:30] if r["error"] else ""}</div></div>'
|
||||
)
|
||||
|
||||
orig = ORIG_SRC.get(c["img"])
|
||||
rows_html.append(f'''
|
||||
<div class="combo-row">
|
||||
<div class="cell-model">{MODEL_LABEL.get(m, m)}<div class="cell-sub">res={RES_LABEL.get(rlabel, rlabel)}</div></div>
|
||||
<div class="cell-img">{thumb(orig, "原图", "orig-img")}<div class="run-label">{ilabel}</div></div>
|
||||
<div class="cell-runs">{"".join(run_cells)}</div>
|
||||
</div>''')
|
||||
|
||||
# 模型对比汇总
|
||||
summary_rows = []
|
||||
for m in MODEL_ORDER:
|
||||
s = stats.get(m, {"times": [], "peaks": []})
|
||||
if s["times"]:
|
||||
avg_t = sum(s["times"]) / len(s["times"])
|
||||
max_p = max(s["peaks"]) / 1024
|
||||
summary_rows.append(
|
||||
f"<tr><td>{MODEL_LABEL.get(m,m)}</td><td>{avg_t:.1f}s</td>"
|
||||
f"<td>{max_p:.1f} GB</td><td>{len(s['times'])} 成功</td></tr>"
|
||||
)
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Flux 模型矩阵测试报告 — 接口2女性花瓣形</title>
|
||||
<style>
|
||||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f5f5; color: #333; padding: 20px; }}
|
||||
h1 {{ font-size: 22px; margin-bottom: 4px; }}
|
||||
.subtitle {{ color: #888; font-size: 13px; margin-bottom: 16px; }}
|
||||
.summary {{ background: #fff; border-radius: 10px; padding: 16px 20px; margin-bottom: 20px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }}
|
||||
.summary h2 {{ font-size: 16px; margin-bottom: 10px; }}
|
||||
.summary table {{ border-collapse: collapse; width: 100%; font-size: 14px; }}
|
||||
.summary th, .summary td {{ border: 1px solid #e5e7eb; padding: 8px 12px; text-align: left; }}
|
||||
.summary th {{ background: #f9fafb; font-weight: 600; }}
|
||||
.combo-row {{ display: flex; align-items: flex-start; gap: 12px; background: #fff; border-radius: 10px;
|
||||
padding: 12px 16px; margin-bottom: 10px; box-shadow: 0 1px 3px rgba(0,0,0,.05); }}
|
||||
.cell-model {{ min-width: 130px; font-weight: 700; font-size: 14px; padding-top: 6px; }}
|
||||
.cell-sub {{ font-weight: 400; font-size: 12px; color: #6b7280; margin-top: 2px; }}
|
||||
.cell-img {{ min-width: 160px; text-align: center; }}
|
||||
.cell-runs {{ display: flex; gap: 10px; flex: 1; }}
|
||||
.run-cell {{ text-align: center; }}
|
||||
.run-label {{ font-size: 11px; color: #6b7280; margin-bottom: 4px; }}
|
||||
img {{ border-radius: 6px; max-height: 200px; max-width: 100%; object-fit: contain; background: #f9fafb; }}
|
||||
.orig-img {{ max-height: 180px; border: 2px solid #e5e7eb; }}
|
||||
.result-img {{ max-height: 200px; }}
|
||||
.na {{ color: #d1d5db; font-size: 12px; padding: 40px 20px; background: #f9fafb; border-radius: 6px; width: 150px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>💇 Flux 模型矩阵测试报告</h1>
|
||||
<p class="subtitle">接口2女性 · 花瓣形发型 · 4模型 × 3分辨率 × 3图 × 3次 = 108 次 · RTX 3090 24GB</p>
|
||||
|
||||
<div class="summary">
|
||||
<h2>📊 模型对比汇总</h2>
|
||||
<table>
|
||||
<tr><th>模型</th><th>平均耗时</th><th>峰值显存</th><th>成功次数</th></tr>
|
||||
{"".join(summary_rows)}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 style="font-size:16px;margin:24px 0 12px">🖼️ 各组合对比(每行:原图 + 3次生发结果)</h2>
|
||||
{"".join(rows_html)}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
with open(HTML, "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
print(f"✓ 报告已生成: {HTML} ({HTML.stat().st_size//1024} KB)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=ComfyUI (127.0.0.1:8188)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=ubuntu
|
||||
WorkingDirectory=/home/ubuntu/ComfyUI
|
||||
ExecStart=/home/ubuntu/ComfyUI/venv/bin/python main.py --listen 127.0.0.1 --port 8188 --cache-classic --fast
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
After Width: | Height: | Size: 304 KiB |
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"request_id": "mock-request-id",
|
||||
"data": {
|
||||
"hairline_id": "chang_zhixian",
|
||||
"gen_backend": "swaphair",
|
||||
"hairgrow_strength": 0.75,
|
||||
"is_hr": false,
|
||||
"seg_model": "segformer",
|
||||
"mask_type": "pushed",
|
||||
"erode_cm": 0.6,
|
||||
"swap_mode": "ext_mask",
|
||||
"blend_method": "multiband",
|
||||
"edge_erode_px": 3,
|
||||
"mb_levels": 5,
|
||||
"hairline_push_cm": 1.0,
|
||||
"hairline_edge": "column",
|
||||
"denoising_strength": 0.6,
|
||||
"color_match": true,
|
||||
"color_match_strength": 1.0,
|
||||
"mb_feather_px": 1,
|
||||
"transition_band_px": -1,
|
||||
"inpainting_fill": 1,
|
||||
"mask_blur": 11,
|
||||
"mask_dilate_scale": 1.0,
|
||||
"px_per_cm": 47.5311,
|
||||
"erode_px": 29,
|
||||
"hair_pixels": 186798,
|
||||
"closed_pixels": 191712,
|
||||
"mask_pixels": 140299,
|
||||
"image_size": {
|
||||
"width": 1257,
|
||||
"height": 1495
|
||||
},
|
||||
"timings_ms": {
|
||||
"mask": 1462,
|
||||
"swap": 5596,
|
||||
"blend": 220
|
||||
},
|
||||
"redraw": {
|
||||
"enabled": false
|
||||
},
|
||||
"_rid": "bc7205a4",
|
||||
"steps": {
|
||||
"input_base64": "<omitted 427407 chars>",
|
||||
"baseline_overlay_base64": "<omitted 435895 chars>",
|
||||
"upper_overlay_base64": "<omitted 384479 chars>",
|
||||
"hair_seg_overlay_base64": "<omitted 428039 chars>",
|
||||
"top_fill_overlay_base64": "",
|
||||
"closed_overlay_base64": "",
|
||||
"hairline_overlay_base64": "<omitted 445467 chars>",
|
||||
"pushed_overlay_base64": "<omitted 452803 chars>",
|
||||
"mask_overlay_base64": "<omitted 420375 chars>",
|
||||
"mask_base64": "<omitted 8026 chars>",
|
||||
"swap_raw_base64": "<omitted 340791 chars>",
|
||||
"hard_paste_base64": "<omitted 420679 chars>",
|
||||
"alpha_base64": "<omitted 7762 chars>",
|
||||
"final_base64": "<omitted 415811 chars>",
|
||||
"redraw_band_overlay_base64": "",
|
||||
"redraw_a_base64": "",
|
||||
"redraw_c_base64": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 304 KiB |
|
After Width: | Height: | Size: 314 KiB |
|
After Width: | Height: | Size: 326 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 313 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 332 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 282 KiB |
@@ -0,0 +1,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)(需手动在图上画发际线后作为划线图上传)
|
||||
@@ -1,11 +1,14 @@
|
||||
# 发际线生发遮罩算法(pushed 模式)
|
||||
|
||||
> 对应接口11 `/api/v1/hairline/grow`、接口12 `/api/v1/hairline/grow_v2`,`mask_type=pushed`。
|
||||
> 代码:`face_analysis/hairline_grow.py`(`_extract_hairline` / `_pushed_mask` / `compute_mask`)。
|
||||
> 对应接口11 `/api/v1/hairline/grow`、接口12 `/api/v1/hairline/grow_v2`。
|
||||
> 遮罩算法固定为 pushed;融合算法默认 multiband(多频段金字塔),接口11 可切换 seamless/two_stage/feather。
|
||||
> 代码:`face_analysis/hairline_grow.py`(`_extract_hairline` / `_pushed_mask` / `compute_mask` / `_composite`)。
|
||||
|
||||
## 概述
|
||||
|
||||
pushed 模式是发际线生发的默认遮罩算法(接口11/12 的 `mask_type` 三选一:`eroded` / `closed` / `pushed`,当前只用 pushed)。它从头发分割结果中提取「头发/皮肤交界线」(发际线),以眉心为圆心逐点径向外推一段距离,与 baseline 组成闭合区域作为最终遮罩。这样遮罩顶部会覆盖现有头发下沿一小段,贴回生发结果时顶部与真头发重叠、过渡自然。
|
||||
pushed 是发际线生发的**唯一**遮罩算法。融合算法默认 multiband(多频段金字塔),接口11 暴露 `blend_method` 可切换为 seamless(泊松)/two_stage(泊松→多频段两段式)/feather(羽化),便于对比调优。它从头发分割结果中提取「头发/皮肤交界线」(发际线),以眉心为圆心逐点径向外推一段距离,与 baseline 组成闭合区域作为最终遮罩。这样遮罩顶部会覆盖现有头发下沿一小段,贴回生发结果时顶部与真头发重叠、过渡自然。
|
||||
|
||||
> 接口12 `/api/v1/hairline/grow_v2` 只需传 `image` + `hairline_id`,遮罩和融合全部固定,无需任何算法选择参数。
|
||||
|
||||
## 算法流程(5 步)
|
||||
|
||||
@@ -54,15 +57,52 @@ segformer(默认)或 bisenet 得到的头发二值掩码。
|
||||
|
||||
## 关键参数
|
||||
|
||||
遮罩算法(pushed)固定。融合算法接口11 通过 `blend_method` 可切换(默认 multiband),其余融合参数均可调:
|
||||
|
||||
| 参数 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `mask_type` | `eroded`(接口默认)/ `pushed`(当前推荐) | pushed 走上述流程;eroded/closed 走旧的 top_fill→closed/eroded 流程 |
|
||||
| `hairline_push_cm` | 1.0 | 内轮廓径向外推距离(厘米),= push_px / px_per_cm。`px_per_cm` 由虹膜直径标定 |
|
||||
| `hairline_edge` | `column` | 兼容保留的入参;新版内轮廓提取(轮廓+内侧判定)不再按它分支,取值不影响结果 |
|
||||
| `hairline_edge` | `column` | 兼容保留的入参;内轮廓提取(轮廓+内侧判定)不再按它分支,取值不影响结果 |
|
||||
| `mb_levels` | 5 | 多频段金字塔层数(2~6,越大低频色差抹得越宽)|
|
||||
| `blend_method` | `multiband` | 接缝融合:multiband(多频段金字塔) / seamless(泊松) / two_stage(泊松→多频段,大色差) / feather(羽化) / alpha_gradient。接口12 固定 multiband |
|
||||
| `color_match` | `true` | 融合前 Reinhard 颜色迁移消除整体色差(multiband/feather/alpha_gradient 生效;seamless/two_stage 自带调色故跳过)|
|
||||
| `color_match_strength` | 1.0 | 颜色迁移强度(0~1,<1 只迁移部分,防 Reinhard 过度改色)|
|
||||
| `mb_feather_px` | 1 | 多频段最细层掩码轻羽化像素(0=不羽化),消除发丝边缘锯齿 |
|
||||
| `transition_band_px` | -1 | keep-region 过渡带边距(-1=自动按层数 `2**n`;>=0 用绝对像素与层数解耦)|
|
||||
| `edge_erode_px` | 3 | 贴图前遮罩内缩像素(防边缘露皮/光晕)|
|
||||
| `erode_cm` | 0.6(接口12 固定)| baseline 参考内缩距离,对 pushed 影响很小 |
|
||||
| `redraw` | `false` | 发际线带重绘开关:开启后用 final(④融合图)在「外推线↔发际线」带重绘,swapHair/Flux-2 两路对比,结果单独展示(不替换 final)|
|
||||
| `inpainting_fill` | 1 | change_hair 重绘填充:0=保留原图(治染绿) / 1=填充噪声(默认) / 2=纯色 / 3=潜变量噪声 |
|
||||
| `mask_blur` | 11 | change_hair 遮罩边缘模糊像素(越大颜色越易从边缘渗透)|
|
||||
| `mask_dilate_scale` | 1.0 | change_hair 遮罩膨胀核缩放(1.0=原始,<1 收缩防越界)|
|
||||
| `comfyui_prompt` | `null` | redraw Flux-2 路提示词,null 用默认「补充遮罩区域的头发,加一点美颜」|
|
||||
|
||||
## 与旧模式(eroded/closed)的区别
|
||||
> 接口12 `/api/v1/hairline/grow_v2` 只需传 `image` + `hairline_id`,遮罩和融合全部用默认值(multiband + color_match=true),不暴露算法选择参数。
|
||||
|
||||
| | eroded/closed | pushed(当前) |
|
||||
### 融合方法选择建议
|
||||
|
||||
- **multiband**(默认):常规首选。低频抹色差、高频保发丝。需配合 `color_match=true` 消除整体色差。
|
||||
- **two_stage**:生成图与原图色差大时用。先泊松克隆统一色调,再多频段贴细节,兼顾调色与保发丝。比纯 seamless 更不易溢色。
|
||||
- **seamless**:纯泊松梯度域调和,色调统一干净,但可能整体改色/边缘溢色。
|
||||
- **feather / alpha_gradient**:单层 alpha 过渡,最轻量,但过渡带内色差不会被抹平,仅适合色差极小的场景。
|
||||
|
||||
## 发际线带重绘(redraw,接口11 可选)
|
||||
|
||||
`redraw=true` 时,在主流程(④接缝融合 final)之后额外跑一条重绘分支,结果单独展示(`steps.redraw_a` / `redraw_c`),**不替换** final。
|
||||
|
||||
**重绘区域** = ①-g 外推发际线(`outer_pts`)与 ①-f 发际线(`inner_pts`)两条折线端点相连组成的带状闭合区域(宽度 ≈ `hairline_push_cm`,只覆盖发际线交界处)。
|
||||
|
||||
**两路后端对比**(输入图 + 融合基底都用 final):
|
||||
- **swapHair 路**(`redraw_a`):final + 带遮罩调 change_hair → final 走 multiband 融合
|
||||
- **Flux-2 路**(`redraw_c`):final + 带遮罩调 ComfyUI(`hair_repaint.json` 工作流)→ final 走 multiband 融合。Flux-2 经 reference latent + ColorMatch 双重保色,**不易染绿**
|
||||
|
||||
> `inpainting_fill` / `mask_blur` / `mask_dilate_scale` 透传 change_hair 服务端(仅影响 swapHair 路)。`comfyui_prompt` 仅影响 Flux-2 路。
|
||||
> 两路独立容错:任一路失败只跳过该路,不影响另一路和主 final。
|
||||
> ⚠️ Flux-2 路需 ComfyUI(8188)在跑;swapHair 路需 change_hair(8801)在跑。
|
||||
|
||||
## 与旧模式(eroded/closed,已移除)的区别
|
||||
|
||||
| | eroded/closed(已移除) | pushed(当前唯一) |
|
||||
|---|---|---|
|
||||
| 遮罩来源 | top_fill(头发向下填充含额头)外缘内缩 | 头发内轮廓线 径向外推成带 |
|
||||
| 截断方式 | 用 baseline 组上半区 upper | 内侧判定 + 下颌 y 截断(不再用 baseline) |
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
# 接口11 运行记录 — `image/hair_test.jpg`
|
||||
|
||||
> 实测时间:2026-07-15
|
||||
> 调用:`POST http://127.0.0.1:8187/api/v1/hairline/grow`
|
||||
> 鉴权:`X-Internal-Token: dev-shared-secret-2026`
|
||||
> 输入图:`image/hair_test.jpg`(1257×1495)
|
||||
> `hairline_id`:`chang_zhixian`(直线);其余全部走接口默认值
|
||||
> 业务结果:`code=0`,`rid=bc7205a4`
|
||||
> 产物目录:`docs/iface11_hair_test_run/`
|
||||
|
||||
---
|
||||
|
||||
## 1. 本次调用用到的全部默认参数
|
||||
|
||||
未在 Form 里显式传的参数均取 `app.py` / `generate_hairline_grow` 默认值;下表即本次实际生效值。
|
||||
|
||||
| 参数 | 本次值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `hairline_id` | `chang_zhixian` | **必填**。发际线类型 = change_hair 的 `hair_id`(直线) |
|
||||
| `gen_backend` | `swaphair` | 生成后端:换发型 LoRA |
|
||||
| `hairgrow_strength` | `0.75` | 仅 `hairgrow` 后端用;本次未走该路径 |
|
||||
| `is_hr` | `false` | 高清关闭(576×768 档,非 1152×1536) |
|
||||
| `seg_model` | `segformer` | 头发分割模型 |
|
||||
| `erode_cm` | `0.6` | baseline 参考内缩(cm);pushed 下影响很小 |
|
||||
| `hairline_push_cm` | `1.0` | 发际线内轮廓径向外推距离(cm) |
|
||||
| `hairline_edge` | `column` | 兼容入参;当前内轮廓提取不再按它分支 |
|
||||
| `swap_mode` | `ext_mask` | 把 pushed 遮罩作为 `ext_mask` 传给 swapHair |
|
||||
| `edge_erode_px` | `3` | 贴图前遮罩内缩像素 |
|
||||
| `denoising_strength` | `0.6` | 换发型 webui 重绘强度 |
|
||||
| `mb_levels` | `5` | 多频段金字塔层数 |
|
||||
| `blend_method` | `multiband` | 接缝融合:多频段金字塔 |
|
||||
| `color_match` | `true` | 融合前 Reinhard 颜色迁移 |
|
||||
| `color_match_strength` | `1.0` | 颜色迁移强度(全迁移) |
|
||||
| `mb_feather_px` | `1` | 多频段最细层掩码轻羽化 |
|
||||
| `transition_band_px` | `-1` | keep-region 过渡带:自动按层数 `2**n` |
|
||||
| `redraw` | `false` | 发际线带重绘关闭 |
|
||||
| `inpainting_fill` | `1` | change_hair 填充噪声 |
|
||||
| `mask_blur` | `11` | change_hair 遮罩边缘模糊像素 |
|
||||
| `mask_dilate_scale` | `1.0` | change_hair 遮罩膨胀缩放 |
|
||||
| `comfyui_prompt` | `null` | 仅 `redraw`+Flux-2 路用;本次未用 |
|
||||
| `mask_type` | `pushed`(固定) | 代码写死,不可选 |
|
||||
|
||||
图片入参:仅传了 `image_file`(三选一中的文件上传)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 返回元数据(无 base64)
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|-----|
|
||||
| `px_per_cm` | 47.5311(虹膜直径标定) |
|
||||
| `erode_px` | 29(≈ 0.6cm × px_per_cm) |
|
||||
| `hair_pixels` | 186798 |
|
||||
| `closed_pixels` | 191712 |
|
||||
| `mask_pixels` | 140299 |
|
||||
| `image_size` | 1257 × 1495 |
|
||||
| `timings_ms.mask` | 1462 |
|
||||
| `timings_ms.swap` | 5596 |
|
||||
| `timings_ms.blend` | 220 |
|
||||
| `redraw.enabled` | false |
|
||||
| 总耗时(curl) | ≈ 7.4 s |
|
||||
|
||||
完整精简 JSON:`docs/iface11_hair_test_run/response_meta.json`
|
||||
完整原始响应(含 base64):`docs/iface11_hair_test_run/response.json`
|
||||
|
||||
---
|
||||
|
||||
## 3. 管线分步说明与产物
|
||||
|
||||
管线:① pushed 遮罩 → ② swapHair 生成 → ③ 硬贴回 → ④ multiband 融合。
|
||||
各步图保存在 `docs/iface11_hair_test_run/steps/`。
|
||||
|
||||
### ①-a 发际线分割线(baseline)
|
||||
|
||||
- **做什么**:MediaPipe 关键点连成眉骨折线(中心为 151 眉心),并向左右边缘水平延长。
|
||||
- **图**:[`steps/baseline_overlay.jpg`](iface11_hair_test_run/steps/baseline_overlay.jpg)
|
||||
- **含义**:黄线 = baseline;151 中心点为后续径向外推圆心。
|
||||
|
||||
### ①-b 分割线上半区(upper)
|
||||
|
||||
- **做什么**:baseline 折线以上的多边形区域,作为后续裁剪范围。
|
||||
- **图**:[`steps/upper_overlay.jpg`](iface11_hair_test_run/steps/upper_overlay.jpg)
|
||||
- **含义**:青 = 上半区。
|
||||
|
||||
### ①-c 头发分割(hair_seg)
|
||||
|
||||
- **做什么**:SegFormer 得到头发二值掩码。
|
||||
- **图**:[`steps/hair_seg_overlay.jpg`](iface11_hair_test_run/steps/hair_seg_overlay.jpg)
|
||||
- **含义**:绿 = 原始头发像素(本次 `hair_pixels=186798`)。
|
||||
|
||||
### ①-d / ①-e(旧 eroded/closed 中间步)
|
||||
|
||||
- pushed 模式**不走**这两步;返回字段为空字符串。
|
||||
- `top_fill_overlay` / `closed_overlay`:本次无图。
|
||||
|
||||
### ①-f 头发内轮廓线(hairline)
|
||||
|
||||
- **做什么**:取头发朝脸一侧的内轮廓(额头弧 + 两侧到下颌),有序折线。
|
||||
- **图**:[`steps/hairline_overlay.jpg`](iface11_hair_test_run/steps/hairline_overlay.jpg)
|
||||
- **含义**:绿 = 内轮廓;黄 = baseline。
|
||||
|
||||
### ①-g 外推发际线(pushed)
|
||||
|
||||
- **做什么**:以眉心 151 为圆心,内轮廓逐点向外推 `hairline_push_cm=1.0`(≈ 47.5 px),与 baseline 组闭合区域。
|
||||
- **图**:[`steps/pushed_overlay.jpg`](iface11_hair_test_run/steps/pushed_overlay.jpg)
|
||||
- **含义**:青 = 外推线;红 = 外推遮罩区域。
|
||||
|
||||
### ① 最终遮罩
|
||||
|
||||
- **叠加图**:[`steps/mask_overlay.jpg`](iface11_hair_test_run/steps/mask_overlay.jpg) — 红 = 遮罩区(贴回/生成区)
|
||||
- **纯遮罩**:[`steps/mask.png`](iface11_hair_test_run/steps/mask.png) — 白 = 生成/贴回区
|
||||
- 本次 `mask_pixels=140299`;贴图前再内缩 `edge_erode_px=3`。
|
||||
|
||||
### ② 生成全帧(swap_raw)
|
||||
|
||||
- **做什么**:`gen_backend=swaphair` + `swap_mode=ext_mask`,把遮罩交给 change_hair(`:8801`),LoRA=`chang_zhixian`,`denoising_strength=0.6`。
|
||||
- **图**:[`steps/swap_raw.jpg`](iface11_hair_test_run/steps/swap_raw.jpg)
|
||||
- **含义**:生成结果已与原图同分辨率对齐;耗时约 5.6 s。
|
||||
|
||||
### ③ 严格按遮罩贴回(hard_paste)
|
||||
|
||||
- **做什么**:遮罩内用生成图,遮罩外保持原图,无融合。
|
||||
- **图**:[`steps/hard_paste.jpg`](iface11_hair_test_run/steps/hard_paste.jpg)
|
||||
- **含义**:用于对比接缝融合前后差异。
|
||||
|
||||
### ④ 融合权重 alpha + 最终结果
|
||||
|
||||
- **做法**:`blend_method=multiband`,`mb_levels=5`,`color_match=true`(强度 1.0),`mb_feather_px=1`。
|
||||
- **alpha**:[`steps/alpha.png`](iface11_hair_test_run/steps/alpha.png) — 白 = 更多采用生成图
|
||||
- **最终输出**:[`steps/final.jpg`](iface11_hair_test_run/steps/final.jpg)(副本:[`final.jpg`](iface11_hair_test_run/final.jpg))
|
||||
- **输入对照**:[`steps/input.jpg`](iface11_hair_test_run/steps/input.jpg)
|
||||
|
||||
### ⑤ 发际线带重绘(本次关闭)
|
||||
|
||||
`redraw=false`,故 `redraw_band_overlay` / `redraw_a` / `redraw_c` 均为空。
|
||||
|
||||
---
|
||||
|
||||
## 4. 最终输出
|
||||
|
||||
**主结果文件**:[`docs/iface11_hair_test_run/final.jpg`](iface11_hair_test_run/final.jpg)
|
||||
|
||||
含义:同一人、同一发型观感下,按直线发际线类型(`chang_zhixian`)压低发际线后的合成图;遮罩外像素保持原图不动。
|
||||
|
||||
---
|
||||
|
||||
## 5. 复现命令
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "http://127.0.0.1:8187/api/v1/hairline/grow" \
|
||||
-H "X-Internal-Token: dev-shared-secret-2026" \
|
||||
-F "image_file=@image/hair_test.jpg" \
|
||||
-F "hairline_id=chang_zhixian" \
|
||||
-o docs/iface11_hair_test_run/response.json
|
||||
```
|
||||
|
||||
(其余参数全部省略即可走默认值。)
|
||||
|
||||
---
|
||||
|
||||
## 6. 产物清单
|
||||
|
||||
```
|
||||
docs/接口11_hair_test运行记录.md ← 本文档
|
||||
docs/iface11_hair_test_run/
|
||||
final.jpg ← 最终结果
|
||||
response.json ← 完整 API 响应(含 base64)
|
||||
response_meta.json ← 去掉大图的元数据
|
||||
steps/
|
||||
input.jpg
|
||||
baseline_overlay.jpg
|
||||
upper_overlay.jpg
|
||||
hair_seg_overlay.jpg
|
||||
hairline_overlay.jpg
|
||||
pushed_overlay.jpg
|
||||
mask_overlay.jpg
|
||||
mask.png
|
||||
swap_raw.jpg
|
||||
hard_paste.jpg
|
||||
alpha.png
|
||||
final.jpg
|
||||
```
|
||||
@@ -20,7 +20,6 @@
|
||||
| 3 B 端生发 | POST | `/api/v1/hair/grow-b` |
|
||||
| 4 用户特征 | POST | `/api/v1/face/features` |
|
||||
| 5 发际线 PNG 生成 | POST | `/api/v1/hairline/generate` |
|
||||
| 7 C 端生发 v2 | POST | `/api/v1/hair/grow-v2` |
|
||||
|
||||
---
|
||||
|
||||
@@ -87,6 +86,7 @@
|
||||
| 1006 | 文件超出大小限制 | 单文件超过 1 MB |
|
||||
| 1007 | 图片参数错误 | file / url / base64 未传,或同时传了多个(三者严格互斥) |
|
||||
| 1008 | 图片格式不支持 | 非 JPG / PNG |
|
||||
| 1009 | 未授权 | 缺少或错误的 `X-Internal-Token`(`/api/*` 路径鉴权) |
|
||||
|
||||
---
|
||||
|
||||
@@ -109,6 +109,8 @@
|
||||
| four_courts | object | 四庭数据,见下表 |
|
||||
| seven_eyes | object | 七眼数据,见下表 |
|
||||
| landmarks | object | 关键分界点坐标(头顶 / 发际线 / 眉心 / 鼻翼下缘 / 下巴尖),原图像素坐标 |
|
||||
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` |
|
||||
| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:`{ "x": int, "y": int }` |
|
||||
|
||||
`four_courts`(四庭,自上而下):
|
||||
|
||||
@@ -128,6 +130,15 @@
|
||||
| face_width_cm | number | 脸宽(cm) |
|
||||
| inter_eye_distance_cm | number | 两眼间距(cm) |
|
||||
| ratios | object | 七眼各段占脸宽的比例 |
|
||||
| eye1 | number \| null | 从左到右第 1 段宽度(cm):人头最左 → 左脸颊(左耳外侧段)。该侧耳朵不可见时为 null |
|
||||
| eye2 | number | 从左到右第 2 段宽度(cm):左脸颊 → 左眼外角 |
|
||||
| eye3 | number | 从左到右第 3 段宽度(cm):左眼外角 → 左眼内角(左眼宽度) |
|
||||
| eye4 | number | 从左到右第 4 段宽度(cm):左眼内角 → 右眼内角(两眼间距) |
|
||||
| eye5 | number | 从左到右第 5 段宽度(cm):右眼内角 → 右眼外角(右眼宽度) |
|
||||
| eye6 | number | 从左到右第 6 段宽度(cm):右眼外角 → 右脸颊 |
|
||||
| eye7 | number \| null | 从左到右第 7 段宽度(cm):右脸颊 → 人头最右(右耳外侧段)。该侧耳朵不可见时为 null |
|
||||
|
||||
> `eye1`~`eye7` 为从左到右共 7 段宽度,与标注图竖线一一对应。最左/最右端线取自耳朵分割外缘;某侧耳朵被头发或侧脸遮挡(不可见)时该侧端线省略,对应 `eye1` 或 `eye7` 为 `null`(键始终保留),实际有效段为 5 或 6 段。`eye3`/`eye5` 为左右眼宽、`eye4` 为两眼间距,与 `eye_width_cm` / `inter_eye_distance_cm` 语义一致。
|
||||
|
||||
### 标注图片(UI)规范
|
||||
|
||||
@@ -161,7 +172,9 @@
|
||||
"eye_width_cm": 3.44,
|
||||
"face_width_cm": 24.08,
|
||||
"inter_eye_distance_cm": 3.44,
|
||||
"ratios": { "eye_width": 0.143, "inter_eye_distance": 0.143 }
|
||||
"ratios": { "eye_width": 0.143, "inter_eye_distance": 0.143 },
|
||||
"eye1": 3.44, "eye2": 3.44, "eye3": 3.44, "eye4": 3.44,
|
||||
"eye5": 3.44, "eye6": 3.44, "eye7": 3.44
|
||||
},
|
||||
"landmarks": {
|
||||
"hair_top": { "x": 540, "y": 120 },
|
||||
@@ -198,8 +211,12 @@
|
||||
| annotated_image_url | string | 标注图层 PNG URL(透明底,仅标注线/文字,不含人物) |
|
||||
| face_total_height_cm | number | 面部总高度(cm)= 上庭 + 中庭 + 下庭(**不含顶庭**) |
|
||||
| four_courts | object | 三庭数据(上/中/下庭,各含 cm 与 ratio;**无顶庭**) |
|
||||
| seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距,各含 cm 与 ratio) |
|
||||
| seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距 cm + 占比 ratios + **eye2~eye6** 共 5 段宽度) |
|
||||
| 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 有)。
|
||||
|
||||
### 响应示例
|
||||
|
||||
@@ -217,7 +234,8 @@
|
||||
},
|
||||
"seven_eyes": {
|
||||
"eye_width_cm": 3.44, "face_width_cm": 24.08, "inter_eye_distance_cm": 3.44,
|
||||
"ratios": { "eye_width": 0.143, "inter_eye_distance": 0.143 }
|
||||
"ratios": { "eye_width": 0.143, "inter_eye_distance": 0.143 },
|
||||
"eye2": 3.0, "eye3": 3.44, "eye4": 3.44, "eye5": 3.44, "eye6": 3.0
|
||||
},
|
||||
"landmarks": {
|
||||
"hairline": { "x": 540, "y": 430 },
|
||||
@@ -233,10 +251,10 @@
|
||||
|
||||
## 接口 2:C 端生发接口
|
||||
|
||||
**说明**:输入用户正面照 + 性别 + 发型序号(可多选),按指定发际线类型渲染预览图 + 生发图。
|
||||
**说明**:输入用户正面照 + 性别 + 发型序号(可多选),按指定发际线类型渲染发际线曲线透明 PNG + 生发图。
|
||||
|
||||
> **每个方案返回两张图**:`image_url`=「原照片 + 发际线曲线叠加的**预览图**」;`grown_image_url`=
|
||||
> 经 ComfyUI/Flux 的「植发 3 个月**生发后图片**」。两者均已实现,实现简述见 [`实现说明.md`](实现说明.md)。
|
||||
> **每个方案返回两张图**:`image_url`=「发际线曲线**透明 PNG**(仅白色曲线,透明底,需叠加原图显示)」;`grown_image_url`=
|
||||
> 经 ComfyUI/Flux 的「植发 3 个月**生发后图片**」(完整人像照片)。两者均已实现,实现简述见 [`实现说明.md`](实现说明.md)。
|
||||
|
||||
**请求**:`POST /api/v1/hair/grow`
|
||||
|
||||
@@ -250,6 +268,7 @@
|
||||
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),最多不超过该性别的预设数。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight。越界/非法返回 `1007` |
|
||||
| beauty_enabled | bool | 否 | 生发图是否带美颜效果,默认 false(当前阶段不生效) |
|
||||
| use_mask | bool | 否 | 是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
|
||||
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
|
||||
|
||||
### 输出(data)
|
||||
|
||||
@@ -257,14 +276,16 @@
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| image_url | string | 方案**预览图** URL(发际线曲线叠加图) |
|
||||
| grown_image_url | string | **生发后图片** URL(ComfyUI/Flux「植发 3 个月」效果图) |
|
||||
| image_url | string | 发际线曲线**透明 PNG** URL(仅白色发际线曲线,透明底,**不含人物**,需前端叠加原图显示) |
|
||||
| grown_image_url | string | **生发后图片** URL(ComfyUI/Flux「植发 3 个月」效果图,完整人像照片) |
|
||||
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`(female),`ellipse`/`m`/`straight`/`inverse_arc`(male) |
|
||||
| order | int | 排序序号(当前阶段固定 `1..N`,按贴图顺序,暂不计算合适度) |
|
||||
|
||||
> ⚠️ 生发图由本机 ComfyUI(Flux-2,端口 8182)生成,**一次请求生成指定发型的 1 张、同步返回**。
|
||||
> worker 侧返回 `image_base64` / `grown_image_base64`,
|
||||
> 网关落盘后改写为上表的 `image_url` / `grown_image_url`。
|
||||
>
|
||||
> 💡 `image_url` 为透明底 PNG,前端需用绝对定位叠加到原图上显示(参考[测试页](https://hair.xiangsilian.com/static/test_interface2.html)的 `.img-stack` 叠加结构)。
|
||||
|
||||
### 响应示例
|
||||
|
||||
@@ -302,6 +323,7 @@
|
||||
|------|------|------|------|
|
||||
| marked_image_* | file / string | 是 | 已用马克笔标注发际线的图片,三选一 |
|
||||
| use_mask | bool | 否 | 是否画发际线,默认 `true`。`false` 时跳过划线检测、直接送划线图,模型仅凭手绘黑线生发,供测试对比 |
|
||||
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
|
||||
|
||||
### 输出(data)
|
||||
|
||||
@@ -386,7 +408,8 @@
|
||||
| gender | string | **是** | 性别:`male` / `female`。决定发型集合(female 5 / male 4)。缺失/非法返回 `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` |
|
||||
| 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 张生发图。
|
||||
|
||||
@@ -395,20 +418,40 @@
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| hairline_images | object[] | **选中发型**列表,**数量 = 所选发型数**,元素见下表 |
|
||||
| best_hairline_center_point | object | **首个选中发型**的 middle 档发际线曲线「面部中间点」坐标,原图像素:`{ "x": number, "y": number }` |
|
||||
| best_hairline_center_point | object \| null | **首个选中发型**的 **middle 档**发际线曲线「面部中间点」坐标,原图像素:`{ "x": number, "y": number }` |
|
||||
| high_hairline_center_point | object \| null | 同上,**high 档**发际线中点(发际线偏高) |
|
||||
| low_hairline_center_point | object \| null | 同上,**low 档**发际线中点(发际线偏低) |
|
||||
| face_measure | object \| null | **复用接口1**的四庭七眼测量**数值**(不含标注图)。独立流程,测量失败(无人脸/非正面/分割失败)时为 `null`,不影响发际线主结果。字段结构见下表 |
|
||||
|
||||
`hairline_images` 元素:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`(female),`ellipse`/`m`/`straight`/`inverse_arc`(male) |
|
||||
| image_middle_url | string | middle 档发际线叠加图 URL |
|
||||
| image_high_url | string | high 档发际线叠加图 URL |
|
||||
| image_low_url | string | low 档发际线叠加图 URL |
|
||||
| grown_image_url | string \| null | **生发后图片** URL(ComfyUI「植发」效果图,生发失败时为 `null`) |
|
||||
| image_middle_url | string | middle 档发际线曲线**透明 PNG** URL(仅曲线,透明底,**不含人物**,需叠加原图显示) |
|
||||
| image_high_url | string | high 档发际线曲线**透明 PNG** URL(同上,high 档曲线) |
|
||||
| image_low_url | string | low 档发际线曲线**透明 PNG** URL(同上,low 档曲线) |
|
||||
| grown_image_url | string \| null | **生发后图片** URL(ComfyUI「植发」效果图,完整人像照片,生发失败或 `generate_grow_image=false` 时为 `null`) |
|
||||
| order | int | 发型序号(= 传入的 hair_style 值) |
|
||||
|
||||
> worker 侧返回 `image_middle_base64` / `image_high_base64` / `image_low_base64` / `grown_image_base64`,网关落盘后改写为上表对应的 `*_url`。
|
||||
>
|
||||
> 💡 三档 `image_*_url` 为透明底 PNG,前端需用绝对定位叠加到原图上显示(参考[测试页](https://hair.xiangsilian.com/static/test_interface5.html)的 `.img-stack` 叠加结构)。`grown_image_url` 是完整人像照片,直接显示即可。
|
||||
|
||||
`face_measure` 元素(与[接口1](#接口-1四庭七眼测量标注接口)的 `data` 同构,**不含** `annotated_image_*` 标注图字段):
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| face_total_height_cm | number | 全脸总高度(cm)= 四庭之和 |
|
||||
| four_courts | object | 四庭数据(顶/上/中/下庭 cm + 占比 ratios),结构同接口1 |
|
||||
| seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距 cm + 占比 ratios + eye1~eye7 从左到右 7 段宽度),结构同接口1 |
|
||||
| landmarks | object | 5 个纵向关键点像素坐标(hair_top/hairline/brow_center/nose_bottom/chin_tip),结构同接口1 |
|
||||
| hairline_source | string | 发际线来源:`segmentation`(真实分割)/ `estimated`(比例估算) |
|
||||
| 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说明。
|
||||
|
||||
### 响应示例(当前 Mock 返回值)
|
||||
|
||||
@@ -436,7 +479,34 @@
|
||||
"order": 3
|
||||
}
|
||||
],
|
||||
"best_hairline_center_point": { "x": 540, "y": 430 }
|
||||
"best_hairline_center_point": { "x": 540, "y": 430 },
|
||||
"high_hairline_center_point": { "x": 540, "y": 380 },
|
||||
"low_hairline_center_point": { "x": 540, "y": 480 },
|
||||
"face_measure": {
|
||||
"face_total_height_cm": 26.76,
|
||||
"four_courts": {
|
||||
"top_court_cm": 5.77, "upper_court_cm": 5.93,
|
||||
"middle_court_cm": 7.62, "lower_court_cm": 7.44,
|
||||
"ratios": { "top_court": 0.216, "upper_court": 0.222,
|
||||
"middle_court": 0.285, "lower_court": 0.278 }
|
||||
},
|
||||
"seven_eyes": {
|
||||
"eye_width_cm": 2.76, "face_width_cm": 15.08,
|
||||
"inter_eye_distance_cm": 3.9,
|
||||
"ratios": { "eye_width": 0.183, "inter_eye_distance": 0.259 },
|
||||
"eye1": null, "eye2": 3.0, "eye3": 2.76, "eye4": 3.9,
|
||||
"eye5": 2.76, "eye6": 3.0, "eye7": null
|
||||
},
|
||||
"landmarks": {
|
||||
"hair_top": { "x": 504, "y": 103 },
|
||||
"hairline": { "x": 504, "y": 228 },
|
||||
"brow_center": { "x": 504, "y": 357 },
|
||||
"nose_bottom": { "x": 505, "y": 522 },
|
||||
"chin_tip": { "x": 506, "y": 683 }
|
||||
},
|
||||
"hairline_source": "segmentation",
|
||||
"head_pose": { "yaw": -1.39, "pitch": 2.49, "roll": -0.06 }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -445,59 +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` 时用干净原图生成(空遮罩、不烧模板黑线) |
|
||||
|
||||
### 输出(data)
|
||||
|
||||
与接口 2 完全相同。`results`:发际线方案数组,**数量 = 所选发型数**。每个元素:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| image_url | string | 方案**预览图** 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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 汇总:输入输出一览
|
||||
|
||||
| 接口 | 输入 | 主要输出 |
|
||||
@@ -508,7 +525,6 @@
|
||||
| 3 B 端生发 | 划线图片 | 最合适发际线图片 + 生发后图片 |
|
||||
| 4 用户特征 | 用户照片 | 6 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格) |
|
||||
| 5 发际线 PNG | 用户照片 + gender + hair_style(多选) | 每个选中发型 middle/high/low 三档发际线叠图 + 生发图 + 最合适发际线面部中间点坐标 |
|
||||
| 7 C 端生发 v2 | 用户照片 + gender + hair_style | 同接口2,使用 add_hair2.json 工作流 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
- 纵向竖线 8 条:人头最左 + 左脸颊/左眼外/内角/右眼内/外角/右脸颊 + 人头最右,
|
||||
把头宽切 7 段(七眼),段宽数值上下交替(上 3 / 下 4),带虚线双箭头。
|
||||
人头最左/最右取自耳朵分割外缘,看不到耳朵则省略该侧(最少 6 点 5 段)。
|
||||
- 四庭:图片左侧,「名」上「数值」下两行换行(不带 cm),带竖向虚线双箭头。
|
||||
- 四庭:图片左侧,「名」「数值(带 cm)」「百分比」三行换行,带竖向虚线双箭头。
|
||||
- 五条横线右侧标名:头顶/发际线/眉心/鼻翼下缘/下巴尖。
|
||||
- 单位 cm 统一标在底部「单位cm」。
|
||||
- 每段数值直接带 cm 后缀,下方另起一行标百分比(不再单独标底部「单位cm」)。
|
||||
中文字体用打包的思源黑体绝对路径加载,缺字体直接抛错(不静默降级成方块)。
|
||||
"""
|
||||
import os
|
||||
@@ -189,9 +189,8 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
|
||||
取自耳朵分割掩膜的外缘(方案 B,BiSeNet 类 7/8);耳朵不可见(被头发/侧脸
|
||||
遮挡 → 掩膜空)或无掩膜时省略该侧端线,只画对应脸颊线。
|
||||
- 横向 5 条分界线:头顶/发际线/眉心/鼻翼下缘/下巴尖,右侧标名。
|
||||
- 四庭(顶/上/中/下庭)在左侧:名 + 数值两行换行(无 cm),竖向虚线双箭头。
|
||||
- 七眼段宽数值上下交替(上 3 / 下 4,无 cm),横向虚线双箭头。
|
||||
- 底部统一标「单位cm」。
|
||||
- 四庭(顶/上/中/下庭)在左侧:名 + 数值(带 cm) + 百分比三行换行,竖向虚线双箭头。
|
||||
- 七眼段宽上下交替(上 3 / 下 4):数值(带 cm) 上、百分比(占头宽比)下,横向虚线双箭头。
|
||||
|
||||
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)
|
||||
font_size = max(11, round(s * 0.026)) # 字体更小
|
||||
font_size = max(9, round(s * 0.020)) # 字号上调一档
|
||||
line_w = max(1, round(s * 0.0022))
|
||||
dash_len = max(4, round(s * 0.008))
|
||||
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)
|
||||
|
||||
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"]
|
||||
else:
|
||||
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
|
||||
over = max(6, round(s * 0.030)) # 线超出包围盒的长度(参考图风格)
|
||||
face_half = (fx1 - fx0) / 2 + over # 横线超出最外侧竖线一点
|
||||
# v6 竖线纵向范围 = 发际线→下巴尖(不超出);v1 = 头顶→下巴尖并两端超出一点
|
||||
# 竖线纵向范围:v6 = 发际线→下巴尖(不超出);v1(含发际线弃用)= 头顶→下巴尖并两端超出一点
|
||||
v_top = fy0 if variant == "v6" else fy0 - over
|
||||
v_bot = fy1 if variant == "v6" else fy1 + over
|
||||
|
||||
@@ -256,70 +259,85 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
font = _load_font(font_size)
|
||||
|
||||
# --- 3a. 横线右侧:线名(头顶/发际线/眉心/鼻翼下缘/下巴尖),文字在线上方 ---
|
||||
name_x = fx1 + pad
|
||||
name_gap = max(2, round(pad * 1.6)) # 文字底部到线的间距(再上移)
|
||||
# --- 3a. 横线右侧:线名(头顶/发际线/眉心/鼻翼下缘/下巴尖),文字纵向居中对齐到线 ---
|
||||
name_x = fx1 + over + pad # 移到横线右端外侧一点(往右)
|
||||
for i, name in enumerate(order):
|
||||
text = _LINE_NAMES[name]
|
||||
tw, th = _text_size(draw, text, font)
|
||||
tw, _ = _text_size(draw, text, font)
|
||||
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)+ 竖向虚线双箭头 ---
|
||||
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_name = ["上庭", "中庭", "下庭"]
|
||||
n_court = 3
|
||||
court_start = 0
|
||||
else:
|
||||
court_cm = [measure_result.top_cm, measure_result.upper_cm,
|
||||
measure_result.middle_cm, measure_result.lower_cm]
|
||||
court_name = ["顶庭", "上庭", "中庭", "下庭"]
|
||||
n_court = 4
|
||||
court_start = 0
|
||||
arrow_x = max(arrow_size + 1, fx0 - pad) # 竖箭头所在 x(脸左侧,贴近最左竖线)
|
||||
court_total = sum(court_cm) or 1.0 # 各庭占比分母 = 四庭(v6 三庭)之和
|
||||
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)
|
||||
draw_dashed_line_with_arrows(
|
||||
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)
|
||||
# 名 + 数值两行,右对齐到箭头左侧
|
||||
# 名 + 数值(带 cm) + 百分比三行,右对齐到箭头左侧
|
||||
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)
|
||||
vw, _ = _text_size(draw, val, font)
|
||||
pw, _ = _text_size(draw, pct, font)
|
||||
label_right = arrow_x - pad
|
||||
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 - 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
|
||||
y_arrow_top = max(txt_off + font_size + 2, fy0 - pad - arrow_size)
|
||||
y_arrow_bot = min(h - txt_off - font_size - 2, fy1 + pad + arrow_size)
|
||||
txt_block = 2 * line_h # 两行文字总高(数值 + 百分比)
|
||||
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):
|
||||
x_a, x_b = xs[i], xs[i + 1]
|
||||
if x_b - x_a < 1:
|
||||
continue
|
||||
seg_cm = (x_b - x_a) / pc
|
||||
seg_pct = (x_b - x_a) / head_w * 100
|
||||
cx_seg = (x_a + x_b) / 2
|
||||
text = f"{seg_cm:.2f}"
|
||||
tw, th = _text_size(draw, text, font)
|
||||
val = f"{seg_cm:.2f}cm"
|
||||
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)
|
||||
on_top = (i % 2 == 1) # 奇数段在上 → 上 3 / 下 4
|
||||
y_arrow = y_arrow_top if on_top else y_arrow_bot
|
||||
draw_dashed_line_with_arrows(
|
||||
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)
|
||||
ty = (y_arrow - th - txt_off) if on_top else (y_arrow + txt_off)
|
||||
draw.text((cx_seg - tw / 2, ty), text, fill=LINE_COLOR, font=font)
|
||||
|
||||
# --- 5. 底部统一单位 ---
|
||||
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)
|
||||
# 数值行在上、百分比行在下;on_top 时整块置于箭头上方,否则下方
|
||||
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)
|
||||
draw.text((cx_seg - pw / 2, text_top + line_h), pct, fill=LINE_COLOR, font=font)
|
||||
|
||||
return canvas
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ RIGHT_EYE_INNER = 362 # 右眼内角
|
||||
RIGHT_EYE_OUTER = 263 # 右眼外角
|
||||
LEFT_CHEEK = 234 # 左脸颧弓(脸宽左端)
|
||||
RIGHT_CHEEK = 454 # 右脸颧弓(脸宽右端)
|
||||
LEFT_POSITION = 21 # 左脸前侧定位点(脸颊/耳前区域,与 251 镜像)
|
||||
RIGHT_POSITION = 251 # 右脸前侧定位点(与 21 镜像)
|
||||
|
||||
# --- 鼻尖(solvePnP 用,可选) ---
|
||||
NOSE_TIP = 1 # 鼻尖(也有用 4 的版本)
|
||||
|
||||
@@ -35,6 +35,7 @@ from face_analysis.detector import detector
|
||||
from face_analysis.calibration import estimate_scale_factor
|
||||
from face_analysis.head_mask import (
|
||||
NoFaceError,
|
||||
BASELINE_IDX,
|
||||
_baseline_points,
|
||||
_upper_region_mask,
|
||||
_bisenet_hair_mask,
|
||||
@@ -46,8 +47,11 @@ from face_analysis.head_mask import (
|
||||
_draw_baseline,
|
||||
)
|
||||
|
||||
# 调试日志:写 /home/xsl/hair/log/hairline_grow.log,每个步骤详细记录
|
||||
_LOG_DIR = "/home/xsl/hair/log"
|
||||
# 调试日志:写 <仓库根>/log/hairline_grow.log,每个步骤详细记录(可用 HAIR_LOG_DIR 覆盖)
|
||||
_LOG_DIR = os.getenv(
|
||||
"HAIR_LOG_DIR",
|
||||
os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "log"),
|
||||
)
|
||||
os.makedirs(_LOG_DIR, exist_ok=True)
|
||||
logger = logging.getLogger("hairline_grow")
|
||||
_log_fh = logging.FileHandler(os.path.join(_LOG_DIR, "hairline_grow.log"), encoding="utf-8")
|
||||
@@ -60,20 +64,22 @@ SWAP_URL = os.getenv("SWAP_HAIR_URL", "http://127.0.0.1:8801/api/swapHair/v1")
|
||||
HAIRGROW_URL = os.getenv("HAIR_GROW_URL", "http://127.0.0.1:8801/api/hairGrow/v1")
|
||||
SWAP_TIMEOUT = float(os.getenv("SWAP_HAIR_TIMEOUT", "300"))
|
||||
|
||||
# 多频段融合最细层羽化:羽化最细 FEATHER_LAYERS 层(每层核尺寸按尺度放大)。
|
||||
# 只羽最细1层效果极弱(其拉普拉斯系数幅度小),羽化 3 层才能明显软化发丝边缘锯齿。
|
||||
FEATHER_LAYERS = 3
|
||||
|
||||
DEFAULTS = {
|
||||
"gen_backend": "swaphair", # swaphair(换发型LoRA) | hairgrow(区域生发inpaint)
|
||||
"is_hr": False,
|
||||
"seg_model": "segformer", # bisenet | segformer
|
||||
"mask_type": "eroded", # eroded | closed
|
||||
"erode_cm": 1.2,
|
||||
"hairline_push_cm": 1.0, # 发际线径向外推距离(厘米)
|
||||
"hairline_edge": "column", # column(逐列下沿)
|
||||
"swap_mode": "ext_mask", # ext_mask | as_is(仅 swaphair)
|
||||
"denoising_strength": 0.6, # 仅 swaphair
|
||||
"hairgrow_strength": 0.75, # 仅 hairgrow
|
||||
"blend_method": "feather", # feather | alpha_gradient | seamless | multiband
|
||||
"feather_px": 15,
|
||||
"edge_erode_px": 3,
|
||||
"color_match": False, # True 时对生成图做 Reinhard 颜色校正(seamless 下自动跳过)
|
||||
"mb_levels": 5, # multiband 金字塔层数(2~6,越大色差抹得越宽)
|
||||
"erode_cm": 0.6, # 接口12 固定值(pushed 模式下仅用于 baseline 截断参考,影响很小)
|
||||
}
|
||||
|
||||
|
||||
@@ -96,11 +102,26 @@ def _png_b64(bgr_or_gray):
|
||||
|
||||
|
||||
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)
|
||||
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 构件)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -296,8 +317,48 @@ def _pushed_mask(hair_mask, upper, baseline_pts, push_px, rid="",
|
||||
|
||||
|
||||
|
||||
def _redraw_band_mask(inner_pts, outer_pts, h, w, rid="", upper=None,
|
||||
lo_mult=0.5, hi_mult=1.5):
|
||||
"""重绘带遮罩:由发际线(①-f 内轮廓 inner_pts)沿径向外推方向,取
|
||||
`lo_mult × push` 与 `hi_mult × push` 两条外推线之间的带状区域作为重绘 mask。
|
||||
|
||||
inner_pts / outer_pts 是一一对应的有序点列(outer = inner 径向外推 1.0×push_px),
|
||||
故每点的 1.0× 位移向量 = outer - inner;下界线 = inner + lo_mult×位移,
|
||||
上界线 = inner + hi_mult×位移。闭合环 = 下界线(正向)+ 上界线(反向)首尾相接。
|
||||
|
||||
lo_mult / hi_mult:外推倍率(相对 hairline_push_cm)。默认 0.5 / 1.5,即带位于
|
||||
0.5×push ~ 1.5×push 之间(以内轮廓为 0×、原外推线为 1.0×)。
|
||||
|
||||
upper:①-a baseline 以上区域布尔掩码。传入时把重绘带与它求交集,只保留 baseline
|
||||
以上的部分(两侧鬓角落到 baseline 以下的段会被截掉)。
|
||||
|
||||
返回 band_bool。
|
||||
"""
|
||||
lg = lambda msg: logger.info("[%s] %s", rid, msg) if rid else None
|
||||
if len(inner_pts) < 2 or len(outer_pts) < 2:
|
||||
return np.zeros((h, w), dtype=bool)
|
||||
inner_f = np.asarray(inner_pts, dtype=np.float32)
|
||||
outer_f = np.asarray(outer_pts, dtype=np.float32)
|
||||
disp = outer_f - inner_f # 每点 1.0×push 的径向位移向量
|
||||
lo_line = inner_f + float(lo_mult) * disp # 下界外推线(lo_mult×push)
|
||||
hi_line = inner_f + float(hi_mult) * disp # 上界外推线(hi_mult×push)
|
||||
# 闭合多边形:下界线正向 + 上界线反向,端点自然相连
|
||||
ring = np.vstack([lo_line.astype(np.int32), hi_line[::-1].astype(np.int32)])
|
||||
band_u8 = np.zeros((h, w), dtype=np.uint8)
|
||||
cv2.fillPoly(band_u8, [ring], 255)
|
||||
band = band_u8 > 0
|
||||
raw_px = int(band.sum())
|
||||
# ①-a baseline 截断:只保留 baseline 以上的重绘带
|
||||
if upper is not None:
|
||||
band = band & upper
|
||||
lg(f"_redraw_band_mask: 内轮廓点={len(inner_pts)} lo_mult={lo_mult} hi_mult={hi_mult} "
|
||||
f"band像素(截断前)={raw_px} band像素(截断后)={int(band.sum())}")
|
||||
return band
|
||||
|
||||
|
||||
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。
|
||||
@@ -305,6 +366,10 @@ def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm
|
||||
hairline_push_cm: 仅 pushed 模式——发际线往头发方向外推的厘米数(进入现有头发)。
|
||||
hairline_edge: 仅 pushed 模式——发际线提取方式 column(逐列最低点) | contour(形态学轮廓)。
|
||||
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)。
|
||||
"""
|
||||
lg = lambda msg: logger.info("[%s] %s", rid, msg) if rid else None
|
||||
@@ -318,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)
|
||||
lg(f"baseline 第一点={baseline_pts[0]} 末点={baseline_pts[-1]} upper像素={int(upper.sum())}")
|
||||
|
||||
if seg_model == "bisenet":
|
||||
hair_mask = _bisenet_hair_mask(image_bgr, landmarks, w, h)
|
||||
elif seg_model == "segformer":
|
||||
hair_mask = _segformer_hair_mask(image_bgr)
|
||||
if hair_mask is None:
|
||||
if seg_model == "bisenet":
|
||||
hair_mask = _bisenet_hair_mask(image_bgr, landmarks, w, h)
|
||||
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:
|
||||
raise ValueError(f"未知 seg_model: {seg_model}")
|
||||
lg(f"头发分割完成 seg_model={seg_model} hair_pixels={int(hair_mask.sum())}")
|
||||
lg(f"头发分割跳过(复用外部传入) hair_pixels={int(hair_mask.sum())}")
|
||||
|
||||
top_fill = _fill_to_baseline(hair_mask, upper) # 含额头,延伸到图底
|
||||
closed = _largest_cc(top_fill & upper) # 闭合区域:头发+额头,底=基线
|
||||
@@ -336,7 +404,9 @@ def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm
|
||||
if mask_type == "pushed":
|
||||
push_px = int(round(max(0.0, hairline_push_cm) * px_per_cm))
|
||||
# 圆心 = 151 点(眉心)完整坐标,内侧判定与径向外推共用
|
||||
center = baseline_pts[5] if len(baseline_pts) > 5 else None
|
||||
# 按值查 151 在 BASELINE_IDX 中的位置,避免列表变动后索引错位(曾硬编码 [5])
|
||||
_idx151 = BASELINE_IDX.index(151) if 151 in BASELINE_IDX else -1
|
||||
center = baseline_pts[_idx151] if _idx151 >= 0 else None
|
||||
# 下颌截断线:下巴关键点 152 的 y(内轮廓两侧向下画到这里为止)
|
||||
try:
|
||||
chin_y = int(round(landmarks.landmark[152].y * h))
|
||||
@@ -363,48 +433,55 @@ def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm
|
||||
# 遮罩计算过程可视化:
|
||||
# eroded/closed 走 top_fill→closed/eroded 流程;
|
||||
# pushed 走 baseline→头发分割→发际线→外推 流程,与 top_fill/closed 无关,故置空。
|
||||
# render_viz=False(接口2/12 路径)时跳过 overlay JPG 编码,只保留数据字段。
|
||||
viz = {
|
||||
"erode_px": r,
|
||||
"hair_pixels": int(hair_mask.sum()),
|
||||
"closed_pixels": int(closed.sum()),
|
||||
"mask_pixels": int(mask_bool.sum()),
|
||||
# 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 半区):青色叠加
|
||||
"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):绿色叠加在原图上
|
||||
"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 流程无关,留空
|
||||
"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))),
|
||||
"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))),
|
||||
# 5. pushed 模式专有(发际线提取/外推)—— 非 pushed 留空
|
||||
"hairline_overlay_base64": "",
|
||||
"pushed_overlay_base64": "",
|
||||
# —— 最终遮罩 ——
|
||||
"mask_overlay_base64": _jpg_b64(_overlay(image_bgr, mask_bool, (0, 0, 255))),
|
||||
"mask_base64": _png_b64((mask_bool.astype(np.uint8)) * 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) if render_viz else "",
|
||||
}
|
||||
# pushed 模式:补充内轮廓提取 + 外推线可视化
|
||||
if pushed_info is not None:
|
||||
inner_pts, outer_pts, push_px = pushed_info
|
||||
# ①-f 提取内轮廓:绿=头发内轮廓线(额头弧+两侧到下颌),黄=baseline 折线
|
||||
hl_img = _draw_baseline(image_bgr, baseline_pts, w) # 画 baseline(黄线+关键点)
|
||||
hl_img = _draw_polyline(hl_img, inner_pts, (0, 255, 0), 3)
|
||||
viz["hairline_overlay_base64"] = _jpg_b64(hl_img)
|
||||
# ①-g 外推:圆心红点(151) + 内轮廓(绿)+ 外推线(青)+ 遮罩(红半透明)
|
||||
ps_img = _draw_polyline(image_bgr.copy(), inner_pts, (0, 255, 0), 2)
|
||||
ps_img = _draw_polyline(ps_img, outer_pts, (0, 255, 255), 3)
|
||||
# 画圆心(151 点)红点,标示径向外推的中心
|
||||
if baseline_pts is not None and len(baseline_pts) > 5:
|
||||
cx151, cy151 = baseline_pts[5]
|
||||
cv2.circle(ps_img, (cx151, cy151), 6, (0, 0, 255), -1, cv2.LINE_AA)
|
||||
cv2.putText(ps_img, "151", (cx151 + 8, cy151 - 8),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 1, cv2.LINE_AA)
|
||||
ps_img = _overlay(ps_img, mask_bool, (0, 0, 255), 0.3)
|
||||
viz["pushed_overlay_base64"] = _jpg_b64(ps_img)
|
||||
if render_viz:
|
||||
# ①-f 提取内轮廓:绿=头发内轮廓线(额头弧+两侧到下颌),黄=baseline 折线
|
||||
hl_img = _draw_baseline(image_bgr, baseline_pts, w) # 画 baseline(黄线+关键点)
|
||||
hl_img = _draw_polyline(hl_img, inner_pts, (0, 255, 0), 3)
|
||||
viz["hairline_overlay_base64"] = _jpg_b64(hl_img)
|
||||
# ①-g 外推:圆心红点(151) + 内轮廓(绿)+ 外推线(青)+ 遮罩(红半透明)
|
||||
ps_img = _draw_polyline(image_bgr.copy(), inner_pts, (0, 255, 0), 2)
|
||||
ps_img = _draw_polyline(ps_img, outer_pts, (0, 255, 255), 3)
|
||||
# 画圆心(151 点)红点,标示径向外推的中心(_idx151 上方已按值查到)
|
||||
if center is not None:
|
||||
cx151, cy151 = center
|
||||
cv2.circle(ps_img, (cx151, cy151), 6, (0, 0, 255), -1, cv2.LINE_AA)
|
||||
cv2.putText(ps_img, "151", (cx151 + 8, cy151 - 8),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 1, cv2.LINE_AA)
|
||||
ps_img = _overlay(ps_img, mask_bool, (0, 0, 255), 0.3)
|
||||
viz["pushed_overlay_base64"] = _jpg_b64(ps_img)
|
||||
viz["push_px"] = push_px
|
||||
# 重绘带用原始数据:内轮廓点 + 外推点(供 _redraw_band_mask 连端点成带)
|
||||
viz["_inner_pts"] = inner_pts
|
||||
viz["_outer_pts"] = outer_pts
|
||||
# baseline 以上区域,供重绘带按 ①-a baseline 截断(只留上面)
|
||||
viz["_upper_mask"] = upper
|
||||
# 记录 viz 各字段是否非空(长度),便于排查前端取不到图的问题
|
||||
viz_summary = {k: (len(v) if isinstance(v, str) and v else 0)
|
||||
for k, v in viz.items() if k.endswith("_base64")}
|
||||
@@ -412,15 +489,32 @@ def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm
|
||||
return mask_bool, viz
|
||||
|
||||
|
||||
def _segment_hair(image_bgr, seg_model, landmarks, w, h):
|
||||
"""对任意图(如 hard_paste 重绘结果)重跑头发分割,返回 bool 掩码。
|
||||
|
||||
与 compute_mask 内部用的同一个 seg_model 逻辑(bisenet 需 landmarks,
|
||||
segformer 不需要),保证第1步(原图头发)与第2步(重绘后头发)分割口径一致。
|
||||
"""
|
||||
if seg_model == "bisenet":
|
||||
return _bisenet_hair_mask(image_bgr, landmarks, w, h)
|
||||
elif seg_model == "segformer":
|
||||
return _segformer_hair_mask(image_bgr)
|
||||
else:
|
||||
raise ValueError(f"未知 seg_model: {seg_model}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 步骤2:调 change_hair 换发型
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _call_swap(image_bgr, hairline_id, is_hr, ext_mask_bool, denoising_strength):
|
||||
def _call_swap(image_bgr, hairline_id, is_hr, ext_mask_bool, denoising_strength,
|
||||
inpainting_fill=1, mask_blur=11, mask_dilate_scale=1.0):
|
||||
"""调 change_hair /api/swapHair/v1,返回与输入同分辨率同对齐的换发型结果(BGR)。
|
||||
|
||||
ext_mask_bool 非 None 时作为 ext_mask 传入(swap_mode=ext_mask)。
|
||||
denoising_strength:webui img2img 重绘强度(越大生发越激进),透传给换发型。
|
||||
inpainting_fill / mask_blur / mask_dilate_scale:服务端重绘参数(透传给 change_hair,
|
||||
默认值=服务端原始硬编码值,未传时行为不变)。详见 change_hair 文档。
|
||||
"""
|
||||
import requests
|
||||
|
||||
@@ -432,6 +526,9 @@ def _call_swap(image_bgr, hairline_id, is_hr, ext_mask_bool, denoising_strength)
|
||||
"user_img_path": "data:image/jpeg;base64," + base64.b64encode(ibuf.tobytes()).decode(),
|
||||
"output_format": "base64",
|
||||
"denoising_strength": float(denoising_strength),
|
||||
"inpainting_fill": int(inpainting_fill),
|
||||
"mask_blur": int(mask_blur),
|
||||
"mask_dilate_scale": float(mask_dilate_scale),
|
||||
}
|
||||
if ext_mask_bool is not None:
|
||||
mbuf = cv2.imencode(".png", (ext_mask_bool.astype(np.uint8)) * 255)[1]
|
||||
@@ -502,25 +599,60 @@ def _call_hairgrow(image_bgr, mask_bool, strength):
|
||||
return result
|
||||
|
||||
|
||||
_REPAINT_WORKFLOW = os.path.join(os.path.dirname(os.path.dirname(__file__)), "hair_repaint.json")
|
||||
|
||||
|
||||
def _call_comfyui(image_bgr, mask_bool, prompt=None):
|
||||
"""调远端 ComfyUI 的 Flux-2 inpaint 工作流(hair_repaint.json),返回与输入同分辨率的 BGR。
|
||||
|
||||
与 swapHair 的区别:ComfyUI 把「原图 VAE 编码作 reference latent + ColorMatch」双重保色,
|
||||
天生不易染色;提示词自由可调(中文)。mask 经 RGBA alpha 通道传入(透明=重绘区)。
|
||||
ComfyUI 不在线时抛 SwapError(由调用方捕获降级)。prompt=None 用工作流内置默认提示词。
|
||||
"""
|
||||
import io
|
||||
from hairline.mask import compose_comfy_rgba
|
||||
from hairline.comfyui import COMFYUI_URL, run as comfyui_run, ping
|
||||
|
||||
if not ping():
|
||||
raise SwapError(f"ComfyUI 不可达({COMFYUI_URL}),redraw Flux-2 路跳过")
|
||||
mask_u8 = (mask_bool.astype(np.uint8)) * 255
|
||||
rgba_img = compose_comfy_rgba(image_bgr, mask_u8) # alpha=255-mask:透明=重绘区
|
||||
buf = io.BytesIO()
|
||||
rgba_img.save(buf, format="PNG")
|
||||
png_bytes = comfyui_run(buf.getvalue(), prompt=prompt, workflow_path=_REPAINT_WORKFLOW)
|
||||
result = cv2.imdecode(np.frombuffer(png_bytes, np.uint8), cv2.IMREAD_COLOR)
|
||||
if result is None:
|
||||
raise SwapError("ComfyUI 结果解码失败")
|
||||
if result.shape[:2] != image_bgr.shape[:2]:
|
||||
result = cv2.resize(result, (image_bgr.shape[1], image_bgr.shape[0]),
|
||||
interpolation=cv2.INTER_LANCZOS4)
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 步骤3+4:按遮罩贴回 + 接缝融合
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _color_match_to_orig(swap_result, orig, mask_bool):
|
||||
def _color_match_to_orig(swap_result, orig, mask_bool, strength=1.0):
|
||||
"""在 mask_bool 区域内做 Reinhard 颜色迁移:逐通道把 swap_result 的均值/方差对齐 orig。
|
||||
|
||||
strength 控制迁移强度:1.0=完全对齐到 orig(原行为),<1.0 只迁移部分,
|
||||
防止 Reinhard 在某些图上过度改色(如把生成发色整体拉向皮肤色)。
|
||||
遮罩外保持 swap_result 原样(不会越界污染)。返回 uint8 BGR。
|
||||
"""
|
||||
m = mask_bool.astype(bool)
|
||||
out = swap_result.astype(np.float32).copy()
|
||||
src_f = swap_result.astype(np.float32)
|
||||
out = src_f.copy()
|
||||
if m.sum() < 30:
|
||||
return swap_result.copy()
|
||||
strength = float(min(max(strength, 0.0), 1.0))
|
||||
for c in range(3):
|
||||
src_pix = swap_result[..., c][m].astype(np.float32)
|
||||
dst_pix = orig[..., c][m].astype(np.float32)
|
||||
s_mean, s_std = src_pix.mean(), src_pix.std() + 1e-6
|
||||
d_mean, d_std = dst_pix.mean(), dst_pix.std() + 1e-6
|
||||
out[..., c] = (out[..., c] - s_mean) * (d_std / s_std) + d_mean
|
||||
aligned = (out[..., c] - s_mean) * (d_std / s_std) + d_mean
|
||||
out[..., c] = src_f[..., c] * (1.0 - strength) + aligned * strength
|
||||
return np.clip(out, 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
@@ -555,10 +687,17 @@ def _multiband_alpha(mask_bool, edge_erode_px):
|
||||
return m
|
||||
|
||||
|
||||
def _multiband_blend(orig, swap_result, mask_bool, levels, edge_erode_px):
|
||||
def _multiband_blend(orig, swap_result, mask_bool, levels, edge_erode_px,
|
||||
feather_px=1, transition_band_px=-1):
|
||||
"""多频段(拉普拉斯金字塔)融合:低频用宽窗抹色差,高频用窄窗保发丝。
|
||||
|
||||
levels:金字塔层数(2~6),越大则低频色差在越宽范围被抹平。
|
||||
feather_px:最细若干层掩码轻羽化像素(0=不羽化,保持硬二值)。羽化最细 FEATHER_LAYERS
|
||||
层(核尺寸按层尺度放大),消除发丝边缘 1px 硬切锯齿;粗层仍保持二值(否则粗层会
|
||||
把整图混色)。注意:这是消除锯齿的微调,幅度有限(边界 Δ 约 1~3/255),
|
||||
不要指望它做大范围过渡——那是 mb_levels/transition_band_px 的事。
|
||||
transition_band_px:keep-region 外缘边距。-1=自动按层数 2**n(旧行为);
|
||||
>=0 则用绝对像素,使过渡带宽度与金字塔层数解耦。
|
||||
返回 uint8 BGR。
|
||||
"""
|
||||
m = _multiband_alpha(mask_bool, edge_erode_px)
|
||||
@@ -603,6 +742,21 @@ def _multiband_blend(orig, swap_result, mask_bool, levels, edge_erode_px):
|
||||
lb = lap_pyr(swap_result, n)
|
||||
ma = mask_pyr(m, n)
|
||||
|
||||
# 最细层(reversed 后末元素 = 全分辨率原始二值掩码)及其下若干层轻羽化,
|
||||
# 消除发丝边缘 1px 硬切锯齿。注意:多频段融合中各层都贡献边界过渡,但最细层的
|
||||
# 拉普拉斯系数幅度最小,只羽化它效果很弱(实测边界 Δ 仅 ~0.25/255)。因此对最细
|
||||
# FEATHER_LAYERS 层都做按尺度放大的羽化(越细的层核越大),才能明显软化边缘。
|
||||
# 粗层(低频)仍保持二值,否则会把整图混色,违反多频段融合的二值掩码前提。
|
||||
fp = int(max(0, feather_px))
|
||||
if fp > 0:
|
||||
for li in range(1, FEATHER_LAYERS + 1):
|
||||
idx = -li
|
||||
if abs(idx) > len(ma):
|
||||
break
|
||||
scale = 2 ** (li - 1)
|
||||
ksz = fp * 2 * scale + 1
|
||||
ma[idx] = cv2.GaussianBlur(ma[idx], (ksz, ksz), sigmaX=fp * scale / 2.0)
|
||||
|
||||
merged = []
|
||||
for a, b, mk in zip(la, lb, ma):
|
||||
m3 = mk[:, :, None]
|
||||
@@ -620,33 +774,68 @@ def _multiband_blend(orig, swap_result, mask_bool, levels, edge_erode_px):
|
||||
# 这条带正是 mb_levels 要控制的东西。若像旧实现那样用原始硬二值遮罩钳回,
|
||||
# 过渡带会被整条抹掉(实测 levels 2↔6 边界差恒为 0),mb_levels 形同虚设。
|
||||
# 故按层数膨胀出一个外缘 keep 区:keep 内允许过渡,keep 外才强制还原原图。
|
||||
margin = 2 ** n # n=2→4px … n=6→64px,与粗层掩码的自然扩散宽度匹配
|
||||
if transition_band_px is not None and transition_band_px >= 0:
|
||||
margin = int(transition_band_px) # 与金字塔层数解耦,用绝对像素
|
||||
else:
|
||||
margin = 2 ** n # n=2→4px … n=6→64px,与粗层掩码的自然扩散宽度匹配
|
||||
margin = max(0, margin)
|
||||
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * margin + 1, 2 * margin + 1))
|
||||
keep = cv2.dilate(mask_bool.astype(np.uint8), k).astype(bool)
|
||||
out[~keep] = orig[~keep]
|
||||
return out
|
||||
|
||||
|
||||
def _seamless_clone(orig, swap_result, mask_bool, edge_erode_px):
|
||||
"""泊松无缝克隆(cv2.seamlessClone NORMAL_CLONE):梯度域调和整体色调。
|
||||
|
||||
返回调色后的整帧 uint8 BGR;掩码过小(<10px)时返回原图。
|
||||
供 seamless 分支与 two_stage 两段式融合的第一段复用。
|
||||
"""
|
||||
m = mask_bool.astype(np.uint8)
|
||||
if edge_erode_px > 0:
|
||||
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * edge_erode_px + 1,) * 2)
|
||||
m = cv2.erode(m, k)
|
||||
if m.sum() < 10:
|
||||
return orig.copy()
|
||||
ys, xs = np.where(m > 0)
|
||||
center = (int((xs.min() + xs.max()) / 2), int((ys.min() + ys.max()) / 2))
|
||||
return cv2.seamlessClone(swap_result, orig, m * 255, center, cv2.NORMAL_CLONE)
|
||||
|
||||
|
||||
def _composite(orig, swap_result, mask_bool, blend_method, feather_px, edge_erode_px,
|
||||
color_match=False, mb_levels=5):
|
||||
"""把 swap_result 按遮罩贴回 orig,返回 (final_bgr, alpha_float or None)。"""
|
||||
color_match=False, mb_levels=5, color_match_strength=1.0,
|
||||
mb_feather_px=1, transition_band_px=-1):
|
||||
"""把 swap_result 按遮罩贴回 orig,返回 (final_bgr, alpha_float or None)。
|
||||
|
||||
blend_method:
|
||||
- multiband : 多频段金字塔融合(默认)
|
||||
- seamless : 泊松无缝克隆(梯度域调色,自带色彩调和,故跳过 color_match)
|
||||
- two_stage : 先 seamless 统一整体色调,再 multiband 贴发丝细节(大色差场景)
|
||||
- feather/alpha_gradient : 单层 alpha 过渡
|
||||
"""
|
||||
# seamless / two_stage 自带梯度域色彩调和,不叠 Reinhard 颜色迁移
|
||||
if blend_method == "seamless":
|
||||
m = mask_bool.astype(np.uint8)
|
||||
if edge_erode_px > 0:
|
||||
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * edge_erode_px + 1,) * 2)
|
||||
m = cv2.erode(m, k)
|
||||
if m.sum() < 10:
|
||||
return orig.copy(), None
|
||||
ys, xs = np.where(m > 0)
|
||||
center = (int((xs.min() + xs.max()) / 2), int((ys.min() + ys.max()) / 2))
|
||||
final = cv2.seamlessClone(swap_result, orig, m * 255, center, cv2.NORMAL_CLONE)
|
||||
final = _seamless_clone(orig, swap_result, mask_bool, edge_erode_px)
|
||||
return final, None
|
||||
|
||||
# 颜色校正前置(seamless 自带色彩调和,已在上面提前返回;其余分支在此生效)
|
||||
src = _color_match_to_orig(swap_result, orig, mask_bool) if color_match else swap_result
|
||||
if blend_method == "two_stage":
|
||||
# 第一段:seamless 把整体色调拉平(生成图色调对齐到原图)
|
||||
harmonized = _seamless_clone(orig, swap_result, mask_bool, edge_erode_px)
|
||||
# 第二段:对调色后的结果再做 multiband 贴发丝细节(不加 color_match,避免重复改色)
|
||||
final = _multiband_blend(orig, harmonized, mask_bool, mb_levels, edge_erode_px,
|
||||
feather_px=mb_feather_px,
|
||||
transition_band_px=transition_band_px)
|
||||
alpha = (_multiband_alpha(mask_bool, edge_erode_px).astype(np.float32)) / 255.0
|
||||
return final, alpha
|
||||
|
||||
# multiband / feather / alpha_gradient:先做 Reinhard 颜色迁移消除整体色差
|
||||
src = (_color_match_to_orig(swap_result, orig, mask_bool, color_match_strength)
|
||||
if color_match else swap_result)
|
||||
|
||||
if blend_method == "multiband":
|
||||
final = _multiband_blend(orig, src, mask_bool, mb_levels, edge_erode_px)
|
||||
final = _multiband_blend(orig, src, mask_bool, mb_levels, edge_erode_px,
|
||||
feather_px=mb_feather_px,
|
||||
transition_band_px=transition_band_px)
|
||||
# 可视化用:用多频段的二值掩码做一层 alpha 标记(展示实际合成区)
|
||||
alpha = (_multiband_alpha(mask_bool, edge_erode_px).astype(np.float32)) / 255.0
|
||||
return final, alpha
|
||||
@@ -661,22 +850,28 @@ def _composite(orig, swap_result, mask_bool, blend_method, feather_px, edge_erod
|
||||
# 主入口
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segformer",
|
||||
mask_type="eroded", erode_cm=1.2, swap_mode="ext_mask",
|
||||
blend_method="feather", feather_px=15, edge_erode_px=3,
|
||||
denoising_strength=0.6, gen_backend="swaphair",
|
||||
hairgrow_strength=0.75, color_match=False, mb_levels=5,
|
||||
hairline_push_cm=0.0, hairline_edge="column", rid=None):
|
||||
"""接口11 完整管线。返回可直接进 ok() 的 data dict。未检出人脸抛 NoFaceError。
|
||||
def _grow_core(image_bgr, hairline_id, *, is_hr, seg_model, erode_cm, swap_mode,
|
||||
edge_erode_px, denoising_strength, gen_backend, hairgrow_strength,
|
||||
mb_levels, hairline_push_cm, hairline_edge, blend_method, color_match,
|
||||
color_match_strength, mb_feather_px, transition_band_px,
|
||||
inpainting_fill, mask_blur, mask_dilate_scale, rid, render_viz=True,
|
||||
hair_mask=None):
|
||||
"""接口11 共享核心:遮罩(pushed)→生成→硬贴回→接缝融合,产出 ④ final。
|
||||
|
||||
rid: 调用方的 request id,用于日志关联。为 None 时自动生成。
|
||||
不做任何重绘。返回中间产物 dict(供接口11 构造响应、接口12 取 final+重绘带用):
|
||||
final / swap_result / hard_paste / alpha / mask_bool / mask_viz /
|
||||
px_per_cm / t_mask / t_swap / t_blend / h / w
|
||||
未检出人脸抛 NoFaceError。
|
||||
"""
|
||||
if rid is None:
|
||||
rid = uuid4().hex[:8]
|
||||
logger.info("[%s] ===== generate_hairline_grow 开始 =====", rid)
|
||||
logger.info("[%s] 参数: mask_type=%r erode_cm=%s blend=%s hairline_push_cm=%s hairline_edge=%r "
|
||||
"seg=%s gen_backend=%s swap_mode=%s", rid, mask_type, erode_cm, blend_method,
|
||||
hairline_push_cm, hairline_edge, seg_model, gen_backend, swap_mode)
|
||||
mask_type = "pushed" # 固定:只支持 pushed 遮罩算法
|
||||
logger.info("[%s] _grow_core 参数(固定 mask=pushed): erode_cm=%s hairline_push_cm=%s "
|
||||
"hairline_edge=%r mb_levels=%s seg=%s gen_backend=%s swap_mode=%s blend=%s "
|
||||
"color_match=%s cm_strength=%s mb_feather_px=%s transition_band_px=%s "
|
||||
"inpainting_fill=%s mask_blur=%s mask_dilate_scale=%s",
|
||||
rid, erode_cm, hairline_push_cm, hairline_edge, mb_levels,
|
||||
seg_model, gen_backend, swap_mode, blend_method, color_match,
|
||||
color_match_strength, mb_feather_px, transition_band_px,
|
||||
inpainting_fill, mask_blur, mask_dilate_scale)
|
||||
h, w = image_bgr.shape[:2]
|
||||
landmarks = detector.detect(image_bgr)
|
||||
if landmarks is None:
|
||||
@@ -685,11 +880,12 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
|
||||
px_per_cm = estimate_scale_factor(landmarks, w, h)
|
||||
logger.info("[%s] 人脸检出 px_per_cm=%.3f 图尺寸=%dx%d", rid, px_per_cm, w, h)
|
||||
|
||||
# 步骤1:接口9 遮罩
|
||||
# 步骤1:接口9 遮罩(固定 pushed)
|
||||
t0 = time.time()
|
||||
mask_bool, mask_viz = compute_mask(
|
||||
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
|
||||
logger.info("[%s] 步骤1 遮罩完成 耗时=%dms mask_pixels=%d", rid, int(t_mask*1000), int(mask_bool.sum()))
|
||||
|
||||
@@ -699,47 +895,105 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
|
||||
swap_result = _call_hairgrow(image_bgr, mask_bool, hairgrow_strength)
|
||||
else:
|
||||
ext_mask = mask_bool if swap_mode == "ext_mask" else None
|
||||
swap_result = _call_swap(image_bgr, hairline_id, is_hr, ext_mask, denoising_strength)
|
||||
swap_result = _call_swap(image_bgr, hairline_id, is_hr, ext_mask, denoising_strength,
|
||||
inpainting_fill=inpainting_fill, mask_blur=mask_blur,
|
||||
mask_dilate_scale=mask_dilate_scale)
|
||||
t_swap = time.time() - t0
|
||||
|
||||
# 步骤3:严格按遮罩硬贴回(无融合,用于对比)
|
||||
hard_paste = image_bgr.copy()
|
||||
hard_paste[mask_bool] = swap_result[mask_bool]
|
||||
|
||||
# 步骤4:接缝融合
|
||||
# 步骤4:接缝融合(默认 multiband)→ ④ final
|
||||
t0 = time.time()
|
||||
final, alpha = _composite(
|
||||
image_bgr, swap_result, mask_bool, blend_method, feather_px, edge_erode_px,
|
||||
color_match=color_match, mb_levels=mb_levels)
|
||||
image_bgr, swap_result, mask_bool, blend_method, 0, edge_erode_px,
|
||||
color_match=color_match, mb_levels=mb_levels,
|
||||
color_match_strength=color_match_strength,
|
||||
mb_feather_px=mb_feather_px, transition_band_px=transition_band_px)
|
||||
t_blend = time.time() - t0
|
||||
|
||||
return {
|
||||
"final": final, "swap_result": swap_result, "hard_paste": hard_paste,
|
||||
"alpha": alpha, "mask_bool": mask_bool, "mask_viz": mask_viz,
|
||||
"px_per_cm": px_per_cm, "t_mask": t_mask, "t_swap": t_swap, "t_blend": t_blend,
|
||||
"h": h, "w": w,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 主入口
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segformer",
|
||||
erode_cm=0.6, swap_mode="ext_mask",
|
||||
edge_erode_px=3,
|
||||
denoising_strength=0.6, gen_backend="swaphair",
|
||||
hairgrow_strength=0.75, mb_levels=5,
|
||||
hairline_push_cm=1.0, hairline_edge="column",
|
||||
blend_method="multiband", color_match=True,
|
||||
color_match_strength=1.0, mb_feather_px=1,
|
||||
transition_band_px=-1,
|
||||
inpainting_fill=1, mask_blur=11, mask_dilate_scale=1.0,
|
||||
rid=None):
|
||||
"""接口11 完整管线(**不含重绘**,重绘见接口12 generate_hairline_redraw)。
|
||||
返回可直接进 ok() 的 data dict。未检出人脸抛 NoFaceError。
|
||||
|
||||
遮罩算法固定为 pushed(发际线外推)。
|
||||
融合算法 blend_method 默认 multiband(多频段金字塔),可选 seamless(泊松)/
|
||||
two_stage(泊松→多频段两段式)/feather(羽化)/alpha_gradient(距离变换)。
|
||||
color_match 默认开启 Reinhard 颜色迁移消除整体色差(对 multiband/feather 有效)。
|
||||
inpainting_fill/mask_blur/mask_dilate_scale:透传 change_hair 服务端换发型重绘参数。
|
||||
rid: 调用方的 request id,用于日志关联。为 None 时自动生成。
|
||||
"""
|
||||
if rid is None:
|
||||
rid = uuid4().hex[:8]
|
||||
logger.info("[%s] ===== generate_hairline_grow 开始 =====", rid)
|
||||
core = _grow_core(
|
||||
image_bgr, hairline_id, is_hr=is_hr, seg_model=seg_model, erode_cm=erode_cm,
|
||||
swap_mode=swap_mode, edge_erode_px=edge_erode_px, denoising_strength=denoising_strength,
|
||||
gen_backend=gen_backend, hairgrow_strength=hairgrow_strength, mb_levels=mb_levels,
|
||||
hairline_push_cm=hairline_push_cm, hairline_edge=hairline_edge, blend_method=blend_method,
|
||||
color_match=color_match, color_match_strength=color_match_strength,
|
||||
mb_feather_px=mb_feather_px, transition_band_px=transition_band_px,
|
||||
inpainting_fill=inpainting_fill, mask_blur=mask_blur,
|
||||
mask_dilate_scale=mask_dilate_scale, rid=rid)
|
||||
mask_viz = core["mask_viz"]
|
||||
alpha = core["alpha"]
|
||||
w, h = core["w"], core["h"]
|
||||
|
||||
data = {
|
||||
"hairline_id": hairline_id,
|
||||
"gen_backend": gen_backend,
|
||||
"hairgrow_strength": round(float(hairgrow_strength), 3),
|
||||
"is_hr": is_hr,
|
||||
"seg_model": seg_model,
|
||||
"mask_type": mask_type,
|
||||
"mask_type": "pushed",
|
||||
"erode_cm": round(float(erode_cm), 2),
|
||||
"swap_mode": swap_mode,
|
||||
"blend_method": blend_method,
|
||||
"feather_px": int(feather_px),
|
||||
"edge_erode_px": int(edge_erode_px),
|
||||
"color_match": bool(color_match) and blend_method != "seamless",
|
||||
"mb_levels": int(mb_levels),
|
||||
"hairline_push_cm": round(float(hairline_push_cm), 2),
|
||||
"hairline_edge": hairline_edge,
|
||||
"denoising_strength": round(float(denoising_strength), 3),
|
||||
"px_per_cm": round(float(px_per_cm), 4),
|
||||
"color_match": bool(color_match),
|
||||
"color_match_strength": round(float(color_match_strength), 3),
|
||||
"mb_feather_px": int(mb_feather_px),
|
||||
"transition_band_px": int(transition_band_px),
|
||||
"inpainting_fill": int(inpainting_fill),
|
||||
"mask_blur": int(mask_blur),
|
||||
"mask_dilate_scale": round(float(mask_dilate_scale), 3),
|
||||
"px_per_cm": round(float(core["px_per_cm"]), 4),
|
||||
"erode_px": mask_viz["erode_px"],
|
||||
"hair_pixels": mask_viz["hair_pixels"],
|
||||
"closed_pixels": mask_viz["closed_pixels"],
|
||||
"mask_pixels": mask_viz["mask_pixels"],
|
||||
"image_size": {"width": w, "height": h},
|
||||
"timings_ms": {
|
||||
"mask": int(t_mask * 1000),
|
||||
"swap": int(t_swap * 1000),
|
||||
"blend": int(t_blend * 1000),
|
||||
"mask": int(core["t_mask"] * 1000),
|
||||
"swap": int(core["t_swap"] * 1000),
|
||||
"blend": int(core["t_blend"] * 1000),
|
||||
},
|
||||
"steps": {
|
||||
"input_base64": _jpg_b64(image_bgr),
|
||||
@@ -755,16 +1009,135 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
|
||||
# 最终遮罩
|
||||
"mask_overlay_base64": mask_viz["mask_overlay_base64"],
|
||||
"mask_base64": mask_viz["mask_base64"],
|
||||
"swap_raw_base64": _jpg_b64(swap_result),
|
||||
"hard_paste_base64": _jpg_b64(hard_paste),
|
||||
"swap_raw_base64": _jpg_b64(core["swap_result"]),
|
||||
"hard_paste_base64": _jpg_b64(core["hard_paste"]),
|
||||
"alpha_base64": _gray_b64(alpha) if alpha is not None else mask_viz["mask_base64"],
|
||||
"final_base64": _jpg_b64(final),
|
||||
"final_base64": _jpg_b64(core["final"]),
|
||||
},
|
||||
"_rid": rid, # 调试用:返回本次请求的日志关联 id
|
||||
"_rid": rid,
|
||||
}
|
||||
# 记录 steps 各图字段是否非空,供排查前端取图问题
|
||||
steps_summary = {k: (len(v) if isinstance(v, str) and v else 0)
|
||||
for k, v in data["steps"].items() if k.endswith("_base64")}
|
||||
logger.info("[%s] 返回 steps 字段长度: %s", rid, steps_summary)
|
||||
logger.info("[%s] ===== generate_hairline_grow 完成 =====", rid)
|
||||
return data
|
||||
|
||||
|
||||
def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="segformer",
|
||||
erode_cm=0.6, swap_mode="ext_mask",
|
||||
edge_erode_px=3,
|
||||
denoising_strength=0.6, gen_backend="swaphair",
|
||||
hairgrow_strength=0.75, mb_levels=5,
|
||||
hairline_push_cm=1.0, hairline_edge="column",
|
||||
blend_method="multiband", color_match=True,
|
||||
color_match_strength=1.0, mb_feather_px=1,
|
||||
transition_band_px=-1,
|
||||
inpainting_fill=1, mask_blur=11, mask_dilate_scale=1.0,
|
||||
comfyui_prompt=None, beauty_alpha=0.6,
|
||||
band_lo_mult=0.5, band_hi_mult=1.5, rid=None,
|
||||
hair_mask=None):
|
||||
"""接口12 发际线带重绘。内部先跑接口11 核心拿到 ④ final,再取 ⑤-① 发际线重绘带
|
||||
(外推↔内推之间、经 baseline 截断只留上部)作遮罩。
|
||||
|
||||
**本接口不再做 Flux-2 重绘**:只产出 `final`(接缝融合基底)+ 纯红遮罩
|
||||
`redraw_band_mask`(RGBA,遮罩区=(255,0,0,255)、其余全透明),重绘交给后端
|
||||
ComfyUI 重绘接口(/api/v1/redraw)完成。旧的 `redraw_full` / `redraw_band`
|
||||
字段保留为空,仅作结构兼容。
|
||||
|
||||
返回可直接进 ok() 的 data dict。未检出人脸抛 NoFaceError。
|
||||
|
||||
comfyui_prompt:保留入参,但本接口不再使用(重绘提示词由外部服务自行决定)。
|
||||
beauty_alpha:保留入参,但本接口不再使用(美颜由外部服务控制)。
|
||||
band_lo_mult / band_hi_mult:重绘带外推倍率(相对 hairline_push_cm),带位于
|
||||
lo×push ~ hi×push 之间(内轮廓=0×、原外推线=1.0×),默认 0.5 / 1.5。
|
||||
其余参数含义与接口11 相同(用于内部生成 final 与重绘带)。
|
||||
"""
|
||||
if rid is None:
|
||||
rid = uuid4().hex[:8]
|
||||
logger.info("[%s] ===== generate_hairline_redraw 开始 =====", rid)
|
||||
core = _grow_core(
|
||||
image_bgr, hairline_id, is_hr=is_hr, seg_model=seg_model, erode_cm=erode_cm,
|
||||
swap_mode=swap_mode, edge_erode_px=edge_erode_px, denoising_strength=denoising_strength,
|
||||
gen_backend=gen_backend, hairgrow_strength=hairgrow_strength, mb_levels=mb_levels,
|
||||
hairline_push_cm=hairline_push_cm, hairline_edge=hairline_edge, blend_method=blend_method,
|
||||
color_match=color_match, color_match_strength=color_match_strength,
|
||||
mb_feather_px=mb_feather_px, transition_band_px=transition_band_px,
|
||||
inpainting_fill=inpainting_fill, mask_blur=mask_blur,
|
||||
mask_dilate_scale=mask_dilate_scale, rid=rid, render_viz=False,
|
||||
hair_mask=hair_mask)
|
||||
final = core["final"]
|
||||
mask_viz = core["mask_viz"]
|
||||
w, h = core["w"], core["h"]
|
||||
px_per_cm = core["px_per_cm"]
|
||||
|
||||
# ① 算重绘带(⑤-①):发际线(内轮廓)↔外推发际线成带,经 baseline 截断只留上部
|
||||
t0 = time.time()
|
||||
inner_pts = mask_viz.get("_inner_pts")
|
||||
outer_pts = mask_viz.get("_outer_pts")
|
||||
upper_mask = mask_viz.get("_upper_mask")
|
||||
push_px = int(round(max(0.0, hairline_push_cm) * px_per_cm))
|
||||
redraw_band_overlay_b64 = ""
|
||||
redraw_band_mask_b64 = "" # 纯红 alpha PNG(遮罩区=(255,0,0,255),其余全透明)
|
||||
redraw_info = {"enabled": False}
|
||||
band_mask = None
|
||||
try:
|
||||
band_mask = _redraw_band_mask(inner_pts, outer_pts, h, w, rid=rid, upper=upper_mask,
|
||||
lo_mult=band_lo_mult, hi_mult=band_hi_mult)
|
||||
if band_mask.sum() < 30:
|
||||
raise RuntimeError("重绘带像素过少,可能内轮廓/外推线缺失")
|
||||
logger.info("[%s] 重绘带 push_px=%d lo_mult=%s hi_mult=%s band_pixels=%d",
|
||||
rid, push_px, band_lo_mult, band_hi_mult, int(band_mask.sum()))
|
||||
redraw_band_overlay_b64 = _jpg_b64(_overlay(final, band_mask, (255, 0, 255)))
|
||||
# 纯红遮罩 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,
|
||||
"band_lo_mult": float(band_lo_mult), "band_hi_mult": float(band_hi_mult)}
|
||||
except Exception as ex: # noqa: BLE001
|
||||
logger.exception("[%s] 重绘带计算失败,整个重绘跳过", rid)
|
||||
redraw_info = {"enabled": False, "error": f"band: {ex}"}
|
||||
|
||||
# ② Flux-2 重绘已下线:本接口现在只产出 final(接缝融合基底)+ 纯红重绘带遮罩,
|
||||
# 重绘交给后端 ComfyUI 重绘接口(/api/v1/redraw)完成。
|
||||
# 下面保留 redraw_full_b64 / redraw_band_b64 为空,保持返回结构兼容(旧字段)。
|
||||
redraw_full_b64 = ""
|
||||
redraw_band_b64 = ""
|
||||
t_redraw = time.time() - t0
|
||||
|
||||
data = {
|
||||
"hairline_id": hairline_id,
|
||||
"blend_method": blend_method,
|
||||
"hairline_push_cm": round(float(hairline_push_cm), 2),
|
||||
"comfyui_prompt": comfyui_prompt or "填充遮罩区域的头发",
|
||||
"beauty_alpha": beauty_alpha,
|
||||
"px_per_cm": round(float(px_per_cm), 4),
|
||||
"mask_pixels": mask_viz["mask_pixels"],
|
||||
"image_size": {"width": w, "height": h},
|
||||
"timings_ms": {
|
||||
"mask": int(core["t_mask"] * 1000),
|
||||
"swap": int(core["t_swap"] * 1000),
|
||||
"blend": int(core["t_blend"] * 1000),
|
||||
"redraw": int(t_redraw * 1000),
|
||||
},
|
||||
"steps": {
|
||||
"input_base64": _jpg_b64(image_bgr),
|
||||
# 接口11 的 ④ final —— 作为本接口的重绘输入基底
|
||||
"final_base64": _jpg_b64(final),
|
||||
# ⑤-① 发际线重绘带(紫,已按 baseline 截断只留上部)
|
||||
"redraw_band_overlay_base64": redraw_band_overlay_b64,
|
||||
# ⑤-② 发际线重绘带遮罩(纯红 alpha PNG,遮罩区=(255,0,0,255))
|
||||
"redraw_band_mask_base64": redraw_band_mask_b64,
|
||||
# A:ComfyUI 整帧重绘+美颜(已下线,保留空字段兼容旧前端)
|
||||
"redraw_full_base64": redraw_full_b64,
|
||||
# B:加发只在发际线带、美颜保留全脸(已下线,保留空字段兼容旧前端)
|
||||
"redraw_band_base64": redraw_band_b64,
|
||||
# 兼容旧字段:指向 A(整帧版)
|
||||
"redraw_c_base64": redraw_full_b64,
|
||||
},
|
||||
"redraw": redraw_info,
|
||||
"_rid": rid,
|
||||
}
|
||||
steps_summary = {k: (len(v) if isinstance(v, str) and v else 0)
|
||||
for k, v in data["steps"].items() if k.endswith("_base64")}
|
||||
logger.info("[%s] 返回 steps 字段长度: %s", rid, steps_summary)
|
||||
logger.info("[%s] ===== generate_hairline_redraw 完成 =====", rid)
|
||||
return data
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
流程(详见需求讨论):
|
||||
1. MediaPipe 关键点检测。
|
||||
2. 底部分割线 = 关键点 [21,68,104,69,108,151,337,299,333,298,251] 的连线(左端21→中心151→右端251),
|
||||
再把左端点 21 水平延伸到图片最左边、右端点 251 水平延伸到图片最右边。
|
||||
2. 底部分割线 = 关键点 [162,71,68,104,69,108,151,337,299,333,298,301,389] 的连线(左端162→中心151→右端389),
|
||||
再把左端点 162 水平延伸到图片最左边、右端点 389 水平延伸到图片最右边。
|
||||
3. 上半区 = 分割线以上区域(多边形填充:左边缘→弧线→右边缘→上边缘闭合)。
|
||||
4. 头发分割:BiSeNet 与 SegFormer 各出一张 hair_mask(两套供对比)。
|
||||
5. 闭合区域(含额头):每列从最顶端头发像素向下填充到分割线,把头发与画线之间的额头皮肤
|
||||
@@ -22,8 +22,11 @@ import numpy as np
|
||||
from face_analysis.detector import detector
|
||||
from face_analysis.calibration import estimate_scale_factor, normalized_to_pixel
|
||||
|
||||
# 底部额头弧线关键点(图像上从左到右:左端 21 → 中心 151 → 右端 251)
|
||||
BASELINE_IDX = [21, 68, 104, 69, 108, 151, 337, 299, 333, 298, 251]
|
||||
# 底部分割线关键点(图像上从左到右,眉骨弧线 → 中心 151 → 右侧对称)
|
||||
# 左端 104 → 中心 151 → 右端 333;首末点向图片左右边缘水平延长
|
||||
# BASELINE_IDX = [104, 69, 108, 151, 337, 299, 333]
|
||||
# BASELINE_IDX = [34, 139, 71, 68, 104, 69, 108, 151, 337, 299, 333, 298, 301, 368, 264]
|
||||
BASELINE_IDX = [71, 68, 104, 69, 108, 151, 337, 299, 333, 298, 301]
|
||||
CENTER_IDX = 151 # 内缩方向的目标点(额头中心)
|
||||
ERODE_CM = 1.2 # 外缘内缩距离(厘米,默认;可由入参覆盖)
|
||||
SEGFORMER_HAIR = 13 # jonathandinu/face-parsing 中 hair 类索引
|
||||
@@ -50,12 +53,12 @@ def _baseline_points(landmarks, w, h):
|
||||
def _upper_region_mask(baseline_pts, w, h):
|
||||
"""分割线以上区域(bool,H×W)。
|
||||
|
||||
多边形顶点:左上角 →(0, y54)→ 弧线各点 →(w-1, y284)→ 右上角,闭合后填充。
|
||||
其中 54→左边缘、284→右边缘为两段水平延长线。
|
||||
多边形顶点:左上角 →(0, y左端)→ 弧线各点 →(w-1, y右端)→ 右上角,闭合后填充。
|
||||
左端/右端为两段水平延长线(向图片左右边缘延伸)。
|
||||
"""
|
||||
x54, y54 = baseline_pts[0]
|
||||
x284, y284 = baseline_pts[-1]
|
||||
poly = [(0, 0), (0, y54)] + baseline_pts + [(w - 1, y284), (w - 1, 0)]
|
||||
x0, y0 = baseline_pts[0]
|
||||
x1, y1 = baseline_pts[-1]
|
||||
poly = [(0, 0), (0, y0)] + baseline_pts + [(w - 1, y1), (w - 1, 0)]
|
||||
mask = np.zeros((h, w), np.uint8)
|
||||
cv2.fillPoly(mask, [np.array(poly, np.int32)], 1)
|
||||
return mask.astype(bool)
|
||||
@@ -108,9 +111,9 @@ def _overlay(image, mask_bool, color, alpha=0.45):
|
||||
def _draw_baseline(image, baseline_pts, w):
|
||||
"""画分割线(含左右水平延长线)+ 关键点,中心点 151 标红。"""
|
||||
out = image.copy()
|
||||
y54 = baseline_pts[0][1]
|
||||
y284 = baseline_pts[-1][1]
|
||||
chain = [(0, y54)] + baseline_pts + [(w - 1, y284)]
|
||||
y0 = baseline_pts[0][1]
|
||||
y1 = baseline_pts[-1][1]
|
||||
chain = [(0, y0)] + baseline_pts + [(w - 1, y1)]
|
||||
for a, b in zip(chain[:-1], chain[1:]):
|
||||
cv2.line(out, a, b, (0, 255, 255), 2, cv2.LINE_AA)
|
||||
for idx, p in zip(BASELINE_IDX, baseline_pts):
|
||||
|
||||
@@ -12,9 +12,9 @@ from face_analysis.calibration import (
|
||||
estimate_scale_factor, normalized_to_pixel, pixel_distance, _lm_list,
|
||||
)
|
||||
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_CHEEK, RIGHT_CHEEK,
|
||||
LEFT_CHEEK, RIGHT_CHEEK, LEFT_POSITION, RIGHT_POSITION,
|
||||
)
|
||||
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):
|
||||
"""眉心 = 索引 9 / 151 中点。"""
|
||||
g9 = 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
|
||||
"""眉心 = 索引 9(眉间上点)。"""
|
||||
return normalized_to_pixel(lm[GLABELLA_9], w, h)
|
||||
|
||||
|
||||
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:
|
||||
"""测量结果,提供 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.eyes = eyes
|
||||
self.px_per_cm = px_per_cm
|
||||
self.hairline_source = hairline_source
|
||||
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.upper_cm = vertical["upper_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.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
|
||||
@@ -167,46 +190,88 @@ class MeasureResult:
|
||||
self.inter_eye_cm = eyes["inter_eye_distance_px"] / px_per_cm
|
||||
|
||||
def to_response(self):
|
||||
total_px = (self.vertical["top_court_px"] + self.vertical["upper_court_px"]
|
||||
+ self.vertical["middle_court_px"] + self.vertical["lower_court_px"])
|
||||
fw_px = self.eyes["face_width_px"]
|
||||
|
||||
def pt(name):
|
||||
x, y = self.vertical[name]
|
||||
return {"x": int(round(x)), "y": int(round(y))}
|
||||
|
||||
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),
|
||||
# 发际线弃用:顶/上庭相关字段置 null(保留键),ratio 分母只算中下庭;
|
||||
# landmarks.hair_top/hairline 置 null。否则按四庭正常输出。
|
||||
if self.hairline_discarded:
|
||||
base_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": None,
|
||||
"upper_court_cm": None,
|
||||
"middle_court_cm": round(self.middle_cm, 2),
|
||||
"lower_court_cm": round(self.lower_cm, 2),
|
||||
"ratios": {
|
||||
"top_court": None,
|
||||
"upper_court": None,
|
||||
"middle_court": round(self.vertical["middle_court_px"] / base_px, 3),
|
||||
"lower_court": round(self.vertical["lower_court_px"] / base_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"] / fw_px, 3),
|
||||
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / fw_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("hair_top"),
|
||||
"hairline": pt("hairline"),
|
||||
"brow_center": pt("brow_center"),
|
||||
"nose_bottom": pt("nose_bottom"),
|
||||
"chin_tip": pt("chin_tip"),
|
||||
},
|
||||
"hairline_source": self.hairline_source,
|
||||
}
|
||||
"landmarks": {
|
||||
"hair_top": None,
|
||||
"hairline": None,
|
||||
"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,
|
||||
}
|
||||
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:
|
||||
yaw, pitch, roll = self.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)
|
||||
eyes = measure_seven_eyes(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__":
|
||||
|
||||
@@ -50,12 +50,21 @@ def estimate_head_pose(landmarks, image_width, image_height):
|
||||
[0, 0, 1]], dtype=np.float64)
|
||||
dist = np.zeros((4, 1)) # 假设无畸变
|
||||
|
||||
success, rvec, _tvec = cv2.solvePnP(
|
||||
success, rvec, tvec = cv2.solvePnP(
|
||||
_MODEL_POINTS, image_points, cam_matrix, dist,
|
||||
flags=cv2.SOLVEPNP_ITERATIVE,
|
||||
)
|
||||
if not success:
|
||||
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)
|
||||
# 在「相机坐标系」(x右 y下 z内) 下抽取 Tait-Bryan 欧拉角,物理含义对齐:
|
||||
# yaw = 绕 Y(竖轴)转 → 左右扭头
|
||||
|
||||
@@ -37,7 +37,8 @@ _PROMPT = (
|
||||
"基因风格(戏剧型/睿智型/自然型/古典型/优雅型/浪漫型/前卫型/少女型/少年型)"
|
||||
)
|
||||
|
||||
_client = None
|
||||
_client = None # 缓存的 Ark client(api_key 变更时自动重建)
|
||||
_client_key: str | None = None # _client 构建时使用的 api_key,用于检测配置变更
|
||||
|
||||
|
||||
def _load_api_key() -> str | None:
|
||||
@@ -60,13 +61,21 @@ def _load_api_key() -> str | None:
|
||||
|
||||
|
||||
def get_client():
|
||||
global _client
|
||||
if _client is None:
|
||||
"""返回 Ark client。
|
||||
|
||||
client 全局缓存,但每次都会重新读取 api_key —— 一旦配置(环境变量 /
|
||||
worker_config.json / gateway/config.json 的 ark_api_key)发生变化,
|
||||
自动重建 client。这样换 key 后无需重启进程。
|
||||
"""
|
||||
global _client, _client_key
|
||||
key = _load_api_key()
|
||||
if not key:
|
||||
raise RuntimeError("缺少火山方舟 API Key(设 ARK_API_KEY 或 worker_config.json.ark_api_key)")
|
||||
# client 未建、或 key 变了 → 重建
|
||||
if _client is None or key != _client_key:
|
||||
from volcenginesdkarkruntime import Ark
|
||||
key = _load_api_key()
|
||||
if not key:
|
||||
raise RuntimeError("缺少火山方舟 API Key(设 ARK_API_KEY 或 worker_config.json.ark_api_key)")
|
||||
_client = Ark(base_url=ARK_BASE_URL, api_key=key)
|
||||
_client_key = key
|
||||
return _client
|
||||
|
||||
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
[Unit]
|
||||
Description=Hair Worker (GPU) - 四庭七眼测量 接口1
|
||||
After=network.target
|
||||
Description=hair GPU worker FastAPI (0.0.0.0:8187)
|
||||
After=network-online.target comfyui.service change_hair-hair.service
|
||||
Wants=comfyui.service change_hair-hair.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=xsl
|
||||
WorkingDirectory=/home/xsl/hair
|
||||
# 鉴权密码:优先 worker_config.json;也可在此用环境变量覆盖
|
||||
# Environment=WORKER_ACCEPT_PASSWORDS=your-strong-secret
|
||||
ExecStart=/home/xsl/hair/venv/bin/uvicorn app:app --host 0.0.0.0 --port 8187
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
User=ubuntu
|
||||
WorkingDirectory=/home/ubuntu/hair
|
||||
ExecStart=/home/ubuntu/hair/venv/bin/uvicorn app:app --host 0.0.0.0 --port 8187
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
{
|
||||
"1": {
|
||||
"inputs": {
|
||||
"scheduler": "simple",
|
||||
"steps": 6,
|
||||
"denoise": 1,
|
||||
"model": [
|
||||
"2",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "BasicScheduler",
|
||||
"_meta": {
|
||||
"title": "基本调度器"
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"inputs": {
|
||||
"max_shift": 1.15,
|
||||
"base_shift": 0.5,
|
||||
"width": [
|
||||
"14",
|
||||
0
|
||||
],
|
||||
"height": [
|
||||
"14",
|
||||
1
|
||||
],
|
||||
"model": [
|
||||
"16",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ModelSamplingFlux",
|
||||
"_meta": {
|
||||
"title": "采样算法(Flux)"
|
||||
}
|
||||
},
|
||||
"3": {
|
||||
"inputs": {
|
||||
"vae_name": "flux2-vae.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "加载VAE"
|
||||
}
|
||||
},
|
||||
"5": {
|
||||
"inputs": {
|
||||
"conditioning": [
|
||||
"19",
|
||||
0
|
||||
],
|
||||
"latent": [
|
||||
"13",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ReferenceLatent",
|
||||
"_meta": {
|
||||
"title": "参考Latent"
|
||||
}
|
||||
},
|
||||
"6": {
|
||||
"inputs": {
|
||||
"noise_seed": 808990860769642
|
||||
},
|
||||
"class_type": "RandomNoise",
|
||||
"_meta": {
|
||||
"title": "随机噪波"
|
||||
}
|
||||
},
|
||||
"7": {
|
||||
"inputs": {
|
||||
"width": [
|
||||
"14",
|
||||
0
|
||||
],
|
||||
"height": [
|
||||
"14",
|
||||
1
|
||||
],
|
||||
"batch_size": 1
|
||||
},
|
||||
"class_type": "EmptySD3LatentImage",
|
||||
"_meta": {
|
||||
"title": "空Latent图像(SD3)"
|
||||
}
|
||||
},
|
||||
"8": {
|
||||
"inputs": {
|
||||
"sampler_name": "euler"
|
||||
},
|
||||
"class_type": "KSamplerSelect",
|
||||
"_meta": {
|
||||
"title": "K采样器选择"
|
||||
}
|
||||
},
|
||||
"9": {
|
||||
"inputs": {
|
||||
"noise": [
|
||||
"6",
|
||||
0
|
||||
],
|
||||
"guider": [
|
||||
"20",
|
||||
0
|
||||
],
|
||||
"sampler": [
|
||||
"8",
|
||||
0
|
||||
],
|
||||
"sigmas": [
|
||||
"1",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"7",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"_meta": {
|
||||
"title": "自定义采样器(高级)"
|
||||
}
|
||||
},
|
||||
"10": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"9",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"3",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": {
|
||||
"title": "VAE解码"
|
||||
}
|
||||
},
|
||||
"13": {
|
||||
"inputs": {
|
||||
"pixels": [
|
||||
"44",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"3",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEEncode",
|
||||
"_meta": {
|
||||
"title": "VAE编码"
|
||||
}
|
||||
},
|
||||
"14": {
|
||||
"inputs": {
|
||||
"image": [
|
||||
"44",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "GetImageSize+",
|
||||
"_meta": {
|
||||
"title": "🔧 Get Image Size"
|
||||
}
|
||||
},
|
||||
"16": {
|
||||
"inputs": {
|
||||
"unet_name": "flux-2-klein-9b-Q4_K_M.gguf",
|
||||
"weight_dtype": "fp8_e4m3fn"
|
||||
},
|
||||
"class_type": "UnetLoaderGGUF",
|
||||
"_meta": {
|
||||
"title": "UNet加载器"
|
||||
}
|
||||
},
|
||||
"17": {
|
||||
"inputs": {
|
||||
"filename_prefix": "ComfyUI",
|
||||
"images": [
|
||||
"62",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SaveImage",
|
||||
"_meta": {
|
||||
"title": "保存图像"
|
||||
}
|
||||
},
|
||||
"19": {
|
||||
"inputs": {
|
||||
"guidance": 1,
|
||||
"conditioning": [
|
||||
"22",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "FluxGuidance",
|
||||
"_meta": {
|
||||
"title": "Flux引导"
|
||||
}
|
||||
},
|
||||
"20": {
|
||||
"inputs": {
|
||||
"model": [
|
||||
"2",
|
||||
0
|
||||
],
|
||||
"conditioning": [
|
||||
"5",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "BasicGuider",
|
||||
"_meta": {
|
||||
"title": "基本引导器"
|
||||
}
|
||||
},
|
||||
"22": {
|
||||
"inputs": {
|
||||
"text": [
|
||||
"60",
|
||||
0
|
||||
],
|
||||
"clip": [
|
||||
"61",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": {
|
||||
"title": "CLIP文本编码"
|
||||
}
|
||||
},
|
||||
"26": {
|
||||
"inputs": {
|
||||
"image": "clipspace/clipspace-painted-masked-1784045785080.png [input]"
|
||||
},
|
||||
"class_type": "LoadImage",
|
||||
"_meta": {
|
||||
"title": "加载图像"
|
||||
}
|
||||
},
|
||||
"31": {
|
||||
"inputs": {
|
||||
"image": [
|
||||
"26",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "easy imageSize",
|
||||
"_meta": {
|
||||
"title": "图像尺寸"
|
||||
}
|
||||
},
|
||||
"32": {
|
||||
"inputs": {
|
||||
"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",
|
||||
"image": [
|
||||
"26",
|
||||
0
|
||||
],
|
||||
"mask": [
|
||||
"37",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "LayerUtility: ImageScaleByAspectRatio V2",
|
||||
"_meta": {
|
||||
"title": "LayerUtility: ImageScaleByAspectRatio V2"
|
||||
}
|
||||
},
|
||||
"33": {
|
||||
"inputs": {
|
||||
"masks": [
|
||||
"26",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "Mask Fill Holes",
|
||||
"_meta": {
|
||||
"title": "遮罩填充漏洞"
|
||||
}
|
||||
},
|
||||
"36": {
|
||||
"inputs": {
|
||||
"masks": [
|
||||
"33",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "Convert Masks to Images",
|
||||
"_meta": {
|
||||
"title": "遮罩到图像"
|
||||
}
|
||||
},
|
||||
"37": {
|
||||
"inputs": {
|
||||
"method": "intensity",
|
||||
"image": [
|
||||
"39",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "Image To Mask",
|
||||
"_meta": {
|
||||
"title": "图像到遮罩"
|
||||
}
|
||||
},
|
||||
"39": {
|
||||
"inputs": {
|
||||
"upscale_method": "nearest-exact",
|
||||
"width": [
|
||||
"31",
|
||||
0
|
||||
],
|
||||
"height": [
|
||||
"31",
|
||||
1
|
||||
],
|
||||
"crop": "disabled",
|
||||
"image": [
|
||||
"36",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ImageScale",
|
||||
"_meta": {
|
||||
"title": "缩放图像"
|
||||
}
|
||||
},
|
||||
"44": {
|
||||
"inputs": {
|
||||
"mask_opacity": 1,
|
||||
"mask_color": "FFFF00",
|
||||
"pass_through": true,
|
||||
"image": [
|
||||
"32",
|
||||
0
|
||||
],
|
||||
"mask": [
|
||||
"32",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "ImageAndMaskPreview",
|
||||
"_meta": {
|
||||
"title": "图像与遮罩预览"
|
||||
}
|
||||
},
|
||||
"45": {
|
||||
"inputs": {
|
||||
"images": [
|
||||
"44",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "PreviewImage",
|
||||
"_meta": {
|
||||
"title": "预览图像"
|
||||
}
|
||||
},
|
||||
"53": {
|
||||
"inputs": {
|
||||
"rgthree_comparer": {
|
||||
"images": [
|
||||
{
|
||||
"name": "A",
|
||||
"selected": true,
|
||||
"url": "/api/view?filename=rgthree.compare._temp_kzrpg_00019_.png&type=temp&subfolder=&rand=0.8964945384546902"
|
||||
},
|
||||
{
|
||||
"name": "B",
|
||||
"selected": true,
|
||||
"url": "/api/view?filename=rgthree.compare._temp_kzrpg_00020_.png&type=temp&subfolder=&rand=0.6762414189274947"
|
||||
}
|
||||
]
|
||||
},
|
||||
"image_a": [
|
||||
"62",
|
||||
0
|
||||
],
|
||||
"image_b": [
|
||||
"26",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "Image Comparer (rgthree)",
|
||||
"_meta": {
|
||||
"title": "图像对比"
|
||||
}
|
||||
},
|
||||
"60": {
|
||||
"inputs": {
|
||||
"text": "填充遮罩区域的头发"
|
||||
},
|
||||
"class_type": "JjkText",
|
||||
"_meta": {
|
||||
"title": "Text"
|
||||
}
|
||||
},
|
||||
"61": {
|
||||
"inputs": {
|
||||
"clip_name": "qwen_3_8b_fp8mixed.safetensors",
|
||||
"type": "flux2",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": {
|
||||
"title": "加载CLIP"
|
||||
}
|
||||
},
|
||||
"62": {
|
||||
"inputs": {
|
||||
"method": "mkl",
|
||||
"strength": 1,
|
||||
"multithread": true,
|
||||
"image_ref": [
|
||||
"26",
|
||||
0
|
||||
],
|
||||
"image_target": [
|
||||
"10",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ColorMatch",
|
||||
"_meta": {
|
||||
"title": "Color Match"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
"""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 输出。
|
||||
ComfyUI 开启了 HTTP Basic Auth(user `admin` + 密码),所有请求都带凭据。
|
||||
ComfyUI 若开启了 HTTP Basic Auth(user `admin` + 密码),所有请求都带凭据。
|
||||
|
||||
支持多工作流:run() 可通过 workflow_path 指定不同工作流 JSON,自动检测 SaveImage 输出节点。
|
||||
"""
|
||||
@@ -10,6 +11,7 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
@@ -28,6 +30,19 @@ _REPO = os.path.dirname(os.path.dirname(__file__))
|
||||
_INPUT_NODE = "26" # LoadImage:外部输入图(含 alpha 遮罩)
|
||||
_SEED_NODE = "6" # RandomNoise
|
||||
_PROMPT_NODE = "60" # JjkText:提示词
|
||||
_UNET_NODE = "16" # UNETLoader / UnetLoaderGGUF:Flux 模型加载
|
||||
_CLIP_NODE = "61" # CLIPLoader:qwen 文本编码器
|
||||
|
||||
# Flux 模型 → 配套文本编码器映射。切换 unet 时自动同步编码器,避免维度不匹配。
|
||||
# 规则:4b 系列配 qwen_3_4b,9b 系列(fp8/GGUF)配 qwen_3_8b_fp8mixed。
|
||||
def _clip_for_unet(unet_name: str) -> str | None:
|
||||
"""根据 unet 文件名推断配套的文本编码器文件名;无法推断返回 None。"""
|
||||
low = unet_name.lower()
|
||||
if "4b" in low and "9b" not in low:
|
||||
return "qwen_3_4b.safetensors"
|
||||
if "9b" in low:
|
||||
return "qwen_3_8b_fp8mixed.safetensors"
|
||||
return None
|
||||
|
||||
_wf_cache: dict[str, dict] = {} # path → workflow JSON
|
||||
_wf_output_node: dict[str, str] = {} # path → SaveImage 节点 ID
|
||||
@@ -84,11 +99,18 @@ def _get_output_node(workflow_path: str | None = None) -> str:
|
||||
|
||||
|
||||
def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = None,
|
||||
workflow_path: str | None = None) -> bytes:
|
||||
workflow_path: str | None = None, front: bool = False,
|
||||
unet_name: str | None = None) -> bytes:
|
||||
"""提交一次生发任务,返回输出 PNG 字节。失败抛异常。
|
||||
|
||||
prompt:非 None 时替换工作流节点60(JjkText)的文本;None 时用工作流内置默认提示词。
|
||||
workflow_path:工作流 JSON 路径,None 则用默认 add_hair.json。
|
||||
front:True 时任务插到 ComfyUI 队列最前(server 端 "front" 字段,队列号取负)。
|
||||
接口2 对时延敏感用 True,避免排在接口3/5 的批量任务后面;其余接口保持 False。
|
||||
unet_name:非 None 时改写工作流里的模型加载节点(节点16),动态切换 Flux 模型。
|
||||
.safetensors → 保持 UNETLoader 节点类型不变,只替换 unet_name;
|
||||
.gguf → 自动把节点类型改成 UnetLoaderGGUF(需装 ComfyUI-GGUF 插件)。
|
||||
None 时用工作流内置默认模型。
|
||||
"""
|
||||
path = workflow_path or _WORKFLOW_DEFAULT
|
||||
output_node = _get_output_node(path)
|
||||
@@ -103,14 +125,55 @@ def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = Non
|
||||
name = (up.get("subfolder") + "/" if up.get("subfolder") else "") + up["name"]
|
||||
|
||||
# 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[_INPUT_NODE]["inputs"]["image"] = name
|
||||
wf[_SEED_NODE]["inputs"]["noise_seed"] = random.randint(0, 2**63 - 1)
|
||||
if prompt is not None:
|
||||
wf[_PROMPT_NODE]["inputs"]["text"] = prompt
|
||||
if unet_name is not None:
|
||||
node = wf.get(_UNET_NODE)
|
||||
if node is not None:
|
||||
# .gguf 需切换到 ComfyUI-GGUF 插件的 UnetLoaderGGUF 节点;
|
||||
# .safetensors/.ckpt 保持原 UNETLoader 节点类型不变
|
||||
if unet_name.lower().endswith(".gguf"):
|
||||
node["class_type"] = "UnetLoaderGGUF"
|
||||
else:
|
||||
node["class_type"] = "UNETLoader"
|
||||
node["inputs"]["unet_name"] = unet_name
|
||||
# 同步切换配套文本编码器(4b→qwen_3_4b, 9b→qwen_3_8b),避免维度不匹配
|
||||
clip_node = wf.get(_CLIP_NODE)
|
||||
clip_name = _clip_for_unet(unet_name)
|
||||
if clip_node is not None and clip_name is not None:
|
||||
clip_node["inputs"]["clip_name"] = clip_name
|
||||
|
||||
# 3. 提交
|
||||
r = cli.post("/prompt", json={"prompt": wf, "client_id": client_id})
|
||||
# 诊断:落盘实际提交的工作流 + 输入图,便于和手动 ComfyUI 跑的对比
|
||||
try:
|
||||
import os as _os
|
||||
_diag = _os.path.join(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))),
|
||||
"log", "comfyui_last_submit")
|
||||
_os.makedirs(_diag, exist_ok=True)
|
||||
with open(_os.path.join(_diag, "workflow.json"), "w", encoding="utf-8") as _f:
|
||||
json.dump(wf, _f, ensure_ascii=False, indent=2)
|
||||
with open(_os.path.join(_diag, "input.png"), "wb") as _f:
|
||||
_f.write(rgba_png_bytes)
|
||||
with open(_os.path.join(_diag, "prompt.txt"), "w", encoding="utf-8") as _f:
|
||||
_f.write(prompt if prompt is not None else "(None=用工作流内置默认)")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
# 3. 提交(front=True 时插队到队列最前)
|
||||
payload = {"prompt": wf, "client_id": client_id}
|
||||
if front:
|
||||
payload["front"] = True
|
||||
r = cli.post("/prompt", json=payload)
|
||||
r.raise_for_status()
|
||||
prompt_id = r.json()["prompt_id"]
|
||||
|
||||
@@ -129,7 +192,7 @@ def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = Non
|
||||
outputs = entry.get("outputs")
|
||||
if outputs and output_node in outputs:
|
||||
break
|
||||
time.sleep(1.0)
|
||||
time.sleep(0.05)
|
||||
if not outputs or output_node not in outputs:
|
||||
raise TimeoutError(f"ComfyUI 出图超时({timeout}s) prompt_id={prompt_id}")
|
||||
|
||||
|
||||
@@ -186,6 +186,53 @@ def smooth_hairline_corner_aware(
|
||||
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.
|
||||
#
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""直接调 ComfyUI 重绘 — 替代 local_test HTTP 服务。
|
||||
|
||||
将 local_test/app.py 的核心逻辑(遮罩处理 + ComfyUI 调用)提取为 Python 函数,
|
||||
不再需要独立 Flask 服务。使用 0716add-hair-api.json 工作流(steps=4)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageFilter
|
||||
|
||||
from . import comfyui
|
||||
|
||||
logger = logging.getLogger("hair.worker")
|
||||
|
||||
_DEFAULT_PROMPT = "填充遮罩区域的头发"
|
||||
_REPO = os.path.dirname(os.path.dirname(__file__))
|
||||
_REPAINT_WORKFLOW = os.path.join(_REPO, "0716add-hair-api.json")
|
||||
|
||||
|
||||
def _process_mask_to_rgba(image_bytes: bytes, mask_bytes: bytes) -> bytes:
|
||||
"""将分开的 image + mask 处理为 ComfyUI 用的 RGBA PNG bytes。
|
||||
|
||||
复制 local_test/app.py 的遮罩处理逻辑:
|
||||
1. 加载 image 为 RGB
|
||||
2. 加载 mask 为 RGBA,取所有通道 max 值(支持红/白/alpha 遮罩)
|
||||
3. resize mask 到与 image 一致
|
||||
4. 高斯模糊(radius=4) 柔化边缘
|
||||
5. alpha = 255 - mask(绘制区=255 → alpha=0 → 重绘区)
|
||||
6. 合成 RGBA PNG
|
||||
"""
|
||||
image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
|
||||
mask_img = Image.open(io.BytesIO(mask_bytes)).convert("RGBA")
|
||||
mask_arr = np.array(mask_img)
|
||||
mask_data = np.max(mask_arr, axis=2) # (H, W) uint8
|
||||
|
||||
mask_data_img = Image.fromarray(mask_data, mode="L")
|
||||
if mask_data_img.size != image.size:
|
||||
mask_data_img = mask_data_img.resize(image.size, Image.LANCZOS)
|
||||
mask_data_img = mask_data_img.filter(ImageFilter.GaussianBlur(radius=4))
|
||||
|
||||
# ComfyUI LoadImage: mask = 1.0 - (alpha/255)
|
||||
# alpha=0 -> mask=1.0 (inpaint), alpha=255 -> mask=0.0 (keep)
|
||||
comfyui_alpha = Image.eval(mask_data_img, lambda x: 255 - x)
|
||||
|
||||
r, g, b = image.split()
|
||||
rgba = Image.merge("RGBA", (r, g, b, comfyui_alpha))
|
||||
|
||||
buf = io.BytesIO()
|
||||
rgba.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def run_redraw(image_bytes: bytes, mask_bytes: bytes,
|
||||
prompt: str | None = None, timeout: float = 300.0,
|
||||
front: bool = False, unet_name: str | None = None) -> bytes:
|
||||
"""直接调 ComfyUI 重绘 — 替代 local_test /api/generate。
|
||||
|
||||
Args:
|
||||
image_bytes: 人物图片字节(JPG/PNG)
|
||||
mask_bytes: 遮罩图片字节(支持红/白/alpha 遮罩格式)
|
||||
prompt: 提示词,None 用默认 "填充遮罩区域的头发"
|
||||
timeout: ComfyUI 超时秒数
|
||||
front: True 时任务插到 ComfyUI 队列最前(接口2 时延敏感路径用)
|
||||
unet_name: 非 None 时切换 Flux 模型(如 flux-2-klein-9b-Q5_K_M.gguf),None 用工作流默认
|
||||
|
||||
Returns:
|
||||
重绘后的 PNG 图片字节
|
||||
|
||||
Raises:
|
||||
RuntimeError: ComfyUI 执行失败
|
||||
TimeoutError: ComfyUI 超时
|
||||
"""
|
||||
rgba_png = _process_mask_to_rgba(image_bytes, mask_bytes)
|
||||
return comfyui.run(rgba_png, timeout=timeout, prompt=prompt,
|
||||
workflow_path=_REPAINT_WORKFLOW, front=front,
|
||||
unet_name=unet_name)
|
||||
@@ -14,21 +14,82 @@ from . import constants as C
|
||||
from . import comfyui
|
||||
from .face_landmarks import FaceLandmarker
|
||||
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 .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 .marker_detect import detect_marker_hairline, path_to_curve_mask
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("hair.worker")
|
||||
|
||||
# 接口2 女性发型 key → change_hair hair_id(chang_*)映射:换发型+Flux-2 整帧重绘用。
|
||||
# 与接口12 final 的 5 型一一对应。
|
||||
_FEMALE_KEY_TO_CHANG = {
|
||||
"ellipse": "chang_tuoyuan", # 椭圆
|
||||
"flower": "chang_huaban", # 花瓣
|
||||
"heart": "chang_xinxing", # 心形
|
||||
"straight": "chang_zhixian", # 直线
|
||||
"wave": "chang_bolang", # 波浪
|
||||
}
|
||||
|
||||
_REPO = os.path.dirname(os.path.dirname(__file__))
|
||||
_TEXTURE_DIR = os.path.join(_REPO, "hairline_texture")
|
||||
_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 且激活显存把模型挤出。
|
||||
# 策略:输入图长边 > REDRAW_MAX_SIDE 才等比缩到该长边;≤ 时原图分辨率直送(不放大)。
|
||||
# 默认 1024:大于 1024 的图压到 1024(画质/速度均衡),≤1024 的小图保持原分辨率重绘。
|
||||
# 可用 REDRAW_MAX_SIDE 覆盖;0=永不缩图(原图直送)。
|
||||
_REDRAW_MAX_SIDE = int(os.getenv("REDRAW_MAX_SIDE", "1024"))
|
||||
|
||||
def _call_local_redraw(image_png_bytes, mask_png_bytes, timeout=300.0,
|
||||
max_side=None, unet_name=None):
|
||||
"""直接调 ComfyUI 重绘(替代原 local_test HTTP 服务)。
|
||||
|
||||
传 final 图 + 纯红遮罩 PNG,返回重绘后的 PNG bytes。
|
||||
失败抛异常(调用方负责 try/except 跳过)。
|
||||
|
||||
max_side:送 ComfyUI 前长边压到多少像素,None 用全局默认 _REDRAW_MAX_SIDE。
|
||||
unet_name:非 None 时切换 Flux 模型,None 用工作流内置默认。
|
||||
"""
|
||||
from .redraw import run_redraw
|
||||
eff_side = _REDRAW_MAX_SIDE if max_side is None else max_side
|
||||
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 eff_side > 0 and m > eff_side:
|
||||
scale = eff_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, eff_side)
|
||||
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前,避免排在接口3/5 的批量任务后面
|
||||
out = run_redraw(image_png_bytes, mask_png_bytes, timeout=timeout,
|
||||
prompt=_REDRAW_PROMPT, front=True, unet_name=unet_name)
|
||||
if scale < 1.0 and out:
|
||||
out = _upscale_png_to(out, orig_w, orig_h)
|
||||
return out
|
||||
|
||||
# 发际线贴图档位:middle=默认(hairline_texture/),high/low 各自独立文件夹。
|
||||
_TEXTURE_DIRS = {
|
||||
"middle": _TEXTURE_DIR,
|
||||
@@ -36,9 +97,8 @@ _TEXTURE_DIRS = {
|
||||
"low": os.path.join(_REPO, "hairline_texture_low"),
|
||||
}
|
||||
|
||||
# ⚠️ 本 worker 是 RTX 5090(sm_120),torch 2.2.2(cu121) 只编到 sm_90,CUDA 跑算子会报
|
||||
# "no kernel image"。SegFormer 默认走 CPU(~2.5s/张)。换 torch cu128 后可设 SEG_DEVICE=cuda。
|
||||
_SEG_DEVICE = os.getenv("SEG_DEVICE", "cpu")
|
||||
# torch 2.7.1+cu128 已支持 RTX 5090 (sm_120),SegFormer 走 GPU(~0.05s/张)
|
||||
_SEG_DEVICE = os.getenv("SEG_DEVICE", "cuda")
|
||||
|
||||
_landmarker = None
|
||||
_parser = None
|
||||
@@ -100,13 +160,19 @@ def extract_502(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)
|
||||
landmarks = get_landmarker().detect(rgb)
|
||||
if landmarks is None:
|
||||
return None
|
||||
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_3d = lift_hairline_to_3d(landmarks, hairline_2d)
|
||||
middle_3d = build_middle_row(landmarks, hairline_3d)
|
||||
@@ -140,8 +206,9 @@ def generate_previews(image_bgr: np.ndarray, gender: str):
|
||||
|
||||
def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = True,
|
||||
prompt: str = None, hair_styles: list[int] | None = None,
|
||||
workflow_path: str | None = None):
|
||||
"""指定发际线类型:预览图(白线) + 生发图(ComfyUI)。
|
||||
workflow_path: str | None = None,
|
||||
unet_name: str | None = None):
|
||||
"""指定发际线类型:发际线透明叠图(白线 RGBA) + 生发图(ComfyUI)。
|
||||
|
||||
hair_styles(1-indexed 列表):指定生成哪几张发际线(按贴图排序)。female: 1..5,male: 1..4。
|
||||
为 None 时生成全部(兼容旧调用)。
|
||||
@@ -149,7 +216,8 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
|
||||
False 时用**干净原图 + 空遮罩**送 ComfyUI(不烧黑色模板线)。
|
||||
prompt(默认 None):ComfyUI 提示词,非 None 时替换工作流节点60文本。
|
||||
workflow_path(默认 None):ComfyUI 工作流 JSON 路径,None 用默认 add_hair.json。
|
||||
Returns: list[dict] {"hairline_type","order","image_bgr"(预览), "grown_png"(bytes 或 None)}。
|
||||
Returns: list[dict] {"hairline_type","order","overlay"((H,W,4) RGBA 透明层),
|
||||
"grown_png"(bytes 或 None)}。
|
||||
无人脸返回 None。某张 ComfyUI 失败时该项 grown_png=None,不抛异常。
|
||||
"""
|
||||
if gender not in ("male", "female"):
|
||||
@@ -170,16 +238,23 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
|
||||
if not use_mask:
|
||||
try:
|
||||
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()
|
||||
compose_comfy_rgba(image_bgr, np.zeros((h, w), np.uint8)).save(buf, format="PNG")
|
||||
shared_grown = comfyui.run(buf.getvalue(), prompt=prompt, workflow_path=workflow_path)
|
||||
compose_comfy_rgba(img_s, msk_s).save(buf, format="PNG", compress_level=1)
|
||||
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前
|
||||
shared_grown = comfyui.run(buf.getvalue(), prompt=prompt,
|
||||
workflow_path=workflow_path, front=True,
|
||||
unet_name=unet_name)
|
||||
if gsc < 1.0 and shared_grown:
|
||||
shared_grown = _upscale_png_to(shared_grown, w, h)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("接口2 生发图失败(无遮罩):%s", e)
|
||||
|
||||
results = []
|
||||
h, w = image_bgr.shape[:2]
|
||||
for order, (key, white_path) in items:
|
||||
white = load_texture_rgba(white_path)
|
||||
preview = render_hairline_overlay(image_bgr, ctx["points"], ext_faces, uv, white)
|
||||
overlay = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)
|
||||
|
||||
if not use_mask:
|
||||
grown_png = shared_grown
|
||||
@@ -189,14 +264,124 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
|
||||
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(marked, mask).save(buf, format="PNG")
|
||||
grown_png = comfyui.run(buf.getvalue(), prompt=prompt, workflow_path=workflow_path)
|
||||
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,
|
||||
workflow_path=workflow_path, front=True,
|
||||
unet_name=unet_name)
|
||||
if gsc < 1.0 and grown_png:
|
||||
grown_png = _upscale_png_to(grown_png, w, h)
|
||||
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
|
||||
logger.warning("接口2 生发图失败 type=%s:%s", key, e)
|
||||
|
||||
results.append({"hairline_type": key, "order": order,
|
||||
"image_bgr": preview, "grown_png": grown_png})
|
||||
"overlay": overlay, "grown_png": grown_png})
|
||||
return results
|
||||
|
||||
|
||||
def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | None,
|
||||
redraw_defaults: dict,
|
||||
redraw_max_side: int | None = None,
|
||||
unet_name: str | None = None):
|
||||
"""接口2 女性专用:发际线透明叠图(同 generate_grow_results)+ 换发型重绘图。
|
||||
|
||||
grown 图来源(新流程):对每个选中发型把 female key 映射到 change_hair 的 chang_* hair_id,
|
||||
调 face_analysis.hairline_grow.generate_hairline_redraw(= 接口12 final 管线,参数用
|
||||
redraw_defaults)拿到 ④ final(接缝融合基底)+ ⑤-② 纯红遮罩 PNG,再**后端直接调
|
||||
ComfyUI**(0716add-hair-api.json 工作流)完成发际线带重绘,重绘结果作为生发图。
|
||||
|
||||
overlay 仍是发际线曲线透明层(与 generate_grow_results 完全一致)。
|
||||
Returns: list[dict] {"hairline_type","order","overlay","grown_png"(jpg bytes 或 None)};
|
||||
无人脸返回 None。单个发型换发型/重绘失败时 grown_png=None,不抛异常。
|
||||
"""
|
||||
from face_analysis.hairline_grow import generate_hairline_redraw, NoFaceError
|
||||
from face_analysis.head_mask import SEGFORMER_HAIR
|
||||
|
||||
ctx = extract_context(image_bgr)
|
||||
if ctx is None:
|
||||
return None
|
||||
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), ...] 已排序
|
||||
if hair_styles is not None:
|
||||
items = [(s, textures[s - 1]) for s in hair_styles]
|
||||
else:
|
||||
items = list(enumerate(textures, start=1))
|
||||
|
||||
results = []
|
||||
h, w = image_bgr.shape[:2]
|
||||
|
||||
# 重绘管线(swapHair + ComfyUI)统一降分辨率:真实照片 swap(SD WebUI)~5s、blend、ComfyUI
|
||||
# 均随分辨率线性下降。overlay 预览仍用全分辨率;grown_png 最后放大回原尺寸。
|
||||
eff_side = _REDRAW_MAX_SIDE if redraw_max_side is None else redraw_max_side
|
||||
redraw_img = image_bgr
|
||||
hair_mask_redraw = hair_mask_reuse
|
||||
if eff_side > 0 and max(h, w) > eff_side:
|
||||
redraw_img, _rs = _downscale_max_side(image_bgr, eff_side)
|
||||
_nh, _nw = redraw_img.shape[:2]
|
||||
if hair_mask_redraw is not None:
|
||||
hair_mask_redraw = cv2.resize(hair_mask_reuse.astype(np.uint8), (_nw, _nh),
|
||||
interpolation=cv2.INTER_NEAREST).astype(bool)
|
||||
logger.info("接口2女 管线降分辨率: %dx%d → %dx%d (max_side=%d)",
|
||||
w, h, _nw, _nh, eff_side)
|
||||
|
||||
for order, (key, white_path) in items:
|
||||
white = load_texture_rgba(white_path)
|
||||
overlay = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)
|
||||
|
||||
grown_png = None
|
||||
chang_id = _FEMALE_KEY_TO_CHANG.get(key)
|
||||
if chang_id is None:
|
||||
logger.warning("接口2 换发型:female key=%s 无对应 chang_id,跳过生发图", key)
|
||||
else:
|
||||
try:
|
||||
import time as _t
|
||||
_ts0 = _t.perf_counter()
|
||||
data = generate_hairline_redraw(redraw_img, chang_id, hair_mask=hair_mask_redraw, **redraw_defaults)
|
||||
_ts1 = _t.perf_counter()
|
||||
steps = data.get("steps") or {}
|
||||
# ④ final(接缝融合基底)+ ⑤-② 纯红遮罩 PNG
|
||||
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,
|
||||
max_side=redraw_max_side,
|
||||
unet_name=unet_name)
|
||||
_tr1 = _t.perf_counter()
|
||||
_tm = data.get("timings_ms") or {}
|
||||
logger.info("接口2女 分段计时 type=%s: swapHair管线=%.2fs (mask=%dms swap=%dms blend=%dms), ComfyUI重绘=%.2fs",
|
||||
key, _ts1 - _ts0,
|
||||
_tm.get("mask", 0), _tm.get("swap", 0), _tm.get("blend", 0),
|
||||
_tr1 - _tr0)
|
||||
if grown_png is None:
|
||||
logger.warning("接口2 换发型:type=%s 重绘结果为空", key)
|
||||
elif redraw_img is not image_bgr:
|
||||
# 管线在降分辨率图上跑,结果放大回原尺寸
|
||||
grown_png = _upscale_png_to(grown_png, w, h)
|
||||
except NoFaceError:
|
||||
logger.warning("接口2 换发型:type=%s 未检出人脸", key)
|
||||
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
|
||||
logger.warning("接口2 换发型图失败 type=%s:%s", key, e)
|
||||
|
||||
results.append({"hairline_type": key, "order": order,
|
||||
"overlay": overlay, "grown_png": grown_png})
|
||||
return results
|
||||
|
||||
|
||||
@@ -216,7 +401,7 @@ def _grow_from_texture(image_bgr: np.ndarray, ctx: dict, white_path: str | None,
|
||||
h, w = image_bgr.shape[:2]
|
||||
marked, mask = image_bgr, np.zeros((h, w), np.uint8)
|
||||
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)
|
||||
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
|
||||
logger.warning("接口5 生发图失败:%s", e)
|
||||
@@ -225,15 +410,27 @@ def _grow_from_texture(image_bgr: np.ndarray, ctx: dict, white_path: str | None,
|
||||
|
||||
def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
|
||||
hair_styles: list[int], use_mask: bool = True,
|
||||
prompt: str | None = None):
|
||||
"""接口5:对选中发型返回 middle/high/low 三档发际线叠图 + 生发图(同接口2)。
|
||||
prompt: str | None = None,
|
||||
generate_grow_image: bool = True,
|
||||
redraw_max_side: int | None = None,
|
||||
unet_name: str | None = None,
|
||||
v2_defaults: dict | None = None):
|
||||
"""接口5:对选中发型返回 middle/high/low 三档发际线透明叠图 + 生发图(同接口2)。
|
||||
|
||||
入参同接口2:先选 gender,再多选 hair_styles(必填,1-indexed 按贴图排序)。
|
||||
每个选中发型返回三档叠图(middle/high/low)与一张生发图;三档贴图同名,
|
||||
生发黑模板固定取自 hairline_texture_black/(middle),故生发目标固定 middle 档。
|
||||
use_mask/prompt:同接口2 的生发参数。
|
||||
Returns: {"images":[{hairline_type,order,overlays:{middle,high,low}(BGR),grown_png}],
|
||||
"best_center":(x,y)};无人脸 None。best_center 取首个选中发型的 middle 档。
|
||||
每个选中发型返回三档叠图(middle/high/low,RGBA 透明层只含发际线曲线)与一张生发图;
|
||||
|
||||
生发机制(同接口2,按性别分流):
|
||||
- female:generate_grow_results_swap(swapHair + Flux-2 整帧重绘)
|
||||
- male:generate_grow_results(ComfyUI add_hair inpaint)
|
||||
redraw_max_side / unet_name / v2_defaults:female 路径参数,同接口2。
|
||||
male 路径仅用 unet_name;redraw_max_side/v2_defaults 对 male 无意义(忽略)。
|
||||
use_mask/prompt:仅 male 路径生效(同接口2 male)。
|
||||
generate_grow_image(默认 True):是否生成生发图(最耗时)。False 时跳过生发,
|
||||
各发型 grown_png 恒为 None,可大幅降低耗时(仅留三档发际线叠图与中心点)。
|
||||
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 取首个选中发型三档各自的发际线中点。
|
||||
"""
|
||||
if gender not in ("male", "female"):
|
||||
raise ValueError(f"gender 必须是 male/female,收到 {gender!r}")
|
||||
@@ -251,33 +448,96 @@ def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
|
||||
# 三档贴图表(同性别、同 key 顺序,因三个文件夹同名)
|
||||
tex_by_level = {lv: get_texture_map(lv)[gender] for lv in _TEXTURE_DIRS}
|
||||
|
||||
# use_mask=False:干净原图+空遮罩与贴图无关,只跑一次 ComfyUI,选中项复用
|
||||
shared_grown = None
|
||||
if not use_mask:
|
||||
shared_grown = _grow_from_texture(image_bgr, ctx, None, use_mask=False, prompt=prompt)
|
||||
# 生发图(同接口2,按性别分流):一次性算出所有选中发型的生发图,按 order 对应回叠图。
|
||||
# female→generate_grow_results_swap(swapHair+Flux-2 整帧重绘);
|
||||
# male→generate_grow_results(ComfyUI add_hair inpaint)。
|
||||
# generate_grow_image=False 时跳过,grown_by_order 为空 dict(各发型 grown_png 恒 None)。
|
||||
grown_by_order: dict[int, bytes | None] = {}
|
||||
if generate_grow_image:
|
||||
try:
|
||||
if gender == "female":
|
||||
items = generate_grow_results_swap(
|
||||
image_bgr, hair_styles, v2_defaults or {},
|
||||
redraw_max_side=redraw_max_side, unet_name=unet_name)
|
||||
else:
|
||||
items = generate_grow_results(
|
||||
image_bgr, gender, use_mask, prompt, hair_styles,
|
||||
unet_name=unet_name)
|
||||
if items is None:
|
||||
return None # 无人脸(同接口2 的 None 语义)
|
||||
for it in items:
|
||||
grown_by_order[it["order"]] = it.get("grown_png")
|
||||
except Exception as e: # noqa: BLE001 整批生发失败不拖垮叠图主结果
|
||||
logger.warning("接口5 生发批量失败(gender=%s):%s", gender, e)
|
||||
|
||||
images, best_center = [], None
|
||||
def _center_of(overlay):
|
||||
"""从某档发际线透明叠图取面部中轴处的发际线中点 (x,y),无像素返回 None。"""
|
||||
ys, xs = np.where(overlay[:, :, 3] > 40)
|
||||
if not xs.size:
|
||||
return None
|
||||
near = np.abs(xs - face_cx) <= max(2, int(w * 0.02))
|
||||
col_ys = ys[near] if near.any() else ys[np.argsort(np.abs(xs - face_cx))[:20]]
|
||||
return (int(round(face_cx)), int(round(float(col_ys.mean()))))
|
||||
|
||||
images, best_centers = [], None
|
||||
for s in hair_styles: # s = 1-indexed 发型序号
|
||||
key, mid_path = tex_by_level["middle"][s - 1]
|
||||
key, _mid_path = tex_by_level["middle"][s - 1]
|
||||
overlays = {}
|
||||
for lv in _TEXTURE_DIRS:
|
||||
white = load_texture_rgba(tex_by_level[lv][s - 1][1])
|
||||
overlays[lv] = render_hairline_overlay(image_bgr, ctx["points"], ext_faces, uv, white)
|
||||
# 生发:固定 middle 黑模板
|
||||
grown_png = shared_grown if not use_mask else \
|
||||
_grow_from_texture(image_bgr, ctx, mid_path, use_mask=True, prompt=prompt)
|
||||
overlays[lv] = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)
|
||||
# 生发图:从按性别算好的结果里按 order 取(generate_grow_image=False 时缺省 None)
|
||||
grown_png = grown_by_order.get(s)
|
||||
images.append({"hairline_type": key, "order": s,
|
||||
"overlays": overlays, "grown_png": grown_png})
|
||||
# best_center:首个选中发型的 middle 档发际线中点(面部中轴处的发际线 y)
|
||||
if best_center is None:
|
||||
white = load_texture_rgba(mid_path)
|
||||
overlay = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)
|
||||
ys, xs = np.where(overlay[:, :, 3] > 40)
|
||||
if xs.size:
|
||||
near = np.abs(xs - face_cx) <= max(2, int(w * 0.02))
|
||||
col_ys = ys[near] if near.any() else ys[np.argsort(np.abs(xs - face_cx))[:20]]
|
||||
best_center = (int(round(face_cx)), int(round(float(col_ys.mean()))))
|
||||
return {"images": images, "best_center": best_center}
|
||||
# best_centers:首个选中发型三档(middle/high/low)发际线中点
|
||||
if best_centers is None:
|
||||
best_centers = {lv: _center_of(overlays[lv]) for lv in _TEXTURE_DIRS}
|
||||
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):
|
||||
@@ -286,12 +546,23 @@ def generate_grow_b(marked_bgr: np.ndarray, use_mask: bool = True, prompt: str =
|
||||
检测路径只用来**建遮罩**;ComfyUI 输入图用 **marked 原图**(含医生手绘线,
|
||||
工作流提示词会清除黑线再生发)。
|
||||
|
||||
进 Comfy 前若长边 > GROW_B_MAX_SIDE(默认 896)会先等比例缩小,降低峰值显存;
|
||||
输出再拉回原图尺寸。
|
||||
|
||||
use_mask(默认 True):是否启用自动检测的遮罩,用于测试对比。
|
||||
- True:检测手绘线 → 建遮罩 → alpha=255−mask(透明区=重绘区,节点44 画黄色参考区)。
|
||||
- False:跳过检测,直接送划线图,alpha 全 255(空遮罩,节点26 mask 为空),
|
||||
模型仅凭医生黑线参考生发。无需改工作流,唯一变量是遮罩。
|
||||
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]
|
||||
if use_mask:
|
||||
rgb = cv2.cvtColor(marked_bgr, cv2.COLOR_BGR2RGB)
|
||||
@@ -309,8 +580,10 @@ def generate_grow_b(marked_bgr: np.ndarray, use_mask: bool = True, prompt: str =
|
||||
mask = np.zeros((h, w), np.uint8) # 空遮罩:alpha 全 255,跳过检测
|
||||
|
||||
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)
|
||||
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"}
|
||||
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 440 KiB |
@@ -0,0 +1,75 @@
|
||||
=== 接口2 female 批量对比测试 ===
|
||||
矩阵: 4图 × 5发型 × 2档 = 40 次
|
||||
已跳过 0 个已完成项
|
||||
|
||||
[1/40] ▶ girl2_ellipse_default896 (side=None)... ✅ 10.8s 出图 1082x1081 (204589B)
|
||||
[2/40] ▶ girl2_ellipse_origin0 (side=0)... ✅ 13.3s 出图 1088x1088 (232252B)
|
||||
[3/40] ▶ girl2_flower_default896 (side=None)... ✅ 11.0s 出图 1082x1081 (210875B)
|
||||
[4/40] ▶ girl2_flower_origin0 (side=0)... ✅ 12.5s 出图 1088x1088 (236073B)
|
||||
[5/40] ▶ girl2_heart_default896 (side=None)... ✅ 11.9s 出图 1082x1081 (198911B)
|
||||
[6/40] ▶ girl2_heart_origin0 (side=0)... ✅ 12.2s 出图 1088x1088 (249893B)
|
||||
[7/40] ▶ girl2_straight_default896 (side=None)... ✅ 12.1s 出图 1082x1081 (211803B)
|
||||
[8/40] ▶ girl2_straight_origin0 (side=0)... ✅ 12.3s 出图 1088x1088 (235377B)
|
||||
[9/40] ▶ girl2_wave_default896 (side=None)... ✅ 12.7s 出图 1082x1081 (207736B)
|
||||
[10/40] ▶ girl2_wave_origin0 (side=0)... ✅ 11.9s 出图 1088x1088 (238081B)
|
||||
[11/40] ▶ girl5_ellipse_default896 (side=None)... ✅ 9.6s 出图 768x752 (105867B)
|
||||
[12/40] ▶ girl5_ellipse_origin0 (side=0)... ✅ 7.3s 出图 768x752 (112963B)
|
||||
[13/40] ▶ girl5_flower_default896 (side=None)... ✅ 8.0s 出图 768x752 (114378B)
|
||||
[14/40] ▶ girl5_flower_origin0 (side=0)... ✅ 7.2s 出图 768x752 (108495B)
|
||||
[15/40] ▶ girl5_heart_default896 (side=None)... ✅ 8.1s 出图 768x752 (113874B)
|
||||
[16/40] ▶ girl5_heart_origin0 (side=0)... ✅ 7.6s 出图 768x752 (103160B)
|
||||
[17/40] ▶ girl5_straight_default896 (side=None)... ✅ 8.4s 出图 768x752 (104492B)
|
||||
[18/40] ▶ girl5_straight_origin0 (side=0)... ✅ 7.2s 出图 768x752 (100941B)
|
||||
[19/40] ▶ girl5_wave_default896 (side=None)... ✅ 8.4s 出图 768x752 (120122B)
|
||||
[20/40] ▶ girl5_wave_origin0 (side=0)... ✅ 7.7s 出图 768x752 (113282B)
|
||||
[21/40] ▶ qwer_ellipse_default896 (side=None)... ✅ 10.2s 出图 1288x1678 (261812B)
|
||||
[22/40] ▶ qwer_ellipse_origin0 (side=0)... ✅ 30.0s 出图 1280x1680 (331013B)
|
||||
[23/40] ▶ qwer_flower_default896 (side=None)... ✅ 10.2s 出图 1288x1678 (312834B)
|
||||
[24/40] ▶ qwer_flower_origin0 (side=0)... ✅ 25.0s 出图 1280x1680 (328605B)
|
||||
[25/40] ▶ qwer_heart_default896 (side=None)... ✅ 11.1s 出图 1288x1678 (331337B)
|
||||
[26/40] ▶ qwer_heart_origin0 (side=0)... ✅ 25.0s 出图 1280x1680 (355982B)
|
||||
[27/40] ▶ qwer_straight_default896 (side=None)... ✅ 10.7s 出图 1288x1678 (281665B)
|
||||
[28/40] ▶ qwer_straight_origin0 (side=0)... ✅ 23.4s 出图 1280x1680 (322183B)
|
||||
[29/40] ▶ qwer_wave_default896 (side=None)... ✅ 11.1s 出图 1288x1678 (289322B)
|
||||
[30/40] ▶ qwer_wave_origin0 (side=0)... ✅ 25.8s 出图 1280x1680 (351719B)
|
||||
[31/40] ▶ asdf_ellipse_default896 (side=None)... ✅ 11.2s 出图 1254x1666 (285362B)
|
||||
[32/40] ▶ asdf_ellipse_origin0 (side=0)... ✅ 25.7s 出图 1248x1664 (286768B)
|
||||
[33/40] ▶ asdf_flower_default896 (side=None)... ✅ 10.7s 出图 1254x1666 (266946B)
|
||||
[34/40] ▶ asdf_flower_origin0 (side=0)... ✅ 22.9s 出图 1248x1664 (353668B)
|
||||
[35/40] ▶ asdf_heart_default896 (side=None)... ✅ 10.4s 出图 1254x1666 (271975B)
|
||||
[36/40] ▶ asdf_heart_origin0 (side=0)... ✅ 22.9s 出图 1248x1664 (291983B)
|
||||
[37/40] ▶ asdf_straight_default896 (side=None)... ✅ 9.8s 出图 1254x1666 (258665B)
|
||||
[38/40] ▶ asdf_straight_origin0 (side=0)... ✅ 24.0s 出图 1248x1664 (279883B)
|
||||
[39/40] ▶ asdf_wave_default896 (side=None)... ✅ 11.9s 出图 1254x1666 (286515B)
|
||||
[40/40] ▶ asdf_wave_origin0 (side=0)... ✅ 24.3s 出图 1248x1664 (295694B)
|
||||
|
||||
======================================================================
|
||||
汇总报告
|
||||
======================================================================
|
||||
|
||||
--- 速度对比(秒,✓=成功 ✗=失败)---
|
||||
图 发型 默认896 原图0 差值
|
||||
girl2 ellipse 10.8✓ 13.3✓ +2.5
|
||||
girl2 flower 11.0✓ 12.5✓ +1.5
|
||||
girl2 heart 11.9✓ 12.2✓ +0.3
|
||||
girl2 straight 12.1✓ 12.3✓ +0.2
|
||||
girl2 wave 12.7✓ 11.9✓ -0.8
|
||||
girl5 ellipse 9.6✓ 7.3✓ -2.3
|
||||
girl5 flower 8.0✓ 7.2✓ -0.8
|
||||
girl5 heart 8.1✓ 7.6✓ -0.5
|
||||
girl5 straight 8.4✓ 7.2✓ -1.2
|
||||
girl5 wave 8.4✓ 7.7✓ -0.7
|
||||
qwer ellipse 10.2✓ 30.0✓ +19.8
|
||||
qwer flower 10.2✓ 25.0✓ +14.8
|
||||
qwer heart 11.1✓ 25.0✓ +13.9
|
||||
qwer straight 10.7✓ 23.4✓ +12.7
|
||||
qwer wave 11.1✓ 25.8✓ +14.7
|
||||
asdf ellipse 11.2✓ 25.7✓ +14.5
|
||||
asdf flower 10.7✓ 22.9✓ +12.2
|
||||
asdf heart 10.4✓ 22.9✓ +12.5
|
||||
asdf straight 9.8✓ 24.0✓ +14.2
|
||||
asdf wave 11.9✓ 24.3✓ +12.4
|
||||
|
||||
结果图: /home/ubuntu/hair/image/compare_test/out/
|
||||
CSV报告: /home/ubuntu/hair/image/compare_test/out/report.csv
|
||||
JSON明细: /home/ubuntu/hair/image/compare_test/out/report.json
|
||||
@@ -0,0 +1,41 @@
|
||||
img,style,side,ok,elapsed_s,out_w,out_h,err
|
||||
girl2,ellipse,default896,True,10.8,1082,1081,
|
||||
girl2,ellipse,origin0,True,13.3,1088,1088,
|
||||
girl2,flower,default896,True,11.0,1082,1081,
|
||||
girl2,flower,origin0,True,12.5,1088,1088,
|
||||
girl2,heart,default896,True,11.9,1082,1081,
|
||||
girl2,heart,origin0,True,12.2,1088,1088,
|
||||
girl2,straight,default896,True,12.1,1082,1081,
|
||||
girl2,straight,origin0,True,12.3,1088,1088,
|
||||
girl2,wave,default896,True,12.7,1082,1081,
|
||||
girl2,wave,origin0,True,11.9,1088,1088,
|
||||
girl5,ellipse,default896,True,9.6,768,752,
|
||||
girl5,ellipse,origin0,True,7.3,768,752,
|
||||
girl5,flower,default896,True,8.0,768,752,
|
||||
girl5,flower,origin0,True,7.2,768,752,
|
||||
girl5,heart,default896,True,8.1,768,752,
|
||||
girl5,heart,origin0,True,7.6,768,752,
|
||||
girl5,straight,default896,True,8.4,768,752,
|
||||
girl5,straight,origin0,True,7.2,768,752,
|
||||
girl5,wave,default896,True,8.4,768,752,
|
||||
girl5,wave,origin0,True,7.7,768,752,
|
||||
qwer,ellipse,default896,True,10.2,1288,1678,
|
||||
qwer,ellipse,origin0,True,30.0,1280,1680,
|
||||
qwer,flower,default896,True,10.2,1288,1678,
|
||||
qwer,flower,origin0,True,25.0,1280,1680,
|
||||
qwer,heart,default896,True,11.1,1288,1678,
|
||||
qwer,heart,origin0,True,25.0,1280,1680,
|
||||
qwer,straight,default896,True,10.7,1288,1678,
|
||||
qwer,straight,origin0,True,23.4,1280,1680,
|
||||
qwer,wave,default896,True,11.1,1288,1678,
|
||||
qwer,wave,origin0,True,25.8,1280,1680,
|
||||
asdf,ellipse,default896,True,11.2,1254,1666,
|
||||
asdf,ellipse,origin0,True,25.7,1248,1664,
|
||||
asdf,flower,default896,True,10.7,1254,1666,
|
||||
asdf,flower,origin0,True,22.9,1248,1664,
|
||||
asdf,heart,default896,True,10.4,1254,1666,
|
||||
asdf,heart,origin0,True,22.9,1248,1664,
|
||||
asdf,straight,default896,True,9.8,1254,1666,
|
||||
asdf,straight,origin0,True,24.0,1248,1664,
|
||||
asdf,wave,default896,True,11.9,1254,1666,
|
||||
asdf,wave,origin0,True,24.3,1248,1664,
|
||||
|
@@ -0,0 +1,562 @@
|
||||
[
|
||||
{
|
||||
"key": "girl2_ellipse_default896",
|
||||
"img": "girl2",
|
||||
"style": "ellipse",
|
||||
"style_idx": 1,
|
||||
"side": "default896",
|
||||
"side_val": null,
|
||||
"ok": true,
|
||||
"elapsed": 10.8,
|
||||
"err": "",
|
||||
"out_w": 1082,
|
||||
"out_h": 1081,
|
||||
"bytes": 204589
|
||||
},
|
||||
{
|
||||
"key": "girl2_ellipse_origin0",
|
||||
"img": "girl2",
|
||||
"style": "ellipse",
|
||||
"style_idx": 1,
|
||||
"side": "origin0",
|
||||
"side_val": 0,
|
||||
"ok": true,
|
||||
"elapsed": 13.3,
|
||||
"err": "",
|
||||
"out_w": 1088,
|
||||
"out_h": 1088,
|
||||
"bytes": 232252
|
||||
},
|
||||
{
|
||||
"key": "girl2_flower_default896",
|
||||
"img": "girl2",
|
||||
"style": "flower",
|
||||
"style_idx": 2,
|
||||
"side": "default896",
|
||||
"side_val": null,
|
||||
"ok": true,
|
||||
"elapsed": 11.0,
|
||||
"err": "",
|
||||
"out_w": 1082,
|
||||
"out_h": 1081,
|
||||
"bytes": 210875
|
||||
},
|
||||
{
|
||||
"key": "girl2_flower_origin0",
|
||||
"img": "girl2",
|
||||
"style": "flower",
|
||||
"style_idx": 2,
|
||||
"side": "origin0",
|
||||
"side_val": 0,
|
||||
"ok": true,
|
||||
"elapsed": 12.5,
|
||||
"err": "",
|
||||
"out_w": 1088,
|
||||
"out_h": 1088,
|
||||
"bytes": 236073
|
||||
},
|
||||
{
|
||||
"key": "girl2_heart_default896",
|
||||
"img": "girl2",
|
||||
"style": "heart",
|
||||
"style_idx": 3,
|
||||
"side": "default896",
|
||||
"side_val": null,
|
||||
"ok": true,
|
||||
"elapsed": 11.9,
|
||||
"err": "",
|
||||
"out_w": 1082,
|
||||
"out_h": 1081,
|
||||
"bytes": 198911
|
||||
},
|
||||
{
|
||||
"key": "girl2_heart_origin0",
|
||||
"img": "girl2",
|
||||
"style": "heart",
|
||||
"style_idx": 3,
|
||||
"side": "origin0",
|
||||
"side_val": 0,
|
||||
"ok": true,
|
||||
"elapsed": 12.2,
|
||||
"err": "",
|
||||
"out_w": 1088,
|
||||
"out_h": 1088,
|
||||
"bytes": 249893
|
||||
},
|
||||
{
|
||||
"key": "girl2_straight_default896",
|
||||
"img": "girl2",
|
||||
"style": "straight",
|
||||
"style_idx": 4,
|
||||
"side": "default896",
|
||||
"side_val": null,
|
||||
"ok": true,
|
||||
"elapsed": 12.1,
|
||||
"err": "",
|
||||
"out_w": 1082,
|
||||
"out_h": 1081,
|
||||
"bytes": 211803
|
||||
},
|
||||
{
|
||||
"key": "girl2_straight_origin0",
|
||||
"img": "girl2",
|
||||
"style": "straight",
|
||||
"style_idx": 4,
|
||||
"side": "origin0",
|
||||
"side_val": 0,
|
||||
"ok": true,
|
||||
"elapsed": 12.3,
|
||||
"err": "",
|
||||
"out_w": 1088,
|
||||
"out_h": 1088,
|
||||
"bytes": 235377
|
||||
},
|
||||
{
|
||||
"key": "girl2_wave_default896",
|
||||
"img": "girl2",
|
||||
"style": "wave",
|
||||
"style_idx": 5,
|
||||
"side": "default896",
|
||||
"side_val": null,
|
||||
"ok": true,
|
||||
"elapsed": 12.7,
|
||||
"err": "",
|
||||
"out_w": 1082,
|
||||
"out_h": 1081,
|
||||
"bytes": 207736
|
||||
},
|
||||
{
|
||||
"key": "girl2_wave_origin0",
|
||||
"img": "girl2",
|
||||
"style": "wave",
|
||||
"style_idx": 5,
|
||||
"side": "origin0",
|
||||
"side_val": 0,
|
||||
"ok": true,
|
||||
"elapsed": 11.9,
|
||||
"err": "",
|
||||
"out_w": 1088,
|
||||
"out_h": 1088,
|
||||
"bytes": 238081
|
||||
},
|
||||
{
|
||||
"key": "girl5_ellipse_default896",
|
||||
"img": "girl5",
|
||||
"style": "ellipse",
|
||||
"style_idx": 1,
|
||||
"side": "default896",
|
||||
"side_val": null,
|
||||
"ok": true,
|
||||
"elapsed": 9.6,
|
||||
"err": "",
|
||||
"out_w": 768,
|
||||
"out_h": 752,
|
||||
"bytes": 105867
|
||||
},
|
||||
{
|
||||
"key": "girl5_ellipse_origin0",
|
||||
"img": "girl5",
|
||||
"style": "ellipse",
|
||||
"style_idx": 1,
|
||||
"side": "origin0",
|
||||
"side_val": 0,
|
||||
"ok": true,
|
||||
"elapsed": 7.3,
|
||||
"err": "",
|
||||
"out_w": 768,
|
||||
"out_h": 752,
|
||||
"bytes": 112963
|
||||
},
|
||||
{
|
||||
"key": "girl5_flower_default896",
|
||||
"img": "girl5",
|
||||
"style": "flower",
|
||||
"style_idx": 2,
|
||||
"side": "default896",
|
||||
"side_val": null,
|
||||
"ok": true,
|
||||
"elapsed": 8.0,
|
||||
"err": "",
|
||||
"out_w": 768,
|
||||
"out_h": 752,
|
||||
"bytes": 114378
|
||||
},
|
||||
{
|
||||
"key": "girl5_flower_origin0",
|
||||
"img": "girl5",
|
||||
"style": "flower",
|
||||
"style_idx": 2,
|
||||
"side": "origin0",
|
||||
"side_val": 0,
|
||||
"ok": true,
|
||||
"elapsed": 7.2,
|
||||
"err": "",
|
||||
"out_w": 768,
|
||||
"out_h": 752,
|
||||
"bytes": 108495
|
||||
},
|
||||
{
|
||||
"key": "girl5_heart_default896",
|
||||
"img": "girl5",
|
||||
"style": "heart",
|
||||
"style_idx": 3,
|
||||
"side": "default896",
|
||||
"side_val": null,
|
||||
"ok": true,
|
||||
"elapsed": 8.1,
|
||||
"err": "",
|
||||
"out_w": 768,
|
||||
"out_h": 752,
|
||||
"bytes": 113874
|
||||
},
|
||||
{
|
||||
"key": "girl5_heart_origin0",
|
||||
"img": "girl5",
|
||||
"style": "heart",
|
||||
"style_idx": 3,
|
||||
"side": "origin0",
|
||||
"side_val": 0,
|
||||
"ok": true,
|
||||
"elapsed": 7.6,
|
||||
"err": "",
|
||||
"out_w": 768,
|
||||
"out_h": 752,
|
||||
"bytes": 103160
|
||||
},
|
||||
{
|
||||
"key": "girl5_straight_default896",
|
||||
"img": "girl5",
|
||||
"style": "straight",
|
||||
"style_idx": 4,
|
||||
"side": "default896",
|
||||
"side_val": null,
|
||||
"ok": true,
|
||||
"elapsed": 8.4,
|
||||
"err": "",
|
||||
"out_w": 768,
|
||||
"out_h": 752,
|
||||
"bytes": 104492
|
||||
},
|
||||
{
|
||||
"key": "girl5_straight_origin0",
|
||||
"img": "girl5",
|
||||
"style": "straight",
|
||||
"style_idx": 4,
|
||||
"side": "origin0",
|
||||
"side_val": 0,
|
||||
"ok": true,
|
||||
"elapsed": 7.2,
|
||||
"err": "",
|
||||
"out_w": 768,
|
||||
"out_h": 752,
|
||||
"bytes": 100941
|
||||
},
|
||||
{
|
||||
"key": "girl5_wave_default896",
|
||||
"img": "girl5",
|
||||
"style": "wave",
|
||||
"style_idx": 5,
|
||||
"side": "default896",
|
||||
"side_val": null,
|
||||
"ok": true,
|
||||
"elapsed": 8.4,
|
||||
"err": "",
|
||||
"out_w": 768,
|
||||
"out_h": 752,
|
||||
"bytes": 120122
|
||||
},
|
||||
{
|
||||
"key": "girl5_wave_origin0",
|
||||
"img": "girl5",
|
||||
"style": "wave",
|
||||
"style_idx": 5,
|
||||
"side": "origin0",
|
||||
"side_val": 0,
|
||||
"ok": true,
|
||||
"elapsed": 7.7,
|
||||
"err": "",
|
||||
"out_w": 768,
|
||||
"out_h": 752,
|
||||
"bytes": 113282
|
||||
},
|
||||
{
|
||||
"key": "qwer_ellipse_default896",
|
||||
"img": "qwer",
|
||||
"style": "ellipse",
|
||||
"style_idx": 1,
|
||||
"side": "default896",
|
||||
"side_val": null,
|
||||
"ok": true,
|
||||
"elapsed": 10.2,
|
||||
"err": "",
|
||||
"out_w": 1288,
|
||||
"out_h": 1678,
|
||||
"bytes": 261812
|
||||
},
|
||||
{
|
||||
"key": "qwer_ellipse_origin0",
|
||||
"img": "qwer",
|
||||
"style": "ellipse",
|
||||
"style_idx": 1,
|
||||
"side": "origin0",
|
||||
"side_val": 0,
|
||||
"ok": true,
|
||||
"elapsed": 30.0,
|
||||
"err": "",
|
||||
"out_w": 1280,
|
||||
"out_h": 1680,
|
||||
"bytes": 331013
|
||||
},
|
||||
{
|
||||
"key": "qwer_flower_default896",
|
||||
"img": "qwer",
|
||||
"style": "flower",
|
||||
"style_idx": 2,
|
||||
"side": "default896",
|
||||
"side_val": null,
|
||||
"ok": true,
|
||||
"elapsed": 10.2,
|
||||
"err": "",
|
||||
"out_w": 1288,
|
||||
"out_h": 1678,
|
||||
"bytes": 312834
|
||||
},
|
||||
{
|
||||
"key": "qwer_flower_origin0",
|
||||
"img": "qwer",
|
||||
"style": "flower",
|
||||
"style_idx": 2,
|
||||
"side": "origin0",
|
||||
"side_val": 0,
|
||||
"ok": true,
|
||||
"elapsed": 25.0,
|
||||
"err": "",
|
||||
"out_w": 1280,
|
||||
"out_h": 1680,
|
||||
"bytes": 328605
|
||||
},
|
||||
{
|
||||
"key": "qwer_heart_default896",
|
||||
"img": "qwer",
|
||||
"style": "heart",
|
||||
"style_idx": 3,
|
||||
"side": "default896",
|
||||
"side_val": null,
|
||||
"ok": true,
|
||||
"elapsed": 11.1,
|
||||
"err": "",
|
||||
"out_w": 1288,
|
||||
"out_h": 1678,
|
||||
"bytes": 331337
|
||||
},
|
||||
{
|
||||
"key": "qwer_heart_origin0",
|
||||
"img": "qwer",
|
||||
"style": "heart",
|
||||
"style_idx": 3,
|
||||
"side": "origin0",
|
||||
"side_val": 0,
|
||||
"ok": true,
|
||||
"elapsed": 25.0,
|
||||
"err": "",
|
||||
"out_w": 1280,
|
||||
"out_h": 1680,
|
||||
"bytes": 355982
|
||||
},
|
||||
{
|
||||
"key": "qwer_straight_default896",
|
||||
"img": "qwer",
|
||||
"style": "straight",
|
||||
"style_idx": 4,
|
||||
"side": "default896",
|
||||
"side_val": null,
|
||||
"ok": true,
|
||||
"elapsed": 10.7,
|
||||
"err": "",
|
||||
"out_w": 1288,
|
||||
"out_h": 1678,
|
||||
"bytes": 281665
|
||||
},
|
||||
{
|
||||
"key": "qwer_straight_origin0",
|
||||
"img": "qwer",
|
||||
"style": "straight",
|
||||
"style_idx": 4,
|
||||
"side": "origin0",
|
||||
"side_val": 0,
|
||||
"ok": true,
|
||||
"elapsed": 23.4,
|
||||
"err": "",
|
||||
"out_w": 1280,
|
||||
"out_h": 1680,
|
||||
"bytes": 322183
|
||||
},
|
||||
{
|
||||
"key": "qwer_wave_default896",
|
||||
"img": "qwer",
|
||||
"style": "wave",
|
||||
"style_idx": 5,
|
||||
"side": "default896",
|
||||
"side_val": null,
|
||||
"ok": true,
|
||||
"elapsed": 11.1,
|
||||
"err": "",
|
||||
"out_w": 1288,
|
||||
"out_h": 1678,
|
||||
"bytes": 289322
|
||||
},
|
||||
{
|
||||
"key": "qwer_wave_origin0",
|
||||
"img": "qwer",
|
||||
"style": "wave",
|
||||
"style_idx": 5,
|
||||
"side": "origin0",
|
||||
"side_val": 0,
|
||||
"ok": true,
|
||||
"elapsed": 25.8,
|
||||
"err": "",
|
||||
"out_w": 1280,
|
||||
"out_h": 1680,
|
||||
"bytes": 351719
|
||||
},
|
||||
{
|
||||
"key": "asdf_ellipse_default896",
|
||||
"img": "asdf",
|
||||
"style": "ellipse",
|
||||
"style_idx": 1,
|
||||
"side": "default896",
|
||||
"side_val": null,
|
||||
"ok": true,
|
||||
"elapsed": 11.2,
|
||||
"err": "",
|
||||
"out_w": 1254,
|
||||
"out_h": 1666,
|
||||
"bytes": 285362
|
||||
},
|
||||
{
|
||||
"key": "asdf_ellipse_origin0",
|
||||
"img": "asdf",
|
||||
"style": "ellipse",
|
||||
"style_idx": 1,
|
||||
"side": "origin0",
|
||||
"side_val": 0,
|
||||
"ok": true,
|
||||
"elapsed": 25.7,
|
||||
"err": "",
|
||||
"out_w": 1248,
|
||||
"out_h": 1664,
|
||||
"bytes": 286768
|
||||
},
|
||||
{
|
||||
"key": "asdf_flower_default896",
|
||||
"img": "asdf",
|
||||
"style": "flower",
|
||||
"style_idx": 2,
|
||||
"side": "default896",
|
||||
"side_val": null,
|
||||
"ok": true,
|
||||
"elapsed": 10.7,
|
||||
"err": "",
|
||||
"out_w": 1254,
|
||||
"out_h": 1666,
|
||||
"bytes": 266946
|
||||
},
|
||||
{
|
||||
"key": "asdf_flower_origin0",
|
||||
"img": "asdf",
|
||||
"style": "flower",
|
||||
"style_idx": 2,
|
||||
"side": "origin0",
|
||||
"side_val": 0,
|
||||
"ok": true,
|
||||
"elapsed": 22.9,
|
||||
"err": "",
|
||||
"out_w": 1248,
|
||||
"out_h": 1664,
|
||||
"bytes": 353668
|
||||
},
|
||||
{
|
||||
"key": "asdf_heart_default896",
|
||||
"img": "asdf",
|
||||
"style": "heart",
|
||||
"style_idx": 3,
|
||||
"side": "default896",
|
||||
"side_val": null,
|
||||
"ok": true,
|
||||
"elapsed": 10.4,
|
||||
"err": "",
|
||||
"out_w": 1254,
|
||||
"out_h": 1666,
|
||||
"bytes": 271975
|
||||
},
|
||||
{
|
||||
"key": "asdf_heart_origin0",
|
||||
"img": "asdf",
|
||||
"style": "heart",
|
||||
"style_idx": 3,
|
||||
"side": "origin0",
|
||||
"side_val": 0,
|
||||
"ok": true,
|
||||
"elapsed": 22.9,
|
||||
"err": "",
|
||||
"out_w": 1248,
|
||||
"out_h": 1664,
|
||||
"bytes": 291983
|
||||
},
|
||||
{
|
||||
"key": "asdf_straight_default896",
|
||||
"img": "asdf",
|
||||
"style": "straight",
|
||||
"style_idx": 4,
|
||||
"side": "default896",
|
||||
"side_val": null,
|
||||
"ok": true,
|
||||
"elapsed": 9.8,
|
||||
"err": "",
|
||||
"out_w": 1254,
|
||||
"out_h": 1666,
|
||||
"bytes": 258665
|
||||
},
|
||||
{
|
||||
"key": "asdf_straight_origin0",
|
||||
"img": "asdf",
|
||||
"style": "straight",
|
||||
"style_idx": 4,
|
||||
"side": "origin0",
|
||||
"side_val": 0,
|
||||
"ok": true,
|
||||
"elapsed": 24.0,
|
||||
"err": "",
|
||||
"out_w": 1248,
|
||||
"out_h": 1664,
|
||||
"bytes": 279883
|
||||
},
|
||||
{
|
||||
"key": "asdf_wave_default896",
|
||||
"img": "asdf",
|
||||
"style": "wave",
|
||||
"style_idx": 5,
|
||||
"side": "default896",
|
||||
"side_val": null,
|
||||
"ok": true,
|
||||
"elapsed": 11.9,
|
||||
"err": "",
|
||||
"out_w": 1254,
|
||||
"out_h": 1666,
|
||||
"bytes": 286515
|
||||
},
|
||||
{
|
||||
"key": "asdf_wave_origin0",
|
||||
"img": "asdf",
|
||||
"style": "wave",
|
||||
"style_idx": 5,
|
||||
"side": "origin0",
|
||||
"side_val": 0,
|
||||
"ok": true,
|
||||
"elapsed": 24.3,
|
||||
"err": "",
|
||||
"out_w": 1248,
|
||||
"out_h": 1664,
|
||||
"bytes": 295694
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env python3
|
||||
"""接口2 female 批量对比测试:4图 × 5发型 × 3分辨率档位 = 60次请求。
|
||||
串行执行,记录耗时与成败,结果图按 图_发型_档位 命名保存。
|
||||
用法: python3 batch_test.py
|
||||
支持断点续跑(progress.json);新增档位时只会补跑未完成项。
|
||||
"""
|
||||
import base64, csv, json, os, sys, time, traceback
|
||||
import requests
|
||||
|
||||
API = "http://127.0.0.1:8187/api/v1/hair/grow"
|
||||
TOKEN = "dev-shared-secret-2026"
|
||||
TIMEOUT = 600
|
||||
OUT = "/home/ubuntu/hair/image/compare_test/out"
|
||||
PROGRESS = "/home/ubuntu/hair/image/compare_test/progress.json"
|
||||
|
||||
IMG_DIR = "/home/ubuntu/hair/image"
|
||||
IMAGES = [
|
||||
("girl2", f"{IMG_DIR}/girl_img/girl2.jpg"),
|
||||
("girl5", f"{IMG_DIR}/girl_img/girl5.jpg"),
|
||||
("qwer", f"{IMG_DIR}/qwer.jpg"),
|
||||
("asdf", f"{IMG_DIR}/asdf.jpg"),
|
||||
]
|
||||
# female: 1=ellipse 2=flower 3=heart 4=straight 5=wave
|
||||
STYLES = [
|
||||
(1, "ellipse"), (2, "flower"), (3, "heart"), (4, "straight"), (5, "wave"),
|
||||
]
|
||||
# 三档: 默认896 / 显式1024 / 原图直送0
|
||||
SIDES = [
|
||||
("default896", None), # 不传 → 后端默认896
|
||||
("side1024", 1024), # 长边压到 1024
|
||||
("origin0", 0), # 原图直送
|
||||
]
|
||||
SIDE_LABEL = {
|
||||
"default896": "默认896",
|
||||
"side1024": "1024",
|
||||
"origin0": "原图0",
|
||||
}
|
||||
|
||||
def load_progress():
|
||||
if os.path.exists(PROGRESS):
|
||||
try:
|
||||
return json.load(open(PROGRESS))
|
||||
except Exception:
|
||||
pass
|
||||
return {"done": [], "results": []}
|
||||
|
||||
def save_progress(prog):
|
||||
json.dump(prog, open(PROGRESS, "w"), ensure_ascii=False, indent=1)
|
||||
|
||||
def run_one(img_name, img_path, style_idx, style_key, side_name, side_val):
|
||||
"""跑单次请求,返回 dict 结果。"""
|
||||
key = f"{img_name}_{style_key}_{side_name}"
|
||||
with open(img_path, "rb") as f:
|
||||
img_b64 = base64.b64encode(f.read()).decode()
|
||||
data = {
|
||||
"image_base64": "data:image/jpeg;base64," + img_b64,
|
||||
"gender": "female",
|
||||
"hair_style": str(style_idx),
|
||||
}
|
||||
if side_val is not None:
|
||||
data["redraw_max_side"] = str(side_val)
|
||||
t0 = time.time()
|
||||
rec = {"key": key, "img": img_name, "style": style_key, "style_idx": style_idx,
|
||||
"side": side_name, "side_val": side_val, "ok": False,
|
||||
"elapsed": 0.0, "err": "", "out_w": 0, "out_h": 0}
|
||||
try:
|
||||
r = requests.post(API, data=data, headers={"X-Internal-Token": TOKEN}, timeout=TIMEOUT)
|
||||
rec["elapsed"] = round(time.time() - t0, 1)
|
||||
d = r.json()
|
||||
if d.get("code") != 0:
|
||||
rec["err"] = f"code={d.get('code')} {d.get('message','')}"[:200]
|
||||
return rec
|
||||
results = (d.get("data") or {}).get("results") or []
|
||||
if not results:
|
||||
rec["err"] = "空结果"
|
||||
return rec
|
||||
it = results[0]
|
||||
grown = it.get("grown_image_base64")
|
||||
if not grown:
|
||||
rec["err"] = "无生发图(grown_png=None, 重绘失败)"
|
||||
return rec
|
||||
raw = base64.b64decode(grown)
|
||||
from PIL import Image
|
||||
import io as _io
|
||||
im = Image.open(_io.BytesIO(raw))
|
||||
rec["out_w"], rec["out_h"] = im.size
|
||||
out_path = f"{OUT}/{key}.jpg"
|
||||
with open(out_path, "wb") as fo:
|
||||
fo.write(raw)
|
||||
rec["ok"] = True
|
||||
rec["bytes"] = len(raw)
|
||||
except requests.exceptions.Timeout:
|
||||
rec["elapsed"] = round(time.time() - t0, 1)
|
||||
rec["err"] = f"超时(>{TIMEOUT}s)"
|
||||
except Exception as e:
|
||||
rec["elapsed"] = round(time.time() - t0, 1)
|
||||
rec["err"] = f"{type(e).__name__}: {str(e)[:180]}"
|
||||
return rec
|
||||
|
||||
def main():
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
prog = load_progress()
|
||||
done_keys = set(prog["done"])
|
||||
total = len(IMAGES) * len(STYLES) * len(SIDES)
|
||||
print(f"=== 接口2 female 批量对比测试 ===")
|
||||
print(f"矩阵: {len(IMAGES)}图 × {len(STYLES)}发型 × {len(SIDES)}档 = {total} 次")
|
||||
print(f"已跳过 {len(done_keys)} 个已完成项\n")
|
||||
|
||||
idx = 0
|
||||
for img_name, img_path in IMAGES:
|
||||
for style_idx, style_key in STYLES:
|
||||
for side_name, side_val in SIDES:
|
||||
idx += 1
|
||||
key = f"{img_name}_{style_key}_{side_name}"
|
||||
if key in done_keys:
|
||||
print(f"[{idx}/{total}] ⏭ 跳过已完成 {key}")
|
||||
continue
|
||||
print(f"[{idx}/{total}] ▶ {key} (side={side_val})...", end=" ", flush=True)
|
||||
rec = run_one(img_name, img_path, style_idx, style_key, side_name, side_val)
|
||||
prog["results"].append(rec)
|
||||
prog["done"].append(key)
|
||||
save_progress(prog)
|
||||
if rec["ok"]:
|
||||
print(f"✅ {rec['elapsed']}s 出图 {rec['out_w']}x{rec['out_h']} ({rec['bytes']}B)")
|
||||
else:
|
||||
print(f"❌ {rec['elapsed']}s {rec['err']}")
|
||||
time.sleep(2) # 串行间隔,避免队列堆积
|
||||
|
||||
# 汇总
|
||||
print("\n" + "="*70)
|
||||
print("汇总报告")
|
||||
print("="*70)
|
||||
write_report(prog["results"])
|
||||
print(f"\n结果图: {OUT}/")
|
||||
print(f"CSV报告: {OUT}/report.csv")
|
||||
print(f"JSON明细: {OUT}/report.json")
|
||||
|
||||
def write_report(results):
|
||||
# CSV
|
||||
csv_path = f"{OUT}/report.csv"
|
||||
with open(csv_path, "w", newline="") as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(["img","style","side","ok","elapsed_s","out_w","out_h","err"])
|
||||
for r in results:
|
||||
w.writerow([r["img"],r["style"],r["side"],r["ok"],r["elapsed"],
|
||||
r["out_w"],r["out_h"],r["err"]])
|
||||
# JSON
|
||||
json.dump(results, open(f"{OUT}/report.json","w"), ensure_ascii=False, indent=1)
|
||||
# 控制台速度对比表
|
||||
print("\n--- 速度对比(秒,✓=成功 ✗=失败)---")
|
||||
headers = ["图", "发型"] + [SIDE_LABEL[s] for s, _ in SIDES]
|
||||
print(f"{headers[0]:<8}{headers[1]:<10}" + "".join(f"{h:>12}" for h in headers[2:]))
|
||||
for img_name, _ in IMAGES:
|
||||
for _, style_key in STYLES:
|
||||
cells = []
|
||||
for side_name, _ in SIDES:
|
||||
r = next((x for x in results if x["img"]==img_name and x["style"]==style_key and x["side"]==side_name), None)
|
||||
cells.append(f"{r['elapsed']}{'✓' if r['ok'] else '✗'}" if r else "-")
|
||||
print(f"{img_name:<8}{style_key:<10}" + "".join(f"{c:>12}" for c in cells))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env python3
|
||||
"""把 report.json + 结果图生成为自包含 HTML 报告(支持 896 / 1024 / 原图0 三档)。"""
|
||||
import json, os, html
|
||||
|
||||
OUT = "/home/ubuntu/hair/image/compare_test/out"
|
||||
REPORT_HTML = os.path.join(OUT, "report.html")
|
||||
results = json.load(open(os.path.join(OUT, "report.json")))
|
||||
|
||||
IMAGES = ["girl2", "girl5", "qwer", "asdf"]
|
||||
STYLES = ["ellipse", "flower", "heart", "straight", "wave"]
|
||||
SIDES = [
|
||||
("default896", "默认 896", "#2563eb", "d"),
|
||||
("side1024", "1024", "#10b981", "m"),
|
||||
("origin0", "原图直送 0", "#f59e0b", "o"),
|
||||
]
|
||||
IMG_LONGSIDE = {"girl2": 1082, "girl5": 767, "qwer": 1678, "asdf": 1666}
|
||||
STYLE_CN = {"ellipse": "椭圆", "flower": "花瓣", "heart": "心形", "straight": "直线", "wave": "波浪"}
|
||||
|
||||
def get(img, style, side):
|
||||
for r in results:
|
||||
if r["img"] == img and r["style"] == style and r["side"] == side:
|
||||
return r
|
||||
return None
|
||||
|
||||
# 总览统计(>60s 视为冷启动/异常,不进均值)
|
||||
total = len(results)
|
||||
ok = sum(1 for r in results if r["ok"])
|
||||
COLD_S = 60.0
|
||||
|
||||
def avg(seq):
|
||||
seq = [x for x in seq if x is not None]
|
||||
return sum(seq) / len(seq) if seq else 0
|
||||
|
||||
def times(side, img=None):
|
||||
out = []
|
||||
for r in results:
|
||||
if not r["ok"] or r["side"] != side:
|
||||
continue
|
||||
if img is not None and r["img"] != img:
|
||||
continue
|
||||
if r["elapsed"] >= COLD_S:
|
||||
continue
|
||||
out.append(r["elapsed"])
|
||||
return out
|
||||
|
||||
cold_n = sum(1 for r in results if r["ok"] and r["elapsed"] >= COLD_S)
|
||||
side_avgs = {sid: avg(times(sid)) for sid, *_ in SIDES}
|
||||
all_times = [t for sid, *_ in SIDES for t in times(sid)]
|
||||
max_bar = max(all_times + [1])
|
||||
|
||||
# 构造速度对比图数据:每图 × 三档
|
||||
chart_rows = []
|
||||
for img in IMAGES:
|
||||
chart_rows.append((img, IMG_LONGSIDE[img], [avg(times(sid, img)) for sid, *_ in SIDES]))
|
||||
|
||||
def bar_svg():
|
||||
bar_h = 22
|
||||
gap = 12
|
||||
label_w = 78
|
||||
chart_w = 760
|
||||
n_bars = len(SIDES)
|
||||
rows = len(chart_rows)
|
||||
h = rows * (bar_h * n_bars + gap) + 40
|
||||
parts = [f'<svg viewBox="0 0 {label_w + chart_w + 80} {h}" class="chart">']
|
||||
y = 10
|
||||
for img, longside, avgs in chart_rows:
|
||||
base = avgs[0] if avgs and avgs[0] else 1
|
||||
for i, ((sid, label, color, _), a) in enumerate(zip(SIDES, avgs)):
|
||||
yi = y + i * bar_h
|
||||
w = int(a / max_bar * chart_w) if a else 0
|
||||
warn = "⚠" if (i > 0 and a > base * 1.8) else ""
|
||||
parts.append(f'<rect x="{label_w}" y="{yi}" width="{w}" height="{bar_h-4}" rx="3" fill="{color}"/>')
|
||||
parts.append(f'<text x="{label_w + w + 6}" y="{yi + bar_h - 10}" class="barlabel">{a:.1f}s {warn}</text>')
|
||||
parts.append(f'<text x="{label_w-8}" y="{yi + bar_h - 10}" class="rowlabel" text-anchor="end">{html.escape(label)}</text>')
|
||||
parts.append(f'<text x="0" y="{y + bar_h - 2}" class="imglabel">{img}<tspan class="imgside">长边{longside}</tspan></text>')
|
||||
y += bar_h * n_bars + gap
|
||||
parts.append("</svg>")
|
||||
return "".join(parts)
|
||||
|
||||
def img_cell(r, cls):
|
||||
if not r:
|
||||
return f'<td class="{cls} fail">缺失</td>'
|
||||
fname = r["key"] + ".jpg"
|
||||
status = "✅" if r["ok"] else "❌"
|
||||
t = f'{r["elapsed"]}s'
|
||||
dim = f'{r["out_w"]}×{r["out_h"]}'
|
||||
err = f'<div class="err">{html.escape(r["err"])}</div>' if r["err"] else ""
|
||||
img_tag = (f'<img loading="lazy" src="{fname}" onclick="openImg(this.src)" alt="{html.escape(r["key"])}">'
|
||||
if r["ok"] else '<div class="noimg">无图</div>')
|
||||
return (f'<td class="{cls}"><div class="thumb">{img_tag}</div>'
|
||||
f'<div class="meta">{status} {t} · {dim}</div>{err}</td>')
|
||||
|
||||
def compare_cards():
|
||||
out = []
|
||||
for img in IMAGES:
|
||||
out.append(f'<div class="card"><div class="card-h">📷 {html.escape(img)} <span class="tag">原图长边 {IMG_LONGSIDE[img]}px</span></div><div class="card-b">')
|
||||
heads = "".join(f'<th class="{cls}">{html.escape(label)}</th>' for _, label, _, cls in SIDES)
|
||||
out.append(f'<table class="cmp"><thead><tr><th>发型</th>{heads}</tr></thead><tbody>')
|
||||
for style in STYLES:
|
||||
sc = STYLE_CN[style]
|
||||
cells = "".join(img_cell(get(img, style, sid), cls) for sid, _, _, cls in SIDES)
|
||||
out.append(f'<tr><td class="sname">{html.escape(style)}<span>{sc}</span></td>{cells}</tr>')
|
||||
out.append("</tbody></table></div></div>")
|
||||
return "".join(out)
|
||||
|
||||
# 结论
|
||||
small = [img for img in IMAGES if IMG_LONGSIDE[img] <= 1100]
|
||||
large = [img for img in IMAGES if IMG_LONGSIDE[img] > 1100]
|
||||
def ratio_range(num_side, den_side="default896"):
|
||||
ratios = []
|
||||
for img in large:
|
||||
den = avg(times(den_side, img))
|
||||
num = avg(times(num_side, img))
|
||||
if den:
|
||||
ratios.append(num / den)
|
||||
if not ratios:
|
||||
return 0, 0
|
||||
return min(ratios), max(ratios)
|
||||
|
||||
r1024_lo, r1024_hi = ratio_range("side1024")
|
||||
r0_lo, r0_hi = ratio_range("origin0")
|
||||
cold_note = f"冷启动 {cold_n} 次(≥{COLD_S:.0f}s)已从均值剔除。" if cold_n else ""
|
||||
conclusion = (
|
||||
f"原图长边 ≤ 1100({'/'.join(small)})时三档耗时接近;"
|
||||
f"长边 > 1600({'/'.join(large)})时相对默认896:"
|
||||
f"<b>1024 约 {r1024_lo:.1f}~{r1024_hi:.1f}×</b>,"
|
||||
f"<b>原图直送约 {r0_lo:.1f}~{r0_hi:.1f}×</b>。"
|
||||
f"{cold_note}画质对比见下方三列并排,点击可放大。"
|
||||
)
|
||||
|
||||
avg896 = side_avgs.get("default896", 0)
|
||||
avg1024 = side_avgs.get("side1024", 0)
|
||||
avg0 = side_avgs.get("origin0", 0)
|
||||
ratio1024 = avg1024 / avg896 if avg896 else 0
|
||||
ratio0 = avg0 / avg896 if avg896 else 0
|
||||
|
||||
legend = "".join(
|
||||
f'<span><i style="background:{color}"></i>{html.escape(label)}</span>'
|
||||
for _, label, color, _ in SIDES
|
||||
)
|
||||
|
||||
html_doc = f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>接口2 分辨率对比测试报告</title>
|
||||
<style>
|
||||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif; background: #f3f4f6; color: #1f2937; line-height: 1.5; padding: 20px; }}
|
||||
.wrap {{ max-width: 1400px; margin: 0 auto; }}
|
||||
h1 {{ font-size: 24px; margin-bottom: 4px; }}
|
||||
.sub {{ color: #6b7280; font-size: 13px; margin-bottom: 20px; }}
|
||||
.summary {{ display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-bottom: 24px; }}
|
||||
.stat {{ background: #fff; border-radius: 10px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,.06); }}
|
||||
.stat .num {{ font-size: 26px; font-weight: 700; }}
|
||||
.stat .lbl {{ font-size: 12px; color: #6b7280; margin-top: 2px; }}
|
||||
.stat.d .num {{ color: #2563eb; }}
|
||||
.stat.m .num {{ color: #10b981; }}
|
||||
.stat.o .num {{ color: #f59e0b; }}
|
||||
.card {{ background: #fff; border-radius: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.07); margin-bottom: 20px; overflow: hidden; }}
|
||||
.card-h {{ padding: 12px 18px; background: #fafafa; border-bottom: 1px solid #f0f0f0; font-weight: 700; font-size: 15px; display: flex; align-items: center; gap: 8px; }}
|
||||
.card-h .tag {{ font-size: 11px; font-weight: 500; color: #6b7280; background: #f3f4f6; padding: 2px 8px; border-radius: 10px; }}
|
||||
.card-b {{ padding: 16px; }}
|
||||
.chart {{ width: 100%; height: auto; max-width: 920px; }}
|
||||
.barlabel {{ font-size: 12px; fill: #374151; font-weight: 600; }}
|
||||
.rowlabel {{ font-size: 11px; fill: #6b7280; }}
|
||||
.imglabel {{ font-size: 14px; fill: #111; font-weight: 700; }}
|
||||
.imgside {{ font-size: 10px; fill: #9ca3af; font-weight: 400; }}
|
||||
table.cmp {{ width: 100%; border-collapse: collapse; table-layout: fixed; }}
|
||||
table.cmp th {{ font-size: 12px; color: #6b7280; font-weight: 600; padding: 8px; text-align: center; border-bottom: 2px solid #f0f0f0; }}
|
||||
table.cmp td {{ padding: 8px; border-bottom: 1px solid #f6f6f6; vertical-align: top; text-align: center; }}
|
||||
table.cmp th.d, table.cmp td.d {{ background: #eff6ff; }}
|
||||
table.cmp th.m, table.cmp td.m {{ background: #ecfdf5; }}
|
||||
table.cmp th.o, table.cmp td.o {{ background: #fffbeb; }}
|
||||
td.sname {{ font-weight: 600; text-align: left; width: 90px; }}
|
||||
td.sname span {{ display: block; font-size: 11px; color: #9ca3af; font-weight: 400; }}
|
||||
.thumb {{ background: #222; border-radius: 6px; overflow: hidden; margin-bottom: 4px; cursor: zoom-in; }}
|
||||
.thumb img {{ width: 100%; height: 200px; object-fit: contain; display: block; }}
|
||||
.noimg {{ color: #d1d5db; font-size: 12px; padding: 40px 0; }}
|
||||
.meta {{ font-size: 11px; color: #6b7280; }}
|
||||
.err {{ font-size: 10px; color: #dc2626; margin-top: 2px; }}
|
||||
.note {{ background: #fef3c7; border-left: 3px solid #f59e0b; padding: 12px 16px; border-radius: 6px; font-size: 13px; margin-bottom: 20px; }}
|
||||
.legend {{ display: flex; gap: 20px; font-size: 12px; color: #6b7280; margin-bottom: 12px; flex-wrap: wrap; }}
|
||||
.legend span {{ display: inline-flex; align-items: center; gap: 5px; }}
|
||||
.legend i {{ width: 12px; height: 12px; border-radius: 2px; display: inline-block; }}
|
||||
.overlay {{ display: none; position: fixed; inset: 0; background: rgba(0,0,0,.9); z-index: 999; justify-content: center; align-items: center; cursor: zoom-out; padding: 30px; }}
|
||||
.overlay.active {{ display: flex; }}
|
||||
.overlay img {{ max-width: 95%; max-height: 95%; object-fit: contain; border-radius: 4px; }}
|
||||
@media (max-width: 900px) {{
|
||||
.summary {{ grid-template-columns: repeat(2, 1fr); }}
|
||||
.thumb img {{ height: 160px; }}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>接口2 生发 · 分辨率对比测试报告</h1>
|
||||
<p class="sub">POST /api/v1/hair/grow · female · 4 张图 × 5 发型 × 3 档分辨率 = {total} 次 · 串行</p>
|
||||
|
||||
<div class="summary">
|
||||
<div class="stat"><div class="num">{ok}/{total}</div><div class="lbl">成功 / 总数</div></div>
|
||||
<div class="stat d"><div class="num">{avg896:.1f}s</div><div class="lbl">默认896 平均</div></div>
|
||||
<div class="stat m"><div class="num">{avg1024:.1f}s</div><div class="lbl">1024 平均 · ×{ratio1024:.2f}</div></div>
|
||||
<div class="stat o"><div class="num">{avg0:.1f}s</div><div class="lbl">原图直送 平均 · ×{ratio0:.2f}</div></div>
|
||||
<div class="stat"><div class="num" style="color:{'#dc2626' if ratio0>1.3 else '#16a34a'}">×{ratio0:.2f}</div><div class="lbl">原图/默认 倍率</div></div>
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
<b>结论:</b>{conclusion}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-h">平均耗时对比(按图分组,单位:秒)</div>
|
||||
<div class="card-b">
|
||||
<div class="legend">{legend}</div>
|
||||
{bar_svg()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 style="font-size:18px;margin:8px 0 14px">画质对比(左→右:默认896 · 1024 · 原图直送)</h2>
|
||||
{compare_cards()}
|
||||
|
||||
</div>
|
||||
<div class="overlay" id="overlay" onclick="this.classList.remove('active')">
|
||||
<img id="overlayImg" src="">
|
||||
</div>
|
||||
<script>
|
||||
function openImg(src) {{
|
||||
document.getElementById('overlayImg').src = src;
|
||||
document.getElementById('overlay').classList.add('active');
|
||||
}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
with open(REPORT_HTML, "w") as f:
|
||||
f.write(html_doc)
|
||||
# 同步首页
|
||||
with open(os.path.join(OUT, "index.html"), "w") as f:
|
||||
f.write(html_doc)
|
||||
print(f"已生成: {REPORT_HTML}")
|
||||
print(f"图片目录: {OUT}")
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=接口2 分辨率对比测试报告 HTTP 服务 (8848)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=ubuntu
|
||||
WorkingDirectory=/home/ubuntu/hair/image/compare_test/out
|
||||
ExecStart=/home/ubuntu/miniconda3/envs/my_hair/bin/python -m http.server 8848 --bind 0.0.0.0
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 459 KiB |
@@ -0,0 +1,47 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>测试报告索引</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif; background: #f3f4f6; color: #1f2937; padding: 40px 20px; }
|
||||
.wrap { max-width: 720px; margin: 0 auto; }
|
||||
h1 { font-size: 24px; margin-bottom: 4px; }
|
||||
.sub { color: #6b7280; font-size: 13px; margin-bottom: 28px; }
|
||||
.card { display: block; background: #fff; border-radius: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.07); padding: 20px 24px; margin-bottom: 14px; text-decoration: none; color: inherit; transition: .15s; border-left: 4px solid #2563eb; }
|
||||
.card:hover { transform: translateX(4px); box-shadow: 0 4px 12px rgba(0,0,0,.1); }
|
||||
.card .title { font-size: 16px; font-weight: 700; margin-bottom: 4px; }
|
||||
.card .desc { font-size: 13px; color: #6b7280; }
|
||||
.card .url { font-size: 11px; color: #9ca3af; margin-top: 6px; font-family: monospace; }
|
||||
.card.wave { border-left-color: #16a34a; }
|
||||
.card.v2 { border-left-color: #f59e0b; }
|
||||
.card.v1 { border-left-color: #2563eb; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>📊 测试报告索引</h1>
|
||||
<p class="sub">接口2 / 接口5 分辨率对比测试报告合集</p>
|
||||
|
||||
<a class="card wave" href="wave/">
|
||||
<div class="title">💇 wave发型 · 5档分辨率对比(最新)</div>
|
||||
<div class="desc">21图 × 5档(原图/1024/896/768/640) = 105次 · wave发型 · female</div>
|
||||
<div class="url">/wave/</div>
|
||||
</a>
|
||||
|
||||
<a class="card v2" href="v2/">
|
||||
<div class="title">💄 5图 · 5档分辨率对比 v2</div>
|
||||
<div class="desc">5图 × 3发型(花瓣/心形/波浪) × 5档 = 75次 · female</div>
|
||||
<div class="url">/v2/</div>
|
||||
</a>
|
||||
|
||||
<a class="card v1" href="v1/">
|
||||
<div class="title">💄 4图 · 2档分辨率对比 v1</div>
|
||||
<div class="desc">4图 × 5发型 × 2档(默认896/原图) = 40次 · female</div>
|
||||
<div class="url">/v1/</div>
|
||||
</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=测试报告统一HTTP服务 (8850, 路径区分: /wave /v2 /v1)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=ubuntu
|
||||
WorkingDirectory=/home/ubuntu/hair/image/reports
|
||||
ExecStart=/home/ubuntu/miniconda3/envs/my_hair/bin/python -m http.server 8850 --bind 0.0.0.0
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1 @@
|
||||
/home/ubuntu/hair/image/compare_test/out
|
||||
@@ -0,0 +1 @@
|
||||
/home/ubuntu/hair/image/res_test/out
|
||||
@@ -0,0 +1 @@
|
||||
/home/ubuntu/hair/image/wave_test/out
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env python3
|
||||
"""接口2 female 分辨率对比测试 v2: 5图 × 3发型 × 5档 = 75次。
|
||||
串行执行,记录耗时与成败,结果图按 图_发型_档位 命名保存。
|
||||
支持断点续跑(progress.json)。
|
||||
"""
|
||||
import base64, csv, json, os, sys, time, io
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
API = "http://127.0.0.1:8187/api/v1/hair/grow"
|
||||
TOKEN = "dev-shared-secret-2026"
|
||||
TIMEOUT = 600
|
||||
OUT = "/home/ubuntu/hair/image/res_test/out"
|
||||
PROGRESS = "/home/ubuntu/hair/image/res_test/progress.json"
|
||||
|
||||
IMG_DIR = "/home/ubuntu/hair/image"
|
||||
IMAGES = [
|
||||
("girl2", f"{IMG_DIR}/girl_img/girl2.jpg"),
|
||||
("girl5", f"{IMG_DIR}/girl_img/girl5.jpg"),
|
||||
("qwer", f"{IMG_DIR}/qwer.jpg"),
|
||||
("asdf", f"{IMG_DIR}/asdf.jpg"),
|
||||
("girl7", f"{IMG_DIR}/girl_img/girl7.jpg"),
|
||||
]
|
||||
# female: 2=flower(花瓣) 3=heart(心形) 5=wave(波浪)
|
||||
STYLES = [(2, "flower"), (3, "heart"), (5, "wave")]
|
||||
# 5档: 原图(0) / 1024 / 896 / 768 / 640
|
||||
SIDES = [
|
||||
("origin", 0),
|
||||
("s1024", 1024),
|
||||
("s896", 896),
|
||||
("s768", 768),
|
||||
("s640", 640),
|
||||
]
|
||||
|
||||
def load_progress():
|
||||
if os.path.exists(PROGRESS):
|
||||
try:
|
||||
return json.load(open(PROGRESS))
|
||||
except Exception:
|
||||
pass
|
||||
return {"done": [], "results": []}
|
||||
|
||||
def save_progress(prog):
|
||||
json.dump(prog, open(PROGRESS, "w"), ensure_ascii=False, indent=1)
|
||||
|
||||
def run_one(img_name, img_path, style_idx, style_key, side_name, side_val):
|
||||
key = f"{img_name}_{style_key}_{side_name}"
|
||||
with open(img_path, "rb") as f:
|
||||
img_b64 = base64.b64encode(f.read()).decode()
|
||||
data = {
|
||||
"image_base64": "data:image/jpeg;base64," + img_b64,
|
||||
"gender": "female",
|
||||
"hair_style": str(style_idx),
|
||||
"redraw_max_side": str(side_val), # 含原图档=0
|
||||
}
|
||||
t0 = time.time()
|
||||
rec = {"key": key, "img": img_name, "style": style_key, "style_idx": style_idx,
|
||||
"side": side_name, "side_val": side_val, "ok": False,
|
||||
"elapsed": 0.0, "err": "", "out_w": 0, "out_h": 0, "bytes": 0}
|
||||
try:
|
||||
r = requests.post(API, data=data, headers={"X-Internal-Token": TOKEN}, timeout=TIMEOUT)
|
||||
rec["elapsed"] = round(time.time() - t0, 1)
|
||||
d = r.json()
|
||||
if d.get("code") != 0:
|
||||
rec["err"] = f"code={d.get('code')} {d.get('message','')}"[:200]
|
||||
return rec
|
||||
results = (d.get("data") or {}).get("results") or []
|
||||
if not results:
|
||||
rec["err"] = "空结果"
|
||||
return rec
|
||||
it = results[0]
|
||||
grown = it.get("grown_image_base64")
|
||||
if not grown:
|
||||
rec["err"] = "无生发图(重绘失败/OOM?)"
|
||||
return rec
|
||||
raw = base64.b64decode(grown)
|
||||
im = Image.open(io.BytesIO(raw))
|
||||
rec["out_w"], rec["out_h"] = im.size
|
||||
out_path = f"{OUT}/{key}.jpg"
|
||||
with open(out_path, "wb") as fo:
|
||||
fo.write(raw)
|
||||
rec["ok"] = True
|
||||
rec["bytes"] = len(raw)
|
||||
except requests.exceptions.Timeout:
|
||||
rec["elapsed"] = round(time.time() - t0, 1)
|
||||
rec["err"] = f"超时(>{TIMEOUT}s)"
|
||||
except Exception as e:
|
||||
rec["elapsed"] = round(time.time() - t0, 1)
|
||||
rec["err"] = f"{type(e).__name__}: {str(e)[:180]}"
|
||||
return rec
|
||||
|
||||
def main():
|
||||
prog = load_progress()
|
||||
done_keys = set(prog["done"])
|
||||
total = len(IMAGES) * len(STYLES) * len(SIDES)
|
||||
print(f"=== 接口2 female 分辨率对比测试 v2 ===")
|
||||
print(f"矩阵: {len(IMAGES)}图 × {len(STYLES)}发型 × {len(SIDES)}档 = {total} 次")
|
||||
print(f"已跳过 {len(done_keys)} 个已完成项\n")
|
||||
|
||||
idx = 0
|
||||
for img_name, img_path in IMAGES:
|
||||
for style_idx, style_key in STYLES:
|
||||
for side_name, side_val in SIDES:
|
||||
idx += 1
|
||||
key = f"{img_name}_{style_key}_{side_name}"
|
||||
if key in done_keys:
|
||||
print(f"[{idx}/{total}] ⏭ 跳过 {key}")
|
||||
continue
|
||||
print(f"[{idx}/{total}] ▶ {key} (side={side_val})...", end=" ", flush=True)
|
||||
rec = run_one(img_name, img_path, style_idx, style_key, side_name, side_val)
|
||||
prog["results"].append(rec)
|
||||
prog["done"].append(key)
|
||||
save_progress(prog)
|
||||
if rec["ok"]:
|
||||
print(f"✅ {rec['elapsed']}s {rec['out_w']}x{rec['out_h']} ({rec['bytes']}B)")
|
||||
else:
|
||||
print(f"❌ {rec['elapsed']}s {rec['err']}")
|
||||
time.sleep(2)
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("汇总")
|
||||
print("=" * 70)
|
||||
write_report(prog["results"])
|
||||
print(f"\n结果图: {OUT}/")
|
||||
print(f"CSV: {OUT}/report.csv JSON: {OUT}/report.json")
|
||||
|
||||
def write_report(results):
|
||||
with open(f"{OUT}/report.csv", "w", newline="") as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(["img", "style", "side", "side_val", "ok", "elapsed_s", "out_w", "out_h", "bytes", "err"])
|
||||
for r in results:
|
||||
w.writerow([r["img"], r["style"], r["side"], r["side_val"], r["ok"],
|
||||
r["elapsed"], r["out_w"], r["out_h"], r["bytes"], r["err"]])
|
||||
json.dump(results, open(f"{OUT}/report.json", "w"), ensure_ascii=False, indent=1)
|
||||
|
||||
# 控制台速度表:按 图×发型 分行,5档列
|
||||
print("\n--- 速度对比(秒)---")
|
||||
print(f"{'图·发型':<18}{'原图0':>8}{'1024':>8}{'896':>8}{'768':>8}{'640':>8}")
|
||||
for img_name, _ in IMAGES:
|
||||
for _, style_key in STYLES:
|
||||
cells = []
|
||||
for side_name, _ in SIDES:
|
||||
r = next((x for x in results if x["img"] == img_name and x["style"] == style_key and x["side"] == side_name), None)
|
||||
cells.append(f"{r['elapsed']}✓" if r and r["ok"] else (f"{r['elapsed']}✗" if r else "-"))
|
||||
print(f"{img_name+'·'+style_key:<18}{cells[0]:>8}{cells[1]:>8}{cells[2]:>8}{cells[3]:>8}{cells[4]:>8}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env python3
|
||||
"""生成 v2 分辨率对比报告:每行=原图+5档,按图×发型组织15行。"""
|
||||
import json, os, html
|
||||
|
||||
OUT = "/home/ubuntu/hair/image/res_test/out"
|
||||
REPORT = os.path.join(OUT, "report.html")
|
||||
results = json.load(open(os.path.join(OUT, "report.json")))
|
||||
|
||||
IMAGES = ["girl2", "girl5", "qwer", "asdf", "girl7"]
|
||||
STYLES = [("flower", "花瓣"), ("heart", "心形"), ("wave", "波浪")]
|
||||
SIDES = [("origin", "原图直送", 0), ("s1024", "1024", 1024),
|
||||
("s896", "896", 896), ("s768", "768", 768), ("s640", "640", 640)]
|
||||
LONGSIDE = {"girl2": 1082, "girl5": 767, "qwer": 1678, "asdf": 1666, "girl7": 925}
|
||||
|
||||
def get(img, style, side):
|
||||
for r in results:
|
||||
if r["img"] == img and r["style"] == style and r["side"] == side:
|
||||
return r
|
||||
return None
|
||||
|
||||
# 统计
|
||||
total = len(results)
|
||||
ok = sum(1 for r in results if r["ok"])
|
||||
|
||||
# 每档平均耗时(排除冷启动异常值:girl2_flower_origin=135s 明显是冷启动)
|
||||
def avg(side, exclude_first_cold=False):
|
||||
ts = []
|
||||
for r in results:
|
||||
if r["side"] == side and r["ok"]:
|
||||
if exclude_first_cold and r["key"] == "girl2_flower_origin":
|
||||
continue # 跳过冷启动
|
||||
ts.append(r["elapsed"])
|
||||
return sum(ts) / len(ts) if ts else 0
|
||||
|
||||
avg_origin = avg("origin", exclude_first_cold=True)
|
||||
avg_1024 = avg("s1024")
|
||||
avg_896 = avg("s896")
|
||||
avg_768 = avg("s768")
|
||||
avg_640 = avg("s640")
|
||||
avgs = [("origin", "原图直送", avg_origin, 0),
|
||||
("s1024", "1024", avg_1024, 1024),
|
||||
("s896", "896", avg_896, 896),
|
||||
("s768", "768", avg_768, 768),
|
||||
("s640", "640", avg_640, 640)]
|
||||
|
||||
# 速度色阶:以全部耗时的 min-max 映射颜色(绿→黄→红)
|
||||
all_t = sorted([r["elapsed"] for r in results if r["ok"] and r["key"] != "girl2_flower_origin"])
|
||||
tmin, tmax = all_t[0], all_t[-1]
|
||||
def speed_color(t):
|
||||
if tmax == tmin:
|
||||
return "#16a34a"
|
||||
ratio = (t - tmin) / (tmax - tmin) # 0=最快(绿) 1=最慢(红)
|
||||
if ratio < 0.33:
|
||||
return "#16a34a" # 绿
|
||||
elif ratio < 0.66:
|
||||
return "#f59e0b" # 橙
|
||||
else:
|
||||
return "#dc2626" # 红
|
||||
|
||||
# 表格行
|
||||
rows_html = []
|
||||
for img in IMAGES:
|
||||
for style_key, style_cn in STYLES:
|
||||
# 原图格
|
||||
orig_cell = (f'<td class="cell orig">'
|
||||
f'<div class="thumb"><img loading="lazy" src="orig_{img}.jpg" '
|
||||
f'onclick="openImg(this.src)" alt="原图"></div>'
|
||||
f'<div class="meta">📷 原图</div></td>')
|
||||
# 5档格
|
||||
side_cells = []
|
||||
for side_name, side_lbl, side_val in SIDES:
|
||||
r = get(img, style_key, side_name)
|
||||
if r and r["ok"]:
|
||||
col = speed_color(r["elapsed"])
|
||||
cell = (f'<td class="cell">'
|
||||
f'<div class="thumb"><img loading="lazy" src="{r["key"]}.jpg" '
|
||||
f'onclick="openImg(this.src)" alt="{html.escape(r["key"])}"></div>'
|
||||
f'<div class="meta"><b style="color:{col}">{r["elapsed"]}s</b> · '
|
||||
f'{r["out_w"]}×{r["out_h"]}</div></td>')
|
||||
elif r:
|
||||
cell = (f'<td class="cell fail"><div class="thumb noimg">❌</div>'
|
||||
f'<div class="meta err">{html.escape(r["err"][:30])}</div></td>')
|
||||
else:
|
||||
cell = '<td class="cell fail"><div class="thumb noimg">—</div></td>'
|
||||
side_cells.append(cell)
|
||||
row_label = (f'<td class="rowlabel">'
|
||||
f'<div class="rimg">{html.escape(img)}</div>'
|
||||
f'<div class="rs">{html.escape(style_cn)}</div>'
|
||||
f'<div class="rl">长边 {LONGSIDE[img]}</div></td>')
|
||||
rows_html.append("<tr>" + row_label + orig_cell + "".join(side_cells) + "</tr>")
|
||||
|
||||
# 顶部平均耗时卡
|
||||
def stat_card(lbl, val, col, sub=""):
|
||||
return (f'<div class="stat" style="border-left:3px solid {col}">'
|
||||
f'<div class="num" style="color:{col}">{val:.1f}s</div>'
|
||||
f'<div class="lbl">{lbl}{sub}</div></div>')
|
||||
|
||||
stat_cards = "".join([
|
||||
stat_card("原图直送", avg_origin, "#dc2626", "<br><span class='dim'>排除冷启动</span>"),
|
||||
stat_card("1024", avg_1024, "#f59e0b"),
|
||||
stat_card("896 (默认)", avg_896, "#2563eb"),
|
||||
stat_card("768", avg_768, "#16a34a"),
|
||||
stat_card("640", avg_640, "#0d9488"),
|
||||
])
|
||||
|
||||
html_doc = f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>接口2 分辨率对比测试 v2</title>
|
||||
<style>
|
||||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif; background: #f3f4f6; color: #1f2937; line-height: 1.5; padding: 16px; }}
|
||||
.wrap {{ max-width: 100%; margin: 0 auto; }}
|
||||
h1 {{ font-size: 22px; margin-bottom: 4px; }}
|
||||
.sub {{ color: #6b7280; font-size: 12px; margin-bottom: 16px; }}
|
||||
.stats {{ display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }}
|
||||
.stat {{ background: #fff; border-radius: 8px; padding: 12px 14px; box-shadow: 0 1px 3px rgba(0,0,0,.06); flex: 1; min-width: 110px; }}
|
||||
.stat .num {{ font-size: 22px; font-weight: 700; }}
|
||||
.stat .lbl {{ font-size: 11px; color: #6b7280; margin-top: 2px; }}
|
||||
.stat .dim {{ color: #9ca3af; font-size: 10px; }}
|
||||
.summary-bar {{ background: #fff; border-radius: 8px; padding: 12px 16px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,.06); font-size: 13px; }}
|
||||
.summary-bar b {{ color: #dc2626; }}
|
||||
.table-wrap {{ overflow-x: auto; background: #fff; border-radius: 10px; box-shadow: 0 1px 4px rgba(0,0,0,.07); }}
|
||||
table {{ border-collapse: collapse; min-width: 100%; }}
|
||||
th, td {{ vertical-align: top; }}
|
||||
thead th {{ position: sticky; top: 0; background: #f9fafb; z-index: 2; padding: 10px 8px; font-size: 12px; color: #374151; border-bottom: 2px solid #e5e7eb; text-align: center; }}
|
||||
thead th.orig-h {{ background: #fef3c7; }}
|
||||
tbody td {{ border-bottom: 1px solid #f3f4f6; padding: 8px; }}
|
||||
tbody tr:hover {{ background: #f9fafb; }}
|
||||
td.rowlabel {{ text-align: left; padding: 8px 12px; position: sticky; left: 0; background: #fff; z-index: 1; min-width: 90px; box-shadow: 2px 0 4px rgba(0,0,0,.04); }}
|
||||
tbody tr:hover td.rowlabel {{ background: #f9fafb; }}
|
||||
.rimg {{ font-weight: 700; font-size: 14px; }}
|
||||
.rs {{ font-size: 12px; color: #6b7280; }}
|
||||
.rl {{ font-size: 10px; color: #9ca3af; margin-top: 2px; }}
|
||||
.cell {{ width: 180px; min-width: 180px; text-align: center; }}
|
||||
.cell.orig {{ width: 180px; background: #fffbeb; }}
|
||||
.thumb {{ background: #1f2937; border-radius: 6px; overflow: hidden; margin-bottom: 4px; cursor: zoom-in; }}
|
||||
.thumb img {{ width: 100%; height: 240px; object-fit: contain; display: block; }}
|
||||
.thumb.noimg {{ color: #d1d5db; font-size: 16px; padding: 100px 0; text-align: center; }}
|
||||
.meta {{ font-size: 11px; color: #6b7280; }}
|
||||
.meta.err {{ color: #dc2626; }}
|
||||
.legend {{ display: inline-flex; gap: 12px; font-size: 11px; color: #6b7280; margin-left: 12px; }}
|
||||
.legend span {{ display: inline-flex; align-items: center; gap: 4px; }}
|
||||
.legend i {{ width: 10px; height: 10px; border-radius: 2px; display: inline-block; }}
|
||||
.overlay {{ display: none; position: fixed; inset: 0; background: rgba(0,0,0,.92); z-index: 999; justify-content: center; align-items: center; cursor: zoom-out; padding: 24px; }}
|
||||
.overlay.active {{ display: flex; }}
|
||||
.overlay img {{ max-width: 96%; max-height: 96%; object-fit: contain; border-radius: 4px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>💄 接口2 生发 · 5档分辨率对比报告</h1>
|
||||
<p class="sub">POST /api/v1/hair/grow · female · 5 图 × 3 发型(花瓣/心形/波浪) × 5 档分辨率 = 75 次 · 串行 · 4090 (24G)</p>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat" style="border-left:3px solid #16a34a"><div class="num" style="color:#16a34a">{ok}/{total}</div><div class="lbl">成功 / 总数</div></div>
|
||||
{stat_cards}
|
||||
</div>
|
||||
|
||||
<div class="summary-bar">
|
||||
📊 <b>结论:</b>耗时随送图分辨率单调下降。<b>大图(qwer/asdf 长边~1670) 原图直送需 ~27-30s,是 896 档(10s) 的近 3 倍</b>;
|
||||
中小图(girl2/girl5/girl7 长边 767-1082)各档差异较小(6-15s)。
|
||||
<b>4090 24G 全程无 OOM</b>,75/75 成功。<b>画质对比</b>见下表(横向滑动),点击任意图可放大。
|
||||
<span class="legend">
|
||||
<span><i style="background:#16a34a"></i>快(<{tmin+ (tmax-tmin)*0.33:.0f}s)</span>
|
||||
<span><i style="background:#f59e0b"></i>中等</span>
|
||||
<span><i style="background:#dc2626"></i>慢(>{tmin+ (tmax-tmin)*0.66:.0f}s)</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>图 · 发型</th>
|
||||
<th class="orig-h">📷 原图</th>
|
||||
<th>原图直送 (0)<br><span class="dim">不缩放</span></th>
|
||||
<th>1024</th>
|
||||
<th>896<br><span class="dim">默认</span></th>
|
||||
<th>768</th>
|
||||
<th>640</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{"".join(rows_html)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overlay" id="overlay" onclick="this.classList.remove('active')"><img id="overlayImg" src=""></div>
|
||||
<script>
|
||||
function openImg(src) {{
|
||||
document.getElementById('overlayImg').src = src;
|
||||
document.getElementById('overlay').classList.add('active');
|
||||
}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
with open(REPORT, "w") as f:
|
||||
f.write(html_doc)
|
||||
print(f"已生成: {REPORT}")
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=接口2 5档分辨率对比报告 v2 (8849)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=ubuntu
|
||||
WorkingDirectory=/home/ubuntu/hair/image/res_test/out
|
||||
ExecStart=/home/ubuntu/miniconda3/envs/my_hair/bin/python -m http.server 8849 --bind 0.0.0.0
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
"""接口2 female wave发型 分辨率对比测试 v3: 21图 × 5档 = 105次。
|
||||
串行执行,记录耗时与成败,结果图按 图_档位 命名保存。支持断点续跑。
|
||||
"""
|
||||
import base64, csv, json, os, time, io
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
API = "http://127.0.0.1:8187/api/v1/hair/grow"
|
||||
TOKEN = "dev-shared-secret-2026"
|
||||
TIMEOUT = 600
|
||||
OUT = "/home/ubuntu/hair/image/wave_test/out"
|
||||
PROGRESS = "/home/ubuntu/hair/image/wave_test/progress.json"
|
||||
|
||||
IMG_DIR = "/home/ubuntu/hair/image"
|
||||
# 21张图:19张girl_img + asdf + qwer
|
||||
IMAGES = []
|
||||
for f in sorted(os.listdir(os.path.join(IMG_DIR, "girl_img"))):
|
||||
if f.lower().endswith((".jpg", ".jpeg", ".png")):
|
||||
IMAGES.append((os.path.splitext(f)[0], os.path.join(IMG_DIR, "girl_img", f)))
|
||||
IMAGES.append(("asdf", os.path.join(IMG_DIR, "asdf.jpg")))
|
||||
IMAGES.append(("qwer", os.path.join(IMG_DIR, "qwer.jpg")))
|
||||
|
||||
# wave = female hair_style 5
|
||||
STYLE_IDX = 5
|
||||
# 5档: 原图(0) / 1024 / 896 / 768 / 640
|
||||
SIDES = [
|
||||
("origin", 0),
|
||||
("s1024", 1024),
|
||||
("s896", 896),
|
||||
("s768", 768),
|
||||
("s640", 640),
|
||||
]
|
||||
|
||||
def load_progress():
|
||||
if os.path.exists(PROGRESS):
|
||||
try:
|
||||
return json.load(open(PROGRESS))
|
||||
except Exception:
|
||||
pass
|
||||
return {"done": [], "results": []}
|
||||
|
||||
def save_progress(prog):
|
||||
json.dump(prog, open(PROGRESS, "w"), ensure_ascii=False, indent=1)
|
||||
|
||||
def run_one(img_name, img_path, side_name, side_val):
|
||||
key = f"{img_name}_{side_name}"
|
||||
with open(img_path, "rb") as f:
|
||||
img_b64 = base64.b64encode(f.read()).decode()
|
||||
data = {
|
||||
"image_base64": "data:image/jpeg;base64," + img_b64,
|
||||
"gender": "female",
|
||||
"hair_style": str(STYLE_IDX),
|
||||
"redraw_max_side": str(side_val),
|
||||
}
|
||||
t0 = time.time()
|
||||
rec = {"key": key, "img": img_name, "side": side_name, "side_val": side_val,
|
||||
"ok": False, "elapsed": 0.0, "err": "", "out_w": 0, "out_h": 0, "bytes": 0}
|
||||
try:
|
||||
r = requests.post(API, data=data, headers={"X-Internal-Token": TOKEN}, timeout=TIMEOUT)
|
||||
rec["elapsed"] = round(time.time() - t0, 1)
|
||||
d = r.json()
|
||||
if d.get("code") != 0:
|
||||
rec["err"] = f"code={d.get('code')} {d.get('message','')}"[:200]
|
||||
return rec
|
||||
results = (d.get("data") or {}).get("results") or []
|
||||
if not results:
|
||||
rec["err"] = "空结果"
|
||||
return rec
|
||||
it = results[0]
|
||||
grown = it.get("grown_image_base64")
|
||||
if not grown:
|
||||
rec["err"] = "无生发图(重绘失败/OOM?)"
|
||||
return rec
|
||||
raw = base64.b64decode(grown)
|
||||
im = Image.open(io.BytesIO(raw))
|
||||
rec["out_w"], rec["out_h"] = im.size
|
||||
out_path = f"{OUT}/{key}.jpg"
|
||||
with open(out_path, "wb") as fo:
|
||||
fo.write(raw)
|
||||
rec["ok"] = True
|
||||
rec["bytes"] = len(raw)
|
||||
except requests.exceptions.Timeout:
|
||||
rec["elapsed"] = round(time.time() - t0, 1)
|
||||
rec["err"] = f"超时(>{TIMEOUT}s)"
|
||||
except Exception as e:
|
||||
rec["elapsed"] = round(time.time() - t0, 1)
|
||||
rec["err"] = f"{type(e).__name__}: {str(e)[:180]}"
|
||||
return rec
|
||||
|
||||
def main():
|
||||
prog = load_progress()
|
||||
done_keys = set(prog["done"])
|
||||
total = len(IMAGES) * len(SIDES)
|
||||
print(f"=== 接口2 female wave 分辨率对比测试 v3 ===")
|
||||
print(f"矩阵: {len(IMAGES)}图 × {len(SIDES)}档 = {total} 次 (发型固定 wave)")
|
||||
print(f"已跳过 {len(done_keys)} 个已完成项\n")
|
||||
|
||||
idx = 0
|
||||
for img_name, img_path in IMAGES:
|
||||
for side_name, side_val in SIDES:
|
||||
idx += 1
|
||||
key = f"{img_name}_{side_name}"
|
||||
if key in done_keys:
|
||||
print(f"[{idx}/{total}] ⏭ 跳过 {key}")
|
||||
continue
|
||||
print(f"[{idx}/{total}] ▶ {key} (side={side_val})...", end=" ", flush=True)
|
||||
rec = run_one(img_name, img_path, side_name, side_val)
|
||||
prog["results"].append(rec)
|
||||
prog["done"].append(key)
|
||||
save_progress(prog)
|
||||
if rec["ok"]:
|
||||
print(f"✅ {rec['elapsed']}s {rec['out_w']}x{rec['out_h']} ({rec['bytes']}B)")
|
||||
else:
|
||||
print(f"❌ {rec['elapsed']}s {rec['err']}")
|
||||
time.sleep(2)
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("汇总")
|
||||
print("=" * 70)
|
||||
write_report(prog["results"])
|
||||
print(f"\n结果图: {OUT}/")
|
||||
print(f"CSV: {OUT}/report.csv JSON: {OUT}/report.json")
|
||||
|
||||
def write_report(results):
|
||||
with open(f"{OUT}/report.csv", "w", newline="") as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(["img", "side", "side_val", "ok", "elapsed_s", "out_w", "out_h", "bytes", "err"])
|
||||
for r in results:
|
||||
w.writerow([r["img"], r["side"], r["side_val"], r["ok"],
|
||||
r["elapsed"], r["out_w"], r["out_h"], r["bytes"], r["err"]])
|
||||
json.dump(results, open(f"{OUT}/report.json", "w"), ensure_ascii=False, indent=1)
|
||||
|
||||
# 各档平均耗时
|
||||
print("\n--- 各档平均耗时(秒)---")
|
||||
for side_name, side_val in SIDES:
|
||||
ts = [r["elapsed"] for r in results if r["side"] == side_name and r["ok"]]
|
||||
if ts:
|
||||
avg = sum(ts) / len(ts)
|
||||
print(f" {side_name:8s} (={side_val:>4}): 平均 {avg:5.1f}s [{min(ts):.1f}~{max(ts):.1f}] 成功 {len(ts)}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env python3
|
||||
"""生成 v3 wave发型 分辨率对比报告:每行=原图+5档,21行;含各档耗时统计。"""
|
||||
import json, os, html
|
||||
|
||||
OUT = "/home/ubuntu/hair/image/wave_test/out"
|
||||
REPORT = os.path.join(OUT, "report.html")
|
||||
results = json.load(open(os.path.join(OUT, "report.json")))
|
||||
|
||||
SIDES = [("origin", "原图直送", 0), ("s1024", "1024", 1024),
|
||||
("s896", "896", 896), ("s768", "768", 768), ("s640", "640", 640)]
|
||||
|
||||
# 图片顺序(与测试脚本一致):girl_img 排序 + asdf + qwer
|
||||
IMG_DIR = "/home/ubuntu/hair/image"
|
||||
IMAGES = []
|
||||
for f in sorted(os.listdir(os.path.join(IMG_DIR, "girl_img"))):
|
||||
if f.lower().endswith((".jpg", ".jpeg", ".png")):
|
||||
IMAGES.append(os.path.splitext(f)[0])
|
||||
IMAGES.append("asdf")
|
||||
IMAGES.append("qwer")
|
||||
|
||||
def get(img, side):
|
||||
for r in results:
|
||||
if r["img"] == img and r["side"] == side:
|
||||
return r
|
||||
return None
|
||||
|
||||
# 统计
|
||||
total = len(results)
|
||||
ok = sum(1 for r in results if r["ok"])
|
||||
|
||||
# 各档统计
|
||||
def avg(side):
|
||||
ts = [r["elapsed"] for r in results if r["side"] == side and r["ok"]]
|
||||
return sum(ts)/len(ts) if ts else 0
|
||||
avgs = {s[0]: avg(s[0]) for s in SIDES}
|
||||
|
||||
# 速度色阶
|
||||
all_t = sorted(r["elapsed"] for r in results if r["ok"])
|
||||
tmin, tmax = all_t[0], all_t[-1]
|
||||
def speed_color(t):
|
||||
if tmax == tmin: return "#16a34a"
|
||||
ratio = (t - tmin) / (tmax - tmin)
|
||||
if ratio < 0.33: return "#16a34a"
|
||||
elif ratio < 0.66: return "#f59e0b"
|
||||
else: return "#dc2626"
|
||||
|
||||
# 表格行
|
||||
rows_html = []
|
||||
for img in IMAGES:
|
||||
orig_cell = (f'<td class="cell orig">'
|
||||
f'<div class="thumb"><img loading="lazy" src="orig_{img}.jpg" '
|
||||
f'onclick="openImg(this.src)" alt="原图"></div>'
|
||||
f'<div class="meta">📷 原图</div></td>')
|
||||
side_cells = []
|
||||
for side_name, side_lbl, side_val in SIDES:
|
||||
r = get(img, side_name)
|
||||
if r and r["ok"]:
|
||||
col = speed_color(r["elapsed"])
|
||||
cell = (f'<td class="cell">'
|
||||
f'<div class="thumb"><img loading="lazy" src="{r["key"]}.jpg" '
|
||||
f'onclick="openImg(this.src)" alt="{html.escape(r["key"])}"></div>'
|
||||
f'<div class="meta"><b style="color:{col}">{r["elapsed"]}s</b></div></td>')
|
||||
elif r:
|
||||
cell = (f'<td class="cell fail"><div class="thumb noimg">❌</div>'
|
||||
f'<div class="meta err">{html.escape(r["err"][:30])}</div></td>')
|
||||
else:
|
||||
cell = '<td class="cell fail"><div class="thumb noimg">—</div></td>'
|
||||
side_cells.append(cell)
|
||||
rows_html.append("<tr>" + orig_cell + "".join(side_cells) + "</tr>")
|
||||
|
||||
# 各档统计卡
|
||||
def stat_card(lbl, val, col, rng=""):
|
||||
return (f'<div class="stat" style="border-left:3px solid {col}">'
|
||||
f'<div class="num" style="color:{col}">{val:.1f}s</div>'
|
||||
f'<div class="lbl">{lbl}{rng}</div></div>')
|
||||
|
||||
stat_cards = "".join([
|
||||
stat_card("原图直送(0)", avgs["origin"], "#dc2626"),
|
||||
stat_card("1024", avgs["s1024"], "#f59e0b"),
|
||||
stat_card("896", avgs["s896"], "#2563eb"),
|
||||
stat_card("768", avgs["s768"], "#16a34a"),
|
||||
stat_card("640", avgs["s640"], "#0d9488"),
|
||||
])
|
||||
|
||||
html_doc = f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>wave发型 5档分辨率对比</title>
|
||||
<style>
|
||||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif; background: #f3f4f6; color: #1f2937; line-height: 1.5; padding: 16px; }}
|
||||
.wrap {{ max-width: 100%; margin: 0 auto; }}
|
||||
h1 {{ font-size: 22px; margin-bottom: 4px; }}
|
||||
.sub {{ color: #6b7280; font-size: 12px; margin-bottom: 16px; }}
|
||||
.stats {{ display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }}
|
||||
.stat {{ background: #fff; border-radius: 8px; padding: 12px 14px; box-shadow: 0 1px 3px rgba(0,0,0,.06); flex: 1; min-width: 110px; }}
|
||||
.stat .num {{ font-size: 22px; font-weight: 700; }}
|
||||
.stat .lbl {{ font-size: 11px; color: #6b7280; margin-top: 2px; }}
|
||||
.summary-bar {{ background: #fff; border-radius: 8px; padding: 12px 16px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,.06); font-size: 13px; }}
|
||||
.summary-bar b {{ color: #dc2626; }}
|
||||
.legend {{ display: inline-flex; gap: 12px; font-size: 11px; color: #6b7280; margin-left: 12px; }}
|
||||
.legend span {{ display: inline-flex; align-items: center; gap: 4px; }}
|
||||
.legend i {{ width: 10px; height: 10px; border-radius: 2px; display: inline-block; }}
|
||||
.table-wrap {{ overflow-x: auto; background: #fff; border-radius: 10px; box-shadow: 0 1px 4px rgba(0,0,0,.07); }}
|
||||
table {{ border-collapse: collapse; min-width: 100%; }}
|
||||
th, td {{ vertical-align: top; }}
|
||||
thead th {{ position: sticky; top: 0; background: #f9fafb; z-index: 2; padding: 10px 8px; font-size: 12px; color: #374151; border-bottom: 2px solid #e5e7eb; text-align: center; }}
|
||||
thead th.orig-h {{ background: #fef3c7; }}
|
||||
tbody td {{ border-bottom: 1px solid #f3f4f6; padding: 8px; }}
|
||||
tbody tr:hover {{ background: #f9fafb; }}
|
||||
.cell {{ width: 200px; min-width: 200px; text-align: center; }}
|
||||
.cell.orig {{ background: #fffbeb; }}
|
||||
.thumb {{ background: #1f2937; border-radius: 6px; overflow: hidden; margin-bottom: 4px; cursor: zoom-in; }}
|
||||
.thumb img {{ width: 100%; height: 260px; object-fit: contain; display: block; }}
|
||||
.thumb.noimg {{ color: #d1d5db; font-size: 16px; padding: 110px 0; text-align: center; }}
|
||||
.meta {{ font-size: 11px; color: #6b7280; }}
|
||||
.meta.err {{ color: #dc2626; }}
|
||||
.overlay {{ display: none; position: fixed; inset: 0; background: rgba(0,0,0,.92); z-index: 999; justify-content: center; align-items: center; cursor: zoom-out; padding: 24px; }}
|
||||
.overlay.active {{ display: flex; }}
|
||||
.overlay img {{ max-width: 96%; max-height: 96%; object-fit: contain; border-radius: 4px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>💇 wave发型 · 5档分辨率对比报告</h1>
|
||||
<p class="sub">POST /api/v1/hair/grow · female · wave(波浪) · 21 图 × 5 档分辨率 = 105 次 · 串行 · 4090 (24G)</p>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat" style="border-left:3px solid #16a34a"><div class="num" style="color:#16a34a">{ok}/{total}</div><div class="lbl">成功 / 总数</div></div>
|
||||
{stat_cards}
|
||||
</div>
|
||||
|
||||
<div class="summary-bar">
|
||||
📊 <b>结论:</b>耗时随分辨率单调下降。<b>大图(asdf 1666/qwer 1678) 原图直送需 24~26s,是 1024 档(11s) 的 2.3 倍</b>;
|
||||
中小图(≤1024) 各档差异较小(6~13s),因小图本身不触发缩图。
|
||||
<b>4090 24G 全程无 OOM</b>,105/105 成功。<b>画质对比</b>见下表(横向滑动),点击任意图可放大。
|
||||
<span class="legend">
|
||||
<span><i style="background:#16a34a"></i>快(<{(tmin+(tmax-tmin)*0.33):.0f}s)</span>
|
||||
<span><i style="background:#f59e0b"></i>中等</span>
|
||||
<span><i style="background:#dc2626"></i>慢(>{(tmin+(tmax-tmin)*0.66):.0f}s)</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="orig-h">📷 原图</th>
|
||||
<th>原图直送 (0)<br><span class="dim">不缩放</span></th>
|
||||
<th>1024</th>
|
||||
<th>896<br><span class="dim">默认</span></th>
|
||||
<th>768</th>
|
||||
<th>640</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{"".join(rows_html)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overlay" id="overlay" onclick="this.classList.remove('active')"><img id="overlayImg" src=""></div>
|
||||
<script>
|
||||
function openImg(src) {{
|
||||
document.getElementById('overlayImg').src = src;
|
||||
document.getElementById('overlay').classList.add('active');
|
||||
}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
with open(REPORT, "w") as f:
|
||||
f.write(html_doc)
|
||||
print(f"已生成: {REPORT}")
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=wave发型5档分辨率对比报告 (8850)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=ubuntu
|
||||
WorkingDirectory=/home/ubuntu/hair/image/wave_test/out
|
||||
ExecStart=/home/ubuntu/miniconda3/envs/my_hair/bin/python -m http.server 8850 --bind 0.0.0.0
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -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 |
@@ -1,156 +0,0 @@
|
||||
"""批量调用接口12(/api/v1/hairline/grow_v2)生成对比素材。
|
||||
|
||||
20 张女生照片 × 5 种发际线发型 × {高清, 非高清} = 200 张输出。
|
||||
并发 4,失败的跳过并记录原因。结果图落盘到 static/report_hairline_v2/img/,
|
||||
元数据落盘 static/report_hairline_v2/results.json,供生成报告用。
|
||||
|
||||
用法: python scripts/batch_grow_v2.py
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
import httpx
|
||||
|
||||
API = "http://127.0.0.1:8187/api/v1/hairline/grow_v2"
|
||||
TOKEN = "dev-shared-secret-2026"
|
||||
CONCURRENCY = 2
|
||||
|
||||
INPUT_DIR = "/home/xsl/hair/image/test"
|
||||
OUT_DIR = "/home/xsl/hair/static/report_hairline_v2"
|
||||
IMG_DIR = os.path.join(OUT_DIR, "img")
|
||||
ORIG_DIR = os.path.join(OUT_DIR, "orig")
|
||||
|
||||
# 5 种发际线发型(= change_hair hair_id)
|
||||
HAIRSTYLES = [
|
||||
("chang_zhixian", "直线"),
|
||||
("chang_tuoyuan", "椭圆"),
|
||||
("chang_bolang", "波浪"),
|
||||
("chang_xinxing", "心形"),
|
||||
("chang_huaban", "花瓣"),
|
||||
]
|
||||
HR_OPTIONS = [(True, "hr"), (False, "nohr")]
|
||||
|
||||
|
||||
def list_inputs():
|
||||
files = sorted(f for f in os.listdir(INPUT_DIR) if f.lower().endswith((".jpg", ".png")))
|
||||
return files
|
||||
|
||||
|
||||
def one_call(stem, face_file, hair_id, hair_cn, is_hr, hr_tag):
|
||||
"""调用一次接口,落盘结果图。返回结果 dict。"""
|
||||
src = os.path.join(INPUT_DIR, face_file)
|
||||
out_name = f"{stem}__{hair_id}__{hr_tag}.jpg"
|
||||
out_path = os.path.join(IMG_DIR, out_name)
|
||||
# 断点续跑:已存在的图直接跳过,不重复调用
|
||||
if os.path.exists(out_path) and os.path.getsize(out_path) > 1024:
|
||||
return {
|
||||
"stem": stem, "face_file": face_file, "hair_id": hair_id, "hair_cn": hair_cn,
|
||||
"is_hr": is_hr, "hr_tag": hr_tag, "ok": True,
|
||||
"out": f"img/{out_name}", "size": None,
|
||||
"ms": 0, "error": None, "skipped": True,
|
||||
}
|
||||
t0 = time.time()
|
||||
try:
|
||||
with open(src, "rb") as fh:
|
||||
files = {"image_file": (face_file, fh.read(), "image/jpeg")}
|
||||
data = {"hairline_id": hair_id, "is_hr": str(is_hr).lower()}
|
||||
with httpx.Client(timeout=180.0) as c:
|
||||
resp = c.post(API, headers={"X-Internal-Token": TOKEN}, files=files, data=data)
|
||||
j = resp.json()
|
||||
if j.get("code") != 0 or not j.get("data"):
|
||||
raise RuntimeError(f"code={j.get('code')} msg={j.get('message')}")
|
||||
b64 = j["data"]["final_base64"].split(",", 1)[1]
|
||||
raw = base64.b64decode(b64)
|
||||
with open(out_path, "wb") as fh:
|
||||
fh.write(raw)
|
||||
return {
|
||||
"stem": stem, "face_file": face_file, "hair_id": hair_id, "hair_cn": hair_cn,
|
||||
"is_hr": is_hr, "hr_tag": hr_tag, "ok": True,
|
||||
"out": f"img/{out_name}", "size": j["data"].get("image_size"),
|
||||
"ms": int((time.time() - t0) * 1000), "error": None,
|
||||
}
|
||||
except Exception as ex: # noqa: BLE001
|
||||
return {
|
||||
"stem": stem, "face_file": face_file, "hair_id": hair_id, "hair_cn": hair_cn,
|
||||
"is_hr": is_hr, "hr_tag": hr_tag, "ok": False,
|
||||
"out": None, "size": None, "ms": int((time.time() - t0) * 1000),
|
||||
"error": str(ex)[:200],
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(IMG_DIR, exist_ok=True)
|
||||
os.makedirs(ORIG_DIR, exist_ok=True)
|
||||
faces = list_inputs()
|
||||
print(f"输入 {len(faces)} 张脸 × {len(HAIRSTYLES)} 发型 × {len(HR_OPTIONS)} = "
|
||||
f"{len(faces)*len(HAIRSTYLES)*len(HR_OPTIONS)} 次调用,并发 {CONCURRENCY}")
|
||||
|
||||
# 1. 先把原图拷一份到 orig/(报告要用)
|
||||
import shutil
|
||||
for f in faces:
|
||||
stem = os.path.splitext(f)[0]
|
||||
dst = os.path.join(ORIG_DIR, f"{stem}.jpg")
|
||||
if not os.path.exists(dst):
|
||||
shutil.copy2(os.path.join(INPUT_DIR, f), dst)
|
||||
|
||||
# 2. 构造全部任务
|
||||
tasks = []
|
||||
for f in faces:
|
||||
stem = os.path.splitext(f)[0]
|
||||
for hair_id, hair_cn in HAIRSTYLES:
|
||||
for is_hr, hr_tag in HR_OPTIONS:
|
||||
tasks.append((stem, f, hair_id, hair_cn, is_hr, hr_tag))
|
||||
|
||||
results = []
|
||||
done = 0
|
||||
total = len(tasks)
|
||||
t_start = time.time()
|
||||
with ThreadPoolExecutor(max_workers=CONCURRENCY) as ex:
|
||||
futs = {ex.submit(one_call, *t): t for t in tasks}
|
||||
for fut in as_completed(futs):
|
||||
r = fut.result()
|
||||
results.append(r)
|
||||
done += 1
|
||||
status = "OK " if r["ok"] else "FAIL"
|
||||
if r.get("skipped"):
|
||||
print(f"[{done}/{total}] SKIP {r['stem']} {r['hair_cn']} {r['hr_tag']}")
|
||||
elif r["ok"]:
|
||||
print(f"[{done}/{total}] {status} {r['stem']} {r['hair_cn']} {r['hr_tag']} "
|
||||
f"({r['ms']}ms)", flush=True)
|
||||
else:
|
||||
print(f"[{done}/{total}] {status} {r['stem']} {r['hair_cn']} {r['hr_tag']} "
|
||||
f"-> {r['error']}")
|
||||
|
||||
elapsed = time.time() - t_start
|
||||
ok = sum(1 for r in results if r["ok"])
|
||||
fail = len(results) - ok
|
||||
# 按稳定顺序排序,报告好看
|
||||
order = {s: i for i, s in enumerate(HAIRSTYLES)}
|
||||
hr_order = {True: 0, False: 1}
|
||||
face_order = {os.path.splitext(f)[0]: i for i, f in enumerate(faces)}
|
||||
results.sort(key=lambda r: (face_order.get(r["stem"], 0),
|
||||
order.get((r["hair_id"], r["hair_cn"]), 0),
|
||||
hr_order.get(r["is_hr"], 0)))
|
||||
|
||||
meta = {
|
||||
"total": total, "ok": ok, "fail": fail,
|
||||
"elapsed_sec": round(elapsed, 1), "concurrency": CONCURRENCY,
|
||||
"hairstyles": [{"id": h, "cn": c} for h, c in HAIRSTYLES],
|
||||
"hr_options": [{"is_hr": True, "tag": "hr"}, {"is_hr": False, "tag": "nohr"}],
|
||||
"faces": [os.path.splitext(f)[0] for f in faces],
|
||||
"generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
out = {"meta": meta, "results": results}
|
||||
with open(os.path.join(OUT_DIR, "results.json"), "w", encoding="utf-8") as fh:
|
||||
json.dump(out, fh, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"\n完成:{ok}/{total} 成功,{fail} 失败,耗时 {elapsed:.1f}s")
|
||||
print(f"结果图 -> {IMG_DIR}")
|
||||
print(f"元数据 -> {OUT_DIR}/results.json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,229 +0,0 @@
|
||||
"""根据 results.json 生成发际线生发对比报告 HTML。
|
||||
|
||||
报告布局(原图 vs 结果对比):
|
||||
- 顶部:总览统计(成功/失败数、耗时、参数)
|
||||
- 按脸分组,每张脸一个区块:
|
||||
- 左:原图
|
||||
- 右:5 发型 × {高清, 非高清} 网格(共 10 张),失败的格子标注原因
|
||||
- 失败用例汇总表
|
||||
|
||||
输出:static/report_hairline_v2/index.html
|
||||
"""
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
from urllib.parse import quote
|
||||
|
||||
OUT_DIR = "/home/xsl/hair/static/report_hairline_v2"
|
||||
RESULTS = os.path.join(OUT_DIR, "results.json")
|
||||
TARGET = os.path.join(OUT_DIR, "index.html")
|
||||
|
||||
|
||||
def url(path):
|
||||
"""把相对路径里的中文做 URL 编码,分隔符 '/' 保留。
|
||||
Starlette StaticFiles 对未编码中文路径返回 400,编码后所有浏览器稳定可加载。
|
||||
"""
|
||||
return "/".join(quote(seg) for seg in path.split("/"))
|
||||
|
||||
|
||||
def main():
|
||||
with open(RESULTS, encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
meta = data["meta"]
|
||||
results = data["results"]
|
||||
|
||||
hairstyles = meta["hairstyles"] # [{id, cn}]
|
||||
faces = meta["faces"] # [stem, ...]
|
||||
hr_opts = meta["hr_options"] # [{is_hr, tag}]
|
||||
|
||||
# 索引:(stem, hair_id, hr_tag) -> result
|
||||
idx = {}
|
||||
for r in results:
|
||||
idx[(r["stem"], r["hair_id"], r["hr_tag"])] = r
|
||||
|
||||
# 统计
|
||||
ok = sum(1 for r in results if r["ok"])
|
||||
fail = len(results) - ok
|
||||
|
||||
parts = []
|
||||
parts.append(f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>发际线生发对比报告 · 接口12 grow_v2</title>
|
||||
<style>
|
||||
:root {{
|
||||
--bg:#0f1115; --card:#1a1d24; --border:#2a2f3a; --txt:#e6e6e6;
|
||||
--muted:#8a93a3; --accent:#6ea8fe; --ok:#4ade80; --fail:#f87171;
|
||||
}}
|
||||
* {{ box-sizing:border-box; }}
|
||||
body {{ margin:0; background:var(--bg); color:var(--txt);
|
||||
font-family:-apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
|
||||
line-height:1.5; }}
|
||||
header {{ padding:24px 32px; border-bottom:1px solid var(--border); }}
|
||||
header h1 {{ margin:0 0 8px; font-size:22px; }}
|
||||
header .sub {{ color:var(--muted); font-size:14px; }}
|
||||
.stats {{ display:flex; gap:16px; flex-wrap:wrap; margin-top:16px; }}
|
||||
.stat {{ background:var(--card); border:1px solid var(--border); border-radius:8px;
|
||||
padding:12px 16px; min-width:120px; }}
|
||||
.stat .n {{ font-size:24px; font-weight:600; }}
|
||||
.stat .l {{ font-size:12px; color:var(--muted); }}
|
||||
.stat.ok .n {{ color:var(--ok); }}
|
||||
.stat.fail .n {{ color:var(--fail); }}
|
||||
main {{ padding:24px 32px; }}
|
||||
.face-block {{ background:var(--card); border:1px solid var(--border);
|
||||
border-radius:12px; padding:20px; margin-bottom:24px; }}
|
||||
.face-head {{ display:flex; align-items:center; gap:12px; margin-bottom:16px; }}
|
||||
.face-head h2 {{ margin:0; font-size:18px; }}
|
||||
.face-head .orig-thumb {{ width:64px; height:64px; object-fit:cover;
|
||||
border-radius:8px; border:1px solid var(--border); }}
|
||||
.grid {{ display:grid; grid-template-columns:140px repeat({len(hairstyles)*2}, 1fr);
|
||||
gap:8px; align-items:start; }}
|
||||
.col-hdr {{ font-size:12px; color:var(--muted); text-align:center; padding:6px 4px;
|
||||
border-bottom:1px solid var(--border); }}
|
||||
.row-lbl {{ font-size:13px; color:var(--accent); padding:8px 4px;
|
||||
border-right:1px solid var(--border); }}
|
||||
.cell {{ position:relative; }}
|
||||
.cell img {{ width:100%; border-radius:6px; display:block;
|
||||
border:1px solid var(--border); }}
|
||||
.cell .cap {{ font-size:11px; color:var(--muted); margin-top:4px; text-align:center; }}
|
||||
.cell.fail .failbox {{ aspect-ratio:3/4; background:#2a1518; border:1px solid #5c2a30;
|
||||
border-radius:6px; display:flex; align-items:center;
|
||||
justify-content:center; padding:8px; text-align:center;
|
||||
font-size:11px; color:var(--fail); }}
|
||||
.tag {{ display:inline-block; font-size:11px; padding:1px 6px; border-radius:4px;
|
||||
background:#243044; color:var(--accent); margin-left:6px; }}
|
||||
.tag.no {{ background:#3a3526; color:#e0c97a; }}
|
||||
.legend {{ font-size:13px; color:var(--muted); margin-bottom:16px; }}
|
||||
.fail-table {{ width:100%; border-collapse:collapse; font-size:13px; margin-top:8px; }}
|
||||
.fail-table th, .fail-table td {{ border:1px solid var(--border); padding:6px 10px; text-align:left; }}
|
||||
.fail-table th {{ background:#1f232c; color:var(--muted); }}
|
||||
.anchor {{ display:block; height:0; overflow:hidden; }}
|
||||
footer {{ padding:24px 32px; color:var(--muted); font-size:12px; border-top:1px solid var(--border); }}
|
||||
/* 点击放大 */
|
||||
img.zoomable {{ cursor:zoom-in; transition:opacity .12s; }}
|
||||
img.zoomable:hover {{ opacity:.85; }}
|
||||
#lightbox {{ position:fixed; inset:0; background:rgba(0,0,0,.92); display:none;
|
||||
align-items:center; justify-content:center; z-index:9999; padding:24px;
|
||||
cursor:zoom-out; }}
|
||||
#lightbox.open {{ display:flex; }}
|
||||
#lightbox img {{ max-width:100%; max-height:100%; object-fit:contain;
|
||||
border-radius:8px; box-shadow:0 8px 40px rgba(0,0,0,.6); }}
|
||||
#lightbox .lb-cap {{ position:absolute; bottom:16px; left:0; right:0; text-align:center;
|
||||
color:var(--muted); font-size:13px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>发际线生发对比报告 <span class="tag">接口12 · grow_v2</span></h1>
|
||||
<div class="sub">固定参数:blend_method=multiband · mb_levels=5 · erode_cm=0.6 · 生成后端=swaphair</div>
|
||||
<div class="sub">生成时间:{html.escape(meta['generated_at'])} · 并发 {meta['concurrency']} · 耗时 {meta['elapsed_sec']}s</div>
|
||||
<div class="stats">
|
||||
<div class="stat"><div class="n">{meta['total']}</div><div class="l">总调用</div></div>
|
||||
<div class="stat ok"><div class="n">{ok}</div><div class="l">成功</div></div>
|
||||
<div class="stat fail"><div class="n">{fail}</div><div class="l">失败</div></div>
|
||||
<div class="stat"><div class="n">{len(faces)}</div><div class="l">人脸数</div></div>
|
||||
<div class="stat"><div class="n">{len(hairstyles)}</div><div class="l">发型数</div></div>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
""")
|
||||
|
||||
# 图例
|
||||
parts.append('<div class="legend">每张脸横向为 5 种发际线发型,每种发型分 '
|
||||
'<span class="tag">高清 is_hr=true</span>'
|
||||
'<span class="tag no">非高清 is_hr=false</span> 两列。</div>')
|
||||
|
||||
# 每张脸一个区块
|
||||
for stem in faces:
|
||||
orig_rel = f"orig/{stem}.jpg"
|
||||
parts.append('<div class="face-block">')
|
||||
parts.append(f' <div class="face-head">'
|
||||
f'<img class="orig-thumb zoomable" src="{url(orig_rel)}" '
|
||||
f'data-full="{url(orig_rel)}" alt="原图 {html.escape(stem)}">'
|
||||
f'<h2>{html.escape(stem)}</h2>'
|
||||
f'</div>')
|
||||
# 网格表头
|
||||
parts.append('<div class="grid">')
|
||||
parts.append('<div class="col-hdr"></div>')
|
||||
for h in hairstyles:
|
||||
parts.append(f'<div class="col-hdr" style="grid-column:span 2;">{html.escape(h["cn"])}<br><span style="opacity:.6">{html.escape(h["id"])}</span></div>')
|
||||
# 行:原图占满左侧第一列的高度由第一行承载;每行 = 一个 hr 选项
|
||||
for hro in hr_opts:
|
||||
hr_tag = hro["tag"]
|
||||
is_hr = hro["is_hr"]
|
||||
tag_cls = "" if is_hr else " no"
|
||||
tag_txt = "高清" if is_hr else "非高清"
|
||||
parts.append(f'<div class="row-lbl">{tag_txt}<span class="tag{tag_cls}">{hr_tag}</span></div>')
|
||||
for h in hairstyles:
|
||||
r = idx.get((stem, h["id"], hr_tag))
|
||||
if r and r["ok"]:
|
||||
ms = r["ms"]
|
||||
cap = f"{html.escape(h['cn'])} · {tag_txt} · {ms}ms"
|
||||
parts.append(
|
||||
f'<div class="cell"><img class="zoomable" src="{url(r["out"])}" '
|
||||
f'data-full="{url(r["out"])}" loading="lazy" '
|
||||
f'alt="{html.escape(stem)} {html.escape(h["cn"])} {hr_tag}">'
|
||||
f'<div class="cap">{ms}ms</div></div>')
|
||||
else:
|
||||
err = (r or {}).get("error", "未执行")
|
||||
parts.append(
|
||||
f'<div class="cell fail"><div class="failbox">{html.escape(err)}</div></div>')
|
||||
parts.append('</div>') # grid
|
||||
parts.append('</div>') # face-block
|
||||
|
||||
# 失败汇总
|
||||
fails = [r for r in results if not r["ok"]]
|
||||
if fails:
|
||||
parts.append('<div class="face-block">')
|
||||
parts.append(f'<h2>失败用例({len(fails)})</h2>')
|
||||
parts.append('<table class="fail-table"><thead><tr>'
|
||||
'<th>人脸</th><th>发型</th><th>高清</th><th>耗时(ms)</th><th>原因</th>'
|
||||
'</tr></thead><tbody>')
|
||||
for r in fails:
|
||||
parts.append(
|
||||
f'<tr><td>{html.escape(r["stem"])}</td>'
|
||||
f'<td>{html.escape(r["hair_cn"])}</td>'
|
||||
f'<td>{r["hr_tag"]}</td>'
|
||||
f'<td>{r["ms"]}</td>'
|
||||
f'<td>{html.escape(r["error"] or "")}</td></tr>')
|
||||
parts.append('</tbody></table></div>')
|
||||
|
||||
parts.append(f"""
|
||||
<footer>
|
||||
接口:<code>POST /api/v1/hairline/grow_v2</code> ·
|
||||
固定 multiband / mb_levels=5 / erode_cm=0.6 ·
|
||||
数据源 results.json · 点击任意图片可放大查看
|
||||
</footer>
|
||||
</main>
|
||||
<div id="lightbox"><img><div class="lb-cap"></div></div>
|
||||
<script>
|
||||
(function(){{
|
||||
var lb=document.getElementById('lightbox'),lbImg=lb.querySelector('img'),
|
||||
lbCap=lb.querySelector('.lb-cap');
|
||||
function open(src,cap){{
|
||||
lbImg.src=src; lbCap.textContent=cap||''; lb.classList.add('open');
|
||||
}}
|
||||
function close(){{ lb.classList.remove('open'); lbImg.src=''; }}
|
||||
document.addEventListener('click',function(e){{
|
||||
var t=e.target.closest('img.zoomable');
|
||||
if(t){{ open(t.dataset.full||t.src, t.alt||''); }}
|
||||
else if(e.target===lb||e.target===lbImg){{ close(); }}
|
||||
}});
|
||||
document.addEventListener('keydown',function(e){{
|
||||
if(e.key==='Escape') close();
|
||||
}});
|
||||
}})();
|
||||
</script>
|
||||
</body>
|
||||
</html>""")
|
||||
|
||||
with open(TARGET, "w", encoding="utf-8") as fh:
|
||||
fh.write("".join(parts))
|
||||
print(f"报告已生成:{TARGET}")
|
||||
print(f"成功 {ok}/{meta['total']},失败 {fail}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 296 KiB |