Merge branch 'main' of http://git.xiangsilian.com:3000/xsl/hair
This commit is contained in:
+29
@@ -53,3 +53,32 @@ static/report_hairline_v2.zip
|
|||||||
# local_test 运行期日志 / pid(不入 git)
|
# local_test 运行期日志 / pid(不入 git)
|
||||||
local_test/hair_service.log
|
local_test/hair_service.log
|
||||||
local_test/hair_service.pid
|
local_test/hair_service.pid
|
||||||
|
|
||||||
|
# benchmark 原始输出(含结果图+原图,体积大,不入 git)
|
||||||
|
benchmark_out/
|
||||||
|
|
||||||
|
# benchmark 部署的 HTML 报告(图片 base64 内嵌,体积大,不入 git)
|
||||||
|
static/hairstyle_thumbs/
|
||||||
|
|
||||||
|
# 网关运行期日志(不入 git)
|
||||||
|
gateway.log
|
||||||
|
|
||||||
|
# 工作流备份文件(不入 git)
|
||||||
|
*.json.bak.*
|
||||||
|
|
||||||
|
# 脸型测试素材(人像照片,体积大,不入 git;仅保留 6 张基准标注图)
|
||||||
|
face/test_img/脸型测试集合/
|
||||||
|
face/test_img/girl/
|
||||||
|
face/test_img/man/
|
||||||
|
|
||||||
|
# 脸型特征缓存(由 face/dump_features.py 生成,可随时重跑)
|
||||||
|
face/cache/
|
||||||
|
|
||||||
|
# 脸型报告输出(标注图体积大,不入 git)
|
||||||
|
static/face_shape_report/
|
||||||
|
static/face_shape_report.html
|
||||||
|
static/facetest_report/
|
||||||
|
static/facetest_report.html
|
||||||
|
static/facetest_all_report/
|
||||||
|
static/facetest_all_report.html
|
||||||
|
|
||||||
|
|||||||
+327
-1
@@ -1 +1,327 @@
|
|||||||
{"16": {"class_type": "UNETLoader", "inputs": {"unet_name": "flux-2-klein-4b-fp8.safetensors", "weight_dtype": "fp8_e4m3fn_fast"}}, "3": {"class_type": "VAELoader", "inputs": {"vae_name": "flux2-vae.safetensors"}}, "61": {"class_type": "CLIPLoader", "inputs": {"clip_name": "qwen_3_4b.safetensors", "type": "flux2", "device": "default"}}, "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"}}}
|
{
|
||||||
|
"16": {
|
||||||
|
"class_type": "UNETLoader",
|
||||||
|
"inputs": {
|
||||||
|
"unet_name": "flux-2-klein-4b-fp8.safetensors",
|
||||||
|
"weight_dtype": "fp8_e4m3fn_fast"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
"class_type": "VAELoader",
|
||||||
|
"inputs": {
|
||||||
|
"vae_name": "flux2-vae.safetensors"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"61": {
|
||||||
|
"class_type": "CLIPLoader",
|
||||||
|
"inputs": {
|
||||||
|
"clip_name": "qwen_3_4b.safetensors",
|
||||||
|
"type": "flux2",
|
||||||
|
"device": "cpu"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"26": {
|
||||||
|
"class_type": "LoadImage",
|
||||||
|
"inputs": {
|
||||||
|
"image": "placeholder.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"60": {
|
||||||
|
"class_type": "JjkText",
|
||||||
|
"inputs": {
|
||||||
|
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"22": {
|
||||||
|
"class_type": "CLIPTextEncode",
|
||||||
|
"inputs": {
|
||||||
|
"clip": [
|
||||||
|
"61",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"text": [
|
||||||
|
"60",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"31": {
|
||||||
|
"class_type": "easy imageSize",
|
||||||
|
"inputs": {
|
||||||
|
"image": [
|
||||||
|
"26",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"33": {
|
||||||
|
"class_type": "Mask Fill Holes",
|
||||||
|
"inputs": {
|
||||||
|
"masks": [
|
||||||
|
"26",
|
||||||
|
1
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"36": {
|
||||||
|
"class_type": "Convert Masks to Images",
|
||||||
|
"inputs": {
|
||||||
|
"masks": [
|
||||||
|
"33",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"39": {
|
||||||
|
"class_type": "ImageScale",
|
||||||
|
"inputs": {
|
||||||
|
"image": [
|
||||||
|
"36",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"upscale_method": "nearest-exact",
|
||||||
|
"width": [
|
||||||
|
"31",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"height": [
|
||||||
|
"31",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"crop": "disabled"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"37": {
|
||||||
|
"class_type": "Image To Mask",
|
||||||
|
"inputs": {
|
||||||
|
"image": [
|
||||||
|
"39",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"method": "intensity"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"32": {
|
||||||
|
"class_type": "LayerUtility: ImageScaleByAspectRatio V2",
|
||||||
|
"inputs": {
|
||||||
|
"image": [
|
||||||
|
"26",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"mask": [
|
||||||
|
"37",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"aspect_ratio": "custom",
|
||||||
|
"proportional_width": [
|
||||||
|
"31",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"proportional_height": [
|
||||||
|
"31",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"fit": "letterbox",
|
||||||
|
"method": "lanczos",
|
||||||
|
"round_to_multiple": "8",
|
||||||
|
"scale_to_side": "None",
|
||||||
|
"scale_to_length": 1024,
|
||||||
|
"background_color": "#000000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"44": {
|
||||||
|
"class_type": "ImageAndMaskPreview",
|
||||||
|
"inputs": {
|
||||||
|
"image": [
|
||||||
|
"32",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"mask": [
|
||||||
|
"32",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"mask_opacity": 1,
|
||||||
|
"mask_color": "FFFF00",
|
||||||
|
"pass_through": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"14": {
|
||||||
|
"class_type": "GetImageSize+",
|
||||||
|
"inputs": {
|
||||||
|
"image": [
|
||||||
|
"44",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"13": {
|
||||||
|
"class_type": "VAEEncode",
|
||||||
|
"inputs": {
|
||||||
|
"pixels": [
|
||||||
|
"44",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"vae": [
|
||||||
|
"3",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"class_type": "ModelSamplingFlux",
|
||||||
|
"inputs": {
|
||||||
|
"model": [
|
||||||
|
"16",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"max_shift": 1.15,
|
||||||
|
"base_shift": 0.5,
|
||||||
|
"width": [
|
||||||
|
"14",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"height": [
|
||||||
|
"14",
|
||||||
|
1
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"19": {
|
||||||
|
"class_type": "FluxGuidance",
|
||||||
|
"inputs": {
|
||||||
|
"conditioning": [
|
||||||
|
"22",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"guidance": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"5": {
|
||||||
|
"class_type": "ReferenceLatent",
|
||||||
|
"inputs": {
|
||||||
|
"conditioning": [
|
||||||
|
"19",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"latent": [
|
||||||
|
"13",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"7": {
|
||||||
|
"class_type": "EmptySD3LatentImage",
|
||||||
|
"inputs": {
|
||||||
|
"width": [
|
||||||
|
"14",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"height": [
|
||||||
|
"14",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"batch_size": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
"class_type": "BasicScheduler",
|
||||||
|
"inputs": {
|
||||||
|
"model": [
|
||||||
|
"2",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"scheduler": "simple",
|
||||||
|
"steps": 4,
|
||||||
|
"denoise": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"20": {
|
||||||
|
"class_type": "BasicGuider",
|
||||||
|
"inputs": {
|
||||||
|
"model": [
|
||||||
|
"2",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"conditioning": [
|
||||||
|
"5",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"6": {
|
||||||
|
"class_type": "RandomNoise",
|
||||||
|
"inputs": {
|
||||||
|
"noise_seed": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"8": {
|
||||||
|
"class_type": "KSamplerSelect",
|
||||||
|
"inputs": {
|
||||||
|
"sampler_name": "euler"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"9": {
|
||||||
|
"class_type": "SamplerCustomAdvanced",
|
||||||
|
"inputs": {
|
||||||
|
"noise": [
|
||||||
|
"6",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"guider": [
|
||||||
|
"20",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"sampler": [
|
||||||
|
"8",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"sigmas": [
|
||||||
|
"1",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"latent_image": [
|
||||||
|
"7",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"10": {
|
||||||
|
"class_type": "VAEDecode",
|
||||||
|
"inputs": {
|
||||||
|
"samples": [
|
||||||
|
"9",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"vae": [
|
||||||
|
"3",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"62": {
|
||||||
|
"class_type": "ColorMatch",
|
||||||
|
"inputs": {
|
||||||
|
"image_ref": [
|
||||||
|
"26",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"image_target": [
|
||||||
|
"10",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"method": "mkl",
|
||||||
|
"strength": 1,
|
||||||
|
"multithread": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"17": {
|
||||||
|
"class_type": "SaveImage",
|
||||||
|
"inputs": {
|
||||||
|
"images": [
|
||||||
|
"62",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"filename_prefix": "hair_inpaint"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
+450
-1
@@ -1 +1,450 @@
|
|||||||
{"1": {"inputs": {"scheduler": "simple", "steps": 4, "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": 217742615722421}, "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-4b-fp8.safetensors", "weight_dtype": "fp8_e4m3fn"}, "class_type": "UNETLoader", "_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-1781655058499.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": "Mask Fill Holes"}}, "36": {"inputs": {"masks": ["33", 0]}, "class_type": "Convert Masks to Images", "_meta": {"title": "Convert Masks to Images"}}, "37": {"inputs": {"method": "intensity", "image": ["39", 0]}, "class_type": "Image To Mask", "_meta": {"title": "Image To Mask"}}, "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": "ImageAndMaskPreview"}}, "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_zuixa_00119_.png&type=temp&subfolder=&rand=0.7926013627811991"}, {"name": "B", "selected": true, "url": "/api/view?filename=rgthree.compare._temp_zuixa_00120_.png&type=temp&subfolder=&rand=0.43692946883795813"}]}, "image_a": ["62", 0], "image_b": ["26", 0]}, "class_type": "Image Comparer (rgthree)", "_meta": {"title": "Image Comparer (rgthree)"}}, "60": {"inputs": {"text": "补充遮罩区补充遮罩区域内的头发,头发填满遮罩区域。发际线往下挡住额头"}, "class_type": "JjkText", "_meta": {"title": "Text"}}, "61": {"inputs": {"clip_name": "qwen_3_4b.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": {
|
||||||
|
"inputs": {
|
||||||
|
"scheduler": "simple",
|
||||||
|
"steps": 4,
|
||||||
|
"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": 217742615722421
|
||||||
|
},
|
||||||
|
"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-4b-fp8.safetensors",
|
||||||
|
"weight_dtype": "fp8_e4m3fn"
|
||||||
|
},
|
||||||
|
"class_type": "UNETLoader",
|
||||||
|
"_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-1781655058499.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": "Mask Fill Holes"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"36": {
|
||||||
|
"inputs": {
|
||||||
|
"masks": [
|
||||||
|
"33",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "Convert Masks to Images",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Convert Masks to Images"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"37": {
|
||||||
|
"inputs": {
|
||||||
|
"method": "intensity",
|
||||||
|
"image": [
|
||||||
|
"39",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "Image To Mask",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Image To Mask"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": "ImageAndMaskPreview"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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_zuixa_00119_.png&type=temp&subfolder=&rand=0.7926013627811991"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "B",
|
||||||
|
"selected": true,
|
||||||
|
"url": "/api/view?filename=rgthree.compare._temp_zuixa_00120_.png&type=temp&subfolder=&rand=0.43692946883795813"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"image_a": [
|
||||||
|
"62",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"image_b": [
|
||||||
|
"26",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "Image Comparer (rgthree)",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Image Comparer (rgthree)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"60": {
|
||||||
|
"inputs": {
|
||||||
|
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
||||||
|
},
|
||||||
|
"class_type": "JjkText",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Text"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"61": {
|
||||||
|
"inputs": {
|
||||||
|
"clip_name": "qwen_3_4b.safetensors",
|
||||||
|
"type": "flux2",
|
||||||
|
"device": "cpu"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -362,21 +362,36 @@ def _run_face_measure_data(image, variant="v1"):
|
|||||||
logger.warning("头发/耳朵分割失败,回退方案A:%s", seg_e)
|
logger.warning("头发/耳朵分割失败,回退方案A:%s", seg_e)
|
||||||
|
|
||||||
result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose)
|
result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose)
|
||||||
|
discarded = result.hairline_discarded
|
||||||
data = result.to_response()
|
data = result.to_response()
|
||||||
|
vd = result.vertical
|
||||||
if variant == "v6":
|
if variant == "v6":
|
||||||
vd = result.vertical
|
if discarded:
|
||||||
base_px = vd["upper_court_px"] + vd["middle_court_px"] + vd["lower_court_px"]
|
# 发际线弃用:接口6 的上庭也依赖发际线,一并置 null;只保留中/下庭。
|
||||||
# 接口6 是三庭:去掉顶庭相关字段(top_court_cm / ratios.top_court / landmarks.hair_top)
|
base_px = vd["middle_court_px"] + vd["lower_court_px"]
|
||||||
data["four_courts"]["ratios"] = {
|
data["four_courts"]["upper_court_cm"] = None
|
||||||
"upper_court": round(vd["upper_court_px"] / base_px, 3),
|
data["four_courts"]["ratios"] = {
|
||||||
"middle_court": round(vd["middle_court_px"] / base_px, 3),
|
"upper_court": None,
|
||||||
"lower_court": round(vd["lower_court_px"] / base_px, 3),
|
"middle_court": round(vd["middle_court_px"] / base_px, 3),
|
||||||
}
|
"lower_court": round(vd["lower_court_px"] / base_px, 3),
|
||||||
data["four_courts"].pop("top_court_cm", None)
|
}
|
||||||
data["face_total_height_cm"] = round(
|
data["four_courts"].pop("top_court_cm", None)
|
||||||
result.upper_cm + result.middle_cm + result.lower_cm, 2)
|
data["face_total_height_cm"] = round(
|
||||||
# 注:landmarks.hair_top 保留返回(供前端/下游定位头顶),但顶庭数值、
|
result.middle_cm + result.lower_cm, 2)
|
||||||
# 占比、标注图仍按三庭处理,显示效果不变。
|
data["landmarks"]["hairline"] = None
|
||||||
|
else:
|
||||||
|
base_px = vd["upper_court_px"] + vd["middle_court_px"] + vd["lower_court_px"]
|
||||||
|
# 接口6 是三庭:去掉顶庭相关字段(top_court_cm / ratios.top_court / landmarks.hair_top)
|
||||||
|
data["four_courts"]["ratios"] = {
|
||||||
|
"upper_court": round(vd["upper_court_px"] / base_px, 3),
|
||||||
|
"middle_court": round(vd["middle_court_px"] / base_px, 3),
|
||||||
|
"lower_court": round(vd["lower_court_px"] / base_px, 3),
|
||||||
|
}
|
||||||
|
data["four_courts"].pop("top_court_cm", None)
|
||||||
|
data["face_total_height_cm"] = round(
|
||||||
|
result.upper_cm + result.middle_cm + result.lower_cm, 2)
|
||||||
|
# 注:landmarks.hair_top 保留返回(供前端/下游定位头顶),但顶庭数值、
|
||||||
|
# 占比、标注图仍按三庭处理,显示效果不变。
|
||||||
|
|
||||||
# 七眼段宽度(cm)。eye1=左耳外段 eye2=左脸颊 eye3=左眼 eye4=两眼间距 eye5=右眼 eye6=右脸颊 eye7=右耳外段。
|
# 七眼段宽度(cm)。eye1=左耳外段 eye2=左脸颊 eye3=左眼 eye4=两眼间距 eye5=右眼 eye6=右脸颊 eye7=右耳外段。
|
||||||
# eye2~eye6(5段)只用内部分点,接口1/6 共用;eye1/eye7 需耳朵分割端线,仅接口1 有。
|
# eye2~eye6(5段)只用内部分点,接口1/6 共用;eye1/eye7 需耳朵分割端线,仅接口1 有。
|
||||||
@@ -391,11 +406,14 @@ def _run_face_measure_data(image, variant="v1"):
|
|||||||
data["seven_eyes"][f"eye{i + 2}"] = (
|
data["seven_eyes"][f"eye{i + 2}"] = (
|
||||||
None if (a is None or b is None) else round((b - a) / pc, 2))
|
None if (a is None or b is None) else round((b - a) / pc, 2))
|
||||||
if variant != "v6":
|
if variant != "v6":
|
||||||
# 接口1 额外算 eye1/eye7(左/右耳外段),需耳朵分割端线
|
# 接口1 额外算 eye1/eye7(左/右耳外段),需耳朵分割端线。
|
||||||
|
# 竖向范围:发际线弃用时用眉心做上界(hair_top 不可靠),否则用头顶。
|
||||||
from face_analysis.annotation import _ear_edges_from_mask
|
from face_analysis.annotation import _ear_edges_from_mask
|
||||||
|
top_y = (vd["brow_center"][1] if discarded
|
||||||
|
else vd["hair_top"][1])
|
||||||
head_l, head_r = _ear_edges_from_mask(
|
head_l, head_r = _ear_edges_from_mask(
|
||||||
ear_mask, hair_mask,
|
ear_mask, hair_mask,
|
||||||
result.vertical["hair_top"][1], result.vertical["chin_tip"][1],
|
top_y, vd["chin_tip"][1],
|
||||||
lcx, rcx, (lcx + rcx) / 2)
|
lcx, rcx, (lcx + rcx) / 2)
|
||||||
data["seven_eyes"]["eye1"] = (
|
data["seven_eyes"]["eye1"] = (
|
||||||
None if (head_l is None) else round((lcx - head_l) / pc, 2))
|
None if (head_l is None) else round((lcx - head_l) / pc, 2))
|
||||||
@@ -718,7 +736,7 @@ async def hair_grow(
|
|||||||
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填),如 1,2,3。female:1-5 male:1-4"),
|
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填),如 1,2,3。female:1-5 male:1-4"),
|
||||||
beauty_enabled: bool = Form(default=False, description="是否开启美颜(本期不生效)"),
|
beauty_enabled: bool = Form(default=False, description="是否开启美颜(本期不生效)"),
|
||||||
use_mask: bool = Form(default=True, description="是否启用 inpaint 遮罩(测试对比用)。false 时用干净原图生成(空遮罩,不烧模板线)"),
|
use_mask: bool = Form(default=True, description="是否启用 inpaint 遮罩(测试对比用)。false 时用干净原图生成(空遮罩,不烧模板线)"),
|
||||||
prompt: str = Form(default="补充遮罩区域的头发,加一点美颜", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||||
):
|
):
|
||||||
# 1. gender 必填校验(非法/缺失 → 1004)
|
# 1. gender 必填校验(非法/缺失 → 1004)
|
||||||
if gender not in ("male", "female"):
|
if gender not in ("male", "female"):
|
||||||
@@ -771,117 +789,15 @@ async def hair_grow(
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 接口 7:C 端生发 v2(add_hair2.json 工作流)
|
# 接口 7:C 端生发 v2 —— 已弃用(add_hair2.json 用 Klein-9b 大模型,会把常驻的
|
||||||
|
# Klein-4b/Flux 挤出显存,导致接口2/3/5 耗时抖动;且业务已不再调用)。
|
||||||
|
# 保留路由返回明确错误,避免老客户端拿到裸 404。
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_WORKFLOW2_PATH = os.path.join(os.path.dirname(__file__), "add_hair2.json")
|
@app.post("/api/v1/hair/grow-v2", include_in_schema=False, deprecated=True)
|
||||||
|
async def hair_grow_v2():
|
||||||
|
"""接口7 已弃用:请改用 /api/v1/hair/grow(接口2)。"""
|
||||||
@app.post(
|
return err(1007, "接口7(/api/v1/hair/grow-v2)已弃用,请使用 /api/v1/hair/grow")
|
||||||
"/api/v1/hair/grow-v2",
|
|
||||||
summary="接口7 C端生发 v2(add_hair2 工作流)",
|
|
||||||
tags=["生发"],
|
|
||||||
description=f"""
|
|
||||||
输入用户正面照 + **性别** + **发型序号**,使用 add_hair2.json 工作流生成指定发际线类型的预览图与生发图。
|
|
||||||
功能与接口2 完全一致,仅 ComfyUI 工作流不同。
|
|
||||||
|
|
||||||
{_image_fields_desc}
|
|
||||||
|
|
||||||
图片同时支持 `multipart/form-data` 文件上传(字段名 `image_file`)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
- **gender**(必填):`male` / `female`。决定返回的贴图集合(female 5 张 / male 4 张)。
|
|
||||||
非法或缺失返回 `1004`。
|
|
||||||
- **hair_style**(必填):`int`,发型序号。`female`:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave;
|
|
||||||
`male`:1=ellipse, 2=inverse_arc, 3=m, 4=straight。越界返回 `1007`。
|
|
||||||
- **beauty_enabled**:本期保留但不生效。
|
|
||||||
|
|
||||||
`hairline_type` 取值:`ellipse` / `flower` / `heart` / `straight` / `wave`(female),
|
|
||||||
`ellipse` / `m` / `straight` / `inverse_arc`(male)。
|
|
||||||
""",
|
|
||||||
responses={
|
|
||||||
200: {
|
|
||||||
"description": "成功",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"example": {
|
|
||||||
"code": 0,
|
|
||||||
"message": "success",
|
|
||||||
"request_id": "mock-request-id",
|
|
||||||
"data": {
|
|
||||||
"results": [
|
|
||||||
{"image_base64": "iVBORw0KGgo...", "hairline_type": "ellipse", "order": 1},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
400: {
|
|
||||||
"description": "参数错误 / 图片识别失败",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"examples": {
|
|
||||||
"图片参数错误": {"value": {"code": 1007, "message": "图片参数错误:必须且只能传 image_file / image_url / image_base64 其中一个", "request_id": "x", "data": None}},
|
|
||||||
"非正面照": {"value": {"code": 1003, "message": "角度问题,请上传正面照", "request_id": "x", "data": None}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def hair_grow_v2(
|
|
||||||
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG)"),
|
|
||||||
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
|
||||||
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
|
||||||
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
|
|
||||||
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填),如 1,2,3。female:1-5 male:1-4"),
|
|
||||||
beauty_enabled: bool = Form(default=False, description="是否开启美颜(本期不生效)"),
|
|
||||||
use_mask: bool = Form(default=True, description="是否启用 inpaint 遮罩(测试对比用)。false 时用干净原图生成(空遮罩,不烧模板线)"),
|
|
||||||
prompt: str = Form(default="补充遮罩区域的头发,加一点美颜", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
|
||||||
):
|
|
||||||
# 1. gender 必填校验(非法/缺失 → 1004)
|
|
||||||
if gender not in ("male", "female"):
|
|
||||||
return err(1004, "gender 必填且只能为 male / female")
|
|
||||||
|
|
||||||
# 2. hair_style 必填校验(解析逗号分隔,越界 → 1007)
|
|
||||||
max_styles = {"female": 5, "male": 4}[gender]
|
|
||||||
hair_styles = _parse_hair_styles(hair_style, max_styles)
|
|
||||||
if hair_styles is None:
|
|
||||||
return err(1007, f"hair_style 必填且为 1..{max_styles} 的整数(逗号分隔),收到 {hair_style!r}")
|
|
||||||
|
|
||||||
# 3. 三选一取图
|
|
||||||
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
|
|
||||||
if e is not None:
|
|
||||||
return e
|
|
||||||
|
|
||||||
image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
|
|
||||||
if image is None:
|
|
||||||
return err(1008, "图片格式不支持(仅 JPG / PNG)")
|
|
||||||
|
|
||||||
try:
|
|
||||||
from fastapi.concurrency import run_in_threadpool
|
|
||||||
from hairline.service import generate_grow_results
|
|
||||||
|
|
||||||
# 预览 + 生发(ComfyUI) 都是阻塞且较慢,放线程池避免卡住事件循环
|
|
||||||
items = await run_in_threadpool(generate_grow_results, image, gender, use_mask, prompt, hair_styles, _WORKFLOW2_PATH)
|
|
||||||
if items is None:
|
|
||||||
return err(1001, "无法识别人像")
|
|
||||||
|
|
||||||
results = []
|
|
||||||
for p in items:
|
|
||||||
results.append({
|
|
||||||
"image_base64": _jpg_b64(p["image_bgr"]), # 预览图 JPG
|
|
||||||
"grown_image_base64": (_png_to_jpg_b64(p["grown_png"]) # 生发图 JPG
|
|
||||||
if p["grown_png"] else None),
|
|
||||||
"hairline_type": p["hairline_type"],
|
|
||||||
"order": p["order"],
|
|
||||||
})
|
|
||||||
return ok({"results": results})
|
|
||||||
except Exception as ex: # noqa: BLE001
|
|
||||||
logger.exception("接口7 处理异常")
|
|
||||||
return err(1007, f"处理失败:{ex}")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -931,7 +847,7 @@ async def hair_grow_b(
|
|||||||
marked_image_url: Optional[str] = Form(default=None, description="划线图片 URL"),
|
marked_image_url: Optional[str] = Form(default=None, description="划线图片 URL"),
|
||||||
marked_image_base64: Optional[str] = Form(default=None, description="划线图片 base64"),
|
marked_image_base64: Optional[str] = Form(default=None, description="划线图片 base64"),
|
||||||
use_mask: bool = Form(default=True, description="是否画发际线(测试对比用)。false 时跳过划线检测、直接送划线图"),
|
use_mask: bool = Form(default=True, description="是否画发际线(测试对比用)。false 时跳过划线检测、直接送划线图"),
|
||||||
prompt: str = Form(default="补充遮罩区域的头发,加一点美颜", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||||
):
|
):
|
||||||
# 划线图三选一取图(只需这一张)
|
# 划线图三选一取图(只需这一张)
|
||||||
marked_raw, e = await resolve_image_bytes(marked_image_file, marked_image_url, marked_image_base64)
|
marked_raw, e = await resolve_image_bytes(marked_image_file, marked_image_url, marked_image_base64)
|
||||||
@@ -1062,6 +978,8 @@ async def face_features(
|
|||||||
`female`:1=ellipse,2=flower,3=heart,4=straight,5=wave;`male`:1=ellipse,2=inverse_arc,3=m,4=straight。
|
`female`:1=ellipse,2=flower,3=heart,4=straight,5=wave;`male`:1=ellipse,2=inverse_arc,3=m,4=straight。
|
||||||
- 可选 `use_mask` / `prompt`:同接口2 的生发控制参数。
|
- 可选 `use_mask` / `prompt`:同接口2 的生发控制参数。
|
||||||
注:生发黑模板固定取 `hairline_texture_black/`(middle 档),即三档叠图分别用各自贴图、但生发目标固定 middle。
|
注:生发黑模板固定取 `hairline_texture_black/`(middle 档),即三档叠图分别用各自贴图、但生发目标固定 middle。
|
||||||
|
- 可选 `generate_grow_image`(默认 `true`):是否生成生发效果图(ComfyUI 生发,全流程最耗时)。
|
||||||
|
`false` 时跳过生发,各发型 `grown_image_*` 恒为 `null`,仅返回三档发际线叠图与中心点,大幅降低耗时。
|
||||||
|
|
||||||
**返回说明**:
|
**返回说明**:
|
||||||
|
|
||||||
@@ -1139,7 +1057,8 @@ async def hairline_generate(
|
|||||||
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
|
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
|
||||||
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填,如 1,2,3)。female:1-5 male:1-4"),
|
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填,如 1,2,3)。female:1-5 male:1-4"),
|
||||||
use_mask: bool = Form(default=True, description="生发是否启用 inpaint 遮罩(同接口2,测试对比用)"),
|
use_mask: bool = Form(default=True, description="生发是否启用 inpaint 遮罩(同接口2,测试对比用)"),
|
||||||
prompt: str = Form(default="补充遮罩区域的头发,加一点美颜", description="ComfyUI 提示词(同接口2),会替换工作流节点60的文本"),
|
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", description="ComfyUI 提示词(同接口2),会替换工作流节点60的文本"),
|
||||||
|
generate_grow_image: bool = Form(default=True, description="是否生成生发效果图(ComfyUI 生发,最耗时)。默认 true 出图;false 时跳过生发,各发型 grown_image 恒为 null,仅返回三档发际线叠图与中心点"),
|
||||||
):
|
):
|
||||||
if gender not in ("male", "female"):
|
if gender not in ("male", "female"):
|
||||||
return err(1004, "gender 必填且只能为 male / female")
|
return err(1004, "gender 必填且只能为 male / female")
|
||||||
@@ -1163,7 +1082,8 @@ async def hairline_generate(
|
|||||||
from hairline.service import generate_hairline_pngs
|
from hairline.service import generate_hairline_pngs
|
||||||
|
|
||||||
res = await run_in_threadpool(
|
res = await run_in_threadpool(
|
||||||
generate_hairline_pngs, image, gender, hair_styles, use_mask, prompt)
|
generate_hairline_pngs, image, gender, hair_styles, use_mask, prompt,
|
||||||
|
generate_grow_image=generate_grow_image)
|
||||||
if res is None:
|
if res is None:
|
||||||
return err(1001, "无法识别人像")
|
return err(1001, "无法识别人像")
|
||||||
|
|
||||||
@@ -1531,7 +1451,7 @@ async def hairline_grow_v2(
|
|||||||
inpainting_fill: int = Form(default=1, description="change_hair服务端重绘填充:0=保留原图 | 1=噪声 | 2=纯色 | 3=潜变量。默认 1"),
|
inpainting_fill: int = Form(default=1, description="change_hair服务端重绘填充:0=保留原图 | 1=噪声 | 2=纯色 | 3=潜变量。默认 1"),
|
||||||
mask_blur: int = Form(default=11, description="change_hair服务端遮罩边缘模糊像素,默认 11"),
|
mask_blur: int = Form(default=11, description="change_hair服务端遮罩边缘模糊像素,默认 11"),
|
||||||
mask_dilate_scale: float = Form(default=1.0, description="change_hair服务端遮罩膨胀缩放,默认 1.0"),
|
mask_dilate_scale: float = Form(default=1.0, description="change_hair服务端遮罩膨胀缩放,默认 1.0"),
|
||||||
comfyui_prompt: Optional[str] = Form(default=None, description="Flux-2 重绘提示词,None 用默认「补充遮罩区域的头发,加一点美颜」"),
|
comfyui_prompt: Optional[str] = Form(default=None, description="Flux-2 重绘提示词,None 用默认「填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜」"),
|
||||||
beauty_alpha: float = Form(default=0.6, description="redraw_band 版 band 外的全脸美颜融入强度(0=band外无美颜纯用final,1≈整帧版),默认 0.6"),
|
beauty_alpha: float = Form(default=0.6, description="redraw_band 版 band 外的全脸美颜融入强度(0=band外无美颜纯用final,1≈整帧版),默认 0.6"),
|
||||||
band_lo_mult: float = Form(default=0.5, description="重绘带外推倍率下限(相对 hairline_push_cm,内轮廓=0×、原外推线=1.0×),默认 0.5"),
|
band_lo_mult: float = Form(default=0.5, description="重绘带外推倍率下限(相对 hairline_push_cm,内轮廓=0×、原外推线=1.0×),默认 0.5"),
|
||||||
band_hi_mult: float = Form(default=1.5, description="重绘带外推倍率上限(相对 hairline_push_cm),默认 1.5"),
|
band_hi_mult: float = Form(default=1.5, description="重绘带外推倍率上限(相对 hairline_push_cm),默认 1.5"),
|
||||||
@@ -1697,7 +1617,7 @@ async def hairline_grow_v2_final_v2(
|
|||||||
async def api_redraw(
|
async def api_redraw(
|
||||||
image_file: UploadFile = File(..., description="人物图片(JPG/PNG)"),
|
image_file: UploadFile = File(..., description="人物图片(JPG/PNG)"),
|
||||||
mask_file: UploadFile = File(..., description="遮罩图片(PNG,支持红/白/alpha 格式)"),
|
mask_file: UploadFile = File(..., description="遮罩图片(PNG,支持红/白/alpha 格式)"),
|
||||||
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮",
|
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜",
|
||||||
description="ComfyUI 提示词"),
|
description="ComfyUI 提示词"),
|
||||||
):
|
):
|
||||||
image_bytes = await image_file.read()
|
image_bytes = await image_file.read()
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ def call_iface2(image_path, hair_style):
|
|||||||
with open(image_path, "rb") as f:
|
with open(image_path, "rb") as f:
|
||||||
img_data = f.read()
|
img_data = f.read()
|
||||||
fields = {"gender": "female", "hair_style": str(hair_style),
|
fields = {"gender": "female", "hair_style": str(hair_style),
|
||||||
"prompt": "填充遮罩区域的头发,皮肤加一点磨皮"}
|
"prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"}
|
||||||
body, boundary = _multipart(
|
body, boundary = _multipart(
|
||||||
fields, {"image_file": (os.path.basename(image_path), img_data, "image/jpeg")})
|
fields, {"image_file": (os.path.basename(image_path), img_data, "image/jpeg")})
|
||||||
req = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow", data=body, method="POST")
|
req = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow", data=body, method="POST")
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ def call_iface2_female(image_path, hair_style):
|
|||||||
with open(image_path, "rb") as f:
|
with open(image_path, "rb") as f:
|
||||||
img_data = f.read()
|
img_data = f.read()
|
||||||
fields = {"gender": "female", "hair_style": str(hair_style),
|
fields = {"gender": "female", "hair_style": str(hair_style),
|
||||||
"prompt": "填充遮罩区域的头发,皮肤加一点磨皮"}
|
"prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"}
|
||||||
body, boundary = _multipart(
|
body, boundary = _multipart(
|
||||||
fields, {"image_file": (os.path.basename(image_path), img_data, "image/jpeg")})
|
fields, {"image_file": (os.path.basename(image_path), img_data, "image/jpeg")})
|
||||||
req = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow", data=body, method="POST")
|
req = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow", data=body, method="POST")
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ def call_api2(image_path, gender, hair_style="1"):
|
|||||||
with open(image_path, "rb") as f:
|
with open(image_path, "rb") as f:
|
||||||
img_data = f.read()
|
img_data = f.read()
|
||||||
fields = {"gender": gender, "hair_style": hair_style, "use_mask": "0",
|
fields = {"gender": gender, "hair_style": hair_style, "use_mask": "0",
|
||||||
"prompt": "填充遮罩区域的头发,皮肤加一点磨皮"}
|
"prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"}
|
||||||
body, boundary = _multipart(fields, {"image_file": (os.path.basename(image_path), img_data, "image/jpeg")})
|
body, boundary = _multipart(fields, {"image_file": (os.path.basename(image_path), img_data, "image/jpeg")})
|
||||||
req = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow", data=body, method="POST")
|
req = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow", data=body, method="POST")
|
||||||
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
|
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
|
||||||
@@ -107,7 +107,7 @@ def call_api3(image_path):
|
|||||||
"""接口3:B端生发(use_mask=False,直接送图)"""
|
"""接口3:B端生发(use_mask=False,直接送图)"""
|
||||||
with open(image_path, "rb") as f:
|
with open(image_path, "rb") as f:
|
||||||
img_data = f.read()
|
img_data = f.read()
|
||||||
fields = {"use_mask": "true", "prompt": "补充遮罩区域的头发,加一点美颜"}
|
fields = {"use_mask": "true", "prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"}
|
||||||
body, boundary = _multipart(fields, {"marked_image_file": (os.path.basename(image_path), img_data, "image/jpeg")})
|
body, boundary = _multipart(fields, {"marked_image_file": (os.path.basename(image_path), img_data, "image/jpeg")})
|
||||||
req = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow-b", data=body, method="POST")
|
req = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow-b", data=body, method="POST")
|
||||||
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
|
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
|
||||||
|
|||||||
+9
-57
@@ -20,7 +20,6 @@
|
|||||||
| 3 B 端生发 | POST | `/api/v1/hair/grow-b` |
|
| 3 B 端生发 | POST | `/api/v1/hair/grow-b` |
|
||||||
| 4 用户特征 | POST | `/api/v1/face/features` |
|
| 4 用户特征 | POST | `/api/v1/face/features` |
|
||||||
| 5 发际线 PNG 生成 | POST | `/api/v1/hairline/generate` |
|
| 5 发际线 PNG 生成 | POST | `/api/v1/hairline/generate` |
|
||||||
| 7 C 端生发 v2 | POST | `/api/v1/hair/grow-v2` |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -87,6 +86,7 @@
|
|||||||
| 1006 | 文件超出大小限制 | 单文件超过 1 MB |
|
| 1006 | 文件超出大小限制 | 单文件超过 1 MB |
|
||||||
| 1007 | 图片参数错误 | file / url / base64 未传,或同时传了多个(三者严格互斥) |
|
| 1007 | 图片参数错误 | file / url / base64 未传,或同时传了多个(三者严格互斥) |
|
||||||
| 1008 | 图片格式不支持 | 非 JPG / PNG |
|
| 1008 | 图片格式不支持 | 非 JPG / PNG |
|
||||||
|
| 1009 | 未授权 | 缺少或错误的 `X-Internal-Token`(`/api/*` 路径鉴权) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -109,6 +109,8 @@
|
|||||||
| four_courts | object | 四庭数据,见下表 |
|
| four_courts | object | 四庭数据,见下表 |
|
||||||
| seven_eyes | object | 七眼数据,见下表 |
|
| seven_eyes | object | 七眼数据,见下表 |
|
||||||
| landmarks | object | 关键分界点坐标(头顶 / 发际线 / 眉心 / 鼻翼下缘 / 下巴尖),原图像素坐标 |
|
| landmarks | object | 关键分界点坐标(头顶 / 发际线 / 眉心 / 鼻翼下缘 / 下巴尖),原图像素坐标 |
|
||||||
|
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` |
|
||||||
|
| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:`{ "x": int, "y": int }` |
|
||||||
|
|
||||||
`four_courts`(四庭,自上而下):
|
`four_courts`(四庭,自上而下):
|
||||||
|
|
||||||
@@ -211,6 +213,8 @@
|
|||||||
| four_courts | object | 三庭数据(上/中/下庭,各含 cm 与 ratio;**无顶庭**) |
|
| four_courts | object | 三庭数据(上/中/下庭,各含 cm 与 ratio;**无顶庭**) |
|
||||||
| seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距 cm + 占比 ratios + **eye2~eye6** 共 5 段宽度) |
|
| seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距 cm + 占比 ratios + **eye2~eye6** 共 5 段宽度) |
|
||||||
| landmarks | object | 四个关键点像素坐标(发际线/眉心/鼻翼下缘/下巴尖) |
|
| landmarks | object | 四个关键点像素坐标(发际线/眉心/鼻翼下缘/下巴尖) |
|
||||||
|
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` |
|
||||||
|
| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:`{ "x": int, "y": int }` |
|
||||||
|
|
||||||
> 接口6 是**三庭五眼**:`four_courts`/`landmarks` 不含顶庭与头顶点(无 `top_court_cm`/`hair_top`);`seven_eyes` 只含 **eye2~eye6**(左脸颊/左眼/两眼间距/右眼/右脸颊,5 段),**无 eye1/eye7**(耳外段需头发轮廓端线,仅接口1 有)。
|
> 接口6 是**三庭五眼**:`four_courts`/`landmarks` 不含顶庭与头顶点(无 `top_court_cm`/`hair_top`);`seven_eyes` 只含 **eye2~eye6**(左脸颊/左眼/两眼间距/右眼/右脸颊,5 段),**无 eye1/eye7**(耳外段需头发轮廓端线,仅接口1 有)。
|
||||||
|
|
||||||
@@ -405,6 +409,7 @@
|
|||||||
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),决定返回哪些发际线类型。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight。缺失/越界/非法返回 `1007` |
|
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),决定返回哪些发际线类型。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight。缺失/越界/非法返回 `1007` |
|
||||||
| use_mask | bool | 否 | 生发是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
|
| use_mask | bool | 否 | 生发是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
|
||||||
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
|
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
|
||||||
|
| generate_grow_image | bool | 否 | 是否生成生发效果图(ComfyUI 生发,全流程最耗时),默认 `true`。传 `false` 时跳过生发,各发型 `grown_image_*` 恒为 `null`,仅返回三档发际线叠图与中心点,可大幅降低耗时 |
|
||||||
|
|
||||||
> ⚠️ 三档叠图分别用 `hairline_texture` / `hairline_texture_high` / `hairline_texture_low` 三套同名贴图;**生发黑模板固定取自 `hairline_texture_black/`(middle 档)**,即生发目标固定压到 middle 档,每个发型仅 1 张生发图。
|
> ⚠️ 三档叠图分别用 `hairline_texture` / `hairline_texture_high` / `hairline_texture_low` 三套同名贴图;**生发黑模板固定取自 `hairline_texture_black/`(middle 档)**,即生发目标固定压到 middle 档,每个发型仅 1 张生发图。
|
||||||
|
|
||||||
@@ -426,7 +431,7 @@
|
|||||||
| image_middle_url | string | middle 档发际线曲线**透明 PNG** URL(仅曲线,透明底,**不含人物**,需叠加原图显示) |
|
| image_middle_url | string | middle 档发际线曲线**透明 PNG** URL(仅曲线,透明底,**不含人物**,需叠加原图显示) |
|
||||||
| image_high_url | string | high 档发际线曲线**透明 PNG** URL(同上,high 档曲线) |
|
| image_high_url | string | high 档发际线曲线**透明 PNG** URL(同上,high 档曲线) |
|
||||||
| image_low_url | string | low 档发际线曲线**透明 PNG** URL(同上,low 档曲线) |
|
| image_low_url | string | low 档发际线曲线**透明 PNG** URL(同上,low 档曲线) |
|
||||||
| grown_image_url | string \| null | **生发后图片** URL(ComfyUI「植发」效果图,完整人像照片,生发失败时为 `null`) |
|
| grown_image_url | string \| null | **生发后图片** URL(ComfyUI「植发」效果图,完整人像照片,生发失败或 `generate_grow_image=false` 时为 `null`) |
|
||||||
| order | int | 发型序号(= 传入的 hair_style 值) |
|
| order | int | 发型序号(= 传入的 hair_style 值) |
|
||||||
|
|
||||||
> worker 侧返回 `image_middle_base64` / `image_high_base64` / `image_low_base64` / `grown_image_base64`,网关落盘后改写为上表对应的 `*_url`。
|
> worker 侧返回 `image_middle_base64` / `image_high_base64` / `image_low_base64` / `grown_image_base64`,网关落盘后改写为上表对应的 `*_url`。
|
||||||
@@ -443,6 +448,8 @@
|
|||||||
| landmarks | object | 5 个纵向关键点像素坐标(hair_top/hairline/brow_center/nose_bottom/chin_tip),结构同接口1 |
|
| landmarks | object | 5 个纵向关键点像素坐标(hair_top/hairline/brow_center/nose_bottom/chin_tip),结构同接口1 |
|
||||||
| hairline_source | string | 发际线来源:`segmentation`(真实分割)/ `estimated`(比例估算) |
|
| hairline_source | string | 发际线来源:`segmentation`(真实分割)/ `estimated`(比例估算) |
|
||||||
| head_pose | object | 头部姿态角度(yaw/pitch/roll,单位:度) |
|
| head_pose | object | 头部姿态角度(yaw/pitch/roll,单位:度) |
|
||||||
|
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` |
|
||||||
|
| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:`{ "x": int, "y": int }` |
|
||||||
|
|
||||||
> `eye1`~`eye7` 为从左到右共 7 段宽度,eye1=左耳外段、eye7=右耳外段,某侧耳朵不可见时对应段为 `null`。详见接口1说明。
|
> `eye1`~`eye7` 为从左到右共 7 段宽度,eye1=左耳外段、eye7=右耳外段,某侧耳朵不可见时对应段为 `null`。详见接口1说明。
|
||||||
|
|
||||||
@@ -508,60 +515,6 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 接口 7:C 端生发 v2 接口
|
|
||||||
|
|
||||||
**说明**:功能与[接口 2](#接口-2c-端生发接口)完全一致,仅 ComfyUI 工作流不同——使用 `add_hair2.json` 替代 `add_hair.json`。
|
|
||||||
|
|
||||||
**请求**:`POST /api/v1/hair/grow-v2`
|
|
||||||
|
|
||||||
### 输入
|
|
||||||
|
|
||||||
与接口 2 完全相同。图片参数见「通用约定 → 图片传参字段」。专属参数:
|
|
||||||
|
|
||||||
| 参数 | 类型 | 必填 | 说明 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| gender | string | **是** | 性别:`male` / `female`。决定使用的发际线贴图集合 |
|
|
||||||
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`)。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight。越界/非法返回 `1007` |
|
|
||||||
| beauty_enabled | bool | 否 | 生发图是否带美颜效果,默认 false(当前阶段不生效) |
|
|
||||||
| use_mask | bool | 否 | 是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线) |
|
|
||||||
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
|
|
||||||
|
|
||||||
### 输出(data)
|
|
||||||
|
|
||||||
与接口 2 完全相同。`results`:发际线方案数组,**数量 = 所选发型数**。每个元素:
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| image_url | string | 发际线曲线**透明 PNG** URL(仅曲线,透明底,**不含人物**,需叠加原图显示) |
|
|
||||||
| grown_image_url | string | **生发后图片** URL(ComfyUI/Flux「植发 3 个月」效果图,完整人像照片) |
|
|
||||||
| hairline_type | string | 发际线类型 key |
|
|
||||||
| order | int | 排序序号 |
|
|
||||||
|
|
||||||
> ⚠️ 与接口 2 的区别:本接口使用 `add_hair2.json` 工作流(Flux-2 Klein 9b),输入/遮罩节点同为 26,
|
|
||||||
> SaveImage 输出节点为 75。
|
|
||||||
|
|
||||||
### 响应示例
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 0,
|
|
||||||
"message": "success",
|
|
||||||
"request_id": "mock-request-id",
|
|
||||||
"data": {
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"image_url": "https://hair.xiangsilian.com/static/sample.jpg",
|
|
||||||
"grown_image_url": "https://hair.xiangsilian.com/static/sample.jpg",
|
|
||||||
"hairline_type": "ellipse",
|
|
||||||
"order": 1
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 汇总:输入输出一览
|
## 汇总:输入输出一览
|
||||||
|
|
||||||
| 接口 | 输入 | 主要输出 |
|
| 接口 | 输入 | 主要输出 |
|
||||||
@@ -572,7 +525,6 @@
|
|||||||
| 3 B 端生发 | 划线图片 | 最合适发际线图片 + 生发后图片 |
|
| 3 B 端生发 | 划线图片 | 最合适发际线图片 + 生发后图片 |
|
||||||
| 4 用户特征 | 用户照片 | 6 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格) |
|
| 4 用户特征 | 用户照片 | 6 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格) |
|
||||||
| 5 发际线 PNG | 用户照片 + gender + hair_style(多选) | 每个选中发型 middle/high/low 三档发际线叠图 + 生发图 + 最合适发际线面部中间点坐标 |
|
| 5 发际线 PNG | 用户照片 + gender + hair_style(多选) | 每个选中发型 middle/high/low 三档发际线叠图 + 生发图 + 最合适发际线面部中间点坐标 |
|
||||||
| 7 C 端生发 v2 | 用户照片 + gender + hair_style | 同接口2,使用 add_hair2.json 工作流 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,506 @@
|
|||||||
|
"""
|
||||||
|
build_dataset_report.py
|
||||||
|
对任意图片目录(可含多层子目录)批量预测脸型并生成 HTML 报告。
|
||||||
|
保留图片原始所属的子目录名作为「分组」,在报告中按分组展示与统计。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
./venv/bin/python face/build_dataset_report.py --src <图片目录> [--sample 50] [--seed 42]
|
||||||
|
|
||||||
|
示例:
|
||||||
|
./venv/bin/python face/build_dataset_report.py \
|
||||||
|
--src face/test_img/脸型测试集合 --sample 50 --name 脸型测试集合
|
||||||
|
|
||||||
|
输出:
|
||||||
|
static/<slug>_report.html
|
||||||
|
static/<slug>_report/images/*.jpg
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import html
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import unicodedata
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from face.face_shape_classifier import classify_from_image # noqa: E402
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
|
||||||
|
MAX_IMAGE_SIDE = 900
|
||||||
|
JPEG_QUALITY = 88
|
||||||
|
|
||||||
|
SHAPE_ORDER = ["圆形脸", "心形脸", "菱形脸", "鹅蛋脸", "方形脸", "长形脸", "瓜子脸"]
|
||||||
|
SHAPE_COLORS = {
|
||||||
|
"圆形脸": "#e67e22",
|
||||||
|
"心形脸": "#e74c3c",
|
||||||
|
"菱形脸": "#9b59b6",
|
||||||
|
"鹅蛋脸": "#27ae60",
|
||||||
|
"方形脸": "#2980b9",
|
||||||
|
"长形脸": "#16a085",
|
||||||
|
"瓜子脸": "#c0392b",
|
||||||
|
"检测失败": "#7f8c8d",
|
||||||
|
}
|
||||||
|
# 数据集分组名与分类器脸型口径的近似对应(仅用于交叉表高亮参考,非严格标签)
|
||||||
|
TAXONOMY_EQUIV = {
|
||||||
|
"方形脸": "方形脸",
|
||||||
|
"长形脸": "长形脸",
|
||||||
|
"瓜子脸": "瓜子脸",
|
||||||
|
"标准脸": "鹅蛋脸",
|
||||||
|
"娃娃脸": "圆形脸",
|
||||||
|
}
|
||||||
|
|
||||||
|
FEATURE_KEYS = [
|
||||||
|
"face_width",
|
||||||
|
"face_height",
|
||||||
|
"jaw_angle",
|
||||||
|
"taper_ratio",
|
||||||
|
"forehead_ratio",
|
||||||
|
"cheekbone_ratio",
|
||||||
|
"jaw_ratio",
|
||||||
|
"chin_ratio",
|
||||||
|
"chin_sharpness",
|
||||||
|
"width_uniformity",
|
||||||
|
"face_curve_score",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def natural_key(text: str):
|
||||||
|
parts = re.split(r"(\d+)", text)
|
||||||
|
return [int(p) if p.isdigit() else p for p in parts]
|
||||||
|
|
||||||
|
|
||||||
|
def collect_images(src: Path) -> List[Path]:
|
||||||
|
return sorted(
|
||||||
|
(p for p in src.rglob("*") if p.suffix.lower() in IMAGE_SUFFIXES),
|
||||||
|
key=lambda p: natural_key(str(p.relative_to(src))),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def group_of(path: Path, src: Path) -> str:
|
||||||
|
"""图片相对根目录的父目录名;直接位于根目录则记为「根目录」。"""
|
||||||
|
rel = path.relative_to(src).parent
|
||||||
|
return str(rel) if str(rel) != "." else "(根目录)"
|
||||||
|
|
||||||
|
|
||||||
|
def ascii_slug(text: str, fallback: str) -> str:
|
||||||
|
"""生成安全的 ASCII 文件名片段(中文目录名转拼音不可靠,直接编号兜底)。"""
|
||||||
|
norm = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
|
||||||
|
norm = re.sub(r"[^A-Za-z0-9_-]+", "_", norm).strip("_")
|
||||||
|
return norm or fallback
|
||||||
|
|
||||||
|
|
||||||
|
def stratified_sample(
|
||||||
|
images: List[Path], src: Path, total: int, seed: int, min_per_group: int
|
||||||
|
) -> List[Path]:
|
||||||
|
"""
|
||||||
|
按分组分层抽样:先保证每组至少 min_per_group 张,剩余名额按组大小比例分配。
|
||||||
|
小分组(如只有 3 张的梨形脸)在纯随机抽样下几乎必然缺席,分层可保证覆盖。
|
||||||
|
"""
|
||||||
|
rng = random.Random(seed)
|
||||||
|
buckets: Dict[str, List[Path]] = defaultdict(list)
|
||||||
|
for p in images:
|
||||||
|
buckets[group_of(p, src)].append(p)
|
||||||
|
|
||||||
|
groups = sorted(buckets, key=natural_key)
|
||||||
|
quota = {g: min(min_per_group, len(buckets[g])) for g in groups}
|
||||||
|
|
||||||
|
remaining = total - sum(quota.values())
|
||||||
|
if remaining > 0:
|
||||||
|
spare = {g: len(buckets[g]) - quota[g] for g in groups}
|
||||||
|
pool = sum(spare.values())
|
||||||
|
if pool > 0:
|
||||||
|
# 按剩余可选量比例分配,再把取整误差补给最大的分组
|
||||||
|
extra = {g: int(remaining * spare[g] / pool) for g in groups}
|
||||||
|
for g in sorted(groups, key=lambda g: -spare[g]):
|
||||||
|
if sum(extra.values()) >= remaining:
|
||||||
|
break
|
||||||
|
if extra[g] < spare[g]:
|
||||||
|
extra[g] += 1
|
||||||
|
for g in groups:
|
||||||
|
quota[g] += min(extra[g], spare[g])
|
||||||
|
|
||||||
|
chosen: List[Path] = []
|
||||||
|
for g in groups:
|
||||||
|
chosen.extend(rng.sample(buckets[g], min(quota[g], len(buckets[g]))))
|
||||||
|
return chosen
|
||||||
|
|
||||||
|
|
||||||
|
def analyze(
|
||||||
|
src: Path, sample: int, seed: int, img_dir: Path, min_per_group: int
|
||||||
|
) -> List[Dict]:
|
||||||
|
all_images = collect_images(src)
|
||||||
|
if not all_images:
|
||||||
|
raise SystemExit(f"目录中没有图片: {src}")
|
||||||
|
|
||||||
|
if sample and sample < len(all_images):
|
||||||
|
if min_per_group > 0:
|
||||||
|
chosen = stratified_sample(all_images, src, sample, seed, min_per_group)
|
||||||
|
else:
|
||||||
|
chosen = random.Random(seed).sample(all_images, sample)
|
||||||
|
chosen.sort(key=lambda p: natural_key(str(p.relative_to(src))))
|
||||||
|
else:
|
||||||
|
chosen = all_images
|
||||||
|
|
||||||
|
mode = f"分层抽样,每组至少 {min_per_group} 张" if min_per_group > 0 else "纯随机抽样"
|
||||||
|
print(f"共发现 {len(all_images)} 张图片,本次测试 {len(chosen)} 张({mode},seed={seed})\n")
|
||||||
|
|
||||||
|
if img_dir.exists():
|
||||||
|
shutil.rmtree(img_dir)
|
||||||
|
img_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
group_slugs: Dict[str, str] = {}
|
||||||
|
rows: List[Dict] = []
|
||||||
|
|
||||||
|
for idx, path in enumerate(chosen, 1):
|
||||||
|
group = group_of(path, src)
|
||||||
|
if group not in group_slugs:
|
||||||
|
group_slugs[group] = ascii_slug(group, f"g{len(group_slugs) + 1}")
|
||||||
|
out_name = f"{group_slugs[group]}_{idx:03d}.jpg"
|
||||||
|
|
||||||
|
item = {
|
||||||
|
"index": idx,
|
||||||
|
"group": group,
|
||||||
|
"file": path.name,
|
||||||
|
"rel_path": str(path.relative_to(src)),
|
||||||
|
# 相对 static/ 的路径(报告 HTML 也放在 static/ 根下)
|
||||||
|
"img_src": f"{img_dir.relative_to(ROOT / 'static').as_posix()}/{out_name}",
|
||||||
|
"ok": False,
|
||||||
|
"predicted": None,
|
||||||
|
"display": None,
|
||||||
|
"confidence": None,
|
||||||
|
"score": None,
|
||||||
|
"top3": [],
|
||||||
|
"features": {},
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = classify_from_image(path, return_details=True, return_annotated=True)
|
||||||
|
annotated = result["annotated"]
|
||||||
|
h, w = annotated.shape[:2]
|
||||||
|
if max(h, w) > MAX_IMAGE_SIDE:
|
||||||
|
scale = MAX_IMAGE_SIDE / max(h, w)
|
||||||
|
annotated = cv2.resize(
|
||||||
|
annotated, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA
|
||||||
|
)
|
||||||
|
cv2.imwrite(str(img_dir / out_name), annotated, [int(cv2.IMWRITE_JPEG_QUALITY), JPEG_QUALITY])
|
||||||
|
|
||||||
|
item.update(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"predicted": result["face_shape"],
|
||||||
|
"display": result["display"],
|
||||||
|
"confidence": result["confidence"],
|
||||||
|
"score": result["details"]["ranked"][0][1],
|
||||||
|
"top3": result["details"]["ranked"][:3],
|
||||||
|
"features": {k: result["features"][k] for k in FEATURE_KEYS},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 - 报告需要汇总所有失败样本
|
||||||
|
img = cv2.imread(str(path))
|
||||||
|
if img is not None:
|
||||||
|
h, w = img.shape[:2]
|
||||||
|
if max(h, w) > MAX_IMAGE_SIDE:
|
||||||
|
scale = MAX_IMAGE_SIDE / max(h, w)
|
||||||
|
img = cv2.resize(img, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA)
|
||||||
|
cv2.imwrite(str(img_dir / out_name), img, [int(cv2.IMWRITE_JPEG_QUALITY), JPEG_QUALITY])
|
||||||
|
item["error"] = str(exc)
|
||||||
|
|
||||||
|
rows.append(item)
|
||||||
|
print(f"[{idx:3d}/{len(chosen)}] [{group}] {path.name} -> {item['display'] or 'ERR: ' + str(item['error'])}")
|
||||||
|
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def bar_chart(counter: Counter) -> str:
|
||||||
|
if not counter:
|
||||||
|
return "<p class='muted'>无数据</p>"
|
||||||
|
total = sum(counter.values())
|
||||||
|
parts = []
|
||||||
|
order = [s for s in SHAPE_ORDER if counter.get(s)] + [
|
||||||
|
s for s in counter if s not in SHAPE_ORDER
|
||||||
|
]
|
||||||
|
for shape in order:
|
||||||
|
n = counter[shape]
|
||||||
|
color = SHAPE_COLORS.get(shape, "#7f8c8d")
|
||||||
|
pct = n / total * 100
|
||||||
|
parts.append(
|
||||||
|
f"<div class='bar-row'><span class='bar-label'>{html.escape(shape)}</span>"
|
||||||
|
f"<div class='bar-track'><div class='bar-fill' style='width:{pct:.1f}%;background:{color}'></div></div>"
|
||||||
|
f"<span class='bar-num'>{n}({pct:.0f}%)</span></div>"
|
||||||
|
)
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_feat(key: str, value: float) -> str:
|
||||||
|
if key in {"face_width", "face_height"}:
|
||||||
|
return f"{value:.1f}px"
|
||||||
|
if key == "jaw_angle":
|
||||||
|
return f"{value:.1f}°"
|
||||||
|
return f"{value:.3f}"
|
||||||
|
|
||||||
|
|
||||||
|
def card(item: Dict) -> str:
|
||||||
|
group_tag = f"<span class='group-tag'>{html.escape(item['group'])}</span>"
|
||||||
|
if not item["ok"]:
|
||||||
|
return f"""
|
||||||
|
<article class="card error">
|
||||||
|
<a class="img-link" href="{html.escape(item['img_src'])}" target="_blank">
|
||||||
|
<img src="{html.escape(item['img_src'])}" alt="{html.escape(item['file'])}" loading="lazy"/>
|
||||||
|
</a>
|
||||||
|
<div class="body">
|
||||||
|
<div class="meta"><h3>{html.escape(item['file'])}</h3>{group_tag}</div>
|
||||||
|
<p class="badge bad">检测失败</p>
|
||||||
|
<p class="muted">{html.escape(item['error'] or '')}</p>
|
||||||
|
</div>
|
||||||
|
</article>"""
|
||||||
|
|
||||||
|
color = SHAPE_COLORS.get(item["predicted"], "#34495e")
|
||||||
|
top3 = "".join(
|
||||||
|
f"<li><span>{html.escape(name)}</span><b>{score:.1f}</b></li>" for name, score in item["top3"]
|
||||||
|
)
|
||||||
|
feat_html = "".join(
|
||||||
|
f"<tr><td>{html.escape(k)}</td><td>{html.escape(fmt_feat(k, v))}</td></tr>"
|
||||||
|
for k, v in item["features"].items()
|
||||||
|
)
|
||||||
|
return f"""
|
||||||
|
<article class="card">
|
||||||
|
<a class="img-link" href="{html.escape(item['img_src'])}" target="_blank" title="点击查看大图标注">
|
||||||
|
<img src="{html.escape(item['img_src'])}" alt="{html.escape(item['file'])}" loading="lazy"/>
|
||||||
|
</a>
|
||||||
|
<div class="body">
|
||||||
|
<div class="meta"><h3>{html.escape(item['file'])}</h3>{group_tag}</div>
|
||||||
|
<p class="path muted">{html.escape(item['rel_path'])}</p>
|
||||||
|
<p class="badge" style="background:{color}">{html.escape(item['display'])}</p>
|
||||||
|
<p class="conf">匹配度 {item['score']:.1f} · 置信度 {item['confidence']:.3f}</p>
|
||||||
|
<ul class="scores">{top3}</ul>
|
||||||
|
<details>
|
||||||
|
<summary>标注特征数值</summary>
|
||||||
|
<table>{feat_html}</table>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</article>"""
|
||||||
|
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
:root { --bg:#f3efe6; --ink:#1c1915; --muted:#6b645a; --card:#fffdf8; --line:#e2d8c8; --accent:#0f6b5c; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin:0; font-family:"PingFang SC","Noto Sans SC","Segoe UI",sans-serif; color:var(--ink);
|
||||||
|
background: radial-gradient(1200px 600px at 10% -10%, #ffe8c8 0%, transparent 55%),
|
||||||
|
radial-gradient(900px 500px at 100% 0%, #d9f2ea 0%, transparent 50%), var(--bg);
|
||||||
|
}
|
||||||
|
header { padding:40px 24px 20px; max-width:1320px; margin:0 auto; }
|
||||||
|
header h1 { margin:0 0 8px; font-size:clamp(1.8rem,3vw,2.4rem); }
|
||||||
|
header p { margin:4px 0; color:var(--muted); }
|
||||||
|
.legend-box { max-width:1320px; margin:0 auto 20px; padding:0 24px; }
|
||||||
|
.legend-box .inner { background:var(--card); border:1px solid var(--line); border-radius:14px;
|
||||||
|
padding:14px 16px; font-size:.9rem; line-height:1.55; }
|
||||||
|
.legend-box code { background:#efe7da; padding:1px 6px; border-radius:4px; font-size:.84rem; }
|
||||||
|
.swatch { display:inline-block; width:10px; height:10px; border-radius:2px; margin-right:4px; vertical-align:middle; }
|
||||||
|
.stats { display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); gap:16px;
|
||||||
|
max-width:1320px; margin:0 auto 28px; padding:0 24px; }
|
||||||
|
.stat { background:var(--card); border:1px solid var(--line); border-radius:16px; padding:16px 18px; }
|
||||||
|
.stat h2 { margin:0 0 12px; font-size:1rem; }
|
||||||
|
.bar-row { display:grid; grid-template-columns:72px 1fr 80px; gap:8px; align-items:center; margin:6px 0; font-size:.86rem; }
|
||||||
|
.bar-track { height:8px; background:#efe7da; border-radius:999px; overflow:hidden; }
|
||||||
|
.bar-fill { height:100%; border-radius:999px; }
|
||||||
|
.bar-num { color:var(--muted); text-align:right; }
|
||||||
|
section { max-width:1320px; margin:0 auto 36px; padding:0 24px; }
|
||||||
|
section h2 { margin:0 0 14px; font-size:1.3rem; border-left:4px solid var(--accent); padding-left:10px; }
|
||||||
|
section h2 small { color:var(--muted); font-weight:400; font-size:.8rem; margin-left:8px; }
|
||||||
|
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(270px,1fr)); gap:16px; }
|
||||||
|
.card { background:var(--card); border:1px solid var(--line); border-radius:18px; overflow:hidden;
|
||||||
|
display:flex; flex-direction:column; box-shadow:0 8px 24px rgba(60,40,10,.05); }
|
||||||
|
.card.error { opacity:.9; }
|
||||||
|
.img-link { display:block; }
|
||||||
|
.card img { width:100%; aspect-ratio:3/4; object-fit:cover; background:#ddd; display:block; }
|
||||||
|
.card .body { padding:14px; }
|
||||||
|
.meta { display:flex; justify-content:space-between; align-items:baseline; gap:8px; }
|
||||||
|
.meta h3 { margin:0; font-size:.95rem; word-break:break-all; }
|
||||||
|
.group-tag { font-size:.72rem; color:var(--accent); background:#e7f6f2; padding:2px 8px;
|
||||||
|
border-radius:999px; white-space:nowrap; }
|
||||||
|
.path { font-size:.72rem; margin:4px 0 0; word-break:break-all; }
|
||||||
|
.badge { display:inline-block; margin:10px 0 4px; color:#fff; padding:6px 10px; border-radius:999px;
|
||||||
|
font-weight:600; font-size:.92rem; }
|
||||||
|
.badge.bad { background:#c0392b; }
|
||||||
|
.conf { margin:0 0 8px; color:var(--muted); font-size:.85rem; }
|
||||||
|
.scores { list-style:none; padding:0; margin:0 0 8px; }
|
||||||
|
.scores li { display:flex; justify-content:space-between; padding:4px 0; border-bottom:1px dashed var(--line); font-size:.86rem; }
|
||||||
|
details { margin-top:8px; }
|
||||||
|
summary { cursor:pointer; color:var(--accent); font-size:.86rem; }
|
||||||
|
table { width:100%; border-collapse:collapse; margin-top:8px; font-size:.8rem; }
|
||||||
|
td { padding:3px 0; border-bottom:1px solid var(--line); }
|
||||||
|
td:last-child { text-align:right; font-variant-numeric:tabular-nums; }
|
||||||
|
.muted { color:var(--muted); }
|
||||||
|
.cross-wrap { overflow-x:auto; background:var(--card); border:1px solid var(--line);
|
||||||
|
border-radius:16px; padding:14px 16px; }
|
||||||
|
table.cross { border-collapse:collapse; width:100%; font-size:.88rem; }
|
||||||
|
table.cross th, table.cross td { padding:7px 10px; text-align:center; border-bottom:1px solid var(--line);
|
||||||
|
white-space:nowrap; }
|
||||||
|
table.cross thead th { background:#efe7da; font-weight:600; position:sticky; top:0; }
|
||||||
|
table.cross th.rowh { text-align:left; font-weight:600; }
|
||||||
|
table.cross th.rowh small { color:var(--muted); font-weight:400; }
|
||||||
|
table.cross td.num { font-variant-numeric:tabular-nums; }
|
||||||
|
table.cross td.hit { background:#d8f3e4; color:#0f6b5c; font-weight:700; font-variant-numeric:tabular-nums; }
|
||||||
|
table.cross td.zero { color:#cfc6b6; }
|
||||||
|
footer { max-width:1320px; margin:0 auto; padding:8px 24px 40px; color:var(--muted); font-size:.85rem; }
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def cross_table(by_group: Dict[str, List[Dict]]) -> str:
|
||||||
|
"""原始分组 × 预测脸型 交叉表,对角线(口径对应的格子)高亮。"""
|
||||||
|
cols = SHAPE_ORDER + ["检测失败"]
|
||||||
|
head = "".join(f"<th>{html.escape(c)}</th>" for c in cols)
|
||||||
|
body = []
|
||||||
|
for group, items in sorted(by_group.items(), key=lambda kv: natural_key(kv[0])):
|
||||||
|
counts = Counter(i["predicted"] if i["ok"] else "检测失败" for i in items)
|
||||||
|
equiv = TAXONOMY_EQUIV.get(group)
|
||||||
|
cells = []
|
||||||
|
for c in cols:
|
||||||
|
n = counts.get(c, 0)
|
||||||
|
if n == 0:
|
||||||
|
cells.append("<td class='zero'>·</td>")
|
||||||
|
continue
|
||||||
|
cls = "hit" if c == equiv else "num"
|
||||||
|
cells.append(f"<td class='{cls}'>{n}</td>")
|
||||||
|
label = html.escape(group)
|
||||||
|
if equiv:
|
||||||
|
label += f" <small>≈{html.escape(equiv)}</small>"
|
||||||
|
body.append(f"<tr><th class='rowh'>{label}</th>{''.join(cells)}<th>{len(items)}</th></tr>")
|
||||||
|
return (
|
||||||
|
"<div class='cross-wrap'><table class='cross'>"
|
||||||
|
f"<thead><tr><th>原始分组 \\ 预测</th>{head}<th>合计</th></tr></thead>"
|
||||||
|
f"<tbody>{''.join(body)}</tbody></table></div>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_html(rows: List[Dict], name: str, src: Path, seed: int, img_dir_name: str) -> str:
|
||||||
|
overall = Counter(r["predicted"] if r["ok"] else "检测失败" for r in rows)
|
||||||
|
by_group: Dict[str, List[Dict]] = defaultdict(list)
|
||||||
|
for r in rows:
|
||||||
|
by_group[r["group"]].append(r)
|
||||||
|
|
||||||
|
group_stats = "".join(
|
||||||
|
f"<div class='stat'><h2>{html.escape(g)} <span class='muted'>({len(items)} 张)</span></h2>"
|
||||||
|
f"{bar_chart(Counter(i['predicted'] if i['ok'] else '检测失败' for i in items))}</div>"
|
||||||
|
for g, items in sorted(by_group.items(), key=lambda kv: natural_key(kv[0]))
|
||||||
|
)
|
||||||
|
|
||||||
|
sections = "".join(
|
||||||
|
f"<section><h2>{html.escape(g)}<small>{len(items)} 张</small></h2>"
|
||||||
|
f"<div class='grid'>{''.join(card(i) for i in items)}</div></section>"
|
||||||
|
for g, items in sorted(by_group.items(), key=lambda kv: natural_key(kv[0]))
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
n_ok = sum(1 for r in rows if r["ok"])
|
||||||
|
n_kind = len([s for s in SHAPE_ORDER if overall.get(s)])
|
||||||
|
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<title>{html.escape(name)} — 脸型分类报告</title>
|
||||||
|
<style>{CSS}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>{html.escape(name)} — 脸型分类报告</h1>
|
||||||
|
<p>z 分数原型匹配分类 · 照片上标注 face_width / face_height 及各比例特征</p>
|
||||||
|
<p>生成时间:{html.escape(now)} · 抽样 {len(rows)} 张(随机种子 {seed})· 成功 {n_ok} 张 ·
|
||||||
|
覆盖 {n_kind} 种脸型 · 共 {len(by_group)} 个原始分组 · 点击图片看大图</p>
|
||||||
|
<p class="muted">来源目录:{html.escape(str(src))}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="legend-box">
|
||||||
|
<div class="inner">
|
||||||
|
<b>图上标注说明</b><br/>
|
||||||
|
<span class="swatch" style="background:#00dc78"></span><code>face_width</code> 颧骨宽度
|
||||||
|
<span class="swatch" style="background:#28b4ff"></span><code>face_height</code> 额头顶→下巴
|
||||||
|
<span class="swatch" style="background:#ff5a00"></span><code>jaw_angle</code> 下巴到左右下颌角夹角
|
||||||
|
<span class="swatch" style="background:#ffc828"></span><code>taper_ratio</code> 额头→下巴收窄
|
||||||
|
<span class="swatch" style="background:#28a0ff"></span><code>forehead / jaw / chin ratio</code> 各级宽度比
|
||||||
|
<span class="swatch" style="background:#b4ff50"></span><code>face_curve_score</code> 下颌中点→下巴
|
||||||
|
右侧柱状条示意 <code>width_uniformity</code>;左上角是完整数值图例。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>原始分组 × 预测脸型 对照<small>数据集分组本身是脸型标签,但命名口径与分类器不同</small></h2>
|
||||||
|
{cross_table(by_group)}
|
||||||
|
<p class="muted" style="margin-top:10px;font-size:.85rem">
|
||||||
|
绿色格子表示预测结果与该分组的对应口径一致(标准脸≈鹅蛋脸、娃娃脸≈圆形脸,方形/长形/瓜子同名直接对应)。
|
||||||
|
「梨形脸」「混合脸」在分类器的 7 分类里没有对应项,不作一致性判断。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat"><h2>总体脸型分布({len(rows)} 张)</h2>{bar_chart(overall)}</div>
|
||||||
|
{group_stats}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sections}
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
分类实现:face/face_shape_classifier.py · 报告生成:face/build_dataset_report.py ·
|
||||||
|
图片目录:static/{html.escape(img_dir_name)}/
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser(description="批量脸型预测并生成 HTML 报告")
|
||||||
|
ap.add_argument("--src", required=True, help="图片根目录(可含子目录)")
|
||||||
|
ap.add_argument("--sample", type=int, default=50, help="随机抽样张数,0 表示全部")
|
||||||
|
ap.add_argument("--seed", type=int, default=42, help="随机种子")
|
||||||
|
ap.add_argument("--name", default=None, help="报告标题,默认取目录名")
|
||||||
|
ap.add_argument("--slug", default="dataset", help="输出文件名前缀(ASCII)")
|
||||||
|
ap.add_argument(
|
||||||
|
"--min-per-group",
|
||||||
|
type=int,
|
||||||
|
default=2,
|
||||||
|
help="分层抽样时每个分组至少抽几张,0 表示纯随机抽样",
|
||||||
|
)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
src = Path(args.src).expanduser().resolve()
|
||||||
|
if not src.is_dir():
|
||||||
|
raise SystemExit(f"目录不存在: {src}")
|
||||||
|
|
||||||
|
name = args.name or src.name
|
||||||
|
img_dir_name = f"{args.slug}_report"
|
||||||
|
img_dir = ROOT / "static" / img_dir_name / "images"
|
||||||
|
out_html = ROOT / "static" / f"{args.slug}_report.html"
|
||||||
|
|
||||||
|
rows = analyze(src, args.sample, args.seed, img_dir, args.min_per_group)
|
||||||
|
out_html.write_text(
|
||||||
|
build_html(rows, name, src, args.seed, img_dir_name), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
overall = Counter(r["predicted"] if r["ok"] else "检测失败" for r in rows)
|
||||||
|
total = sum(overall.values())
|
||||||
|
print(f"\n写入 {out_html}")
|
||||||
|
print("=== 总体脸型分布 ===")
|
||||||
|
for shape in SHAPE_ORDER + ["检测失败"]:
|
||||||
|
n = overall.get(shape, 0)
|
||||||
|
if n:
|
||||||
|
print(f" {shape}: {n:3d} ({n / total * 100:4.1f}%) {'#' * n}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
"""
|
||||||
|
build_report.py
|
||||||
|
对 face/test_img/girl 与 face/test_img/man 下的照片批量预测脸型,
|
||||||
|
在照片上标注 face_width / face_height 等特征,并生成 HTML 报告。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
./venv/bin/python face/build_report.py
|
||||||
|
输出:
|
||||||
|
static/face_shape_report.html
|
||||||
|
static/face_shape_report/images/*.jpg
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from collections import Counter
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from face.face_shape_classifier import classify_from_image # noqa: E402
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SRC_DIRS = {
|
||||||
|
"女": ROOT / "face/test_img/girl",
|
||||||
|
"男": ROOT / "face/test_img/man",
|
||||||
|
}
|
||||||
|
OUT_DIR = ROOT / "static/face_shape_report"
|
||||||
|
IMG_DIR = OUT_DIR / "images"
|
||||||
|
OUT_HTML = ROOT / "static/face_shape_report.html"
|
||||||
|
|
||||||
|
MAX_IMAGE_SIDE = 900
|
||||||
|
JPEG_QUALITY = 90
|
||||||
|
|
||||||
|
SHAPE_ORDER = ["圆形脸", "心形脸", "菱形脸", "鹅蛋脸", "方形脸", "长形脸", "瓜子脸"]
|
||||||
|
SHAPE_COLORS = {
|
||||||
|
"圆形脸": "#e67e22",
|
||||||
|
"心形脸": "#e74c3c",
|
||||||
|
"菱形脸": "#9b59b6",
|
||||||
|
"鹅蛋脸": "#27ae60",
|
||||||
|
"方形脸": "#2980b9",
|
||||||
|
"长形脸": "#16a085",
|
||||||
|
"瓜子脸": "#c0392b",
|
||||||
|
}
|
||||||
|
FEATURE_KEYS = [
|
||||||
|
"face_width",
|
||||||
|
"face_height",
|
||||||
|
"jaw_angle",
|
||||||
|
"taper_ratio",
|
||||||
|
"forehead_ratio",
|
||||||
|
"cheekbone_ratio",
|
||||||
|
"jaw_ratio",
|
||||||
|
"chin_ratio",
|
||||||
|
"chin_sharpness",
|
||||||
|
"width_uniformity",
|
||||||
|
"face_curve_score",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def natural_key(path: Path):
|
||||||
|
m = re.search(r"(\d+)", path.stem)
|
||||||
|
return (0, int(m.group(1))) if m else (1, path.stem)
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_all() -> tuple[List[Dict], Dict[str, Counter]]:
|
||||||
|
if IMG_DIR.exists():
|
||||||
|
shutil.rmtree(IMG_DIR)
|
||||||
|
IMG_DIR.mkdir(parents=True)
|
||||||
|
|
||||||
|
rows: List[Dict] = []
|
||||||
|
summary = {"女": Counter(), "男": Counter(), "all": Counter()}
|
||||||
|
|
||||||
|
for gender, src in SRC_DIRS.items():
|
||||||
|
prefix = "girl" if gender == "女" else "man"
|
||||||
|
paths = sorted(
|
||||||
|
[p for p in src.iterdir() if p.suffix.lower() in {".jpg", ".jpeg", ".png", ".webp"}],
|
||||||
|
key=natural_key,
|
||||||
|
)
|
||||||
|
for path in paths:
|
||||||
|
m = re.search(r"(\d+)", path.stem)
|
||||||
|
out_name = f"{prefix}_{int(m.group(1)) if m else 0:02d}.jpg"
|
||||||
|
dest = IMG_DIR / out_name
|
||||||
|
|
||||||
|
item = {
|
||||||
|
"gender": gender,
|
||||||
|
"file": path.name,
|
||||||
|
"img_src": f"face_shape_report/images/{out_name}",
|
||||||
|
"ok": False,
|
||||||
|
"predicted": None,
|
||||||
|
"display": None,
|
||||||
|
"confidence": None,
|
||||||
|
"score": None,
|
||||||
|
"top3": [],
|
||||||
|
"features": {},
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
result = classify_from_image(path, return_details=True, return_annotated=True)
|
||||||
|
annotated = result["annotated"]
|
||||||
|
h, w = annotated.shape[:2]
|
||||||
|
if max(h, w) > MAX_IMAGE_SIDE:
|
||||||
|
scale = MAX_IMAGE_SIDE / max(h, w)
|
||||||
|
annotated = cv2.resize(
|
||||||
|
annotated, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA
|
||||||
|
)
|
||||||
|
cv2.imwrite(str(dest), annotated, [int(cv2.IMWRITE_JPEG_QUALITY), JPEG_QUALITY])
|
||||||
|
|
||||||
|
item.update(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"predicted": result["face_shape"],
|
||||||
|
"display": result["display"],
|
||||||
|
"confidence": result["confidence"],
|
||||||
|
"score": result["details"]["ranked"][0][1],
|
||||||
|
"top3": result["details"]["ranked"][:3],
|
||||||
|
"features": {k: result["features"][k] for k in FEATURE_KEYS},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
summary[gender][result["face_shape"]] += 1
|
||||||
|
summary["all"][result["face_shape"]] += 1
|
||||||
|
except Exception as exc: # noqa: BLE001 - 报告需要汇总所有失败
|
||||||
|
img = cv2.imread(str(path))
|
||||||
|
if img is not None:
|
||||||
|
cv2.imwrite(str(dest), img, [int(cv2.IMWRITE_JPEG_QUALITY), JPEG_QUALITY])
|
||||||
|
item["error"] = str(exc)
|
||||||
|
summary[gender]["检测失败"] += 1
|
||||||
|
summary["all"]["检测失败"] += 1
|
||||||
|
|
||||||
|
rows.append(item)
|
||||||
|
print(f"[{gender}] {path.name} -> {item['display'] or 'ERR ' + str(item['error'])}")
|
||||||
|
|
||||||
|
return rows, summary
|
||||||
|
|
||||||
|
|
||||||
|
def count_table(counter: Counter) -> str:
|
||||||
|
if not counter:
|
||||||
|
return "<p class='muted'>无数据</p>"
|
||||||
|
total = sum(counter.values())
|
||||||
|
parts = []
|
||||||
|
for shape, n in sorted(counter.items(), key=lambda x: (-x[1], x[0])):
|
||||||
|
color = SHAPE_COLORS.get(shape, "#7f8c8d")
|
||||||
|
pct = n / total * 100
|
||||||
|
parts.append(
|
||||||
|
f"<div class='bar-row'><span class='bar-label'>{html.escape(shape)}</span>"
|
||||||
|
f"<div class='bar-track'><div class='bar-fill' style='width:{pct:.1f}%;background:{color}'></div></div>"
|
||||||
|
f"<span class='bar-num'>{n}({pct:.0f}%)</span></div>"
|
||||||
|
)
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_feat(key: str, value: float) -> str:
|
||||||
|
if key in {"face_width", "face_height"}:
|
||||||
|
return f"{value:.1f}px"
|
||||||
|
if key == "jaw_angle":
|
||||||
|
return f"{value:.1f}°"
|
||||||
|
return f"{value:.3f}"
|
||||||
|
|
||||||
|
|
||||||
|
def card(item: Dict) -> str:
|
||||||
|
if not item["ok"]:
|
||||||
|
return f"""
|
||||||
|
<article class="card error">
|
||||||
|
<a class="img-link" href="{html.escape(item['img_src'])}" target="_blank">
|
||||||
|
<img src="{html.escape(item['img_src'])}" alt="{html.escape(item['file'])}" loading="lazy"/>
|
||||||
|
</a>
|
||||||
|
<div class="body">
|
||||||
|
<h3>{html.escape(item['file'])}</h3>
|
||||||
|
<p class="badge bad">检测失败</p>
|
||||||
|
<p class="muted">{html.escape(item['error'] or '')}</p>
|
||||||
|
</div>
|
||||||
|
</article>"""
|
||||||
|
|
||||||
|
color = SHAPE_COLORS.get(item["predicted"], "#34495e")
|
||||||
|
top3 = "".join(
|
||||||
|
f"<li><span>{html.escape(name)}</span><b>{score:.1f}</b></li>" for name, score in item["top3"]
|
||||||
|
)
|
||||||
|
feat_html = "".join(
|
||||||
|
f"<tr><td>{html.escape(k)}</td><td>{html.escape(fmt_feat(k, v))}</td></tr>"
|
||||||
|
for k, v in item["features"].items()
|
||||||
|
)
|
||||||
|
return f"""
|
||||||
|
<article class="card">
|
||||||
|
<a class="img-link" href="{html.escape(item['img_src'])}" target="_blank" title="点击查看大图标注">
|
||||||
|
<img src="{html.escape(item['img_src'])}" alt="{html.escape(item['file'])}" loading="lazy"/>
|
||||||
|
</a>
|
||||||
|
<div class="body">
|
||||||
|
<div class="meta">
|
||||||
|
<h3>{html.escape(item['file'])}</h3>
|
||||||
|
<span class="gender">{html.escape(item['gender'])}</span>
|
||||||
|
</div>
|
||||||
|
<p class="badge" style="background:{color}">{html.escape(item['display'])}</p>
|
||||||
|
<p class="conf">匹配度 {item['score']:.1f} · 置信度 {item['confidence']:.3f}</p>
|
||||||
|
<h4>Top-3 得分</h4>
|
||||||
|
<ul class="scores">{top3}</ul>
|
||||||
|
<details>
|
||||||
|
<summary>标注特征数值</summary>
|
||||||
|
<table>{feat_html}</table>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</article>"""
|
||||||
|
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
:root {
|
||||||
|
--bg: #f3efe6; --ink: #1c1915; --muted: #6b645a;
|
||||||
|
--card: #fffdf8; --line: #e2d8c8; --accent: #0f6b5c;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "PingFang SC", "Noto Sans SC", "Segoe UI", sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 600px at 10% -10%, #ffe8c8 0%, transparent 55%),
|
||||||
|
radial-gradient(900px 500px at 100% 0%, #d9f2ea 0%, transparent 50%),
|
||||||
|
var(--bg);
|
||||||
|
}
|
||||||
|
header { padding: 40px 24px 20px; max-width: 1280px; margin: 0 auto; }
|
||||||
|
header h1 { margin: 0 0 8px; font-size: clamp(1.8rem, 3vw, 2.4rem); letter-spacing: .02em; }
|
||||||
|
header p { margin: 4px 0; color: var(--muted); }
|
||||||
|
.legend-box { max-width: 1280px; margin: 0 auto 20px; padding: 0 24px; }
|
||||||
|
.legend-box .inner {
|
||||||
|
background: var(--card); border: 1px solid var(--line);
|
||||||
|
border-radius: 14px; padding: 14px 16px; font-size: .9rem; line-height: 1.55;
|
||||||
|
}
|
||||||
|
.legend-box code { background: #efe7da; padding: 1px 6px; border-radius: 4px; font-size: .84rem; }
|
||||||
|
.swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 4px; vertical-align: middle; }
|
||||||
|
.stats {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 16px; max-width: 1280px; margin: 0 auto 28px; padding: 0 24px;
|
||||||
|
}
|
||||||
|
.stat { background: var(--card); border: 1px solid var(--line); border-radius: 16px; padding: 16px 18px; }
|
||||||
|
.stat h2 { margin: 0 0 12px; font-size: 1rem; }
|
||||||
|
.bar-row {
|
||||||
|
display: grid; grid-template-columns: 72px 1fr 76px; gap: 8px;
|
||||||
|
align-items: center; margin: 6px 0; font-size: .86rem;
|
||||||
|
}
|
||||||
|
.bar-track { height: 8px; background: #efe7da; border-radius: 999px; overflow: hidden; }
|
||||||
|
.bar-fill { height: 100%; border-radius: 999px; }
|
||||||
|
.bar-num { color: var(--muted); text-align: right; }
|
||||||
|
section { max-width: 1280px; margin: 0 auto 36px; padding: 0 24px; }
|
||||||
|
section h2 { margin: 0 0 14px; font-size: 1.35rem; border-left: 4px solid var(--accent); padding-left: 10px; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
|
||||||
|
.card {
|
||||||
|
background: var(--card); border: 1px solid var(--line); border-radius: 18px;
|
||||||
|
overflow: hidden; display: flex; flex-direction: column;
|
||||||
|
box-shadow: 0 8px 24px rgba(60, 40, 10, .05);
|
||||||
|
}
|
||||||
|
.card.error { opacity: .9; }
|
||||||
|
.img-link { display: block; }
|
||||||
|
.card img { width: 100%; aspect-ratio: 3/4; object-fit: cover; background: #ddd; display: block; }
|
||||||
|
.card .body { padding: 14px 14px 16px; }
|
||||||
|
.meta { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
|
||||||
|
.meta h3 { margin: 0; font-size: 1rem; }
|
||||||
|
.gender { font-size: .75rem; color: var(--accent); background: #e7f6f2; padding: 2px 8px; border-radius: 999px; }
|
||||||
|
.badge {
|
||||||
|
display: inline-block; margin: 10px 0 4px; color: #fff;
|
||||||
|
padding: 6px 10px; border-radius: 999px; font-weight: 600; font-size: .92rem;
|
||||||
|
}
|
||||||
|
.badge.bad { background: #c0392b; }
|
||||||
|
.conf { margin: 0 0 10px; color: var(--muted); font-size: .85rem; }
|
||||||
|
.scores { list-style: none; padding: 0; margin: 0 0 8px; }
|
||||||
|
.scores li {
|
||||||
|
display: flex; justify-content: space-between; padding: 4px 0;
|
||||||
|
border-bottom: 1px dashed var(--line); font-size: .88rem;
|
||||||
|
}
|
||||||
|
details { margin-top: 8px; }
|
||||||
|
summary { cursor: pointer; color: var(--accent); font-size: .88rem; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-top: 8px; font-size: .8rem; }
|
||||||
|
td { padding: 3px 0; border-bottom: 1px solid var(--line); }
|
||||||
|
td:last-child { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
footer { max-width: 1280px; margin: 0 auto; padding: 8px 24px 40px; color: var(--muted); font-size: .85rem; }
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def build_html(rows: List[Dict], summary: Dict[str, Counter]) -> str:
|
||||||
|
girl_cards = "\n".join(card(r) for r in rows if r["gender"] == "女")
|
||||||
|
man_cards = "\n".join(card(r) for r in rows if r["gender"] == "男")
|
||||||
|
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
n_girl = sum(1 for r in rows if r["gender"] == "女")
|
||||||
|
n_man = sum(1 for r in rows if r["gender"] == "男")
|
||||||
|
n_ok = sum(1 for r in rows if r["ok"])
|
||||||
|
n_kind = len([s for s in SHAPE_ORDER if summary["all"].get(s)])
|
||||||
|
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<title>脸型分类预测报告(特征标注)</title>
|
||||||
|
<style>{CSS}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>脸型分类预测报告</h1>
|
||||||
|
<p>z 分数原型匹配分类 · 照片上标注 face_width / face_height 及各比例特征</p>
|
||||||
|
<p>生成时间:{html.escape(now)} · 样本 {n_girl + n_man} 张(女 {n_girl} / 男 {n_man})· 成功 {n_ok} · 覆盖 {n_kind} 种脸型 · 点击图片看大图</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="legend-box">
|
||||||
|
<div class="inner">
|
||||||
|
<b>图上标注说明</b><br/>
|
||||||
|
<span class="swatch" style="background:#00dc78"></span><code>face_width</code> 颧骨宽度
|
||||||
|
<span class="swatch" style="background:#28b4ff"></span><code>face_height</code> 额头顶→下巴
|
||||||
|
<span class="swatch" style="background:#ff5a00"></span><code>jaw_angle</code> 下巴到左右下颌角夹角
|
||||||
|
<span class="swatch" style="background:#ffc828"></span><code>taper_ratio</code> 额头→下巴收窄
|
||||||
|
<span class="swatch" style="background:#28a0ff"></span><code>forehead / jaw / chin ratio</code> 各级宽度比
|
||||||
|
<span class="swatch" style="background:#b4ff50"></span><code>face_curve_score</code> 下颌中点→下巴
|
||||||
|
右侧柱状条示意 <code>width_uniformity</code>;左上角是完整数值图例。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat"><h2>全部脸型分布</h2>{count_table(summary['all'])}</div>
|
||||||
|
<div class="stat"><h2>女性脸型分布</h2>{count_table(summary['女'])}</div>
|
||||||
|
<div class="stat"><h2>男性脸型分布</h2>{count_table(summary['男'])}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>女性样本({n_girl})</h2>
|
||||||
|
<div class="grid">{girl_cards}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>男性样本({n_man})</h2>
|
||||||
|
<div class="grid">{man_cards}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
分类实现:face/face_shape_classifier.py · 报告生成:face/build_report.py
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
rows, summary = analyze_all()
|
||||||
|
OUT_HTML.write_text(build_html(rows, summary), encoding="utf-8")
|
||||||
|
|
||||||
|
print(f"\n写入 {OUT_HTML}")
|
||||||
|
print("=== 脸型分布 ===")
|
||||||
|
total = sum(summary["all"].values())
|
||||||
|
for shape in SHAPE_ORDER:
|
||||||
|
n = summary["all"].get(shape, 0)
|
||||||
|
print(f" {shape}: {n:2d} ({n / total * 100:4.1f}%) {'#' * n}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""
|
||||||
|
把各数据集的人脸特征抽取一次并缓存为 JSON,供调参脚本反复使用。
|
||||||
|
|
||||||
|
MediaPipe 关键点检测是调参循环里唯一的耗时环节,缓存后调参可以秒级迭代。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
|
from face_shape_classifier import ( # noqa: E402
|
||||||
|
_get_face_mesh,
|
||||||
|
extract_face_features,
|
||||||
|
)
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
IMG_EXT = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
|
||||||
|
|
||||||
|
|
||||||
|
def iter_images(root: Path) -> List[Path]:
|
||||||
|
return sorted(p for p in root.rglob("*") if p.suffix.lower() in IMG_EXT)
|
||||||
|
|
||||||
|
|
||||||
|
def features_for(path: Path) -> Dict[str, float] | None:
|
||||||
|
bgr = cv2.imread(str(path))
|
||||||
|
if bgr is None:
|
||||||
|
return None
|
||||||
|
h, w = bgr.shape[:2]
|
||||||
|
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
||||||
|
res = _get_face_mesh().process(rgb)
|
||||||
|
if not res.multi_face_landmarks:
|
||||||
|
return None
|
||||||
|
return extract_face_features(res.multi_face_landmarks[0].landmark, image_size=(w, h))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
out_path = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "cache" / "features.json"
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
sources = {
|
||||||
|
# 6 张带标注的基准图,文件名即期望脸型
|
||||||
|
"benchmark": [p for p in ROOT.joinpath("test_img").glob("*.png")],
|
||||||
|
"girl": iter_images(ROOT / "test_img" / "girl"),
|
||||||
|
"man": iter_images(ROOT / "test_img" / "man"),
|
||||||
|
"dataset": iter_images(ROOT / "test_img" / "脸型测试集合"),
|
||||||
|
}
|
||||||
|
|
||||||
|
records = []
|
||||||
|
failed = 0
|
||||||
|
for source, paths in sources.items():
|
||||||
|
for i, path in enumerate(paths, 1):
|
||||||
|
feats = features_for(path)
|
||||||
|
if feats is None:
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
rel = path.relative_to(ROOT)
|
||||||
|
records.append(
|
||||||
|
{
|
||||||
|
"source": source,
|
||||||
|
"path": rel.as_posix(),
|
||||||
|
"file": path.name,
|
||||||
|
# dataset 的上级目录名即原始分组(弱标签,非可信真值)
|
||||||
|
"group": path.parent.name if source == "dataset" else source,
|
||||||
|
"expected": path.stem if source == "benchmark" else None,
|
||||||
|
"features": feats,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if i % 50 == 0 or i == len(paths):
|
||||||
|
print(f"[{source}] {i}/{len(paths)}", flush=True)
|
||||||
|
|
||||||
|
out_path.write_text(json.dumps(records, ensure_ascii=False), encoding="utf-8")
|
||||||
|
print(f"\n写入 {out_path}:{len(records)} 条,检测失败 {failed} 张")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,904 @@
|
|||||||
|
# 脸型判断规则优化报告
|
||||||
|
|
||||||
|
> 基于 Mediapipe 468 点人脸关键点的脸型分类系统
|
||||||
|
> 优化日期:2026-07-28
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、优化总览
|
||||||
|
|
||||||
|
### 原始规则主要问题
|
||||||
|
|
||||||
|
| 问题 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **规则冲突** | 7条 if 规则可能同时匹配,无优先级机制 |
|
||||||
|
| **特征定义模糊** | `width_ratio`、`chin_narrowness` 等未给出精确计算方式 |
|
||||||
|
| **关键点不足** | 额头宽度用 234/454(颧骨点)而非太阳穴点,导致测量不准 |
|
||||||
|
| **无置信度** | 硬判断,混合脸型无处理 |
|
||||||
|
| **阈值经验性** | 阈值未经统计校准,边界处容易误判 |
|
||||||
|
| **缺少归一化** | 不同距离拍的照片结果不一致 |
|
||||||
|
|
||||||
|
### 优化策略
|
||||||
|
|
||||||
|
1. **精确特征提取**:增加关键点,所有测量归一化
|
||||||
|
2. **评分制分类**:每个脸型计算匹配度分数(0-100),取最高分
|
||||||
|
3. **置信度输出**:报告 Top-1 / Top-2 分差,判断是否为混合脸型
|
||||||
|
4. **优先级仲裁**:分数接近时按"特异性优先"原则仲裁
|
||||||
|
5. **鲁棒性增强**:clamp 防止除零、NaN,角度计算增加 3D 投影
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、优化后的完整 Python 代码
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""
|
||||||
|
face_shape_classifier.py
|
||||||
|
基于 Mediapipe 468 点人脸关键点的脸型分类系统
|
||||||
|
|
||||||
|
支持的脸型:圆形脸 / 心形脸 / 菱形脸 / 鹅蛋脸 / 方形脸 / 长形脸 / 瓜子脸
|
||||||
|
分类策略:多维度特征提取 → 加权评分 → 置信度判断
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import numpy as np
|
||||||
|
from typing import Dict, Tuple, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 第一部分:关键点索引定义
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class FaceLandmarks:
|
||||||
|
"""Mediapipe 468 点关键点索引(仅列出脸型分析所需)"""
|
||||||
|
|
||||||
|
# --- 中线关键点 ---
|
||||||
|
FOREHEAD_TOP = 10 # 额头顶部(发际线附近)
|
||||||
|
NOSE_BRIDGE = 1 # 鼻根(眉心位置)
|
||||||
|
NOSE_TIP = 168 # 鼻尖
|
||||||
|
CHIN_BOTTOM = 152 # 下巴最低点(menton)
|
||||||
|
|
||||||
|
# --- 太阳穴 / 额头两侧(额头宽度)---
|
||||||
|
LEFT_TEMPLE = 127 # 左太阳穴
|
||||||
|
RIGHT_TEMPLE = 356 # 右太阳穴
|
||||||
|
|
||||||
|
# --- 颧骨 / 脸颊最宽处 ---
|
||||||
|
LEFT_CHEEK = 234 # 左颧弓最外侧
|
||||||
|
RIGHT_CHEEK = 454 # 右颧弓最外侧
|
||||||
|
|
||||||
|
# --- 下颌角(gonion 区域)---
|
||||||
|
LEFT_JAW_ANGLE = 172 # 左下颌角
|
||||||
|
RIGHT_JAW_ANGLE = 397 # 右下颌角
|
||||||
|
|
||||||
|
# --- 下巴两侧(下巴宽度)---
|
||||||
|
LEFT_CHIN = 136 # 左下巴缘
|
||||||
|
RIGHT_CHIN = 365 # 右下巴缘
|
||||||
|
|
||||||
|
# --- 嘴角(辅助参考)---
|
||||||
|
LEFT_MOUTH = 61 # 左嘴角
|
||||||
|
RIGHT_MOUTH = 291 # 右嘴角
|
||||||
|
|
||||||
|
# --- 眼角(辅助参考)---
|
||||||
|
LEFT_EYE_OUT = 33 # 左眼外角
|
||||||
|
RIGHT_EYE_OUT = 263 # 右眼外角
|
||||||
|
|
||||||
|
# --- 额头侧缘(辅助)---
|
||||||
|
LEFT_FOREHEAD = 50 # 左额侧
|
||||||
|
RIGHT_FOREHEAD = 280 # 右额侧
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 第二部分:特征提取
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def extract_face_features(landmarks) -> Dict[str, float]:
|
||||||
|
"""
|
||||||
|
从 Mediapipe 关键点中提取脸型特征向量。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
landmarks: Mediapipe 的 NormalizedLandmark 列表(468 点)
|
||||||
|
|
||||||
|
返回:
|
||||||
|
features dict,包含以下归一化特征:
|
||||||
|
- aspect_ratio: 面部长宽比(face_width / face_height)
|
||||||
|
- jaw_angle: 下颌角度(度),越大越圆润
|
||||||
|
- taper_ratio: 额头→下巴收窄比例
|
||||||
|
- forehead_ratio: 额头宽度 / 面部宽度
|
||||||
|
- cheekbone_ratio: 颧骨宽度 / 面部宽度
|
||||||
|
- jaw_ratio: 下颌宽度 / 面部宽度
|
||||||
|
- chin_ratio: 下巴宽度 / 面部宽度
|
||||||
|
- chin_sharpness: 下巴尖锐度(下巴宽 / 下颌宽)
|
||||||
|
- width_uniformity: 宽度均匀度(越小越方正)
|
||||||
|
- face_curve_score: 面部曲线评分(越大越圆润)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def pt(idx):
|
||||||
|
"""提取 3D 坐标"""
|
||||||
|
lm = landmarks[idx]
|
||||||
|
return np.array([lm.x, lm.y, lm.z])
|
||||||
|
|
||||||
|
def dist(p1, p2):
|
||||||
|
"""欧氏距离"""
|
||||||
|
return float(np.linalg.norm(p1 - p2))
|
||||||
|
|
||||||
|
# --- 1. 提取关键点 ---
|
||||||
|
forehead_top = pt(FaceLandmarks.FOREHEAD_TOP)
|
||||||
|
chin_bottom = pt(FaceLandmarks.CHIN_BOTTOM)
|
||||||
|
|
||||||
|
left_temple = pt(FaceLandmarks.LEFT_TEMPLE)
|
||||||
|
right_temple = pt(FaceLandmarks.RIGHT_TEMPLE)
|
||||||
|
|
||||||
|
left_cheek = pt(FaceLandmarks.LEFT_CHEEK)
|
||||||
|
right_cheek = pt(FaceLandmarks.RIGHT_CHEEK)
|
||||||
|
|
||||||
|
left_jaw = pt(FaceLandmarks.LEFT_JAW_ANGLE)
|
||||||
|
right_jaw = pt(FaceLandmarks.RIGHT_JAW_ANGLE)
|
||||||
|
|
||||||
|
left_chin = pt(FaceLandmarks.LEFT_CHIN)
|
||||||
|
right_chin = pt(FaceLandmarks.RIGHT_CHIN)
|
||||||
|
|
||||||
|
# --- 2. 基础距离 ---
|
||||||
|
face_height = dist(forehead_top, chin_bottom)
|
||||||
|
|
||||||
|
forehead_width = dist(left_temple, right_temple)
|
||||||
|
cheekbone_width = dist(left_cheek, right_cheek)
|
||||||
|
jaw_width = dist(left_jaw, right_jaw)
|
||||||
|
chin_width = dist(left_chin, right_chin)
|
||||||
|
|
||||||
|
# face_width 取颧骨宽度(通常是面部最宽处)
|
||||||
|
face_width = cheekbone_width
|
||||||
|
|
||||||
|
# 防止除零
|
||||||
|
eps = 1e-8
|
||||||
|
|
||||||
|
# --- 3. 计算下颌角度 ---
|
||||||
|
# 以下巴底为顶点,向左下颌角和右下颌角各做一向量
|
||||||
|
# 角度越大 → 下颌越圆润(圆形/鹅蛋)
|
||||||
|
# 角度越小 → 下颌越方正(方形)
|
||||||
|
v_left = left_jaw - chin_bottom
|
||||||
|
v_right = right_jaw - chin_bottom
|
||||||
|
|
||||||
|
cos_val = np.dot(v_left, v_right) / (np.linalg.norm(v_left) * np.linalg.norm(v_right) + eps)
|
||||||
|
cos_val = np.clip(cos_val, -1.0, 1.0)
|
||||||
|
jaw_angle = math.degrees(math.acos(cos_val))
|
||||||
|
|
||||||
|
# --- 4. 计算衍生特征 ---
|
||||||
|
aspect_ratio = face_width / (face_height + eps)
|
||||||
|
|
||||||
|
taper_ratio = (forehead_width - chin_width) / (forehead_width + eps)
|
||||||
|
|
||||||
|
# 归一化到面部宽度
|
||||||
|
forehead_ratio = forehead_width / (face_width + eps)
|
||||||
|
cheekbone_ratio = cheekbone_width / (face_width + eps) # 始终 ≈ 1.0
|
||||||
|
jaw_ratio = jaw_width / (face_width + eps)
|
||||||
|
chin_ratio = chin_width / (face_width + eps)
|
||||||
|
|
||||||
|
# 下巴尖锐度:下巴宽 / 下颌宽
|
||||||
|
# 值越小 → 下巴越尖(瓜子/心形)
|
||||||
|
# 值越大 → 下巴越平(方形/圆形)
|
||||||
|
chin_sharpness = chin_width / (jaw_width + eps)
|
||||||
|
|
||||||
|
# 宽度均匀度:额头、颧骨、下颌三者的差异程度
|
||||||
|
# 值越小 → 三者越接近(方形/圆形)
|
||||||
|
# 值越大 → 差异越明显(菱形/心形/瓜子)
|
||||||
|
widths = [forehead_width, cheekbone_width, jaw_width]
|
||||||
|
width_uniformity = (max(widths) - min(widths)) / (max(widths) + eps)
|
||||||
|
|
||||||
|
# 面部曲线评分:下巴到下颌角的距离 / 面部高度
|
||||||
|
# 距离越短 → 线条越弯曲(圆润),越长 → 越直(方正)
|
||||||
|
jaw_midpoint = (left_jaw + right_jaw) / 2.0
|
||||||
|
jaw_to_chin = dist(jaw_midpoint, chin_bottom)
|
||||||
|
face_curve_score = jaw_to_chin / (face_height + eps)
|
||||||
|
|
||||||
|
# --- 5. 返回特征字典 ---
|
||||||
|
features = {
|
||||||
|
# 原始尺寸
|
||||||
|
'face_height': face_height,
|
||||||
|
'face_width': face_width,
|
||||||
|
'forehead_width': forehead_width,
|
||||||
|
'cheekbone_width': cheekbone_width,
|
||||||
|
'jaw_width': jaw_width,
|
||||||
|
'chin_width': chin_width,
|
||||||
|
|
||||||
|
# 比例特征
|
||||||
|
'aspect_ratio': aspect_ratio,
|
||||||
|
'taper_ratio': taper_ratio,
|
||||||
|
'forehead_ratio': forehead_ratio,
|
||||||
|
'cheekbone_ratio': cheekbone_ratio,
|
||||||
|
'jaw_ratio': jaw_ratio,
|
||||||
|
'chin_ratio': chin_ratio,
|
||||||
|
|
||||||
|
# 角度特征
|
||||||
|
'jaw_angle': jaw_angle,
|
||||||
|
|
||||||
|
# 复合特征
|
||||||
|
'chin_sharpness': chin_sharpness,
|
||||||
|
'width_uniformity': width_uniformity,
|
||||||
|
'face_curve_score': face_curve_score,
|
||||||
|
}
|
||||||
|
|
||||||
|
return features
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 第三部分:评分制分类器
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def classify_face_shape(
|
||||||
|
features: Dict[str, float],
|
||||||
|
return_details: bool = False
|
||||||
|
) -> Tuple[str, float, Optional[Dict]]:
|
||||||
|
"""
|
||||||
|
基于评分的脸型分类器。
|
||||||
|
|
||||||
|
策略:
|
||||||
|
每种脸型计算 0-100 的匹配度分数。
|
||||||
|
取最高分作为结果,返回置信度和详细得分。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
features: extract_face_features() 的输出
|
||||||
|
return_details: 是否返回详细评分
|
||||||
|
|
||||||
|
返回:
|
||||||
|
(face_shape: str, confidence: float, details: dict | None)
|
||||||
|
"""
|
||||||
|
|
||||||
|
ar = features['aspect_ratio'] # 长宽比(宽/高)
|
||||||
|
jaw = features['jaw_angle'] # 下颌角度
|
||||||
|
tap = features['taper_ratio'] # 额头→下巴收窄
|
||||||
|
fr = features['forehead_ratio'] # 额头宽/面宽
|
||||||
|
jr = features['jaw_ratio'] # 下颌宽/面宽
|
||||||
|
cr = features['chin_ratio'] # 下巴宽/面宽
|
||||||
|
cs = features['chin_sharpness'] # 下巴尖锐度
|
||||||
|
wu = features['width_uniformity'] # 宽度均匀度
|
||||||
|
fcs = features['face_curve_score'] # 面部曲线
|
||||||
|
fw = features['forehead_width']
|
||||||
|
cw = features['chin_width']
|
||||||
|
jw = features['jaw_width']
|
||||||
|
sw = features['cheekbone_width']
|
||||||
|
fh = features['face_height']
|
||||||
|
eps = 1e-8
|
||||||
|
|
||||||
|
scores: Dict[str, float] = {}
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 1. 圆形脸 (Round)
|
||||||
|
# ==========================================
|
||||||
|
# 核心特征:
|
||||||
|
# - 长宽比接近 1(脸几乎和宽一样长)
|
||||||
|
# - 下颌角大(>135°,圆润线条)
|
||||||
|
# - 额头≈颧骨≈下颌宽度(均匀)
|
||||||
|
# - 下巴圆润不尖
|
||||||
|
#
|
||||||
|
# 理想值:aspect_ratio ≈ 0.88-1.0, jaw_angle ≈ 140-160°
|
||||||
|
# ==========================================
|
||||||
|
s = 0.0
|
||||||
|
s += _score_range(ar, 0.85, 1.0, peak=0.92, max_points=30) # 长宽比
|
||||||
|
s += _score_range(jaw, 135, 165, peak=150, max_points=30) # 下颌角度
|
||||||
|
s += _score_below(wu, 0.10, max_points=20) # 宽度均匀
|
||||||
|
s += _score_range(cs, 0.65, 0.90, peak=0.75, max_points=10) # 下巴不尖
|
||||||
|
s += _score_range(tap, -0.05, 0.10, peak=0.02, max_points=10) # 几乎不收窄
|
||||||
|
scores['圆形脸'] = s
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 2. 心形脸 (Heart)
|
||||||
|
# ==========================================
|
||||||
|
# 核心特征:
|
||||||
|
# - 额头明显宽于下巴(taper > 0.2)
|
||||||
|
# - 下巴尖细(chin_sharpness < 0.55)
|
||||||
|
# - 颧骨与额头接近(不是颧骨最宽)
|
||||||
|
# - 前额发际线较宽
|
||||||
|
#
|
||||||
|
# 理想值:taper ≈ 0.25-0.40, chin_sharpness ≈ 0.35-0.55
|
||||||
|
# ==========================================
|
||||||
|
s = 0.0
|
||||||
|
s += _score_above(tap, 0.20, max_points=25) # 额头宽于下巴
|
||||||
|
s += _score_below(cs, 0.55, max_points=25) # 下巴尖
|
||||||
|
s += _score_above(fw, sw * 0.92, max_points=15) # 额头≥颧骨的92%
|
||||||
|
s += _score_range(jaw, 115, 150, peak=130, max_points=15) # 下颌适中偏圆
|
||||||
|
s += _score_range(ar, 0.75, 0.92, peak=0.82, max_points=10) # 长宽比适中
|
||||||
|
s += _score_above(cr, 0.0, max_points=10) # 下巴存在但窄
|
||||||
|
scores['心形脸'] = s
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 3. 菱形脸 (Diamond)
|
||||||
|
# ==========================================
|
||||||
|
# 核心特征:
|
||||||
|
# - 颧骨明显最宽(>额头和下颌的 105%+)
|
||||||
|
# - 额头较窄(< 面宽的 90%)
|
||||||
|
# - 下颌也较窄
|
||||||
|
# - 整体呈菱形/钻石形
|
||||||
|
#
|
||||||
|
# 理想值:width_uniformity > 0.15
|
||||||
|
# ==========================================
|
||||||
|
s = 0.0
|
||||||
|
s += _score_above(sw, fw * 1.05, max_points=25) # 颧骨>额头5%+
|
||||||
|
s += _score_above(sw, jw * 1.10, max_points=25) # 颧骨>下颌10%+
|
||||||
|
s += _score_below(fr, 0.92, max_points=15) # 额头偏窄
|
||||||
|
s += _score_below(jr, 0.92, max_points=15) # 下颌偏窄
|
||||||
|
s += _score_above(wu, 0.12, max_points=10) # 宽度不均匀
|
||||||
|
s += _score_range(ar, 0.72, 0.90, peak=0.80, max_points=10) # 长宽比适中
|
||||||
|
scores['菱形脸'] = s
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 4. 鹅蛋脸 (Oval)
|
||||||
|
# ==========================================
|
||||||
|
# 核心特征:
|
||||||
|
# - 长宽比适中(0.72-0.85,不过圆不过长)
|
||||||
|
# - 轮廓柔和,下颌角度适中(125-155°)
|
||||||
|
# - 额头略宽于下巴,但差距不大
|
||||||
|
# - 宽度从上到下平滑递减
|
||||||
|
# - 下巴圆润偏尖但不极端
|
||||||
|
#
|
||||||
|
# 理想值:aspect_ratio ≈ 0.75-0.82
|
||||||
|
# ==========================================
|
||||||
|
s = 0.0
|
||||||
|
s += _score_range(ar, 0.70, 0.85, peak=0.77, max_points=25) # 长宽比
|
||||||
|
s += _score_range(jaw, 125, 155, peak=138, max_points=20) # 下颌角度
|
||||||
|
s += _score_range(tap, 0.03, 0.20, peak=0.10, max_points=15) # 适度收窄
|
||||||
|
s += _score_range(cs, 0.50, 0.75, peak=0.62, max_points=15) # 下巴适中
|
||||||
|
s += _score_below(wu, 0.12, max_points=15) # 宽度比较均匀
|
||||||
|
s += _score_range(fcs, 0.15, 0.25, peak=0.19, max_points=10) # 曲线适中
|
||||||
|
scores['鹅蛋脸'] = s
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 5. 方形脸 (Square)
|
||||||
|
# ==========================================
|
||||||
|
# 核心特征:
|
||||||
|
# - 长宽比较大(接近等宽,ar > 0.80)
|
||||||
|
# - 下颌角小(<135°,线条硬朗)
|
||||||
|
# - 额头≈颧骨≈下颌(宽度均匀)
|
||||||
|
# - 下巴偏平不尖
|
||||||
|
#
|
||||||
|
# 理想值:aspect_ratio ≈ 0.85-0.95, jaw_angle ≈ 110-125°
|
||||||
|
# ==========================================
|
||||||
|
s = 0.0
|
||||||
|
s += _score_range(ar, 0.78, 0.95, peak=0.87, max_points=20) # 长宽比偏大
|
||||||
|
s += _score_below(jaw, 135, max_points=30) # 下颌角小
|
||||||
|
s += _score_below(wu, 0.10, max_points=20) # 宽度均匀
|
||||||
|
s += _score_above(cs, 0.62, max_points=15) # 下巴偏宽
|
||||||
|
s += _score_range(jr, 0.90, 1.05, peak=0.96, max_points=15) # 下颌宽接近面宽
|
||||||
|
scores['方形脸'] = s
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 6. 长形脸 (Long/Oblong)
|
||||||
|
# ==========================================
|
||||||
|
# 核心特征:
|
||||||
|
# - 长宽比低(< 0.72,脸明显比宽长很多)
|
||||||
|
# - 面部高度 > 宽度的 1.4 倍
|
||||||
|
# - 额头略宽于下巴
|
||||||
|
# - 整体修长
|
||||||
|
#
|
||||||
|
# 理想值:aspect_ratio ≈ 0.58-0.70
|
||||||
|
# ==========================================
|
||||||
|
s = 0.0
|
||||||
|
s += _score_below(ar, 0.72, max_points=35) # 长宽比低
|
||||||
|
s += _score_above(fh, features['face_width'] * 1.35, max_points=20) # 高>宽*1.35
|
||||||
|
s += _score_range(tap, 0.0, 0.20, peak=0.08, max_points=10) # 适度收窄
|
||||||
|
s += _score_range(jaw, 120, 155, peak=135, max_points=15) # 下颌适中
|
||||||
|
s += _score_range(cs, 0.45, 0.72, peak=0.58, max_points=10) # 下巴适中
|
||||||
|
s += _score_range(wu, 0.02, 0.15, peak=0.08, max_points=10) # 宽度比较均匀
|
||||||
|
scores['长形脸'] = s
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 7. 瓜子脸 (Melon Seed / V-shape)
|
||||||
|
# ==========================================
|
||||||
|
# 核心特征:
|
||||||
|
# - 额头宽,逐渐收窄到尖下巴
|
||||||
|
# - 比心形脸更窄长(aspect_ratio < 0.82)
|
||||||
|
# - 颧骨不超过额头
|
||||||
|
# - 下巴尖锐(V 线条)
|
||||||
|
# - 整体线条流畅
|
||||||
|
#
|
||||||
|
# 理想值:taper ≈ 0.20-0.35, chin_sharpness ≈ 0.30-0.55
|
||||||
|
# ==========================================
|
||||||
|
s = 0.0
|
||||||
|
s += _score_above(tap, 0.15, max_points=20) # 额头宽于下巴
|
||||||
|
s += _score_below(ar, 0.82, max_points=15) # 偏长
|
||||||
|
s += _score_below(cs, 0.58, max_points=25) # 下巴尖
|
||||||
|
s += _score_below(sw, fw * 1.02, max_points=15) # 颧骨≤额头
|
||||||
|
s += _score_below(jw, fw * 0.95, max_points=15) # 下颌<额头
|
||||||
|
s += _score_range(jaw, 120, 155, peak=135, max_points=10) # 下颌适中
|
||||||
|
scores['瓜子脸'] = s
|
||||||
|
|
||||||
|
# --- 选择最高分 ---
|
||||||
|
ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
|
||||||
|
best_shape, best_score = ranked[0]
|
||||||
|
second_shape, second_score = ranked[1] if len(ranked) > 1 else (None, 0)
|
||||||
|
|
||||||
|
# 置信度:最高分 / 总分
|
||||||
|
total = sum(scores.values())
|
||||||
|
confidence = best_score / total if total > 0 else 0.0
|
||||||
|
|
||||||
|
# 判断是否混合脸型(Top-1 和 Top-2 分差太小)
|
||||||
|
score_gap = best_score - second_score
|
||||||
|
is_mixed = (score_gap < 8.0 and best_score > 30.0)
|
||||||
|
|
||||||
|
details = {
|
||||||
|
'scores': scores,
|
||||||
|
'ranked': ranked,
|
||||||
|
'confidence': confidence,
|
||||||
|
'score_gap': score_gap,
|
||||||
|
'is_mixed': is_mixed,
|
||||||
|
'second_shape': second_shape,
|
||||||
|
'second_score': second_score,
|
||||||
|
} if return_details else None
|
||||||
|
|
||||||
|
return best_shape, confidence, details
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 第四部分:评分辅助函数
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def _score_range(
|
||||||
|
value: float,
|
||||||
|
low: float,
|
||||||
|
high: float,
|
||||||
|
peak: float,
|
||||||
|
max_points: float = 10.0
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
在 [low, high] 范围内评分,peak 处满分。
|
||||||
|
范围外线性衰减到 0。
|
||||||
|
使用三角窗函数(triangular window)。
|
||||||
|
|
||||||
|
例:_score_range(0.77, 0.70, 0.85, peak=0.77, max_points=25)
|
||||||
|
→ value == peak → 返回 25.0
|
||||||
|
→ value == low → 返回 0.0(边界)
|
||||||
|
→ value 在 peak 和 low 之间 → 线性插值
|
||||||
|
"""
|
||||||
|
if value < low or value > high:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
if value == peak:
|
||||||
|
return max_points
|
||||||
|
|
||||||
|
if value < peak:
|
||||||
|
# 在 [low, peak] 区间线性上升
|
||||||
|
ratio = (value - low) / (peak - low + 1e-8)
|
||||||
|
else:
|
||||||
|
# 在 [peak, high] 区间线性下降
|
||||||
|
ratio = (high - value) / (high - peak + 1e-8)
|
||||||
|
|
||||||
|
return max_points * ratio
|
||||||
|
|
||||||
|
|
||||||
|
def _score_above(value: float, threshold: float, max_points: float = 10.0) -> float:
|
||||||
|
"""
|
||||||
|
value >= threshold 时给满分,低于则线性衰减。
|
||||||
|
衰减区间:[threshold * 0.7, threshold]
|
||||||
|
"""
|
||||||
|
if value >= threshold:
|
||||||
|
return max_points
|
||||||
|
floor = threshold * 0.7
|
||||||
|
if value <= floor:
|
||||||
|
return 0.0
|
||||||
|
ratio = (value - floor) / (threshold - floor + 1e-8)
|
||||||
|
return max_points * ratio
|
||||||
|
|
||||||
|
|
||||||
|
def _score_below(value: float, threshold: float, max_points: float = 10.0) -> float:
|
||||||
|
"""
|
||||||
|
value <= threshold 时给满分,高于则线性衰减。
|
||||||
|
衰减区间:[threshold, threshold * 1.3]
|
||||||
|
"""
|
||||||
|
if value <= threshold:
|
||||||
|
return max_points
|
||||||
|
ceil = threshold * 1.3
|
||||||
|
if value >= ceil:
|
||||||
|
return 0.0
|
||||||
|
ratio = (ceil - value) / (ceil - threshold + 1e-8)
|
||||||
|
return max_points * ratio
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 第五部分:完整调用示例
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def classify_from_mediapipe(multi_face_landmarks) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
完整调用示例:从 Mediapipe 结果到脸型分类。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
multi_face_landmarks: mediapipe FaceMesh 的结果
|
||||||
|
result.multi_face_landmarks
|
||||||
|
|
||||||
|
返回:
|
||||||
|
每张脸的分类结果列表
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
for face_lms in multi_face_landmarks:
|
||||||
|
features = extract_face_features(face_lms.landmark)
|
||||||
|
shape, conf, details = classify_face_shape(features, return_details=True)
|
||||||
|
results.append({
|
||||||
|
'face_shape': shape,
|
||||||
|
'confidence': conf,
|
||||||
|
'features': features,
|
||||||
|
'details': details,
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 第六部分:混合脸型输出(可选)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def get_mixed_description(details: Dict) -> str:
|
||||||
|
"""
|
||||||
|
当检测到混合脸型时,生成描述文本。
|
||||||
|
|
||||||
|
例:"鹅蛋脸(偏瓜子脸)"
|
||||||
|
"""
|
||||||
|
if not details or not details.get('is_mixed'):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
shape1 = details['ranked'][0][0]
|
||||||
|
shape2 = details['ranked'][1][0]
|
||||||
|
return f"{shape1}(偏{shape2})"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、各脸型详细特征说明
|
||||||
|
|
||||||
|
### 1. 圆形脸 (Round)
|
||||||
|
|
||||||
|
| 特征 | 典型值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| aspect_ratio | 0.88-1.0 | 面部宽度和长度几乎相等 |
|
||||||
|
| jaw_angle | 140-160° | 下颌线条圆润 |
|
||||||
|
| width_uniformity | < 0.08 | 额头、颧骨、下颌宽度接近 |
|
||||||
|
| chin_sharpness | 0.65-0.85 | 下巴圆润,不尖锐 |
|
||||||
|
| taper_ratio | -0.05 ~ 0.08 | 额头到下巴几乎不收窄 |
|
||||||
|
|
||||||
|
**视觉特征**:面部轮廓呈圆形,没有明显棱角,看起来年轻可爱。
|
||||||
|
|
||||||
|
### 2. 心形脸 (Heart)
|
||||||
|
|
||||||
|
| 特征 | 典型值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| taper_ratio | 0.25-0.40 | 额头明显宽于下巴 |
|
||||||
|
| chin_sharpness | 0.35-0.55 | 下巴尖细 |
|
||||||
|
| forehead_ratio | > 0.92 | 额头宽,接近面宽 |
|
||||||
|
| jaw_angle | 120-145° | 下颌适中 |
|
||||||
|
|
||||||
|
**视觉特征**:上宽下窄,额头饱满,下巴尖俏,像心形。
|
||||||
|
|
||||||
|
### 3. 菱形脸 (Diamond)
|
||||||
|
|
||||||
|
| 特征 | 典型值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 颧骨宽度 | > 额头×1.05 | 颧骨明显最突出 |
|
||||||
|
| 颧骨宽度 | > 下颌×1.10 | 远宽于下颌 |
|
||||||
|
| forehead_ratio | < 0.92 | 额头偏窄 |
|
||||||
|
| jaw_ratio | < 0.92 | 下颌偏窄 |
|
||||||
|
| width_uniformity | > 0.12 | 宽度差异明显 |
|
||||||
|
|
||||||
|
**视觉特征**:颧骨最宽,额头和下巴都偏窄,呈菱形/钻石轮廓。
|
||||||
|
|
||||||
|
### 4. 鹅蛋脸 (Oval)
|
||||||
|
|
||||||
|
| 特征 | 典型值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| aspect_ratio | 0.75-0.82 | 长宽比理想 |
|
||||||
|
| jaw_angle | 130-148° | 轮廓柔和 |
|
||||||
|
| taper_ratio | 0.05-0.15 | 适度收窄 |
|
||||||
|
| chin_sharpness | 0.55-0.68 | 下巴圆润偏尖 |
|
||||||
|
| width_uniformity | < 0.10 | 宽度比较均匀 |
|
||||||
|
|
||||||
|
**视觉特征**:被认为是最理想的脸型,比例匀称,轮廓流畅。
|
||||||
|
|
||||||
|
### 5. 方形脸 (Square)
|
||||||
|
|
||||||
|
| 特征 | 典型值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| aspect_ratio | 0.85-0.92 | 接近等宽 |
|
||||||
|
| jaw_angle | 108-128° | 下颌角明显,线条硬朗 |
|
||||||
|
| width_uniformity | < 0.08 | 三处宽度接近 |
|
||||||
|
| chin_sharpness | > 0.65 | 下巴偏平宽 |
|
||||||
|
| jaw_ratio | > 0.92 | 下颌宽接近面宽 |
|
||||||
|
|
||||||
|
**视觉特征**:额头、颧骨、下颌宽度接近,下颌角明显,给人干练印象。
|
||||||
|
|
||||||
|
### 6. 长形脸 (Long/Oblong)
|
||||||
|
|
||||||
|
| 特征 | 典型值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| aspect_ratio | 0.58-0.70 | 面部明显偏长 |
|
||||||
|
| face_height/face_width | > 1.40 | 高度远超宽度 |
|
||||||
|
| taper_ratio | 0.05-0.15 | 适度收窄 |
|
||||||
|
| jaw_angle | 125-145° | 下颌适中 |
|
||||||
|
|
||||||
|
**视觉特征**:面部修长,整体偏窄,额头较饱满。
|
||||||
|
|
||||||
|
### 7. 瓜子脸 (Melon Seed / V-shape)
|
||||||
|
|
||||||
|
| 特征 | 典型值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| taper_ratio | 0.20-0.35 | 额头宽于下巴 |
|
||||||
|
| aspect_ratio | 0.65-0.80 | 偏长 |
|
||||||
|
| chin_sharpness | 0.30-0.55 | V 形尖下巴 |
|
||||||
|
| 颧骨 | ≤ 额头宽度 | 颧骨不突出 |
|
||||||
|
| jaw_width | < 额头×0.95 | 下颌收窄 |
|
||||||
|
|
||||||
|
**视觉特征**:额头较宽,向下逐渐收窄到尖下巴,整体呈瓜子形。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、关键参数含义与阈值设定理由
|
||||||
|
|
||||||
|
### aspect_ratio(面部长宽比)
|
||||||
|
|
||||||
|
```
|
||||||
|
计算方式:face_width / face_height
|
||||||
|
```
|
||||||
|
|
||||||
|
| 范围 | 脸型倾向 | 理由 |
|
||||||
|
|------|----------|------|
|
||||||
|
| < 0.70 | 长形脸 | 脸长明显大于宽 |
|
||||||
|
| 0.70-0.85 | 鹅蛋/心形/瓜子 | 多数亚洲人的标准比例 |
|
||||||
|
| 0.85-1.0 | 圆形/方形 | 脸宽接近脸长 |
|
||||||
|
|
||||||
|
**设定理由**:根据 Farkas 面部测量数据,东亚人群面宽/面高比通常在 0.75-0.88 之间。0.85 和 0.70 是自然的分界点。
|
||||||
|
|
||||||
|
### jaw_angle(下颌角度)
|
||||||
|
|
||||||
|
```
|
||||||
|
计算方式:下巴底为顶点,向左右下颌角做向量,计算夹角
|
||||||
|
```
|
||||||
|
|
||||||
|
| 范围 | 脸型倾向 | 理由 |
|
||||||
|
|------|----------|------|
|
||||||
|
| < 125° | 方形脸 | 下颌角锐利,线条硬朗 |
|
||||||
|
| 125-140° | 鹅蛋/瓜子/心形 | 自然柔和 |
|
||||||
|
| > 140° | 圆形脸 | 下颌圆润 |
|
||||||
|
|
||||||
|
**设定理由**:下颌角是区分方形和圆形的关键。方形脸 gonion 角通常在 110-125°,圆形脸在 140-155°。
|
||||||
|
|
||||||
|
### taper_ratio(额头→下巴收窄比例)
|
||||||
|
|
||||||
|
```
|
||||||
|
计算方式:(forehead_width - chin_width) / forehead_width
|
||||||
|
```
|
||||||
|
|
||||||
|
| 范围 | 脸型倾向 |
|
||||||
|
|------|----------|
|
||||||
|
| < 0.05 | 圆形/方形(无收窄)|
|
||||||
|
| 0.05-0.15 | 鹅蛋/长形(适度收窄)|
|
||||||
|
| > 0.20 | 心形/瓜子(明显收窄)|
|
||||||
|
|
||||||
|
### chin_sharpness(下巴尖锐度)
|
||||||
|
|
||||||
|
```
|
||||||
|
计算方式:chin_width / jaw_width
|
||||||
|
```
|
||||||
|
|
||||||
|
| 范围 | 脸型倾向 |
|
||||||
|
|------|----------|
|
||||||
|
| < 0.50 | 尖下巴(瓜子/心形)|
|
||||||
|
| 0.50-0.65 | 适中(鹅蛋)|
|
||||||
|
| > 0.65 | 宽下巴(圆形/方形)|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、优化点说明
|
||||||
|
|
||||||
|
### 5.1 从「硬规则」到「评分制」
|
||||||
|
|
||||||
|
**原始方案**:每条规则是独立的 if 判断,可能同时满足多条,也可能都不满足。
|
||||||
|
|
||||||
|
**优化方案**:每种脸型计算 0-100 的匹配度分数,取最高分。
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 原始:可能冲突
|
||||||
|
if 0.85 <= width_ratio <= 1.0: # 圆形脸
|
||||||
|
...
|
||||||
|
if jaw_angle < 130: # 方形脸
|
||||||
|
...
|
||||||
|
# 同一张脸可能同时满足或都不满足!
|
||||||
|
|
||||||
|
# 优化:评分制,必然有结果
|
||||||
|
scores = {'圆形脸': 72.5, '方形脸': 45.0, ...}
|
||||||
|
# 取最高分 → 圆形脸,置信度 72.5/total
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 三角窗评分函数
|
||||||
|
|
||||||
|
每个特征的贡献不是 0/1 的硬切换,而是使用**三角窗函数**平滑过渡:
|
||||||
|
|
||||||
|
```
|
||||||
|
满分
|
||||||
|
/\
|
||||||
|
/ \
|
||||||
|
/ \
|
||||||
|
/ \
|
||||||
|
_____/__ \____
|
||||||
|
low peak high
|
||||||
|
```
|
||||||
|
|
||||||
|
好处:在阈值边界处不会产生跳变,结果更稳定。
|
||||||
|
|
||||||
|
### 5.3 增加关键点精度
|
||||||
|
|
||||||
|
| 测量 | 原始方案 | 优化方案 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| 额头宽度 | 234-454(颧骨点)| 127-356(太阳穴点)|
|
||||||
|
| 下巴宽度 | 未明确 | 136-365(下巴缘)|
|
||||||
|
| 下颌宽度 | 172-397 | 172-397(保持,下颌角)|
|
||||||
|
|
||||||
|
**改进理由**:234/454 是颧弓最外侧点,用它们测"额头宽度"会把颧骨宽度误当额头宽度。改用 127/356 太阳穴点更准确。
|
||||||
|
|
||||||
|
### 5.4 混合脸型检测
|
||||||
|
|
||||||
|
当 Top-1 和 Top-2 分差小于 8 分时,判定为混合脸型:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 例:鹅蛋脸 65 分,瓜子脸 62 分 → 分差 3 < 8
|
||||||
|
# 输出:"鹅蛋脸(偏瓜子脸)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 置信度输出
|
||||||
|
|
||||||
|
```python
|
||||||
|
confidence = best_score / total_score
|
||||||
|
# > 0.25 → 高置信度,结果明确
|
||||||
|
# 0.18-0.25 → 中等,有一定混合
|
||||||
|
# < 0.18 → 低置信度,建议人工复核
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、边界情况处理建议
|
||||||
|
|
||||||
|
### 6.1 人脸偏转(非正脸)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 检测左右对称性,偏转过大时拒绝判断
|
||||||
|
def check_symmetry(landmarks):
|
||||||
|
left_eye = landmarks[33]
|
||||||
|
right_eye = landmarks[263]
|
||||||
|
nose_tip = landmarks[168]
|
||||||
|
|
||||||
|
eye_mid_x = (left_eye.x + right_eye.x) / 2
|
||||||
|
symmetry = abs(eye_mid_x - nose_tip.x)
|
||||||
|
|
||||||
|
if symmetry > 0.03: # 偏移过大
|
||||||
|
return False, "检测到人脸偏转,建议正脸拍摄"
|
||||||
|
return True, ""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 表情影响
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 微笑会改变下巴形状,检测嘴部张开度
|
||||||
|
def check_expression(landmarks):
|
||||||
|
upper_lip = landmarks[13]
|
||||||
|
lower_lip = landmarks[14]
|
||||||
|
mouth_open = abs(upper_lip.y - lower_lip.y)
|
||||||
|
|
||||||
|
if mouth_open > 0.05: # 嘴巴张大
|
||||||
|
return False, "检测到嘴巴张开,建议自然闭合"
|
||||||
|
return True, ""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 多特征都低分
|
||||||
|
|
||||||
|
```python
|
||||||
|
if best_score < 25.0:
|
||||||
|
return "无法确定", 0.0, {"reason": "特征不够明显,无法准确分类"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 与正脸自拍的差异
|
||||||
|
|
||||||
|
建议在分类前对图像做正脸对齐(使用 Mediapipe 的 transform),确保额头在上、下巴在下,左右对称。
|
||||||
|
|
||||||
|
### 6.5 性别/年龄差异
|
||||||
|
|
||||||
|
男性下颌通常更宽,女性下巴更尖。分类阈值可以考虑:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 如果有性别信息(可由另一个分类器提供)
|
||||||
|
if gender == 'male':
|
||||||
|
jaw_angle_threshold += 3 # 男性下颌角自然偏小
|
||||||
|
else:
|
||||||
|
chin_sharpness_threshold -= 0.03 # 女性下巴自然偏尖
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、实现建议和注意事项
|
||||||
|
|
||||||
|
### 7.1 预处理
|
||||||
|
|
||||||
|
```python
|
||||||
|
import mediapipe as mp
|
||||||
|
|
||||||
|
mp_face_mesh = mp.solutions.face_mesh
|
||||||
|
|
||||||
|
with mp_face_mesh.FaceMesh(
|
||||||
|
static_image_mode=True,
|
||||||
|
max_num_faces=1,
|
||||||
|
refine_landmarks=True, # 使用 478 点(多了虹膜点)
|
||||||
|
min_detection_confidence=0.5,
|
||||||
|
) as face_mesh:
|
||||||
|
results = face_mesh.process(rgb_image)
|
||||||
|
if results.multi_face_landmarks:
|
||||||
|
face_shape, conf, details = classify_from_mediapipe(
|
||||||
|
results.multi_face_landmarks
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 性能注意事项
|
||||||
|
|
||||||
|
- Mediapipe FaceMesh 在 CPU 上 ~10ms/帧,足够实时
|
||||||
|
- 关键点 z 坐标精度有限,距离计算建议用 (x, y) 2D 即可
|
||||||
|
- 如需更高精度,可用 `refine_landmarks=True` 获取 478 点
|
||||||
|
|
||||||
|
### 7.3 阈值校准
|
||||||
|
|
||||||
|
当前阈值基于以下来源综合设定:
|
||||||
|
|
||||||
|
1. **Farkas 面部测量学数据**(经典人体测量参考)
|
||||||
|
2. **亚洲人脸型分布统计**(鹅蛋脸和瓜子脸比例较高)
|
||||||
|
3. **Mediapipe 归一化坐标特性**(坐标已归一化到 0-1)
|
||||||
|
|
||||||
|
建议在实际部署后收集样本数据进行微调:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 收集误分类案例,统计特征分布
|
||||||
|
# 使用 ROC 曲线优化各阈值
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 2D vs 3D 距离
|
||||||
|
|
||||||
|
Mediapipe 返回的 landmark 包含 z 坐标,但 z 精度不如 x/y。建议:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 推荐方案:仅用 x, y 计算(忽略 z)
|
||||||
|
def pt_2d(idx):
|
||||||
|
lm = landmarks[idx]
|
||||||
|
return np.array([lm.x, lm.y])
|
||||||
|
|
||||||
|
# 高精度方案:用 z 但加权降低
|
||||||
|
def pt_weighted(idx):
|
||||||
|
lm = landmarks[idx]
|
||||||
|
return np.array([lm.x, lm.y, lm.z * 0.5]) # z 权重减半
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.5 与原始规则的对比
|
||||||
|
|
||||||
|
| 维度 | 原始规则 | 优化后 |
|
||||||
|
|------|----------|--------|
|
||||||
|
| 判断方式 | 硬 if-else(可能冲突/遗漏)| 评分制(必然有结果)|
|
||||||
|
| 关键点 | 12 个 | 16 个(增加太阳穴、下巴缘点)|
|
||||||
|
| 特征数 | 5-6 个 | 15 个(含复合特征)|
|
||||||
|
| 输出 | 单一标签 | 标签 + 置信度 + 混合脸型 |
|
||||||
|
| 边界处理 | 无 | 三角窗平滑 + 低分兜底 |
|
||||||
|
| 可调性 | 改阈值需要理解全部分支 | 改 `peak` 值即可微调 |
|
||||||
|
| 代码行数 | ~60 行 | ~300 行(含注释)|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、测试用例参考
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 单元测试伪代码
|
||||||
|
test_cases = [
|
||||||
|
# (features_dict, expected_shape)
|
||||||
|
({'aspect_ratio': 0.92, 'jaw_angle': 148, 'taper_ratio': 0.03,
|
||||||
|
'chin_sharpness': 0.75, 'width_uniformity': 0.06,
|
||||||
|
'forehead_ratio': 0.98, 'jaw_ratio': 0.95, 'chin_ratio': 0.70,
|
||||||
|
'face_curve_score': 0.18, ...}, '圆形脸'),
|
||||||
|
|
||||||
|
({'aspect_ratio': 0.77, 'jaw_angle': 135, 'taper_ratio': 0.10,
|
||||||
|
'chin_sharpness': 0.60, 'width_uniformity': 0.08,
|
||||||
|
'forehead_ratio': 0.98, 'jaw_ratio': 0.92, 'chin_ratio': 0.62,
|
||||||
|
'face_curve_score': 0.19, ...}, '鹅蛋脸'),
|
||||||
|
|
||||||
|
# ... 更多测试用例
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*报告结束。代码可直接集成到 Mediapipe 人脸分析流水线中。*
|
||||||
@@ -0,0 +1,874 @@
|
|||||||
|
"""
|
||||||
|
face_shape_classifier.py
|
||||||
|
基于 Mediapipe 468 点人脸关键点的脸型分类系统
|
||||||
|
|
||||||
|
支持的脸型:圆形脸 / 心形脸 / 菱形脸 / 鹅蛋脸 / 方形脸 / 长形脸 / 瓜子脸
|
||||||
|
分类策略:多维度特征提取 → 加权评分 → 置信度判断
|
||||||
|
|
||||||
|
实现说明:
|
||||||
|
- 特征与评分框架参考 face_shape_classification.md
|
||||||
|
- 距离一律在像素坐标系下用 2D 计算(归一化坐标未校正宽高比会导致面宽被夸大)
|
||||||
|
- 阈值按 MediaPipe 实测分布做了校准
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import mediapipe as mp
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
ImageInput = Union[str, Path, np.ndarray]
|
||||||
|
|
||||||
|
|
||||||
|
class FaceLandmarks:
|
||||||
|
"""Mediapipe 关键点索引(脸型分析用)。"""
|
||||||
|
|
||||||
|
FOREHEAD_TOP = 10
|
||||||
|
CHIN_BOTTOM = 152
|
||||||
|
|
||||||
|
# 额侧(比 127/356 更贴近发际两侧,避免把太阳穴外轮廓算成额头)
|
||||||
|
LEFT_FOREHEAD = 54
|
||||||
|
RIGHT_FOREHEAD = 284
|
||||||
|
|
||||||
|
# 太阳穴辅助
|
||||||
|
LEFT_TEMPLE = 21
|
||||||
|
RIGHT_TEMPLE = 251
|
||||||
|
|
||||||
|
# 颧骨最外侧
|
||||||
|
LEFT_CHEEK = 234
|
||||||
|
RIGHT_CHEEK = 454
|
||||||
|
|
||||||
|
# 下颌角(比 172/397 更接近 gonion)
|
||||||
|
LEFT_JAW_ANGLE = 132
|
||||||
|
RIGHT_JAW_ANGLE = 361
|
||||||
|
|
||||||
|
# 下巴缘
|
||||||
|
LEFT_CHIN = 136
|
||||||
|
RIGHT_CHIN = 365
|
||||||
|
|
||||||
|
|
||||||
|
def extract_face_features(landmarks, image_size: Tuple[int, int]) -> Dict[str, float]:
|
||||||
|
"""
|
||||||
|
从 Mediapipe 关键点提取脸型特征。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
landmarks: NormalizedLandmark 列表
|
||||||
|
image_size: (width, height),用于还原像素坐标
|
||||||
|
"""
|
||||||
|
w, h = image_size
|
||||||
|
|
||||||
|
def pt(idx: int) -> np.ndarray:
|
||||||
|
lm = landmarks[idx]
|
||||||
|
return np.array([lm.x * w, lm.y * h], dtype=float)
|
||||||
|
|
||||||
|
def dist(p1: np.ndarray, p2: np.ndarray) -> float:
|
||||||
|
return float(np.linalg.norm(p1 - p2))
|
||||||
|
|
||||||
|
def xwidth(p1: np.ndarray, p2: np.ndarray) -> float:
|
||||||
|
"""横向宽度(脸型比例更稳定)。"""
|
||||||
|
return abs(float(p1[0] - p2[0]))
|
||||||
|
|
||||||
|
forehead_top = pt(FaceLandmarks.FOREHEAD_TOP)
|
||||||
|
chin_bottom = pt(FaceLandmarks.CHIN_BOTTOM)
|
||||||
|
|
||||||
|
left_forehead = pt(FaceLandmarks.LEFT_FOREHEAD)
|
||||||
|
right_forehead = pt(FaceLandmarks.RIGHT_FOREHEAD)
|
||||||
|
left_temple = pt(FaceLandmarks.LEFT_TEMPLE)
|
||||||
|
right_temple = pt(FaceLandmarks.RIGHT_TEMPLE)
|
||||||
|
left_cheek = pt(FaceLandmarks.LEFT_CHEEK)
|
||||||
|
right_cheek = pt(FaceLandmarks.RIGHT_CHEEK)
|
||||||
|
left_jaw = pt(FaceLandmarks.LEFT_JAW_ANGLE)
|
||||||
|
right_jaw = pt(FaceLandmarks.RIGHT_JAW_ANGLE)
|
||||||
|
left_chin = pt(FaceLandmarks.LEFT_CHIN)
|
||||||
|
right_chin = pt(FaceLandmarks.RIGHT_CHIN)
|
||||||
|
|
||||||
|
face_height = dist(forehead_top, chin_bottom)
|
||||||
|
forehead_width = xwidth(left_forehead, right_forehead)
|
||||||
|
temple_width = xwidth(left_temple, right_temple)
|
||||||
|
cheekbone_width = xwidth(left_cheek, right_cheek)
|
||||||
|
jaw_width = xwidth(left_jaw, right_jaw)
|
||||||
|
chin_width = xwidth(left_chin, right_chin)
|
||||||
|
face_width = cheekbone_width
|
||||||
|
|
||||||
|
eps = 1e-8
|
||||||
|
|
||||||
|
# 下巴顶点夹角:越大越宽圆,越小越尖
|
||||||
|
v_left = left_jaw - chin_bottom
|
||||||
|
v_right = right_jaw - chin_bottom
|
||||||
|
cos_val = np.dot(v_left, v_right) / (
|
||||||
|
np.linalg.norm(v_left) * np.linalg.norm(v_right) + eps
|
||||||
|
)
|
||||||
|
cos_val = float(np.clip(cos_val, -1.0, 1.0))
|
||||||
|
jaw_angle = math.degrees(math.acos(cos_val))
|
||||||
|
|
||||||
|
# 下颌角(左):颧骨→下颌角→下巴,越小越方正硬朗
|
||||||
|
v1 = left_cheek - left_jaw
|
||||||
|
v2 = chin_bottom - left_jaw
|
||||||
|
cos_g = np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2) + eps)
|
||||||
|
cos_g = float(np.clip(cos_g, -1.0, 1.0))
|
||||||
|
gonion_angle = math.degrees(math.acos(cos_g))
|
||||||
|
|
||||||
|
aspect_ratio = face_width / (face_height + eps)
|
||||||
|
length_ratio = face_height / (face_width + eps)
|
||||||
|
taper_ratio = (forehead_width - chin_width) / (forehead_width + eps)
|
||||||
|
cheek_taper = (cheekbone_width - jaw_width) / (cheekbone_width + eps)
|
||||||
|
|
||||||
|
forehead_ratio = forehead_width / (face_width + eps)
|
||||||
|
temple_ratio = temple_width / (face_width + eps)
|
||||||
|
jaw_ratio = jaw_width / (face_width + eps)
|
||||||
|
chin_ratio = chin_width / (face_width + eps)
|
||||||
|
chin_sharpness = chin_width / (jaw_width + eps)
|
||||||
|
|
||||||
|
widths = [forehead_width, cheekbone_width, jaw_width]
|
||||||
|
width_uniformity = (max(widths) - min(widths)) / (max(widths) + eps)
|
||||||
|
|
||||||
|
jaw_midpoint = (left_jaw + right_jaw) / 2.0
|
||||||
|
face_curve_score = dist(jaw_midpoint, chin_bottom) / (face_height + eps)
|
||||||
|
|
||||||
|
forehead_vs_jaw = forehead_width / (jaw_width + eps)
|
||||||
|
cheek_dominance = cheekbone_width / ((forehead_width + jaw_width) / 2.0 + eps)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"face_height": face_height,
|
||||||
|
"face_width": face_width,
|
||||||
|
"forehead_width": forehead_width,
|
||||||
|
"temple_width": temple_width,
|
||||||
|
"cheekbone_width": cheekbone_width,
|
||||||
|
"jaw_width": jaw_width,
|
||||||
|
"chin_width": chin_width,
|
||||||
|
"aspect_ratio": aspect_ratio,
|
||||||
|
"length_ratio": length_ratio,
|
||||||
|
"taper_ratio": taper_ratio,
|
||||||
|
"cheek_taper": cheek_taper,
|
||||||
|
"forehead_ratio": forehead_ratio,
|
||||||
|
"temple_ratio": temple_ratio,
|
||||||
|
"cheekbone_ratio": 1.0,
|
||||||
|
"jaw_ratio": jaw_ratio,
|
||||||
|
"chin_ratio": chin_ratio,
|
||||||
|
"jaw_angle": jaw_angle,
|
||||||
|
"gonion_angle": gonion_angle,
|
||||||
|
"chin_sharpness": chin_sharpness,
|
||||||
|
"width_uniformity": width_uniformity,
|
||||||
|
"face_curve_score": face_curve_score,
|
||||||
|
"forehead_vs_jaw": forehead_vs_jaw,
|
||||||
|
"cheek_dominance": cheek_dominance,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 参考分布:1143 张样本(1093 张脸型测试集合 + 44 张真实照片 + 6 张标注图)
|
||||||
|
# 的稳健统计量 (中位数, 稳健标准差=IQR/1.349),把绝对测量值转成 z 分数。
|
||||||
|
# 绝对阈值会随镜头、人群漂移;z 分数让评分只依赖"相对人群偏离多少"。
|
||||||
|
#
|
||||||
|
# 早前这组数字只由 50 张样本估得,相对全量人群有系统性偏移
|
||||||
|
# (jaw_ratio 中位偏低 0.44 sd、taper_ratio 偏高 0.53 sd 等),
|
||||||
|
# 恰好三项都在给方形脸加分,是方形脸占比虚高的主因之一。
|
||||||
|
# ============================================================
|
||||||
|
REFERENCE_STATS: Dict[str, Tuple[float, float]] = {
|
||||||
|
"aspect_ratio": (0.8294, 0.0281),
|
||||||
|
"jaw_angle": (88.6299, 3.9040),
|
||||||
|
"gonion_angle": (144.5014, 3.4991),
|
||||||
|
"taper_ratio": (0.2671, 0.0376),
|
||||||
|
"forehead_ratio": (0.8975, 0.0204),
|
||||||
|
"jaw_ratio": (0.9286, 0.0138),
|
||||||
|
"chin_ratio": (0.6578, 0.0217),
|
||||||
|
"chin_sharpness": (0.7092, 0.0155),
|
||||||
|
"width_uniformity": (0.1028, 0.0184),
|
||||||
|
"forehead_vs_jaw": (0.9669, 0.0341),
|
||||||
|
"cheek_dominance": (1.0953, 0.0084),
|
||||||
|
"face_curve_score": (0.3940, 0.0210),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 匹配容差(以 z 为单位):偏离目标 1 个容差,该项得分降到约 0.61
|
||||||
|
MATCH_TOLERANCE = 1.0
|
||||||
|
|
||||||
|
# 每种脸型的原型:特征 -> (目标 z, 权重, 模式)
|
||||||
|
# 'high' 超过目标即满分(越极端越像)
|
||||||
|
# 'low' 低于目标即满分
|
||||||
|
# 'peak' 双侧衰减(该特征应当落在目标附近)
|
||||||
|
#
|
||||||
|
# 只使用互相独立的特征:length_ratio(=1/aspect_ratio) 与
|
||||||
|
# cheek_taper(=1-jaw_ratio) 是重复信号,纳入会让对应脸型拿双倍权重。
|
||||||
|
#
|
||||||
|
# 靶心与权重的来源:先按 1093 张测试集中各原始分组(方形脸/长形脸/瓜子脸/
|
||||||
|
# 标准脸/娃娃脸)的实测 z 画像给出靶心,再在「6 张标注图判定不变」的硬约束下
|
||||||
|
# 做带边界的退火微调(权重限 [0.5,5]、靶心限 [-2,2],并惩罚失去区分力的空项)。
|
||||||
|
SHAPE_PROTOTYPES: Dict[str, Dict[str, Tuple[float, float, str]]] = {
|
||||||
|
# 宽、短,下颌圆钝
|
||||||
|
"圆形脸": {
|
||||||
|
"aspect_ratio": (+2.00, 5.00, "high"),
|
||||||
|
"jaw_angle": (-0.07, 2.28, "high"),
|
||||||
|
"chin_sharpness": (+0.15, 0.50, "peak"),
|
||||||
|
"face_curve_score": (+0.17, 3.67, "low"),
|
||||||
|
},
|
||||||
|
# 额头宽、下颌与下巴明显收窄
|
||||||
|
"心形脸": {
|
||||||
|
"forehead_vs_jaw": (+1.94, 1.62, "high"),
|
||||||
|
"taper_ratio": (+2.00, 1.09, "high"),
|
||||||
|
"jaw_ratio": (+1.02, 1.86, "low"),
|
||||||
|
"chin_ratio": (-1.70, 1.71, "low"),
|
||||||
|
"aspect_ratio": (+1.69, 1.31, "peak"),
|
||||||
|
},
|
||||||
|
# 颧骨最突出,额头与下颌都窄
|
||||||
|
"菱形脸": {
|
||||||
|
"cheek_dominance": (+1.62, 2.84, "high"),
|
||||||
|
"width_uniformity": (+1.24, 2.12, "high"),
|
||||||
|
"forehead_ratio": (-1.57, 4.58, "low"),
|
||||||
|
"jaw_ratio": (-0.11, 1.85, "low"),
|
||||||
|
"aspect_ratio": (+1.31, 2.60, "peak"),
|
||||||
|
},
|
||||||
|
# 各项都接近人群中位——没有突出特征即为匀称
|
||||||
|
"鹅蛋脸": {
|
||||||
|
"aspect_ratio": (-0.26, 5.00, "peak"),
|
||||||
|
"jaw_ratio": (+0.45, 2.47, "peak"),
|
||||||
|
"chin_sharpness": (+0.52, 2.88, "peak"),
|
||||||
|
"width_uniformity": (+0.53, 0.94, "peak"),
|
||||||
|
"cheek_dominance": (-0.51, 1.14, "peak"),
|
||||||
|
},
|
||||||
|
# 下颌与下巴都宽、几乎不收窄、下颌角锐利、额头相对窄。
|
||||||
|
# 注意 aspect_ratio 用 peak 而非 high:测试集中 121 张方脸的
|
||||||
|
# aspect_ratio 中位仅 +0.16,真正"宽"的是娃娃脸(+1.01)——
|
||||||
|
# 早前把它当成 high 模式的强特征,是方形脸吞掉圆脸的主因。
|
||||||
|
#
|
||||||
|
# chin_ratio 是方脸组区分度最大的一项(组内中位 z=+1.45,标准脸组仅 -0.06),
|
||||||
|
# 故靶心直接对齐 +1.45。靶心与权重必须同时提:若只加权重而把靶心留在低位,
|
||||||
|
# 全人群八成都能拿满分,等于给所有人同加一笔,反而推高方形脸占比。
|
||||||
|
"方形脸": {
|
||||||
|
"aspect_ratio": (+1.11, 4.75, "peak"),
|
||||||
|
"jaw_ratio": (-0.02, 2.27, "high"),
|
||||||
|
"chin_ratio": (+1.45, 3.00, "high"),
|
||||||
|
"taper_ratio": (-0.38, 0.51, "low"),
|
||||||
|
"chin_sharpness": (-0.38, 0.99, "high"),
|
||||||
|
"width_uniformity": (+0.58, 4.34, "high"),
|
||||||
|
"gonion_angle": (+0.10, 3.38, "low"),
|
||||||
|
"forehead_ratio": (-1.70, 4.05, "low"),
|
||||||
|
},
|
||||||
|
# 明显偏长偏窄
|
||||||
|
"长形脸": {
|
||||||
|
"aspect_ratio": (-1.49, 1.17, "low"),
|
||||||
|
"chin_sharpness": (+1.76, 0.50, "high"),
|
||||||
|
"taper_ratio": (+0.84, 0.50, "low"),
|
||||||
|
},
|
||||||
|
# 似心形但下巴更长更尖(face_curve_score 高),颧骨不外扩
|
||||||
|
"瓜子脸": {
|
||||||
|
"face_curve_score": (+1.02, 3.41, "high"),
|
||||||
|
"forehead_ratio": (+0.85, 3.31, "high"),
|
||||||
|
"taper_ratio": (+0.35, 1.02, "high"),
|
||||||
|
"cheek_dominance": (-1.41, 2.21, "low"),
|
||||||
|
"jaw_ratio": (-1.04, 0.57, "low"),
|
||||||
|
"chin_sharpness": (-1.33, 2.53, "low"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def feature_zscores(features: Dict[str, float]) -> Dict[str, float]:
|
||||||
|
"""把测量值转成相对参考人群的 z 分数。"""
|
||||||
|
return {
|
||||||
|
key: (features[key] - median) / scale
|
||||||
|
for key, (median, scale) in REFERENCE_STATS.items()
|
||||||
|
if key in features
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _match(z: float, target: float, mode: str) -> float:
|
||||||
|
"""单项匹配度 0~1。"""
|
||||||
|
if mode == "high" and z >= target:
|
||||||
|
return 1.0
|
||||||
|
if mode == "low" and z <= target:
|
||||||
|
return 1.0
|
||||||
|
return math.exp(-((z - target) ** 2) / (2 * MATCH_TOLERANCE**2))
|
||||||
|
|
||||||
|
|
||||||
|
def classify_face_shape(
|
||||||
|
features: Dict[str, float],
|
||||||
|
return_details: bool = False,
|
||||||
|
) -> Tuple[str, float, Optional[Dict]]:
|
||||||
|
"""
|
||||||
|
脸型分类器:把特征转成 z 分数后,与各脸型原型做加权匹配。
|
||||||
|
|
||||||
|
返回 (脸型, 置信度, 详情)。置信度 = Top1 / (Top1 + Top2),
|
||||||
|
0.5 表示两种脸型完全无法区分,接近 1 表示判定明确。
|
||||||
|
"""
|
||||||
|
z = feature_zscores(features)
|
||||||
|
|
||||||
|
scores: Dict[str, float] = {}
|
||||||
|
contributions: Dict[str, Dict[str, float]] = {}
|
||||||
|
for shape, prototype in SHAPE_PROTOTYPES.items():
|
||||||
|
total_weight = sum(w for _, w, _ in prototype.values())
|
||||||
|
acc = 0.0
|
||||||
|
per_feature = {}
|
||||||
|
for key, (target, weight, mode) in prototype.items():
|
||||||
|
m = _match(z[key], target, mode)
|
||||||
|
per_feature[key] = m
|
||||||
|
acc += weight * m
|
||||||
|
scores[shape] = 100.0 * acc / total_weight
|
||||||
|
contributions[shape] = per_feature
|
||||||
|
|
||||||
|
ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
|
||||||
|
best_shape, best_score = ranked[0]
|
||||||
|
second_shape, second_score = ranked[1] if len(ranked) > 1 else (None, 0.0)
|
||||||
|
|
||||||
|
denom = best_score + second_score
|
||||||
|
confidence = best_score / denom if denom > 0 else 0.0
|
||||||
|
score_gap = best_score - second_score
|
||||||
|
is_mixed = score_gap < 5.0
|
||||||
|
|
||||||
|
details = None
|
||||||
|
if return_details:
|
||||||
|
details = {
|
||||||
|
"scores": scores,
|
||||||
|
"ranked": ranked,
|
||||||
|
"confidence": confidence,
|
||||||
|
"score_gap": score_gap,
|
||||||
|
"is_mixed": is_mixed,
|
||||||
|
"second_shape": second_shape,
|
||||||
|
"second_score": second_score,
|
||||||
|
"zscores": z,
|
||||||
|
"contributions": contributions,
|
||||||
|
}
|
||||||
|
|
||||||
|
return best_shape, confidence, details
|
||||||
|
|
||||||
|
|
||||||
|
def get_mixed_description(details: Dict) -> str:
|
||||||
|
if not details or not details.get("is_mixed"):
|
||||||
|
return ""
|
||||||
|
shape1 = details["ranked"][0][0]
|
||||||
|
shape2 = details["ranked"][1][0]
|
||||||
|
return f"{shape1}(偏{shape2})"
|
||||||
|
|
||||||
|
|
||||||
|
_face_mesh = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_face_mesh():
|
||||||
|
global _face_mesh
|
||||||
|
if _face_mesh is None:
|
||||||
|
_face_mesh = mp.solutions.face_mesh.FaceMesh(
|
||||||
|
static_image_mode=True,
|
||||||
|
max_num_faces=1,
|
||||||
|
refine_landmarks=True,
|
||||||
|
min_detection_confidence=0.5,
|
||||||
|
)
|
||||||
|
return _face_mesh
|
||||||
|
|
||||||
|
|
||||||
|
def _load_image(image: ImageInput) -> np.ndarray:
|
||||||
|
if isinstance(image, np.ndarray):
|
||||||
|
if image.ndim != 3 or image.shape[2] not in (3, 4):
|
||||||
|
raise ValueError("numpy 图片需为 HxWx3/4 的彩色图")
|
||||||
|
if image.shape[2] == 4:
|
||||||
|
return cv2.cvtColor(image, cv2.COLOR_BGRA2BGR)
|
||||||
|
return image
|
||||||
|
|
||||||
|
path = Path(image)
|
||||||
|
img = cv2.imread(str(path))
|
||||||
|
if img is None:
|
||||||
|
raise FileNotFoundError(f"无法读取图片: {path}")
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def _landmark_points(landmarks, image_size: Tuple[int, int]) -> Dict[str, Tuple[int, int]]:
|
||||||
|
"""提取标注用像素点。"""
|
||||||
|
w, h = image_size
|
||||||
|
|
||||||
|
def xy(idx: int) -> Tuple[int, int]:
|
||||||
|
lm = landmarks[idx]
|
||||||
|
return int(round(lm.x * w)), int(round(lm.y * h))
|
||||||
|
|
||||||
|
left_jaw = xy(FaceLandmarks.LEFT_JAW_ANGLE)
|
||||||
|
right_jaw = xy(FaceLandmarks.RIGHT_JAW_ANGLE)
|
||||||
|
return {
|
||||||
|
"forehead_top": xy(FaceLandmarks.FOREHEAD_TOP),
|
||||||
|
"chin_bottom": xy(FaceLandmarks.CHIN_BOTTOM),
|
||||||
|
"left_forehead": xy(FaceLandmarks.LEFT_FOREHEAD),
|
||||||
|
"right_forehead": xy(FaceLandmarks.RIGHT_FOREHEAD),
|
||||||
|
"left_cheek": xy(FaceLandmarks.LEFT_CHEEK),
|
||||||
|
"right_cheek": xy(FaceLandmarks.RIGHT_CHEEK),
|
||||||
|
"left_jaw": left_jaw,
|
||||||
|
"right_jaw": right_jaw,
|
||||||
|
"left_chin": xy(FaceLandmarks.LEFT_CHIN),
|
||||||
|
"right_chin": xy(FaceLandmarks.RIGHT_CHIN),
|
||||||
|
"jaw_mid": (
|
||||||
|
int(round((left_jaw[0] + right_jaw[0]) / 2)),
|
||||||
|
int(round((left_jaw[1] + right_jaw[1]) / 2)),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _put_text_cn(
|
||||||
|
img: np.ndarray,
|
||||||
|
text: str,
|
||||||
|
org: Tuple[int, int],
|
||||||
|
color: Tuple[int, int, int],
|
||||||
|
font_size: int = 18,
|
||||||
|
) -> None:
|
||||||
|
"""在图上绘制中文/英文混合文字(Pillow)。"""
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
||||||
|
pil = Image.fromarray(rgb)
|
||||||
|
draw = ImageDraw.Draw(pil)
|
||||||
|
|
||||||
|
font_paths = [
|
||||||
|
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
|
||||||
|
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
|
]
|
||||||
|
font = None
|
||||||
|
for fp in font_paths:
|
||||||
|
if Path(fp).exists():
|
||||||
|
try:
|
||||||
|
font = ImageFont.truetype(fp, font_size)
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if font is None:
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
|
||||||
|
x, y = org
|
||||||
|
# 阴影提升可读性
|
||||||
|
draw.text((x + 1, y + 1), text, font=font, fill=(0, 0, 0))
|
||||||
|
draw.text((x, y), text, font=font, fill=(color[2], color[1], color[0]))
|
||||||
|
img[:] = cv2.cvtColor(np.array(pil), cv2.COLOR_RGB2BGR)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_h_line(
|
||||||
|
img: np.ndarray,
|
||||||
|
p1: Tuple[int, int],
|
||||||
|
p2: Tuple[int, int],
|
||||||
|
color: Tuple[int, int, int],
|
||||||
|
label: str,
|
||||||
|
thickness: int = 2,
|
||||||
|
label_above: bool = True,
|
||||||
|
) -> None:
|
||||||
|
"""画横向宽度线 + 端点 + 标签。"""
|
||||||
|
y = int(round((p1[1] + p2[1]) / 2))
|
||||||
|
x1, x2 = min(p1[0], p2[0]), max(p1[0], p2[0])
|
||||||
|
cv2.line(img, (x1, y), (x2, y), color, thickness, cv2.LINE_AA)
|
||||||
|
cv2.circle(img, (x1, y), 4, color, -1, cv2.LINE_AA)
|
||||||
|
cv2.circle(img, (x2, y), 4, color, -1, cv2.LINE_AA)
|
||||||
|
# 端点小竖线
|
||||||
|
tick = max(6, thickness * 3)
|
||||||
|
cv2.line(img, (x1, y - tick), (x1, y + tick), color, thickness, cv2.LINE_AA)
|
||||||
|
cv2.line(img, (x2, y - tick), (x2, y + tick), color, thickness, cv2.LINE_AA)
|
||||||
|
mid = ((x1 + x2) // 2, y - 8 if label_above else y + 4)
|
||||||
|
_put_text_cn(img, label, mid, color, font_size=max(14, img.shape[0] // 55))
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_v_line(
|
||||||
|
img: np.ndarray,
|
||||||
|
p1: Tuple[int, int],
|
||||||
|
p2: Tuple[int, int],
|
||||||
|
color: Tuple[int, int, int],
|
||||||
|
label: str,
|
||||||
|
thickness: int = 2,
|
||||||
|
) -> None:
|
||||||
|
"""画纵向高度线 + 端点 + 标签。"""
|
||||||
|
x = int(round((p1[0] + p2[0]) / 2))
|
||||||
|
y1, y2 = min(p1[1], p2[1]), max(p1[1], p2[1])
|
||||||
|
cv2.line(img, (x, y1), (x, y2), color, thickness, cv2.LINE_AA)
|
||||||
|
cv2.circle(img, (x, y1), 4, color, -1, cv2.LINE_AA)
|
||||||
|
cv2.circle(img, (x, y2), 4, color, -1, cv2.LINE_AA)
|
||||||
|
tick = max(6, thickness * 3)
|
||||||
|
cv2.line(img, (x - tick, y1), (x + tick, y1), color, thickness, cv2.LINE_AA)
|
||||||
|
cv2.line(img, (x - tick, y2), (x + tick, y2), color, thickness, cv2.LINE_AA)
|
||||||
|
_put_text_cn(
|
||||||
|
img,
|
||||||
|
label,
|
||||||
|
(x + 8, (y1 + y2) // 2),
|
||||||
|
color,
|
||||||
|
font_size=max(14, img.shape[0] // 55),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def annotate_face_features(
|
||||||
|
image: ImageInput,
|
||||||
|
landmarks=None,
|
||||||
|
features: Optional[Dict[str, float]] = None,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
在原图上标注 face_width / face_height 及文档中的关键比例特征。
|
||||||
|
|
||||||
|
返回 BGR 标注图。
|
||||||
|
"""
|
||||||
|
bgr = _load_image(image).copy()
|
||||||
|
h, w = bgr.shape[:2]
|
||||||
|
|
||||||
|
if landmarks is None:
|
||||||
|
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
||||||
|
results = _get_face_mesh().process(rgb)
|
||||||
|
if not results.multi_face_landmarks:
|
||||||
|
raise ValueError("未检测到人脸关键点")
|
||||||
|
landmarks = results.multi_face_landmarks[0].landmark
|
||||||
|
|
||||||
|
if features is None:
|
||||||
|
features = extract_face_features(landmarks, image_size=(w, h))
|
||||||
|
|
||||||
|
pts = _landmark_points(landmarks, (w, h))
|
||||||
|
overlay = bgr.copy()
|
||||||
|
fs = max(14, h // 55)
|
||||||
|
thick = max(2, h // 400)
|
||||||
|
|
||||||
|
# ---- 尺寸主轴 ----
|
||||||
|
# face_height: 额头顶 → 下巴底
|
||||||
|
_draw_v_line(
|
||||||
|
overlay,
|
||||||
|
pts["forehead_top"],
|
||||||
|
pts["chin_bottom"],
|
||||||
|
(40, 180, 255),
|
||||||
|
f"face_height {features['face_height']:.0f}px",
|
||||||
|
thickness=thick + 1,
|
||||||
|
)
|
||||||
|
# face_width (= cheekbone): 左颧 → 右颧
|
||||||
|
_draw_h_line(
|
||||||
|
overlay,
|
||||||
|
pts["left_cheek"],
|
||||||
|
pts["right_cheek"],
|
||||||
|
(0, 220, 120),
|
||||||
|
f"face_width {features['face_width']:.0f}px",
|
||||||
|
thickness=thick + 1,
|
||||||
|
label_above=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- 各级宽度(forehead / jaw / chin)----
|
||||||
|
# 略微错开 y,避免完全重叠
|
||||||
|
fh_y = pts["left_forehead"][1]
|
||||||
|
_draw_h_line(
|
||||||
|
overlay,
|
||||||
|
(pts["left_forehead"][0], fh_y),
|
||||||
|
(pts["right_forehead"][0], fh_y),
|
||||||
|
(255, 160, 40),
|
||||||
|
f"forehead ratio={features['forehead_ratio']:.3f}",
|
||||||
|
thickness=thick,
|
||||||
|
label_above=True,
|
||||||
|
)
|
||||||
|
jy = pts["left_jaw"][1]
|
||||||
|
_draw_h_line(
|
||||||
|
overlay,
|
||||||
|
(pts["left_jaw"][0], jy),
|
||||||
|
(pts["right_jaw"][0], jy),
|
||||||
|
(80, 120, 255),
|
||||||
|
f"jaw ratio={features['jaw_ratio']:.3f}",
|
||||||
|
thickness=thick,
|
||||||
|
label_above=False,
|
||||||
|
)
|
||||||
|
cy = pts["left_chin"][1]
|
||||||
|
_draw_h_line(
|
||||||
|
overlay,
|
||||||
|
(pts["left_chin"][0], cy),
|
||||||
|
(pts["right_chin"][0], cy),
|
||||||
|
(220, 80, 220),
|
||||||
|
f"chin ratio={features['chin_ratio']:.3f}",
|
||||||
|
thickness=thick,
|
||||||
|
label_above=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# cheekbone_ratio(相对 face_width,恒为 1.0)写在颧骨线旁
|
||||||
|
cheek_mid = (
|
||||||
|
(pts["left_cheek"][0] + pts["right_cheek"][0]) // 2,
|
||||||
|
pts["left_cheek"][1] + max(18, h // 40),
|
||||||
|
)
|
||||||
|
_put_text_cn(
|
||||||
|
overlay,
|
||||||
|
f"cheekbone_ratio={features['cheekbone_ratio']:.3f}",
|
||||||
|
cheek_mid,
|
||||||
|
(0, 200, 100),
|
||||||
|
font_size=fs,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- jaw_angle:下巴 → 左右下颌角 ----
|
||||||
|
cv2.line(overlay, pts["chin_bottom"], pts["left_jaw"], (0, 90, 255), thick, cv2.LINE_AA)
|
||||||
|
cv2.line(overlay, pts["chin_bottom"], pts["right_jaw"], (0, 90, 255), thick, cv2.LINE_AA)
|
||||||
|
cv2.circle(overlay, pts["chin_bottom"], 5, (0, 90, 255), -1, cv2.LINE_AA)
|
||||||
|
# 角度弧
|
||||||
|
v1 = np.array(pts["left_jaw"], dtype=float) - np.array(pts["chin_bottom"], dtype=float)
|
||||||
|
v2 = np.array(pts["right_jaw"], dtype=float) - np.array(pts["chin_bottom"], dtype=float)
|
||||||
|
a1 = math.degrees(math.atan2(-v1[1], v1[0]))
|
||||||
|
a2 = math.degrees(math.atan2(-v2[1], v2[0]))
|
||||||
|
# OpenCV ellipse 角度:从 x 轴顺时针;atan2 转一下
|
||||||
|
start_ang = -a1
|
||||||
|
end_ang = -a2
|
||||||
|
if end_ang < start_ang:
|
||||||
|
start_ang, end_ang = end_ang, start_ang
|
||||||
|
radius = max(28, int(0.08 * features["face_height"]))
|
||||||
|
cv2.ellipse(
|
||||||
|
overlay,
|
||||||
|
pts["chin_bottom"],
|
||||||
|
(radius, radius),
|
||||||
|
0,
|
||||||
|
start_ang,
|
||||||
|
end_ang,
|
||||||
|
(0, 90, 255),
|
||||||
|
thick,
|
||||||
|
cv2.LINE_AA,
|
||||||
|
)
|
||||||
|
_put_text_cn(
|
||||||
|
overlay,
|
||||||
|
f"jaw_angle {features['jaw_angle']:.1f}°",
|
||||||
|
(pts["chin_bottom"][0] + radius + 4, pts["chin_bottom"][1] - radius),
|
||||||
|
(0, 90, 255),
|
||||||
|
font_size=fs,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- taper_ratio:额头两端 → 下巴两端(收窄示意)----
|
||||||
|
cv2.line(
|
||||||
|
overlay,
|
||||||
|
pts["left_forehead"],
|
||||||
|
pts["left_chin"],
|
||||||
|
(40, 200, 255),
|
||||||
|
max(1, thick - 1),
|
||||||
|
cv2.LINE_AA,
|
||||||
|
)
|
||||||
|
cv2.line(
|
||||||
|
overlay,
|
||||||
|
pts["right_forehead"],
|
||||||
|
pts["right_chin"],
|
||||||
|
(40, 200, 255),
|
||||||
|
max(1, thick - 1),
|
||||||
|
cv2.LINE_AA,
|
||||||
|
)
|
||||||
|
taper_anchor = (
|
||||||
|
pts["left_forehead"][0] - max(10, w // 30),
|
||||||
|
(pts["left_forehead"][1] + pts["left_chin"][1]) // 2,
|
||||||
|
)
|
||||||
|
_put_text_cn(
|
||||||
|
overlay,
|
||||||
|
f"taper_ratio={features['taper_ratio']:.3f}",
|
||||||
|
taper_anchor,
|
||||||
|
(40, 200, 255),
|
||||||
|
font_size=fs,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- chin_sharpness:下巴宽 vs 下颌宽 ----
|
||||||
|
_put_text_cn(
|
||||||
|
overlay,
|
||||||
|
f"chin_sharpness={features['chin_sharpness']:.3f} (chin/jaw)",
|
||||||
|
(pts["left_chin"][0], pts["left_chin"][1] + max(16, h // 45)),
|
||||||
|
(220, 80, 220),
|
||||||
|
font_size=fs,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- width_uniformity:三宽差异 ----
|
||||||
|
widths = [
|
||||||
|
("F", features["forehead_width"], (255, 160, 40)),
|
||||||
|
("C", features["cheekbone_width"], (0, 220, 120)),
|
||||||
|
("J", features["jaw_width"], (80, 120, 255)),
|
||||||
|
]
|
||||||
|
# 右侧小柱状示意
|
||||||
|
panel_x = min(w - max(90, w // 8), max(pts["right_cheek"][0] + 20, w - max(100, w // 7)))
|
||||||
|
panel_y = max(40, pts["forehead_top"][1])
|
||||||
|
max_w = max(x[1] for x in widths) + 1e-8
|
||||||
|
bar_h = max(10, h // 60)
|
||||||
|
gap = max(4, h // 120)
|
||||||
|
for i, (name, val, color) in enumerate(widths):
|
||||||
|
bw = int((val / max_w) * max(50, w // 10))
|
||||||
|
y0 = panel_y + i * (bar_h + gap)
|
||||||
|
cv2.rectangle(overlay, (panel_x, y0), (panel_x + bw, y0 + bar_h), color, -1, cv2.LINE_AA)
|
||||||
|
_put_text_cn(overlay, name, (panel_x + bw + 4, y0 - 2), color, font_size=max(12, fs - 2))
|
||||||
|
_put_text_cn(
|
||||||
|
overlay,
|
||||||
|
f"width_uniformity={features['width_uniformity']:.3f}",
|
||||||
|
(panel_x, panel_y + 3 * (bar_h + gap) + 2),
|
||||||
|
(230, 230, 230),
|
||||||
|
font_size=fs,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- face_curve_score:下颌中点 → 下巴 ----
|
||||||
|
cv2.line(
|
||||||
|
overlay,
|
||||||
|
pts["jaw_mid"],
|
||||||
|
pts["chin_bottom"],
|
||||||
|
(180, 255, 80),
|
||||||
|
thick,
|
||||||
|
cv2.LINE_AA,
|
||||||
|
)
|
||||||
|
cv2.circle(overlay, pts["jaw_mid"], 4, (180, 255, 80), -1, cv2.LINE_AA)
|
||||||
|
curve_label_pos = (
|
||||||
|
pts["jaw_mid"][0] + 6,
|
||||||
|
pts["jaw_mid"][1] - max(8, h // 80),
|
||||||
|
)
|
||||||
|
_put_text_cn(
|
||||||
|
overlay,
|
||||||
|
f"face_curve_score={features['face_curve_score']:.3f}",
|
||||||
|
curve_label_pos,
|
||||||
|
(180, 255, 80),
|
||||||
|
font_size=fs,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 半透明叠回原图,再叠一层实线标注更清晰:直接用 overlay
|
||||||
|
# 左侧参数图例
|
||||||
|
legend = [
|
||||||
|
("face_width / face_height", (0, 220, 120)),
|
||||||
|
(f"jaw_angle={features['jaw_angle']:.1f}°", (0, 90, 255)),
|
||||||
|
(f"taper_ratio={features['taper_ratio']:.3f}", (40, 200, 255)),
|
||||||
|
(f"forehead_ratio={features['forehead_ratio']:.3f}", (255, 160, 40)),
|
||||||
|
(f"cheekbone_ratio={features['cheekbone_ratio']:.3f}", (0, 200, 100)),
|
||||||
|
(f"jaw_ratio={features['jaw_ratio']:.3f}", (80, 120, 255)),
|
||||||
|
(f"chin_ratio={features['chin_ratio']:.3f}", (220, 80, 220)),
|
||||||
|
(f"chin_sharpness={features['chin_sharpness']:.3f}", (220, 80, 220)),
|
||||||
|
(f"width_uniformity={features['width_uniformity']:.3f}", (230, 230, 230)),
|
||||||
|
(f"face_curve_score={features['face_curve_score']:.3f}", (180, 255, 80)),
|
||||||
|
]
|
||||||
|
box_h = 12 + len(legend) * (fs + 6)
|
||||||
|
box_w = max(220, w // 3)
|
||||||
|
cv2.rectangle(overlay, (8, 8), (8 + box_w, 8 + box_h), (20, 20, 20), -1)
|
||||||
|
cv2.rectangle(overlay, (8, 8), (8 + box_w, 8 + box_h), (90, 90, 90), 1)
|
||||||
|
for i, (text, color) in enumerate(legend):
|
||||||
|
_put_text_cn(overlay, text, (16, 14 + i * (fs + 6)), color, font_size=fs)
|
||||||
|
|
||||||
|
return overlay
|
||||||
|
|
||||||
|
|
||||||
|
def classify_from_image(
|
||||||
|
image: ImageInput,
|
||||||
|
return_details: bool = True,
|
||||||
|
return_annotated: bool = False,
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
从图片判断脸型。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
image: 图片路径,或 OpenCV BGR numpy 数组
|
||||||
|
return_details: 是否返回特征与各脸型得分
|
||||||
|
return_annotated: 是否同时返回特征标注图(BGR)
|
||||||
|
"""
|
||||||
|
bgr = _load_image(image)
|
||||||
|
h, w = bgr.shape[:2]
|
||||||
|
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
||||||
|
results = _get_face_mesh().process(rgb)
|
||||||
|
|
||||||
|
if not results.multi_face_landmarks:
|
||||||
|
raise ValueError("未检测到人脸关键点")
|
||||||
|
|
||||||
|
landmarks = results.multi_face_landmarks[0].landmark
|
||||||
|
features = extract_face_features(landmarks, image_size=(w, h))
|
||||||
|
shape, conf, details = classify_face_shape(features, return_details=True)
|
||||||
|
|
||||||
|
display = get_mixed_description(details) or shape
|
||||||
|
result = {
|
||||||
|
"face_shape": shape,
|
||||||
|
"confidence": conf,
|
||||||
|
"display": display,
|
||||||
|
}
|
||||||
|
if return_details:
|
||||||
|
result["features"] = features
|
||||||
|
result["details"] = details
|
||||||
|
if return_annotated:
|
||||||
|
result["annotated"] = annotate_face_features(
|
||||||
|
bgr, landmarks=landmarks, features=features
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def classify_from_mediapipe(
|
||||||
|
multi_face_landmarks,
|
||||||
|
image_size: Tuple[int, int],
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""从 Mediapipe FaceMesh 结果批量分类。image_size=(width, height)。"""
|
||||||
|
results = []
|
||||||
|
for face_lms in multi_face_landmarks:
|
||||||
|
features = extract_face_features(face_lms.landmark, image_size=image_size)
|
||||||
|
shape, conf, details = classify_face_shape(features, return_details=True)
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"face_shape": shape,
|
||||||
|
"confidence": conf,
|
||||||
|
"display": get_mixed_description(details) or shape,
|
||||||
|
"features": features,
|
||||||
|
"details": details,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def run_test_images(test_dir: Union[str, Path, None] = None) -> List[Dict]:
|
||||||
|
"""用 test_img 做回归测试;文件名(不含扩展名)为期望脸型。"""
|
||||||
|
if test_dir is None:
|
||||||
|
test_dir = Path(__file__).resolve().parent / "test_img"
|
||||||
|
test_dir = Path(test_dir)
|
||||||
|
|
||||||
|
image_paths = sorted(
|
||||||
|
p
|
||||||
|
for p in test_dir.iterdir()
|
||||||
|
if p.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
|
||||||
|
)
|
||||||
|
if not image_paths:
|
||||||
|
raise FileNotFoundError(f"测试目录无图片: {test_dir}")
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for path in image_paths:
|
||||||
|
expected = path.stem
|
||||||
|
try:
|
||||||
|
result = classify_from_image(path, return_details=True)
|
||||||
|
predicted = result["face_shape"]
|
||||||
|
display = result["display"]
|
||||||
|
conf = result["confidence"]
|
||||||
|
top3 = result["details"]["ranked"][:3]
|
||||||
|
ok = predicted == expected
|
||||||
|
error = None
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
predicted = display = conf = None
|
||||||
|
top3 = []
|
||||||
|
ok = False
|
||||||
|
error = str(exc)
|
||||||
|
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"file": path.name,
|
||||||
|
"expected": expected,
|
||||||
|
"predicted": predicted,
|
||||||
|
"display": display,
|
||||||
|
"confidence": conf,
|
||||||
|
"top3": top3,
|
||||||
|
"correct": ok,
|
||||||
|
"error": error,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _print_test_report(rows: List[Dict]) -> None:
|
||||||
|
correct = sum(1 for r in rows if r["correct"])
|
||||||
|
total = len(rows)
|
||||||
|
|
||||||
|
print("=" * 72)
|
||||||
|
print("脸型分类测试结果")
|
||||||
|
print("=" * 72)
|
||||||
|
for r in rows:
|
||||||
|
status = "✓" if r["correct"] else "✗"
|
||||||
|
if r["error"]:
|
||||||
|
print(f"{status} {r['file']}")
|
||||||
|
print(f" 期望: {r['expected']}")
|
||||||
|
print(f" 错误: {r['error']}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
top3_str = ", ".join(f"{name}:{score:.1f}" for name, score in r["top3"])
|
||||||
|
print(f"{status} {r['file']}")
|
||||||
|
print(f" 期望: {r['expected']}")
|
||||||
|
print(f" 预测: {r['display']} (conf={r['confidence']:.3f})")
|
||||||
|
print(f" Top3: {top3_str}")
|
||||||
|
|
||||||
|
print("-" * 72)
|
||||||
|
print(f"准确率: {correct}/{total} = {correct / total:.1%}")
|
||||||
|
print("=" * 72)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if len(sys.argv) > 1 and sys.argv[1] not in {"--test", "-t"}:
|
||||||
|
out = classify_from_image(sys.argv[1], return_details=True)
|
||||||
|
print(f"脸型: {out['display']}")
|
||||||
|
print(f"置信度: {out['confidence']:.3f}")
|
||||||
|
print("各脸型得分:")
|
||||||
|
for name, score in out["details"]["ranked"]:
|
||||||
|
print(f" {name}: {score:.1f}")
|
||||||
|
else:
|
||||||
|
report = run_test_images()
|
||||||
|
_print_test_report(report)
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 655 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 658 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 656 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 649 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 650 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 651 KiB |
@@ -213,7 +213,11 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
|
|||||||
|
|
||||||
buf = np.zeros((h, w, 4), dtype=np.uint8)
|
buf = np.zeros((h, w, 4), dtype=np.uint8)
|
||||||
|
|
||||||
if variant == "v6":
|
# 发际线弃用(hairline_discarded):保留头顶横线,去掉发际线横线,
|
||||||
|
# 也不标顶/上庭(缺发际线作边界,算不出)。横线 = 头顶/眉心/鼻翼下缘/下巴尖。
|
||||||
|
if getattr(measure_result, "hairline_discarded", False):
|
||||||
|
order = ["hair_top", "brow_center", "nose_bottom", "chin_tip"]
|
||||||
|
elif variant == "v6":
|
||||||
order = ["hairline", "brow_center", "nose_bottom", "chin_tip"]
|
order = ["hairline", "brow_center", "nose_bottom", "chin_tip"]
|
||||||
else:
|
else:
|
||||||
order = ["hair_top", "hairline", "brow_center", "nose_bottom", "chin_tip"]
|
order = ["hair_top", "hairline", "brow_center", "nose_bottom", "chin_tip"]
|
||||||
@@ -266,18 +270,27 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
|
|||||||
draw.text((x, max(2, ys[i] - th - name_gap)), text, fill=LINE_COLOR, font=font)
|
draw.text((x, max(2, ys[i] - th - name_gap)), text, fill=LINE_COLOR, font=font)
|
||||||
|
|
||||||
# --- 3b. 左侧四庭:名 + 数值两行(无 cm)+ 竖向虚线双箭头 ---
|
# --- 3b. 左侧四庭:名 + 数值两行(无 cm)+ 竖向虚线双箭头 ---
|
||||||
if variant == "v6":
|
# court_start:庭段在 order 里的起始索引。发际线弃用时 order 首位是头顶(无下界发际线,
|
||||||
|
# 顶/上庭不标),中庭从眉心开始 → 跳过 order[0]。
|
||||||
|
if getattr(measure_result, "hairline_discarded", False):
|
||||||
|
court_cm = [measure_result.middle_cm, measure_result.lower_cm]
|
||||||
|
court_name = ["中庭", "下庭"]
|
||||||
|
n_court = 2
|
||||||
|
court_start = 1
|
||||||
|
elif variant == "v6":
|
||||||
court_cm = [measure_result.upper_cm, measure_result.middle_cm, measure_result.lower_cm]
|
court_cm = [measure_result.upper_cm, measure_result.middle_cm, measure_result.lower_cm]
|
||||||
court_name = ["上庭", "中庭", "下庭"]
|
court_name = ["上庭", "中庭", "下庭"]
|
||||||
n_court = 3
|
n_court = 3
|
||||||
|
court_start = 0
|
||||||
else:
|
else:
|
||||||
court_cm = [measure_result.top_cm, measure_result.upper_cm,
|
court_cm = [measure_result.top_cm, measure_result.upper_cm,
|
||||||
measure_result.middle_cm, measure_result.lower_cm]
|
measure_result.middle_cm, measure_result.lower_cm]
|
||||||
court_name = ["顶庭", "上庭", "中庭", "下庭"]
|
court_name = ["顶庭", "上庭", "中庭", "下庭"]
|
||||||
n_court = 4
|
n_court = 4
|
||||||
|
court_start = 0
|
||||||
arrow_x = max(arrow_size + 1, fx0 - pad) # 竖箭头所在 x(脸左侧,贴近最左竖线)
|
arrow_x = max(arrow_size + 1, fx0 - pad) # 竖箭头所在 x(脸左侧,贴近最左竖线)
|
||||||
for i in range(n_court):
|
for i in range(n_court):
|
||||||
y_a, y_b = ys[i], ys[i + 1]
|
y_a, y_b = ys[court_start + i], ys[court_start + i + 1]
|
||||||
# 竖向虚线双箭头,覆盖该庭高度(略收一点避免压到横线)
|
# 竖向虚线双箭头,覆盖该庭高度(略收一点避免压到横线)
|
||||||
inset = min(arrow_size, (y_b - y_a) * 0.12)
|
inset = min(arrow_size, (y_b - y_a) * 0.12)
|
||||||
draw_dashed_line_with_arrows(
|
draw_dashed_line_with_arrows(
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ RIGHT_EYE_INNER = 362 # 右眼内角
|
|||||||
RIGHT_EYE_OUTER = 263 # 右眼外角
|
RIGHT_EYE_OUTER = 263 # 右眼外角
|
||||||
LEFT_CHEEK = 234 # 左脸颧弓(脸宽左端)
|
LEFT_CHEEK = 234 # 左脸颧弓(脸宽左端)
|
||||||
RIGHT_CHEEK = 454 # 右脸颧弓(脸宽右端)
|
RIGHT_CHEEK = 454 # 右脸颧弓(脸宽右端)
|
||||||
|
LEFT_POSITION = 21 # 左脸前侧定位点(脸颊/耳前区域,与 251 镜像)
|
||||||
|
RIGHT_POSITION = 251 # 右脸前侧定位点(与 21 镜像)
|
||||||
|
|
||||||
# --- 鼻尖(solvePnP 用,可选) ---
|
# --- 鼻尖(solvePnP 用,可选) ---
|
||||||
NOSE_TIP = 1 # 鼻尖(也有用 4 的版本)
|
NOSE_TIP = 1 # 鼻尖(也有用 4 的版本)
|
||||||
|
|||||||
@@ -1107,7 +1107,7 @@ def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="seg
|
|||||||
"hairline_id": hairline_id,
|
"hairline_id": hairline_id,
|
||||||
"blend_method": blend_method,
|
"blend_method": blend_method,
|
||||||
"hairline_push_cm": round(float(hairline_push_cm), 2),
|
"hairline_push_cm": round(float(hairline_push_cm), 2),
|
||||||
"comfyui_prompt": comfyui_prompt or "补充遮罩区域的头发,加一点美颜",
|
"comfyui_prompt": comfyui_prompt or "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜",
|
||||||
"beauty_alpha": beauty_alpha,
|
"beauty_alpha": beauty_alpha,
|
||||||
"px_per_cm": round(float(px_per_cm), 4),
|
"px_per_cm": round(float(px_per_cm), 4),
|
||||||
"mask_pixels": mask_viz["mask_pixels"],
|
"mask_pixels": mask_viz["mask_pixels"],
|
||||||
|
|||||||
+110
-42
@@ -14,7 +14,7 @@ from face_analysis.calibration import (
|
|||||||
from face_analysis.face_mesh_landmarks import (
|
from face_analysis.face_mesh_landmarks import (
|
||||||
GLABELLA_9, GLABELLA_151, NOSE_BOTTOM, CHIN_TIP,
|
GLABELLA_9, GLABELLA_151, NOSE_BOTTOM, CHIN_TIP,
|
||||||
LEFT_EYE_OUTER, LEFT_EYE_INNER, RIGHT_EYE_INNER, RIGHT_EYE_OUTER,
|
LEFT_EYE_OUTER, LEFT_EYE_INNER, RIGHT_EYE_INNER, RIGHT_EYE_OUTER,
|
||||||
LEFT_CHEEK, RIGHT_CHEEK,
|
LEFT_CHEEK, RIGHT_CHEEK, LEFT_POSITION, RIGHT_POSITION,
|
||||||
)
|
)
|
||||||
from face_analysis.hair_segmenter import locate_hairline_by_segmentation
|
from face_analysis.hair_segmenter import locate_hairline_by_segmentation
|
||||||
|
|
||||||
@@ -144,22 +144,47 @@ def measure_seven_eyes(landmarks, image_width, image_height):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def pt_or_none(vertical, name):
|
||||||
|
"""vertical dict 的点 → {"x","y"},值为 None 时返回 None。"""
|
||||||
|
v = vertical.get(name)
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
return {"x": int(round(v[0])), "y": int(round(v[1]))}
|
||||||
|
|
||||||
|
|
||||||
class MeasureResult:
|
class MeasureResult:
|
||||||
"""测量结果,提供 to_response() 输出与接口文档同构的 data 字段。"""
|
"""测量结果,提供 to_response() 输出与接口文档同构的 data 字段。"""
|
||||||
|
|
||||||
def __init__(self, vertical, eyes, px_per_cm, hairline_source, head_pose):
|
# 发际线弃用阈值:发际线离头顶(顶庭)< 此值时判定分割不可靠,弃用发际线。
|
||||||
|
# hairline 与 hair_top 几乎重合(如稀疏头发中轴漏检只剩一小撮),说明发际线
|
||||||
|
# 定位无意义 → 顶/上庭置 null、标注图不画头顶/发际线。
|
||||||
|
HAIRLINE_DISCARD_TOP_CM = 0.7
|
||||||
|
|
||||||
|
def __init__(self, vertical, eyes, px_per_cm, hairline_source, head_pose,
|
||||||
|
landmarks=None, image_width=None, image_height=None):
|
||||||
self.vertical = vertical
|
self.vertical = vertical
|
||||||
self.eyes = eyes
|
self.eyes = eyes
|
||||||
self.px_per_cm = px_per_cm
|
self.px_per_cm = px_per_cm
|
||||||
self.hairline_source = hairline_source
|
self.hairline_source = hairline_source
|
||||||
self.head_pose = head_pose # (yaw, pitch, roll) 或 None
|
self.head_pose = head_pose # (yaw, pitch, roll) 或 None
|
||||||
|
# 原始 mediapipe 点集 + 图像尺寸,供 to_response 输出 21/251 号定位点
|
||||||
|
self.landmarks = landmarks
|
||||||
|
self.w = image_width
|
||||||
|
self.h = image_height
|
||||||
|
|
||||||
# 各庭厘米
|
# 各庭厘米
|
||||||
self.top_cm = vertical["top_court_px"] / px_per_cm
|
self.top_cm = vertical["top_court_px"] / px_per_cm
|
||||||
self.upper_cm = vertical["upper_court_px"] / px_per_cm
|
self.upper_cm = vertical["upper_court_px"] / px_per_cm
|
||||||
self.middle_cm = vertical["middle_court_px"] / px_per_cm
|
self.middle_cm = vertical["middle_court_px"] / px_per_cm
|
||||||
self.lower_cm = vertical["lower_court_px"] / px_per_cm
|
self.lower_cm = vertical["lower_court_px"] / px_per_cm
|
||||||
self.face_total_cm = self.top_cm + self.upper_cm + self.middle_cm + self.lower_cm
|
# 发际线弃用判定:顶庭(头顶→发际线)过小视为发际线贴近头顶、不可靠。
|
||||||
|
# 弃用时 hairline_source 改为 "discarded",face_total 只算中庭+下庭。
|
||||||
|
self.hairline_discarded = self.top_cm < self.HAIRLINE_DISCARD_TOP_CM
|
||||||
|
if self.hairline_discarded:
|
||||||
|
self.hairline_source = "discarded"
|
||||||
|
self.face_total_cm = self.middle_cm + self.lower_cm
|
||||||
|
else:
|
||||||
|
self.face_total_cm = self.top_cm + self.upper_cm + self.middle_cm + self.lower_cm
|
||||||
|
|
||||||
# 七眼厘米
|
# 七眼厘米
|
||||||
self.eye_width_cm = eyes["eye_width_px"] / px_per_cm
|
self.eye_width_cm = eyes["eye_width_px"] / px_per_cm
|
||||||
@@ -167,46 +192,88 @@ class MeasureResult:
|
|||||||
self.inter_eye_cm = eyes["inter_eye_distance_px"] / px_per_cm
|
self.inter_eye_cm = eyes["inter_eye_distance_px"] / px_per_cm
|
||||||
|
|
||||||
def to_response(self):
|
def to_response(self):
|
||||||
total_px = (self.vertical["top_court_px"] + self.vertical["upper_court_px"]
|
# 发际线弃用:顶/上庭相关字段置 null(保留键),ratio 分母只算中下庭;
|
||||||
+ self.vertical["middle_court_px"] + self.vertical["lower_court_px"])
|
# landmarks.hair_top/hairline 置 null。否则按四庭正常输出。
|
||||||
fw_px = self.eyes["face_width_px"]
|
if self.hairline_discarded:
|
||||||
|
base_px = (self.vertical["middle_court_px"] + self.vertical["lower_court_px"])
|
||||||
def pt(name):
|
data = {
|
||||||
x, y = self.vertical[name]
|
"face_total_height_cm": round(self.face_total_cm, 2),
|
||||||
return {"x": int(round(x)), "y": int(round(y))}
|
"four_courts": {
|
||||||
|
"top_court_cm": None,
|
||||||
data = {
|
"upper_court_cm": None,
|
||||||
"face_total_height_cm": round(self.face_total_cm, 2),
|
"middle_court_cm": round(self.middle_cm, 2),
|
||||||
"four_courts": {
|
"lower_court_cm": round(self.lower_cm, 2),
|
||||||
"top_court_cm": round(self.top_cm, 2),
|
"ratios": {
|
||||||
"upper_court_cm": round(self.upper_cm, 2),
|
"top_court": None,
|
||||||
"middle_court_cm": round(self.middle_cm, 2),
|
"upper_court": None,
|
||||||
"lower_court_cm": round(self.lower_cm, 2),
|
"middle_court": round(self.vertical["middle_court_px"] / base_px, 3),
|
||||||
"ratios": {
|
"lower_court": round(self.vertical["lower_court_px"] / base_px, 3),
|
||||||
"top_court": round(self.vertical["top_court_px"] / total_px, 3),
|
},
|
||||||
"upper_court": round(self.vertical["upper_court_px"] / total_px, 3),
|
|
||||||
"middle_court": round(self.vertical["middle_court_px"] / total_px, 3),
|
|
||||||
"lower_court": round(self.vertical["lower_court_px"] / total_px, 3),
|
|
||||||
},
|
},
|
||||||
},
|
"seven_eyes": {
|
||||||
"seven_eyes": {
|
"eye_width_cm": round(self.eye_width_cm, 2),
|
||||||
"eye_width_cm": round(self.eye_width_cm, 2),
|
"face_width_cm": round(self.face_width_cm, 2),
|
||||||
"face_width_cm": round(self.face_width_cm, 2),
|
"inter_eye_distance_cm": round(self.inter_eye_cm, 2),
|
||||||
"inter_eye_distance_cm": round(self.inter_eye_cm, 2),
|
"ratios": {
|
||||||
"ratios": {
|
"eye_width": round(self.eyes["eye_width_px"] / self.eyes["face_width_px"], 3),
|
||||||
"eye_width": round(self.eyes["eye_width_px"] / fw_px, 3),
|
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / self.eyes["face_width_px"], 3),
|
||||||
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / fw_px, 3),
|
},
|
||||||
},
|
},
|
||||||
},
|
"landmarks": {
|
||||||
"landmarks": {
|
"hair_top": None,
|
||||||
"hair_top": pt("hair_top"),
|
"hairline": None,
|
||||||
"hairline": pt("hairline"),
|
"brow_center": pt_or_none(self.vertical, "brow_center"),
|
||||||
"brow_center": pt("brow_center"),
|
"nose_bottom": pt_or_none(self.vertical, "nose_bottom"),
|
||||||
"nose_bottom": pt("nose_bottom"),
|
"chin_tip": pt_or_none(self.vertical, "chin_tip"),
|
||||||
"chin_tip": pt("chin_tip"),
|
},
|
||||||
},
|
"hairline_source": self.hairline_source,
|
||||||
"hairline_source": self.hairline_source,
|
}
|
||||||
}
|
else:
|
||||||
|
total_px = (self.vertical["top_court_px"] + self.vertical["upper_court_px"]
|
||||||
|
+ self.vertical["middle_court_px"] + self.vertical["lower_court_px"])
|
||||||
|
data = {
|
||||||
|
"face_total_height_cm": round(self.face_total_cm, 2),
|
||||||
|
"four_courts": {
|
||||||
|
"top_court_cm": round(self.top_cm, 2),
|
||||||
|
"upper_court_cm": round(self.upper_cm, 2),
|
||||||
|
"middle_court_cm": round(self.middle_cm, 2),
|
||||||
|
"lower_court_cm": round(self.lower_cm, 2),
|
||||||
|
"ratios": {
|
||||||
|
"top_court": round(self.vertical["top_court_px"] / total_px, 3),
|
||||||
|
"upper_court": round(self.vertical["upper_court_px"] / total_px, 3),
|
||||||
|
"middle_court": round(self.vertical["middle_court_px"] / total_px, 3),
|
||||||
|
"lower_court": round(self.vertical["lower_court_px"] / total_px, 3),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"seven_eyes": {
|
||||||
|
"eye_width_cm": round(self.eye_width_cm, 2),
|
||||||
|
"face_width_cm": round(self.face_width_cm, 2),
|
||||||
|
"inter_eye_distance_cm": round(self.inter_eye_cm, 2),
|
||||||
|
"ratios": {
|
||||||
|
"eye_width": round(self.eyes["eye_width_px"] / self.eyes["face_width_px"], 3),
|
||||||
|
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / self.eyes["face_width_px"], 3),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"landmarks": {
|
||||||
|
"hair_top": pt_or_none(self.vertical, "hair_top"),
|
||||||
|
"hairline": pt_or_none(self.vertical, "hairline"),
|
||||||
|
"brow_center": pt_or_none(self.vertical, "brow_center"),
|
||||||
|
"nose_bottom": pt_or_none(self.vertical, "nose_bottom"),
|
||||||
|
"chin_tip": pt_or_none(self.vertical, "chin_tip"),
|
||||||
|
},
|
||||||
|
"hairline_source": self.hairline_source,
|
||||||
|
}
|
||||||
|
# left/right_position:mediapipe 21/251 号定位点(原图像素,与 landmarks 同坐标系)。
|
||||||
|
# landmarks 缺省(如测试直构 MeasureResult)时不输出,保持向后兼容。
|
||||||
|
if self.landmarks is not None and self.w and self.h:
|
||||||
|
lm = _lm_list(self.landmarks)
|
||||||
|
|
||||||
|
def _pt_lm(idx):
|
||||||
|
px, py = normalized_to_pixel(lm[idx], self.w, self.h)
|
||||||
|
return {"x": int(round(px)), "y": int(round(py))}
|
||||||
|
|
||||||
|
data["left_position"] = _pt_lm(LEFT_POSITION)
|
||||||
|
data["right_position"] = _pt_lm(RIGHT_POSITION)
|
||||||
if self.head_pose is not None:
|
if self.head_pose is not None:
|
||||||
yaw, pitch, roll = self.head_pose
|
yaw, pitch, roll = self.head_pose
|
||||||
data["head_pose"] = {
|
data["head_pose"] = {
|
||||||
@@ -220,7 +287,8 @@ def measure_face(landmarks, hair_mask, image_width, image_height, head_pose=None
|
|||||||
vertical, source = decide_vertical(landmarks, image_width, image_height, hair_mask)
|
vertical, source = decide_vertical(landmarks, image_width, image_height, hair_mask)
|
||||||
eyes = measure_seven_eyes(landmarks, image_width, image_height)
|
eyes = measure_seven_eyes(landmarks, image_width, image_height)
|
||||||
px_per_cm = estimate_scale_factor(landmarks, image_width, image_height)
|
px_per_cm = estimate_scale_factor(landmarks, image_width, image_height)
|
||||||
return MeasureResult(vertical, eyes, px_per_cm, source, head_pose)
|
return MeasureResult(vertical, eyes, px_per_cm, source, head_pose,
|
||||||
|
landmarks, image_width, image_height)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+10
-1
@@ -50,12 +50,21 @@ def estimate_head_pose(landmarks, image_width, image_height):
|
|||||||
[0, 0, 1]], dtype=np.float64)
|
[0, 0, 1]], dtype=np.float64)
|
||||||
dist = np.zeros((4, 1)) # 假设无畸变
|
dist = np.zeros((4, 1)) # 假设无畸变
|
||||||
|
|
||||||
success, rvec, _tvec = cv2.solvePnP(
|
success, rvec, tvec = cv2.solvePnP(
|
||||||
_MODEL_POINTS, image_points, cam_matrix, dist,
|
_MODEL_POINTS, image_points, cam_matrix, dist,
|
||||||
flags=cv2.SOLVEPNP_ITERATIVE,
|
flags=cv2.SOLVEPNP_ITERATIVE,
|
||||||
)
|
)
|
||||||
if not success:
|
if not success:
|
||||||
return None
|
return None
|
||||||
|
# ITERATIVE 偶发收敛到相机后方的翻转解(tz<0),此时 roll 落在 ±180° 附近,
|
||||||
|
# 会把真正的正面照误判为 1003。改用 SQPNP 重解正深度解。
|
||||||
|
if float(tvec[2, 0]) < 0:
|
||||||
|
ok2, rvec2, tvec2 = cv2.solvePnP(
|
||||||
|
_MODEL_POINTS, image_points, cam_matrix, dist,
|
||||||
|
flags=cv2.SOLVEPNP_SQPNP,
|
||||||
|
)
|
||||||
|
if ok2 and float(tvec2[2, 0]) > 0:
|
||||||
|
rvec = rvec2
|
||||||
rot, _ = cv2.Rodrigues(rvec)
|
rot, _ = cv2.Rodrigues(rvec)
|
||||||
# 在「相机坐标系」(x右 y下 z内) 下抽取 Tait-Bryan 欧拉角,物理含义对齐:
|
# 在「相机坐标系」(x右 y下 z内) 下抽取 Tait-Bryan 欧拉角,物理含义对齐:
|
||||||
# yaw = 绕 Y(竖轴)转 → 左右扭头
|
# yaw = 绕 Y(竖轴)转 → 左右扭头
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
|
|
||||||
from gateway.config import load_config
|
from gateway.config import load_config
|
||||||
from gateway.logging_middleware import (
|
from gateway.logging_middleware import (
|
||||||
|
get_available_dates,
|
||||||
|
get_daily_stats,
|
||||||
get_stats,
|
get_stats,
|
||||||
init_logging as _init_req_logging,
|
init_logging as _init_req_logging,
|
||||||
request_logging_middleware,
|
request_logging_middleware,
|
||||||
@@ -185,6 +187,9 @@ async def gateway_health():
|
|||||||
"workers_total": status["total"],
|
"workers_total": status["total"],
|
||||||
"workers_healthy": status["healthy"],
|
"workers_healthy": status["healthy"],
|
||||||
"workers_busy": status["busy"],
|
"workers_busy": status["busy"],
|
||||||
|
"global_max": status.get("global_max", 1),
|
||||||
|
"global_busy": status.get("global_busy", 0),
|
||||||
|
"global_waiting": status.get("global_waiting", 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -201,6 +206,7 @@ async def index():
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"docs": "/docs",
|
"docs": "/docs",
|
||||||
"stats": "/admin/stats",
|
"stats": "/admin/stats",
|
||||||
|
"timing_dashboard": "/static/api_timing_dashboard.html",
|
||||||
"integration_guide": "/static/integration.html",
|
"integration_guide": "/static/integration.html",
|
||||||
"test_pages": {
|
"test_pages": {
|
||||||
"if1_measure": "/static/test_interface1.html",
|
"if1_measure": "/static/test_interface1.html",
|
||||||
@@ -470,6 +476,18 @@ async def admin_stats_json():
|
|||||||
return get_stats()
|
return get_stats()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/admin/stats/dates", include_in_schema=False)
|
||||||
|
async def admin_stats_dates():
|
||||||
|
"""返回日志中出现过的所有本地日期(供耗时看板日期选择器使用)。"""
|
||||||
|
return {"dates": get_available_dates()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/admin/stats/daily", include_in_schema=False)
|
||||||
|
async def admin_stats_daily(date: str):
|
||||||
|
"""按天统计各接口调用耗时(含多发型接口的单次换发型耗时拆解)。"""
|
||||||
|
return get_daily_stats(date)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 代理路由
|
# 代理路由
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -529,6 +547,7 @@ async def hair_grow_b(request: Request):
|
|||||||
|
|
||||||
@app.post("/api/v1/face/features", tags=["人脸分析"])
|
@app.post("/api/v1/face/features", tags=["人脸分析"])
|
||||||
async def face_features(
|
async def face_features(
|
||||||
|
request: Request,
|
||||||
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG)"),
|
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG)"),
|
||||||
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
||||||
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带前缀)"),
|
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带前缀)"),
|
||||||
@@ -559,6 +578,26 @@ async def face_features(
|
|||||||
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
|
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# 入参日志(接口4 不走 forward.py,自行记录;不获取全局串行槽,因不占 GPU)
|
||||||
|
try:
|
||||||
|
from gateway.config import get_config as _get_cfg
|
||||||
|
from gateway.reqlog import save_image_bytes
|
||||||
|
_cfg = _get_cfg()
|
||||||
|
_params: dict = {}
|
||||||
|
if image_file:
|
||||||
|
_url = save_image_bytes(img_bytes, _cfg["static_dir"], _cfg["public_base_url"]) if img_bytes else None
|
||||||
|
_params["image_file"] = {"_image": True, "source": "file",
|
||||||
|
"size": len(img_bytes) if img_bytes else 0, "url": _url}
|
||||||
|
elif image_base64:
|
||||||
|
_url = save_image_bytes(img_bytes, _cfg["static_dir"], _cfg["public_base_url"]) if img_bytes else None
|
||||||
|
_params["image_base64"] = {"_image": True, "source": "base64",
|
||||||
|
"size": len(img_bytes) if img_bytes else 0, "url": _url}
|
||||||
|
elif image_url:
|
||||||
|
_params["image_url"] = image_url
|
||||||
|
request.state.log_request_params = _params
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
request.state.log_request_params = {"_parse_error": "iface4"}
|
||||||
|
|
||||||
from fastapi.concurrency import run_in_threadpool
|
from fastapi.concurrency import run_in_threadpool
|
||||||
from face_features import analyze_features
|
from face_features import analyze_features
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
"ark_api_key": "",
|
"ark_api_key": "",
|
||||||
"dispatch": {
|
"dispatch": {
|
||||||
"per_worker_concurrency": 1,
|
"per_worker_concurrency": 1,
|
||||||
|
"max_global_concurrency": 1,
|
||||||
|
"max_queue_wait_seconds": 590,
|
||||||
"queue_wait_seconds": 30,
|
"queue_wait_seconds": 30,
|
||||||
"request_timeout_seconds": 600,
|
"request_timeout_seconds": 600,
|
||||||
"retry_on_failure": true,
|
"retry_on_failure": true,
|
||||||
|
|||||||
+6
-1
@@ -24,6 +24,8 @@ DEFAULTS = {
|
|||||||
},
|
},
|
||||||
"dispatch": {
|
"dispatch": {
|
||||||
"per_worker_concurrency": 1,
|
"per_worker_concurrency": 1,
|
||||||
|
"max_global_concurrency": 1,
|
||||||
|
"max_queue_wait_seconds": 590,
|
||||||
"queue_wait_seconds": 30,
|
"queue_wait_seconds": 30,
|
||||||
"request_timeout_seconds": 600,
|
"request_timeout_seconds": 600,
|
||||||
"retry_on_failure": True,
|
"retry_on_failure": True,
|
||||||
@@ -105,12 +107,15 @@ def load_config() -> dict:
|
|||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"配置加载完成 | workers=%s | public_base_url=%s | "
|
"配置加载完成 | workers=%s | public_base_url=%s | "
|
||||||
"hc_interval=%ds | dispatch_timeout=%ds | queue_wait=%ds",
|
"hc_interval=%ds | dispatch_timeout=%ds | queue_wait=%ds | "
|
||||||
|
"global_concurrency=%d | max_queue_wait=%ds",
|
||||||
cfg["workers"],
|
cfg["workers"],
|
||||||
cfg["public_base_url"],
|
cfg["public_base_url"],
|
||||||
cfg["health_check"]["interval_seconds"],
|
cfg["health_check"]["interval_seconds"],
|
||||||
cfg["dispatch"]["request_timeout_seconds"],
|
cfg["dispatch"]["request_timeout_seconds"],
|
||||||
cfg["dispatch"]["queue_wait_seconds"],
|
cfg["dispatch"]["queue_wait_seconds"],
|
||||||
|
cfg["dispatch"]["max_global_concurrency"],
|
||||||
|
cfg["dispatch"]["max_queue_wait_seconds"],
|
||||||
)
|
)
|
||||||
|
|
||||||
_config_cache = cfg
|
_config_cache = cfg
|
||||||
|
|||||||
+46
-4
@@ -19,10 +19,13 @@ from fastapi.responses import JSONResponse
|
|||||||
|
|
||||||
from gateway.pool import (
|
from gateway.pool import (
|
||||||
NoWorkerAvailable,
|
NoWorkerAvailable,
|
||||||
|
acquire_global_slot,
|
||||||
acquire_worker,
|
acquire_worker,
|
||||||
mark_worker_unhealthy,
|
mark_worker_unhealthy,
|
||||||
|
release_global_slot,
|
||||||
release_worker,
|
release_worker,
|
||||||
)
|
)
|
||||||
|
from gateway.reqlog import extract_form_params
|
||||||
|
|
||||||
logger = logging.getLogger("gateway.forward")
|
logger = logging.getLogger("gateway.forward")
|
||||||
|
|
||||||
@@ -143,17 +146,56 @@ async def proxy_request(request: Request, path: str) -> JSONResponse:
|
|||||||
"""
|
"""
|
||||||
from gateway.config import get_config
|
from gateway.config import get_config
|
||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
|
|
||||||
|
# --- 1. 读取客户端请求体(原始字节,不做解析) ---
|
||||||
|
body = await request.body()
|
||||||
|
|
||||||
|
# --- 入参日志解析(非致命,绝不影响转发) ---
|
||||||
|
try:
|
||||||
|
request.state.log_request_params = extract_form_params(
|
||||||
|
request.headers.get("content-type", ""),
|
||||||
|
body,
|
||||||
|
cfg["static_dir"],
|
||||||
|
cfg["public_base_url"],
|
||||||
|
)
|
||||||
|
except Exception as ex: # noqa: BLE001
|
||||||
|
request.state.log_request_params = {"_parse_error": str(ex)}
|
||||||
|
|
||||||
|
# --- 全局串行槽(GPU 请求在此排队;超时拒绝) ---
|
||||||
|
if not await acquire_global_slot(cfg):
|
||||||
|
request.state.worker_url = ""
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=503,
|
||||||
|
content={
|
||||||
|
"code": 1007,
|
||||||
|
"message": "排队超时,请稍后重试",
|
||||||
|
"request_id": f"gw-{uuid.uuid4().hex[:8]}",
|
||||||
|
"data": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await _dispatch_with_retries(request, path, cfg, body)
|
||||||
|
finally:
|
||||||
|
release_global_slot()
|
||||||
|
|
||||||
|
|
||||||
|
async def _dispatch_with_retries(
|
||||||
|
request: Request, path: str, cfg: dict, body: bytes
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""在已持有全局串行槽的前提下:选 worker → 转发 → 重试 → 改写 base64 → 返回。
|
||||||
|
|
||||||
|
故障转移:worker 失败 → mark_worker_unhealthy → release_worker → 循环再 acquire
|
||||||
|
(此时主 worker 已下线,自动选备机)。全程在同一个全局槽持有期内,故
|
||||||
|
「平时只打主 worker,坏了才用备机」。
|
||||||
|
"""
|
||||||
dispatch_cfg = cfg["dispatch"]
|
dispatch_cfg = cfg["dispatch"]
|
||||||
token = cfg["shared_password"]
|
token = cfg["shared_password"]
|
||||||
request_timeout = dispatch_cfg["request_timeout_seconds"]
|
request_timeout = dispatch_cfg["request_timeout_seconds"]
|
||||||
max_retries = dispatch_cfg.get("max_retries", 1)
|
max_retries = dispatch_cfg.get("max_retries", 1)
|
||||||
retry_on_failure = dispatch_cfg.get("retry_on_failure", True)
|
|
||||||
public_base_url = cfg["public_base_url"]
|
public_base_url = cfg["public_base_url"]
|
||||||
static_dir = cfg["static_dir"]
|
static_dir = cfg["static_dir"]
|
||||||
|
|
||||||
# --- 1. 读取客户端请求体(原始字节,不做解析) ---
|
|
||||||
body = await request.body()
|
|
||||||
|
|
||||||
# --- 2. 获取 worker(最多重试 max_retries+1 次) ---
|
# --- 2. 获取 worker(最多重试 max_retries+1 次) ---
|
||||||
attempts = max_retries + 1
|
attempts = max_retries + 1
|
||||||
last_error_response = None
|
last_error_response = None
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ from dataclasses import asdict, dataclass, field
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from gateway.reqlog import summarize_for_log
|
||||||
|
|
||||||
logger = logging.getLogger("gateway.logging_middleware")
|
logger = logging.getLogger("gateway.logging_middleware")
|
||||||
|
|
||||||
|
|
||||||
@@ -34,6 +36,8 @@ class RequestLogEntry:
|
|||||||
worker: str = "" # 处理请求的 worker URL(空串表示网关本地处理)
|
worker: str = "" # 处理请求的 worker URL(空串表示网关本地处理)
|
||||||
response_code: Optional[int] = None # 响应 JSON 中的 code 字段
|
response_code: Optional[int] = None # 响应 JSON 中的 code 字段
|
||||||
request_id: Optional[str] = None # 响应 JSON 中的 request_id
|
request_id: Optional[str] = None # 响应 JSON 中的 request_id
|
||||||
|
request_params: Optional[dict] = None # 入参(图片用 URL 引用,无 base64)
|
||||||
|
response_data: Optional[Any] = None # 出参摘要(递归截断)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -195,6 +199,8 @@ def _load_from_logfile(filepath: str, max_entries: int) -> int:
|
|||||||
worker=data.get("worker", ""),
|
worker=data.get("worker", ""),
|
||||||
response_code=data.get("response_code"),
|
response_code=data.get("response_code"),
|
||||||
request_id=data.get("request_id"),
|
request_id=data.get("request_id"),
|
||||||
|
request_params=data.get("request_params"),
|
||||||
|
response_data=data.get("response_data"),
|
||||||
)
|
)
|
||||||
_buffer.append(entry)
|
_buffer.append(entry)
|
||||||
count += 1
|
count += 1
|
||||||
@@ -248,6 +254,7 @@ async def request_logging_middleware(request, call_next):
|
|||||||
# 提取响应 body 并解析业务字段(仅 JSON 响应)
|
# 提取响应 body 并解析业务字段(仅 JSON 响应)
|
||||||
response_code = None
|
response_code = None
|
||||||
request_id = None
|
request_id = None
|
||||||
|
response_data = None
|
||||||
content_type = response.headers.get("content-type", "")
|
content_type = response.headers.get("content-type", "")
|
||||||
|
|
||||||
if "application/json" in content_type or "application/json" in (response.media_type or ""):
|
if "application/json" in content_type or "application/json" in (response.media_type or ""):
|
||||||
@@ -261,6 +268,7 @@ async def request_logging_middleware(request, call_next):
|
|||||||
data = json.loads(body)
|
data = json.loads(body)
|
||||||
response_code = data.get("code")
|
response_code = data.get("code")
|
||||||
request_id = data.get("request_id")
|
request_id = data.get("request_id")
|
||||||
|
response_data = summarize_for_log(data)
|
||||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -274,6 +282,9 @@ async def request_logging_middleware(request, call_next):
|
|||||||
media_type=response.media_type,
|
media_type=response.media_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 入参(由 forward.py / 接口4 handler 挂到 request.state)
|
||||||
|
request_params = getattr(request.state, "log_request_params", None)
|
||||||
|
|
||||||
# 记录
|
# 记录
|
||||||
now = datetime.datetime.utcnow()
|
now = datetime.datetime.utcnow()
|
||||||
entry = RequestLogEntry(
|
entry = RequestLogEntry(
|
||||||
@@ -287,6 +298,8 @@ async def request_logging_middleware(request, call_next):
|
|||||||
worker=worker_url,
|
worker=worker_url,
|
||||||
response_code=response_code,
|
response_code=response_code,
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
|
request_params=request_params,
|
||||||
|
response_data=response_data,
|
||||||
)
|
)
|
||||||
|
|
||||||
_buffer.append(entry)
|
_buffer.append(entry)
|
||||||
@@ -366,6 +379,8 @@ def get_stats() -> Dict[str, Any]:
|
|||||||
"worker": e.worker,
|
"worker": e.worker,
|
||||||
"response_code": e.response_code,
|
"response_code": e.response_code,
|
||||||
"request_id": e.request_id,
|
"request_id": e.request_id,
|
||||||
|
"request_params": e.request_params,
|
||||||
|
"response_data": e.response_data,
|
||||||
}
|
}
|
||||||
for e in recent_100
|
for e in recent_100
|
||||||
]
|
]
|
||||||
@@ -404,3 +419,250 @@ def get_stats() -> Dict[str, Any]:
|
|||||||
"recent": recent,
|
"recent": recent,
|
||||||
"last_updated": datetime.datetime.utcnow().isoformat() + "Z",
|
"last_updated": datetime.datetime.utcnow().isoformat() + "Z",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 按天统计(读取磁盘日志全量,支持任意历史日期 + 单次换发型耗时拆解)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# 接口路径 → 编号/名称(对齐 gateway/app.py 里各路由 docstring 中的「接口N」编号)
|
||||||
|
PATH_INTERFACE_MAP: Dict[str, Dict[str, Any]] = {
|
||||||
|
"/api/v1/face/measure": {"num": 1, "name": "四庭七眼测量标注"},
|
||||||
|
"/api/v1/hair/grow": {"num": 2, "name": "C端生发"},
|
||||||
|
"/api/v1/hair/grow-b": {"num": 3, "name": "B端生发(医生/操作端)"},
|
||||||
|
"/api/v1/face/features": {"num": 4, "name": "用户特征分析"},
|
||||||
|
"/api/v1/hairline/generate": {"num": 5, "name": "发际线PNG生成"},
|
||||||
|
"/api/v1/face/measure-v2": {"num": 6, "name": "四庭七眼测量标注 v2"},
|
||||||
|
"/api/v1/hair/grow-v2": {"num": 7, "name": "C端生发 v2"},
|
||||||
|
"/api/v1/head/mask": {"num": 9, "name": "头发遮罩生成"},
|
||||||
|
"/api/v1/head/band": {"num": 10, "name": "头部外缘膨胀带遮罩"},
|
||||||
|
}
|
||||||
|
|
||||||
|
# 会按「勾选发型数量」串行多次调用 ComfyUI 的接口(耗时 ≈ 固定开销 + 单次换发型耗时 × 发型数)
|
||||||
|
MULTI_STYLE_PATHS = {
|
||||||
|
"/api/v1/hair/grow",
|
||||||
|
"/api/v1/hair/grow-v2",
|
||||||
|
"/api/v1/hairline/generate",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _log_file_path() -> Path:
|
||||||
|
"""当前日志文件路径(若尚未 init_logging,用默认路径兜底)。"""
|
||||||
|
if _writer is not None:
|
||||||
|
return _writer.filepath
|
||||||
|
return Path(__file__).resolve().parent.parent / "gateway" / "request_log.jsonl"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_all_log_entries() -> List[dict]:
|
||||||
|
"""读取磁盘上完整的日志历史(含轮转备份 .jsonl.1,按时间顺序:备份在前)。"""
|
||||||
|
log_path = _log_file_path()
|
||||||
|
paths = []
|
||||||
|
backup = log_path.with_suffix(".jsonl.1")
|
||||||
|
if backup.exists():
|
||||||
|
paths.append(backup)
|
||||||
|
if log_path.exists():
|
||||||
|
paths.append(log_path)
|
||||||
|
|
||||||
|
entries: List[dict] = []
|
||||||
|
for p in paths:
|
||||||
|
try:
|
||||||
|
with open(p, "r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
entries.append(json.loads(line))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
logger.warning("读取日志文件失败: %s", p, exc_info=True)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ts(ts: str) -> Optional[datetime.datetime]:
|
||||||
|
for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"):
|
||||||
|
try:
|
||||||
|
return datetime.datetime.strptime(ts, fmt).replace(tzinfo=datetime.timezone.utc)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _percentile(values: List[float], p: float) -> float:
|
||||||
|
"""线性插值百分位数,values 需已排序。"""
|
||||||
|
if not values:
|
||||||
|
return 0.0
|
||||||
|
if len(values) == 1:
|
||||||
|
return values[0]
|
||||||
|
k = (len(values) - 1) * p
|
||||||
|
f = int(k)
|
||||||
|
c = min(f + 1, len(values) - 1)
|
||||||
|
if f == c:
|
||||||
|
return values[f]
|
||||||
|
return values[f] + (values[c] - values[f]) * (k - f)
|
||||||
|
|
||||||
|
|
||||||
|
def get_available_dates(tz_offset_hours: float = 8) -> List[str]:
|
||||||
|
"""返回日志中出现过的所有本地日期(YYYY-MM-DD),最新在前。"""
|
||||||
|
tz = datetime.timezone(datetime.timedelta(hours=tz_offset_hours))
|
||||||
|
dates = set()
|
||||||
|
for e in _read_all_log_entries():
|
||||||
|
t = _parse_ts(e.get("timestamp", ""))
|
||||||
|
if t is None:
|
||||||
|
continue
|
||||||
|
dates.add(t.astimezone(tz).date().isoformat())
|
||||||
|
return sorted(dates, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _estimate_per_style(durations_ms: List[float]) -> Optional[Dict[str, Any]]:
|
||||||
|
"""从一组请求耗时里拆解出「单次换发型边际耗时」与「固定开销」。
|
||||||
|
|
||||||
|
原理:这类接口对每个勾选的发型串行跑一次 ComfyUI,总耗时 ≈ 固定开销(人脸检测等)
|
||||||
|
+ 单次换发型耗时 × 发型数量。发型数量未被记录,这里用迭代最小二乘估计"量子"
|
||||||
|
(近似每多选 1 个发型多花多少秒),再据此把样本归类、做线性回归得到最终估计。
|
||||||
|
样本耗时种类过少(不足以分辨固定开销与边际耗时)时返回 None。
|
||||||
|
"""
|
||||||
|
xs = sorted(d / 1000.0 for d in durations_ms if d and d > 0)
|
||||||
|
if len(xs) < 3:
|
||||||
|
return None
|
||||||
|
|
||||||
|
guess = xs[0]
|
||||||
|
if guess <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _fit(ns: List[int], xs_: List[float]):
|
||||||
|
n_mean = sum(ns) / len(ns)
|
||||||
|
x_mean = sum(xs_) / len(xs_)
|
||||||
|
num = sum((n - n_mean) * (x - x_mean) for n, x in zip(ns, xs_))
|
||||||
|
den = sum((n - n_mean) ** 2 for n in ns)
|
||||||
|
if den == 0:
|
||||||
|
return None
|
||||||
|
slope = num / den
|
||||||
|
intercept = x_mean - slope * n_mean
|
||||||
|
return slope, intercept
|
||||||
|
|
||||||
|
for _ in range(8):
|
||||||
|
ns = [max(1, round(x / guess)) for x in xs]
|
||||||
|
fit = _fit(ns, xs)
|
||||||
|
if fit is None or fit[0] <= 0:
|
||||||
|
break
|
||||||
|
guess = fit[0]
|
||||||
|
|
||||||
|
ns_final = [max(1, round(x / guess)) for x in xs]
|
||||||
|
if len(set(ns_final)) < 2:
|
||||||
|
return None # 样本都挤在同一档,无法拆分固定开销 / 边际耗时
|
||||||
|
|
||||||
|
fit = _fit(ns_final, xs)
|
||||||
|
if fit is None or fit[0] <= 0:
|
||||||
|
return None
|
||||||
|
slope, intercept = fit
|
||||||
|
|
||||||
|
groups: Dict[int, List[float]] = {}
|
||||||
|
for x, n in zip(xs, ns_final):
|
||||||
|
groups.setdefault(n, []).append(x)
|
||||||
|
clusters = [
|
||||||
|
{"styles": n, "count": len(v), "avg_seconds": round(sum(v) / len(v), 2)}
|
||||||
|
for n, v in sorted(groups.items())
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"per_style_seconds": round(slope, 2),
|
||||||
|
"fixed_overhead_seconds": round(max(intercept, 0.0), 2),
|
||||||
|
"sample_count": len(xs),
|
||||||
|
"clusters": clusters,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_daily_stats(date_str: str, tz_offset_hours: float = 8) -> Dict[str, Any]:
|
||||||
|
"""统计某一天(本地时区,默认 UTC+8)的接口调用情况。
|
||||||
|
|
||||||
|
读取磁盘上的完整日志历史(不受内存环形缓冲区大小限制),返回:
|
||||||
|
- summary:当天总请求数/成功率/总耗时/平均耗时
|
||||||
|
- endpoints:按接口分组的详细统计(含接口编号/名称、耗时分布、
|
||||||
|
对「多发型串行」接口额外给出单次换发型耗时拆解)
|
||||||
|
- hourly:按小时的请求量分布,供画图用
|
||||||
|
"""
|
||||||
|
tz = datetime.timezone(datetime.timedelta(hours=tz_offset_hours))
|
||||||
|
try:
|
||||||
|
target_date = datetime.date.fromisoformat(date_str)
|
||||||
|
except ValueError:
|
||||||
|
return {"error": f"日期格式错误: {date_str!r},应为 YYYY-MM-DD"}
|
||||||
|
|
||||||
|
day_entries = []
|
||||||
|
for e in _read_all_log_entries():
|
||||||
|
if not str(e.get("path", "")).startswith("/api/"):
|
||||||
|
continue
|
||||||
|
t = _parse_ts(e.get("timestamp", ""))
|
||||||
|
if t is None:
|
||||||
|
continue
|
||||||
|
local = t.astimezone(tz)
|
||||||
|
if local.date() == target_date:
|
||||||
|
e = dict(e)
|
||||||
|
e["_local_hour"] = local.hour
|
||||||
|
day_entries.append(e)
|
||||||
|
|
||||||
|
if not day_entries:
|
||||||
|
return {
|
||||||
|
"date": date_str,
|
||||||
|
"summary": {"total": 0, "success_rate": 0, "avg_duration_ms": 0,
|
||||||
|
"total_duration_seconds": 0, "min_duration_ms": 0, "max_duration_ms": 0},
|
||||||
|
"endpoints": [],
|
||||||
|
"hourly": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
total = len(day_entries)
|
||||||
|
ok_count = sum(1 for e in day_entries
|
||||||
|
if e.get("status_code") == 200 and e.get("response_code") in (0, None))
|
||||||
|
all_durations = sorted(e.get("duration_ms", 0.0) for e in day_entries)
|
||||||
|
|
||||||
|
by_path: Dict[str, List[dict]] = {}
|
||||||
|
for e in day_entries:
|
||||||
|
by_path.setdefault(e["path"], []).append(e)
|
||||||
|
|
||||||
|
endpoints = []
|
||||||
|
for path, items in by_path.items():
|
||||||
|
durs = sorted(it.get("duration_ms", 0.0) for it in items)
|
||||||
|
ok = sum(1 for it in items
|
||||||
|
if it.get("status_code") == 200 and it.get("response_code") in (0, None))
|
||||||
|
info = PATH_INTERFACE_MAP.get(path, {"num": None, "name": path})
|
||||||
|
entry: Dict[str, Any] = {
|
||||||
|
"path": path,
|
||||||
|
"interface_num": info["num"],
|
||||||
|
"interface_name": info["name"],
|
||||||
|
"count": len(items),
|
||||||
|
"ok_count": ok,
|
||||||
|
"fail_count": len(items) - ok,
|
||||||
|
"success_rate": round(ok / len(items) * 100, 1),
|
||||||
|
"avg_duration_ms": round(sum(durs) / len(durs), 1),
|
||||||
|
"median_duration_ms": round(_percentile(durs, 0.5), 1),
|
||||||
|
"p95_duration_ms": round(_percentile(durs, 0.95), 1),
|
||||||
|
"min_duration_ms": round(durs[0], 1),
|
||||||
|
"max_duration_ms": round(durs[-1], 1),
|
||||||
|
"total_duration_seconds": round(sum(durs) / 1000, 1),
|
||||||
|
"per_style": None,
|
||||||
|
}
|
||||||
|
if path in MULTI_STYLE_PATHS:
|
||||||
|
entry["per_style"] = _estimate_per_style(durs)
|
||||||
|
endpoints.append(entry)
|
||||||
|
|
||||||
|
endpoints.sort(key=lambda x: -x["count"])
|
||||||
|
|
||||||
|
hourly_counts: Dict[int, int] = {}
|
||||||
|
for e in day_entries:
|
||||||
|
hourly_counts[e["_local_hour"]] = hourly_counts.get(e["_local_hour"], 0) + 1
|
||||||
|
hourly = [{"hour": h, "count": hourly_counts.get(h, 0)} for h in range(24) if hourly_counts.get(h, 0) > 0]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"date": date_str,
|
||||||
|
"summary": {
|
||||||
|
"total": total,
|
||||||
|
"success_rate": round(ok_count / total * 100, 1),
|
||||||
|
"avg_duration_ms": round(sum(all_durations) / total, 1),
|
||||||
|
"total_duration_seconds": round(sum(all_durations) / 1000, 1),
|
||||||
|
"min_duration_ms": round(all_durations[0], 1),
|
||||||
|
"max_duration_ms": round(all_durations[-1], 1),
|
||||||
|
},
|
||||||
|
"endpoints": endpoints,
|
||||||
|
"hourly": hourly,
|
||||||
|
}
|
||||||
|
|||||||
+68
-2
@@ -50,6 +50,14 @@ _pool_condition: Optional[asyncio.Condition] = None
|
|||||||
_health_task: Optional[asyncio.Task] = None
|
_health_task: Optional[asyncio.Task] = None
|
||||||
_shutdown_event: Optional[asyncio.Event] = None
|
_shutdown_event: Optional[asyncio.Event] = None
|
||||||
|
|
||||||
|
# 全局串行槽:同一时间最多 max_global_concurrency 个请求进入 worker 派发,其余排队。
|
||||||
|
# ⚠️ 依赖单进程 uvicorn 部署(hair-gateway.service 无 --workers)。
|
||||||
|
# 若改多 worker / gunicorn,进程内信号量会失效,需换成跨进程锁(文件锁/Redis)。
|
||||||
|
_global_sem: Optional[asyncio.Semaphore] = None
|
||||||
|
_global_max: int = 1
|
||||||
|
_global_busy: int = 0
|
||||||
|
_global_waiting: int = 0
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 健康检查后台任务
|
# 健康检查后台任务
|
||||||
@@ -162,6 +170,9 @@ async def init_pool(cfg: dict) -> None:
|
|||||||
_pool_condition = asyncio.Condition()
|
_pool_condition = asyncio.Condition()
|
||||||
_shutdown_event = asyncio.Event()
|
_shutdown_event = asyncio.Event()
|
||||||
|
|
||||||
|
# 全局串行槽(独立于 worker busy 标志)
|
||||||
|
init_global_slot(cfg)
|
||||||
|
|
||||||
# 立即做一轮健康检查以快速上线
|
# 立即做一轮健康检查以快速上线
|
||||||
hc_cfg = cfg["health_check"]
|
hc_cfg = cfg["health_check"]
|
||||||
token = cfg["shared_password"]
|
token = cfg["shared_password"]
|
||||||
@@ -206,6 +217,56 @@ async def shutdown_pool() -> None:
|
|||||||
logger.info("Worker 池已关闭")
|
logger.info("Worker 池已关闭")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 全局串行槽
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def init_global_slot(cfg: dict) -> None:
|
||||||
|
"""初始化全局串行信号量。
|
||||||
|
|
||||||
|
max_global_concurrency=1 → 同一时间只有一个请求进入 worker 派发,其余在信号量上排队。
|
||||||
|
依赖单进程 uvicorn 部署;多进程需换跨进程锁。
|
||||||
|
"""
|
||||||
|
global _global_sem, _global_max, _global_busy, _global_waiting
|
||||||
|
n = int(cfg.get("dispatch", {}).get("max_global_concurrency", 1))
|
||||||
|
if n < 1:
|
||||||
|
n = 1
|
||||||
|
_global_sem = asyncio.Semaphore(n)
|
||||||
|
_global_max = n
|
||||||
|
_global_busy = 0
|
||||||
|
_global_waiting = 0
|
||||||
|
logger.info("全局并发槽初始化: max=%d(单进程生效)", n)
|
||||||
|
|
||||||
|
|
||||||
|
async def acquire_global_slot(cfg: dict) -> bool:
|
||||||
|
"""获取一个全局处理槽。True=获得;False=排队超时。
|
||||||
|
|
||||||
|
超时由 dispatch.max_queue_wait_seconds 控制(需 < nginx proxy_read_timeout 600s)。
|
||||||
|
"""
|
||||||
|
global _global_busy, _global_waiting
|
||||||
|
if _global_sem is None:
|
||||||
|
return True # 未初始化,不限流
|
||||||
|
timeout = float(cfg.get("dispatch", {}).get("max_queue_wait_seconds", 590))
|
||||||
|
_global_waiting += 1
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(_global_sem.acquire(), timeout=timeout)
|
||||||
|
_global_busy += 1
|
||||||
|
return True
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning("全局排队超时(%.1fs),拒绝请求 | waiting=%d", timeout, _global_waiting)
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
_global_waiting -= 1
|
||||||
|
|
||||||
|
|
||||||
|
def release_global_slot() -> None:
|
||||||
|
"""释放全局处理槽。"""
|
||||||
|
global _global_busy
|
||||||
|
if _global_sem is not None:
|
||||||
|
_global_busy -= 1
|
||||||
|
_global_sem.release()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 派发
|
# 派发
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -270,9 +331,14 @@ async def release_worker(w: WorkerState) -> None:
|
|||||||
|
|
||||||
def get_pool_status() -> dict:
|
def get_pool_status() -> dict:
|
||||||
"""返回当前池状态(供 /gateway-health 使用)。"""
|
"""返回当前池状态(供 /gateway-health 使用)。"""
|
||||||
|
global_info = {
|
||||||
|
"global_max": _global_max,
|
||||||
|
"global_busy": _global_busy,
|
||||||
|
"global_waiting": _global_waiting,
|
||||||
|
}
|
||||||
if not _workers:
|
if not _workers:
|
||||||
return {"total": 0, "healthy": 0, "busy": 0}
|
return {"total": 0, "healthy": 0, "busy": 0, **global_info}
|
||||||
total = len(_workers)
|
total = len(_workers)
|
||||||
healthy = sum(1 for w in _workers.values() if w.online)
|
healthy = sum(1 for w in _workers.values() if w.online)
|
||||||
busy = sum(1 for w in _workers.values() if w.busy)
|
busy = sum(1 for w in _workers.values() if w.busy)
|
||||||
return {"total": total, "healthy": healthy, "busy": busy}
|
return {"total": total, "healthy": healthy, "busy": busy, **global_info}
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""请求日志辅助:multipart 入参解析(标量保留 + 图片存盘转 URL)、出参摘要。
|
||||||
|
|
||||||
|
设计原则(与用户约定):
|
||||||
|
- 入参/出参日志里**绝不内嵌图片 base64**。图片统一存盘后用 URL 引用,保持日志短小。
|
||||||
|
- 入参图片(image_file / *_base64)当前网关不存盘——这里补存到 static_dir(in_ 前缀),
|
||||||
|
复用现有 24h 清理循环自动回收;url 输入图本身就在远端,不重新下载,直接记 URL。
|
||||||
|
- 出参响应在 forward.py 已把 base64 改写成 *_url(无大图),记录完整 data,
|
||||||
|
但用递归摘要器截断超长结构(landmarks、长字符串),单条上限 ~8KB。
|
||||||
|
|
||||||
|
所有解析/存盘均包 try/except,**绝不影响请求转发**。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from python_multipart.multipart import parse_form, parse_options_header
|
||||||
|
|
||||||
|
logger = logging.getLogger("gateway.reqlog")
|
||||||
|
|
||||||
|
_PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _sniff_ext(data: bytes) -> str:
|
||||||
|
"""按 magic byte 嗅探图片扩展名(PNG / 否则 JPEG)。与 forward.py 一致。"""
|
||||||
|
return "png" if data[:8] == _PNG_MAGIC else "jpg"
|
||||||
|
|
||||||
|
|
||||||
|
def save_image_bytes(
|
||||||
|
data: bytes,
|
||||||
|
static_dir: str,
|
||||||
|
public_base_url: str,
|
||||||
|
prefix: str = "in_",
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""把图片字节存盘并返回公网 URL。失败返回 None(不抛异常)。
|
||||||
|
|
||||||
|
存到 static_dir/{prefix}{uuid}.{ext},URL = {public_base_url}/static/annotations/{file}。
|
||||||
|
与 forward.py 的 rewrite_base64_to_url 落盘路径/URL 规则一致,可被同一清理循环回收。
|
||||||
|
"""
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
ext = _sniff_ext(data)
|
||||||
|
filename = f"{prefix}{uuid.uuid4().hex}.{ext}"
|
||||||
|
target = Path(static_dir)
|
||||||
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
|
(target / filename).write_bytes(data)
|
||||||
|
return f"{public_base_url}/static/annotations/{filename}"
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.warning("保存输入图片失败: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_b64_text(text: str) -> Optional[bytes]:
|
||||||
|
"""解码 base64 文本(兼容 data: URI 前缀和裸 base64)。失败返回 None。"""
|
||||||
|
s = text.strip()
|
||||||
|
if s.startswith("data:") and "," in s:
|
||||||
|
s = s.split(",", 1)[1]
|
||||||
|
try:
|
||||||
|
return base64.b64decode(s)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _name(b) -> str:
|
||||||
|
"""把 field_name(可能是 bytes)安全解码为 str。"""
|
||||||
|
if isinstance(b, (bytes, bytearray)):
|
||||||
|
return b.decode("utf-8", "replace")
|
||||||
|
return str(b)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_form_params(
|
||||||
|
content_type: str,
|
||||||
|
body: bytes,
|
||||||
|
static_dir: str,
|
||||||
|
public_base_url: str,
|
||||||
|
) -> dict:
|
||||||
|
"""解析 multipart/form-data 请求体,返回可安全写入日志的入参 dict。
|
||||||
|
|
||||||
|
- 标量字段(gender/hair_style/use_mask/prompt/erode_cm/...):utf-8 解码,超 500 字符截断。
|
||||||
|
- *_base64 字段:视为图片 → 解码存盘 → 记 {_image, source:"base64", size, url},绝不保留 base64 文本。
|
||||||
|
- file 字段:读字节存盘 → 记 {_image, source:"file", filename?, ctype?, size, url}。
|
||||||
|
- 非 multipart:记 {_content_type, _body_bytes}。
|
||||||
|
任何异常都吞掉并在结果里记 _parse_error,不抛。
|
||||||
|
"""
|
||||||
|
result: dict = {}
|
||||||
|
|
||||||
|
if not content_type or "multipart/form-data" not in content_type:
|
||||||
|
result["_content_type"] = content_type or ""
|
||||||
|
result["_body_bytes"] = len(body)
|
||||||
|
return result
|
||||||
|
|
||||||
|
try:
|
||||||
|
_mime, options = parse_options_header(content_type)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
result["_parse_error"] = f"options_header: {e}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
if not options.get(b"boundary"):
|
||||||
|
result["_parse_error"] = "no boundary in content-type"
|
||||||
|
return result
|
||||||
|
|
||||||
|
def on_field(field) -> None:
|
||||||
|
try:
|
||||||
|
name = _name(field.field_name)
|
||||||
|
val = field.value
|
||||||
|
val_b = val if isinstance(val, (bytes, bytearray)) else (
|
||||||
|
val.encode("utf-8") if isinstance(val, str) else b""
|
||||||
|
)
|
||||||
|
if name.endswith("_base64"):
|
||||||
|
text = val_b.decode("utf-8", "replace")
|
||||||
|
data = _decode_b64_text(text)
|
||||||
|
if data:
|
||||||
|
url = save_image_bytes(data, static_dir, public_base_url)
|
||||||
|
result[name] = {"_image": True, "source": "base64",
|
||||||
|
"size": len(data), "url": url}
|
||||||
|
else:
|
||||||
|
result[name] = {"_image": True, "source": "base64",
|
||||||
|
"size": len(val_b), "url": None,
|
||||||
|
"_save_error": "decode failed"}
|
||||||
|
else:
|
||||||
|
text = val_b.decode("utf-8", "replace")
|
||||||
|
if len(text) > 500:
|
||||||
|
text = text[:500] + f"…(+{len(text) - 500} chars)"
|
||||||
|
result[name] = text
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.warning("on_field 解析失败 (%s): %s", getattr(field, "field_name", "?"), e)
|
||||||
|
|
||||||
|
def on_file(file) -> None:
|
||||||
|
try:
|
||||||
|
name = _name(file.field_name)
|
||||||
|
data = b""
|
||||||
|
fo = getattr(file, "file_object", None)
|
||||||
|
if fo is not None:
|
||||||
|
try:
|
||||||
|
fo.seek(0)
|
||||||
|
data = fo.read()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
data = b""
|
||||||
|
size = getattr(file, "size", None)
|
||||||
|
if size is None:
|
||||||
|
size = len(data)
|
||||||
|
entry: dict = {"_image": True, "source": "file", "size": size}
|
||||||
|
fn = getattr(file, "file_name", None)
|
||||||
|
if fn:
|
||||||
|
entry["filename"] = _name(fn)
|
||||||
|
ct = getattr(file, "content_type", None)
|
||||||
|
if ct:
|
||||||
|
entry["ctype"] = _name(ct)
|
||||||
|
entry["url"] = save_image_bytes(data, static_dir, public_base_url) if data else None
|
||||||
|
result[name] = entry
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.warning("on_file 解析失败 (%s): %s", getattr(file, "field_name", "?"), e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# headers: dict[str, bytes],仅需 Content-Type(含 boundary)。
|
||||||
|
# 不传 Content-Length:parse_form 会从 BytesIO 读到 EOF。
|
||||||
|
parse_form(
|
||||||
|
{"Content-Type": content_type.encode("utf-8")},
|
||||||
|
io.BytesIO(body),
|
||||||
|
on_field,
|
||||||
|
on_file,
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
result["_parse_error"] = f"parse_form: {e}"
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_for_log(
|
||||||
|
obj: Any,
|
||||||
|
max_str: int = 200,
|
||||||
|
max_list: int = 5,
|
||||||
|
max_depth: int = 6,
|
||||||
|
max_bytes: int = 8192,
|
||||||
|
) -> Any:
|
||||||
|
"""递归摘要:截断超长字符串 / 大数组 / 深层嵌套,保证日志短小。
|
||||||
|
|
||||||
|
- str 超 max_str → 截断并标注溢出字符数。
|
||||||
|
- list/tuple 超 max_list → 保留前 max_list 项 + "…(+N items)"。
|
||||||
|
- 嵌套深度超 max_depth → "<…>"。
|
||||||
|
- 摘要后 JSON 序列化若超 max_bytes → 退化为 {_truncated, size_bytes, keys} 桩。
|
||||||
|
"""
|
||||||
|
def _trunc(o: Any, depth: int) -> Any:
|
||||||
|
if depth > max_depth:
|
||||||
|
return "<…>"
|
||||||
|
if isinstance(o, bool) or o is None:
|
||||||
|
return o
|
||||||
|
if isinstance(o, (int, float)):
|
||||||
|
return o
|
||||||
|
if isinstance(o, str):
|
||||||
|
return o[:max_str] + f"…(+{len(o) - max_str} chars)" if len(o) > max_str else o
|
||||||
|
if isinstance(o, dict):
|
||||||
|
return {str(k): _trunc(v, depth + 1) for k, v in o.items()}
|
||||||
|
if isinstance(o, (list, tuple)):
|
||||||
|
n = len(o)
|
||||||
|
if n <= max_list:
|
||||||
|
return [_trunc(v, depth + 1) for v in o]
|
||||||
|
return [_trunc(v, depth + 1) for v in o[:max_list]] + [f"…(+{n - max_list} items)"]
|
||||||
|
return str(o)[:max_str]
|
||||||
|
|
||||||
|
summarized = _trunc(obj, 0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = json.dumps(summarized, ensure_ascii=False).encode("utf-8")
|
||||||
|
if len(raw) > max_bytes:
|
||||||
|
keys = list(summarized.keys()) if isinstance(summarized, dict) else None
|
||||||
|
return {"_truncated": True, "size_bytes": len(raw), "keys": keys}
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return {"_truncated": True}
|
||||||
|
|
||||||
|
return summarized
|
||||||
+1
-1
@@ -410,7 +410,7 @@
|
|||||||
},
|
},
|
||||||
"60": {
|
"60": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"text": "补充遮罩区域的头发,加一点美颜"
|
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
||||||
},
|
},
|
||||||
"class_type": "JjkText",
|
"class_type": "JjkText",
|
||||||
"_meta": {
|
"_meta": {
|
||||||
|
|||||||
+8
-3
@@ -85,11 +85,13 @@ def _get_output_node(workflow_path: str | None = None) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = None,
|
def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = None,
|
||||||
workflow_path: str | None = None) -> bytes:
|
workflow_path: str | None = None, front: bool = False) -> bytes:
|
||||||
"""提交一次生发任务,返回输出 PNG 字节。失败抛异常。
|
"""提交一次生发任务,返回输出 PNG 字节。失败抛异常。
|
||||||
|
|
||||||
prompt:非 None 时替换工作流节点60(JjkText)的文本;None 时用工作流内置默认提示词。
|
prompt:非 None 时替换工作流节点60(JjkText)的文本;None 时用工作流内置默认提示词。
|
||||||
workflow_path:工作流 JSON 路径,None 则用默认 add_hair.json。
|
workflow_path:工作流 JSON 路径,None 则用默认 add_hair.json。
|
||||||
|
front:True 时任务插到 ComfyUI 队列最前(server 端 "front" 字段,队列号取负)。
|
||||||
|
接口2 对时延敏感用 True,避免排在接口3/5 的批量任务后面;其余接口保持 False。
|
||||||
"""
|
"""
|
||||||
path = workflow_path or _WORKFLOW_DEFAULT
|
path = workflow_path or _WORKFLOW_DEFAULT
|
||||||
output_node = _get_output_node(path)
|
output_node = _get_output_node(path)
|
||||||
@@ -125,8 +127,11 @@ def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = Non
|
|||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 3. 提交
|
# 3. 提交(front=True 时插队到队列最前)
|
||||||
r = cli.post("/prompt", json={"prompt": wf, "client_id": client_id})
|
payload = {"prompt": wf, "client_id": client_id}
|
||||||
|
if front:
|
||||||
|
payload["front"] = True
|
||||||
|
r = cli.post("/prompt", json=payload)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
prompt_id = r.json()["prompt_id"]
|
prompt_id = r.json()["prompt_id"]
|
||||||
|
|
||||||
|
|||||||
+6
-4
@@ -16,7 +16,7 @@ from . import comfyui
|
|||||||
|
|
||||||
logger = logging.getLogger("hair.worker")
|
logger = logging.getLogger("hair.worker")
|
||||||
|
|
||||||
_DEFAULT_PROMPT = "填充遮罩区域的头发,皮肤加一点磨皮"
|
_DEFAULT_PROMPT = "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
||||||
_REPO = os.path.dirname(os.path.dirname(__file__))
|
_REPO = os.path.dirname(os.path.dirname(__file__))
|
||||||
_REPAINT_WORKFLOW = os.path.join(_REPO, "0716add-hair-api.json")
|
_REPAINT_WORKFLOW = os.path.join(_REPO, "0716add-hair-api.json")
|
||||||
|
|
||||||
@@ -55,14 +55,16 @@ def _process_mask_to_rgba(image_bytes: bytes, mask_bytes: bytes) -> bytes:
|
|||||||
|
|
||||||
|
|
||||||
def run_redraw(image_bytes: bytes, mask_bytes: bytes,
|
def run_redraw(image_bytes: bytes, mask_bytes: bytes,
|
||||||
prompt: str | None = None, timeout: float = 300.0) -> bytes:
|
prompt: str | None = None, timeout: float = 300.0,
|
||||||
|
front: bool = False) -> bytes:
|
||||||
"""直接调 ComfyUI 重绘 — 替代 local_test /api/generate。
|
"""直接调 ComfyUI 重绘 — 替代 local_test /api/generate。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
image_bytes: 人物图片字节(JPG/PNG)
|
image_bytes: 人物图片字节(JPG/PNG)
|
||||||
mask_bytes: 遮罩图片字节(支持红/白/alpha 遮罩格式)
|
mask_bytes: 遮罩图片字节(支持红/白/alpha 遮罩格式)
|
||||||
prompt: 提示词,None 用默认 "填充遮罩区域的头发,皮肤加一点磨皮"
|
prompt: 提示词,None 用默认 "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
||||||
timeout: ComfyUI 超时秒数
|
timeout: ComfyUI 超时秒数
|
||||||
|
front: True 时任务插到 ComfyUI 队列最前(接口2 时延敏感路径用)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
重绘后的 PNG 图片字节
|
重绘后的 PNG 图片字节
|
||||||
@@ -73,4 +75,4 @@ def run_redraw(image_bytes: bytes, mask_bytes: bytes,
|
|||||||
"""
|
"""
|
||||||
rgba_png = _process_mask_to_rgba(image_bytes, mask_bytes)
|
rgba_png = _process_mask_to_rgba(image_bytes, mask_bytes)
|
||||||
return comfyui.run(rgba_png, timeout=timeout, prompt=prompt,
|
return comfyui.run(rgba_png, timeout=timeout, prompt=prompt,
|
||||||
workflow_path=_REPAINT_WORKFLOW)
|
workflow_path=_REPAINT_WORKFLOW, front=front)
|
||||||
|
|||||||
+21
-8
@@ -47,7 +47,8 @@ def _call_local_redraw(image_png_bytes, mask_png_bytes, timeout=300.0):
|
|||||||
失败抛异常(调用方负责 try/except 跳过)。
|
失败抛异常(调用方负责 try/except 跳过)。
|
||||||
"""
|
"""
|
||||||
from .redraw import run_redraw
|
from .redraw import run_redraw
|
||||||
return run_redraw(image_png_bytes, mask_png_bytes, timeout=timeout)
|
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前,避免排在接口3/5 的批量任务后面
|
||||||
|
return run_redraw(image_png_bytes, mask_png_bytes, timeout=timeout, front=True)
|
||||||
|
|
||||||
# 发际线贴图档位:middle=默认(hairline_texture/),high/low 各自独立文件夹。
|
# 发际线贴图档位:middle=默认(hairline_texture/),high/low 各自独立文件夹。
|
||||||
_TEXTURE_DIRS = {
|
_TEXTURE_DIRS = {
|
||||||
@@ -192,7 +193,9 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
|
|||||||
h, w = image_bgr.shape[:2]
|
h, w = image_bgr.shape[:2]
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
compose_comfy_rgba(image_bgr, np.zeros((h, w), np.uint8)).save(buf, format="PNG")
|
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)
|
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前
|
||||||
|
shared_grown = comfyui.run(buf.getvalue(), prompt=prompt,
|
||||||
|
workflow_path=workflow_path, front=True)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
logger.warning("接口2 生发图失败(无遮罩):%s", e)
|
logger.warning("接口2 生发图失败(无遮罩):%s", e)
|
||||||
|
|
||||||
@@ -212,7 +215,9 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
|
|||||||
image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black)
|
image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black)
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
compose_comfy_rgba(marked, mask).save(buf, format="PNG")
|
compose_comfy_rgba(marked, mask).save(buf, format="PNG")
|
||||||
grown_png = comfyui.run(buf.getvalue(), prompt=prompt, workflow_path=workflow_path)
|
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前
|
||||||
|
grown_png = comfyui.run(buf.getvalue(), prompt=prompt,
|
||||||
|
workflow_path=workflow_path, front=True)
|
||||||
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
|
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
|
||||||
logger.warning("接口2 生发图失败 type=%s:%s", key, e)
|
logger.warning("接口2 生发图失败 type=%s:%s", key, e)
|
||||||
|
|
||||||
@@ -318,13 +323,16 @@ def _grow_from_texture(image_bgr: np.ndarray, ctx: dict, white_path: str | None,
|
|||||||
|
|
||||||
def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
|
def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
|
||||||
hair_styles: list[int], use_mask: bool = True,
|
hair_styles: list[int], use_mask: bool = True,
|
||||||
prompt: str | None = None):
|
prompt: str | None = None,
|
||||||
|
generate_grow_image: bool = True):
|
||||||
"""接口5:对选中发型返回 middle/high/low 三档发际线透明叠图 + 生发图(同接口2)。
|
"""接口5:对选中发型返回 middle/high/low 三档发际线透明叠图 + 生发图(同接口2)。
|
||||||
|
|
||||||
入参同接口2:先选 gender,再多选 hair_styles(必填,1-indexed 按贴图排序)。
|
入参同接口2:先选 gender,再多选 hair_styles(必填,1-indexed 按贴图排序)。
|
||||||
每个选中发型返回三档叠图(middle/high/low,RGBA 透明层只含发际线曲线)与一张生发图;
|
每个选中发型返回三档叠图(middle/high/low,RGBA 透明层只含发际线曲线)与一张生发图;
|
||||||
三档贴图同名,生发黑模板固定取自 hairline_texture_black/(middle),故生发目标固定 middle 档。
|
三档贴图同名,生发黑模板固定取自 hairline_texture_black/(middle),故生发目标固定 middle 档。
|
||||||
use_mask/prompt:同接口2 的生发参数。
|
use_mask/prompt:同接口2 的生发参数。
|
||||||
|
generate_grow_image(默认 True):是否生成生发图(ComfyUI,最耗时)。False 时跳过生发,
|
||||||
|
各发型 grown_png 恒为 None,可大幅降低耗时(仅留三档发际线叠图与中心点)。
|
||||||
Returns: {"images":[{hairline_type,order,overlays:{middle,high,low}((H,W,4) RGBA 透明层),grown_png}],
|
Returns: {"images":[{hairline_type,order,overlays:{middle,high,low}((H,W,4) RGBA 透明层),grown_png}],
|
||||||
"best_centers":{"middle":(x,y),"high":(x,y),"low":(x,y)}};无人脸 None。
|
"best_centers":{"middle":(x,y),"high":(x,y),"low":(x,y)}};无人脸 None。
|
||||||
best_centers 取首个选中发型三档各自的发际线中点。
|
best_centers 取首个选中发型三档各自的发际线中点。
|
||||||
@@ -346,8 +354,9 @@ def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
|
|||||||
tex_by_level = {lv: get_texture_map(lv)[gender] for lv in _TEXTURE_DIRS}
|
tex_by_level = {lv: get_texture_map(lv)[gender] for lv in _TEXTURE_DIRS}
|
||||||
|
|
||||||
# use_mask=False:干净原图+空遮罩与贴图无关,只跑一次 ComfyUI,选中项复用
|
# use_mask=False:干净原图+空遮罩与贴图无关,只跑一次 ComfyUI,选中项复用
|
||||||
|
# generate_grow_image=False:完全跳过生发(最耗时),grown_png 恒为 None
|
||||||
shared_grown = None
|
shared_grown = None
|
||||||
if not use_mask:
|
if generate_grow_image and not use_mask:
|
||||||
shared_grown = _grow_from_texture(image_bgr, ctx, None, use_mask=False, prompt=prompt)
|
shared_grown = _grow_from_texture(image_bgr, ctx, None, use_mask=False, prompt=prompt)
|
||||||
|
|
||||||
def _center_of(overlay):
|
def _center_of(overlay):
|
||||||
@@ -366,9 +375,13 @@ def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
|
|||||||
for lv in _TEXTURE_DIRS:
|
for lv in _TEXTURE_DIRS:
|
||||||
white = load_texture_rgba(tex_by_level[lv][s - 1][1])
|
white = load_texture_rgba(tex_by_level[lv][s - 1][1])
|
||||||
overlays[lv] = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)
|
overlays[lv] = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)
|
||||||
# 生发:固定 middle 黑模板
|
# 生发:固定 middle 黑模板(generate_grow_image=False 时跳过,恒 None)
|
||||||
grown_png = shared_grown if not use_mask else \
|
if not generate_grow_image:
|
||||||
_grow_from_texture(image_bgr, ctx, mid_path, use_mask=True, prompt=prompt)
|
grown_png = None
|
||||||
|
elif not use_mask:
|
||||||
|
grown_png = shared_grown
|
||||||
|
else:
|
||||||
|
grown_png = _grow_from_texture(image_bgr, ctx, mid_path, use_mask=True, prompt=prompt)
|
||||||
images.append({"hairline_type": key, "order": s,
|
images.append({"hairline_type": key, "order": s,
|
||||||
"overlays": overlays, "grown_png": grown_png})
|
"overlays": overlays, "grown_png": grown_png})
|
||||||
# best_centers:首个选中发型三档(middle/high/low)发际线中点
|
# best_centers:首个选中发型三档(middle/high/low)发际线中点
|
||||||
|
|||||||
+1
-1
@@ -206,7 +206,7 @@ def generate():
|
|||||||
return jsonify({"error": msg}), 400
|
return jsonify({"error": msg}), 400
|
||||||
image_file = request.files["image"]
|
image_file = request.files["image"]
|
||||||
mask_file = request.files["mask"]
|
mask_file = request.files["mask"]
|
||||||
prompt_text = request.form.get("prompt", "填充遮罩区域的头发,皮肤加一点磨皮")
|
prompt_text = request.form.get("prompt", "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
|
||||||
log.info(
|
log.info(
|
||||||
"收到请求: image=%s mask=%s prompt=%r",
|
"收到请求: image=%s mask=%s prompt=%r",
|
||||||
image_file.filename, mask_file.filename, prompt_text,
|
image_file.filename, mask_file.filename, prompt_text,
|
||||||
|
|||||||
+1
-1
@@ -27,7 +27,7 @@ def prep_and_upload():
|
|||||||
|
|
||||||
|
|
||||||
def run_once(fname, model, dtype, steps):
|
def run_once(fname, model, dtype, steps):
|
||||||
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮")
|
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
|
||||||
wf["16"]["inputs"]["unet_name"] = model
|
wf["16"]["inputs"]["unet_name"] = model
|
||||||
wf["16"]["inputs"]["weight_dtype"] = dtype
|
wf["16"]["inputs"]["weight_dtype"] = dtype
|
||||||
wf["1"]["inputs"]["steps"] = steps
|
wf["1"]["inputs"]["steps"] = steps
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ def upload(scale=1.0):
|
|||||||
|
|
||||||
|
|
||||||
def run(fname, steps):
|
def run(fname, steps):
|
||||||
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮")
|
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
|
||||||
wf["16"]["inputs"]["unet_name"] = MODEL
|
wf["16"]["inputs"]["unet_name"] = MODEL
|
||||||
wf["16"]["inputs"]["weight_dtype"] = DTYPE
|
wf["16"]["inputs"]["weight_dtype"] = DTYPE
|
||||||
wf["1"]["inputs"]["steps"] = steps
|
wf["1"]["inputs"]["steps"] = steps
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ SEED = 123456789
|
|||||||
imgs = []
|
imgs = []
|
||||||
labels = []
|
labels = []
|
||||||
for steps in [2, 3, 4, 6]:
|
for steps in [2, 3, 4, 6]:
|
||||||
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮", seed=SEED)
|
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", seed=SEED)
|
||||||
wf["16"]["inputs"]["unet_name"] = MODEL
|
wf["16"]["inputs"]["unet_name"] = MODEL
|
||||||
wf["16"]["inputs"]["weight_dtype"] = DTYPE
|
wf["16"]["inputs"]["weight_dtype"] = DTYPE
|
||||||
wf["1"]["inputs"]["steps"] = steps
|
wf["1"]["inputs"]["steps"] = steps
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ button { padding: 10px 24px; border: none; border-radius: 6px; cursor: pointer;
|
|||||||
</div>
|
</div>
|
||||||
<div class="controls" style="margin-top:16px">
|
<div class="controls" style="margin-top:16px">
|
||||||
<label>提示词:</label>
|
<label>提示词:</label>
|
||||||
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮">
|
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜">
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align:center; margin-top:16px">
|
<div style="text-align:center; margin-top:16px">
|
||||||
<button class="btn-generate" id="generateBtn" disabled>🚀 生成</button>
|
<button class="btn-generate" id="generateBtn" disabled>🚀 生成</button>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ resp = requests.post(
|
|||||||
"image": ("original.jpg", img_data, "image/jpeg"),
|
"image": ("original.jpg", img_data, "image/jpeg"),
|
||||||
"mask": ("mask.png", mask_data, "image/png"),
|
"mask": ("mask.png", mask_data, "image/png"),
|
||||||
},
|
},
|
||||||
data={"prompt": "填充遮罩区域的头发,皮肤加一点磨皮"},
|
data={"prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"},
|
||||||
timeout=600,
|
timeout=600,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>API 耗时看板</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
|
||||||
|
.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
||||||
|
h1 { font-size: 22px; margin-bottom: 4px; }
|
||||||
|
.subtitle { color: #888; font-size: 13px; margin-bottom: 18px; }
|
||||||
|
.nav { margin-bottom: 18px; }
|
||||||
|
.nav a { color: #2563eb; text-decoration: none; font-size: 13px; margin-right: 14px; }
|
||||||
|
.nav a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||||
|
.toolbar label { font-size: 13px; font-weight: 600; color: #374151; }
|
||||||
|
.toolbar select, .toolbar button { padding: 8px 14px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 14px; background: #fff; cursor: pointer; }
|
||||||
|
.toolbar select { min-width: 160px; }
|
||||||
|
.toolbar button { background: #2563eb; color: #fff; border: none; font-weight: 600; }
|
||||||
|
.toolbar button:hover { background: #1d4ed8; }
|
||||||
|
#loadingHint { font-size: 12px; color: #9ca3af; }
|
||||||
|
|
||||||
|
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 14px; margin-bottom: 24px; }
|
||||||
|
.stat-card { background: #fff; border-radius: 12px; padding: 18px 20px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
|
||||||
|
.stat-card .value { font-size: 26px; font-weight: 700; color: #111827; }
|
||||||
|
.stat-card .label { font-size: 11px; color: #9ca3af; text-transform: uppercase; letter-spacing: .5px; margin-top: 4px; }
|
||||||
|
.stat-card.ok .value { color: #059669; }
|
||||||
|
|
||||||
|
.hourly-wrap { background: #fff; border-radius: 12px; padding: 16px 20px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 24px; }
|
||||||
|
.hourly-wrap h2 { font-size: 14px; color: #374151; margin-bottom: 12px; }
|
||||||
|
.hourly-bars { display: flex; align-items: flex-end; gap: 4px; height: 80px; }
|
||||||
|
.hourly-bar { flex: 1; background: #93c5fd; border-radius: 3px 3px 0 0; position: relative; min-height: 2px; }
|
||||||
|
.hourly-bar .cnt { position: absolute; top: -16px; left: 0; right: 0; text-align: center; font-size: 10px; color: #6b7280; }
|
||||||
|
.hourly-labels { display: flex; gap: 4px; margin-top: 4px; }
|
||||||
|
.hourly-labels span { flex: 1; text-align: center; font-size: 10px; color: #9ca3af; }
|
||||||
|
|
||||||
|
.ep-card { background: #fff; border-radius: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 16px; overflow: hidden; }
|
||||||
|
.ep-header { padding: 14px 18px; background: #fafafa; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.ep-badge { background: #2563eb; color: #fff; padding: 3px 12px; border-radius: 12px; font-size: 12px; font-weight: 700; white-space: nowrap; }
|
||||||
|
.ep-name { font-size: 15px; font-weight: 700; color: #111827; }
|
||||||
|
.ep-path { font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; color: #9ca3af; }
|
||||||
|
.ep-success { margin-left: auto; font-size: 12px; }
|
||||||
|
.ep-body { padding: 16px 18px; }
|
||||||
|
|
||||||
|
.metrics-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 10px; margin-bottom: 4px; }
|
||||||
|
.metric { text-align: center; padding: 10px 4px; background: #f9fafb; border-radius: 8px; }
|
||||||
|
.metric .v { font-size: 17px; font-weight: 700; color: #1f2937; }
|
||||||
|
.metric .l { font-size: 10px; color: #9ca3af; margin-top: 2px; text-transform: uppercase; letter-spacing: .3px; }
|
||||||
|
|
||||||
|
.style-box { margin-top: 14px; background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 10px; padding: 14px 16px; }
|
||||||
|
.style-box .title { font-size: 13px; font-weight: 700; color: #1e40af; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
|
||||||
|
.style-box .formula { font-size: 12px; color: #1e3a8a; margin-bottom: 10px; font-family: "SF Mono", "Fira Code", monospace; background: #dbeafe; padding: 6px 10px; border-radius: 6px; display: inline-block; }
|
||||||
|
.style-metrics { display: flex; gap: 24px; margin-bottom: 10px; flex-wrap: wrap; }
|
||||||
|
.style-metrics .sm { }
|
||||||
|
.style-metrics .sm .v { font-size: 22px; font-weight: 700; color: #1d4ed8; }
|
||||||
|
.style-metrics .sm .l { font-size: 11px; color: #3b82f6; margin-top: 2px; }
|
||||||
|
.cluster-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 6px; }
|
||||||
|
.cluster-table th, .cluster-table td { padding: 5px 10px; text-align: center; border-bottom: 1px solid #dbeafe; }
|
||||||
|
.cluster-table th { color: #1e40af; font-weight: 700; }
|
||||||
|
.no-breakdown { font-size: 12px; color: #9ca3af; margin-top: 10px; }
|
||||||
|
|
||||||
|
.empty-state { text-align: center; padding: 60px 20px; color: #9ca3af; }
|
||||||
|
.footer-note { font-size: 12px; color: #9ca3af; margin-top: 20px; line-height: 1.8; }
|
||||||
|
.footer-note b { color: #6b7280; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.metrics-row { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>⏱️ API 耗时看板</h1>
|
||||||
|
<p class="subtitle">按天统计各接口调用次数与耗时分布,多发型接口额外拆解「单次换发型耗时」与「整体耗时」</p>
|
||||||
|
<div class="nav">
|
||||||
|
<a href="/">← 返回首页</a>
|
||||||
|
<a href="/admin/stats">实时请求统计</a>
|
||||||
|
<a href="/docs">API 文档</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<label for="dateSelect">选择日期</label>
|
||||||
|
<select id="dateSelect" onchange="loadDate()"></select>
|
||||||
|
<button onclick="loadDate()">🔄 刷新</button>
|
||||||
|
<span id="loadingHint"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="content"></div>
|
||||||
|
|
||||||
|
<div class="footer-note">
|
||||||
|
<div><b>说明:</b>「整个 API 耗时」为网关实测的请求总耗时(收到请求到返回响应),已排除网络传输本身的额外延迟。</div>
|
||||||
|
<div><b>单次换发型耗时</b>为估算值:C端生发 / C端生发v2 / 发际线PNG生成 这几个接口,对每个勾选的发型会串行调用一次 ComfyUI 重绘,
|
||||||
|
总耗时 ≈ 固定开销(人脸检测等,通常 < 1s)+ 单次换发型耗时 × 勾选的发型数量。日志未记录具体勾选了几个发型,
|
||||||
|
这里基于当天所有请求耗时的分布,用迭代最小二乘回归拆解出「单次换发型耗时」与「固定开销」两个分量,供参考。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function fmtSeconds(ms) {
|
||||||
|
if (ms == null) return '—';
|
||||||
|
const s = ms / 1000;
|
||||||
|
if (s < 60) return s.toFixed(1) + 's';
|
||||||
|
return (s / 60).toFixed(1) + 'min';
|
||||||
|
}
|
||||||
|
function fmtSecondsRaw(s) {
|
||||||
|
if (s == null) return '—';
|
||||||
|
if (s < 60) return s.toFixed(1) + 's';
|
||||||
|
return (s / 60).toFixed(1) + 'min';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDates() {
|
||||||
|
const r = await fetch('/admin/stats/dates');
|
||||||
|
const data = await r.json();
|
||||||
|
const sel = document.getElementById('dateSelect');
|
||||||
|
sel.innerHTML = '';
|
||||||
|
if (!data.dates || data.dates.length === 0) {
|
||||||
|
sel.innerHTML = '<option>暂无数据</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data.dates.forEach((d, i) => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = d;
|
||||||
|
opt.textContent = d + (i === 0 ? '(今天)' : '');
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
loadDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDate() {
|
||||||
|
const date = document.getElementById('dateSelect').value;
|
||||||
|
if (!date) return;
|
||||||
|
document.getElementById('loadingHint').textContent = '⏳ 加载中...';
|
||||||
|
try {
|
||||||
|
const r = await fetch('/admin/stats/daily?date=' + encodeURIComponent(date));
|
||||||
|
const data = await r.json();
|
||||||
|
render(data);
|
||||||
|
document.getElementById('loadingHint').textContent = '✅ 已更新 ' + new Date().toLocaleTimeString();
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('loadingHint').textContent = '❌ 加载失败: ' + e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(data) {
|
||||||
|
const el = document.getElementById('content');
|
||||||
|
if (data.error) {
|
||||||
|
el.innerHTML = '<div class="empty-state">⚠ ' + data.error + '</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data.summary || data.summary.total === 0) {
|
||||||
|
el.innerHTML = '<div class="empty-state">📭 ' + data.date + ' 当天没有调用记录</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = data.summary;
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
// 汇总卡片
|
||||||
|
html += '<div class="stats-grid">' +
|
||||||
|
'<div class="stat-card"><div class="value">' + s.total + '</div><div class="label">当天请求总数</div></div>' +
|
||||||
|
'<div class="stat-card ok"><div class="value">' + s.success_rate + '%</div><div class="label">成功率</div></div>' +
|
||||||
|
'<div class="stat-card"><div class="value">' + fmtSeconds(s.avg_duration_ms) + '</div><div class="label">平均耗时/次</div></div>' +
|
||||||
|
'<div class="stat-card"><div class="value">' + fmtSecondsRaw(s.total_duration_seconds) + '</div><div class="label">累计耗时</div></div>' +
|
||||||
|
'<div class="stat-card"><div class="value">' + fmtSeconds(s.max_duration_ms) + '</div><div class="label">最长单次耗时</div></div>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
// 按小时分布
|
||||||
|
if (data.hourly && data.hourly.length > 0) {
|
||||||
|
const maxCnt = Math.max(...data.hourly.map(h => h.count));
|
||||||
|
html += '<div class="hourly-wrap"><h2>📅 按小时请求量分布</h2><div class="hourly-bars">';
|
||||||
|
data.hourly.forEach(h => {
|
||||||
|
const pct = Math.max(4, Math.round(h.count / maxCnt * 100));
|
||||||
|
html += '<div class="hourly-bar" style="height:' + pct + '%"><div class="cnt">' + h.count + '</div></div>';
|
||||||
|
});
|
||||||
|
html += '</div><div class="hourly-labels">';
|
||||||
|
data.hourly.forEach(h => {
|
||||||
|
html += '<span>' + String(h.hour).padStart(2, '0') + '时</span>';
|
||||||
|
});
|
||||||
|
html += '</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按接口
|
||||||
|
data.endpoints.forEach(ep => {
|
||||||
|
const badge = ep.interface_num != null ? ('接口' + ep.interface_num) : '未知接口';
|
||||||
|
html += '<div class="ep-card">' +
|
||||||
|
'<div class="ep-header">' +
|
||||||
|
'<span class="ep-badge">' + badge + '</span>' +
|
||||||
|
'<span class="ep-name">' + ep.interface_name + '</span>' +
|
||||||
|
'<span class="ep-path">' + ep.path + '</span>' +
|
||||||
|
'<span class="ep-success">调用 <b>' + ep.count + '</b> 次 · 成功率 <b style="color:' + (ep.success_rate >= 99 ? '#059669' : '#d97706') + '">' + ep.success_rate + '%</b></span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="ep-body">' +
|
||||||
|
'<div class="metrics-row">' +
|
||||||
|
'<div class="metric"><div class="v">' + fmtSeconds(ep.avg_duration_ms) + '</div><div class="l">平均耗时</div></div>' +
|
||||||
|
'<div class="metric"><div class="v">' + fmtSeconds(ep.median_duration_ms) + '</div><div class="l">中位数</div></div>' +
|
||||||
|
'<div class="metric"><div class="v">' + fmtSeconds(ep.p95_duration_ms) + '</div><div class="l">P95</div></div>' +
|
||||||
|
'<div class="metric"><div class="v">' + fmtSeconds(ep.min_duration_ms) + '</div><div class="l">最快</div></div>' +
|
||||||
|
'<div class="metric"><div class="v">' + fmtSeconds(ep.max_duration_ms) + '</div><div class="l">最慢</div></div>' +
|
||||||
|
'<div class="metric"><div class="v">' + fmtSecondsRaw(ep.total_duration_seconds) + '</div><div class="l">累计耗时</div></div>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
if (ep.per_style) {
|
||||||
|
const ps = ep.per_style;
|
||||||
|
html += '<div class="style-box">' +
|
||||||
|
'<div class="title">✂️ 单次换发型耗时拆解(估算)</div>' +
|
||||||
|
'<div class="style-metrics">' +
|
||||||
|
'<div class="sm"><div class="v">' + ps.per_style_seconds.toFixed(1) + 's</div><div class="l">单次换发型耗时</div></div>' +
|
||||||
|
'<div class="sm"><div class="v">' + ps.fixed_overhead_seconds.toFixed(1) + 's</div><div class="l">固定开销</div></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="formula">整体 API 耗时 ≈ ' + ps.fixed_overhead_seconds.toFixed(1) + 's + ' + ps.per_style_seconds.toFixed(1) + 's × 发型数量</div>' +
|
||||||
|
'<table class="cluster-table"><thead><tr><th>推断发型数</th><th>样本数</th><th>平均耗时</th></tr></thead><tbody>';
|
||||||
|
ps.clusters.forEach(c => {
|
||||||
|
html += '<tr><td>' + c.styles + '</td><td>' + c.count + '</td><td>' + c.avg_seconds.toFixed(1) + 's</td></tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
} else if (['/api/v1/hair/grow', '/api/v1/hair/grow-v2', '/api/v1/hairline/generate'].includes(ep.path)) {
|
||||||
|
html += '<div class="no-breakdown">ℹ️ 当天该接口耗时分布过于集中,样本不足以拆解出单次换发型耗时(换个天试试,或等积累更多调用)</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div></div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDates();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+16
-40
@@ -57,7 +57,6 @@
|
|||||||
<a href="#if4">接口4</a>
|
<a href="#if4">接口4</a>
|
||||||
<a href="#if5">接口5</a>
|
<a href="#if5">接口5</a>
|
||||||
<a href="#if6">接口6</a>
|
<a href="#if6">接口6</a>
|
||||||
<a href="#if7">接口7</a>
|
|
||||||
<a href="#errors">错误码</a>
|
<a href="#errors">错误码</a>
|
||||||
<a href="#test">在线测试</a>
|
<a href="#test">在线测试</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,6 +108,8 @@
|
|||||||
<tr><td><code>landmarks</code></td><td>object</td><td>5 个关键点像素坐标:hair_top/hairline/brow_center/nose_bottom/chin_tip</td></tr>
|
<tr><td><code>landmarks</code></td><td>object</td><td>5 个关键点像素坐标:hair_top/hairline/brow_center/nose_bottom/chin_tip</td></tr>
|
||||||
<tr><td><code>hairline_source</code></td><td>string</td><td>发际线来源:<code>"segmentation"</code>(真实分割,可信度高)/ <code>"estimated"</code>(比例估算,可信度低)</td></tr>
|
<tr><td><code>hairline_source</code></td><td>string</td><td>发际线来源:<code>"segmentation"</code>(真实分割,可信度高)/ <code>"estimated"</code>(比例估算,可信度低)</td></tr>
|
||||||
<tr><td><code>head_pose</code></td><td>object</td><td>头部姿态角度:<code>{ yaw, pitch, roll }</code>(度),接近 0 表示正面照</td></tr>
|
<tr><td><code>head_pose</code></td><td>object</td><td>头部姿态角度:<code>{ yaw, pitch, roll }</code>(度),接近 0 表示正面照</td></tr>
|
||||||
|
<tr><td><code>left_position</code></td><td>object</td><td>MediaPipe 21 号关键点坐标(左脸定位点),原图像素:<code>{ x: number, y: number }</code></td></tr>
|
||||||
|
<tr><td><code>right_position</code></td><td>object</td><td>MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:<code>{ x: number, y: number }</code></td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<p style="margin-top:12px;font-size:12px;color:#64748b">💡 前端把标注图叠加到原图上即可呈现测量效果(标注图白色线条 #FFFFFF,透明底)。</p>
|
<p style="margin-top:12px;font-size:12px;color:#64748b">💡 前端把标注图叠加到原图上即可呈现测量效果(标注图白色线条 #FFFFFF,透明底)。</p>
|
||||||
@@ -189,17 +190,17 @@ const { code, data } = await res.json();
|
|||||||
<div class="card" id="if4">
|
<div class="card" id="if4">
|
||||||
<h2>4. 用户特征分析 <span class="badge post">POST</span> <code>/api/v1/face/features</code></h2>
|
<h2>4. 用户特征分析 <span class="badge post">POST</span> <code>/api/v1/face/features</code></h2>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="desc">上传照片 → 火山方舟豆包视觉模型分析 → 返回几十项面部特征(脸型/眉形/肤色/四季色彩…)。</p>
|
<p class="desc">上传照片 → 火山方舟豆包视觉模型分析 → 返回固定 6 项面部特征(脸型/眉形/面部年龄/动静类型/性别/基因风格)。</p>
|
||||||
|
|
||||||
<p><strong>入参</strong>:image_file / image_url / image_base64 三选一。无其他参数。</p>
|
<p><strong>入参</strong>:image_file / image_url / image_base64 三选一。无其他参数。</p>
|
||||||
|
|
||||||
<p style="margin-top:12px"><strong>data 字段</strong></p>
|
<p style="margin-top:12px"><strong>data 字段</strong></p>
|
||||||
<table>
|
<table>
|
||||||
<tr><th>字段</th><th>类型</th><th>说明</th></tr>
|
<tr><th>字段</th><th>类型</th><th>说明</th></tr>
|
||||||
<tr><td><code>features</code></td><td>string</td><td><strong>JSON 字符串</strong>(不是对象!客户端需 <code>JSON.parse()</code>)</td></tr>
|
<tr><td><code>features</code></td><td>string</td><td><strong>JSON 字符串</strong>(不是对象!客户端需 <code>JSON.parse()</code>)。解析后得到<strong>固定 6 个英文字段</strong></td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<p style="margin-top:8px"><strong>features 英文优先字段</strong>(其余中文字段同时返回,共~42个):</p>
|
<p style="margin-top:8px"><strong>features 字段</strong>(固定返回 6 个):</p>
|
||||||
<table>
|
<table>
|
||||||
<tr><th>字段</th><th>说明</th><th>字段</th><th>说明</th></tr>
|
<tr><th>字段</th><th>说明</th><th>字段</th><th>说明</th></tr>
|
||||||
<tr><td>face_shape</td><td>脸型</td><td>eyebrow_shape</td><td>眉形</td></tr>
|
<tr><td>face_shape</td><td>脸型</td><td>eyebrow_shape</td><td>眉形</td></tr>
|
||||||
@@ -214,7 +215,7 @@ const { code, data } = await res.json();
|
|||||||
const { code, data } = await res.json();
|
const { code, data } = await res.json();
|
||||||
const features = JSON.parse(data.features); // ← 注意:data.features 是字符串!
|
const features = JSON.parse(data.features); // ← 注意:data.features 是字符串!
|
||||||
console.log(features.face_shape); // "鹅蛋脸"
|
console.log(features.face_shape); // "鹅蛋脸"
|
||||||
console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保留)</pre>
|
console.log(features.gene_style); // "自然型"</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -230,12 +231,13 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保
|
|||||||
<tr><td>image_file / image_url / image_base64</td><td>—</td><td>三选一</td><td>用户正面照</td></tr>
|
<tr><td>image_file / image_url / image_base64</td><td>—</td><td>三选一</td><td>用户正面照</td></tr>
|
||||||
<tr><td>gender</td><td>string</td><td>✅ 必填</td><td><code>"male"</code> / <code>"female"</code></td></tr>
|
<tr><td>gender</td><td>string</td><td>✅ 必填</td><td><code>"male"</code> / <code>"female"</code></td></tr>
|
||||||
<tr><td>hair_style</td><td>string</td><td>✅ 必填</td><td>发型序号,逗号分隔多选(如 <code>1,2,3</code>)。缺失/越界返回 1007</td></tr>
|
<tr><td>hair_style</td><td>string</td><td>✅ 必填</td><td>发型序号,逗号分隔多选(如 <code>1,2,3</code>)。缺失/越界返回 1007</td></tr>
|
||||||
|
<tr><td>generate_grow_image</td><td>bool</td><td>否</td><td>是否生成生发效果图(ComfyUI 生发,全流程最耗时),默认 <code>true</code>。传 <code>false</code> 时跳过生发,各发型 <code>grown_image_url</code> 恒为 <code>null</code>,仅返回三档发际线叠图与中心点,大幅降低耗时</td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<p style="margin-top:12px"><strong>data 字段</strong></p>
|
<p style="margin-top:12px"><strong>data 字段</strong></p>
|
||||||
<table>
|
<table>
|
||||||
<tr><th>字段</th><th>类型</th><th>说明</th></tr>
|
<tr><th>字段</th><th>类型</th><th>说明</th></tr>
|
||||||
<tr><td><code>hairline_images[]</code></td><td>object[]</td><td>选中发型列表,每项含 <code>hairline_type</code>、<code>image_middle_url</code>/<code>image_high_url</code>/<code>image_low_url</code> 三档<strong>透明 PNG 叠图</strong>(仅曲线,需叠加原图)、<code>grown_image_url</code> 生发图(完整人像,失败为 null)、<code>order</code></td></tr>
|
<tr><td><code>hairline_images[]</code></td><td>object[]</td><td>选中发型列表,每项含 <code>hairline_type</code>、<code>image_middle_url</code>/<code>image_high_url</code>/<code>image_low_url</code> 三档<strong>透明 PNG 叠图</strong>(仅曲线,需叠加原图)、<code>grown_image_url</code> 生发图(完整人像,失败或 <code>generate_grow_image=false</code> 时为 null)、<code>order</code></td></tr>
|
||||||
<tr><td><code>best_hairline_center_point</code></td><td>object \| null</td><td>首个选中发型 <strong>middle 档</strong>发际线中心点像素坐标 <code>{ x: number, y: number }</code></td></tr>
|
<tr><td><code>best_hairline_center_point</code></td><td>object \| null</td><td>首个选中发型 <strong>middle 档</strong>发际线中心点像素坐标 <code>{ x: number, y: number }</code></td></tr>
|
||||||
<tr><td><code>high_hairline_center_point</code></td><td>object \| null</td><td>同上,<strong>high 档</strong>发际线中点(发际线偏高,y 更小)</td></tr>
|
<tr><td><code>high_hairline_center_point</code></td><td>object \| null</td><td>同上,<strong>high 档</strong>发际线中点(发际线偏高,y 更小)</td></tr>
|
||||||
<tr><td><code>low_hairline_center_point</code></td><td>object \| null</td><td>同上,<strong>low 档</strong>发际线中点(发际线偏低,y 更大)</td></tr>
|
<tr><td><code>low_hairline_center_point</code></td><td>object \| null</td><td>同上,<strong>low 档</strong>发际线中点(发际线偏低,y 更大)</td></tr>
|
||||||
@@ -251,6 +253,8 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保
|
|||||||
<tr><td><code>landmarks</code></td><td>object</td><td>5 个关键点像素坐标:hair_top/hairline/brow_center/nose_bottom/chin_tip</td></tr>
|
<tr><td><code>landmarks</code></td><td>object</td><td>5 个关键点像素坐标:hair_top/hairline/brow_center/nose_bottom/chin_tip</td></tr>
|
||||||
<tr><td><code>hairline_source</code></td><td>string</td><td>发际线来源:<code>"segmentation"</code>(真实分割)/ <code>"estimated"</code>(比例估算)</td></tr>
|
<tr><td><code>hairline_source</code></td><td>string</td><td>发际线来源:<code>"segmentation"</code>(真实分割)/ <code>"estimated"</code>(比例估算)</td></tr>
|
||||||
<tr><td><code>head_pose</code></td><td>object</td><td>头部姿态角度:<code>{ yaw, pitch, roll }</code>(度)</td></tr>
|
<tr><td><code>head_pose</code></td><td>object</td><td>头部姿态角度:<code>{ yaw, pitch, roll }</code>(度)</td></tr>
|
||||||
|
<tr><td><code>left_position</code></td><td>object</td><td>MediaPipe 21 号关键点坐标(左脸定位点),原图像素:<code>{ x: number, y: number }</code></td></tr>
|
||||||
|
<tr><td><code>right_position</code></td><td>object</td><td>MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:<code>{ x: number, y: number }</code></td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<p style="margin-top:12px;font-size:12px;color:#64748b">💡 前端无需额外请求接口1 即可拿到四庭七眼测量数值;<code>face_measure</code> 为 <code>null</code> 时(角度过大/无人脸等)仅隐藏测量区块,发际线结果照常展示。</p>
|
<p style="margin-top:12px;font-size:12px;color:#64748b">💡 前端无需额外请求接口1 即可拿到四庭七眼测量数值;<code>face_measure</code> 为 <code>null</code> 时(角度过大/无人脸等)仅隐藏测量区块,发际线结果照常展示。</p>
|
||||||
@@ -282,41 +286,12 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保
|
|||||||
<tr><td><code>four_courts</code></td><td>object</td><td>三庭:upper/middle/lower,各含 _cm 和 ratios(<strong>无 top_court</strong>)</td></tr>
|
<tr><td><code>four_courts</code></td><td>object</td><td>三庭:upper/middle/lower,各含 _cm 和 ratios(<strong>无 top_court</strong>)</td></tr>
|
||||||
<tr><td><code>seven_eyes</code></td><td>object</td><td>七眼:<code>eye_width_cm</code>/<code>face_width_cm</code>/<code>inter_eye_distance_cm</code> + <code>ratios</code> + <strong><code>eye2</code>~<code>eye6</code></strong>(左脸颊/左眼/两眼间距/右眼/右脸颊,5 段宽度 cm;<strong>无 eye1/eye7</strong>)</td></tr>
|
<tr><td><code>seven_eyes</code></td><td>object</td><td>七眼:<code>eye_width_cm</code>/<code>face_width_cm</code>/<code>inter_eye_distance_cm</code> + <code>ratios</code> + <strong><code>eye2</code>~<code>eye6</code></strong>(左脸颊/左眼/两眼间距/右眼/右脸颊,5 段宽度 cm;<strong>无 eye1/eye7</strong>)</td></tr>
|
||||||
<tr><td><code>landmarks</code></td><td>object</td><td>4 个关键点:hairline/brow_center/nose_bottom/chin_tip(<strong>无 hair_top</strong>)</td></tr>
|
<tr><td><code>landmarks</code></td><td>object</td><td>4 个关键点:hairline/brow_center/nose_bottom/chin_tip(<strong>无 hair_top</strong>)</td></tr>
|
||||||
|
<tr><td><code>left_position</code></td><td>object</td><td>MediaPipe 21 号关键点坐标(左脸定位点),原图像素:<code>{ x: number, y: number }</code></td></tr>
|
||||||
|
<tr><td><code>right_position</code></td><td>object</td><td>MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:<code>{ x: number, y: number }</code></td></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ======== 接口7 ======== -->
|
|
||||||
<div class="card" id="if7">
|
|
||||||
<h2>7. C端生发 v2 <span class="badge post">POST</span> <code>/api/v1/hair/grow-v2</code> <span class="badge warn">v2</span></h2>
|
|
||||||
<div class="card-body">
|
|
||||||
<p class="desc">功能与<a href="#if2">接口2</a>完全一致,仅 ComfyUI 工作流不同——使用 <code>add_hair2.json</code> 替代 <code>add_hair.json</code>(Flux-2 Klein 9b)。</p>
|
|
||||||
|
|
||||||
<p><strong>入参</strong></p>
|
|
||||||
<table>
|
|
||||||
<tr><th>参数</th><th>类型</th><th>必填</th><th>说明</th></tr>
|
|
||||||
<tr><td>image_file / image_url / image_base64</td><td>—</td><td>三选一</td><td>用户正面照</td></tr>
|
|
||||||
<tr><td>gender</td><td>string</td><td>✅ 必填</td><td><code>"male"</code> / <code>"female"</code></td></tr>
|
|
||||||
<tr><td>hair_style</td><td>int</td><td>✅ 必填</td><td>发型序号。female: 1~5,male: 1~4</td></tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<p style="margin-top:12px"><strong>data.results[] 元素</strong>(同接口2)</p>
|
|
||||||
<table>
|
|
||||||
<tr><th>字段</th><th>类型</th><th>说明</th></tr>
|
|
||||||
<tr><td><code>image_url</code></td><td>string</td><td>发际线曲线<strong>透明 PNG</strong>(仅曲线,需叠加原图显示,同接口2)</td></tr>
|
|
||||||
<tr><td><code>grown_image_url</code></td><td>string</td><td>生发后效果图(完整人像)⚠ 可空</td></tr>
|
|
||||||
<tr><td><code>hairline_type</code></td><td>string</td><td>发际线类型 key</td></tr>
|
|
||||||
<tr><td><code>order</code></td><td>int</td><td>排序</td></tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<p style="margin-top:8px;font-size:12px;color:#64748b">
|
|
||||||
Female 5 种:ellipse/flower/heart/straight/wave |
|
|
||||||
Male 4 种:ellipse/m/straight/inverse_arc<br>
|
|
||||||
⚠ 工作流: add_hair2.json(Flux-2 Klein 9b),输入节点 26,输出节点 75。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ======== 错误码 ======== -->
|
<!-- ======== 错误码 ======== -->
|
||||||
<div class="card" id="errors">
|
<div class="card" id="errors">
|
||||||
<h2>⚠ 错误码</h2>
|
<h2>⚠ 错误码</h2>
|
||||||
@@ -326,12 +301,14 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保
|
|||||||
<tr><td>1001</td><td>无法识别人像</td><td>未检测到人脸</td></tr>
|
<tr><td>1001</td><td>无法识别人像</td><td>未检测到人脸</td></tr>
|
||||||
|
|
||||||
<tr><td>1003</td><td>角度问题,非正面照</td><td>非正面 / 角度过大</td></tr>
|
<tr><td>1003</td><td>角度问题,非正面照</td><td>非正面 / 角度过大</td></tr>
|
||||||
|
<tr><td>1004</td><td>gender 必填且只能为 male / female</td><td>接口2/5 的 <code>gender</code> 缺失或非法</td></tr>
|
||||||
<tr><td>1005</td><td>检测到多张人脸</td><td>仅支持单人</td></tr>
|
<tr><td>1005</td><td>检测到多张人脸</td><td>仅支持单人</td></tr>
|
||||||
|
|
||||||
<tr><td>1007</td><td>图片参数错误 / 后端不可用</td><td>参数传错 / 服务繁忙请稍后重试</td></tr>
|
<tr><td>1007</td><td>图片参数错误 / 后端不可用</td><td>参数传错 / 服务繁忙请稍后重试</td></tr>
|
||||||
<tr><td>1008</td><td>图片格式不支持</td><td>非 JPG/PNG / base64 解码失败</td></tr>
|
<tr><td>1008</td><td>图片格式不支持</td><td>非 JPG/PNG / base64 解码失败</td></tr>
|
||||||
|
<tr><td>1009</td><td>未授权</td><td>缺少或错误的 <code>X-Internal-Token</code>(<code>/api/*</code> 路径鉴权)</td></tr>
|
||||||
</table>
|
</table>
|
||||||
<p style="font-size:12px;color:#94a3b8;margin-top:8px">1004 已废弃(接口2 不再自动判性别,改由客户端传 gender 参数)。</p>
|
<p style="font-size:12px;color:#94a3b8;margin-top:8px">注:1004 仍在使用(接口2/5 的 gender 校验);接口7(grow-v2)已弃用,请改用接口2。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -344,10 +321,9 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保
|
|||||||
<tr><td>1. 四庭七眼</td><td><a href="/static/test_interface1.html" class="link">/static/test_interface1.html</a></td><td>上传照片 → 原图+标注叠加,底图/标注开关,指标卡片</td></tr>
|
<tr><td>1. 四庭七眼</td><td><a href="/static/test_interface1.html" class="link">/static/test_interface1.html</a></td><td>上传照片 → 原图+标注叠加,底图/标注开关,指标卡片</td></tr>
|
||||||
<tr><td>2. C端生发</td><td><a href="/static/test_interface2.html" class="link">/static/test_interface2.html</a></td><td>上传+性别 → 方案一覧(原图/叠加/生发),双图对比</td></tr>
|
<tr><td>2. C端生发</td><td><a href="/static/test_interface2.html" class="link">/static/test_interface2.html</a></td><td>上传+性别 → 方案一覧(原图/叠加/生发),双图对比</td></tr>
|
||||||
<tr><td>3. B端生发</td><td><a href="/static/test_interface3.html" class="link">/static/test_interface3.html</a></td><td>划线图上传 → 生发效果图</td></tr>
|
<tr><td>3. B端生发</td><td><a href="/static/test_interface3.html" class="link">/static/test_interface3.html</a></td><td>划线图上传 → 生发效果图</td></tr>
|
||||||
<tr><td>4. 用户特征</td><td><a href="/static/test_interface4.html" class="link">/static/test_interface4.html</a></td><td>上传照片 → 42项面部特征表格 + 原始JSON</td></tr>
|
<tr><td>4. 用户特征</td><td><a href="/static/test_interface4.html" class="link">/static/test_interface4.html</a></td><td>上传照片 → 6项面部特征 + 原始JSON</td></tr>
|
||||||
<tr><td>5. 发际线PNG</td><td><a href="/static/test_interface5.html" class="link">/static/test_interface5.html</a></td><td>上传+性别 → 发际线方案+中心点坐标</td></tr>
|
<tr><td>5. 发际线PNG</td><td><a href="/static/test_interface5.html" class="link">/static/test_interface5.html</a></td><td>上传+性别 → 发际线方案+中心点坐标</td></tr>
|
||||||
<tr><td>6. 四庭七眼 v2</td><td><a href="/static/test_interface6.html" class="link">/static/test_interface6.html</a></td><td>同接口1,去顶庭 · 竖线发际线→下巴 · 无头部端线</td></tr>
|
<tr><td>6. 四庭七眼 v2</td><td><a href="/static/test_interface6.html" class="link">/static/test_interface6.html</a></td><td>同接口1,去顶庭 · 竖线发际线→下巴 · 无头部端线</td></tr>
|
||||||
<tr><td>7. C端生发 v2</td><td><a href="/static/test_interface7.html" class="link">/static/test_interface7.html</a></td><td>同接口2,使用 add_hair2.json 工作流(Flux-2 Klein 9b)</td></tr>
|
|
||||||
</table>
|
</table>
|
||||||
<p style="font-size:12px;color:#94a3b8;margin-top:12px">
|
<p style="font-size:12px;color:#94a3b8;margin-top:12px">
|
||||||
完整 API 文档:<a href="/docs" class="link">/docs</a>(Swagger UI)
|
完整 API 文档:<a href="/docs" class="link">/docs</a>(Swagger UI)
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="upload-row" style="margin-top:10px">
|
<div class="upload-row" style="margin-top:10px">
|
||||||
<label style="font-size:13px;font-weight:600;white-space:nowrap">重绘提示词</label>
|
<label style="font-size:13px;font-weight:600;white-space:nowrap">重绘提示词</label>
|
||||||
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发,皮肤加一点磨皮" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||||
</div>
|
</div>
|
||||||
<div class="upload-row" style="margin-top:10px">
|
<div class="upload-row" style="margin-top:10px">
|
||||||
<label style="font-size:13px;font-weight:600;white-space:nowrap">X-Internal-Token</label>
|
<label style="font-size:13px;font-weight:600;white-space:nowrap">X-Internal-Token</label>
|
||||||
@@ -354,7 +354,7 @@ function dataUriToBlob(dataUri) {
|
|||||||
|
|
||||||
async function runLocalRedraw() {
|
async function runLocalRedraw() {
|
||||||
if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; }
|
if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; }
|
||||||
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,皮肤加一点磨皮';
|
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜';
|
||||||
const btn = $('redrawBtn');
|
const btn = $('redrawBtn');
|
||||||
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
|
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
|
||||||
flog('===== 调后端重绘 =====', 'info');
|
flog('===== 调后端重绘 =====', 'info');
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="upload-row" style="margin-top:10px">
|
<div class="upload-row" style="margin-top:10px">
|
||||||
<label style="font-size:13px;font-weight:600;white-space:nowrap">重绘提示词</label>
|
<label style="font-size:13px;font-weight:600;white-space:nowrap">重绘提示词</label>
|
||||||
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发,皮肤加一点磨皮" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||||
</div>
|
</div>
|
||||||
<div class="params">
|
<div class="params">
|
||||||
<div class="pf">
|
<div class="pf">
|
||||||
@@ -169,7 +169,7 @@ async function submitTest() {
|
|||||||
|
|
||||||
async function runLocalRedraw() {
|
async function runLocalRedraw() {
|
||||||
if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; }
|
if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; }
|
||||||
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,皮肤加一点磨皮';
|
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜';
|
||||||
const btn = $('redrawBtn');
|
const btn = $('redrawBtn');
|
||||||
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
|
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@
|
|||||||
<div class="hint">JPG/PNG | 生发图生成较慢(数十秒~数分钟),请耐心等待</div>
|
<div class="hint">JPG/PNG | 生发图生成较慢(数十秒~数分钟),请耐心等待</div>
|
||||||
<div style="margin-top:10px;display:flex;align-items:center;gap:8px">
|
<div style="margin-top:10px;display:flex;align-items:center;gap:8px">
|
||||||
<label style="font-size:13px;font-weight:600;color:#374151;white-space:nowrap">💬 提示词</label>
|
<label style="font-size:13px;font-weight:600;color:#374151;white-space:nowrap">💬 提示词</label>
|
||||||
<input type="text" id="promptInput" value="补充遮罩区域的头发,加一点美颜" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
|
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
|
||||||
</div>
|
</div>
|
||||||
<div id="statusBar" class="status hidden"></div>
|
<div id="statusBar" class="status hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -94,7 +94,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="upload-group" style="margin-top:14px">
|
<div class="upload-group" style="margin-top:14px">
|
||||||
<div class="label">💬 提示词(prompt)</div>
|
<div class="label">💬 提示词(prompt)</div>
|
||||||
<input type="text" id="promptInput" value="补充遮罩区域的头发,加一点美颜" style="width:100%;padding:8px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:14px;max-width:500px">
|
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="width:100%;padding:8px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:14px;max-width:500px">
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top:14px;display:flex;gap:12px;align-items:center">
|
<div style="margin-top:14px;display:flex;gap:12px;align-items:center">
|
||||||
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交</button>
|
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交</button>
|
||||||
|
|||||||
@@ -113,6 +113,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>生发效果图</label>
|
||||||
|
<label class="checkbox-inline" style="font-weight:normal;display:flex;align-items:center;gap:6px">
|
||||||
|
<input type="checkbox" id="genGrowImg" checked> generate_grow_image(默认开;关闭后跳过最耗时的生发,仅返回三档叠图与中心点)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交</button>
|
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交</button>
|
||||||
<button class="btn btn-outline btn-sm" onclick="clearResults()">清除</button>
|
<button class="btn btn-outline btn-sm" onclick="clearResults()">清除</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -206,7 +212,7 @@ async function submitTest() {
|
|||||||
$('submitBtn').disabled = true; $('submitBtn').textContent = '⏳ ...';
|
$('submitBtn').disabled = true; $('submitBtn').textContent = '⏳ ...';
|
||||||
setStatus('请求中...', 'info'); $('resultsArea').classList.add('hidden');
|
setStatus('请求中...', 'info'); $('resultsArea').classList.add('hidden');
|
||||||
|
|
||||||
const fd = new FormData(); fd.append('image_file', f); fd.append('gender', $('gender').value); fd.append('hair_style', checked.join(','));
|
const fd = new FormData(); fd.append('image_file', f); fd.append('gender', $('gender').value); fd.append('hair_style', checked.join(',')); fd.append('generate_grow_image', $('genGrowImg').checked ? 'true' : 'false');
|
||||||
const _reqStart = performance.now();
|
const _reqStart = performance.now();
|
||||||
try {
|
try {
|
||||||
const r = await fetch(API_BASE + '/api/v1/hairline/generate', { method:'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd });
|
const r = await fetch(API_BASE + '/api/v1/hairline/generate', { method:'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd });
|
||||||
|
|||||||
@@ -104,7 +104,7 @@
|
|||||||
<div class="hint">JPG/PNG | 生发图生成较慢(数十秒~数分钟),请耐心等待 | 工作流: add_hair2.json</div>
|
<div class="hint">JPG/PNG | 生发图生成较慢(数十秒~数分钟),请耐心等待 | 工作流: add_hair2.json</div>
|
||||||
<div style="margin-top:10px;display:flex;align-items:center;gap:8px">
|
<div style="margin-top:10px;display:flex;align-items:center;gap:8px">
|
||||||
<label style="font-size:13px;font-weight:600;color:#374151;white-space:nowrap">💬 提示词</label>
|
<label style="font-size:13px;font-weight:600;color:#374151;white-space:nowrap">💬 提示词</label>
|
||||||
<input type="text" id="promptInput" value="补充遮罩区域的头发,加一点美颜" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
|
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
|
||||||
</div>
|
</div>
|
||||||
<div id="statusBar" class="status hidden"></div>
|
<div id="statusBar" class="status hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""gateway.pool 全局串行槽单测:验证 max_global_concurrency=1 时同一时间只跑1个请求、
|
||||||
|
排队超时返回 False。
|
||||||
|
|
||||||
|
用 asyncio.run 套同步测试,避免引入 pytest-asyncio 依赖。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from gateway import pool
|
||||||
|
|
||||||
|
_CFG = {"dispatch": {"max_global_concurrency": 1, "max_queue_wait_seconds": 590}}
|
||||||
|
_CFG_FAST_TIMEOUT = {"dispatch": {"max_global_concurrency": 1, "max_queue_wait_seconds": 0.2}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_global_slot_serializes_to_one():
|
||||||
|
pool.init_global_slot(_CFG)
|
||||||
|
|
||||||
|
state = {"active": 0, "peak": 0}
|
||||||
|
|
||||||
|
async def task():
|
||||||
|
got = await pool.acquire_global_slot(_CFG)
|
||||||
|
assert got is True
|
||||||
|
state["active"] += 1
|
||||||
|
state["peak"] = max(state["peak"], state["active"])
|
||||||
|
await asyncio.sleep(0.03)
|
||||||
|
state["active"] -= 1
|
||||||
|
pool.release_global_slot()
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
await asyncio.gather(*(task() for _ in range(4)))
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
assert state["peak"] == 1, f"期望峰值并发=1,实际={state['peak']}"
|
||||||
|
assert pool.get_pool_status()["global_busy"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_global_slot_timeout_returns_false():
|
||||||
|
pool.init_global_slot(_CFG_FAST_TIMEOUT)
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
first = await pool.acquire_global_slot(_CFG_FAST_TIMEOUT)
|
||||||
|
assert first is True
|
||||||
|
# 槽已被占用,第二个应在 0.2s 内超时
|
||||||
|
second = await pool.acquire_global_slot(_CFG_FAST_TIMEOUT)
|
||||||
|
assert second is False
|
||||||
|
pool.release_global_slot()
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
assert pool.get_pool_status()["global_busy"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_pool_status_has_global_fields():
|
||||||
|
pool.init_global_slot(_CFG)
|
||||||
|
status = pool.get_pool_status()
|
||||||
|
assert "global_max" in status
|
||||||
|
assert "global_busy" in status
|
||||||
|
assert "global_waiting" in status
|
||||||
|
assert status["global_max"] == 1
|
||||||
@@ -62,3 +62,41 @@ def test_threshold_gating_rejects_when_zeroed():
|
|||||||
def test_pose_none_is_not_blocked():
|
def test_pose_none_is_not_blocked():
|
||||||
"""solvePnP 失败(返回 None)时不拦截,check_frontal_face 返回 True。"""
|
"""solvePnP 失败(返回 None)时不拦截,check_frontal_face 返回 True。"""
|
||||||
assert pose.estimate_head_pose.__doc__ # 占位,确保导入
|
assert pose.estimate_head_pose.__doc__ # 占位,确保导入
|
||||||
|
|
||||||
|
|
||||||
|
def test_iterative_flipped_solution_falls_back_to_sqpnp():
|
||||||
|
"""回归:部分正面照上 ITERATIVE 会解出 tz<0、roll≈±180°,应回退 SQPNP。
|
||||||
|
|
||||||
|
像素点取自一张真实正面短发照(720×945);裸跑 ITERATIVE 会得到负深度。
|
||||||
|
"""
|
||||||
|
W, H = 720, 945
|
||||||
|
# 鼻尖 / 下巴 / 左眼外 / 右眼外 / 左嘴角 / 右嘴角(像素)
|
||||||
|
px = [
|
||||||
|
(358.32715988, 600.98652095),
|
||||||
|
(346.83344364, 779.63507116),
|
||||||
|
(242.94779778, 466.76155195),
|
||||||
|
(478.43703747, 480.10321766),
|
||||||
|
(284.13277388, 679.95527387),
|
||||||
|
(422.19510555, 684.31899190),
|
||||||
|
]
|
||||||
|
lm = [_LM(0.5, 0.5) for _ in range(478)]
|
||||||
|
for idx, (u, v) in zip(PNP_INDICES, px):
|
||||||
|
lm[idx] = _LM(u / W, v / H)
|
||||||
|
|
||||||
|
class _Holder:
|
||||||
|
landmark = lm
|
||||||
|
|
||||||
|
holder = _Holder()
|
||||||
|
# 确认裸 ITERATIVE 确实是翻转解(否则本回归失去意义)
|
||||||
|
image_points = np.array(px, dtype=np.float64)
|
||||||
|
cam = np.array([[float(W), 0, W / 2], [0, float(W), H / 2], [0, 0, 1]],
|
||||||
|
dtype=np.float64)
|
||||||
|
ok, rvec, tvec = cv2.solvePnP(
|
||||||
|
_MODEL_POINTS, image_points, cam, np.zeros((4, 1)),
|
||||||
|
flags=cv2.SOLVEPNP_ITERATIVE,
|
||||||
|
)
|
||||||
|
assert ok and float(tvec[2, 0]) < 0
|
||||||
|
|
||||||
|
yaw, pitch, roll = estimate_head_pose(holder, W, H)
|
||||||
|
assert abs(roll) < 30, f"roll 应被纠正,实际 roll={roll}"
|
||||||
|
assert check_frontal_face(holder, W, H) is True
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""gateway.reqlog 单测:multipart 入参解析(标量 + 图片存盘转 URL)+ 出参摘要。
|
||||||
|
|
||||||
|
不依赖网络 / GPU,纯函数级。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
|
||||||
|
from gateway.reqlog import extract_form_params, summarize_for_log
|
||||||
|
|
||||||
|
# 1x1 透明 PNG(合法 magic byte)
|
||||||
|
_PNG = (
|
||||||
|
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
||||||
|
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01"
|
||||||
|
b"\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _multipart(fields, files, boundary=b"----testbnd"):
|
||||||
|
"""手搓 multipart/form-data body。fields: {name:str}; files: {name:(fname,ctype,bytes)}."""
|
||||||
|
parts = []
|
||||||
|
for name, val in fields.items():
|
||||||
|
parts.append(b"--" + boundary + b"\r\n")
|
||||||
|
parts.append(b'Content-Disposition: form-data; name="' + name.encode() + b'"\r\n\r\n')
|
||||||
|
parts.append(val.encode("utf-8") + b"\r\n")
|
||||||
|
for name, (fname, ctype, data) in files.items():
|
||||||
|
parts.append(b"--" + boundary + b"\r\n")
|
||||||
|
parts.append(b'Content-Disposition: form-data; name="' + name.encode() + b'"; filename="'
|
||||||
|
+ fname.encode() + b'"\r\n')
|
||||||
|
parts.append(b"Content-Type: " + ctype.encode() + b"\r\n\r\n")
|
||||||
|
parts.append(data + b"\r\n")
|
||||||
|
parts.append(b"--" + boundary + b"--\r\n")
|
||||||
|
return b"".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_scalars_and_images(tmp_path):
|
||||||
|
b64 = "data:image/png;base64," + base64.b64encode(_PNG).decode()
|
||||||
|
body = _multipart(
|
||||||
|
fields={"gender": "female", "hair_style": "1,3", "use_mask": "True",
|
||||||
|
"prompt": "填充遮罩区域的头发", "image_base64": b64},
|
||||||
|
files={"image_file": ("x.png", "image/png", _PNG)},
|
||||||
|
)
|
||||||
|
ct = "multipart/form-data; boundary=----testbnd"
|
||||||
|
|
||||||
|
res = extract_form_params(ct, body, str(tmp_path), "https://test.local")
|
||||||
|
|
||||||
|
# 标量原样
|
||||||
|
assert res["gender"] == "female"
|
||||||
|
assert res["hair_style"] == "1,3"
|
||||||
|
assert res["use_mask"] == "True"
|
||||||
|
|
||||||
|
# 图片字段是元信息 dict(不是 base64 文本)
|
||||||
|
assert res["image_file"]["_image"] is True
|
||||||
|
assert res["image_file"]["source"] == "file"
|
||||||
|
assert res["image_file"]["filename"] == "x.png"
|
||||||
|
assert res["image_file"]["url"] is not None
|
||||||
|
assert res["image_base64"]["_image"] is True
|
||||||
|
assert res["image_base64"]["source"] == "base64"
|
||||||
|
assert res["image_base64"]["url"] is not None
|
||||||
|
|
||||||
|
# 图片已落盘
|
||||||
|
saved = [p.name for p in tmp_path.iterdir()]
|
||||||
|
assert any(n.startswith("in_") and n.endswith(".png") for n in saved)
|
||||||
|
# URL 指向正确路径
|
||||||
|
assert res["image_file"]["url"].startswith("https://test.local/static/annotations/in_")
|
||||||
|
|
||||||
|
# 关键:日志里绝不包含 base64 文本
|
||||||
|
blob = json.dumps(res, ensure_ascii=False)
|
||||||
|
assert b64.split(",", 1)[1][:40] not in blob
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_non_multipart(tmp_path):
|
||||||
|
res = extract_form_params("application/json", b'{"a":1}', str(tmp_path), "https://t.local")
|
||||||
|
assert res == {"_content_type": "application/json", "_body_bytes": 7}
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_empty_content_type(tmp_path):
|
||||||
|
res = extract_form_params("", b"hello", str(tmp_path), "https://t.local")
|
||||||
|
assert res["_body_bytes"] == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_truncates_long_values():
|
||||||
|
big = {
|
||||||
|
"landmarks": [[i, i] for i in range(500)],
|
||||||
|
"name": "x" * 1000,
|
||||||
|
"deep": {"a": {"b": {"c": {"d": {"e": {"f": {"g": 1}}}}}}},
|
||||||
|
}
|
||||||
|
sm = summarize_for_log(big)
|
||||||
|
# 长数组被截断 + 标注剩余数
|
||||||
|
assert isinstance(sm["landmarks"], list)
|
||||||
|
assert sm["landmarks"][-1].startswith("…(+")
|
||||||
|
assert len(sm["landmarks"]) == 6 # 5 项 + 1 个标注
|
||||||
|
# 长字符串被截断
|
||||||
|
assert "…(+" in sm["name"]
|
||||||
|
# 总体积受控
|
||||||
|
assert len(json.dumps(sm, ensure_ascii=False).encode()) <= 8192
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_keeps_small_intact():
|
||||||
|
obj = {"code": 0, "data": {"hairline_images": [{"order": 1, "image_middle_url": "https://x/y.png"}]}}
|
||||||
|
assert summarize_for_log(obj) == obj
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_byte_cap_stub():
|
||||||
|
# 构造大量小字段触发字节上限
|
||||||
|
obj = {f"k{i}": {f"j{j}": "abcdefghij" for j in range(50)} for i in range(50)}
|
||||||
|
sm = summarize_for_log(obj, max_bytes=2048)
|
||||||
|
assert isinstance(sm, dict) and sm.get("_truncated") is True
|
||||||
|
assert "size_bytes" in sm
|
||||||
Reference in New Issue
Block a user