Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d23414d21 | ||
|
|
cb1989c042 | ||
|
|
28a6062fea |
@@ -24,3 +24,6 @@ hairline/models/face-parsing/model.safetensors
|
|||||||
static/annotations/*
|
static/annotations/*
|
||||||
!static/annotations/.gitkeep
|
!static/annotations/.gitkeep
|
||||||
tests/output/
|
tests/output/
|
||||||
|
|
||||||
|
# 本地临时遮罩测试页(不入 git)
|
||||||
|
test_local.py
|
||||||
|
|||||||
+714
@@ -0,0 +1,714 @@
|
|||||||
|
{
|
||||||
|
"1": {
|
||||||
|
"inputs": {
|
||||||
|
"scheduler": "simple",
|
||||||
|
"steps": 6,
|
||||||
|
"denoise": 0.9,
|
||||||
|
"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": [
|
||||||
|
"64",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "ReferenceLatent",
|
||||||
|
"_meta": {
|
||||||
|
"title": "参考Latent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"6": {
|
||||||
|
"inputs": {
|
||||||
|
"noise_seed": 176834207514884
|
||||||
|
},
|
||||||
|
"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": [
|
||||||
|
"64",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "SamplerCustomAdvanced",
|
||||||
|
"_meta": {
|
||||||
|
"title": "自定义采样器(高级)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"10": {
|
||||||
|
"inputs": {
|
||||||
|
"samples": [
|
||||||
|
"9",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"vae": [
|
||||||
|
"3",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "VAEDecode",
|
||||||
|
"_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加载器"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"19": {
|
||||||
|
"inputs": {
|
||||||
|
"guidance": 1.5,
|
||||||
|
"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-1781933242779.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": "bicubic",
|
||||||
|
"round_to_multiple": "8",
|
||||||
|
"scale_to_side": "None",
|
||||||
|
"scale_to_length": 1024,
|
||||||
|
"background_color": "#000000",
|
||||||
|
"image": [
|
||||||
|
"26",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"mask": [
|
||||||
|
"89",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "LayerUtility: ImageScaleByAspectRatio V2",
|
||||||
|
"_meta": {
|
||||||
|
"title": "图层工具:按宽高比缩放 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_plnpv_00023_.png&type=temp&subfolder=&rand=0.8949967564686747"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "B",
|
||||||
|
"selected": true,
|
||||||
|
"url": "/api/view?filename=rgthree.compare._temp_plnpv_00024_.png&type=temp&subfolder=&rand=0.6013833933740399"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"image_a": [
|
||||||
|
"73",
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"64": {
|
||||||
|
"inputs": {
|
||||||
|
"grow_mask_by": 3,
|
||||||
|
"vae": [
|
||||||
|
"3",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"images": [
|
||||||
|
"44",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"masks": [
|
||||||
|
"32",
|
||||||
|
1
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "VAEEncodeForInpaint_Frames",
|
||||||
|
"_meta": {
|
||||||
|
"title": "VAE Encode For Inpaint Frames ♾️Mixlab"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"68": {
|
||||||
|
"inputs": {
|
||||||
|
"sampler_name": "euler"
|
||||||
|
},
|
||||||
|
"class_type": "KSamplerSelect",
|
||||||
|
"_meta": {
|
||||||
|
"title": "K采样器选择"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"69": {
|
||||||
|
"inputs": {
|
||||||
|
"vae_name": "flux2-vae.safetensors"
|
||||||
|
},
|
||||||
|
"class_type": "VAELoader",
|
||||||
|
"_meta": {
|
||||||
|
"title": "加载VAE"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"70": {
|
||||||
|
"inputs": {
|
||||||
|
"cfg": 1,
|
||||||
|
"model": [
|
||||||
|
"86",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"positive": [
|
||||||
|
"85",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"negative": [
|
||||||
|
"80",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "CFGGuider",
|
||||||
|
"_meta": {
|
||||||
|
"title": "CFG引导器"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"71": {
|
||||||
|
"inputs": {
|
||||||
|
"noise_seed": 1105558556688843
|
||||||
|
},
|
||||||
|
"class_type": "RandomNoise",
|
||||||
|
"_meta": {
|
||||||
|
"title": "随机噪波"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"72": {
|
||||||
|
"inputs": {
|
||||||
|
"noise": [
|
||||||
|
"71",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"guider": [
|
||||||
|
"70",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"sampler": [
|
||||||
|
"68",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"sigmas": [
|
||||||
|
"77",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"latent_image": [
|
||||||
|
"81",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "SamplerCustomAdvanced",
|
||||||
|
"_meta": {
|
||||||
|
"title": "自定义采样器(高级)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"73": {
|
||||||
|
"inputs": {
|
||||||
|
"samples": [
|
||||||
|
"72",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"vae": [
|
||||||
|
"69",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "VAEDecode",
|
||||||
|
"_meta": {
|
||||||
|
"title": "VAE解码"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"75": {
|
||||||
|
"inputs": {
|
||||||
|
"filename_prefix": "ComfyUI",
|
||||||
|
"images": [
|
||||||
|
"73",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "SaveImage",
|
||||||
|
"_meta": {
|
||||||
|
"title": "保存图像"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"76": {
|
||||||
|
"inputs": {
|
||||||
|
"rgthree_comparer": {
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"name": "A",
|
||||||
|
"selected": true,
|
||||||
|
"url": "/api/view?filename=rgthree.compare._temp_ssjll_00009_.png&type=temp&subfolder=&rand=0.8998219800410978"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "B",
|
||||||
|
"selected": true,
|
||||||
|
"url": "/api/view?filename=rgthree.compare._temp_ssjll_00010_.png&type=temp&subfolder=&rand=0.2100153657131264"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"image_a": [
|
||||||
|
"73",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"image_b": [
|
||||||
|
"26",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "Image Comparer (rgthree)",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Image Comparer (rgthree)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"77": {
|
||||||
|
"inputs": {
|
||||||
|
"steps": 6,
|
||||||
|
"width": [
|
||||||
|
"83",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"height": [
|
||||||
|
"83",
|
||||||
|
1
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "Flux2Scheduler",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Flux2调度器"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"78": {
|
||||||
|
"inputs": {
|
||||||
|
"clip_name": "qwen_3_8b_fp8mixed.safetensors",
|
||||||
|
"type": "flux2",
|
||||||
|
"device": "default"
|
||||||
|
},
|
||||||
|
"class_type": "CLIPLoader",
|
||||||
|
"_meta": {
|
||||||
|
"title": "加载CLIP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"79": {
|
||||||
|
"inputs": {
|
||||||
|
"conditioning": [
|
||||||
|
"87",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "ConditioningZeroOut",
|
||||||
|
"_meta": {
|
||||||
|
"title": "条件零化"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"80": {
|
||||||
|
"inputs": {
|
||||||
|
"conditioning": [
|
||||||
|
"79",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"latent": [
|
||||||
|
"81",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "ReferenceLatent",
|
||||||
|
"_meta": {
|
||||||
|
"title": "参考Latent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"81": {
|
||||||
|
"inputs": {
|
||||||
|
"pixels": [
|
||||||
|
"10",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"vae": [
|
||||||
|
"69",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "VAEEncode",
|
||||||
|
"_meta": {
|
||||||
|
"title": "VAE编码"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"82": {
|
||||||
|
"inputs": {
|
||||||
|
"width": [
|
||||||
|
"83",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"height": [
|
||||||
|
"83",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"batch_size": 1
|
||||||
|
},
|
||||||
|
"class_type": "EmptyFlux2LatentImage",
|
||||||
|
"_meta": {
|
||||||
|
"title": "空Latent图像(Flux2)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"83": {
|
||||||
|
"inputs": {
|
||||||
|
"image": [
|
||||||
|
"10",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "GetImageSize",
|
||||||
|
"_meta": {
|
||||||
|
"title": "获取图像尺寸"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"85": {
|
||||||
|
"inputs": {
|
||||||
|
"conditioning": [
|
||||||
|
"87",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"latent": [
|
||||||
|
"81",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "ReferenceLatent",
|
||||||
|
"_meta": {
|
||||||
|
"title": "参考Latent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"86": {
|
||||||
|
"inputs": {
|
||||||
|
"unet_name": "flux2.0/flux-2-klein-9b-fp8.safetensors",
|
||||||
|
"weight_dtype": "default"
|
||||||
|
},
|
||||||
|
"class_type": "UNETLoader",
|
||||||
|
"_meta": {
|
||||||
|
"title": "UNet加载器"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"87": {
|
||||||
|
"inputs": {
|
||||||
|
"text": "去掉头发接缝的黄色痕迹,头发完美融合,保持发型不变,发色不变。其他不变。",
|
||||||
|
"clip": [
|
||||||
|
"78",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "CLIPTextEncode",
|
||||||
|
"_meta": {
|
||||||
|
"title": "CLIP文本编码"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"89": {
|
||||||
|
"inputs": {
|
||||||
|
"left": 5,
|
||||||
|
"top": 15,
|
||||||
|
"right": 5,
|
||||||
|
"bottom": 15,
|
||||||
|
"mask": [
|
||||||
|
"37",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "FeatherMask",
|
||||||
|
"_meta": {
|
||||||
|
"title": "羽化遮罩"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -446,8 +446,7 @@ async def face_measure(
|
|||||||
summary="接口2 C端生发",
|
summary="接口2 C端生发",
|
||||||
tags=["生发"],
|
tags=["生发"],
|
||||||
description=f"""
|
description=f"""
|
||||||
输入用户正面照 + **性别**,返回该性别对应的多张「建议发际线预览图」(本期为
|
输入用户正面照 + **性别** + **发型序号**,返回指定发际线类型的预览图与生发图。每个方案包含:
|
||||||
**发际线曲线叠加在原照片上的预览图**,非最终文生图生发图)。每个方案包含:
|
|
||||||
- 预览图(worker 返回 `image_base64`,网关落盘后改写为 `image_url`)
|
- 预览图(worker 返回 `image_base64`,网关落盘后改写为 `image_url`)
|
||||||
- 发际线类型 `hairline_type`(英文 key)
|
- 发际线类型 `hairline_type`(英文 key)
|
||||||
- 顺序 `order`(本期固定 `1..N`,不排序)
|
- 顺序 `order`(本期固定 `1..N`,不排序)
|
||||||
@@ -460,6 +459,8 @@ async def face_measure(
|
|||||||
|
|
||||||
- **gender**(必填):`male` / `female`。决定返回的贴图集合(female 5 张 / male 4 张)。
|
- **gender**(必填):`male` / `female`。决定返回的贴图集合(female 5 张 / male 4 张)。
|
||||||
非法或缺失返回 `1004`。
|
非法或缺失返回 `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**:本期保留但不生效。
|
- **beauty_enabled**:本期保留但不生效。
|
||||||
|
|
||||||
`hairline_type` 取值:`ellipse` / `flower` / `heart` / `straight` / `wave`(female),
|
`hairline_type` 取值:`ellipse` / `flower` / `heart` / `straight` / `wave`(female),
|
||||||
@@ -477,7 +478,6 @@ async def face_measure(
|
|||||||
"data": {
|
"data": {
|
||||||
"results": [
|
"results": [
|
||||||
{"image_base64": "iVBORw0KGgo...", "hairline_type": "ellipse", "order": 1},
|
{"image_base64": "iVBORw0KGgo...", "hairline_type": "ellipse", "order": 1},
|
||||||
{"image_base64": "iVBORw0KGgo...", "hairline_type": "flower", "order": 2},
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -502,6 +502,7 @@ async def hair_grow(
|
|||||||
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
||||||
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
||||||
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
|
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
|
||||||
|
hair_style: Optional[int] = Form(default=None, description="发型序号(必填)。female:1-5 male:1-4"),
|
||||||
beauty_enabled: bool = Form(default=False, description="是否开启美颜(本期不生效)"),
|
beauty_enabled: bool = Form(default=False, description="是否开启美颜(本期不生效)"),
|
||||||
use_mask: bool = Form(default=True, description="是否启用 inpaint 遮罩(测试对比用)。false 时用干净原图生成(空遮罩,不烧模板线)"),
|
use_mask: bool = Form(default=True, description="是否启用 inpaint 遮罩(测试对比用)。false 时用干净原图生成(空遮罩,不烧模板线)"),
|
||||||
prompt: str = Form(default="补充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
prompt: str = Form(default="补充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||||
@@ -510,7 +511,12 @@ async def hair_grow(
|
|||||||
if gender not in ("male", "female"):
|
if gender not in ("male", "female"):
|
||||||
return err(1004, "gender 必填且只能为 male / female")
|
return err(1004, "gender 必填且只能为 male / female")
|
||||||
|
|
||||||
# 2. 三选一取图
|
# 2. hair_style 必填校验(越界 → 1007)
|
||||||
|
max_styles = {"female": 5, "male": 4}[gender]
|
||||||
|
if hair_style is None or not isinstance(hair_style, int) or hair_style < 1 or hair_style > max_styles:
|
||||||
|
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)
|
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
|
||||||
if e is not None:
|
if e is not None:
|
||||||
return e
|
return e
|
||||||
@@ -531,7 +537,7 @@ async def hair_grow(
|
|||||||
from hairline.service import generate_grow_results
|
from hairline.service import generate_grow_results
|
||||||
|
|
||||||
# 预览 + 生发(ComfyUI) 都是阻塞且较慢,放线程池避免卡住事件循环
|
# 预览 + 生发(ComfyUI) 都是阻塞且较慢,放线程池避免卡住事件循环
|
||||||
items = await run_in_threadpool(generate_grow_results, image, gender, use_mask, prompt)
|
items = await run_in_threadpool(generate_grow_results, image, gender, use_mask, prompt, hair_style)
|
||||||
if items is None:
|
if items is None:
|
||||||
return err(1001, "无法识别人像")
|
return err(1001, "无法识别人像")
|
||||||
|
|
||||||
@@ -550,6 +556,126 @@ async def hair_grow(
|
|||||||
return err(1007, f"处理失败:{ex}")
|
return err(1007, f"处理失败:{ex}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 接口 7:C 端生发 v2(add_hair2.json 工作流)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_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,≤ 1 MB)"),
|
||||||
|
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[int] = Form(default=None, description="发型序号(必填)。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]
|
||||||
|
if hair_style is None or not isinstance(hair_style, int) or hair_style < 1 or hair_style > max_styles:
|
||||||
|
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
|
||||||
|
if len(raw) > MAX_FILE_BYTES:
|
||||||
|
return err(1006, "文件超出 1 MB 限制")
|
||||||
|
|
||||||
|
image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
|
||||||
|
if image is None:
|
||||||
|
return err(1008, "图片格式不支持(仅 JPG / PNG)")
|
||||||
|
|
||||||
|
h, w = image.shape[:2]
|
||||||
|
short_side, long_side = min(w, h), max(w, h)
|
||||||
|
if short_side < MIN_SHORT_SIDE or long_side < MIN_LONG_SIDE:
|
||||||
|
return err(1002, "人像分辨率过低")
|
||||||
|
|
||||||
|
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_style, _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}")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 接口 3:B 端生发
|
# 接口 3:B 端生发
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -653,13 +779,10 @@ async def hair_grow_b(
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
由**火山方舟 豆包视觉模型**分析,返回一大批面部特征(脸型/眉形/眼型/肤色/三庭五眼/四季色彩季型/
|
由**火山方舟 豆包视觉模型**分析,返回**固定 6 个英文字段**。
|
||||||
量感/轮廓/基因风格/性别…几十项),**英文优先字段 + doubao 中文字段并存**。
|
|
||||||
|
|
||||||
**返回格式**:`data.features` 为一个 **JSON 字符串**(不是对象),需要在客户端 `JSON.parse()` 后使用。
|
**返回格式**:`data.features` 为一个 **JSON 字符串**(不是对象),需要在客户端 `JSON.parse()` 后使用。
|
||||||
|
|
||||||
英文优先字段(其余中文字段同时返回):
|
|
||||||
|
|
||||||
| 字段 | 说明 |
|
| 字段 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| face_shape | 脸形(如"鹅蛋脸") |
|
| face_shape | 脸形(如"鹅蛋脸") |
|
||||||
@@ -669,7 +792,7 @@ async def hair_grow_b(
|
|||||||
| gender | 性别("男"/"女") |
|
| gender | 性别("男"/"女") |
|
||||||
| gene_style | 基因风格(如"自然型") |
|
| gene_style | 基因风格(如"自然型") |
|
||||||
|
|
||||||
> 字段不固定、可随时增删,客户端按需取用。无人脸返回 `1001`。
|
> 无人脸返回 `1001`。
|
||||||
""",
|
""",
|
||||||
responses={
|
responses={
|
||||||
200: {
|
200: {
|
||||||
@@ -681,7 +804,7 @@ async def hair_grow_b(
|
|||||||
"message": "success",
|
"message": "success",
|
||||||
"request_id": "mock-request-id",
|
"request_id": "mock-request-id",
|
||||||
"data": {
|
"data": {
|
||||||
"features": '{"图片是否有人脸":"有人","脸型":"鹅蛋脸","眉形":"平眉","面部年龄":"18-25岁","四季色彩季型":"冷夏型","基因风格":"少年型","性别":"女","face_shape":"鹅蛋脸","eyebrow_shape":"平眉","facial_age":"18-25岁","dynamic_static_type":"静态型","gender":"女","gene_style":"少年型"}',
|
"features": '{"face_shape":"鹅蛋脸","eyebrow_shape":"平眉","facial_age":"18-25岁","dynamic_static_type":"静态型","gender":"女","gene_style":"少年型"}',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -706,7 +829,6 @@ async def face_features(
|
|||||||
# 网关不会把本接口转发到 worker,故此处仅留 Mock 占位、保持 worker 无外网依赖。
|
# 网关不会把本接口转发到 worker,故此处仅留 Mock 占位、保持 worker 无外网依赖。
|
||||||
features = json.dumps(
|
features = json.dumps(
|
||||||
{
|
{
|
||||||
"_note": "接口4 由网关实现,worker 此响应为占位 Mock",
|
|
||||||
"face_shape": "鹅蛋脸", "eyebrow_shape": "平眉", "facial_age": "18-25岁",
|
"face_shape": "鹅蛋脸", "eyebrow_shape": "平眉", "facial_age": "18-25岁",
|
||||||
"dynamic_static_type": "静态型", "gender": "女", "gene_style": "少年型",
|
"dynamic_static_type": "静态型", "gender": "女", "gene_style": "少年型",
|
||||||
},
|
},
|
||||||
|
|||||||
+13
-4
@@ -13,7 +13,7 @@
|
|||||||
```
|
```
|
||||||
客户端 ──HTTPS──> 外网网关(gateway/) ──HTTP(X-Internal-Token)──> worker(GPU 机, app.py)
|
客户端 ──HTTPS──> 外网网关(gateway/) ──HTTP(X-Internal-Token)──> worker(GPU 机, app.py)
|
||||||
│ 薄代理 + 落盘改URL │ 跑算法(本地模型/ComfyUI)
|
│ 薄代理 + 落盘改URL │ 跑算法(本地模型/ComfyUI)
|
||||||
└ 接口4 本机直接调豆包(不转发) └ 接口1/2/3/5
|
└ 接口4 本机直接调豆包(不转发) └ 接口1/2/3/5/7
|
||||||
```
|
```
|
||||||
|
|
||||||
- **worker**(`app.py` + `face_analysis/` + `hairline/`):跑真正的算法,**纯本地、无外网依赖**。
|
- **worker**(`app.py` + `face_analysis/` + `hairline/`):跑真正的算法,**纯本地、无外网依赖**。
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
| 2 | `results[].image_base64` / `results[].grown_image_base64`(可空) | `results[].image_url` / `results[].grown_image_url` |
|
| 2 | `results[].image_base64` / `results[].grown_image_base64`(可空) | `results[].image_url` / `results[].grown_image_url` |
|
||||||
| 3 | `hair_growth_image_base64`(可空) | `hair_growth_image_url` |
|
| 3 | `hair_growth_image_base64`(可空) | `hair_growth_image_url` |
|
||||||
| 5 | `hairline_images[].image_base64` | `hairline_images[].image_url` |
|
| 5 | `hairline_images[].image_base64` | `hairline_images[].image_url` |
|
||||||
|
| 7 | `results[].image_base64` / `results[].grown_image_base64`(可空) | `results[].image_url` / `results[].grown_image_url` |
|
||||||
| 4 | (网关本机产出,无图片字段,`features` 为 JSON 字符串) | — |
|
| 4 | (网关本机产出,无图片字段,`features` 为 JSON 字符串) | — |
|
||||||
|
|
||||||
> 实现建议:递归遍历 data,凡 key 以 `_base64` 结尾就落盘改 `_url`,自动覆盖嵌套/新增字段。
|
> 实现建议:递归遍历 data,凡 key 以 `_base64` 结尾就落盘改 `_url`,自动覆盖嵌套/新增字段。
|
||||||
@@ -40,7 +41,7 @@
|
|||||||
|
|
||||||
### 错误码
|
### 错误码
|
||||||
|
|
||||||
`1001` 无法识别人像 | `1002` 分辨率过低 | `1003` 非正面 | `1004` gender 必填/非法(接口2/5)|
|
`1001` 无法识别人像 | `1002` 分辨率过低 | `1003` 非正面 | `1004` gender 必填/非法(接口2/5/7)|
|
||||||
`1006` >1MB | `1007` 图片参数错误(0或多个)/未预期异常 | `1008` 格式不支持。
|
`1006` >1MB | `1007` 图片参数错误(0或多个)/未预期异常 | `1008` 格式不支持。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -55,11 +56,19 @@
|
|||||||
- 门槛可配:`MIN_SHORT_SIDE`/`MIN_LONG_SIDE`(默认600/800)、姿态阈值 `FRONTAL_*_THR`(默认30°)。
|
- 门槛可配:`MIN_SHORT_SIDE`/`MIN_LONG_SIDE`(默认600/800)、姿态阈值 `FRONTAL_*_THR`(默认30°)。
|
||||||
|
|
||||||
### 接口2 C端生发 `/api/v1/hair/grow`(worker)—— 预览 + 生发图
|
### 接口2 C端生发 `/api/v1/hair/grow`(worker)—— 预览 + 生发图
|
||||||
- **做什么**:正面照 + `gender`(必填) → 该性别全部发际线(female5/male4) 各一组:**预览图**(发际线叠在照片上) + **生发后图**(植发3个月效果)。
|
- **做什么**:正面照 + `gender`(必填) + `hair_style`(必填,int) → 指定发际线类型 **1 组**:**预览图**(发际线叠在照片上) + **生发后图**(植发3个月效果)。
|
||||||
- **怎么实现**(`hairline/`):移植 head3d——MediaPipe(Tasks) + SegFormer 分割 + 17 锚点射线检测 → 502 点 mesh,
|
- **怎么实现**(`hairline/`):移植 head3d——MediaPipe(Tasks) + SegFormer 分割 + 17 锚点射线检测 → 502 点 mesh,
|
||||||
按 `face_ext.obj` 的 UV 把发际线贴图渲染到额头(预览)。生发:黑贴图渲染遮罩 → 调本机 **ComfyUI 8182** 的
|
按 `face_ext.obj` 的 UV 把发际线贴图渲染到额头(预览)。生发:黑贴图渲染遮罩 → 调本机 **ComfyUI 8182** 的
|
||||||
`add_hair.json`(Flux-2) 出图。**关键坑**:obj 是重排序,需 `INDEX_MAP_468` 把 MP 序→OBJ 序。
|
`add_hair.json`(Flux-2) 出图。**关键坑**:obj 是重排序,需 `INDEX_MAP_468` 把 MP 序→OBJ 序。
|
||||||
同步、一次 N 张(~18s)。返回 `results[].image_base64` + `grown_image_base64`。
|
单张请求(~5s)。返回 `results[].image_base64` + `grown_image_base64`。
|
||||||
|
- `hair_style` 映射:female 1=ellipse 2=flower 3=heart 4=straight 5=wave;male 1=ellipse 2=inverse_arc 3=m 4=straight。
|
||||||
|
|
||||||
|
### 接口7 C端生发 v2 `/api/v1/hair/grow-v2`(worker)—— 接口2同款,add_hair2 工作流
|
||||||
|
- **做什么**:与接口 2 完全一致(正面照 + `gender` + `hair_style` → 1 组预览+生发图)。
|
||||||
|
- **与接口 2 的唯一区别**:ComfyUI 工作流使用 `add_hair2.json`(Flux-2 Klein 9b),输入/遮罩节点同为 26,
|
||||||
|
SaveImage 输出节点为 75(自动检测)。其他参数、响应结构、错误码**完全相同**。
|
||||||
|
- **网关改动**:在 `gateway/app.py` 新增路由 `POST /api/v1/hair/grow-v2`,转发到 worker 同路径即可(盲转发,
|
||||||
|
base64→URL 改写逻辑无需改动,数组内图片字段已覆盖)。详见 [`网关待改动.md`](网关待改动.md)。
|
||||||
|
|
||||||
### 接口3 B端生发 `/api/v1/hair/grow-b`(worker)—— 马克笔发际线
|
### 接口3 B端生发 `/api/v1/hair/grow-b`(worker)—— 马克笔发际线
|
||||||
- **做什么**:医生用马克笔在额头画好发际线,**只传这一张划线图** → 检测线 → 生发图。输出 `hair_growth_image_url` + `hairline_type="custom"`。
|
- **做什么**:医生用马克笔在额头画好发际线,**只传这一张划线图** → 检测线 → 生发图。输出 `hair_growth_image_url` + `hairline_type="custom"`。
|
||||||
|
|||||||
+67
-15
@@ -19,6 +19,7 @@
|
|||||||
| 3 B 端生发 | POST | `/api/v1/hair/grow-b` |
|
| 3 B 端生发 | POST | `/api/v1/hair/grow-b` |
|
||||||
| 4 用户特征 | POST | `/api/v1/face/features` |
|
| 4 用户特征 | POST | `/api/v1/face/features` |
|
||||||
| 5 发际线 PNG 生成 | POST | `/api/v1/hairline/generate` |
|
| 5 发际线 PNG 生成 | POST | `/api/v1/hairline/generate` |
|
||||||
|
| 7 C 端生发 v2 | POST | `/api/v1/hair/grow-v2` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -177,9 +178,9 @@
|
|||||||
|
|
||||||
## 接口 2:C 端生发接口
|
## 接口 2:C 端生发接口
|
||||||
|
|
||||||
**说明**:输入用户正面照 + 性别,按性别对应的发际线类型,逐张把建议发际线渲染到照片上,输出多张方案。
|
**说明**:输入用户正面照 + 性别 + 发型序号,按指定发际线类型渲染一张预览图 + 生发图。
|
||||||
|
|
||||||
> **每个方案返回两张图**:`image_url`=「原照片 + 发际线曲线叠加的**预览图**」;`grown_image_url`=
|
> **每个请求返回两张图**:`image_url`=「原照片 + 发际线曲线叠加的**预览图**」;`grown_image_url`=
|
||||||
> 经 ComfyUI/Flux 的「植发 3 个月**生发后图片**」。两者均已实现,实现简述见 [`实现说明.md`](实现说明.md)。
|
> 经 ComfyUI/Flux 的「植发 3 个月**生发后图片**」。两者均已实现,实现简述见 [`实现说明.md`](实现说明.md)。
|
||||||
|
|
||||||
**请求**:`POST /api/v1/hair/grow`
|
**请求**:`POST /api/v1/hair/grow`
|
||||||
@@ -191,12 +192,13 @@
|
|||||||
| 参数 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| gender | string | **是** | 性别:`male` / `female`。决定使用的发际线贴图集合 |
|
| gender | string | **是** | 性别:`male` / `female`。决定使用的发际线贴图集合 |
|
||||||
|
| hair_style | int | **是** | 发型序号,1-indexed。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(当前阶段不生效) |
|
| beauty_enabled | bool | 否 | 生发图是否带美颜效果,默认 false(当前阶段不生效) |
|
||||||
| use_mask | bool | 否 | 是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比;此时 N 张结果共用同一张生发图 |
|
| use_mask | bool | 否 | 是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
|
||||||
|
|
||||||
### 输出(data)
|
### 输出(data)
|
||||||
|
|
||||||
`results`:发际线方案数组,**数量 = 该性别的发际线类型数**(`female` 5 个 / `male` 4 个)。每个元素:
|
`results`:发际线方案数组,**含 1 个元素**(指定发型)。每个元素:
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
@@ -205,8 +207,8 @@
|
|||||||
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`(female),`ellipse`/`m`/`straight`/`inverse_arc`(male) |
|
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`(female),`ellipse`/`m`/`straight`/`inverse_arc`(male) |
|
||||||
| order | int | 排序序号(当前阶段固定 `1..N`,按贴图顺序,暂不计算合适度) |
|
| order | int | 排序序号(当前阶段固定 `1..N`,按贴图顺序,暂不计算合适度) |
|
||||||
|
|
||||||
> ⚠️ 生发图由本机 ComfyUI(Flux-2,端口 8182)生成,**一次请求生成全部 N 张、同步返回**,
|
> ⚠️ 生发图由本机 ComfyUI(Flux-2,端口 8182)生成,**一次请求生成指定发型的 1 张、同步返回**。
|
||||||
> 单请求耗时可达数分钟,调用方超时需放大。worker 侧返回 `image_base64` / `grown_image_base64`,
|
> worker 侧返回 `image_base64` / `grown_image_base64`,
|
||||||
> 网关落盘后改写为上表的 `image_url` / `grown_image_url`。
|
> 网关落盘后改写为上表的 `image_url` / `grown_image_url`。
|
||||||
|
|
||||||
### 响应示例
|
### 响应示例
|
||||||
@@ -283,11 +285,7 @@
|
|||||||
|
|
||||||
### 输出(data)
|
### 输出(data)
|
||||||
|
|
||||||
`data.features` 是一个 **JSON 字符串**(不是对象,客户端 `JSON.parse()` 后用)。内含**几十项**特征:
|
`data.features` 是一个 **JSON 字符串**(不是对象,客户端 `JSON.parse()` 后用),**仅含以下 6 个英文字段**:
|
||||||
脸型/眉形/眼型/鼻型/眼袋/法令纹/人中/瞳色/脖长/肤色、三庭五眼、四季色彩季型、量感/轮廓类型、
|
|
||||||
直曲量感得分、面部立体度、瞳距、对比度、基因风格、动静类型、性别……字段不固定、可随时增删。
|
|
||||||
|
|
||||||
其中**英文优先字段**(与 doubao 中文字段并存,方便客户端直接取):
|
|
||||||
|
|
||||||
| 字段 | 说明 |
|
| 字段 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
@@ -309,12 +307,12 @@
|
|||||||
"message": "success",
|
"message": "success",
|
||||||
"request_id": "mock-request-id",
|
"request_id": "mock-request-id",
|
||||||
"data": {
|
"data": {
|
||||||
"features": "{\"图片是否有人脸\":\"有人\",\"脸型\":\"鹅蛋脸\",\"眉形\":\"平眉\",\"面部年龄\":\"18-25岁\",\"四季色彩季型\":\"冷夏型\",\"基因风格\":\"少年型\",\"性别\":\"女\",\"face_shape\":\"鹅蛋脸\",\"eyebrow_shape\":\"平眉\",\"facial_age\":\"18-25岁\",\"dynamic_static_type\":\"静态型\",\"gender\":\"女\",\"gene_style\":\"少年型\"}"
|
"features": "{\"face_shape\":\"鹅蛋脸\",\"eyebrow_shape\":\"平眉\",\"facial_age\":\"18-25岁\",\"dynamic_static_type\":\"静态型\",\"gender\":\"女\",\"gene_style\":\"少年型\"}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> `features` 为字符串形式的 JSON,字段后续可随时增删,不固定。
|
> `features` 为字符串形式的 JSON,固定上述 6 个字段。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -365,15 +363,69 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 接口 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 | int | **是** | 发型序号,1-indexed。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight。越界返回 `1007` |
|
||||||
|
| beauty_enabled | bool | 否 | 生发图是否带美颜效果,默认 false(当前阶段不生效) |
|
||||||
|
| use_mask | bool | 否 | 是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线) |
|
||||||
|
|
||||||
|
### 输出(data)
|
||||||
|
|
||||||
|
与接口 2 完全相同。`results`:发际线方案数组,**含 1 个元素**。每个元素:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| image_url | string | 方案**预览图** URL(发际线曲线叠加图) |
|
||||||
|
| grown_image_url | string | **生发后图片** URL(ComfyUI/Flux「植发 3 个月」效果图) |
|
||||||
|
| hairline_type | string | 发际线类型 key |
|
||||||
|
| order | int | 排序序号 |
|
||||||
|
|
||||||
|
> ⚠️ 与接口 2 的区别:本接口使用 `add_hair2.json` 工作流(Flux-2 Klein 9b),输入/遮罩节点同为 26,
|
||||||
|
> SaveImage 输出节点为 75。
|
||||||
|
|
||||||
|
### 响应示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"request_id": "mock-request-id",
|
||||||
|
"data": {
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"image_url": "https://hair.xiangsilian.com/static/sample.jpg",
|
||||||
|
"grown_image_url": "https://hair.xiangsilian.com/static/sample.jpg",
|
||||||
|
"hairline_type": "ellipse",
|
||||||
|
"order": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 汇总:输入输出一览
|
## 汇总:输入输出一览
|
||||||
|
|
||||||
| 接口 | 输入 | 主要输出 |
|
| 接口 | 输入 | 主要输出 |
|
||||||
|------|------|----------|
|
|------|------|----------|
|
||||||
| 1 四庭七眼测量 | 用户照片 | 标注 PNG(无人物)+ 四庭/七眼厘米数值与坐标 |
|
| 1 四庭七眼测量 | 用户照片 | 标注 PNG(无人物)+ 四庭/七眼厘米数值与坐标 |
|
||||||
| 2 C 端生发 | 用户照片 | 生发后图片 + 多张发际线(带类型与排序) |
|
| 2 C 端生发 | 用户照片 | 生发后图片 + 指定发际线预览(单张) |
|
||||||
| 3 B 端生发 | 划线图片 | 最合适发际线图片 + 生发后图片 |
|
| 3 B 端生发 | 划线图片 | 最合适发际线图片 + 生发后图片 |
|
||||||
| 4 用户特征 | 用户照片 | N 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格…) |
|
| 4 用户特征 | 用户照片 | 6 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格) |
|
||||||
| 5 发际线 PNG | 用户照片 | N 张发际线 PNG + 最合适发际线面部中间点坐标 |
|
| 5 发际线 PNG | 用户照片 | N 张发际线 PNG + 最合适发际线面部中间点坐标 |
|
||||||
|
| 7 C 端生发 v2 | 用户照片 + gender + hair_style | 同接口2,使用 add_hair2.json 工作流 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,73 @@ filename = f"{uuid.uuid4().hex}.{ext}"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 5. 🔲【新增】接口7 C端生发 v2(`/api/v1/hair/grow-v2`)
|
||||||
|
|
||||||
|
**背景**:worker 侧已新增接口 7,功能与接口 2 完全一致,区别仅在于 ComfyUI 工作流使用 `add_hair2.json`(而非 `add_hair.json`)。
|
||||||
|
|
||||||
|
**网关需新增一个路由**,代理转发到 worker 的同路径:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# gateway/app.py
|
||||||
|
|
||||||
|
@app.post("/api/v1/hair/grow-v2", tags=["生发"])
|
||||||
|
async def hair_grow_v2(request: Request):
|
||||||
|
"""接口7:C端生发 v2(add_hair2 工作流)"""
|
||||||
|
return await _proxy(request, "/api/v1/hair/grow-v2")
|
||||||
|
```
|
||||||
|
|
||||||
|
**无需额外改动**:
|
||||||
|
- 请求:multipart/form-data,参数与接口 2 完全相同(`image_file/url/base64` 三选一 + `gender` + `hair_style` + `beauty_enabled` + `use_mask` + `prompt`),网关盲转发即可
|
||||||
|
- 响应:结构与接口 2 完全一致,`results[].image_base64` / `results[].grown_image_base64` 经现有 `rewrite_base64_to_url` 自动改写为 URL
|
||||||
|
- base64→URL:数组内图片字段递归改写已覆盖,无需修改
|
||||||
|
|
||||||
|
### 入参(与接口 2 一致)
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| image_file / image_url / image_base64 | — | **三选一** | 用户正面照 |
|
||||||
|
| gender | string | **是** | `male` / `female` |
|
||||||
|
| hair_style | int | **是** | 发型序号。female: 1–5,male: 1–4 |
|
||||||
|
| beauty_enabled | bool | 否 | 美颜开关(本期不生效) |
|
||||||
|
| use_mask | bool | 否 | 默认 `true`,`false` 跳过遮罩 |
|
||||||
|
| prompt | string | 否 | ComfyUI 提示词 |
|
||||||
|
|
||||||
|
### 出参(与接口 2 一致)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"request_id": "gw-xxxxxxxx",
|
||||||
|
"data": {
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"image_url": "https://hair.xiangsilian.com/static/annotations/xxx.jpg",
|
||||||
|
"grown_image_url": "https://hair.xiangsilian.com/static/annotations/xxx.jpg",
|
||||||
|
"hairline_type": "ellipse",
|
||||||
|
"order": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### worker 侧信息
|
||||||
|
|
||||||
|
| 项目 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| worker 路径 | `/api/v1/hair/grow-v2` |
|
||||||
|
| 工作流文件 | `add_hair2.json` |
|
||||||
|
| 输入/遮罩节点 | 26(LoadImage) |
|
||||||
|
| 输出节点 | 75(SaveImage,自动检测) |
|
||||||
|
| 提示词节点 | 60(JjkText) |
|
||||||
|
|
||||||
|
### 🔲【可选·仅影响 /docs】OpenAPI 表单声明
|
||||||
|
|
||||||
|
若想让网关 `/docs` 展示准确,在 `gateway/app.py` 新增 `_GROW_V2_FORMS`(或复用 `_GROW_FORMS` 并补充 `gender`/`hair_style` 字段),然后将路由函数签名改为显式声明 Form 参数(参考接口 4 的写法)。不改也不影响实际转发。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 已经做好、无需再动的
|
## 已经做好、无需再动的
|
||||||
|
|
||||||
- **接口4 在网关本机实现**(调豆包,不转发 worker)——已完成;config 里配 `ark`。
|
- **接口4 在网关本机实现**(调豆包,不转发 worker)——已完成;config 里配 `ark`。
|
||||||
|
|||||||
+17
-30
@@ -18,7 +18,7 @@ logger = logging.getLogger("hair.worker")
|
|||||||
ARK_BASE_URL = os.getenv("ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3")
|
ARK_BASE_URL = os.getenv("ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3")
|
||||||
ARK_MODEL = os.getenv("ARK_MODEL", "doubao-seed-1-6-vision-250815")
|
ARK_MODEL = os.getenv("ARK_MODEL", "doubao-seed-1-6-vision-250815")
|
||||||
|
|
||||||
# doubao 中文键 → 接口4 英文优先字段
|
# doubao 中文键 → 接口4 英文优先字段(仅保留这 6 项)
|
||||||
_KEY_MAP = {
|
_KEY_MAP = {
|
||||||
"脸型": "face_shape",
|
"脸型": "face_shape",
|
||||||
"眉形": "eyebrow_shape",
|
"眉形": "eyebrow_shape",
|
||||||
@@ -28,27 +28,13 @@ _KEY_MAP = {
|
|||||||
"基因风格": "gene_style",
|
"基因风格": "gene_style",
|
||||||
}
|
}
|
||||||
|
|
||||||
# 移植自 fuyan FaceArk.GetPicDesc 的特征枚举(去掉身高体重前缀)
|
# 仅请求接口4 需要的 6 个字段(+「图片是否有人脸」用于 1001 判定,不进最终输出)
|
||||||
_PROMPT = (
|
_PROMPT = (
|
||||||
"分析一下图片告诉我以下特征,只要答案,格式为json字符串,"
|
"分析一下图片告诉我以下特征,只要答案,格式为json字符串,"
|
||||||
"图片是否有人脸(有人/没人) 三庭五眼特征(答案要有三庭五眼四个字,9个字以内) "
|
"图片是否有人脸(有人/没人) "
|
||||||
"面部年龄(给出区间年龄)鼻长(鼻长适中/长鼻/短鼻) "
|
"脸型(圆形脸/心形脸/菱形脸/鹅蛋脸/方形脸/长形脸/瓜子脸) 眉形 "
|
||||||
"脸型(圆形脸/心形脸/菱形脸/鹅蛋脸/方形脸/长形脸/瓜子脸) 嘴型 "
|
"面部年龄(给出区间年龄) 动静类型(静态型/动态型) 性别(男/女) "
|
||||||
"眼袋(答案要有眼袋两个个字) 眼型 鼻型 眼皮(双眼皮/单眼皮) "
|
|
||||||
"法令纹(有法令纹/无法令纹) 人中(人中适中/人中长/人中短) 眉形 "
|
|
||||||
"瞳色(答案要有瞳色两个字) 脖长(脖长适中/脖子短/脖子长) "
|
|
||||||
"肤色(粉一白/粉二白/粉三白/黄一白/黄二白/黄黑皮)"
|
|
||||||
"直得分 曲得分 直曲总分(直得分-曲得分) 大量感得分 小量感得分 量感总分(大量感得分-小量感得) "
|
|
||||||
"面部立体度(总分十分)瞳距(毫米)对比度(对比度较强/对比度适中/对比度较弱)"
|
|
||||||
"鼻子立体度(立体度高/立体度适中/立体度低)"
|
|
||||||
"色相(中间表示0,最大值分别是-5和5,负数表示偏冷,正数表示偏暖)"
|
|
||||||
"亮度(中间表示0,最大值分别是-5和5;负数表示暗沉,正数表示白皙)"
|
|
||||||
"色度(中间表示0,最大值分别是-5和5;负数表示饱和度低,正数表示鲜艳)"
|
|
||||||
"面部颜色对比度(10分制)"
|
|
||||||
"四季色彩季型(净春型/暖春型/浅春型/浅夏型/冷夏型/柔夏型/柔秋型/暖秋型/深秋型/净冬型/冷冬型/深冬型)"
|
|
||||||
"基因风格(戏剧型/睿智型/自然型/古典型/优雅型/浪漫型/前卫型/少女型/少年型)"
|
"基因风格(戏剧型/睿智型/自然型/古典型/优雅型/浪漫型/前卫型/少女型/少年型)"
|
||||||
"量感类型(大量感/中量感/小量感)轮廓类型(轮廓偏曲/轮廓适中/轮廓偏直)"
|
|
||||||
"动静类型(静态型/动态型)性别(男/女)"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
_client = None
|
_client = None
|
||||||
@@ -102,11 +88,11 @@ def _image_to_url(image_bytes: bytes = None, image_url: str = None) -> str:
|
|||||||
return f"data:image/{fmt};base64," + base64.b64encode(image_bytes).decode()
|
return f"data:image/{fmt};base64," + base64.b64encode(image_bytes).decode()
|
||||||
|
|
||||||
|
|
||||||
def analyze_features(image_bytes: bytes = None, image_url: str = None) -> dict:
|
def analyze_features(image_bytes: bytes = None, image_url: str = None):
|
||||||
"""调 doubao 视觉模型分析人脸特征。
|
"""调 doubao 视觉模型分析人脸特征。
|
||||||
|
|
||||||
Returns: dict —— 含接口4 英文优先字段(face_shape 等) + doubao 全部中文字段。
|
Returns: dict —— 仅含接口4 的 6 个英文字段(face_shape/eyebrow_shape/facial_age/
|
||||||
无人脸时 doubao 的「图片是否有人脸」= 没人,调用方据此可判 1001。
|
dynamic_static_type/gender/gene_style);**无人脸返回 None**(调用方据此判 1001)。
|
||||||
"""
|
"""
|
||||||
url = _image_to_url(image_bytes, image_url)
|
url = _image_to_url(image_bytes, image_url)
|
||||||
resp = get_client().chat.completions.create(
|
resp = get_client().chat.completions.create(
|
||||||
@@ -120,12 +106,11 @@ def analyze_features(image_bytes: bytes = None, image_url: str = None) -> dict:
|
|||||||
}],
|
}],
|
||||||
)
|
)
|
||||||
text = resp.choices[0].message.content
|
text = resp.choices[0].message.content
|
||||||
data = _parse_json(text) # doubao 中文字段
|
raw = _parse_json(text) # doubao 原始中文字段
|
||||||
# 英文优先字段映射(doubao 缺某字段则跳过)
|
if not has_face(raw):
|
||||||
for zh, en in _KEY_MAP.items():
|
return None
|
||||||
if zh in data and en not in data:
|
# 只保留 6 个英文字段(doubao 缺某字段则跳过)
|
||||||
data[en] = data[zh]
|
return {en: raw[zh] for zh, en in _KEY_MAP.items() if zh in raw}
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def has_face(features: dict) -> bool:
|
def has_face(features: dict) -> bool:
|
||||||
@@ -139,5 +124,7 @@ if __name__ == "__main__":
|
|||||||
path = sys.argv[1] if len(sys.argv) > 1 else "tests/fixtures/frontal.jpg"
|
path = sys.argv[1] if len(sys.argv) > 1 else "tests/fixtures/frontal.jpg"
|
||||||
with open(path, "rb") as f:
|
with open(path, "rb") as f:
|
||||||
feats = analyze_features(image_bytes=f.read())
|
feats = analyze_features(image_bytes=f.read())
|
||||||
print("has_face:", has_face(feats))
|
if feats is None:
|
||||||
print(json.dumps(feats, ensure_ascii=False, indent=2))
|
print("无人脸(1001)")
|
||||||
|
else:
|
||||||
|
print(json.dumps(feats, ensure_ascii=False, indent=2))
|
||||||
|
|||||||
+2
-2
@@ -291,7 +291,7 @@ async def face_features(
|
|||||||
})
|
})
|
||||||
|
|
||||||
from fastapi.concurrency import run_in_threadpool
|
from fastapi.concurrency import run_in_threadpool
|
||||||
from face_features import analyze_features, has_face
|
from face_features import analyze_features
|
||||||
|
|
||||||
try:
|
try:
|
||||||
feats = await run_in_threadpool(analyze_features, img_bytes, image_url)
|
feats = await run_in_threadpool(analyze_features, img_bytes, image_url)
|
||||||
@@ -302,7 +302,7 @@ async def face_features(
|
|||||||
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
|
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
|
||||||
})
|
})
|
||||||
|
|
||||||
if not has_face(feats):
|
if feats is None:
|
||||||
return JSONResponse(status_code=200, content={
|
return JSONResponse(status_code=200, content={
|
||||||
"code": 1001, "message": "无法识别人像",
|
"code": 1001, "message": "无法识别人像",
|
||||||
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
|
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
|
||||||
|
|||||||
+38
-15
@@ -1,8 +1,10 @@
|
|||||||
"""ComfyUI 客户端:用 add_hair.json 工作流跑生发图(Flux-2 inpaint)。
|
"""ComfyUI 客户端:用 add_hair.json / add_hair2.json 工作流跑生发图(Flux-2 inpaint)。
|
||||||
|
|
||||||
worker 不跑 Flux,只把「划线图 + 遮罩」的 RGBA 上传到本机 ComfyUI(默认 8188),
|
worker 不跑 Flux,只把「划线图 + 遮罩」的 RGBA 上传到本机 ComfyUI(默认 8188),
|
||||||
替换工作流节点 26 的输入图、随机 seed,提交 /prompt,轮询 /history,取回 /view 输出。
|
替换工作流节点 26 的输入图、随机 seed,提交 /prompt,轮询 /history,取回 /view 输出。
|
||||||
ComfyUI 开启了 HTTP Basic Auth(user `admin` + 密码),所有请求都带凭据。
|
ComfyUI 开启了 HTTP Basic Auth(user `admin` + 密码),所有请求都带凭据。
|
||||||
|
|
||||||
|
支持多工作流:run() 可通过 workflow_path 指定不同工作流 JSON,自动检测 SaveImage 输出节点。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -16,7 +18,7 @@ import uuid
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
COMFYUI_URL = os.getenv("COMFYUI_URL", "http://127.0.0.1:8188").rstrip("/")
|
COMFYUI_URL = os.getenv("COMFYUI_URL", "http://127.0.0.1:8188").rstrip("/")
|
||||||
WORKFLOW_PATH = os.getenv(
|
_WORKFLOW_DEFAULT = os.getenv(
|
||||||
"ADD_HAIR_WORKFLOW",
|
"ADD_HAIR_WORKFLOW",
|
||||||
os.path.join(os.path.dirname(os.path.dirname(__file__)), "add_hair.json"),
|
os.path.join(os.path.dirname(os.path.dirname(__file__)), "add_hair.json"),
|
||||||
)
|
)
|
||||||
@@ -25,10 +27,10 @@ _REPO = os.path.dirname(os.path.dirname(__file__))
|
|||||||
|
|
||||||
_INPUT_NODE = "26" # LoadImage:外部输入图(含 alpha 遮罩)
|
_INPUT_NODE = "26" # LoadImage:外部输入图(含 alpha 遮罩)
|
||||||
_SEED_NODE = "6" # RandomNoise
|
_SEED_NODE = "6" # RandomNoise
|
||||||
_OUTPUT_NODE = "17" # SaveImage
|
|
||||||
_PROMPT_NODE = "60" # JjkText:提示词
|
_PROMPT_NODE = "60" # JjkText:提示词
|
||||||
|
|
||||||
_workflow = None
|
_wf_cache: dict[str, dict] = {} # path → workflow JSON
|
||||||
|
_wf_output_node: dict[str, str] = {} # path → SaveImage 节点 ID
|
||||||
|
|
||||||
|
|
||||||
def _comfy_auth():
|
def _comfy_auth():
|
||||||
@@ -56,19 +58,40 @@ def _comfy_auth():
|
|||||||
return (user, pw) if pw else None
|
return (user, pw) if pw else None
|
||||||
|
|
||||||
|
|
||||||
def _load_workflow() -> dict:
|
def _load_workflow(workflow_path: str | None = None) -> dict:
|
||||||
global _workflow
|
"""加载工作流 JSON(按路径缓存)。自动检测 SaveImage 节点 ID。"""
|
||||||
if _workflow is None:
|
path = workflow_path or _WORKFLOW_DEFAULT
|
||||||
with open(WORKFLOW_PATH, encoding="utf-8") as f:
|
if path not in _wf_cache:
|
||||||
_workflow = json.load(f)
|
with open(path, encoding="utf-8") as f:
|
||||||
return _workflow
|
wf = json.load(f)
|
||||||
|
_wf_cache[path] = wf
|
||||||
|
# 自动检测 SaveImage 输出节点
|
||||||
|
for node_id, node in wf.items():
|
||||||
|
if node.get("class_type") == "SaveImage":
|
||||||
|
_wf_output_node[path] = node_id
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise ValueError(f"工作流 {path} 中未找到 SaveImage 节点")
|
||||||
|
return _wf_cache[path]
|
||||||
|
|
||||||
|
|
||||||
def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = None) -> bytes:
|
def _get_output_node(workflow_path: str | None = None) -> str:
|
||||||
|
"""返回指定工作流的 SaveImage 节点 ID。"""
|
||||||
|
path = workflow_path or _WORKFLOW_DEFAULT
|
||||||
|
if path not in _wf_output_node:
|
||||||
|
_load_workflow(path) # 触发检测
|
||||||
|
return _wf_output_node[path]
|
||||||
|
|
||||||
|
|
||||||
|
def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = None,
|
||||||
|
workflow_path: str | None = None) -> bytes:
|
||||||
"""提交一次生发任务,返回输出 PNG 字节。失败抛异常。
|
"""提交一次生发任务,返回输出 PNG 字节。失败抛异常。
|
||||||
|
|
||||||
prompt:非 None 时替换工作流节点60(JjkText)的文本;None 时用工作流内置默认提示词。
|
prompt:非 None 时替换工作流节点60(JjkText)的文本;None 时用工作流内置默认提示词。
|
||||||
|
workflow_path:工作流 JSON 路径,None 则用默认 add_hair.json。
|
||||||
"""
|
"""
|
||||||
|
path = workflow_path or _WORKFLOW_DEFAULT
|
||||||
|
output_node = _get_output_node(path)
|
||||||
client_id = uuid.uuid4().hex
|
client_id = uuid.uuid4().hex
|
||||||
with httpx.Client(base_url=COMFYUI_URL, timeout=30.0, auth=_comfy_auth()) as cli:
|
with httpx.Client(base_url=COMFYUI_URL, timeout=30.0, auth=_comfy_auth()) as cli:
|
||||||
# 1. 上传输入图(含 alpha 遮罩)到 ComfyUI input 目录
|
# 1. 上传输入图(含 alpha 遮罩)到 ComfyUI input 目录
|
||||||
@@ -80,7 +103,7 @@ def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = Non
|
|||||||
name = (up.get("subfolder") + "/" if up.get("subfolder") else "") + up["name"]
|
name = (up.get("subfolder") + "/" if up.get("subfolder") else "") + up["name"]
|
||||||
|
|
||||||
# 2. 改工作流:节点26 输入图 + 随机 seed
|
# 2. 改工作流:节点26 输入图 + 随机 seed
|
||||||
wf = copy.deepcopy(_load_workflow())
|
wf = copy.deepcopy(_load_workflow(path))
|
||||||
wf[_INPUT_NODE]["inputs"]["image"] = name
|
wf[_INPUT_NODE]["inputs"]["image"] = name
|
||||||
wf[_SEED_NODE]["inputs"]["noise_seed"] = random.randint(0, 2**63 - 1)
|
wf[_SEED_NODE]["inputs"]["noise_seed"] = random.randint(0, 2**63 - 1)
|
||||||
if prompt is not None:
|
if prompt is not None:
|
||||||
@@ -104,14 +127,14 @@ def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = Non
|
|||||||
if status.get("status_str") == "error":
|
if status.get("status_str") == "error":
|
||||||
raise RuntimeError(f"ComfyUI 执行报错: {status}")
|
raise RuntimeError(f"ComfyUI 执行报错: {status}")
|
||||||
outputs = entry.get("outputs")
|
outputs = entry.get("outputs")
|
||||||
if outputs and _OUTPUT_NODE in outputs:
|
if outputs and output_node in outputs:
|
||||||
break
|
break
|
||||||
time.sleep(1.0)
|
time.sleep(1.0)
|
||||||
if not outputs or _OUTPUT_NODE not in outputs:
|
if not outputs or output_node not in outputs:
|
||||||
raise TimeoutError(f"ComfyUI 出图超时({timeout}s) prompt_id={prompt_id}")
|
raise TimeoutError(f"ComfyUI 出图超时({timeout}s) prompt_id={prompt_id}")
|
||||||
|
|
||||||
# 5. 取回输出图
|
# 5. 取回输出图
|
||||||
imgs = outputs[_OUTPUT_NODE].get("images") or []
|
imgs = outputs[output_node].get("images") or []
|
||||||
if not imgs:
|
if not imgs:
|
||||||
raise RuntimeError("ComfyUI 输出无图像")
|
raise RuntimeError("ComfyUI 输出无图像")
|
||||||
info = imgs[0]
|
info = imgs[0]
|
||||||
|
|||||||
+21
-8
@@ -128,15 +128,19 @@ def generate_previews(image_bgr: np.ndarray, gender: str):
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = True, prompt: str = None):
|
def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = True,
|
||||||
"""该性别全部发际线:预览图(白线) + 生发图(ComfyUI)。同步、串行。
|
prompt: str = None, hair_style: int = None,
|
||||||
|
workflow_path: str | None = None):
|
||||||
|
"""指定发际线类型:预览图(白线) + 生发图(ComfyUI)。
|
||||||
|
|
||||||
|
hair_style(1-indexed):指定生成第几张发际线(按贴图排序)。female: 1..5,male: 1..4。
|
||||||
|
为 None 时生成全部(兼容旧调用)。
|
||||||
use_mask(默认 True):是否启用 inpaint 遮罩,用于测试对比(同接口3)。
|
use_mask(默认 True):是否启用 inpaint 遮罩,用于测试对比(同接口3)。
|
||||||
False 时用**干净原图 + 空遮罩**送 ComfyUI(不烧黑色模板线),与模板无关,
|
False 时用**干净原图 + 空遮罩**送 ComfyUI(不烧黑色模板线)。
|
||||||
故只跑一次 ComfyUI、N 项复用同一张生发图;预览图(白线)仍按各模板生成。
|
|
||||||
prompt(默认 None):ComfyUI 提示词,非 None 时替换工作流节点60文本。
|
prompt(默认 None):ComfyUI 提示词,非 None 时替换工作流节点60文本。
|
||||||
|
workflow_path(默认 None):ComfyUI 工作流 JSON 路径,None 用默认 add_hair.json。
|
||||||
Returns: list[dict] {"hairline_type","order","image_bgr"(预览), "grown_png"(bytes 或 None)}。
|
Returns: list[dict] {"hairline_type","order","image_bgr"(预览), "grown_png"(bytes 或 None)}。
|
||||||
无人脸返回 None。某张 ComfyUI 失败时该项 grown_png=None,不影响其余。
|
无人脸返回 None。某张 ComfyUI 失败时该项 grown_png=None,不抛异常。
|
||||||
"""
|
"""
|
||||||
if gender not in ("male", "female"):
|
if gender not in ("male", "female"):
|
||||||
raise ValueError(f"gender 必须是 male/female,收到 {gender!r}")
|
raise ValueError(f"gender 必须是 male/female,收到 {gender!r}")
|
||||||
@@ -145,6 +149,15 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
|
|||||||
return None
|
return None
|
||||||
uv, ext_faces = load_ext_mesh()
|
uv, ext_faces = load_ext_mesh()
|
||||||
|
|
||||||
|
textures = get_texture_map()[gender] # [(key, path), ...] 已排序
|
||||||
|
if hair_style is not None:
|
||||||
|
if hair_style < 1 or hair_style > len(textures):
|
||||||
|
raise ValueError(
|
||||||
|
f"hair_style={hair_style} 超出范围,{gender} 可选 1..{len(textures)}")
|
||||||
|
items = [(hair_style, textures[hair_style - 1])]
|
||||||
|
else:
|
||||||
|
items = list(enumerate(textures, start=1))
|
||||||
|
|
||||||
# 禁用遮罩:干净原图 + 空遮罩,与模板无关 → 只跑一次 ComfyUI,下面 N 项复用
|
# 禁用遮罩:干净原图 + 空遮罩,与模板无关 → 只跑一次 ComfyUI,下面 N 项复用
|
||||||
shared_grown = None
|
shared_grown = None
|
||||||
if not use_mask:
|
if not use_mask:
|
||||||
@@ -152,12 +165,12 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
|
|||||||
h, w = image_bgr.shape[:2]
|
h, w = image_bgr.shape[:2]
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
compose_comfy_rgba(image_bgr, np.zeros((h, w), np.uint8)).save(buf, format="PNG")
|
compose_comfy_rgba(image_bgr, np.zeros((h, w), np.uint8)).save(buf, format="PNG")
|
||||||
shared_grown = comfyui.run(buf.getvalue(), prompt=prompt)
|
shared_grown = comfyui.run(buf.getvalue(), prompt=prompt, workflow_path=workflow_path)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
logger.warning("接口2 生发图失败(无遮罩):%s", e)
|
logger.warning("接口2 生发图失败(无遮罩):%s", e)
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for order, (key, white_path) in enumerate(get_texture_map()[gender], start=1):
|
for order, (key, white_path) in items:
|
||||||
white = load_texture_rgba(white_path)
|
white = load_texture_rgba(white_path)
|
||||||
preview = render_hairline_overlay(image_bgr, ctx["points"], ext_faces, uv, white)
|
preview = render_hairline_overlay(image_bgr, ctx["points"], ext_faces, uv, white)
|
||||||
|
|
||||||
@@ -171,7 +184,7 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
|
|||||||
image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black)
|
image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black)
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
compose_comfy_rgba(marked, mask).save(buf, format="PNG")
|
compose_comfy_rgba(marked, mask).save(buf, format="PNG")
|
||||||
grown_png = comfyui.run(buf.getvalue(), prompt=prompt)
|
grown_png = comfyui.run(buf.getvalue(), prompt=prompt, workflow_path=workflow_path)
|
||||||
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
|
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
|
||||||
logger.warning("接口2 生发图失败 type=%s:%s", key, e)
|
logger.warning("接口2 生发图失败 type=%s:%s", key, e)
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>🔬 接口4 — 用户特征分析 测试</h1>
|
<h1>🔬 接口4 — 用户特征分析 测试</h1>
|
||||||
<p class="subtitle">POST /api/v1/face/features | 上传照片 → AI视觉模型 → 42项面部特征 | data.features 为 JSON 字符串</p>
|
<p class="subtitle">POST /api/v1/face/features | 上传照片 → AI视觉模型 → 6项面部特征 | data.features 为 JSON 字符串</p>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|||||||
Reference in New Issue
Block a user