diff --git a/.gitignore b/.gitignore index 7df2e12..a917e02 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,32 @@ static/report_hairline_v2.zip # local_test 运行期日志 / pid(不入 git) local_test/hair_service.log local_test/hair_service.pid + +# benchmark 原始输出(含结果图+原图,体积大,不入 git) +benchmark_out/ + +# benchmark 部署的 HTML 报告(图片 base64 内嵌,体积大,不入 git) +static/hairstyle_thumbs/ + +# 网关运行期日志(不入 git) +gateway.log + +# 工作流备份文件(不入 git) +*.json.bak.* + +# 脸型测试素材(人像照片,体积大,不入 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 + diff --git a/0716add-hair-api.json b/0716add-hair-api.json index d4de696..3458035 100644 --- a/0716add-hair-api.json +++ b/0716add-hair-api.json @@ -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"}}} \ No newline at end of file +{ + "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" + } + } +} \ No newline at end of file diff --git a/0716add-hair.json b/0716add-hair.json index bbc94fd..db7e4e1 100644 --- a/0716add-hair.json +++ b/0716add-hair.json @@ -1 +1 @@ -{"id":"431f1189-2c6a-448d-925e-5f6c85416393","revision":0,"last_node_id":63,"last_link_id":104,"nodes":[{"id":37,"type":"Image To Mask","pos":[330.00017074563834,-900.0000465455873],"size":[280,110],"flags":{},"order":13,"mode":0,"inputs":[{"label":"图像","localized_name":"图像","name":"image","type":"IMAGE","link":47},{"label":"方法","localized_name":"方法","name":"method","type":"COMBO","widget":{"name":"method"},"link":null}],"outputs":[{"label":"遮罩","localized_name":"遮罩","name":"MASK","type":"MASK","links":[79]}],"properties":{"Node name for S&R":"Image To Mask","cnr_id":"masquerade-nodes-comfyui","ver":"432cb4d146a391b387a0cd25ace824328b5b61cf","ue_properties":{"version":"7.7","widget_ue_connectable":{},"input_ue_unconnectable":{}},"ttNbgOverride":{"color":"#223","bgcolor":"#335","groupcolor":"#88A"}},"widgets_values":["intensity"],"color":"#223","bgcolor":"#335"},{"id":33,"type":"Mask Fill Holes","pos":[320.0000351119579,-1320.0000465455873],"size":[290,80],"flags":{},"order":9,"mode":0,"inputs":[{"label":"遮罩","localized_name":"遮罩","name":"masks","type":"MASK","link":38}],"outputs":[{"label":"遮罩","localized_name":"遮罩","name":"MASKS","type":"MASK","links":[41]}],"properties":{"cnr_id":"was-node-suite-comfyui","ver":"1cd8d304eda256c412b8589ce1f00be3c61cf9ec","Node name for S&R":"Mask Fill Holes","ue_properties":{"version":"7.7","widget_ue_connectable":{},"input_ue_unconnectable":{}},"ttNbgOverride":{"color":"#223","bgcolor":"#335","groupcolor":"#88A"}},"widgets_values":[],"color":"#223","bgcolor":"#335"},{"id":32,"type":"LayerUtility: ImageScaleByAspectRatio V2","pos":[650.0000351119579,-1399.9999109119071],"size":[320,480],"flags":{},"order":14,"mode":0,"inputs":[{"label":"图像","localized_name":"image","name":"image","shape":7,"type":"IMAGE","link":101},{"label":"遮罩","localized_name":"mask","name":"mask","shape":7,"type":"MASK","link":79},{"localized_name":"aspect_ratio","name":"aspect_ratio","type":"COMBO","widget":{"name":"aspect_ratio"},"link":null},{"label":"proportional_width","localized_name":"proportional_width","name":"proportional_width","type":"INT","widget":{"name":"proportional_width"},"link":55},{"label":"proportional_height","localized_name":"proportional_height","name":"proportional_height","type":"INT","widget":{"name":"proportional_height"},"link":56},{"localized_name":"fit","name":"fit","type":"COMBO","widget":{"name":"fit"},"link":null},{"localized_name":"method","name":"method","type":"COMBO","widget":{"name":"method"},"link":null},{"localized_name":"round_to_multiple","name":"round_to_multiple","type":"COMBO","widget":{"name":"round_to_multiple"},"link":null},{"localized_name":"scale_to_side","name":"scale_to_side","type":"COMBO","widget":{"name":"scale_to_side"},"link":null},{"localized_name":"scale_to_length","name":"scale_to_length","type":"INT","widget":{"name":"scale_to_length"},"link":null},{"localized_name":"background_color","name":"background_color","type":"STRING","widget":{"name":"background_color"},"link":null}],"outputs":[{"label":"图像","localized_name":"image","name":"image","type":"IMAGE","links":[57]},{"label":"遮罩","localized_name":"mask","name":"mask","type":"MASK","links":[58]},{"label":"原始大小","localized_name":"original_size","name":"original_size","type":"BOX"},{"label":"width","localized_name":"width","name":"width","type":"INT"},{"label":"height","localized_name":"height","name":"height","type":"INT"}],"properties":{"cnr_id":"comfyui_layerstyle","ver":"a46b1e6d26d45be9784c49f7065ba44700ef2b63","Node name for S&R":"LayerUtility: ImageScaleByAspectRatio V2","ue_properties":{"version":"7.7","widget_ue_connectable":{},"input_ue_unconnectable":{}}},"widgets_values":["custom",1,1,"letterbox","lanczos","8","None",1024,"#000000"],"color":"rgba(38, 73, 116, 0.7)"},{"id":14,"type":"GetImageSize+","pos":[1400.0001707456386,-930.0000465455873],"size":[320,120.15625],"flags":{},"order":17,"mode":0,"inputs":[{"label":"图像","localized_name":"图像","name":"image","type":"IMAGE","link":62}],"outputs":[{"label":"宽度","localized_name":"宽度","name":"width","type":"INT","links":[3,7]},{"label":"高度","localized_name":"高度","name":"height","type":"INT","links":[4,8]},{"label":"count","localized_name":"count","name":"count","type":"INT"}],"properties":{"cnr_id":"comfyui_essentials","ver":"9d9f4bedfc9f0321c19faf71855e228c93bd0dc9","Node name for S&R":"GetImageSize+","ue_properties":{"version":"7.7","widget_ue_connectable":{},"input_ue_unconnectable":{}},"ttNbgOverride":{"color":"#223","bgcolor":"#335","groupcolor":"#88A"}},"widgets_values":[],"color":"#223","bgcolor":"#335"},{"id":13,"type":"VAEEncode","pos":[1409.9998994782773,-1100.0000465455873],"size":[320,100],"flags":{},"order":18,"mode":0,"inputs":[{"label":"图像","localized_name":"像素","name":"pixels","type":"IMAGE","link":63},{"label":"VAE","localized_name":"vae","name":"vae","type":"VAE","link":18}],"outputs":[{"label":"Latent","localized_name":"Latent","name":"LATENT","type":"LATENT","links":[6]}],"properties":{"cnr_id":"comfy-core","ver":"0.3.41","Node name for S&R":"VAEEncode","ue_properties":{"version":"7.7","widget_ue_connectable":{},"input_ue_unconnectable":{}},"ttNbgOverride":{"color":"#223","bgcolor":"#335","groupcolor":"#88A"}},"widgets_values":[],"color":"#223","bgcolor":"#335"},{"id":9,"type":"SamplerCustomAdvanced","pos":[2500.000170745638,-1220.0000465455873],"size":[270,330],"flags":{},"order":24,"mode":0,"inputs":[{"label":"噪波生成","localized_name":"噪波","name":"noise","type":"NOISE","link":9},{"label":"引导","localized_name":"引导器","name":"guider","type":"GUIDER","link":10},{"label":"采样器","localized_name":"采样器","name":"sampler","type":"SAMPLER","link":11},{"label":"Sigmas","localized_name":"西格玛","name":"sigmas","type":"SIGMAS","link":12},{"label":"Latent","localized_name":"Latent图像","name":"latent_image","type":"LATENT","link":13}],"outputs":[{"label":"输出","localized_name":"Latent","name":"output","type":"LATENT","slot_index":0,"links":[14]},{"label":"降噪输出","localized_name":"降噪Latent","name":"denoised_output","type":"LATENT"}],"properties":{"cnr_id":"comfy-core","ver":"0.3.41","Node name for S&R":"SamplerCustomAdvanced","ue_properties":{"version":"7.7","widget_ue_connectable":{},"input_ue_unconnectable":{}},"ttNbgOverride":{"color":"#223","bgcolor":"#335","groupcolor":"#88A"}},"widgets_values":[],"color":"#223","bgcolor":"#335"},{"id":22,"type":"CLIPTextEncode","pos":[1300.0001707456386,-1709.999910911907],"size":[390,140],"flags":{},"order":7,"mode":0,"inputs":[{"label":"CLIP","localized_name":"clip","name":"clip","type":"CLIP","link":92},{"label":"text","localized_name":"文本","name":"text","type":"STRING","widget":{"name":"text"},"link":98}],"outputs":[{"label":"条件","localized_name":"条件","name":"CONDITIONING","type":"CONDITIONING","slot_index":0,"links":[24]}],"properties":{"cnr_id":"comfy-core","ver":"0.3.41","Node name for S&R":"CLIPTextEncode","ue_properties":{"version":"7.7","widget_ue_connectable":{},"input_ue_unconnectable":{}}},"widgets_values":["The yellow part of the picture is replaced by a black bowl."],"color":"#232","bgcolor":"#353"},{"id":10,"type":"VAEDecode","pos":[2759.9998994782773,-1370.0000465455873],"size":[320,100],"flags":{},"order":25,"mode":0,"inputs":[{"label":"Latent","localized_name":"Latent","name":"samples","type":"LATENT","link":14},{"label":"VAE","localized_name":"vae","name":"vae","type":"VAE","link":15}],"outputs":[{"label":"图像","localized_name":"图像","name":"IMAGE","type":"IMAGE","slot_index":0,"links":[95]}],"properties":{"cnr_id":"comfy-core","ver":"0.3.41","Node name for S&R":"VAEDecode","ue_properties":{"version":"7.7","widget_ue_connectable":{},"input_ue_unconnectable":{}},"ttNbgOverride":{"color":"#223","bgcolor":"#335","groupcolor":"#88A"}},"widgets_values":[],"color":"#223","bgcolor":"#335"},{"id":39,"type":"ImageScale","pos":[320.0000351119579,-1150.0000465455873],"size":[290,210],"flags":{},"order":12,"mode":0,"inputs":[{"label":"图像","localized_name":"图像","name":"image","type":"IMAGE","link":44},{"localized_name":"缩放算法","name":"upscale_method","type":"COMBO","widget":{"name":"upscale_method"},"link":null},{"label":"width","localized_name":"宽度","name":"width","type":"INT","widget":{"name":"width"},"link":45},{"label":"height","localized_name":"高度","name":"height","type":"INT","widget":{"name":"height"},"link":46},{"localized_name":"裁剪","name":"crop","type":"COMBO","widget":{"name":"crop"},"link":null}],"outputs":[{"label":"图像","localized_name":"图像","name":"IMAGE","type":"IMAGE","links":[47]}],"properties":{"cnr_id":"comfy-core","ver":"0.3.43","Node name for S&R":"ImageScale","ue_properties":{"version":"7.7","widget_ue_connectable":{},"input_ue_unconnectable":{}},"ttNbgOverride":{"color":"#223","bgcolor":"#335","groupcolor":"#88A"}},"widgets_values":["nearest-exact",512,512,"disabled"],"color":"#223","bgcolor":"#335"},{"id":36,"type":"Convert Masks to Images","pos":[320.0000351119579,-1250.0000465455873],"size":[290,80],"flags":{},"order":11,"mode":0,"inputs":[{"label":"遮罩","localized_name":"遮罩","name":"masks","type":"MASK","link":41}],"outputs":[{"label":"图像","localized_name":"图像","name":"IMAGES","type":"IMAGE","links":[44]}],"properties":{"cnr_id":"was-node-suite-comfyui","ver":"1cd8d304eda256c412b8589ce1f00be3c61cf9ec","Node name for S&R":"Convert Masks to Images","ue_properties":{"version":"7.7","widget_ue_connectable":{},"input_ue_unconnectable":{}},"ttNbgOverride":{"color":"#223","bgcolor":"#335","groupcolor":"#88A"}},"widgets_values":[],"color":"#223","bgcolor":"#335"},{"id":44,"type":"ImageAndMaskPreview","pos":[639.9998994782773,-990.0000465455873],"size":[290,200],"flags":{},"order":15,"mode":0,"inputs":[{"label":"图像","localized_name":"图像","name":"image","shape":7,"type":"IMAGE","link":57},{"label":"遮罩","localized_name":"遮罩","name":"mask","shape":7,"type":"MASK","link":58},{"label":"遮罩不透明","localized_name":"遮罩不透明","name":"mask_opacity","type":"FLOAT","widget":{"name":"mask_opacity"},"link":null},{"label":"遮罩颜色","localized_name":"遮罩颜色","name":"mask_color","type":"STRING","widget":{"name":"mask_color"},"link":null},{"label":"穿透","localized_name":"穿透","name":"pass_through","type":"BOOLEAN","widget":{"name":"pass_through"},"link":null}],"outputs":[{"label":"图像","localized_name":"图像","name":"composite","type":"IMAGE","links":[59,62,63]}],"properties":{"cnr_id":"comfyui-kjnodes","ver":"aeab1a7de581dc7253f48257392e8287bbeee4a9","Node name for S&R":"ImageAndMaskPreview","ue_properties":{"version":"7.7","widget_ue_connectable":{},"input_ue_unconnectable":{}},"ttNbgOverride":{"color":"#223","bgcolor":"#335","groupcolor":"#88A"}},"widgets_values":[1,"FFFF00",true],"color":"#223","bgcolor":"#335"},{"id":7,"type":"EmptySD3LatentImage","pos":[1789.9998994782773,-990.0000465455873],"size":[310,170],"flags":{},"order":20,"mode":0,"inputs":[{"label":"width","localized_name":"宽度","name":"width","type":"INT","widget":{"name":"width"},"link":7},{"label":"height","localized_name":"高度","name":"height","type":"INT","widget":{"name":"height"},"link":8},{"localized_name":"批量大小","name":"batch_size","type":"INT","widget":{"name":"batch_size"},"link":null}],"outputs":[{"label":"Latent","localized_name":"Latent","name":"LATENT","type":"LATENT","slot_index":0,"links":[13]}],"properties":{"cnr_id":"comfy-core","ver":"0.3.41","Node name for S&R":"EmptySD3LatentImage","ue_properties":{"version":"7.7","widget_ue_connectable":{"width":true,"height":true},"input_ue_unconnectable":{}},"ttNbgOverride":{"color":"#223","bgcolor":"#335","groupcolor":"#88A"}},"widgets_values":[1024,1024,1],"color":"#223","bgcolor":"#335"},{"id":62,"type":"ColorMatch","pos":[2790.000170745638,-1210.0000465455873],"size":[270,200],"flags":{},"order":26,"mode":0,"inputs":[{"label":"参考图像","localized_name":"参考图像","name":"image_ref","type":"IMAGE","link":102},{"label":"目标图像","localized_name":"目标图像","name":"image_target","type":"IMAGE","link":95},{"label":"方法","localized_name":"方法","name":"method","type":"COMBO","widget":{"name":"method"},"link":null},{"label":"强度","localized_name":"强度","name":"strength","shape":7,"type":"FLOAT","widget":{"name":"strength"},"link":null},{"label":"多线程","localized_name":"多线程","name":"multithread","shape":7,"type":"BOOLEAN","widget":{"name":"multithread"},"link":null}],"outputs":[{"label":"图像","localized_name":"图像","name":"image","type":"IMAGE","links":[96,97]}],"properties":{"cnr_id":"comfyui-kjnodes","ver":"5b38397a6430fdb16c7bd14a6bd64c2b0e69a5f0","Node name for S&R":"ColorMatch","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.6.2"},"ttNbgOverride":{"color":"#223","bgcolor":"#335","groupcolor":"#88A"}},"widgets_values":["mkl",1,true],"color":"#223","bgcolor":"#335"},{"id":1,"type":"BasicScheduler","pos":[2150.000170745638,-1220.0000465455873],"size":[320,180],"flags":{},"order":22,"mode":0,"inputs":[{"label":"模型","localized_name":"模型","name":"model","type":"MODEL","link":1},{"localized_name":"调度器","name":"scheduler","type":"COMBO","widget":{"name":"scheduler"},"link":null},{"localized_name":"步数","name":"steps","type":"INT","widget":{"name":"steps"},"link":null},{"localized_name":"降噪","name":"denoise","type":"FLOAT","widget":{"name":"denoise"},"link":null}],"outputs":[{"label":"Sigmas","localized_name":"Sigmas","name":"SIGMAS","type":"SIGMAS","links":[12]}],"properties":{"cnr_id":"comfy-core","ver":"0.3.41","Node name for S&R":"BasicScheduler","ue_properties":{"version":"7.7","widget_ue_connectable":{},"input_ue_unconnectable":{}},"ttNbgOverride":{"color":"#223","bgcolor":"#335","groupcolor":"#88A"}},"widgets_values":["simple",6,1],"color":"#223","bgcolor":"#335"},{"id":6,"type":"RandomNoise","pos":[1770.0001707456386,-1170.0000465455873],"size":[310,110],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"噪波随机种","name":"noise_seed","type":"INT","widget":{"name":"noise_seed"},"link":null}],"outputs":[{"label":"噪波生成","localized_name":"噪波","name":"NOISE","type":"NOISE","links":[9]}],"properties":{"cnr_id":"comfy-core","ver":"0.3.41","Node name for S&R":"RandomNoise","ue_properties":{"version":"7.7","widget_ue_connectable":{},"input_ue_unconnectable":{}}},"widgets_values":[1109407891267066,"randomize"],"color":"#2a363b","bgcolor":"#3f5159"},{"id":8,"type":"KSamplerSelect","pos":[1750.0001707456386,-1290.0000465455873],"size":[320,110],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"采样器名称","name":"sampler_name","type":"COMBO","widget":{"name":"sampler_name"},"link":null}],"outputs":[{"label":"采样器","localized_name":"采样器","name":"SAMPLER","type":"SAMPLER","links":[11]}],"properties":{"cnr_id":"comfy-core","ver":"0.3.41","Node name for S&R":"KSamplerSelect","ue_properties":{"version":"7.7","widget_ue_connectable":{},"input_ue_unconnectable":{}},"ttNbgOverride":{"color":"#223","bgcolor":"#335","groupcolor":"#88A"}},"widgets_values":["euler"],"color":"#223","bgcolor":"#335"},{"id":31,"type":"easy imageSize","pos":[-29.999964888042086,-1380.0000465455873],"size":[290,170],"flags":{},"order":8,"mode":0,"inputs":[{"label":"图像","localized_name":"图像","name":"image","type":"IMAGE","link":100}],"outputs":[{"label":"宽度","localized_name":"宽度","name":"width_int","type":"INT","links":[45,55]},{"label":"高度","localized_name":"高度","name":"height_int","type":"INT","links":[46,56]}],"properties":{"cnr_id":"comfyui-easy-use","ver":"501d97bb5ca8d61b486d47379fb1f21a41b772d7","Node name for S&R":"easy imageSize","ue_properties":{"version":"7.7","widget_ue_connectable":{},"input_ue_unconnectable":{}},"ttNbgOverride":{"color":"#223","bgcolor":"#335","groupcolor":"#88A"}},"widgets_values":["Width: 767 , Height: 747"],"color":"#223","bgcolor":"#335"},{"id":20,"type":"BasicGuider","pos":[2170.000170745638,-1350.0000465455873],"size":[270,100],"flags":{},"order":23,"mode":0,"inputs":[{"label":"模型","localized_name":"模型","name":"model","type":"MODEL","link":25},{"label":"条件","localized_name":"条件","name":"conditioning","type":"CONDITIONING","link":104}],"outputs":[{"label":"引导","localized_name":"引导器","name":"GUIDER","type":"GUIDER","slot_index":0,"links":[10]}],"properties":{"cnr_id":"comfy-core","ver":"0.3.41","Node name for S&R":"BasicGuider","ue_properties":{"version":"7.7","widget_ue_connectable":{},"input_ue_unconnectable":{}},"ttNbgOverride":{"color":"#223","bgcolor":"#335","groupcolor":"#88A"}},"widgets_values":[],"color":"#223","bgcolor":"#335"},{"id":5,"type":"ReferenceLatent","pos":[1409.9998994782773,-1220.0000465455873],"size":[310,100],"flags":{},"order":21,"mode":0,"inputs":[{"label":"conditioning","localized_name":"条件","name":"conditioning","type":"CONDITIONING","link":5},{"label":"latent","localized_name":"Latent","name":"latent","shape":7,"type":"LATENT","link":6}],"outputs":[{"label":"CONDITIONING","localized_name":"条件","name":"CONDITIONING","type":"CONDITIONING","links":[104]}],"properties":{"cnr_id":"comfy-core","ver":"0.3.41","Node name for S&R":"ReferenceLatent","ue_properties":{"version":"7.7","widget_ue_connectable":{},"input_ue_unconnectable":{}},"ttNbgOverride":{"color":"#223","bgcolor":"#335","groupcolor":"#88A"}},"widgets_values":[],"color":"#223","bgcolor":"#335"},{"id":19,"type":"FluxGuidance","pos":[1420.0001707456386,-1350.0000465455873],"size":[310,110],"flags":{},"order":10,"mode":0,"inputs":[{"label":"条件","localized_name":"条件","name":"conditioning","type":"CONDITIONING","link":24},{"localized_name":"引导","name":"guidance","type":"FLOAT","widget":{"name":"guidance"},"link":null}],"outputs":[{"label":"条件","localized_name":"条件","name":"CONDITIONING","type":"CONDITIONING","slot_index":0,"links":[5]}],"properties":{"cnr_id":"comfy-core","ver":"0.3.41","Node name for S&R":"FluxGuidance","ue_properties":{"version":"7.7","widget_ue_connectable":{},"input_ue_unconnectable":{}}},"widgets_values":[1],"color":"#233","bgcolor":"#355"},{"id":2,"type":"ModelSamplingFlux","pos":[2130.000170745638,-1020.0000465455873],"size":[320,200],"flags":{},"order":19,"mode":0,"inputs":[{"label":"模型","localized_name":"模型","name":"model","type":"MODEL","link":2},{"localized_name":"最大移位","name":"max_shift","type":"FLOAT","widget":{"name":"max_shift"},"link":null},{"localized_name":"基础移位","name":"base_shift","type":"FLOAT","widget":{"name":"base_shift"},"link":null},{"label":"width","localized_name":"宽度","name":"width","type":"INT","widget":{"name":"width"},"link":3},{"label":"height","localized_name":"高度","name":"height","type":"INT","widget":{"name":"height"},"link":4}],"outputs":[{"label":"模型","localized_name":"模型","name":"MODEL","type":"MODEL","slot_index":0,"links":[1,25]}],"properties":{"cnr_id":"comfy-core","ver":"0.3.41","Node name for S&R":"ModelSamplingFlux","ue_properties":{"version":"7.7","widget_ue_connectable":{"width":true,"height":true},"input_ue_unconnectable":{}},"ttNbgOverride":{"color":"#223","bgcolor":"#335","groupcolor":"#88A"}},"widgets_values":[1.15,0.5,1024,1024],"color":"#223","bgcolor":"#335"},{"id":16,"type":"UNETLoader","pos":[1010.0000351119579,-1390.0000465455873],"size":[310,150],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"UNet名称","name":"unet_name","type":"COMBO","widget":{"name":"unet_name"},"link":null},{"localized_name":"数据类型","name":"weight_dtype","type":"COMBO","widget":{"name":"weight_dtype"},"link":null}],"outputs":[{"label":"模型","localized_name":"模型","name":"MODEL","type":"MODEL","slot_index":0,"links":[2]}],"properties":{"cnr_id":"comfy-core","ver":"0.3.41","Node name for S&R":"UNETLoader","ue_properties":{"version":"7.7","widget_ue_connectable":{},"input_ue_unconnectable":{}}},"widgets_values":["flux2.0/flux-2-klein-9b-fp8.safetensors","fp8_e4m3fn"],"color":"#223","bgcolor":"#335"},{"id":3,"type":"VAELoader","pos":[1010.0000351119579,-1250.0000465455873],"size":[310,110],"flags":{},"order":3,"mode":0,"inputs":[{"localized_name":"vae名称","name":"vae_name","type":"COMBO","widget":{"name":"vae_name"},"link":null}],"outputs":[{"label":"VAE","localized_name":"VAE","name":"VAE","type":"VAE","slot_index":0,"links":[15,18]}],"properties":{"cnr_id":"comfy-core","ver":"0.3.41","Node name for S&R":"VAELoader","ue_properties":{"version":"7.7","widget_ue_connectable":{},"input_ue_unconnectable":{}},"ttNbgOverride":{"color":"#223","bgcolor":"#335","groupcolor":"#88A"}},"widgets_values":["flux2-vae.safetensors"],"color":"#223","bgcolor":"#335"},{"id":61,"type":"CLIPLoader","pos":[1000.0000351119579,-1110.0000465455873],"size":[350,180],"flags":{},"order":4,"mode":0,"inputs":[{"label":"clip_name","localized_name":"CLIP名称","name":"clip_name","type":"COMBO","widget":{"name":"clip_name"},"link":null},{"label":"type","localized_name":"类型","name":"type","type":"COMBO","widget":{"name":"type"},"link":null},{"label":"device","localized_name":"设备","name":"device","shape":7,"type":"COMBO","widget":{"name":"device"},"link":null}],"outputs":[{"label":"CLIP","localized_name":"CLIP","name":"CLIP","type":"CLIP","links":[92]}],"properties":{"cnr_id":"comfy-core","ver":"0.8.2","Node name for S&R":"CLIPLoader","ue_properties":{"version":"7.2.2","widget_ue_connectable":{},"input_ue_unconnectable":{}},"hasSecondTab":false,"models":[{"name":"qwen_3_4b.safetensors","directory":"text_encoders","url":"https://huggingface.co/Comfy-Org/flux2-klein-4B/resolve/main/split_files/text_encoders/qwen_3_4b.safetensors"}],"secondTabText":"Send Back","enableTabs":false,"secondTabOffset":80,"tabWidth":65,"secondTabWidth":65,"tabXOffset":10,"ttNbgOverride":{"color":"#223","bgcolor":"#335","groupcolor":"#88A"},"widget_ue_connectable":{"clip_name":true,"type":true,"device":true}},"widgets_values":["qwen_3_8b_fp8mixed.safetensors","flux2","default"],"color":"#223","bgcolor":"#335"},{"id":45,"type":"PreviewImage","pos":[710.0000351119579,-2120.000046545588],"size":[490,500],"flags":{},"order":16,"mode":0,"inputs":[{"label":"图像","localized_name":"图像","name":"images","type":"IMAGE","link":59}],"outputs":[],"properties":{"cnr_id":"comfy-core","ver":"0.3.43","Node name for S&R":"PreviewImage","ue_properties":{"version":"7.7","widget_ue_connectable":{},"input_ue_unconnectable":{}}},"widgets_values":[],"color":"#233","bgcolor":"#355"},{"id":53,"type":"Image Comparer (rgthree)","pos":[1804.9843155517362,-2156.5215467241615],"size":[650,550],"flags":{},"order":28,"mode":0,"inputs":[{"dir":3,"label":"图像_A","name":"image_a","type":"IMAGE","link":97},{"dir":3,"label":"图像_B","name":"image_b","type":"IMAGE","link":75}],"outputs":[],"properties":{"cnr_id":"rgthree-comfy","ver":"84a146fee39f7b3a8c6631dcac29bc13b077eb49","comparer_mode":"Slide","ue_properties":{"version":"7.7","widget_ue_connectable":{},"input_ue_unconnectable":{}}},"widgets_values":[[{"name":"A","selected":true,"url":"/api/view?filename=rgthree.compare._temp_nqvym_00029_.png&type=temp&subfolder=&rand=0.5791789168392755"},{"name":"B","selected":true,"url":"/api/view?filename=rgthree.compare._temp_nqvym_00030_.png&type=temp&subfolder=&rand=0.21946935876437912"}]],"color":"#2a363b","bgcolor":"#3f5159"},{"id":17,"type":"SaveImage","pos":[2480.000170745638,-2170.000046545588],"size":[570,580],"flags":{},"order":27,"mode":0,"inputs":[{"label":"图像","localized_name":"图片","name":"images","type":"IMAGE","link":96},{"localized_name":"文件名前缀","name":"filename_prefix","type":"STRING","widget":{"name":"filename_prefix"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfy-core","ver":"0.3.41","Node name for S&R":"SaveImage","ue_properties":{"version":"7.7","widget_ue_connectable":{},"input_ue_unconnectable":{}}},"widgets_values":["ComfyUI"],"color":"#2a363b","bgcolor":"#3f5159"},{"id":60,"type":"JjkText","pos":[1290.0001707456386,-2100.000046545588],"size":[410,320],"flags":{},"order":5,"mode":0,"inputs":[{"label":"text","localized_name":"text","name":"text","type":"STRING","widget":{"name":"text"},"link":null}],"outputs":[{"label":"text","localized_name":"text","name":"text","type":"STRING","links":[98]}],"properties":{"Node name for S&R":"JjkText","cnr_id":"ComfyUI-Jjk-Nodes","ver":"b3c99bb78a99551776b5eab1a820e1cd58f84f31","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.7"},"ttNbgOverride":{"color":"#223","bgcolor":"#335","groupcolor":"#88A"}},"widgets_values":["填充遮罩区域的头发,皮肤加一点磨皮"],"color":"#223","bgcolor":"#335"},{"id":26,"type":"LoadImage","pos":[20.000035111957914,-2120.000046545588],"size":[590,530],"flags":{},"order":6,"mode":0,"inputs":[{"localized_name":"图像","name":"image","type":"COMBO","widget":{"name":"image"},"link":null},{"localized_name":"选择文件上传","name":"upload","type":"IMAGEUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"label":"图像","localized_name":"图像","name":"IMAGE","type":"IMAGE","links":[75,100,101,102]},{"label":"遮罩","localized_name":"遮罩","name":"MASK","type":"MASK","links":[38]}],"properties":{"cnr_id":"comfy-core","ver":"0.3.43","Node name for S&R":"LoadImage","ue_properties":{"version":"7.7","widget_ue_connectable":{},"input_ue_unconnectable":{}},"#sdppp_variant":"default","#sdppp_simple_content":"canvas","#sdppp_simple_mask":"canvas","#sdppp_simple_boundary":"canvas","#sdppp_label":"","image":"clipspace/clipspace-painted-masked-1784202952306.png [input]"},"widgets_values":["clipspace/clipspace-painted-masked-1784202952306.png [input]","image"],"color":"#2a363b","bgcolor":"#3f5159"}],"links":[[1,2,0,1,0,"MODEL"],[2,16,0,2,0,"MODEL"],[3,14,0,2,3,"INT"],[4,14,1,2,4,"INT"],[5,19,0,5,0,"CONDITIONING"],[6,13,0,5,1,"LATENT"],[7,14,0,7,0,"INT"],[8,14,1,7,1,"INT"],[9,6,0,9,0,"NOISE"],[10,20,0,9,1,"GUIDER"],[11,8,0,9,2,"SAMPLER"],[12,1,0,9,3,"SIGMAS"],[13,7,0,9,4,"LATENT"],[14,9,0,10,0,"LATENT"],[15,3,0,10,1,"VAE"],[18,3,0,13,1,"VAE"],[24,22,0,19,0,"CONDITIONING"],[25,2,0,20,0,"MODEL"],[38,26,1,33,0,"MASK"],[41,33,0,36,0,"MASK"],[44,36,0,39,0,"IMAGE"],[45,31,0,39,2,"INT"],[46,31,1,39,3,"INT"],[47,39,0,37,0,"IMAGE"],[55,31,0,32,3,"INT"],[56,31,1,32,4,"INT"],[57,32,0,44,0,"IMAGE"],[58,32,1,44,1,"MASK"],[59,44,0,45,0,"IMAGE"],[62,44,0,14,0,"IMAGE"],[63,44,0,13,0,"IMAGE"],[75,26,0,53,1,"IMAGE"],[79,37,0,32,1,"MASK"],[92,61,0,22,0,"CLIP"],[95,10,0,62,1,"IMAGE"],[96,62,0,17,0,"IMAGE"],[97,62,0,53,0,"IMAGE"],[98,60,0,22,1,"STRING"],[100,26,0,31,0,"IMAGE"],[101,26,0,32,0,"IMAGE"],[102,26,0,62,0,"IMAGE"],[104,5,0,20,1,"CONDITIONING"]],"groups":[{"id":2,"title":"图片上传(图片上右键open in maskeditor画遮罩范围,不用涂)","bounding":[-10,-2270,1249.2113037109375,726.7728881835938],"color":"#A88","font_size":45,"flags":{}},{"id":5,"title":"提示词【很重要】","bounding":[1260,-2260,481.13201904296875,714.9009399414062],"color":"#8A8","font_size":24,"flags":{}},{"id":6,"title":"最终效果区","bounding":[1760,-2260,1330.0355224609375,711.3678588867188],"color":"#3f789e","font_size":24,"flags":{}},{"id":7,"title":"核心工作区","bounding":[990,-1480,379.9303894042969,546.5068969726562],"color":"#3f789e","font_size":24,"flags":{}},{"id":9,"title":"Group","bounding":[310,-1470,635.491455078125,641.5743408203125],"color":"#3f789e","font_size":24,"flags":{}},{"id":10,"title":"Group","bounding":[-50,-1460,321.03936767578125,291.3662414550781],"color":"#3f789e","font_size":24,"flags":{}},{"id":11,"title":"Group","bounding":[1390,-1470,1745.5640869140625,621.2001342773438],"color":"#3f789e","font_size":24,"flags":{}}],"config":{},"extra":{"links_added_by_ue":[],"VHS_KeepIntermediate":true,"ue_links":[],"VHS_MetadataImage":true,"0246.VERSION":[0,0,4],"VHS_latentpreviewrate":0,"frontendVersion":"1.41.21","VHS_latentpreview":false,"ds":{"scale":0.45,"offset":[1726.6666011315049,2886.944475612862]},"workflowRendererVersion":"LG","favoritedWidgets":{"favorites":[{"nodeLocatorId":"26","widgetName":"$$canvas-image-preview"}]}},"version":0.4} \ No newline at end of file +{"id": "431f1189-2c6a-448d-925e-5f6c85416393", "revision": 0, "last_node_id": 63, "last_link_id": 104, "nodes": [{"id": 37, "type": "Image To Mask", "pos": [330.00017074563834, -900.0000465455873], "size": [280, 110], "flags": {}, "order": 13, "mode": 0, "inputs": [{"label": "图像", "localized_name": "图像", "name": "image", "type": "IMAGE", "link": 47}, {"label": "方法", "localized_name": "方法", "name": "method", "type": "COMBO", "widget": {"name": "method"}, "link": null}], "outputs": [{"label": "遮罩", "localized_name": "遮罩", "name": "MASK", "type": "MASK", "links": [79]}], "properties": {"Node name for S&R": "Image To Mask", "cnr_id": "masquerade-nodes-comfyui", "ver": "432cb4d146a391b387a0cd25ace824328b5b61cf", "ue_properties": {"version": "7.7", "widget_ue_connectable": {}, "input_ue_unconnectable": {}}, "ttNbgOverride": {"color": "#223", "bgcolor": "#335", "groupcolor": "#88A"}}, "widgets_values": ["intensity"], "color": "#223", "bgcolor": "#335"}, {"id": 33, "type": "Mask Fill Holes", "pos": [320.0000351119579, -1320.0000465455873], "size": [290, 80], "flags": {}, "order": 9, "mode": 0, "inputs": [{"label": "遮罩", "localized_name": "遮罩", "name": "masks", "type": "MASK", "link": 38}], "outputs": [{"label": "遮罩", "localized_name": "遮罩", "name": "MASKS", "type": "MASK", "links": [41]}], "properties": {"cnr_id": "was-node-suite-comfyui", "ver": "1cd8d304eda256c412b8589ce1f00be3c61cf9ec", "Node name for S&R": "Mask Fill Holes", "ue_properties": {"version": "7.7", "widget_ue_connectable": {}, "input_ue_unconnectable": {}}, "ttNbgOverride": {"color": "#223", "bgcolor": "#335", "groupcolor": "#88A"}}, "widgets_values": [], "color": "#223", "bgcolor": "#335"}, {"id": 32, "type": "LayerUtility: ImageScaleByAspectRatio V2", "pos": [650.0000351119579, -1399.9999109119071], "size": [320, 480], "flags": {}, "order": 14, "mode": 0, "inputs": [{"label": "图像", "localized_name": "image", "name": "image", "shape": 7, "type": "IMAGE", "link": 101}, {"label": "遮罩", "localized_name": "mask", "name": "mask", "shape": 7, "type": "MASK", "link": 79}, {"localized_name": "aspect_ratio", "name": "aspect_ratio", "type": "COMBO", "widget": {"name": "aspect_ratio"}, "link": null}, {"label": "proportional_width", "localized_name": "proportional_width", "name": "proportional_width", "type": "INT", "widget": {"name": "proportional_width"}, "link": 55}, {"label": "proportional_height", "localized_name": "proportional_height", "name": "proportional_height", "type": "INT", "widget": {"name": "proportional_height"}, "link": 56}, {"localized_name": "fit", "name": "fit", "type": "COMBO", "widget": {"name": "fit"}, "link": null}, {"localized_name": "method", "name": "method", "type": "COMBO", "widget": {"name": "method"}, "link": null}, {"localized_name": "round_to_multiple", "name": "round_to_multiple", "type": "COMBO", "widget": {"name": "round_to_multiple"}, "link": null}, {"localized_name": "scale_to_side", "name": "scale_to_side", "type": "COMBO", "widget": {"name": "scale_to_side"}, "link": null}, {"localized_name": "scale_to_length", "name": "scale_to_length", "type": "INT", "widget": {"name": "scale_to_length"}, "link": null}, {"localized_name": "background_color", "name": "background_color", "type": "STRING", "widget": {"name": "background_color"}, "link": null}], "outputs": [{"label": "图像", "localized_name": "image", "name": "image", "type": "IMAGE", "links": [57]}, {"label": "遮罩", "localized_name": "mask", "name": "mask", "type": "MASK", "links": [58]}, {"label": "原始大小", "localized_name": "original_size", "name": "original_size", "type": "BOX"}, {"label": "width", "localized_name": "width", "name": "width", "type": "INT"}, {"label": "height", "localized_name": "height", "name": "height", "type": "INT"}], "properties": {"cnr_id": "comfyui_layerstyle", "ver": "a46b1e6d26d45be9784c49f7065ba44700ef2b63", "Node name for S&R": "LayerUtility: ImageScaleByAspectRatio V2", "ue_properties": {"version": "7.7", "widget_ue_connectable": {}, "input_ue_unconnectable": {}}}, "widgets_values": ["custom", 1, 1, "letterbox", "lanczos", "8", "None", 1024, "#000000"], "color": "rgba(38, 73, 116, 0.7)"}, {"id": 14, "type": "GetImageSize+", "pos": [1400.0001707456386, -930.0000465455873], "size": [320, 120.15625], "flags": {}, "order": 17, "mode": 0, "inputs": [{"label": "图像", "localized_name": "图像", "name": "image", "type": "IMAGE", "link": 62}], "outputs": [{"label": "宽度", "localized_name": "宽度", "name": "width", "type": "INT", "links": [3, 7]}, {"label": "高度", "localized_name": "高度", "name": "height", "type": "INT", "links": [4, 8]}, {"label": "count", "localized_name": "count", "name": "count", "type": "INT"}], "properties": {"cnr_id": "comfyui_essentials", "ver": "9d9f4bedfc9f0321c19faf71855e228c93bd0dc9", "Node name for S&R": "GetImageSize+", "ue_properties": {"version": "7.7", "widget_ue_connectable": {}, "input_ue_unconnectable": {}}, "ttNbgOverride": {"color": "#223", "bgcolor": "#335", "groupcolor": "#88A"}}, "widgets_values": [], "color": "#223", "bgcolor": "#335"}, {"id": 13, "type": "VAEEncode", "pos": [1409.9998994782773, -1100.0000465455873], "size": [320, 100], "flags": {}, "order": 18, "mode": 0, "inputs": [{"label": "图像", "localized_name": "像素", "name": "pixels", "type": "IMAGE", "link": 63}, {"label": "VAE", "localized_name": "vae", "name": "vae", "type": "VAE", "link": 18}], "outputs": [{"label": "Latent", "localized_name": "Latent", "name": "LATENT", "type": "LATENT", "links": [6]}], "properties": {"cnr_id": "comfy-core", "ver": "0.3.41", "Node name for S&R": "VAEEncode", "ue_properties": {"version": "7.7", "widget_ue_connectable": {}, "input_ue_unconnectable": {}}, "ttNbgOverride": {"color": "#223", "bgcolor": "#335", "groupcolor": "#88A"}}, "widgets_values": [], "color": "#223", "bgcolor": "#335"}, {"id": 9, "type": "SamplerCustomAdvanced", "pos": [2500.000170745638, -1220.0000465455873], "size": [270, 330], "flags": {}, "order": 24, "mode": 0, "inputs": [{"label": "噪波生成", "localized_name": "噪波", "name": "noise", "type": "NOISE", "link": 9}, {"label": "引导", "localized_name": "引导器", "name": "guider", "type": "GUIDER", "link": 10}, {"label": "采样器", "localized_name": "采样器", "name": "sampler", "type": "SAMPLER", "link": 11}, {"label": "Sigmas", "localized_name": "西格玛", "name": "sigmas", "type": "SIGMAS", "link": 12}, {"label": "Latent", "localized_name": "Latent图像", "name": "latent_image", "type": "LATENT", "link": 13}], "outputs": [{"label": "输出", "localized_name": "Latent", "name": "output", "type": "LATENT", "slot_index": 0, "links": [14]}, {"label": "降噪输出", "localized_name": "降噪Latent", "name": "denoised_output", "type": "LATENT"}], "properties": {"cnr_id": "comfy-core", "ver": "0.3.41", "Node name for S&R": "SamplerCustomAdvanced", "ue_properties": {"version": "7.7", "widget_ue_connectable": {}, "input_ue_unconnectable": {}}, "ttNbgOverride": {"color": "#223", "bgcolor": "#335", "groupcolor": "#88A"}}, "widgets_values": [], "color": "#223", "bgcolor": "#335"}, {"id": 22, "type": "CLIPTextEncode", "pos": [1300.0001707456386, -1709.999910911907], "size": [390, 140], "flags": {}, "order": 7, "mode": 0, "inputs": [{"label": "CLIP", "localized_name": "clip", "name": "clip", "type": "CLIP", "link": 92}, {"label": "text", "localized_name": "文本", "name": "text", "type": "STRING", "widget": {"name": "text"}, "link": 98}], "outputs": [{"label": "条件", "localized_name": "条件", "name": "CONDITIONING", "type": "CONDITIONING", "slot_index": 0, "links": [24]}], "properties": {"cnr_id": "comfy-core", "ver": "0.3.41", "Node name for S&R": "CLIPTextEncode", "ue_properties": {"version": "7.7", "widget_ue_connectable": {}, "input_ue_unconnectable": {}}}, "widgets_values": ["The yellow part of the picture is replaced by a black bowl."], "color": "#232", "bgcolor": "#353"}, {"id": 10, "type": "VAEDecode", "pos": [2759.9998994782773, -1370.0000465455873], "size": [320, 100], "flags": {}, "order": 25, "mode": 0, "inputs": [{"label": "Latent", "localized_name": "Latent", "name": "samples", "type": "LATENT", "link": 14}, {"label": "VAE", "localized_name": "vae", "name": "vae", "type": "VAE", "link": 15}], "outputs": [{"label": "图像", "localized_name": "图像", "name": "IMAGE", "type": "IMAGE", "slot_index": 0, "links": [95]}], "properties": {"cnr_id": "comfy-core", "ver": "0.3.41", "Node name for S&R": "VAEDecode", "ue_properties": {"version": "7.7", "widget_ue_connectable": {}, "input_ue_unconnectable": {}}, "ttNbgOverride": {"color": "#223", "bgcolor": "#335", "groupcolor": "#88A"}}, "widgets_values": [], "color": "#223", "bgcolor": "#335"}, {"id": 39, "type": "ImageScale", "pos": [320.0000351119579, -1150.0000465455873], "size": [290, 210], "flags": {}, "order": 12, "mode": 0, "inputs": [{"label": "图像", "localized_name": "图像", "name": "image", "type": "IMAGE", "link": 44}, {"localized_name": "缩放算法", "name": "upscale_method", "type": "COMBO", "widget": {"name": "upscale_method"}, "link": null}, {"label": "width", "localized_name": "宽度", "name": "width", "type": "INT", "widget": {"name": "width"}, "link": 45}, {"label": "height", "localized_name": "高度", "name": "height", "type": "INT", "widget": {"name": "height"}, "link": 46}, {"localized_name": "裁剪", "name": "crop", "type": "COMBO", "widget": {"name": "crop"}, "link": null}], "outputs": [{"label": "图像", "localized_name": "图像", "name": "IMAGE", "type": "IMAGE", "links": [47]}], "properties": {"cnr_id": "comfy-core", "ver": "0.3.43", "Node name for S&R": "ImageScale", "ue_properties": {"version": "7.7", "widget_ue_connectable": {}, "input_ue_unconnectable": {}}, "ttNbgOverride": {"color": "#223", "bgcolor": "#335", "groupcolor": "#88A"}}, "widgets_values": ["nearest-exact", 512, 512, "disabled"], "color": "#223", "bgcolor": "#335"}, {"id": 36, "type": "Convert Masks to Images", "pos": [320.0000351119579, -1250.0000465455873], "size": [290, 80], "flags": {}, "order": 11, "mode": 0, "inputs": [{"label": "遮罩", "localized_name": "遮罩", "name": "masks", "type": "MASK", "link": 41}], "outputs": [{"label": "图像", "localized_name": "图像", "name": "IMAGES", "type": "IMAGE", "links": [44]}], "properties": {"cnr_id": "was-node-suite-comfyui", "ver": "1cd8d304eda256c412b8589ce1f00be3c61cf9ec", "Node name for S&R": "Convert Masks to Images", "ue_properties": {"version": "7.7", "widget_ue_connectable": {}, "input_ue_unconnectable": {}}, "ttNbgOverride": {"color": "#223", "bgcolor": "#335", "groupcolor": "#88A"}}, "widgets_values": [], "color": "#223", "bgcolor": "#335"}, {"id": 44, "type": "ImageAndMaskPreview", "pos": [639.9998994782773, -990.0000465455873], "size": [290, 200], "flags": {}, "order": 15, "mode": 0, "inputs": [{"label": "图像", "localized_name": "图像", "name": "image", "shape": 7, "type": "IMAGE", "link": 57}, {"label": "遮罩", "localized_name": "遮罩", "name": "mask", "shape": 7, "type": "MASK", "link": 58}, {"label": "遮罩不透明", "localized_name": "遮罩不透明", "name": "mask_opacity", "type": "FLOAT", "widget": {"name": "mask_opacity"}, "link": null}, {"label": "遮罩颜色", "localized_name": "遮罩颜色", "name": "mask_color", "type": "STRING", "widget": {"name": "mask_color"}, "link": null}, {"label": "穿透", "localized_name": "穿透", "name": "pass_through", "type": "BOOLEAN", "widget": {"name": "pass_through"}, "link": null}], "outputs": [{"label": "图像", "localized_name": "图像", "name": "composite", "type": "IMAGE", "links": [59, 62, 63]}], "properties": {"cnr_id": "comfyui-kjnodes", "ver": "aeab1a7de581dc7253f48257392e8287bbeee4a9", "Node name for S&R": "ImageAndMaskPreview", "ue_properties": {"version": "7.7", "widget_ue_connectable": {}, "input_ue_unconnectable": {}}, "ttNbgOverride": {"color": "#223", "bgcolor": "#335", "groupcolor": "#88A"}}, "widgets_values": [1, "FFFF00", true], "color": "#223", "bgcolor": "#335"}, {"id": 7, "type": "EmptySD3LatentImage", "pos": [1789.9998994782773, -990.0000465455873], "size": [310, 170], "flags": {}, "order": 20, "mode": 0, "inputs": [{"label": "width", "localized_name": "宽度", "name": "width", "type": "INT", "widget": {"name": "width"}, "link": 7}, {"label": "height", "localized_name": "高度", "name": "height", "type": "INT", "widget": {"name": "height"}, "link": 8}, {"localized_name": "批量大小", "name": "batch_size", "type": "INT", "widget": {"name": "batch_size"}, "link": null}], "outputs": [{"label": "Latent", "localized_name": "Latent", "name": "LATENT", "type": "LATENT", "slot_index": 0, "links": [13]}], "properties": {"cnr_id": "comfy-core", "ver": "0.3.41", "Node name for S&R": "EmptySD3LatentImage", "ue_properties": {"version": "7.7", "widget_ue_connectable": {"width": true, "height": true}, "input_ue_unconnectable": {}}, "ttNbgOverride": {"color": "#223", "bgcolor": "#335", "groupcolor": "#88A"}}, "widgets_values": [1024, 1024, 1], "color": "#223", "bgcolor": "#335"}, {"id": 62, "type": "ColorMatch", "pos": [2790.000170745638, -1210.0000465455873], "size": [270, 200], "flags": {}, "order": 26, "mode": 0, "inputs": [{"label": "参考图像", "localized_name": "参考图像", "name": "image_ref", "type": "IMAGE", "link": 102}, {"label": "目标图像", "localized_name": "目标图像", "name": "image_target", "type": "IMAGE", "link": 95}, {"label": "方法", "localized_name": "方法", "name": "method", "type": "COMBO", "widget": {"name": "method"}, "link": null}, {"label": "强度", "localized_name": "强度", "name": "strength", "shape": 7, "type": "FLOAT", "widget": {"name": "strength"}, "link": null}, {"label": "多线程", "localized_name": "多线程", "name": "multithread", "shape": 7, "type": "BOOLEAN", "widget": {"name": "multithread"}, "link": null}], "outputs": [{"label": "图像", "localized_name": "图像", "name": "image", "type": "IMAGE", "links": [96, 97]}], "properties": {"cnr_id": "comfyui-kjnodes", "ver": "5b38397a6430fdb16c7bd14a6bd64c2b0e69a5f0", "Node name for S&R": "ColorMatch", "ue_properties": {"widget_ue_connectable": {}, "input_ue_unconnectable": {}, "version": "7.6.2"}, "ttNbgOverride": {"color": "#223", "bgcolor": "#335", "groupcolor": "#88A"}}, "widgets_values": ["mkl", 1, true], "color": "#223", "bgcolor": "#335"}, {"id": 1, "type": "BasicScheduler", "pos": [2150.000170745638, -1220.0000465455873], "size": [320, 180], "flags": {}, "order": 22, "mode": 0, "inputs": [{"label": "模型", "localized_name": "模型", "name": "model", "type": "MODEL", "link": 1}, {"localized_name": "调度器", "name": "scheduler", "type": "COMBO", "widget": {"name": "scheduler"}, "link": null}, {"localized_name": "步数", "name": "steps", "type": "INT", "widget": {"name": "steps"}, "link": null}, {"localized_name": "降噪", "name": "denoise", "type": "FLOAT", "widget": {"name": "denoise"}, "link": null}], "outputs": [{"label": "Sigmas", "localized_name": "Sigmas", "name": "SIGMAS", "type": "SIGMAS", "links": [12]}], "properties": {"cnr_id": "comfy-core", "ver": "0.3.41", "Node name for S&R": "BasicScheduler", "ue_properties": {"version": "7.7", "widget_ue_connectable": {}, "input_ue_unconnectable": {}}, "ttNbgOverride": {"color": "#223", "bgcolor": "#335", "groupcolor": "#88A"}}, "widgets_values": ["simple", 6, 1], "color": "#223", "bgcolor": "#335"}, {"id": 6, "type": "RandomNoise", "pos": [1770.0001707456386, -1170.0000465455873], "size": [310, 110], "flags": {}, "order": 0, "mode": 0, "inputs": [{"localized_name": "噪波随机种", "name": "noise_seed", "type": "INT", "widget": {"name": "noise_seed"}, "link": null}], "outputs": [{"label": "噪波生成", "localized_name": "噪波", "name": "NOISE", "type": "NOISE", "links": [9]}], "properties": {"cnr_id": "comfy-core", "ver": "0.3.41", "Node name for S&R": "RandomNoise", "ue_properties": {"version": "7.7", "widget_ue_connectable": {}, "input_ue_unconnectable": {}}}, "widgets_values": [1109407891267066, "randomize"], "color": "#2a363b", "bgcolor": "#3f5159"}, {"id": 8, "type": "KSamplerSelect", "pos": [1750.0001707456386, -1290.0000465455873], "size": [320, 110], "flags": {}, "order": 1, "mode": 0, "inputs": [{"localized_name": "采样器名称", "name": "sampler_name", "type": "COMBO", "widget": {"name": "sampler_name"}, "link": null}], "outputs": [{"label": "采样器", "localized_name": "采样器", "name": "SAMPLER", "type": "SAMPLER", "links": [11]}], "properties": {"cnr_id": "comfy-core", "ver": "0.3.41", "Node name for S&R": "KSamplerSelect", "ue_properties": {"version": "7.7", "widget_ue_connectable": {}, "input_ue_unconnectable": {}}, "ttNbgOverride": {"color": "#223", "bgcolor": "#335", "groupcolor": "#88A"}}, "widgets_values": ["euler"], "color": "#223", "bgcolor": "#335"}, {"id": 31, "type": "easy imageSize", "pos": [-29.999964888042086, -1380.0000465455873], "size": [290, 170], "flags": {}, "order": 8, "mode": 0, "inputs": [{"label": "图像", "localized_name": "图像", "name": "image", "type": "IMAGE", "link": 100}], "outputs": [{"label": "宽度", "localized_name": "宽度", "name": "width_int", "type": "INT", "links": [45, 55]}, {"label": "高度", "localized_name": "高度", "name": "height_int", "type": "INT", "links": [46, 56]}], "properties": {"cnr_id": "comfyui-easy-use", "ver": "501d97bb5ca8d61b486d47379fb1f21a41b772d7", "Node name for S&R": "easy imageSize", "ue_properties": {"version": "7.7", "widget_ue_connectable": {}, "input_ue_unconnectable": {}}, "ttNbgOverride": {"color": "#223", "bgcolor": "#335", "groupcolor": "#88A"}}, "widgets_values": ["Width: 767 , Height: 747"], "color": "#223", "bgcolor": "#335"}, {"id": 20, "type": "BasicGuider", "pos": [2170.000170745638, -1350.0000465455873], "size": [270, 100], "flags": {}, "order": 23, "mode": 0, "inputs": [{"label": "模型", "localized_name": "模型", "name": "model", "type": "MODEL", "link": 25}, {"label": "条件", "localized_name": "条件", "name": "conditioning", "type": "CONDITIONING", "link": 104}], "outputs": [{"label": "引导", "localized_name": "引导器", "name": "GUIDER", "type": "GUIDER", "slot_index": 0, "links": [10]}], "properties": {"cnr_id": "comfy-core", "ver": "0.3.41", "Node name for S&R": "BasicGuider", "ue_properties": {"version": "7.7", "widget_ue_connectable": {}, "input_ue_unconnectable": {}}, "ttNbgOverride": {"color": "#223", "bgcolor": "#335", "groupcolor": "#88A"}}, "widgets_values": [], "color": "#223", "bgcolor": "#335"}, {"id": 5, "type": "ReferenceLatent", "pos": [1409.9998994782773, -1220.0000465455873], "size": [310, 100], "flags": {}, "order": 21, "mode": 0, "inputs": [{"label": "conditioning", "localized_name": "条件", "name": "conditioning", "type": "CONDITIONING", "link": 5}, {"label": "latent", "localized_name": "Latent", "name": "latent", "shape": 7, "type": "LATENT", "link": 6}], "outputs": [{"label": "CONDITIONING", "localized_name": "条件", "name": "CONDITIONING", "type": "CONDITIONING", "links": [104]}], "properties": {"cnr_id": "comfy-core", "ver": "0.3.41", "Node name for S&R": "ReferenceLatent", "ue_properties": {"version": "7.7", "widget_ue_connectable": {}, "input_ue_unconnectable": {}}, "ttNbgOverride": {"color": "#223", "bgcolor": "#335", "groupcolor": "#88A"}}, "widgets_values": [], "color": "#223", "bgcolor": "#335"}, {"id": 19, "type": "FluxGuidance", "pos": [1420.0001707456386, -1350.0000465455873], "size": [310, 110], "flags": {}, "order": 10, "mode": 0, "inputs": [{"label": "条件", "localized_name": "条件", "name": "conditioning", "type": "CONDITIONING", "link": 24}, {"localized_name": "引导", "name": "guidance", "type": "FLOAT", "widget": {"name": "guidance"}, "link": null}], "outputs": [{"label": "条件", "localized_name": "条件", "name": "CONDITIONING", "type": "CONDITIONING", "slot_index": 0, "links": [5]}], "properties": {"cnr_id": "comfy-core", "ver": "0.3.41", "Node name for S&R": "FluxGuidance", "ue_properties": {"version": "7.7", "widget_ue_connectable": {}, "input_ue_unconnectable": {}}}, "widgets_values": [1], "color": "#233", "bgcolor": "#355"}, {"id": 2, "type": "ModelSamplingFlux", "pos": [2130.000170745638, -1020.0000465455873], "size": [320, 200], "flags": {}, "order": 19, "mode": 0, "inputs": [{"label": "模型", "localized_name": "模型", "name": "model", "type": "MODEL", "link": 2}, {"localized_name": "最大移位", "name": "max_shift", "type": "FLOAT", "widget": {"name": "max_shift"}, "link": null}, {"localized_name": "基础移位", "name": "base_shift", "type": "FLOAT", "widget": {"name": "base_shift"}, "link": null}, {"label": "width", "localized_name": "宽度", "name": "width", "type": "INT", "widget": {"name": "width"}, "link": 3}, {"label": "height", "localized_name": "高度", "name": "height", "type": "INT", "widget": {"name": "height"}, "link": 4}], "outputs": [{"label": "模型", "localized_name": "模型", "name": "MODEL", "type": "MODEL", "slot_index": 0, "links": [1, 25]}], "properties": {"cnr_id": "comfy-core", "ver": "0.3.41", "Node name for S&R": "ModelSamplingFlux", "ue_properties": {"version": "7.7", "widget_ue_connectable": {"width": true, "height": true}, "input_ue_unconnectable": {}}, "ttNbgOverride": {"color": "#223", "bgcolor": "#335", "groupcolor": "#88A"}}, "widgets_values": [1.15, 0.5, 1024, 1024], "color": "#223", "bgcolor": "#335"}, {"id": 16, "type": "UNETLoader", "pos": [1010.0000351119579, -1390.0000465455873], "size": [310, 150], "flags": {}, "order": 2, "mode": 0, "inputs": [{"localized_name": "UNet名称", "name": "unet_name", "type": "COMBO", "widget": {"name": "unet_name"}, "link": null}, {"localized_name": "数据类型", "name": "weight_dtype", "type": "COMBO", "widget": {"name": "weight_dtype"}, "link": null}], "outputs": [{"label": "模型", "localized_name": "模型", "name": "MODEL", "type": "MODEL", "slot_index": 0, "links": [2]}], "properties": {"cnr_id": "comfy-core", "ver": "0.3.41", "Node name for S&R": "UNETLoader", "ue_properties": {"version": "7.7", "widget_ue_connectable": {}, "input_ue_unconnectable": {}}}, "widgets_values": ["flux2.0/flux-2-klein-9b-fp8.safetensors", "fp8_e4m3fn"], "color": "#223", "bgcolor": "#335"}, {"id": 3, "type": "VAELoader", "pos": [1010.0000351119579, -1250.0000465455873], "size": [310, 110], "flags": {}, "order": 3, "mode": 0, "inputs": [{"localized_name": "vae名称", "name": "vae_name", "type": "COMBO", "widget": {"name": "vae_name"}, "link": null}], "outputs": [{"label": "VAE", "localized_name": "VAE", "name": "VAE", "type": "VAE", "slot_index": 0, "links": [15, 18]}], "properties": {"cnr_id": "comfy-core", "ver": "0.3.41", "Node name for S&R": "VAELoader", "ue_properties": {"version": "7.7", "widget_ue_connectable": {}, "input_ue_unconnectable": {}}, "ttNbgOverride": {"color": "#223", "bgcolor": "#335", "groupcolor": "#88A"}}, "widgets_values": ["flux2-vae.safetensors"], "color": "#223", "bgcolor": "#335"}, {"id": 61, "type": "CLIPLoader", "pos": [1000.0000351119579, -1110.0000465455873], "size": [350, 180], "flags": {}, "order": 4, "mode": 0, "inputs": [{"label": "clip_name", "localized_name": "CLIP名称", "name": "clip_name", "type": "COMBO", "widget": {"name": "clip_name"}, "link": null}, {"label": "type", "localized_name": "类型", "name": "type", "type": "COMBO", "widget": {"name": "type"}, "link": null}, {"label": "device", "localized_name": "设备", "name": "device", "shape": 7, "type": "COMBO", "widget": {"name": "device"}, "link": null}], "outputs": [{"label": "CLIP", "localized_name": "CLIP", "name": "CLIP", "type": "CLIP", "links": [92]}], "properties": {"cnr_id": "comfy-core", "ver": "0.8.2", "Node name for S&R": "CLIPLoader", "ue_properties": {"version": "7.2.2", "widget_ue_connectable": {}, "input_ue_unconnectable": {}}, "hasSecondTab": false, "models": [{"name": "qwen_3_4b.safetensors", "directory": "text_encoders", "url": "https://huggingface.co/Comfy-Org/flux2-klein-4B/resolve/main/split_files/text_encoders/qwen_3_4b.safetensors"}], "secondTabText": "Send Back", "enableTabs": false, "secondTabOffset": 80, "tabWidth": 65, "secondTabWidth": 65, "tabXOffset": 10, "ttNbgOverride": {"color": "#223", "bgcolor": "#335", "groupcolor": "#88A"}, "widget_ue_connectable": {"clip_name": true, "type": true, "device": true}}, "widgets_values": ["qwen_3_8b_fp8mixed.safetensors", "flux2", "default"], "color": "#223", "bgcolor": "#335"}, {"id": 45, "type": "PreviewImage", "pos": [710.0000351119579, -2120.000046545588], "size": [490, 500], "flags": {}, "order": 16, "mode": 0, "inputs": [{"label": "图像", "localized_name": "图像", "name": "images", "type": "IMAGE", "link": 59}], "outputs": [], "properties": {"cnr_id": "comfy-core", "ver": "0.3.43", "Node name for S&R": "PreviewImage", "ue_properties": {"version": "7.7", "widget_ue_connectable": {}, "input_ue_unconnectable": {}}}, "widgets_values": [], "color": "#233", "bgcolor": "#355"}, {"id": 53, "type": "Image Comparer (rgthree)", "pos": [1804.9843155517362, -2156.5215467241615], "size": [650, 550], "flags": {}, "order": 28, "mode": 0, "inputs": [{"dir": 3, "label": "图像_A", "name": "image_a", "type": "IMAGE", "link": 97}, {"dir": 3, "label": "图像_B", "name": "image_b", "type": "IMAGE", "link": 75}], "outputs": [], "properties": {"cnr_id": "rgthree-comfy", "ver": "84a146fee39f7b3a8c6631dcac29bc13b077eb49", "comparer_mode": "Slide", "ue_properties": {"version": "7.7", "widget_ue_connectable": {}, "input_ue_unconnectable": {}}}, "widgets_values": [[{"name": "A", "selected": true, "url": "/api/view?filename=rgthree.compare._temp_nqvym_00029_.png&type=temp&subfolder=&rand=0.5791789168392755"}, {"name": "B", "selected": true, "url": "/api/view?filename=rgthree.compare._temp_nqvym_00030_.png&type=temp&subfolder=&rand=0.21946935876437912"}]], "color": "#2a363b", "bgcolor": "#3f5159"}, {"id": 17, "type": "SaveImage", "pos": [2480.000170745638, -2170.000046545588], "size": [570, 580], "flags": {}, "order": 27, "mode": 0, "inputs": [{"label": "图像", "localized_name": "图片", "name": "images", "type": "IMAGE", "link": 96}, {"localized_name": "文件名前缀", "name": "filename_prefix", "type": "STRING", "widget": {"name": "filename_prefix"}, "link": null}], "outputs": [], "properties": {"cnr_id": "comfy-core", "ver": "0.3.41", "Node name for S&R": "SaveImage", "ue_properties": {"version": "7.7", "widget_ue_connectable": {}, "input_ue_unconnectable": {}}}, "widgets_values": ["ComfyUI"], "color": "#2a363b", "bgcolor": "#3f5159"}, {"id": 60, "type": "JjkText", "pos": [1290.0001707456386, -2100.000046545588], "size": [410, 320], "flags": {}, "order": 5, "mode": 0, "inputs": [{"label": "text", "localized_name": "text", "name": "text", "type": "STRING", "widget": {"name": "text"}, "link": null}], "outputs": [{"label": "text", "localized_name": "text", "name": "text", "type": "STRING", "links": [98]}], "properties": {"Node name for S&R": "JjkText", "cnr_id": "ComfyUI-Jjk-Nodes", "ver": "b3c99bb78a99551776b5eab1a820e1cd58f84f31", "ue_properties": {"widget_ue_connectable": {}, "input_ue_unconnectable": {}, "version": "7.7"}, "ttNbgOverride": {"color": "#223", "bgcolor": "#335", "groupcolor": "#88A"}}, "widgets_values": ["填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"], "color": "#223", "bgcolor": "#335"}, {"id": 26, "type": "LoadImage", "pos": [20.000035111957914, -2120.000046545588], "size": [590, 530], "flags": {}, "order": 6, "mode": 0, "inputs": [{"localized_name": "图像", "name": "image", "type": "COMBO", "widget": {"name": "image"}, "link": null}, {"localized_name": "选择文件上传", "name": "upload", "type": "IMAGEUPLOAD", "widget": {"name": "upload"}, "link": null}], "outputs": [{"label": "图像", "localized_name": "图像", "name": "IMAGE", "type": "IMAGE", "links": [75, 100, 101, 102]}, {"label": "遮罩", "localized_name": "遮罩", "name": "MASK", "type": "MASK", "links": [38]}], "properties": {"cnr_id": "comfy-core", "ver": "0.3.43", "Node name for S&R": "LoadImage", "ue_properties": {"version": "7.7", "widget_ue_connectable": {}, "input_ue_unconnectable": {}}, "#sdppp_variant": "default", "#sdppp_simple_content": "canvas", "#sdppp_simple_mask": "canvas", "#sdppp_simple_boundary": "canvas", "#sdppp_label": "", "image": "clipspace/clipspace-painted-masked-1784202952306.png [input]"}, "widgets_values": ["clipspace/clipspace-painted-masked-1784202952306.png [input]", "image"], "color": "#2a363b", "bgcolor": "#3f5159"}], "links": [[1, 2, 0, 1, 0, "MODEL"], [2, 16, 0, 2, 0, "MODEL"], [3, 14, 0, 2, 3, "INT"], [4, 14, 1, 2, 4, "INT"], [5, 19, 0, 5, 0, "CONDITIONING"], [6, 13, 0, 5, 1, "LATENT"], [7, 14, 0, 7, 0, "INT"], [8, 14, 1, 7, 1, "INT"], [9, 6, 0, 9, 0, "NOISE"], [10, 20, 0, 9, 1, "GUIDER"], [11, 8, 0, 9, 2, "SAMPLER"], [12, 1, 0, 9, 3, "SIGMAS"], [13, 7, 0, 9, 4, "LATENT"], [14, 9, 0, 10, 0, "LATENT"], [15, 3, 0, 10, 1, "VAE"], [18, 3, 0, 13, 1, "VAE"], [24, 22, 0, 19, 0, "CONDITIONING"], [25, 2, 0, 20, 0, "MODEL"], [38, 26, 1, 33, 0, "MASK"], [41, 33, 0, 36, 0, "MASK"], [44, 36, 0, 39, 0, "IMAGE"], [45, 31, 0, 39, 2, "INT"], [46, 31, 1, 39, 3, "INT"], [47, 39, 0, 37, 0, "IMAGE"], [55, 31, 0, 32, 3, "INT"], [56, 31, 1, 32, 4, "INT"], [57, 32, 0, 44, 0, "IMAGE"], [58, 32, 1, 44, 1, "MASK"], [59, 44, 0, 45, 0, "IMAGE"], [62, 44, 0, 14, 0, "IMAGE"], [63, 44, 0, 13, 0, "IMAGE"], [75, 26, 0, 53, 1, "IMAGE"], [79, 37, 0, 32, 1, "MASK"], [92, 61, 0, 22, 0, "CLIP"], [95, 10, 0, 62, 1, "IMAGE"], [96, 62, 0, 17, 0, "IMAGE"], [97, 62, 0, 53, 0, "IMAGE"], [98, 60, 0, 22, 1, "STRING"], [100, 26, 0, 31, 0, "IMAGE"], [101, 26, 0, 32, 0, "IMAGE"], [102, 26, 0, 62, 0, "IMAGE"], [104, 5, 0, 20, 1, "CONDITIONING"]], "groups": [{"id": 2, "title": "图片上传(图片上右键open in maskeditor画遮罩范围,不用涂)", "bounding": [-10, -2270, 1249.2113037109375, 726.7728881835938], "color": "#A88", "font_size": 45, "flags": {}}, {"id": 5, "title": "提示词【很重要】", "bounding": [1260, -2260, 481.13201904296875, 714.9009399414062], "color": "#8A8", "font_size": 24, "flags": {}}, {"id": 6, "title": "最终效果区", "bounding": [1760, -2260, 1330.0355224609375, 711.3678588867188], "color": "#3f789e", "font_size": 24, "flags": {}}, {"id": 7, "title": "核心工作区", "bounding": [990, -1480, 379.9303894042969, 546.5068969726562], "color": "#3f789e", "font_size": 24, "flags": {}}, {"id": 9, "title": "Group", "bounding": [310, -1470, 635.491455078125, 641.5743408203125], "color": "#3f789e", "font_size": 24, "flags": {}}, {"id": 10, "title": "Group", "bounding": [-50, -1460, 321.03936767578125, 291.3662414550781], "color": "#3f789e", "font_size": 24, "flags": {}}, {"id": 11, "title": "Group", "bounding": [1390, -1470, 1745.5640869140625, 621.2001342773438], "color": "#3f789e", "font_size": 24, "flags": {}}], "config": {}, "extra": {"links_added_by_ue": [], "VHS_KeepIntermediate": true, "ue_links": [], "VHS_MetadataImage": true, "0246.VERSION": [0, 0, 4], "VHS_latentpreviewrate": 0, "frontendVersion": "1.41.21", "VHS_latentpreview": false, "ds": {"scale": 0.45, "offset": [1726.6666011315049, 2886.944475612862]}, "workflowRendererVersion": "LG", "favoritedWidgets": {"favorites": [{"nodeLocatorId": "26", "widgetName": "$$canvas-image-preview"}]}}, "version": 0.4} \ No newline at end of file diff --git a/add_hair.json b/add_hair.json index da8d33a..2a6c7e0 100644 --- a/add_hair.json +++ b/add_hair.json @@ -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"}}} \ No newline at end of file +{ + "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" + } + } +} diff --git a/app.py b/app.py index db27d6e..6113cb5 100644 --- a/app.py +++ b/app.py @@ -362,21 +362,36 @@ def _run_face_measure_data(image, variant="v1"): logger.warning("头发/耳朵分割失败,回退方案A:%s", seg_e) result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose) + discarded = result.hairline_discarded data = result.to_response() + vd = result.vertical if variant == "v6": - vd = result.vertical - 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 保留返回(供前端/下游定位头顶),但顶庭数值、 - # 占比、标注图仍按三庭处理,显示效果不变。 + if discarded: + # 发际线弃用:接口6 的上庭也依赖发际线,一并置 null;只保留中/下庭。 + base_px = vd["middle_court_px"] + vd["lower_court_px"] + data["four_courts"]["upper_court_cm"] = None + data["four_courts"]["ratios"] = { + "upper_court": None, + "middle_court": round(vd["middle_court_px"] / base_px, 3), + "lower_court": round(vd["lower_court_px"] / base_px, 3), + } + data["four_courts"].pop("top_court_cm", None) + data["face_total_height_cm"] = round( + result.middle_cm + result.lower_cm, 2) + data["landmarks"]["hairline"] = None + else: + base_px = vd["upper_court_px"] + vd["middle_court_px"] + vd["lower_court_px"] + # 接口6 是三庭:去掉顶庭相关字段(top_court_cm / ratios.top_court / landmarks.hair_top) + data["four_courts"]["ratios"] = { + "upper_court": round(vd["upper_court_px"] / base_px, 3), + "middle_court": round(vd["middle_court_px"] / base_px, 3), + "lower_court": round(vd["lower_court_px"] / base_px, 3), + } + data["four_courts"].pop("top_court_cm", None) + data["face_total_height_cm"] = round( + result.upper_cm + result.middle_cm + result.lower_cm, 2) + # 注:landmarks.hair_top 保留返回(供前端/下游定位头顶),但顶庭数值、 + # 占比、标注图仍按三庭处理,显示效果不变。 # 七眼段宽度(cm)。eye1=左耳外段 eye2=左脸颊 eye3=左眼 eye4=两眼间距 eye5=右眼 eye6=右脸颊 eye7=右耳外段。 # eye2~eye6(5段)只用内部分点,接口1/6 共用;eye1/eye7 需耳朵分割端线,仅接口1 有。 @@ -391,11 +406,14 @@ def _run_face_measure_data(image, variant="v1"): data["seven_eyes"][f"eye{i + 2}"] = ( None if (a is None or b is None) else round((b - a) / pc, 2)) if variant != "v6": - # 接口1 额外算 eye1/eye7(左/右耳外段),需耳朵分割端线 + # 接口1 额外算 eye1/eye7(左/右耳外段),需耳朵分割端线。 + # 竖向范围:发际线弃用时用眉心做上界(hair_top 不可靠),否则用头顶。 from face_analysis.annotation import _ear_edges_from_mask + top_y = (vd["brow_center"][1] if discarded + else vd["hair_top"][1]) head_l, head_r = _ear_edges_from_mask( ear_mask, hair_mask, - result.vertical["hair_top"][1], result.vertical["chin_tip"][1], + top_y, vd["chin_tip"][1], lcx, rcx, (lcx + rcx) / 2) data["seven_eyes"]["eye1"] = ( None if (head_l is None) else round((lcx - head_l) / pc, 2)) @@ -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"), beauty_enabled: bool = Form(default=False, description="是否开启美颜(本期不生效)"), use_mask: bool = Form(default=True, description="是否启用 inpaint 遮罩(测试对比用)。false 时用干净原图生成(空遮罩,不烧模板线)"), - prompt: str = Form(default="补充遮罩区域的头发,加一点美颜", description="ComfyUI 提示词,会替换工作流节点60的文本"), + prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", description="ComfyUI 提示词,会替换工作流节点60的文本"), ): # 1. gender 必填校验(非法/缺失 → 1004) 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", - summary="接口7 C端生发 v2(add_hair2 工作流)", - tags=["生发"], - description=f""" -输入用户正面照 + **性别** + **发型序号**,使用 add_hair2.json 工作流生成指定发际线类型的预览图与生发图。 -功能与接口2 完全一致,仅 ComfyUI 工作流不同。 - -{_image_fields_desc} - -图片同时支持 `multipart/form-data` 文件上传(字段名 `image_file`)。 - ---- - -- **gender**(必填):`male` / `female`。决定返回的贴图集合(female 5 张 / male 4 张)。 - 非法或缺失返回 `1004`。 -- **hair_style**(必填):`int`,发型序号。`female`:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave; - `male`:1=ellipse, 2=inverse_arc, 3=m, 4=straight。越界返回 `1007`。 -- **beauty_enabled**:本期保留但不生效。 - -`hairline_type` 取值:`ellipse` / `flower` / `heart` / `straight` / `wave`(female), -`ellipse` / `m` / `straight` / `inverse_arc`(male)。 -""", - responses={ - 200: { - "description": "成功", - "content": { - "application/json": { - "example": { - "code": 0, - "message": "success", - "request_id": "mock-request-id", - "data": { - "results": [ - {"image_base64": "iVBORw0KGgo...", "hairline_type": "ellipse", "order": 1}, - ] - }, - } - } - }, - }, - 400: { - "description": "参数错误 / 图片识别失败", - "content": { - "application/json": { - "examples": { - "图片参数错误": {"value": {"code": 1007, "message": "图片参数错误:必须且只能传 image_file / image_url / image_base64 其中一个", "request_id": "x", "data": None}}, - "非正面照": {"value": {"code": 1003, "message": "角度问题,请上传正面照", "request_id": "x", "data": None}}, - } - } - }, - }, - }, -) -async def hair_grow_v2( - image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG)"), - image_url: Optional[str] = Form(default=None, description="图片 URL"), - image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"), - gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"), - hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填),如 1,2,3。female:1-5 male:1-4"), - beauty_enabled: bool = Form(default=False, description="是否开启美颜(本期不生效)"), - use_mask: bool = Form(default=True, description="是否启用 inpaint 遮罩(测试对比用)。false 时用干净原图生成(空遮罩,不烧模板线)"), - prompt: str = Form(default="补充遮罩区域的头发,加一点美颜", description="ComfyUI 提示词,会替换工作流节点60的文本"), -): - # 1. gender 必填校验(非法/缺失 → 1004) - if gender not in ("male", "female"): - return err(1004, "gender 必填且只能为 male / female") - - # 2. hair_style 必填校验(解析逗号分隔,越界 → 1007) - max_styles = {"female": 5, "male": 4}[gender] - hair_styles = _parse_hair_styles(hair_style, max_styles) - if hair_styles is None: - return err(1007, f"hair_style 必填且为 1..{max_styles} 的整数(逗号分隔),收到 {hair_style!r}") - - # 3. 三选一取图 - raw, e = await resolve_image_bytes(image_file, image_url, image_base64) - if e is not None: - return e - - image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR) - if image is None: - return err(1008, "图片格式不支持(仅 JPG / PNG)") - - try: - from fastapi.concurrency import run_in_threadpool - from hairline.service import generate_grow_results - - # 预览 + 生发(ComfyUI) 都是阻塞且较慢,放线程池避免卡住事件循环 - items = await run_in_threadpool(generate_grow_results, image, gender, use_mask, prompt, hair_styles, _WORKFLOW2_PATH) - if items is None: - return err(1001, "无法识别人像") - - results = [] - for p in items: - results.append({ - "image_base64": _jpg_b64(p["image_bgr"]), # 预览图 JPG - "grown_image_base64": (_png_to_jpg_b64(p["grown_png"]) # 生发图 JPG - if p["grown_png"] else None), - "hairline_type": p["hairline_type"], - "order": p["order"], - }) - return ok({"results": results}) - except Exception as ex: # noqa: BLE001 - logger.exception("接口7 处理异常") - return err(1007, f"处理失败:{ex}") +@app.post("/api/v1/hair/grow-v2", include_in_schema=False, deprecated=True) +async def hair_grow_v2(): + """接口7 已弃用:请改用 /api/v1/hair/grow(接口2)。""" + return err(1007, "接口7(/api/v1/hair/grow-v2)已弃用,请使用 /api/v1/hair/grow") # --------------------------------------------------------------------------- @@ -931,7 +847,7 @@ async def hair_grow_b( marked_image_url: Optional[str] = Form(default=None, description="划线图片 URL"), marked_image_base64: Optional[str] = Form(default=None, description="划线图片 base64"), use_mask: bool = Form(default=True, description="是否画发际线(测试对比用)。false 时跳过划线检测、直接送划线图"), - prompt: str = Form(default="补充遮罩区域的头发,加一点美颜", description="ComfyUI 提示词,会替换工作流节点60的文本"), + prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", description="ComfyUI 提示词,会替换工作流节点60的文本"), ): # 划线图三选一取图(只需这一张) marked_raw, e = await resolve_image_bytes(marked_image_file, marked_image_url, marked_image_base64) @@ -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。 - 可选 `use_mask` / `prompt`:同接口2 的生发控制参数。 注:生发黑模板固定取 `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(必填)"), hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填,如 1,2,3)。female:1-5 male:1-4"), use_mask: bool = Form(default=True, description="生发是否启用 inpaint 遮罩(同接口2,测试对比用)"), - prompt: str = Form(default="补充遮罩区域的头发,加一点美颜", description="ComfyUI 提示词(同接口2),会替换工作流节点60的文本"), + 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"): return err(1004, "gender 必填且只能为 male / female") @@ -1163,7 +1082,8 @@ async def hairline_generate( from hairline.service import generate_hairline_pngs res = await run_in_threadpool( - generate_hairline_pngs, image, gender, hair_styles, use_mask, prompt) + generate_hairline_pngs, image, gender, hair_styles, use_mask, prompt, + generate_grow_image=generate_grow_image) if res is None: 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"), mask_blur: int = Form(default=11, description="change_hair服务端遮罩边缘模糊像素,默认 11"), mask_dilate_scale: float = Form(default=1.0, description="change_hair服务端遮罩膨胀缩放,默认 1.0"), - comfyui_prompt: Optional[str] = Form(default=None, description="Flux-2 重绘提示词,None 用默认「补充遮罩区域的头发,加一点美颜」"), + comfyui_prompt: Optional[str] = Form(default=None, description="Flux-2 重绘提示词,None 用默认「填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜」"), beauty_alpha: float = Form(default=0.6, description="redraw_band 版 band 外的全脸美颜融入强度(0=band外无美颜纯用final,1≈整帧版),默认 0.6"), band_lo_mult: float = Form(default=0.5, description="重绘带外推倍率下限(相对 hairline_push_cm,内轮廓=0×、原外推线=1.0×),默认 0.5"), band_hi_mult: float = Form(default=1.5, description="重绘带外推倍率上限(相对 hairline_push_cm),默认 1.5"), @@ -1697,7 +1617,7 @@ async def hairline_grow_v2_final_v2( async def api_redraw( image_file: UploadFile = File(..., description="人物图片(JPG/PNG)"), mask_file: UploadFile = File(..., description="遮罩图片(PNG,支持红/白/alpha 格式)"), - prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮", + prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", description="ComfyUI 提示词"), ): image_bytes = await image_file.read() diff --git a/benchmark_9b_vs_4b.py b/benchmark_9b_vs_4b.py index 34ba8d8..5148080 100644 --- a/benchmark_9b_vs_4b.py +++ b/benchmark_9b_vs_4b.py @@ -58,7 +58,7 @@ def call_iface2(image_path, hair_style): with open(image_path, "rb") as f: img_data = f.read() fields = {"gender": "female", "hair_style": str(hair_style), - "prompt": "填充遮罩区域的头发,皮肤加一点磨皮"} + "prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"} 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") diff --git a/benchmark_iface2_female.py b/benchmark_iface2_female.py index 47e45cb..c36aa9f 100644 --- a/benchmark_iface2_female.py +++ b/benchmark_iface2_female.py @@ -56,7 +56,7 @@ def call_iface2_female(image_path, hair_style): with open(image_path, "rb") as f: img_data = f.read() fields = {"gender": "female", "hair_style": str(hair_style), - "prompt": "填充遮罩区域的头发,皮肤加一点磨皮"} + "prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"} 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") diff --git a/benchmark_rotation.py b/benchmark_rotation.py index 4512ef6..fc1ee21 100644 --- a/benchmark_rotation.py +++ b/benchmark_rotation.py @@ -89,7 +89,7 @@ def call_api2(image_path, gender, hair_style="1"): with open(image_path, "rb") as f: img_data = f.read() 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")}) 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}") @@ -107,7 +107,7 @@ def call_api3(image_path): """接口3:B端生发(use_mask=False,直接送图)""" with open(image_path, "rb") as f: 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")}) 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}") diff --git a/docs/接口文档.md b/docs/接口文档.md index ac45ce3..f8e770c 100644 --- a/docs/接口文档.md +++ b/docs/接口文档.md @@ -20,7 +20,6 @@ | 3 B 端生发 | POST | `/api/v1/hair/grow-b` | | 4 用户特征 | POST | `/api/v1/face/features` | | 5 发际线 PNG 生成 | POST | `/api/v1/hairline/generate` | -| 7 C 端生发 v2 | POST | `/api/v1/hair/grow-v2` | --- @@ -87,6 +86,7 @@ | 1006 | 文件超出大小限制 | 单文件超过 1 MB | | 1007 | 图片参数错误 | file / url / base64 未传,或同时传了多个(三者严格互斥) | | 1008 | 图片格式不支持 | 非 JPG / PNG | +| 1009 | 未授权 | 缺少或错误的 `X-Internal-Token`(`/api/*` 路径鉴权) | --- @@ -109,6 +109,8 @@ | four_courts | object | 四庭数据,见下表 | | seven_eyes | object | 七眼数据,见下表 | | landmarks | object | 关键分界点坐标(头顶 / 发际线 / 眉心 / 鼻翼下缘 / 下巴尖),原图像素坐标 | +| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` | +| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:`{ "x": int, "y": int }` | `four_courts`(四庭,自上而下): @@ -211,6 +213,8 @@ | four_courts | object | 三庭数据(上/中/下庭,各含 cm 与 ratio;**无顶庭**) | | seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距 cm + 占比 ratios + **eye2~eye6** 共 5 段宽度) | | landmarks | object | 四个关键点像素坐标(发际线/眉心/鼻翼下缘/下巴尖) | +| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` | +| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:`{ "x": int, "y": int }` | > 接口6 是**三庭五眼**:`four_courts`/`landmarks` 不含顶庭与头顶点(无 `top_court_cm`/`hair_top`);`seven_eyes` 只含 **eye2~eye6**(左脸颊/左眼/两眼间距/右眼/右脸颊,5 段),**无 eye1/eye7**(耳外段需头发轮廓端线,仅接口1 有)。 @@ -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` | | use_mask | bool | 否 | 生发是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 | | 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 张生发图。 @@ -426,7 +431,7 @@ | image_middle_url | string | middle 档发际线曲线**透明 PNG** URL(仅曲线,透明底,**不含人物**,需叠加原图显示) | | image_high_url | string | high 档发际线曲线**透明 PNG** URL(同上,high 档曲线) | | image_low_url | string | low 档发际线曲线**透明 PNG** URL(同上,low 档曲线) | -| grown_image_url | string \| null | **生发后图片** URL(ComfyUI「植发」效果图,完整人像照片,生发失败时为 `null`) | +| grown_image_url | string \| null | **生发后图片** URL(ComfyUI「植发」效果图,完整人像照片,生发失败或 `generate_grow_image=false` 时为 `null`) | | order | int | 发型序号(= 传入的 hair_style 值) | > worker 侧返回 `image_middle_base64` / `image_high_base64` / `image_low_base64` / `grown_image_base64`,网关落盘后改写为上表对应的 `*_url`。 @@ -443,6 +448,8 @@ | landmarks | object | 5 个纵向关键点像素坐标(hair_top/hairline/brow_center/nose_bottom/chin_tip),结构同接口1 | | hairline_source | string | 发际线来源:`segmentation`(真实分割)/ `estimated`(比例估算) | | head_pose | object | 头部姿态角度(yaw/pitch/roll,单位:度) | +| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` | +| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:`{ "x": int, "y": int }` | > `eye1`~`eye7` 为从左到右共 7 段宽度,eye1=左耳外段、eye7=右耳外段,某侧耳朵不可见时对应段为 `null`。详见接口1说明。 @@ -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 端生发 | 划线图片 | 最合适发际线图片 + 生发后图片 | | 4 用户特征 | 用户照片 | 6 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格) | | 5 发际线 PNG | 用户照片 + gender + hair_style(多选) | 每个选中发型 middle/high/low 三档发际线叠图 + 生发图 + 最合适发际线面部中间点坐标 | -| 7 C 端生发 v2 | 用户照片 + gender + hair_style | 同接口2,使用 add_hair2.json 工作流 | --- diff --git a/face/build_dataset_report.py b/face/build_dataset_report.py new file mode 100644 index 0000000..6873f48 --- /dev/null +++ b/face/build_dataset_report.py @@ -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/_report.html + static/_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 "

无数据

" + 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"
{html.escape(shape)}" + f"
" + f"{n}({pct:.0f}%)
" + ) + 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"{html.escape(item['group'])}" + if not item["ok"]: + return f""" +
+ + {html.escape(item['file'])} + +
+

{html.escape(item['file'])}

{group_tag}
+

检测失败

+

{html.escape(item['error'] or '')}

+
+
""" + + color = SHAPE_COLORS.get(item["predicted"], "#34495e") + top3 = "".join( + f"
  • {html.escape(name)}{score:.1f}
  • " for name, score in item["top3"] + ) + feat_html = "".join( + f"{html.escape(k)}{html.escape(fmt_feat(k, v))}" + for k, v in item["features"].items() + ) + return f""" +
    + + {html.escape(item['file'])} + +
    +

    {html.escape(item['file'])}

    {group_tag}
    +

    {html.escape(item['rel_path'])}

    +

    {html.escape(item['display'])}

    +

    匹配度 {item['score']:.1f} · 置信度 {item['confidence']:.3f}

    +
      {top3}
    +
    + 标注特征数值 + {feat_html}
    +
    +
    +
    """ + + +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"{html.escape(c)}" 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("·") + continue + cls = "hit" if c == equiv else "num" + cells.append(f"{n}") + label = html.escape(group) + if equiv: + label += f" ≈{html.escape(equiv)}" + body.append(f"{label}{''.join(cells)}{len(items)}") + return ( + "
    " + f"{head}" + f"{''.join(body)}
    原始分组 \\ 预测合计
    " + ) + + +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"

    {html.escape(g)} ({len(items)} 张)

    " + f"{bar_chart(Counter(i['predicted'] if i['ok'] else '检测失败' for i in items))}
    " + for g, items in sorted(by_group.items(), key=lambda kv: natural_key(kv[0])) + ) + + sections = "".join( + f"

    {html.escape(g)}{len(items)} 张

    " + f"
    {''.join(card(i) for i in items)}
    " + 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""" + + + + +{html.escape(name)} — 脸型分类报告 + + + +
    +

    {html.escape(name)} — 脸型分类报告

    +

    z 分数原型匹配分类 · 照片上标注 face_width / face_height 及各比例特征

    +

    生成时间:{html.escape(now)} · 抽样 {len(rows)} 张(随机种子 {seed})· 成功 {n_ok} 张 · + 覆盖 {n_kind} 种脸型 · 共 {len(by_group)} 个原始分组 · 点击图片看大图

    +

    来源目录:{html.escape(str(src))}

    +
    + +
    +
    + 图上标注说明
    + face_width 颧骨宽度  + face_height 额头顶→下巴  + jaw_angle 下巴到左右下颌角夹角  + taper_ratio 额头→下巴收窄  + forehead / jaw / chin ratio 各级宽度比  + face_curve_score 下颌中点→下巴  + 右侧柱状条示意 width_uniformity;左上角是完整数值图例。 +
    +
    + +
    +

    原始分组 × 预测脸型 对照数据集分组本身是脸型标签,但命名口径与分类器不同

    + {cross_table(by_group)} +

    + 绿色格子表示预测结果与该分组的对应口径一致(标准脸≈鹅蛋脸、娃娃脸≈圆形脸,方形/长形/瓜子同名直接对应)。 + 「梨形脸」「混合脸」在分类器的 7 分类里没有对应项,不作一致性判断。 +

    +
    + +
    +

    总体脸型分布({len(rows)} 张)

    {bar_chart(overall)}
    + {group_stats} +
    + +{sections} + +
    + 分类实现:face/face_shape_classifier.py · 报告生成:face/build_dataset_report.py · + 图片目录:static/{html.escape(img_dir_name)}/ +
    + + +""" + + +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() diff --git a/face/build_report.py b/face/build_report.py new file mode 100644 index 0000000..ddf5089 --- /dev/null +++ b/face/build_report.py @@ -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 "

    无数据

    " + 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"
    {html.escape(shape)}" + f"
    " + f"{n}({pct:.0f}%)
    " + ) + 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""" +
    + + {html.escape(item['file'])} + +
    +

    {html.escape(item['file'])}

    +

    检测失败

    +

    {html.escape(item['error'] or '')}

    +
    +
    """ + + color = SHAPE_COLORS.get(item["predicted"], "#34495e") + top3 = "".join( + f"
  • {html.escape(name)}{score:.1f}
  • " for name, score in item["top3"] + ) + feat_html = "".join( + f"{html.escape(k)}{html.escape(fmt_feat(k, v))}" + for k, v in item["features"].items() + ) + return f""" +
    + + {html.escape(item['file'])} + +
    +
    +

    {html.escape(item['file'])}

    + {html.escape(item['gender'])} +
    +

    {html.escape(item['display'])}

    +

    匹配度 {item['score']:.1f} · 置信度 {item['confidence']:.3f}

    +

    Top-3 得分

    +
      {top3}
    +
    + 标注特征数值 + {feat_html}
    +
    +
    +
    """ + + +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""" + + + + +脸型分类预测报告(特征标注) + + + +
    +

    脸型分类预测报告

    +

    z 分数原型匹配分类 · 照片上标注 face_width / face_height 及各比例特征

    +

    生成时间:{html.escape(now)} · 样本 {n_girl + n_man} 张(女 {n_girl} / 男 {n_man})· 成功 {n_ok} · 覆盖 {n_kind} 种脸型 · 点击图片看大图

    +
    + +
    +
    + 图上标注说明
    + face_width 颧骨宽度  + face_height 额头顶→下巴  + jaw_angle 下巴到左右下颌角夹角  + taper_ratio 额头→下巴收窄  + forehead / jaw / chin ratio 各级宽度比  + face_curve_score 下颌中点→下巴  + 右侧柱状条示意 width_uniformity;左上角是完整数值图例。 +
    +
    + +
    +

    全部脸型分布

    {count_table(summary['all'])}
    +

    女性脸型分布

    {count_table(summary['女'])}
    +

    男性脸型分布

    {count_table(summary['男'])}
    +
    + +
    +

    女性样本({n_girl})

    +
    {girl_cards}
    +
    + +
    +

    男性样本({n_man})

    +
    {man_cards}
    +
    + +
    + 分类实现:face/face_shape_classifier.py · 报告生成:face/build_report.py +
    + + +""" + + +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() diff --git a/face/dump_features.py b/face/dump_features.py new file mode 100644 index 0000000..98595c5 --- /dev/null +++ b/face/dump_features.py @@ -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() diff --git a/face/face_shape_classification.md b/face/face_shape_classification.md new file mode 100644 index 0000000..1fdae3d --- /dev/null +++ b/face/face_shape_classification.md @@ -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 人脸分析流水线中。* diff --git a/face/face_shape_classifier.py b/face/face_shape_classifier.py new file mode 100644 index 0000000..273b62a --- /dev/null +++ b/face/face_shape_classifier.py @@ -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) diff --git a/face/test_img/圆形脸.png b/face/test_img/圆形脸.png new file mode 100644 index 0000000..841a2e3 Binary files /dev/null and b/face/test_img/圆形脸.png differ diff --git a/face/test_img/心形脸.png b/face/test_img/心形脸.png new file mode 100644 index 0000000..a09daa6 Binary files /dev/null and b/face/test_img/心形脸.png differ diff --git a/face/test_img/方形脸.png b/face/test_img/方形脸.png new file mode 100644 index 0000000..cf6899d Binary files /dev/null and b/face/test_img/方形脸.png differ diff --git a/face/test_img/菱形脸.png b/face/test_img/菱形脸.png new file mode 100644 index 0000000..7c4c360 Binary files /dev/null and b/face/test_img/菱形脸.png differ diff --git a/face/test_img/长形脸.png b/face/test_img/长形脸.png new file mode 100644 index 0000000..1b03726 Binary files /dev/null and b/face/test_img/长形脸.png differ diff --git a/face/test_img/鹅蛋脸.png b/face/test_img/鹅蛋脸.png new file mode 100644 index 0000000..6de6265 Binary files /dev/null and b/face/test_img/鹅蛋脸.png differ diff --git a/face_analysis/annotation.py b/face_analysis/annotation.py index fb07c24..60b1a10 100644 --- a/face_analysis/annotation.py +++ b/face_analysis/annotation.py @@ -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) - if variant == "v6": + # 发际线弃用(hairline_discarded):保留头顶横线,去掉发际线横线, + # 也不标顶/上庭(缺发际线作边界,算不出)。横线 = 头顶/眉心/鼻翼下缘/下巴尖。 + if getattr(measure_result, "hairline_discarded", False): + order = ["hair_top", "brow_center", "nose_bottom", "chin_tip"] + elif variant == "v6": order = ["hairline", "brow_center", "nose_bottom", "chin_tip"] else: order = ["hair_top", "hairline", "brow_center", "nose_bottom", "chin_tip"] @@ -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) # --- 3b. 左侧四庭:名 + 数值两行(无 cm)+ 竖向虚线双箭头 --- - if variant == "v6": + # court_start:庭段在 order 里的起始索引。发际线弃用时 order 首位是头顶(无下界发际线, + # 顶/上庭不标),中庭从眉心开始 → 跳过 order[0]。 + if getattr(measure_result, "hairline_discarded", False): + court_cm = [measure_result.middle_cm, measure_result.lower_cm] + court_name = ["中庭", "下庭"] + n_court = 2 + court_start = 1 + elif variant == "v6": court_cm = [measure_result.upper_cm, measure_result.middle_cm, measure_result.lower_cm] court_name = ["上庭", "中庭", "下庭"] n_court = 3 + court_start = 0 else: court_cm = [measure_result.top_cm, measure_result.upper_cm, measure_result.middle_cm, measure_result.lower_cm] court_name = ["顶庭", "上庭", "中庭", "下庭"] n_court = 4 + court_start = 0 arrow_x = max(arrow_size + 1, fx0 - pad) # 竖箭头所在 x(脸左侧,贴近最左竖线) for i in range(n_court): - y_a, y_b = ys[i], ys[i + 1] + y_a, y_b = ys[court_start + i], ys[court_start + i + 1] # 竖向虚线双箭头,覆盖该庭高度(略收一点避免压到横线) inset = min(arrow_size, (y_b - y_a) * 0.12) draw_dashed_line_with_arrows( diff --git a/face_analysis/face_mesh_landmarks.py b/face_analysis/face_mesh_landmarks.py index 345206b..c411ab1 100644 --- a/face_analysis/face_mesh_landmarks.py +++ b/face_analysis/face_mesh_landmarks.py @@ -18,6 +18,8 @@ RIGHT_EYE_INNER = 362 # 右眼内角 RIGHT_EYE_OUTER = 263 # 右眼外角 LEFT_CHEEK = 234 # 左脸颧弓(脸宽左端) RIGHT_CHEEK = 454 # 右脸颧弓(脸宽右端) +LEFT_POSITION = 21 # 左脸前侧定位点(脸颊/耳前区域,与 251 镜像) +RIGHT_POSITION = 251 # 右脸前侧定位点(与 21 镜像) # --- 鼻尖(solvePnP 用,可选) --- NOSE_TIP = 1 # 鼻尖(也有用 4 的版本) diff --git a/face_analysis/hairline_grow.py b/face_analysis/hairline_grow.py index 32065b9..6b32fe5 100644 --- a/face_analysis/hairline_grow.py +++ b/face_analysis/hairline_grow.py @@ -1107,7 +1107,7 @@ def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="seg "hairline_id": hairline_id, "blend_method": blend_method, "hairline_push_cm": round(float(hairline_push_cm), 2), - "comfyui_prompt": comfyui_prompt or "补充遮罩区域的头发,加一点美颜", + "comfyui_prompt": comfyui_prompt or "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", "beauty_alpha": beauty_alpha, "px_per_cm": round(float(px_per_cm), 4), "mask_pixels": mask_viz["mask_pixels"], diff --git a/face_analysis/measure.py b/face_analysis/measure.py index d92930b..5cfac61 100644 --- a/face_analysis/measure.py +++ b/face_analysis/measure.py @@ -14,7 +14,7 @@ from face_analysis.calibration import ( from face_analysis.face_mesh_landmarks import ( GLABELLA_9, GLABELLA_151, NOSE_BOTTOM, CHIN_TIP, LEFT_EYE_OUTER, LEFT_EYE_INNER, RIGHT_EYE_INNER, RIGHT_EYE_OUTER, - LEFT_CHEEK, RIGHT_CHEEK, + LEFT_CHEEK, RIGHT_CHEEK, LEFT_POSITION, RIGHT_POSITION, ) from face_analysis.hair_segmenter import locate_hairline_by_segmentation @@ -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: """测量结果,提供 to_response() 输出与接口文档同构的 data 字段。""" - def __init__(self, vertical, eyes, px_per_cm, hairline_source, head_pose): + # 发际线弃用阈值:发际线离头顶(顶庭)< 此值时判定分割不可靠,弃用发际线。 + # hairline 与 hair_top 几乎重合(如稀疏头发中轴漏检只剩一小撮),说明发际线 + # 定位无意义 → 顶/上庭置 null、标注图不画头顶/发际线。 + HAIRLINE_DISCARD_TOP_CM = 0.7 + + def __init__(self, vertical, eyes, px_per_cm, hairline_source, head_pose, + landmarks=None, image_width=None, image_height=None): self.vertical = vertical self.eyes = eyes self.px_per_cm = px_per_cm self.hairline_source = hairline_source self.head_pose = head_pose # (yaw, pitch, roll) 或 None + # 原始 mediapipe 点集 + 图像尺寸,供 to_response 输出 21/251 号定位点 + self.landmarks = landmarks + self.w = image_width + self.h = image_height # 各庭厘米 self.top_cm = vertical["top_court_px"] / px_per_cm self.upper_cm = vertical["upper_court_px"] / px_per_cm self.middle_cm = vertical["middle_court_px"] / px_per_cm self.lower_cm = vertical["lower_court_px"] / px_per_cm - self.face_total_cm = self.top_cm + self.upper_cm + self.middle_cm + self.lower_cm + # 发际线弃用判定:顶庭(头顶→发际线)过小视为发际线贴近头顶、不可靠。 + # 弃用时 hairline_source 改为 "discarded",face_total 只算中庭+下庭。 + self.hairline_discarded = self.top_cm < self.HAIRLINE_DISCARD_TOP_CM + if self.hairline_discarded: + self.hairline_source = "discarded" + self.face_total_cm = self.middle_cm + self.lower_cm + else: + self.face_total_cm = self.top_cm + self.upper_cm + self.middle_cm + self.lower_cm # 七眼厘米 self.eye_width_cm = eyes["eye_width_px"] / px_per_cm @@ -167,46 +192,88 @@ class MeasureResult: self.inter_eye_cm = eyes["inter_eye_distance_px"] / px_per_cm def to_response(self): - total_px = (self.vertical["top_court_px"] + self.vertical["upper_court_px"] - + self.vertical["middle_court_px"] + self.vertical["lower_court_px"]) - fw_px = self.eyes["face_width_px"] - - def pt(name): - x, y = self.vertical[name] - return {"x": int(round(x)), "y": int(round(y))} - - data = { - "face_total_height_cm": round(self.face_total_cm, 2), - "four_courts": { - "top_court_cm": round(self.top_cm, 2), - "upper_court_cm": round(self.upper_cm, 2), - "middle_court_cm": round(self.middle_cm, 2), - "lower_court_cm": round(self.lower_cm, 2), - "ratios": { - "top_court": round(self.vertical["top_court_px"] / total_px, 3), - "upper_court": round(self.vertical["upper_court_px"] / total_px, 3), - "middle_court": round(self.vertical["middle_court_px"] / total_px, 3), - "lower_court": round(self.vertical["lower_court_px"] / total_px, 3), + # 发际线弃用:顶/上庭相关字段置 null(保留键),ratio 分母只算中下庭; + # landmarks.hair_top/hairline 置 null。否则按四庭正常输出。 + if self.hairline_discarded: + base_px = (self.vertical["middle_court_px"] + self.vertical["lower_court_px"]) + data = { + "face_total_height_cm": round(self.face_total_cm, 2), + "four_courts": { + "top_court_cm": None, + "upper_court_cm": None, + "middle_court_cm": round(self.middle_cm, 2), + "lower_court_cm": round(self.lower_cm, 2), + "ratios": { + "top_court": None, + "upper_court": None, + "middle_court": round(self.vertical["middle_court_px"] / base_px, 3), + "lower_court": round(self.vertical["lower_court_px"] / base_px, 3), + }, }, - }, - "seven_eyes": { - "eye_width_cm": round(self.eye_width_cm, 2), - "face_width_cm": round(self.face_width_cm, 2), - "inter_eye_distance_cm": round(self.inter_eye_cm, 2), - "ratios": { - "eye_width": round(self.eyes["eye_width_px"] / fw_px, 3), - "inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / fw_px, 3), + "seven_eyes": { + "eye_width_cm": round(self.eye_width_cm, 2), + "face_width_cm": round(self.face_width_cm, 2), + "inter_eye_distance_cm": round(self.inter_eye_cm, 2), + "ratios": { + "eye_width": round(self.eyes["eye_width_px"] / self.eyes["face_width_px"], 3), + "inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / self.eyes["face_width_px"], 3), + }, }, - }, - "landmarks": { - "hair_top": pt("hair_top"), - "hairline": pt("hairline"), - "brow_center": pt("brow_center"), - "nose_bottom": pt("nose_bottom"), - "chin_tip": pt("chin_tip"), - }, - "hairline_source": self.hairline_source, - } + "landmarks": { + "hair_top": None, + "hairline": None, + "brow_center": pt_or_none(self.vertical, "brow_center"), + "nose_bottom": pt_or_none(self.vertical, "nose_bottom"), + "chin_tip": pt_or_none(self.vertical, "chin_tip"), + }, + "hairline_source": self.hairline_source, + } + else: + total_px = (self.vertical["top_court_px"] + self.vertical["upper_court_px"] + + self.vertical["middle_court_px"] + self.vertical["lower_court_px"]) + data = { + "face_total_height_cm": round(self.face_total_cm, 2), + "four_courts": { + "top_court_cm": round(self.top_cm, 2), + "upper_court_cm": round(self.upper_cm, 2), + "middle_court_cm": round(self.middle_cm, 2), + "lower_court_cm": round(self.lower_cm, 2), + "ratios": { + "top_court": round(self.vertical["top_court_px"] / total_px, 3), + "upper_court": round(self.vertical["upper_court_px"] / total_px, 3), + "middle_court": round(self.vertical["middle_court_px"] / total_px, 3), + "lower_court": round(self.vertical["lower_court_px"] / total_px, 3), + }, + }, + "seven_eyes": { + "eye_width_cm": round(self.eye_width_cm, 2), + "face_width_cm": round(self.face_width_cm, 2), + "inter_eye_distance_cm": round(self.inter_eye_cm, 2), + "ratios": { + "eye_width": round(self.eyes["eye_width_px"] / self.eyes["face_width_px"], 3), + "inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / self.eyes["face_width_px"], 3), + }, + }, + "landmarks": { + "hair_top": pt_or_none(self.vertical, "hair_top"), + "hairline": pt_or_none(self.vertical, "hairline"), + "brow_center": pt_or_none(self.vertical, "brow_center"), + "nose_bottom": pt_or_none(self.vertical, "nose_bottom"), + "chin_tip": pt_or_none(self.vertical, "chin_tip"), + }, + "hairline_source": self.hairline_source, + } + # left/right_position:mediapipe 21/251 号定位点(原图像素,与 landmarks 同坐标系)。 + # landmarks 缺省(如测试直构 MeasureResult)时不输出,保持向后兼容。 + if self.landmarks is not None and self.w and self.h: + lm = _lm_list(self.landmarks) + + def _pt_lm(idx): + px, py = normalized_to_pixel(lm[idx], self.w, self.h) + return {"x": int(round(px)), "y": int(round(py))} + + data["left_position"] = _pt_lm(LEFT_POSITION) + data["right_position"] = _pt_lm(RIGHT_POSITION) if self.head_pose is not None: yaw, pitch, roll = self.head_pose data["head_pose"] = { @@ -220,7 +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) eyes = measure_seven_eyes(landmarks, image_width, image_height) px_per_cm = estimate_scale_factor(landmarks, image_width, image_height) - return MeasureResult(vertical, eyes, px_per_cm, source, head_pose) + return MeasureResult(vertical, eyes, px_per_cm, source, head_pose, + landmarks, image_width, image_height) if __name__ == "__main__": diff --git a/face_analysis/pose.py b/face_analysis/pose.py index 423af00..57d385c 100644 --- a/face_analysis/pose.py +++ b/face_analysis/pose.py @@ -50,12 +50,21 @@ def estimate_head_pose(landmarks, image_width, image_height): [0, 0, 1]], dtype=np.float64) dist = np.zeros((4, 1)) # 假设无畸变 - success, rvec, _tvec = cv2.solvePnP( + success, rvec, tvec = cv2.solvePnP( _MODEL_POINTS, image_points, cam_matrix, dist, flags=cv2.SOLVEPNP_ITERATIVE, ) if not success: return None + # ITERATIVE 偶发收敛到相机后方的翻转解(tz<0),此时 roll 落在 ±180° 附近, + # 会把真正的正面照误判为 1003。改用 SQPNP 重解正深度解。 + if float(tvec[2, 0]) < 0: + ok2, rvec2, tvec2 = cv2.solvePnP( + _MODEL_POINTS, image_points, cam_matrix, dist, + flags=cv2.SOLVEPNP_SQPNP, + ) + if ok2 and float(tvec2[2, 0]) > 0: + rvec = rvec2 rot, _ = cv2.Rodrigues(rvec) # 在「相机坐标系」(x右 y下 z内) 下抽取 Tait-Bryan 欧拉角,物理含义对齐: # yaw = 绕 Y(竖轴)转 → 左右扭头 diff --git a/gateway/app.py b/gateway/app.py index eed7c3f..fb34f13 100644 --- a/gateway/app.py +++ b/gateway/app.py @@ -20,6 +20,8 @@ from fastapi.staticfiles import StaticFiles from gateway.config import load_config from gateway.logging_middleware import ( + get_available_dates, + get_daily_stats, get_stats, init_logging as _init_req_logging, request_logging_middleware, @@ -185,6 +187,9 @@ async def gateway_health(): "workers_total": status["total"], "workers_healthy": status["healthy"], "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", "docs": "/docs", "stats": "/admin/stats", + "timing_dashboard": "/static/api_timing_dashboard.html", "integration_guide": "/static/integration.html", "test_pages": { "if1_measure": "/static/test_interface1.html", @@ -470,6 +476,18 @@ async def admin_stats_json(): 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=["人脸分析"]) async def face_features( + request: Request, 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(需带前缀)"), @@ -559,6 +578,26 @@ async def face_features( "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 face_features import analyze_features diff --git a/gateway/config.example.json b/gateway/config.example.json index d47dd26..b76a439 100644 --- a/gateway/config.example.json +++ b/gateway/config.example.json @@ -16,6 +16,8 @@ "ark_api_key": "", "dispatch": { "per_worker_concurrency": 1, + "max_global_concurrency": 1, + "max_queue_wait_seconds": 590, "queue_wait_seconds": 30, "request_timeout_seconds": 600, "retry_on_failure": true, diff --git a/gateway/config.py b/gateway/config.py index 8198955..629075e 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -24,6 +24,8 @@ DEFAULTS = { }, "dispatch": { "per_worker_concurrency": 1, + "max_global_concurrency": 1, + "max_queue_wait_seconds": 590, "queue_wait_seconds": 30, "request_timeout_seconds": 600, "retry_on_failure": True, @@ -105,12 +107,15 @@ def load_config() -> dict: logger.info( "配置加载完成 | 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["public_base_url"], cfg["health_check"]["interval_seconds"], cfg["dispatch"]["request_timeout_seconds"], cfg["dispatch"]["queue_wait_seconds"], + cfg["dispatch"]["max_global_concurrency"], + cfg["dispatch"]["max_queue_wait_seconds"], ) _config_cache = cfg diff --git a/gateway/forward.py b/gateway/forward.py index 2e6aa40..db17b07 100644 --- a/gateway/forward.py +++ b/gateway/forward.py @@ -19,10 +19,13 @@ from fastapi.responses import JSONResponse from gateway.pool import ( NoWorkerAvailable, + acquire_global_slot, acquire_worker, mark_worker_unhealthy, + release_global_slot, release_worker, ) +from gateway.reqlog import extract_form_params logger = logging.getLogger("gateway.forward") @@ -143,17 +146,56 @@ async def proxy_request(request: Request, path: str) -> JSONResponse: """ from gateway.config import 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"] token = cfg["shared_password"] request_timeout = dispatch_cfg["request_timeout_seconds"] 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"] static_dir = cfg["static_dir"] - # --- 1. 读取客户端请求体(原始字节,不做解析) --- - body = await request.body() - # --- 2. 获取 worker(最多重试 max_retries+1 次) --- attempts = max_retries + 1 last_error_response = None diff --git a/gateway/logging_middleware.py b/gateway/logging_middleware.py index 2003c21..4184856 100644 --- a/gateway/logging_middleware.py +++ b/gateway/logging_middleware.py @@ -14,6 +14,8 @@ from dataclasses import asdict, dataclass, field from pathlib import Path from typing import Any, Dict, List, Optional +from gateway.reqlog import summarize_for_log + logger = logging.getLogger("gateway.logging_middleware") @@ -34,6 +36,8 @@ class RequestLogEntry: worker: str = "" # 处理请求的 worker URL(空串表示网关本地处理) response_code: Optional[int] = None # 响应 JSON 中的 code 字段 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", ""), response_code=data.get("response_code"), request_id=data.get("request_id"), + request_params=data.get("request_params"), + response_data=data.get("response_data"), ) _buffer.append(entry) count += 1 @@ -248,6 +254,7 @@ async def request_logging_middleware(request, call_next): # 提取响应 body 并解析业务字段(仅 JSON 响应) response_code = None request_id = None + response_data = None content_type = response.headers.get("content-type", "") 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) response_code = data.get("code") request_id = data.get("request_id") + response_data = summarize_for_log(data) except (json.JSONDecodeError, UnicodeDecodeError): pass @@ -274,6 +282,9 @@ async def request_logging_middleware(request, call_next): media_type=response.media_type, ) + # 入参(由 forward.py / 接口4 handler 挂到 request.state) + request_params = getattr(request.state, "log_request_params", None) + # 记录 now = datetime.datetime.utcnow() entry = RequestLogEntry( @@ -287,6 +298,8 @@ async def request_logging_middleware(request, call_next): worker=worker_url, response_code=response_code, request_id=request_id, + request_params=request_params, + response_data=response_data, ) _buffer.append(entry) @@ -366,6 +379,8 @@ def get_stats() -> Dict[str, Any]: "worker": e.worker, "response_code": e.response_code, "request_id": e.request_id, + "request_params": e.request_params, + "response_data": e.response_data, } for e in recent_100 ] @@ -404,3 +419,250 @@ def get_stats() -> Dict[str, Any]: "recent": recent, "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, + } diff --git a/gateway/pool.py b/gateway/pool.py index e2e2e6d..9bf790e 100644 --- a/gateway/pool.py +++ b/gateway/pool.py @@ -50,6 +50,14 @@ _pool_condition: Optional[asyncio.Condition] = None _health_task: Optional[asyncio.Task] = 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() _shutdown_event = asyncio.Event() + # 全局串行槽(独立于 worker busy 标志) + init_global_slot(cfg) + # 立即做一轮健康检查以快速上线 hc_cfg = cfg["health_check"] token = cfg["shared_password"] @@ -206,6 +217,56 @@ async def shutdown_pool() -> None: 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: """返回当前池状态(供 /gateway-health 使用)。""" + global_info = { + "global_max": _global_max, + "global_busy": _global_busy, + "global_waiting": _global_waiting, + } if not _workers: - return {"total": 0, "healthy": 0, "busy": 0} + return {"total": 0, "healthy": 0, "busy": 0, **global_info} total = len(_workers) healthy = sum(1 for w in _workers.values() if w.online) 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} diff --git a/gateway/reqlog.py b/gateway/reqlog.py new file mode 100644 index 0000000..17bdeb6 --- /dev/null +++ b/gateway/reqlog.py @@ -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 diff --git a/hair_repaint.json b/hair_repaint.json index 624b4c9..bd46388 100644 --- a/hair_repaint.json +++ b/hair_repaint.json @@ -410,7 +410,7 @@ }, "60": { "inputs": { - "text": "补充遮罩区域的头发,加一点美颜" + "text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" }, "class_type": "JjkText", "_meta": { diff --git a/hairline/comfyui.py b/hairline/comfyui.py index 0a2a23f..9f68b7b 100644 --- a/hairline/comfyui.py +++ b/hairline/comfyui.py @@ -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, - workflow_path: str | None = None) -> bytes: + workflow_path: str | None = None, front: bool = False) -> bytes: """提交一次生发任务,返回输出 PNG 字节。失败抛异常。 prompt:非 None 时替换工作流节点60(JjkText)的文本;None 时用工作流内置默认提示词。 workflow_path:工作流 JSON 路径,None 则用默认 add_hair.json。 + front:True 时任务插到 ComfyUI 队列最前(server 端 "front" 字段,队列号取负)。 + 接口2 对时延敏感用 True,避免排在接口3/5 的批量任务后面;其余接口保持 False。 """ path = workflow_path or _WORKFLOW_DEFAULT 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 pass - # 3. 提交 - r = cli.post("/prompt", json={"prompt": wf, "client_id": client_id}) + # 3. 提交(front=True 时插队到队列最前) + payload = {"prompt": wf, "client_id": client_id} + if front: + payload["front"] = True + r = cli.post("/prompt", json=payload) r.raise_for_status() prompt_id = r.json()["prompt_id"] diff --git a/hairline/redraw.py b/hairline/redraw.py index a32f155..e367380 100644 --- a/hairline/redraw.py +++ b/hairline/redraw.py @@ -16,7 +16,7 @@ from . import comfyui logger = logging.getLogger("hair.worker") -_DEFAULT_PROMPT = "填充遮罩区域的头发,皮肤加一点磨皮" +_DEFAULT_PROMPT = "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" _REPO = os.path.dirname(os.path.dirname(__file__)) _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, - 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。 Args: image_bytes: 人物图片字节(JPG/PNG) mask_bytes: 遮罩图片字节(支持红/白/alpha 遮罩格式) - prompt: 提示词,None 用默认 "填充遮罩区域的头发,皮肤加一点磨皮" + prompt: 提示词,None 用默认 "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" timeout: ComfyUI 超时秒数 + front: True 时任务插到 ComfyUI 队列最前(接口2 时延敏感路径用) Returns: 重绘后的 PNG 图片字节 @@ -73,4 +75,4 @@ def run_redraw(image_bytes: bytes, mask_bytes: bytes, """ rgba_png = _process_mask_to_rgba(image_bytes, mask_bytes) return comfyui.run(rgba_png, timeout=timeout, prompt=prompt, - workflow_path=_REPAINT_WORKFLOW) + workflow_path=_REPAINT_WORKFLOW, front=front) diff --git a/hairline/service.py b/hairline/service.py index 548f292..6af259a 100644 --- a/hairline/service.py +++ b/hairline/service.py @@ -47,7 +47,8 @@ def _call_local_redraw(image_png_bytes, mask_png_bytes, timeout=300.0): 失败抛异常(调用方负责 try/except 跳过)。 """ 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 各自独立文件夹。 _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] buf = io.BytesIO() compose_comfy_rgba(image_bgr, np.zeros((h, w), np.uint8)).save(buf, format="PNG") - shared_grown = comfyui.run(buf.getvalue(), prompt=prompt, workflow_path=workflow_path) + # front=True:接口2 时延敏感,插到 ComfyUI 队列最前 + shared_grown = comfyui.run(buf.getvalue(), prompt=prompt, + workflow_path=workflow_path, front=True) except Exception as e: # noqa: BLE001 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) buf = io.BytesIO() 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 单张失败不拖垮整请求 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, 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)。 入参同接口2:先选 gender,再多选 hair_styles(必填,1-indexed 按贴图排序)。 每个选中发型返回三档叠图(middle/high/low,RGBA 透明层只含发际线曲线)与一张生发图; 三档贴图同名,生发黑模板固定取自 hairline_texture_black/(middle),故生发目标固定 middle 档。 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}], "best_centers":{"middle":(x,y),"high":(x,y),"low":(x,y)}};无人脸 None。 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} # use_mask=False:干净原图+空遮罩与贴图无关,只跑一次 ComfyUI,选中项复用 + # generate_grow_image=False:完全跳过生发(最耗时),grown_png 恒为 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) def _center_of(overlay): @@ -366,9 +375,13 @@ def generate_hairline_pngs(image_bgr: np.ndarray, gender: str, for lv in _TEXTURE_DIRS: white = load_texture_rgba(tex_by_level[lv][s - 1][1]) overlays[lv] = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white) - # 生发:固定 middle 黑模板 - grown_png = shared_grown if not use_mask else \ - _grow_from_texture(image_bgr, ctx, mid_path, use_mask=True, prompt=prompt) + # 生发:固定 middle 黑模板(generate_grow_image=False 时跳过,恒 None) + if not generate_grow_image: + 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, "overlays": overlays, "grown_png": grown_png}) # best_centers:首个选中发型三档(middle/high/low)发际线中点 diff --git a/local_test/app.py b/local_test/app.py index aec326e..f7f1079 100644 --- a/local_test/app.py +++ b/local_test/app.py @@ -206,7 +206,7 @@ def generate(): return jsonify({"error": msg}), 400 image_file = request.files["image"] mask_file = request.files["mask"] - prompt_text = request.form.get("prompt", "填充遮罩区域的头发,皮肤加一点磨皮") + prompt_text = request.form.get("prompt", "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜") log.info( "收到请求: image=%s mask=%s prompt=%r", image_file.filename, mask_file.filename, prompt_text, diff --git a/local_test/bench.py b/local_test/bench.py index 0526ec3..db82cf1 100644 --- a/local_test/bench.py +++ b/local_test/bench.py @@ -27,7 +27,7 @@ def prep_and_upload(): 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"]["weight_dtype"] = dtype wf["1"]["inputs"]["steps"] = steps diff --git a/local_test/bench2.py b/local_test/bench2.py index aa72174..379ae88 100644 --- a/local_test/bench2.py +++ b/local_test/bench2.py @@ -33,7 +33,7 @@ def upload(scale=1.0): def run(fname, steps): - wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮") + wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜") wf["16"]["inputs"]["unet_name"] = MODEL wf["16"]["inputs"]["weight_dtype"] = DTYPE wf["1"]["inputs"]["steps"] = steps diff --git a/local_test/bench_quality.py b/local_test/bench_quality.py index 953b0d9..70af6f7 100644 --- a/local_test/bench_quality.py +++ b/local_test/bench_quality.py @@ -30,7 +30,7 @@ SEED = 123456789 imgs = [] labels = [] 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"]["weight_dtype"] = DTYPE wf["1"]["inputs"]["steps"] = steps diff --git a/local_test/index.html b/local_test/index.html index 61c5504..51d0382 100644 --- a/local_test/index.html +++ b/local_test/index.html @@ -55,7 +55,7 @@ button { padding: 10px 24px; border: none; border-radius: 6px; cursor: pointer;
    - +
    diff --git a/local_test/test_api.py b/local_test/test_api.py index 0bdc520..a84574c 100644 --- a/local_test/test_api.py +++ b/local_test/test_api.py @@ -25,7 +25,7 @@ resp = requests.post( "image": ("original.jpg", img_data, "image/jpeg"), "mask": ("mask.png", mask_data, "image/png"), }, - data={"prompt": "填充遮罩区域的头发,皮肤加一点磨皮"}, + data={"prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"}, timeout=600, ) diff --git a/static/api_timing_dashboard.html b/static/api_timing_dashboard.html new file mode 100644 index 0000000..e71b743 --- /dev/null +++ b/static/api_timing_dashboard.html @@ -0,0 +1,231 @@ + + + + + +API 耗时看板 + + + +
    +

    ⏱️ API 耗时看板

    +

    按天统计各接口调用次数与耗时分布,多发型接口额外拆解「单次换发型耗时」与「整体耗时」

    + + +
    + + + + +
    + +
    + + +
    + + + + diff --git a/static/integration.html b/static/integration.html index 1fedc39..52c346d 100644 --- a/static/integration.html +++ b/static/integration.html @@ -57,7 +57,6 @@ 接口4 接口5 接口6 - 接口7 错误码 在线测试
    @@ -109,6 +108,8 @@ landmarksobject5 个关键点像素坐标:hair_top/hairline/brow_center/nose_bottom/chin_tip hairline_sourcestring发际线来源:"segmentation"(真实分割,可信度高)/ "estimated"(比例估算,可信度低) head_poseobject头部姿态角度:{ yaw, pitch, roll }(度),接近 0 表示正面照 + left_positionobjectMediaPipe 21 号关键点坐标(左脸定位点),原图像素:{ x: number, y: number } + right_positionobjectMediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:{ x: number, y: number }

    💡 前端把标注图叠加到原图上即可呈现测量效果(标注图白色线条 #FFFFFF,透明底)。

    @@ -189,17 +190,17 @@ const { code, data } = await res.json();

    4. 用户特征分析  POST  /api/v1/face/features

    -

    上传照片 → 火山方舟豆包视觉模型分析 → 返回几十项面部特征(脸型/眉形/肤色/四季色彩…)。

    +

    上传照片 → 火山方舟豆包视觉模型分析 → 返回固定 6 项面部特征(脸型/眉形/面部年龄/动静类型/性别/基因风格)。

    入参:image_file / image_url / image_base64 三选一。无其他参数。

    data 字段

    - +
    字段类型说明
    featuresstringJSON 字符串(不是对象!客户端需 JSON.parse()
    featuresstringJSON 字符串(不是对象!客户端需 JSON.parse())。解析后得到固定 6 个英文字段
    -

    features 英文优先字段(其余中文字段同时返回,共~42个):

    +

    features 字段(固定返回 6 个):

    @@ -214,7 +215,7 @@ const { code, data } = await res.json(); const { code, data } = await res.json(); const features = JSON.parse(data.features); // ← 注意:data.features 是字符串! console.log(features.face_shape); // "鹅蛋脸" -console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保留) +console.log(features.gene_style); // "自然型" @@ -230,12 +231,13 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保 +
    字段说明字段说明
    face_shape脸型eyebrow_shape眉形
    image_file / image_url / image_base64三选一用户正面照
    genderstring✅ 必填"male" / "female"
    hair_stylestring✅ 必填发型序号,逗号分隔多选(如 1,2,3)。缺失/越界返回 1007
    generate_grow_imagebool是否生成生发效果图(ComfyUI 生发,全流程最耗时),默认 true。传 false 时跳过生发,各发型 grown_image_url 恒为 null,仅返回三档发际线叠图与中心点,大幅降低耗时

    data 字段

    - + @@ -251,6 +253,8 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保 + +
    字段类型说明
    hairline_images[]object[]选中发型列表,每项含 hairline_typeimage_middle_url/image_high_url/image_low_url 三档透明 PNG 叠图(仅曲线,需叠加原图)、grown_image_url 生发图(完整人像,失败为 null)、order
    hairline_images[]object[]选中发型列表,每项含 hairline_typeimage_middle_url/image_high_url/image_low_url 三档透明 PNG 叠图(仅曲线,需叠加原图)、grown_image_url 生发图(完整人像,失败或 generate_grow_image=false 时为 null)、order
    best_hairline_center_pointobject \| null首个选中发型 middle 档发际线中心点像素坐标 { x: number, y: number }
    high_hairline_center_pointobject \| null同上,high 档发际线中点(发际线偏高,y 更小)
    low_hairline_center_pointobject \| null同上,low 档发际线中点(发际线偏低,y 更大)
    landmarksobject5 个关键点像素坐标:hair_top/hairline/brow_center/nose_bottom/chin_tip
    hairline_sourcestring发际线来源:"segmentation"(真实分割)/ "estimated"(比例估算)
    head_poseobject头部姿态角度:{ yaw, pitch, roll }(度)
    left_positionobjectMediaPipe 21 号关键点坐标(左脸定位点),原图像素:{ x: number, y: number }
    right_positionobjectMediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:{ x: number, y: number }

    💡 前端无需额外请求接口1 即可拿到四庭七眼测量数值;face_measurenull 时(角度过大/无人脸等)仅隐藏测量区块,发际线结果照常展示。

    @@ -282,41 +286,12 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保 four_courtsobject三庭:upper/middle/lower,各含 _cm 和 ratios(无 top_courtseven_eyesobject七眼:eye_width_cm/face_width_cm/inter_eye_distance_cm + ratios + eye2~eye6(左脸颊/左眼/两眼间距/右眼/右脸颊,5 段宽度 cm;无 eye1/eye7landmarksobject4 个关键点:hairline/brow_center/nose_bottom/chin_tip(无 hair_top) + left_positionobjectMediaPipe 21 号关键点坐标(左脸定位点),原图像素:{ x: number, y: number } + right_positionobjectMediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:{ x: number, y: number }
    - -
    -

    7. C端生发 v2  POST  /api/v1/hair/grow-v2  v2

    -
    -

    功能与接口2完全一致,仅 ComfyUI 工作流不同——使用 add_hair2.json 替代 add_hair.json(Flux-2 Klein 9b)。

    - -

    入参

    - - - - - -
    参数类型必填说明
    image_file / image_url / image_base64三选一用户正面照
    genderstring✅ 必填"male" / "female"
    hair_styleint✅ 必填发型序号。female: 1~5,male: 1~4
    - -

    data.results[] 元素(同接口2)

    - - - - - - -
    字段类型说明
    image_urlstring发际线曲线透明 PNG(仅曲线,需叠加原图显示,同接口2)
    grown_image_urlstring生发后效果图(完整人像)⚠ 可空
    hairline_typestring发际线类型 key
    orderint排序
    - -

    - Female 5 种:ellipse/flower/heart/straight/wave  |  - Male 4 种:ellipse/m/straight/inverse_arc
    - ⚠ 工作流: add_hair2.json(Flux-2 Klein 9b),输入节点 26,输出节点 75。 -

    -
    -
    -

    ⚠ 错误码

    @@ -326,12 +301,14 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保 1001无法识别人像未检测到人脸 1003角度问题,非正面照非正面 / 角度过大 + 1004gender 必填且只能为 male / female接口2/5 的 gender 缺失或非法 1005检测到多张人脸仅支持单人 1007图片参数错误 / 后端不可用参数传错 / 服务繁忙请稍后重试 1008图片格式不支持非 JPG/PNG / base64 解码失败 + 1009未授权缺少或错误的 X-Internal-Token/api/* 路径鉴权) -

    1004 已废弃(接口2 不再自动判性别,改由客户端传 gender 参数)。

    +

    注:1004 仍在使用(接口2/5 的 gender 校验);接口7(grow-v2)已弃用,请改用接口2。

    @@ -344,10 +321,9 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保 1. 四庭七眼/static/test_interface1.html上传照片 → 原图+标注叠加,底图/标注开关,指标卡片 2. C端生发/static/test_interface2.html上传+性别 → 方案一覧(原图/叠加/生发),双图对比 3. B端生发/static/test_interface3.html划线图上传 → 生发效果图 - 4. 用户特征/static/test_interface4.html上传照片 → 42项面部特征表格 + 原始JSON + 4. 用户特征/static/test_interface4.html上传照片 → 6项面部特征 + 原始JSON 5. 发际线PNG/static/test_interface5.html上传+性别 → 发际线方案+中心点坐标 6. 四庭七眼 v2/static/test_interface6.html同接口1,去顶庭 · 竖线发际线→下巴 · 无头部端线 - 7. C端生发 v2/static/test_interface7.html同接口2,使用 add_hair2.json 工作流(Flux-2 Klein 9b)

    完整 API 文档:/docs(Swagger UI) diff --git a/static/test_interface12.html b/static/test_interface12.html index e60f739..d4602d0 100644 --- a/static/test_interface12.html +++ b/static/test_interface12.html @@ -69,7 +69,7 @@

    - +
    @@ -354,7 +354,7 @@ function dataUriToBlob(dataUri) { async function runLocalRedraw() { if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; } - const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,皮肤加一点磨皮'; + const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜'; const btn = $('redrawBtn'); btn.disabled = true; btn.textContent = '⏳ 重绘中...'; flog('===== 调后端重绘 =====', 'info'); diff --git a/static/test_interface12_final.html b/static/test_interface12_final.html index 8dc0fa5..63512ca 100644 --- a/static/test_interface12_final.html +++ b/static/test_interface12_final.html @@ -56,7 +56,7 @@
    - +
    @@ -169,7 +169,7 @@ async function submitTest() { async function runLocalRedraw() { if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; } - const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,皮肤加一点磨皮'; + const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜'; const btn = $('redrawBtn'); btn.disabled = true; btn.textContent = '⏳ 重绘中...'; diff --git a/static/test_interface2.html b/static/test_interface2.html index 35cad4c..c3c9d37 100644 --- a/static/test_interface2.html +++ b/static/test_interface2.html @@ -107,7 +107,7 @@
    JPG/PNG  |  生发图生成较慢(数十秒~数分钟),请耐心等待
    - +
    diff --git a/static/test_interface3.html b/static/test_interface3.html index a160604..9eb79bd 100644 --- a/static/test_interface3.html +++ b/static/test_interface3.html @@ -94,7 +94,7 @@
    💬 提示词(prompt)
    - +
    diff --git a/static/test_interface5.html b/static/test_interface5.html index acf6dff..107559c 100644 --- a/static/test_interface5.html +++ b/static/test_interface5.html @@ -113,6 +113,12 @@
    +
    + + +
    @@ -206,7 +212,7 @@ async function submitTest() { $('submitBtn').disabled = true; $('submitBtn').textContent = '⏳ ...'; 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(); try { const r = await fetch(API_BASE + '/api/v1/hairline/generate', { method:'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd }); diff --git a/static/test_interface7.html b/static/test_interface7.html index 3f25061..f4f330c 100644 --- a/static/test_interface7.html +++ b/static/test_interface7.html @@ -104,7 +104,7 @@
    JPG/PNG  |  生发图生成较慢(数十秒~数分钟),请耐心等待  |  工作流: add_hair2.json
    - +
    diff --git a/tests/test_gateway_serialization.py b/tests/test_gateway_serialization.py new file mode 100644 index 0000000..df09a8a --- /dev/null +++ b/tests/test_gateway_serialization.py @@ -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 diff --git a/tests/test_pose.py b/tests/test_pose.py index 11b5695..663ac9e 100644 --- a/tests/test_pose.py +++ b/tests/test_pose.py @@ -62,3 +62,41 @@ def test_threshold_gating_rejects_when_zeroed(): def test_pose_none_is_not_blocked(): """solvePnP 失败(返回 None)时不拦截,check_frontal_face 返回 True。""" 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 diff --git a/tests/test_reqlog.py b/tests/test_reqlog.py new file mode 100644 index 0000000..12c5436 --- /dev/null +++ b/tests/test_reqlog.py @@ -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