Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
688472706d | ||
|
|
a3e13c804a | ||
|
|
a2243266db | ||
|
|
fe0e74ece8 | ||
|
|
52913c94fc | ||
|
|
13526cb5b8 | ||
|
|
f509fe99b4 | ||
|
|
a1d458eb20 | ||
|
|
92e628b0d5 | ||
|
|
b4714cedf3 | ||
|
|
e7b62f2b2e | ||
|
|
4291f125d4 | ||
|
|
7fc0210ce6 | ||
|
|
085ad3ced0 | ||
|
|
2993a0b948 | ||
|
|
08b31a3baa | ||
|
|
51e313e845 | ||
|
|
fbbcd48418 | ||
|
|
74ccab0ff8 | ||
|
|
659c037270 | ||
|
|
c1bb9614c7 | ||
|
|
2b1f528ddd | ||
|
|
d9e96aca87 | ||
|
|
8aed389d79 | ||
|
|
c72e3ceda9 | ||
|
|
99ce21334a | ||
|
|
5fbc03a6df | ||
|
|
0bbb15d668 | ||
|
|
632e75317b | ||
|
|
1cd4115b26 | ||
|
|
e8a2c5a8a1 | ||
|
|
0f8a7e27c0 | ||
|
|
12f34c44f2 | ||
|
|
a208fe88ec | ||
|
|
4001df2c34 | ||
|
|
9386f84c88 | ||
|
|
d5397a6d6f | ||
|
|
95c6a2d929 | ||
|
|
28255ef7c2 | ||
|
|
b163a3f34a | ||
|
|
5d9d91bc82 | ||
|
|
3fe5f6cf0d | ||
|
|
e17677158a | ||
|
|
1e6c2e54de | ||
|
|
1b9f3fdb6f | ||
|
|
41bb164a52 | ||
|
|
de2cecf0d5 | ||
|
|
f8e30ad32e | ||
|
|
864d7f969a | ||
|
|
9675e147a0 | ||
|
|
0ddfa83743 | ||
|
|
3eb60bddc5 | ||
|
|
9774997035 | ||
|
|
aa981229c0 | ||
|
|
5d94edfc1a | ||
|
|
a177dc2583 | ||
|
|
a9439e4975 | ||
|
|
5fd7d05d26 | ||
|
|
e58ece0ada | ||
|
|
57d1a876cc | ||
|
|
6e8113b3ff | ||
|
|
85f1ca521d | ||
|
|
8cd44848d6 | ||
|
|
76bae98073 | ||
|
|
9678b54267 | ||
|
|
1d23414d21 | ||
|
|
cb1989c042 | ||
|
|
28a6062fea | ||
|
|
be81567261 | ||
|
|
24da4eae6f | ||
|
|
c15a3c2e15 | ||
|
|
5311c8d7c7 | ||
|
|
52f1bb4c0b | ||
|
|
01397c414e | ||
|
|
8d68ee323a | ||
|
|
d2642b66f8 | ||
|
|
a86125246e | ||
|
|
4c7681b338 | ||
|
|
e3c67fc8cf | ||
|
|
a3f4c822cb | ||
|
|
bee4bdae10 | ||
|
|
76f7c06905 | ||
|
|
68fe1c1406 | ||
|
|
043a4c0603 | ||
|
|
3008552331 | ||
|
|
0e71830bb6 | ||
|
|
795de68985 | ||
|
|
bf76923591 | ||
|
|
cd4ca278d4 | ||
|
|
d4e6a794d8 | ||
|
|
147fef6ca6 | ||
|
|
38161d1b50 | ||
|
|
ce95a508c1 | ||
|
|
94ad95850e | ||
|
|
4c69bb4623 | ||
|
|
554b64a916 | ||
|
|
891bc0da8b | ||
|
|
10488171eb | ||
|
|
9391408088 | ||
|
|
023bb2e6fd | ||
|
|
0eaaf05ac3 |
@@ -10,6 +10,17 @@ gateway/config.json
|
||||
# worker 配置(含鉴权密码,不入 git)
|
||||
worker_config.json
|
||||
|
||||
# ComfyUI Basic Auth 密码(不入 git)
|
||||
password.txt
|
||||
|
||||
# worker 运行期文件(PID / 日志)
|
||||
worker.pid
|
||||
worker.log
|
||||
|
||||
# 请求日志(运行时生成,不入 git)
|
||||
gateway/request_log.jsonl
|
||||
gateway/request_log.jsonl.1
|
||||
|
||||
# SegFormer 模型权重(~323MB,体积过大,不入 git,见 OFFLINE_ASSETS.md)
|
||||
hairline/models/face-parsing/model.safetensors
|
||||
|
||||
@@ -17,3 +28,28 @@ hairline/models/face-parsing/model.safetensors
|
||||
static/annotations/*
|
||||
!static/annotations/.gitkeep
|
||||
tests/output/
|
||||
|
||||
# 本地临时遮罩测试页(不入 git)
|
||||
test_local.py
|
||||
|
||||
# 运行期日志 / uvicorn 日志(不入 git)
|
||||
log/
|
||||
uvicorn.log
|
||||
uvicorn*.log
|
||||
|
||||
# ZCode 工具目录(不入 git)
|
||||
.zcode/
|
||||
|
||||
# 临时响应文件(不入 git)
|
||||
_grow*_resp.json
|
||||
|
||||
# 测试素材图(体积大,不入 git)
|
||||
image/test/
|
||||
|
||||
# 批量报告输出(生成图+原图,体积大,不入 git)
|
||||
static/report_hairline_v2/
|
||||
static/report_hairline_v2.zip
|
||||
|
||||
# local_test 运行期日志 / pid(不入 git)
|
||||
local_test/hair_service.log
|
||||
local_test/hair_service.pid
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
{
|
||||
"16": {
|
||||
"class_type": "UNETLoader",
|
||||
"inputs": {
|
||||
"unet_name": "flux-2-klein-4b-fp8.safetensors",
|
||||
"weight_dtype": "fp8_e4m3fn_fast"
|
||||
}
|
||||
},
|
||||
"3": {
|
||||
"class_type": "VAELoader",
|
||||
"inputs": {
|
||||
"vae_name": "flux2-vae.safetensors"
|
||||
}
|
||||
},
|
||||
"61": {
|
||||
"class_type": "CLIPLoader",
|
||||
"inputs": {
|
||||
"clip_name": "qwen_3_4b.safetensors",
|
||||
"type": "flux2",
|
||||
"device": "cpu"
|
||||
}
|
||||
},
|
||||
"26": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "placeholder.png"
|
||||
}
|
||||
},
|
||||
"60": {
|
||||
"class_type": "JjkText",
|
||||
"inputs": {
|
||||
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
||||
}
|
||||
},
|
||||
"22": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"clip": [
|
||||
"61",
|
||||
0
|
||||
],
|
||||
"text": [
|
||||
"60",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"31": {
|
||||
"class_type": "easy imageSize",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"26",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"33": {
|
||||
"class_type": "Mask Fill Holes",
|
||||
"inputs": {
|
||||
"masks": [
|
||||
"26",
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
"36": {
|
||||
"class_type": "Convert Masks to Images",
|
||||
"inputs": {
|
||||
"masks": [
|
||||
"33",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"39": {
|
||||
"class_type": "ImageScale",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"36",
|
||||
0
|
||||
],
|
||||
"upscale_method": "nearest-exact",
|
||||
"width": [
|
||||
"31",
|
||||
0
|
||||
],
|
||||
"height": [
|
||||
"31",
|
||||
1
|
||||
],
|
||||
"crop": "disabled"
|
||||
}
|
||||
},
|
||||
"37": {
|
||||
"class_type": "Image To Mask",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"39",
|
||||
0
|
||||
],
|
||||
"method": "intensity"
|
||||
}
|
||||
},
|
||||
"32": {
|
||||
"class_type": "LayerUtility: ImageScaleByAspectRatio V2",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"26",
|
||||
0
|
||||
],
|
||||
"mask": [
|
||||
"37",
|
||||
0
|
||||
],
|
||||
"aspect_ratio": "custom",
|
||||
"proportional_width": [
|
||||
"31",
|
||||
0
|
||||
],
|
||||
"proportional_height": [
|
||||
"31",
|
||||
1
|
||||
],
|
||||
"fit": "letterbox",
|
||||
"method": "lanczos",
|
||||
"round_to_multiple": "8",
|
||||
"scale_to_side": "None",
|
||||
"scale_to_length": 1024,
|
||||
"background_color": "#000000"
|
||||
}
|
||||
},
|
||||
"44": {
|
||||
"class_type": "ImageAndMaskPreview",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"32",
|
||||
0
|
||||
],
|
||||
"mask": [
|
||||
"32",
|
||||
1
|
||||
],
|
||||
"mask_opacity": 1,
|
||||
"mask_color": "FFFF00",
|
||||
"pass_through": true
|
||||
}
|
||||
},
|
||||
"14": {
|
||||
"class_type": "GetImageSize+",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"44",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"13": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {
|
||||
"pixels": [
|
||||
"44",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"3",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"class_type": "ModelSamplingFlux",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"16",
|
||||
0
|
||||
],
|
||||
"max_shift": 1.15,
|
||||
"base_shift": 0.5,
|
||||
"width": [
|
||||
"14",
|
||||
0
|
||||
],
|
||||
"height": [
|
||||
"14",
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
"19": {
|
||||
"class_type": "FluxGuidance",
|
||||
"inputs": {
|
||||
"conditioning": [
|
||||
"22",
|
||||
0
|
||||
],
|
||||
"guidance": 1
|
||||
}
|
||||
},
|
||||
"5": {
|
||||
"class_type": "ReferenceLatent",
|
||||
"inputs": {
|
||||
"conditioning": [
|
||||
"19",
|
||||
0
|
||||
],
|
||||
"latent": [
|
||||
"13",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"7": {
|
||||
"class_type": "EmptySD3LatentImage",
|
||||
"inputs": {
|
||||
"width": [
|
||||
"14",
|
||||
0
|
||||
],
|
||||
"height": [
|
||||
"14",
|
||||
1
|
||||
],
|
||||
"batch_size": 1
|
||||
}
|
||||
},
|
||||
"1": {
|
||||
"class_type": "BasicScheduler",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"2",
|
||||
0
|
||||
],
|
||||
"scheduler": "simple",
|
||||
"steps": 4,
|
||||
"denoise": 1
|
||||
}
|
||||
},
|
||||
"20": {
|
||||
"class_type": "BasicGuider",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"2",
|
||||
0
|
||||
],
|
||||
"conditioning": [
|
||||
"5",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"6": {
|
||||
"class_type": "RandomNoise",
|
||||
"inputs": {
|
||||
"noise_seed": 0
|
||||
}
|
||||
},
|
||||
"8": {
|
||||
"class_type": "KSamplerSelect",
|
||||
"inputs": {
|
||||
"sampler_name": "euler"
|
||||
}
|
||||
},
|
||||
"9": {
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"inputs": {
|
||||
"noise": [
|
||||
"6",
|
||||
0
|
||||
],
|
||||
"guider": [
|
||||
"20",
|
||||
0
|
||||
],
|
||||
"sampler": [
|
||||
"8",
|
||||
0
|
||||
],
|
||||
"sigmas": [
|
||||
"1",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"7",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"10": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"9",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"3",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"62": {
|
||||
"class_type": "ColorMatch",
|
||||
"inputs": {
|
||||
"image_ref": [
|
||||
"26",
|
||||
0
|
||||
],
|
||||
"image_target": [
|
||||
"10",
|
||||
0
|
||||
],
|
||||
"method": "mkl",
|
||||
"strength": 1,
|
||||
"multithread": true
|
||||
}
|
||||
},
|
||||
"17": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"62",
|
||||
0
|
||||
],
|
||||
"filename_prefix": "hair_inpaint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,10 +19,14 @@
|
||||
|------|------|------|
|
||||
| model.safetensors | `hairline/models/face-parsing/model.safetensors` | 338,580,732 B (~323MB) |
|
||||
|
||||
下载命令:
|
||||
下载命令(国内用 hf-mirror 镜像,快很多):
|
||||
```bash
|
||||
# 国内镜像(推荐)
|
||||
curl -L -o hairline/models/face-parsing/model.safetensors \
|
||||
"https://huggingface.co/jonathandinu/face-parsing/resolve/main/model.safetensors"
|
||||
"https://hf-mirror.com/jonathandinu/face-parsing/resolve/main/model.safetensors"
|
||||
# 官方源
|
||||
# curl -L -o hairline/models/face-parsing/model.safetensors \
|
||||
# "https://huggingface.co/jonathandinu/face-parsing/resolve/main/model.safetensors"
|
||||
```
|
||||
|
||||
sha256 校验:
|
||||
@@ -77,10 +81,10 @@ model.safetensors https://huggingface.co/jonathandinu/face-parsing/resol
|
||||
|
||||
模型已就位,但**内网机还需要 Python 依赖的离线 wheel 包**,否则 `pip install` 在内网无法联网安装。这部分**与目标机的操作系统、Python 版本、CUDA 版本强相关**,需确认后单独打包:
|
||||
|
||||
- **worker(GPU 机)**:`mediapipe` / `opencv-python` / `numpy<2` / `Pillow` / **`torch`+`torchvision` 的 CUDA 版**(按 GPU 的 CUDA 版本选 cu118/cu121 等)+ FastAPI/uvicorn 全家桶。
|
||||
- **worker(GPU 机)**:`mediapipe` / `opencv-python` / `numpy<2` / `Pillow` / **`torch`+`torchvision` 的 CUDA 版**(按 GPU 的 CUDA 版本选 cu118/cu121 等)/ `transformers`(接口2 SegFormer)+ FastAPI/uvicorn 全家桶。
|
||||
- **网关机**:很轻,只需 FastAPI/uvicorn/httpx 等代理依赖,**不需要 torch/mediapipe**。
|
||||
|
||||
> 架构已拆分(见 `docs/系统架构-网关与高性能后端.md`):算法依赖只装在 worker,网关保持轻量。
|
||||
> 架构已拆分(见 `docs/实现说明.md`):算法依赖只装在 worker,网关保持轻量。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +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_fast"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "羽化遮罩"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=ComfyUI (127.0.0.1:8188)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=ubuntu
|
||||
WorkingDirectory=/home/ubuntu/ComfyUI
|
||||
ExecStart=/home/ubuntu/ComfyUI/venv/bin/python main.py --listen 127.0.0.1 --port 8188 --cache-classic --fast
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,33 +1,14 @@
|
||||
# 文档索引(按开发机器划分)
|
||||
# 文档索引
|
||||
|
||||
系统拆分为两台机器开发:**外网网关** 和 **高性能 worker(GPU)**。下面标清每台机器该读哪些文档。
|
||||
|
||||
## 🌐 两端共享(都要读)
|
||||
旷视五接口(四庭七眼测量 / C端生发 / B端生发 / 用户特征 / 发际线PNG)。系统拆成
|
||||
**外网网关 + 高性能 worker(GPU)** 两台机器、一个仓库。
|
||||
|
||||
| 文档 | 作用 |
|
||||
|------|------|
|
||||
| [系统架构-网关与高性能后端.md](系统架构-网关与高性能后端.md) | 两层拆分的总设计与契约:拓扑、职责、配置、鉴权、base64→URL、错误处理。**两端的接口约定,先读。** |
|
||||
| [接口文档.md](接口文档.md) | 对外 API 契约(字段/错误码)。**拆分后保持不变**,是字段命名的唯一权威。 |
|
||||
| [旷视具体需求.md](旷视具体需求.md) | 原始需求 |
|
||||
| [实现说明.md](实现说明.md) | **实现总览**:架构、五个接口怎么实现、base64→URL 映射、错误码、部署/环境要点。先读这份。 |
|
||||
| [接口文档.md](接口文档.md) | 对外 API 契约(字段 / 错误码)。字段命名的唯一权威。 |
|
||||
| [旷视具体需求.md](旷视具体需求.md) | 原始需求。 |
|
||||
| [../OFFLINE_ASSETS.md](../OFFLINE_ASSETS.md) | worker 离线模型权重/字体清单(内网部署)。 |
|
||||
|
||||
## 🖥️ 高性能 worker 机(GPU)
|
||||
|
||||
跑完整 `app.py` + `face_analysis`,做真正的算法。
|
||||
|
||||
| 文档 | 作用 |
|
||||
|------|------|
|
||||
| [接口1-四庭七眼测量-技术实现方案.md](接口1-四庭七眼测量-技术实现方案.md) | 四庭七眼算法方案(MediaPipe / BiSeNet / 标定 / 标注图 / 误差) |
|
||||
| [接口1-四庭七眼测量-开发任务书.md](接口1-四庭七眼测量-开发任务书.md) | worker 侧开发任务书(阶段一~十 + 精度验证),AI agent 执行 |
|
||||
| [../OFFLINE_ASSETS.md](../OFFLINE_ASSETS.md) | 离线模型权重/字体清单(内网部署)。worker 需要这些权重 |
|
||||
|
||||
## 🚪 外网网关机
|
||||
|
||||
薄反向代理,不跑算法。
|
||||
|
||||
| 文档 | 作用 |
|
||||
|------|------|
|
||||
| [网关-开发任务书.md](网关-开发任务书.md) | 网关侧开发任务书(健康检查 / 派发 / 鉴权 / base64→URL / 部署),AI agent 执行 |
|
||||
|
||||
---
|
||||
|
||||
> 开发顺序建议:worker 先跑通(本机即可开发验证)→ worker 部署到 GPU 机 → 网关接入联调。网关在 worker 就绪前可用本地 stub worker 独立开发。
|
||||
> 原先分散的「各接口技术方案 / 开发任务书 / 系统架构 / 网关任务书」已合并进 `实现说明.md`
|
||||
> (细节可查 git 历史)。
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# GPU Worker 改动说明
|
||||
|
||||
测试页面已新增字段,worker 侧需对应接收并使用。
|
||||
|
||||
## 涉及接口
|
||||
|
||||
| 接口 | 路径 | 新增字段 |
|
||||
|------|------|----------|
|
||||
| 接口 2(C端生发) | `POST /api/v1/hair/grow` | `prompt` |
|
||||
| 接口 3(B端生发) | `POST /api/v1/hair/grow-b` | `prompt` |
|
||||
|
||||
## 1. 新增 `prompt` 参数
|
||||
|
||||
### 接口 2(`/api/v1/hair/grow`)
|
||||
|
||||
Handler 签名新增:
|
||||
|
||||
```python
|
||||
prompt: str = Form(default="补充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||
```
|
||||
|
||||
透传链路:
|
||||
|
||||
```
|
||||
handler → generate_grow_results(image, gender, use_mask, prompt)
|
||||
→ comfyui.run(buf, prompt=prompt)
|
||||
```
|
||||
|
||||
### 接口 3(`/api/v1/hair/grow-b`)
|
||||
|
||||
Handler 签名新增:
|
||||
|
||||
```python
|
||||
prompt: str = Form(default="补充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||
```
|
||||
|
||||
透传链路:
|
||||
|
||||
```
|
||||
handler → generate_grow_b(marked, use_mask, prompt)
|
||||
→ comfyui.run(buf, prompt=prompt)
|
||||
```
|
||||
|
||||
### `hairline/comfyui.py` 改动
|
||||
|
||||
1. 新增节点常量:
|
||||
|
||||
```python
|
||||
_PROMPT_NODE = "60" # JjkText:提示词
|
||||
```
|
||||
|
||||
2. `run()` 函数签名改为:
|
||||
|
||||
```python
|
||||
def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = None) -> bytes:
|
||||
```
|
||||
|
||||
3. 在 `run()` 内部,注入图片和 seed 之后(约第81行后)加入:
|
||||
|
||||
```python
|
||||
if prompt is not None:
|
||||
wf[_PROMPT_NODE]["inputs"]["text"] = prompt
|
||||
```
|
||||
|
||||
### `hairline/service.py` 改动
|
||||
|
||||
两个服务函数各加一个 `prompt` 参数,透传给 `comfyui.run()`:
|
||||
|
||||
- `generate_grow_results(image_bgr, gender, use_mask=True, prompt=None)` — 两处 `comfyui.run(buf.getvalue())` 都改为 `comfyui.run(buf.getvalue(), prompt=prompt)`
|
||||
- `generate_grow_b(marked_bgr, use_mask=True, prompt=None)` — `comfyui.run(buf.getvalue())` 改为 `comfyui.run(buf.getvalue(), prompt=prompt)`
|
||||
|
||||
## 2. 已有字段回顾
|
||||
|
||||
`use_mask` 和 `prompt` 测试页面均已就绪,表单字段一览:
|
||||
|
||||
| 接口 | 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|------|--------|------|
|
||||
| 2 | `image_file` | file | — | 用户照片(三选一) |
|
||||
| 2 | `image_url` | string | — | 同上 |
|
||||
| 2 | `image_base64` | string | — | 同上 |
|
||||
| 2 | `gender` | string | `female` | `male` / `female` |
|
||||
| 2 | `use_mask` | bool | `true` | inpaint 遮罩开关 |
|
||||
| 2 | `prompt` | string | `补充遮罩区域的头发` | ComfyUI 提示词 |
|
||||
| 3 | `marked_image_file` | file | — | 划线图(三选一) |
|
||||
| 3 | `marked_image_url` | string | — | 同上 |
|
||||
| 3 | `marked_image_base64` | string | — | 同上 |
|
||||
| 3 | `use_mask` | bool | `true` | 画发际线开关 |
|
||||
| 3 | `prompt` | string | `补充遮罩区域的头发` | ComfyUI 提示词 |
|
||||
|
||||
## 3. 当前工作流提示词节点
|
||||
|
||||
`add_hair.json` 中节点 **60**(`JjkText`)为提示词节点,当前硬编码文本:
|
||||
|
||||
> 补充遮罩区补充遮罩区域内的头发,头发填满遮罩区域。发际线往下挡住额头
|
||||
|
||||
`comfyui.run()` 收到 `prompt` 参数后将替换此文本。
|
||||
|
After Width: | Height: | Size: 304 KiB |
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"request_id": "mock-request-id",
|
||||
"data": {
|
||||
"hairline_id": "chang_zhixian",
|
||||
"gen_backend": "swaphair",
|
||||
"hairgrow_strength": 0.75,
|
||||
"is_hr": false,
|
||||
"seg_model": "segformer",
|
||||
"mask_type": "pushed",
|
||||
"erode_cm": 0.6,
|
||||
"swap_mode": "ext_mask",
|
||||
"blend_method": "multiband",
|
||||
"edge_erode_px": 3,
|
||||
"mb_levels": 5,
|
||||
"hairline_push_cm": 1.0,
|
||||
"hairline_edge": "column",
|
||||
"denoising_strength": 0.6,
|
||||
"color_match": true,
|
||||
"color_match_strength": 1.0,
|
||||
"mb_feather_px": 1,
|
||||
"transition_band_px": -1,
|
||||
"inpainting_fill": 1,
|
||||
"mask_blur": 11,
|
||||
"mask_dilate_scale": 1.0,
|
||||
"px_per_cm": 47.5311,
|
||||
"erode_px": 29,
|
||||
"hair_pixels": 186798,
|
||||
"closed_pixels": 191712,
|
||||
"mask_pixels": 140299,
|
||||
"image_size": {
|
||||
"width": 1257,
|
||||
"height": 1495
|
||||
},
|
||||
"timings_ms": {
|
||||
"mask": 1462,
|
||||
"swap": 5596,
|
||||
"blend": 220
|
||||
},
|
||||
"redraw": {
|
||||
"enabled": false
|
||||
},
|
||||
"_rid": "bc7205a4",
|
||||
"steps": {
|
||||
"input_base64": "<omitted 427407 chars>",
|
||||
"baseline_overlay_base64": "<omitted 435895 chars>",
|
||||
"upper_overlay_base64": "<omitted 384479 chars>",
|
||||
"hair_seg_overlay_base64": "<omitted 428039 chars>",
|
||||
"top_fill_overlay_base64": "",
|
||||
"closed_overlay_base64": "",
|
||||
"hairline_overlay_base64": "<omitted 445467 chars>",
|
||||
"pushed_overlay_base64": "<omitted 452803 chars>",
|
||||
"mask_overlay_base64": "<omitted 420375 chars>",
|
||||
"mask_base64": "<omitted 8026 chars>",
|
||||
"swap_raw_base64": "<omitted 340791 chars>",
|
||||
"hard_paste_base64": "<omitted 420679 chars>",
|
||||
"alpha_base64": "<omitted 7762 chars>",
|
||||
"final_base64": "<omitted 415811 chars>",
|
||||
"redraw_band_overlay_base64": "",
|
||||
"redraw_a_base64": "",
|
||||
"redraw_c_base64": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 304 KiB |
|
After Width: | Height: | Size: 314 KiB |
|
After Width: | Height: | Size: 326 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 313 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 332 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 282 KiB |
@@ -0,0 +1,177 @@
|
||||
# 接口3 B端生发 — 实现文档
|
||||
|
||||
> 文档日期:2026-07-18
|
||||
|
||||
---
|
||||
|
||||
## 一、接口概述
|
||||
|
||||
**接口3** 是 B端(医生/操作端)生发接口。医生在用户照片上手动用马克笔画出发际线后,只需上传这一张划线图,系统自动检测划线 → 生成遮罩 → 送 ComfyUI 生发,返回「植发3个月」效果图。
|
||||
|
||||
**与接口2 的核心区别**:
|
||||
|
||||
| 特性 | 接口2(C端生发) | 接口3(B端生发) |
|
||||
|------|----------------|----------------|
|
||||
| 输入 | 原始照片 | 划线图(含手绘线) |
|
||||
| 发际线来源 | 系统按发型模板自动生成 | 医生手绘标注 |
|
||||
| 发型类型 | ellipse/flower/heart/straight/wave | custom(自定义) |
|
||||
| 中间步骤 | extract_context + swapHair + ComfyUI重绘 | 划线检测 + 遮罩 + ComfyUI生发 |
|
||||
| 是否调 change_hair | 是(女性流程) | 否 |
|
||||
| ComfyUI 工作流 | 0716add-hair-api.json(重绘) | add_hair.json(生发) |
|
||||
| 典型耗时 | ~11s | ~6-8s |
|
||||
|
||||
---
|
||||
|
||||
## 二、接口定义
|
||||
|
||||
### 路由
|
||||
|
||||
```
|
||||
POST /api/v1/hair/grow-b
|
||||
```
|
||||
|
||||
### 入参
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `marked_image_file` | UploadFile | 三选一 | 划线图片文件(JPG/PNG) |
|
||||
| `marked_image_url` | str | 三选一 | 划线图片 URL |
|
||||
| `marked_image_base64` | str | 三选一 | 划线图片 base64 |
|
||||
| `use_mask` | bool | 否(默认True) | 是否自动检测划线并建遮罩。False时跳过检测,直接送划线图 |
|
||||
| `prompt` | str | 否 | ComfyUI 提示词,默认"补充遮罩区域的头发,加一点美颜" |
|
||||
|
||||
### 返回
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"hair_growth_image_base64": "iVBORw0KGgo...(生发图 JPG base64)",
|
||||
"hairline_type": "custom"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
错误码:
|
||||
- `1001`: 无法识别人像 / 未检测到发际线划线
|
||||
- `1007`: 处理失败
|
||||
- `1008`: 图片格式不支持
|
||||
|
||||
---
|
||||
|
||||
## 三、完整调用链
|
||||
|
||||
```
|
||||
POST /api/v1/hair/grow-b
|
||||
│
|
||||
├─ app.py hair_grow_b() [app.py:929]
|
||||
│ ├─ resolve_image_bytes() → marked_raw 解析图片(file/url/base64三选一)
|
||||
│ ├─ cv2.imdecode → marked_bgr 解码为 BGR
|
||||
│ └─ run_in_threadpool(generate_grow_b, ...)
|
||||
│
|
||||
├─ service.py generate_grow_b(marked_bgr, use_mask, prompt) [service.py:381]
|
||||
│ │
|
||||
│ ├─ 步骤1:人脸检测 + 头发分割(仅 use_mask=True 时)
|
||||
│ │ ├─ get_landmarker().detect(rgb) MediaPipe 478点人脸检测
|
||||
│ │ │ → landmarks(无人脸返回 no_face)
|
||||
│ │ ├─ get_parser().parse(rgb) SegFormer 面部分割(CPU ~0.9s)
|
||||
│ │ │ → parse_map(int label map)
|
||||
│ │ │
|
||||
│ ├─ 步骤2:手绘发际线检测(仅 use_mask=True 时)
|
||||
│ │ ├─ detect_marker_hairline(marked_bgr, landmarks, parse_map)
|
||||
│ │ │ │ [marker_detect.py:41]
|
||||
│ │ │ ├─ forehead_upper_region(landmarks) 额头上部 ROI
|
||||
│ │ │ ├─ head_silhouette(parse_map) 头部轮廓 ROI
|
||||
│ │ │ ├─ _blackhat(gray) 黑帽变换(响应比邻域暗的细结构)
|
||||
│ │ │ ├─ _snap_anchor(bh, 左鬓角21) 左锚点吸附
|
||||
│ │ │ ├─ _snap_anchor(bh, 右鬓角251) 右锚点吸附
|
||||
│ │ │ ├─ route_through_array(cost, 左, 右) Dijkstra最小代价路径
|
||||
│ │ │ └→ path (N,2) row,col(拒识返回 None → no_line)
|
||||
│ │ │
|
||||
│ │ ├─ path_to_curve_mask(path) 路径→曲线mask(uint8 0/255)
|
||||
│ │ └─ mask_from_curve(curve_mask, landmarks, parse_map)
|
||||
│ │ │ [mask.py]
|
||||
│ │ ├─ _above_curve_region(curve_mask) 曲线以上区域
|
||||
│ │ ├─ cv2.morphologyEx(闭运算) 填洞
|
||||
│ │ ├─ 最大连通域
|
||||
│ │ └─ 高斯羽化 → mask (uint8 0-255)
|
||||
│ │
|
||||
│ ├─ 步骤3:合成 RGBA PNG
|
||||
│ │ ├─ compose_comfy_rgba(marked_bgr, mask) RGB=原图,alpha=255×(1-mask)
|
||||
│ │ └─ PNG 编码 → rgba_png_bytes
|
||||
│ │
|
||||
│ └─ 步骤4:ComfyUI 生发
|
||||
│ └─ comfyui.run(rgba_png_bytes, prompt) [comfyui.py:87]
|
||||
│ ├─ 上传图片到 ComfyUI /upload/image
|
||||
│ ├─ 加载工作流 add_hair.json
|
||||
│ ├─ 替换节点26输入图 + 节点6随机seed + 节点60提示词
|
||||
│ ├─ POST /prompt 提交工作流
|
||||
│ ├─ 轮询 /history/{prompt_id}(间隔0.2s)
|
||||
│ └─ GET /view 取回输出 PNG → grown_png
|
||||
│
|
||||
└─ 返回 {"grown_png": bytes, "status": "ok"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、用到的模型和外部服务
|
||||
|
||||
| 模型/服务 | 用途 | 位置 | 设备 |
|
||||
|----------|------|------|------|
|
||||
| **FaceLandmarker** (MediaPipe) | 478点人脸检测 | hairline/face_landmarks.py | CPU |
|
||||
| **FaceParser** (SegFormer) | 面部分割(hair/skin/...) | hairline/face_parsing.py | CPU (5090不兼容cu121) |
|
||||
| **ComfyUI** (Flux-2) | 生发图生成 | hairline/comfyui.py → :8188 | GPU |
|
||||
|
||||
**注意**:接口3 **不调用** change_hair 服务(:8801),不需要 swapHair。这是它与接口2女性流程的关键区别。
|
||||
|
||||
---
|
||||
|
||||
## 五、核心算法:手绘发际线检测
|
||||
|
||||
### 5.1 为什么不用简单阈值?
|
||||
|
||||
手绘马克笔线条的灰度值与皮肤阴影、抬头纹等重叠,全局阈值无法区分。采用**黑帽变换 + Dijkstra最小路径**方案。
|
||||
|
||||
### 5.2 黑帽变换(Black Hat)
|
||||
|
||||
```python
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k))
|
||||
bh = cv2.morphologyEx(gray, cv2.MORPH_BLACKHAT, kernel)
|
||||
```
|
||||
|
||||
黑帽 = 闭运算 − 原图,响应"比局部邻域暗的细结构"(即马克笔线条),对抬头纹/眉毛/发丝鲁棒。
|
||||
|
||||
### 5.3 Dijkstra 最小代价路径
|
||||
|
||||
1. **ROI 限定**:额头上部 ∩ 头部轮廓(排除背景)
|
||||
2. **锚点**:左鬓角(21) / 右鬓角(251) MediaPipe 关键点
|
||||
3. **代价图**:`cost = (bh.max() - bh) + 1.0`,ROI外设 1e6
|
||||
4. **路径**:`route_through_array(cost, 左锚, 右锚)` — skimage 的 Dijkstra 实现
|
||||
|
||||
### 5.4 拒识机制
|
||||
|
||||
路径平均黑帽响应 < 8.0 → 判定"未画线",返回 `no_line`。
|
||||
|
||||
---
|
||||
|
||||
## 六、与接口1、接口2 的对比
|
||||
|
||||
| 维度 | 接口1 | 接口2 | 接口3 |
|
||||
|------|-------|-------|-------|
|
||||
| 功能 | 四庭七眼测量 | C端生发(5种发际线) | B端生发(手绘线) |
|
||||
| 路由 | /api/v1/face/measure | /api/v1/hair/grow | /api/v1/hair/grow-b |
|
||||
| 输入 | 正面照 | 正面照 | 划线图 |
|
||||
| MediaPipe | ✅ | ✅ | ✅ |
|
||||
| SegFormer | ✅ | ✅ | ✅ |
|
||||
| change_hair | ❌ | ✅(女性) | ❌ |
|
||||
| ComfyUI | ❌ | ✅(Flux-2重绘) | ✅(Flux-2生发) |
|
||||
| 典型耗时 | ~2s | ~11s | ~6-8s |
|
||||
| ComfyUI工作流 | — | 0716add-hair-api.json | add_hair.json |
|
||||
|
||||
---
|
||||
|
||||
## 七、测试
|
||||
|
||||
- **测试页面**:[static/test_interface3.html](file:///home/ubuntu/hair/static/test_interface3.html)
|
||||
- **测试图片**:[image/girl_img/girl13.jpg](file:///home/ubuntu/hair/image/girl_img/girl13.jpg)(需手动在图上画发际线后作为划线图上传)
|
||||
@@ -0,0 +1,7 @@
|
||||
第一步 使用接口9 头发遮罩生成 的算法获取 mask
|
||||
第二部 改造 /home/xsl/change_hair 换发型的工作流, 换发型的参考文档在这里 /home/xsl/change_hair/docs/换发型集成文档.md
|
||||
1、原始换发型工作的遮罩用第一步算出来的遮罩
|
||||
2、然后换发型得到遮罩区域发际线的图片。
|
||||
3、严格按照遮罩区域把图片贴回到原图上面。
|
||||
4、贴图的时候融合贴图边缘和原图的接缝,可以采用羽化算法或者渐变alpha混合的算法,目的就是边缘要和原图过渡自然。 这里通过传入各种参数可以控制选哪种算法和控制过渡细节。
|
||||
最后一步返回生成特定样式的图片。
|
||||
@@ -0,0 +1,115 @@
|
||||
# 发际线生发遮罩算法(pushed 模式)
|
||||
|
||||
> 对应接口11 `/api/v1/hairline/grow`、接口12 `/api/v1/hairline/grow_v2`。
|
||||
> 遮罩算法固定为 pushed;融合算法默认 multiband(多频段金字塔),接口11 可切换 seamless/two_stage/feather。
|
||||
> 代码:`face_analysis/hairline_grow.py`(`_extract_hairline` / `_pushed_mask` / `compute_mask` / `_composite`)。
|
||||
|
||||
## 概述
|
||||
|
||||
pushed 是发际线生发的**唯一**遮罩算法。融合算法默认 multiband(多频段金字塔),接口11 暴露 `blend_method` 可切换为 seamless(泊松)/two_stage(泊松→多频段两段式)/feather(羽化),便于对比调优。它从头发分割结果中提取「头发/皮肤交界线」(发际线),以眉心为圆心逐点径向外推一段距离,与 baseline 组成闭合区域作为最终遮罩。这样遮罩顶部会覆盖现有头发下沿一小段,贴回生发结果时顶部与真头发重叠、过渡自然。
|
||||
|
||||
> 接口12 `/api/v1/hairline/grow_v2` 只需传 `image` + `hairline_id`,遮罩和融合全部固定,无需任何算法选择参数。
|
||||
|
||||
## 算法流程(5 步)
|
||||
|
||||
```
|
||||
①-a baseline 分割线 ← 眉骨/glabella 关键点折线(含 151 中心点)
|
||||
①-b 上半区 upper ← baseline 以上的区域(裁剪范围)
|
||||
①-c 头发分割 hair_mask ← segformer/bisenet 的原始头发像素
|
||||
①-f 头发内轮廓线 ← 取头发轮廓中朝脸一侧的那段(额头弧+两侧到下颌),有序折线
|
||||
①-g 径向外推 + 成带 ← 以 151 点为圆心把内轮廓逐点向外推 push_px,内轮廓↔外推线之间的带 = 最终遮罩
|
||||
```
|
||||
|
||||
> ①-d(填充到基线 top_fill)、①-e(闭合区域 closed)是旧 eroded/closed 模式的中间产物,pushed 模式不走这条流程,前端不展示。
|
||||
|
||||
### ①-a baseline 分割线
|
||||
|
||||
MediaPipe 人脸关键点 `[21,68,104,69,108,151,337,299,333,298,251]` 连成折线(左端 21 → 中心 151 → 右端 251),再向左右边缘水平延长。151 点(glabella/眉心)是后续径向外推的圆心。代码 `_baseline_points` / `_draw_baseline`。
|
||||
|
||||
### ①-b 上半区 upper
|
||||
|
||||
baseline 折线以上的多边形区域(`_upper_region_mask`)。作为后续裁剪范围,保证遮罩不越界到下半脸。
|
||||
|
||||
### ①-c 头发分割 hair_mask
|
||||
|
||||
segformer(默认)或 bisenet 得到的头发二值掩码。
|
||||
|
||||
### ①-f 头发内轮廓线(核心改动)
|
||||
|
||||
代码 `_extract_hairline`。目标是提取「头发区域朝脸一侧的内轮廓线」:额头弧 + 左右两侧鬓角/脸颊边界,一直向下到下颌,是一条**有序折线**(不再是逐列一个 y 的数组,因为两侧近乎竖直、一个 x 对多个 y)。
|
||||
|
||||
1. **取头发轮廓**:`hair_mask` 最大连通域,`cv2.findContours(RETR_EXTERNAL, CHAIN_APPROX_NONE)` 取稠密、保序的外轮廓点。
|
||||
2. **内侧判定**:轮廓同时含「朝背景的外侧剪影」和「朝脸的内轮廓」。对每个轮廓点,朝脸中心 151 方向采样 `sample_px`(≈0.4cm)像素,落点若是**非头发**像素 → 该点朝向脸(内轮廓);否则是外侧剪影,丢弃。
|
||||
3. **取最长连续内侧段**:内轮廓点在闭合轮廓上本是一段连续弧,先做 1D 环形闭运算填掉判定抖动的小缝,再取最长连续 True 段并保序。
|
||||
4. **下颌截断**:丢掉 y > `chin_y`(下巴关键点 152 的 y)的点,把两侧末端截到下颌一带 → 得到「环脸」内轮廓弧。
|
||||
|
||||
**关键点**:不再用 baseline 做水平截断、也不再逐列取下沿;截断改为「朝脸内侧」判定 + 下颌 y 截断,因此能同时拿到额头弧和两侧竖直边界。
|
||||
|
||||
### ①-g 径向外推 + 闭合区域(最终遮罩)
|
||||
|
||||
代码 `_pushed_mask`:
|
||||
|
||||
1. **逐点径向外推**:圆心 = 151 点 (cx, cy)。对内轮廓上每个点 (x, y),沿「从圆心指向它」的单位向量 `(ux, uy)` **向外**(远离脸中心 = 推进现有头发)外推 `push_px`,得到外推线(黄线)点 `(x + ux·push_px, y + uy·push_px)`。`push_px = hairline_push_cm × px_per_cm`(默认 1cm)。
|
||||
2. **逐列归并**:只取外推线中落在 baseline 以上的点,逐列取最靠上的 y 作为遮罩顶界 `pushed_y[x]`;空列线性插值填补。两侧鬓角落到 baseline 以下的段落自然被排除。
|
||||
3. **与 baseline 组闭合区域**:逐列从 `pushed_y[x]` 填充到 `baseline_y[x]`(仅 `pushed_y < baseline_y` 的列),`& upper` 去越界、`_largest_cc` 保留最大连通域。
|
||||
|
||||
最终遮罩 = **外推发际线(①-g 黄线)与 baseline 分割线(①-a)组成的闭合区域**:顶界=外推发际线(覆盖现有头发约 push_cm),底界=baseline。与旧逻辑一致,区别只是 `pushed_y` 现在来自修正后的整条内轮廓,额头弧已延伸到两侧鬓角,额头遮罩宽度不再被截短。
|
||||
|
||||
## 关键参数
|
||||
|
||||
遮罩算法(pushed)固定。融合算法接口11 通过 `blend_method` 可切换(默认 multiband),其余融合参数均可调:
|
||||
|
||||
| 参数 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `hairline_push_cm` | 1.0 | 内轮廓径向外推距离(厘米),= push_px / px_per_cm。`px_per_cm` 由虹膜直径标定 |
|
||||
| `hairline_edge` | `column` | 兼容保留的入参;内轮廓提取(轮廓+内侧判定)不再按它分支,取值不影响结果 |
|
||||
| `mb_levels` | 5 | 多频段金字塔层数(2~6,越大低频色差抹得越宽)|
|
||||
| `blend_method` | `multiband` | 接缝融合:multiband(多频段金字塔) / seamless(泊松) / two_stage(泊松→多频段,大色差) / feather(羽化) / alpha_gradient。接口12 固定 multiband |
|
||||
| `color_match` | `true` | 融合前 Reinhard 颜色迁移消除整体色差(multiband/feather/alpha_gradient 生效;seamless/two_stage 自带调色故跳过)|
|
||||
| `color_match_strength` | 1.0 | 颜色迁移强度(0~1,<1 只迁移部分,防 Reinhard 过度改色)|
|
||||
| `mb_feather_px` | 1 | 多频段最细层掩码轻羽化像素(0=不羽化),消除发丝边缘锯齿 |
|
||||
| `transition_band_px` | -1 | keep-region 过渡带边距(-1=自动按层数 `2**n`;>=0 用绝对像素与层数解耦)|
|
||||
| `edge_erode_px` | 3 | 贴图前遮罩内缩像素(防边缘露皮/光晕)|
|
||||
| `erode_cm` | 0.6(接口12 固定)| baseline 参考内缩距离,对 pushed 影响很小 |
|
||||
| `redraw` | `false` | 发际线带重绘开关:开启后用 final(④融合图)在「外推线↔发际线」带重绘,swapHair/Flux-2 两路对比,结果单独展示(不替换 final)|
|
||||
| `inpainting_fill` | 1 | change_hair 重绘填充:0=保留原图(治染绿) / 1=填充噪声(默认) / 2=纯色 / 3=潜变量噪声 |
|
||||
| `mask_blur` | 11 | change_hair 遮罩边缘模糊像素(越大颜色越易从边缘渗透)|
|
||||
| `mask_dilate_scale` | 1.0 | change_hair 遮罩膨胀核缩放(1.0=原始,<1 收缩防越界)|
|
||||
| `comfyui_prompt` | `null` | redraw Flux-2 路提示词,null 用默认「补充遮罩区域的头发,加一点美颜」|
|
||||
|
||||
> 接口12 `/api/v1/hairline/grow_v2` 只需传 `image` + `hairline_id`,遮罩和融合全部用默认值(multiband + color_match=true),不暴露算法选择参数。
|
||||
|
||||
### 融合方法选择建议
|
||||
|
||||
- **multiband**(默认):常规首选。低频抹色差、高频保发丝。需配合 `color_match=true` 消除整体色差。
|
||||
- **two_stage**:生成图与原图色差大时用。先泊松克隆统一色调,再多频段贴细节,兼顾调色与保发丝。比纯 seamless 更不易溢色。
|
||||
- **seamless**:纯泊松梯度域调和,色调统一干净,但可能整体改色/边缘溢色。
|
||||
- **feather / alpha_gradient**:单层 alpha 过渡,最轻量,但过渡带内色差不会被抹平,仅适合色差极小的场景。
|
||||
|
||||
## 发际线带重绘(redraw,接口11 可选)
|
||||
|
||||
`redraw=true` 时,在主流程(④接缝融合 final)之后额外跑一条重绘分支,结果单独展示(`steps.redraw_a` / `redraw_c`),**不替换** final。
|
||||
|
||||
**重绘区域** = ①-g 外推发际线(`outer_pts`)与 ①-f 发际线(`inner_pts`)两条折线端点相连组成的带状闭合区域(宽度 ≈ `hairline_push_cm`,只覆盖发际线交界处)。
|
||||
|
||||
**两路后端对比**(输入图 + 融合基底都用 final):
|
||||
- **swapHair 路**(`redraw_a`):final + 带遮罩调 change_hair → final 走 multiband 融合
|
||||
- **Flux-2 路**(`redraw_c`):final + 带遮罩调 ComfyUI(`hair_repaint.json` 工作流)→ final 走 multiband 融合。Flux-2 经 reference latent + ColorMatch 双重保色,**不易染绿**
|
||||
|
||||
> `inpainting_fill` / `mask_blur` / `mask_dilate_scale` 透传 change_hair 服务端(仅影响 swapHair 路)。`comfyui_prompt` 仅影响 Flux-2 路。
|
||||
> 两路独立容错:任一路失败只跳过该路,不影响另一路和主 final。
|
||||
> ⚠️ Flux-2 路需 ComfyUI(8188)在跑;swapHair 路需 change_hair(8801)在跑。
|
||||
|
||||
## 与旧模式(eroded/closed,已移除)的区别
|
||||
|
||||
| | eroded/closed(已移除) | pushed(当前唯一) |
|
||||
|---|---|---|
|
||||
| 遮罩来源 | top_fill(头发向下填充含额头)外缘内缩 | 头发内轮廓线 径向外推成带 |
|
||||
| 截断方式 | 用 baseline 组上半区 upper | 内侧判定 + 下颌 y 截断(不再用 baseline) |
|
||||
| 遮罩形状 | 整个额头闭合区域 | 沿内轮廓的环脸带(额头弧+两侧,压住现有头发 push_cm) |
|
||||
|
||||
## 调试
|
||||
|
||||
- 调试页:`http://<host>:8187/static/test_interface11_debug.html`(带前后端日志面板、下载日志按钮)
|
||||
- 后端日志:`/home/xsl/hair/log/hairline_grow.log`(按 `[rid]` 关联一次请求),下载接口 `/api/v1/debug/hairline_log?rid=<id>&tail=500`
|
||||
- 可视化步骤:①-a baseline / ①-b upper / ①-c 头发分割 / ①-f 交界线 / ①-g 外推+遮罩 / 最终遮罩 / 生成 / 贴回 / 融合
|
||||
@@ -0,0 +1,118 @@
|
||||
# 旷视五接口 — 实现说明(总)
|
||||
|
||||
> 把原先分散的「各接口技术方案 + 开发任务书 + 系统架构 + 网关任务书」合并成这一份**简要总览**。
|
||||
> 对外 API 契约以 [`接口文档.md`](接口文档.md) 为唯一权威;原始需求见 [`旷视具体需求.md`](旷视具体需求.md);
|
||||
> 离线模型清单见 [`../OFFLINE_ASSETS.md`](../OFFLINE_ASSETS.md)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 架构
|
||||
|
||||
两台机器、一个仓库:
|
||||
|
||||
```
|
||||
客户端 ──HTTPS──> 外网网关(gateway/) ──HTTP(X-Internal-Token)──> worker(GPU 机, app.py)
|
||||
│ 薄代理 + 落盘改URL │ 跑算法(本地模型/ComfyUI)
|
||||
└ 接口4 本机直接调豆包(不转发) └ 接口1/2/3/5/6/7
|
||||
```
|
||||
|
||||
- **worker**(`app.py` + `face_analysis/` + `hairline/`):跑真正的算法,**纯本地、无外网依赖**。
|
||||
对 `/api/*` 校验 `X-Internal-Token`(密码在 `worker_config.json` 的 `accept_passwords`);
|
||||
`/health` 模型就绪才返回 200。监听 **8187**(`./start.sh` 控制开关,`./run_worker.sh` 热重载)。
|
||||
- **网关**(`gateway/`):薄反向代理,健康检查/派发/鉴权/把 worker 的 `*_base64` 落盘改成 `*_url`。
|
||||
唯一例外是**接口4 在网关本机直接实现**(调外网豆包,不转发 worker)。
|
||||
- **图片三选一**:所有接口图片入参 `image_file`/`image_url`/`image_base64` 严格三选一(接口3 是 `marked_image_*`)。
|
||||
- **响应信封**:`{code, message, request_id, data}`;业务错误用 `code`(HTTP 一律 200)。
|
||||
|
||||
### base64 → URL 映射(网关落盘改写,递归进数组、可空保留 null)
|
||||
|
||||
| 接口 | worker 字段(内部) | 对外字段 |
|
||||
|------|--------------------|----------|
|
||||
| 1 | `annotated_image_base64` | `annotated_image_url` |
|
||||
| 6 | `annotated_image_base64` | `annotated_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` |
|
||||
| 5 | `hairline_images[].image_{middle,high,low}_base64` / `grown_image_base64`(可空) | `hairline_images[].image_{middle,high,low}_url` / `grown_image_url` |
|
||||
| 7 | `results[].image_base64` / `results[].grown_image_base64`(可空) | `results[].image_url` / `results[].grown_image_url` |
|
||||
| 4 | (网关本机产出,无图片字段,`features` 为 JSON 字符串) | — |
|
||||
|
||||
> 实现建议:递归遍历 data,凡 key 以 `_base64` 结尾就落盘改 `_url`,自动覆盖嵌套/新增字段。
|
||||
> **图片格式**:接口1 标注图含透明用 **PNG**;接口2/3/5 是不透明照片用 **JPG**(小很多,~9×)。
|
||||
> 网关落盘按内容嗅探扩展名(PNG 头→`.png`,否则 `.jpg`)。
|
||||
|
||||
### 错误码
|
||||
|
||||
`1001` 无法识别人像 | `1002` 分辨率过低 | `1003` 非正面 | `1004` gender 必填/非法(接口2/5/7)|
|
||||
`1006` >1MB | `1007` 图片参数错误(0或多个)/未预期异常 | `1008` 格式不支持。
|
||||
|
||||
---
|
||||
|
||||
## 2. 五个接口实现简述
|
||||
|
||||
### 接口1 四庭七眼测量 `/api/v1/face/measure`(worker)
|
||||
- **做什么**:正面照 → 四庭(顶/上/中/下庭) + 七眼(眼宽/脸宽/间距) 的 cm 与占比、5 个关键点坐标、一张透明底标注 PNG。
|
||||
- **怎么实现**(`face_analysis/`):MediaPipe Face Mesh 468+虹膜点 → solvePnP 姿态校验(非正面 1003) →
|
||||
虹膜直径法定标(px→cm) → **眉心以下实测**;**眉心以上**用 BiSeNet 头发分割取真实发际线/头顶(方案B),
|
||||
失败回退比例推算(方案A,`hairline_source` 透出)。标注图 numpy 向量化渐变线 + 思源黑体。返回 `annotated_image_base64`。
|
||||
- 门槛可配:`MIN_SHORT_SIDE`/`MIN_LONG_SIDE`(默认600/800)、姿态阈值 `FRONTAL_*_THR`(默认30°)。
|
||||
|
||||
### 接口6 四庭七眼测量 v2 `/api/v1/face/measure-v2`(worker)—— 接口1 的去顶庭变体
|
||||
- **做什么**:基于接口 1,**去顶庭**:不画头顶横线、不返回顶庭数据(`four_courts` 仅上/中/下庭,`landmarks` 无 `hair_top`,`face_total_height_cm` 为三庭之和)。
|
||||
- **与接口1 的标注差异**(`create_annotated_image(variant="v6")`):①竖线范围改为发际线→下巴尖;②不画人头最左/最右端线(仅七眼 6 点 5 段,接口1 为 8 线 7 段);③左侧只标上/中/下庭。箭头/虚线/字体等与接口1 一致。
|
||||
- **怎么实现**:与接口 1 共用 `_face_measure_impl(variant="v6")`;v6 时标注走变体分支、数据由 app.py 边界删顶庭字段并重算三庭比例。
|
||||
- **网关改动**:新增路由 `POST /api/v1/face/measure-v2`,转发到 worker 同路径;base64→URL 改写无需改动。
|
||||
|
||||
### 接口2 C端生发 `/api/v1/hair/grow`(worker)—— 预览 + 生发图
|
||||
- **做什么**:正面照 + `gender`(必填) + `hair_style`(必填,逗号分隔多选,如 `1,2,3`) → 指定发际线类型 **N 组**:**预览图**(发际线叠在照片上) + **生发后图**(植发3个月效果)。
|
||||
- **怎么实现**(`hairline/`):移植 head3d——MediaPipe(Tasks) + SegFormer 分割 + 17 锚点射线检测 → 502 点 mesh,
|
||||
按 `face_ext.obj` 的 UV 把发际线贴图渲染到额头(预览)。生发:黑贴图渲染遮罩 → 调本机 **ComfyUI 8182** 的
|
||||
`add_hair.json`(Flux-2) 出图。**关键坑**:obj 是重排序,需 `INDEX_MAP_468` 把 MP 序→OBJ 序。
|
||||
返回 `results[].image_base64` + `grown_image_base64`。
|
||||
- `hair_style` 映射:female 1=ellipse 2=flower 3=heart 4=straight 5=wave 6=bigflower 7=clasicalflower;male 1=ellipse 2=inverse_arc 3=m 4=straight 5=heart 6=Softpetal。female 1..5 走「换发型(change_hair)」+Flux-2 重绘管线;female 6/7 与 male 全部走原生发(ComfyUI add_hair)管线。
|
||||
|
||||
### 接口7 C端生发 v2 `/api/v1/hair/grow-v2`(worker)—— 接口2同款,add_hair2 工作流
|
||||
- **做什么**:与接口 2 完全一致(正面照 + `gender` + `hair_style` 逗号分隔多选 → N 组预览+生发图)。
|
||||
- **与接口 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)—— 马克笔发际线
|
||||
- **做什么**:医生用马克笔在额头画好发际线,**只传这一张划线图** → 检测线 → 生发图。输出 `hair_growth_image_url` + `hairline_type="custom"`。
|
||||
- **怎么实现**:检测算法源自 headmark——**黑帽响应图 + 鬓角锚点(MediaPipe 21/251) + Dijkstra 最小路径**(scikit-image),
|
||||
比全局阈值鲁棒;路径平均响应过低→拒识(1001)。检测路径建遮罩,划线图原样送 ComfyUI(提示词清除黑线)。
|
||||
|
||||
### 接口4 用户特征 `/api/v1/face/features`(**网关本机**)
|
||||
- **做什么**:照片 → 几十项面部特征(脸型/眉形/肤色/三庭五眼/四季色彩季型/量感/基因风格/性别…)。`data.features` 是 JSON 字符串。
|
||||
- **怎么实现**(`gateway/`,逻辑参考 worker `face_features.py` / `/home/xsl/fuyan`):调**火山方舟 豆包视觉模型**
|
||||
`doubao-seed-1-6-vision`(OpenAI 兼容,base64 data URI 喂图),解析 JSON + 映射 6 个英文优先字段并保留全部中文。
|
||||
无人脸→1001。**唯一调外网的接口**:网关需可达 `ark.cn-beijing.volces.com`,API Key 走网关配置(不入 git)。
|
||||
|
||||
### 接口5 发际线PNG生成 `/api/v1/hairline/generate`(worker)
|
||||
- **做什么**:入参同接口2(`gender` + 多选 `hair_style` 必填)。对每个选中发型 → `middle`/`high`/`low` 三档发际线叠图 + 生发图 + 首个选中发型的面部中间点坐标。
|
||||
- **怎么实现**:复用接口2 的 502 点渲染管线,三档分别用 `hairline_texture[/_high|/_low]` 同名贴图渲染叠图;生发同接口2(ComfyUI inpaint),**黑模板固定取 `hairline_texture_black/`(middle)**,每发型 1 张生发图。`best_hairline_center_point`=眉心 x × 首个选中发型 middle 档发际线 y。
|
||||
|
||||
---
|
||||
|
||||
## 3. 部署 / 环境要点
|
||||
|
||||
**worker(GPU 机)**
|
||||
- Python **3.12**(系统 3.13 无 mediapipe/torch wheel);venv 在 `./venv`,依赖 `requirements.txt`。
|
||||
- `numpy<2`(1.26.4),`scikit-image==0.24.0`(**别升 0.25+,会顶 numpy≥2 顶崩 mediapipe**)。
|
||||
- ⚠️ 本机 **RTX 5090(sm_120)**,pinned `torch 2.2.2(cu121)` 只到 sm_90 → GPU 算子报 "no kernel image",
|
||||
代码已自动**回退 CPU**(BiSeNet/SegFormer CPU 推理可用)。要用 5090 GPU 需换 torch cu128(≥2.7)。
|
||||
- 模型权重/字体见 [`../OFFLINE_ASSETS.md`](../OFFLINE_ASSETS.md);BiSeNet/SegFormer/face_landmarker.task 本地。
|
||||
- 生发接口依赖本机 **ComfyUI(8188)**(Flux-2,它自带支持 5090 的 torch);worker 只调其 HTTP API,不跑 Flux。
|
||||
ComfyUI 开了 **HTTP Basic Auth**(user `admin` + 密码);密码放 `password.txt`(不入 git) /
|
||||
`worker_config.json.comfyui_password` / 环境变量 `COMFYUI_PASSWORD`,URL 用 `COMFYUI_URL`。
|
||||
- `worker_config.json`(不入 git):`accept_passwords`(鉴权) + 鉴权头 `X-Internal-Token`。
|
||||
|
||||
**网关机**
|
||||
- 很轻:FastAPI/uvicorn/httpx + **接口4 的 `volcengine-python-sdk[ark]`**(或直接 httpx 调,OpenAI 兼容)。
|
||||
- 不装 torch/mediapipe/opencv。配置 `gateway/config.json`(不入 git):`workers` 列表、`shared_password`、
|
||||
`ark` 的 api_key/base_url/model、`public_base_url`、超时(**生发接口慢,`request_timeout_seconds` 调大 ≥120s**)。
|
||||
- 托管 `/static/annotations/`(落盘的图),定期清理。
|
||||
|
||||
---
|
||||
|
||||
> 维护:本文为简要总览;字段以 `接口文档.md` 为准。各接口更细的算法推导可查 git 历史中已合并的旧技术方案文档。
|
||||
@@ -1,498 +0,0 @@
|
||||
# 接口 1:四庭七眼测量 — 开发任务书(worker 侧 · AI Agent 执行版)
|
||||
|
||||
> **在高性能 worker(GPU 机)上开发。** 本任务书只负责 worker 侧算法逻辑。
|
||||
> 配套技术方案:[`接口1-四庭七眼测量-技术实现方案.md`](接口1-四庭七眼测量-技术实现方案.md)
|
||||
> 系统架构(两端共享契约,先读):[`系统架构-网关与高性能后端.md`](系统架构-网关与高性能后端.md)
|
||||
> **网关侧在另一台机器开发,有独立任务书**:[`网关-开发任务书.md`](网关-开发任务书.md),本机不涉及。
|
||||
> 执行者:AI coding agent。请**严格按阶段顺序**执行,每个阶段完成后运行该阶段的「验证方法」,**通过后再进入下一阶段**。
|
||||
|
||||
> 📌 **本机职责**:worker 跑完整 `app.py` + `face_analysis`,用 GPU。与单机版的两处关键差异:
|
||||
> - ① torch 用 **GPU(CUDA)** 版;
|
||||
> - ② 阶段八 handler **返回 `annotated_image_base64`,不保存到 static、不拼 URL**(落盘改 URL 由网关做)。
|
||||
> - 接口文档(对外契约)**完全不变**。
|
||||
|
||||
---
|
||||
|
||||
## 0. 背景与目标
|
||||
|
||||
把现有 `/api/v1/face/measure` 接口从 **Mock**(返回硬编码数据)替换为**真实算法实现**。
|
||||
|
||||
- 输入:单人正面人像图(multipart 上传 / URL / base64,三选一,≤1MB)。
|
||||
- 输出:四庭(顶/上/中/下庭)、七眼(眼宽/脸宽/两眼间距)的 cm 值与占比,5 个关键点像素坐标,以及一张**仅含标注图层、透明底**的 PNG。
|
||||
- 保持现有统一响应结构 `{code, message, request_id, data}` 与错误码 1001–1008 不变。
|
||||
|
||||
**核心算法策略**(见技术方案 §1.1):
|
||||
- 眉心以下(中/下庭、七眼):MediaPipe Face Mesh 468 点直接实测。
|
||||
- 眉心以上(上/顶庭,即发际线/头顶):**方案 B(BiSeNet 头发分割,主)** → **方案 A(比例推算,兜底)**。
|
||||
- 尺度换算:虹膜直径法(11.7mm)。
|
||||
- 姿态校验:`cv2.solvePnP` 解算真实欧拉角。
|
||||
|
||||
---
|
||||
|
||||
## 1. 总体约束(所有阶段通用)
|
||||
|
||||
1. **不破坏现有接口契约**:响应外层结构、错误码、三选一图片输入规则、`ok()`/`err()` 帮助函数沿用 `app.py` 现有实现。
|
||||
2. **新增逻辑全部放在 `face_analysis/` 包内**,`app.py` 只做编排(读图→校验→调用→返回),保持单文件 app 的薄控制器风格。
|
||||
3. **依赖锁版本**:`numpy<2`(用 1.26.4)。torch 用 CPU 版。安装走 `pip.conf` 里的腾讯云镜像(torch 需用官方 CPU index)。
|
||||
4. **模型权重不入 git**:`face_analysis/weights/*.pth` 写进 `.gitignore`,由 §2 的下载脚本拉取。
|
||||
5. **中文字体**:`face_analysis/fonts/NotoSansCJKsc-Regular.otf`(= 思源黑体,已预下载,见 `OFFLINE_ASSETS.md`)。
|
||||
6. **每个模块都要能单独 import 且有 `if __name__ == "__main__"` 自测入口**,方便分阶段验证。
|
||||
7. 代码风格、注释密度与 `app.py` 保持一致;注释用中文。
|
||||
8. **不要 mock 兜底**:算法失败时返回对应错误码,**不得**回退成硬编码示例数据。
|
||||
|
||||
---
|
||||
|
||||
## 2. 阶段一:环境与依赖
|
||||
|
||||
**开发步骤**
|
||||
1. 更新 `requirements.txt`,新增:`mediapipe==0.10.14`、`opencv-python==4.10.0`、`Pillow==11.0.0`、`numpy==1.26.4`、`torch==2.2.2`、`torchvision==0.17.2`。
|
||||
2. 在 venv 安装依赖。torch 用 CPU index:
|
||||
`./venv/bin/pip install torch==2.2.2 torchvision==0.17.2 --index-url https://download.pytorch.org/whl/cpu`
|
||||
其余走现有 `pip.conf` 镜像。
|
||||
3. 创建目录骨架:`face_analysis/{__init__.py,fonts/,weights/}`、`static/annotations/`。
|
||||
4. **权重/字体已预下载到位**(内网无需联网,见根目录 `OFFLINE_ASSETS.md` 的 sha256 清单):
|
||||
- `face_analysis/weights/79999_iter.pth`(BiSeNet 主权重 ~53MB)
|
||||
- `face_analysis/weights/resnet18-5c106cde.pth`(骨干 ~45MB)
|
||||
- `face_analysis/fonts/NotoSansCJKsc-Regular.otf`(中文字体 ~16MB)
|
||||
仍需写 `scripts/download_weights.sh`(供联网环境/生产机重建),但内网执行时跳过此步、直接用已有文件。
|
||||
⚠️ **resnet18 骨干**:BiSeNet 初始化会尝试联网下载骨干,内网会失败——需把 `resnet18-5c106cde.pth` 拷到 `~/.cache/torch/hub/checkpoints/` 或改 BiSeNet 代码从 `weights/` 本地加载。
|
||||
5. 更新 `.gitignore`:忽略 `face_analysis/weights/*.pth`、`static/annotations/*`(保留 `.gitkeep`)。
|
||||
|
||||
**交付物**
|
||||
- 更新后的 `requirements.txt`、`.gitignore`
|
||||
- `scripts/download_weights.sh`
|
||||
- 目录骨架
|
||||
|
||||
**验证方法**
|
||||
```bash
|
||||
./venv/bin/python -c "import mediapipe, cv2, torch, numpy, PIL; \
|
||||
print('numpy', numpy.__version__); print('mediapipe', mediapipe.__version__)"
|
||||
```
|
||||
- 必须无 import 错误;`numpy.__version__` 以 `1.26` 开头。
|
||||
- `ls face_analysis/weights/79999_iter.pth` 存在且 >40MB;`resnet18-5c106cde.pth` 存在。
|
||||
- `ls face_analysis/fonts/NotoSansCJKsc-Regular.otf` 存在。
|
||||
|
||||
**完成标准**:上述命令全部通过,无报错。
|
||||
|
||||
---
|
||||
|
||||
## 3. 阶段二:MediaPipe 关键点检测封装
|
||||
|
||||
**开发步骤**
|
||||
1. `face_analysis/face_mesh_landmarks.py`:定义所有关键点索引常量(见技术方案 §2.3):眉心 9/151、鼻翼下缘 94、下巴 152、眼角 33/133/263/362、脸颊 234/454、鼻尖 1/4、虹膜 468–477、solvePnP 用的 61/291。
|
||||
2. `face_analysis/detector.py`:实现 `FaceMeshDetector` 单例(技术方案 §8.2),`static_image_mode=True, max_num_faces=1, refine_landmarks=True`。`detect(image_bgr)` 返回 landmarks 或 None。
|
||||
|
||||
**交付物**:`face_mesh_landmarks.py`、`detector.py`
|
||||
|
||||
**验证方法**
|
||||
- 准备一张正面人像测试图 `tests/fixtures/frontal.jpg`(agent 若无素材,用一张公开 CC0 正面人像;记录来源)。
|
||||
- 自测脚本:加载图 → `detector.detect()` → 断言返回非 None 且 landmark 数 ≥ 478(含虹膜)。
|
||||
```bash
|
||||
./venv/bin/python -m face_analysis.detector tests/fixtures/frontal.jpg
|
||||
# 期望输出:detected landmarks: 478
|
||||
```
|
||||
|
||||
**完成标准**:能稳定检测出 478 点。
|
||||
|
||||
---
|
||||
|
||||
## 4. 阶段三:姿态校验(solvePnP)
|
||||
|
||||
**开发步骤**
|
||||
1. `face_analysis/pose.py`:实现 `estimate_head_pose(landmarks, w, h)` 返回 `(yaw, pitch, roll)`,`check_frontal_face(...)` 返回 bool(技术方案 §9)。
|
||||
2. 阈值用初始值 15°,定义为模块常量便于后续标定。
|
||||
|
||||
**交付物**:`pose.py`
|
||||
|
||||
**验证方法**
|
||||
- 用正面图:`check_frontal_face` 返回 True,三个角绝对值均 < 15。
|
||||
- `hard_longhair.jpg` 略带角度,打印其 yaw/pitch/roll,确认角度比 frontal 大(用于观察姿态评分是否合理)。
|
||||
- 仓库未提供明显侧脸图;若要测 `frontal=False` 的拒绝路径,agent 自备一张明显侧脸图存为 `tests/fixtures/profile.jpg`(公开 CC0,记录来源),否则在测试中用 mock landmarks 构造大 yaw 验证阈值逻辑。
|
||||
```bash
|
||||
./venv/bin/python -m face_analysis.pose tests/fixtures/frontal.jpg # frontal=True, 三角接近 0
|
||||
./venv/bin/python -m face_analysis.pose tests/fixtures/hard_longhair.jpg # 打印角度,观察是否偏大
|
||||
```
|
||||
|
||||
**完成标准**:正面图判定为 True 且三角接近 0;阈值拒绝逻辑(大 yaw→False)有测试覆盖。
|
||||
|
||||
---
|
||||
|
||||
## 5. 阶段四:尺度校准(虹膜直径法)
|
||||
|
||||
**开发步骤**
|
||||
1. `face_analysis/calibration.py`:
|
||||
- `normalized_to_pixel`、`pixel_distance`(技术方案 §3.2)。
|
||||
- `estimate_scale_factor(landmarks, w, h)` 返回 `px_per_cm`,用虹膜左右边缘点(469/471、474/476)求直径,左右取平均,除以 `AVG_IRIS_DIAMETER_CM=1.17`(技术方案 §3.3)。
|
||||
- 虹膜点缺失时降级用眼宽(外→内眼角,均值 2.85cm)。
|
||||
|
||||
**交付物**:`calibration.py`
|
||||
|
||||
**验证方法**
|
||||
- 自测:对正面图算 `px_per_cm`,断言为正且落在合理范围(例如 1080×1920 的人像,px_per_cm 通常在 20–120 之间,agent 实测后记录实际值作为回归基线)。
|
||||
```bash
|
||||
./venv/bin/python -m face_analysis.calibration tests/fixtures/frontal.jpg
|
||||
# 期望输出:px_per_cm: <正数>
|
||||
```
|
||||
|
||||
**完成标准**:输出正数且量级合理;故意传一张无虹膜(refine 关闭模拟)能走眼宽降级不报错。
|
||||
|
||||
---
|
||||
|
||||
## 6. 阶段五:头发分割(方案 B)+ 兜底(方案 A)
|
||||
|
||||
**开发步骤**
|
||||
1. `face_analysis/hair_segmenter.py`:
|
||||
- 封装 BiSeNet face-parsing:加载 `weights/79999_iter.pth`,输入 BGR 图,输出 `hair_mask`(H×W bool,True=头发)。预处理 resize 到 512×512,推理后 resize 回原图尺寸。CPU 推理。单例加载,避免每次请求重载权重。
|
||||
- `locate_hairline_by_segmentation(hair_mask, brow_center_x, h)` 返回 `(hairline_y, hair_top_y)` 或 None(技术方案 §4.0)。
|
||||
2. `face_analysis/measure.py`(先做方案 A 部分):
|
||||
- `estimate_vertical_landmarks(...)`(方案 A,技术方案 §4.3)作为兜底。
|
||||
3. 在 `measure.py` 里实现**决策逻辑**:先尝试方案 B,合理性校验(头顶在发际线上方、发际线在眉心上方、各庭为正)通过则用 B 并标 `hairline_source="segmentation"`,否则回退 A 标 `"estimated"`(技术方案 §4 决策流程)。
|
||||
|
||||
**交付物**:`hair_segmenter.py`、`measure.py`(含纵向定位 + 决策)
|
||||
|
||||
**验证方法**
|
||||
- 自测分割:对 `frontal.jpg` 输出 `hair_mask`,断言 `hair_mask.sum() > 0`;dump 一张 mask 预览 PNG 到 `tests/output/`,目视确认头发区域正确。
|
||||
- 自测定位:方案 B 返回的 `hairline_y < brow_center_y`(发际线在眉心上方,y 向下为正)、`hair_top_y < hairline_y`。
|
||||
- **困难样本** `hard_longhair.jpg`:长发遮挡两侧,确认要么中分缝定位合理、要么合理性校验不过自动回退方案 A(`hairline_source=="estimated"`),**两种都算通过,关键是不报错、不输出离谱坐标**。
|
||||
- **降级路径**:把 mask 置空(`None`)模拟光头/分割失败,断言决策回退方案 A、`hairline_source=="estimated"`、不报错。
|
||||
```bash
|
||||
./venv/bin/python -m face_analysis.hair_segmenter tests/fixtures/frontal.jpg
|
||||
# 期望:hair pixels: <正数>, hairline_y < brow_y, hair_top_y < hairline_y
|
||||
./venv/bin/python -m face_analysis.hair_segmenter tests/fixtures/hard_longhair.jpg
|
||||
# 期望:能跑通,输出分割结果或明确的回退标记
|
||||
```
|
||||
|
||||
**完成标准**:正常头发图走分割且坐标自洽;长发/无头发图自动降级不报错。
|
||||
|
||||
---
|
||||
|
||||
## 7. 阶段六:四庭七眼测量计算
|
||||
|
||||
**开发步骤**
|
||||
1. `measure.py` 补全:
|
||||
- `measure_seven_eyes(...)`(技术方案 §5):眼宽(左右均值)、脸宽、两眼间距像素值。
|
||||
- 整合主函数 `measure_face(landmarks, hair_mask, w, h)`:
|
||||
- 调 §4 决策得 5 个纵向点 + 各庭像素长。
|
||||
- 调七眼测量。
|
||||
- 调 `estimate_scale_factor` 得 px_per_cm,全部像素 → cm。
|
||||
- 算占比:四庭各段/全脸高,眼宽/脸宽、间距/脸宽。
|
||||
- 返回结构化结果对象(含 cm、ratios、5 点像素坐标、hairline_source、head_pose)。
|
||||
2. 结果对象提供 `to_response()` 方法,输出与现有 Mock 的 `data` 字段**完全同构**(字段名对齐 `docs/接口文档.md`)。
|
||||
|
||||
**交付物**:完整 `measure.py`
|
||||
|
||||
**验证方法**
|
||||
- 对正面图跑 `measure_face`,断言:
|
||||
- 四庭 ratio 之和 ≈ 1.0(±0.02)。
|
||||
- 所有 cm 值为正且量级合理(全脸高度通常 18–24cm)。
|
||||
- 眼宽 ratio 在 0.15–0.25 之间(七眼理论 ≈ 0.2)。
|
||||
- 返回字段名与 `docs/接口文档.md` 定义逐一对齐(写一个字段对比断言)。
|
||||
```bash
|
||||
./venv/bin/python -m face_analysis.measure tests/fixtures/frontal.jpg
|
||||
# 打印完整 data dict
|
||||
```
|
||||
|
||||
**完成标准**:数值自洽、字段对齐文档。
|
||||
|
||||
---
|
||||
|
||||
## 8. 阶段七:标注图生成
|
||||
|
||||
**开发步骤**
|
||||
1. `face_analysis/annotation.py`(技术方案 §6):
|
||||
- 用打包中文字体绝对路径加载(**不静默降级**,缺字体直接抛错)。
|
||||
- `draw_gradient_horizontal_line`:**numpy 向量化**实现(技术方案 §6 修订版),全程在 `np.zeros((h,w,4))` 缓冲上画,最后 `Image.fromarray`。
|
||||
- 四庭水平分界线(渐变消失)+ 左侧四庭 cm 数值。
|
||||
- 七眼标注(上下穿插)。
|
||||
- 虚线带箭头 `draw_dashed_line_with_arrows`。
|
||||
- 规格:线/字色 `#FFFFFF`、字体 10pt、线宽 1pt、透明底 RGBA。
|
||||
2. `create_annotated_image(image_bgr, measure_result)` 返回 PIL RGBA Image。
|
||||
|
||||
**交付物**:`annotation.py`
|
||||
|
||||
**验证方法**
|
||||
- 对正面图生成标注 PNG,保存到 `tests/output/annotated.png`,断言:
|
||||
- 模式为 `RGBA`,尺寸 == 原图尺寸。
|
||||
- 存在透明像素(A==0)也存在不透明像素(A>0)。
|
||||
- 中文渲染正常(人工/agent 目视 dump 图,确认"顶庭/上庭/中庭/下庭"非方块)。
|
||||
- 性能:生成耗时记录,单张应 < 1s(验证 numpy 渐变线没有退化成逐像素)。
|
||||
```bash
|
||||
./venv/bin/python -m face_analysis.annotation tests/fixtures/frontal.jpg tests/output/annotated.png
|
||||
```
|
||||
|
||||
**完成标准**:PNG 透明底正确、中文正常、生成快。
|
||||
|
||||
---
|
||||
|
||||
## 9. 阶段八:接入 app.py
|
||||
|
||||
**开发步骤**
|
||||
1. 在 `app.py`(**worker 侧**)替换 `/api/v1/face/measure` 的 Mock 实现:
|
||||
- 解析三选一图片输入(沿用现有 URL/base64/file 处理;URL 需下载,base64 需去前缀解码)。
|
||||
- 校验:大小 ≤1MB(1006)、可解码(1008)、分辨率用**短边/长边**判断(1002,技术方案 §8.3 修订版)。**门槛做成可配置**:读环境变量 `MIN_SHORT_SIDE`(默认 600)、`MIN_LONG_SIDE`(默认 800),不要硬编码(见 §14)。
|
||||
- `detector.detect` → None 则 1001。
|
||||
- `check_frontal_face` → False 则 1003。
|
||||
- `hair_segmenter` 取 mask(失败传 None,由 measure 内部兜底)。
|
||||
- `measure_face` → `create_annotated_image`。
|
||||
- **⚠️ 拆分架构:返回 base64,不落盘不拼 URL**。`data["annotated_image_base64"] = base64(png)`,`return ok(data)`。落盘成 `annotated_image_url` 由网关完成(见架构文档 §9)。**worker 不写 static、不拼 hair.xiangsilian.com URL**。
|
||||
2. 模型单例在模块加载时初始化(detector、segmenter),避免每请求重建;BiSeNet `.to('cuda' if available)`。
|
||||
3. **鉴权中间件**:worker 增加校验 `X-Internal-Token` 的依赖/中间件,密码来自 worker 配置文件 `accept_passwords` 列表,不匹配返回 HTTP 401(架构文档 §7)。`/health` 不校验(供网关探测)。
|
||||
4. 异常兜底:未预期异常返回 `err(1007, ...)`。
|
||||
|
||||
**交付物**:更新后的 `app.py`
|
||||
|
||||
**验证方法**(本地起 worker)
|
||||
> 默认门槛已是 600/800,`frontal.jpg` 直接放行。worker 已加鉴权,需带 `X-Internal-Token` 头(值取 worker 配置的密码;本地测试可设一个测试密码)。
|
||||
```bash
|
||||
./venv/bin/uvicorn app:app --host 127.0.0.1 --port 8000 &
|
||||
F=http://127.0.0.1:8000/api/v1/face/measure
|
||||
H="X-Internal-Token: testpass" # 与本地 worker 配置一致
|
||||
# 0) 正常图 → code==0,data 含 four_courts/seven_eyes/annotated_image_base64/hairline_source/head_pose
|
||||
curl -s -H "$H" -X POST $F -F image_file=@tests/fixtures/frontal.jpg | python -m json.tool
|
||||
# 1002/1001/1008/1006 同样带 -H "$H"
|
||||
curl -s -H "$H" -X POST $F -F image_file=@tests/fixtures/lowres.png # 1002
|
||||
curl -s -H "$H" -X POST $F -F image_file=@tests/fixtures/landscape.jpg # 1001
|
||||
curl -s -H "$H" -X POST $F -F image_file=@tests/fixtures/corrupt.bin # 1008
|
||||
head -c 1100000 /dev/urandom > /tmp/oversize.bin
|
||||
curl -s -H "$H" -X POST $F -F image_file=@/tmp/oversize.bin # 1006
|
||||
# 鉴权) 不带 token → HTTP 401
|
||||
curl -s -o /dev/null -w "%{http_code}\n" -X POST $F -F image_file=@tests/fixtures/frontal.jpg # 401
|
||||
# 1003) 侧脸:仓库无素材,用 mock 大 yaw 在单测中覆盖
|
||||
```
|
||||
- 正常用例 data 含 `annotated_image_base64`(**不是** URL——落盘改 URL 由网关做,见网关任务书);base64 解码后是合法 PNG。
|
||||
- 不带 token 返回 401;`/health` 不需要 token。
|
||||
- `/docs` Swagger 正常加载,该接口 schema 未破坏。
|
||||
|
||||
**完成标准**:0/1002/1001/1008/1006 五类用例返回正确 code(有现成夹具);1003 用 mock 覆盖;正常用例 data 结构与文档一致。
|
||||
|
||||
---
|
||||
|
||||
## 10. 阶段九:测试套件与回归
|
||||
|
||||
**开发步骤**
|
||||
1. `tests/test_face_measure.py`(pytest):
|
||||
- 各模块单元测试(detector/pose/calibration/segmenter/measure/annotation)。
|
||||
- 接口集成测试:用 FastAPI `TestClient` 跑 §9 的错误码用例。
|
||||
- **精度验证:见 §15 三层策略(合成真值 / 缩放不变性 / 可视化)**——这是误差验证的核心,必做。
|
||||
- 数值回归:把 `frontal.jpg` 首次跑出的四庭/七眼 cm 值记为基线,断言后续运行偏差 < 1%(防止重构回归)。
|
||||
2. `tests/fixtures/` 素材已就位(见 §14),无需再准备。
|
||||
3. 在 `docs/接口1-四庭七眼测量-技术实现方案.md` §11 待确认事项旁,补一份「实测基线数值表」。
|
||||
|
||||
**交付物**:`tests/` 目录、`pytest.ini`(或 pyproject 配置)、基线数值表
|
||||
|
||||
**验证方法**
|
||||
```bash
|
||||
./venv/bin/python -m pytest tests/ -v
|
||||
```
|
||||
- 全绿。
|
||||
|
||||
**完成标准**:`pytest` 全部通过。
|
||||
|
||||
---
|
||||
|
||||
## 11. 阶段十:worker 部署与冒烟(GPU 机 :28187)
|
||||
|
||||
**开发步骤**
|
||||
1. 在 GPU 机部署完整 `app.py` + `face_analysis` + 模型权重(见 `OFFLINE_ASSETS.md`);torch 用 CUDA 版。
|
||||
2. resnet18 骨干放入 torch 缓存(`~/.cache/torch/hub/checkpoints/`),避免联网下载。
|
||||
3. worker 配置文件写好 `accept_passwords`;uvicorn 监听 `0.0.0.0:28187`;防火墙只放行网关 IP。
|
||||
4. systemd 管理 worker 进程;确认 `/health` 模型就绪后才返回 200。
|
||||
|
||||
**交付物**:worker 部署说明 + systemd unit + worker 配置文件示例
|
||||
|
||||
**验证方法**(在网关机或被放行的机器上)
|
||||
```bash
|
||||
TOK="X-Internal-Token: <worker配置的密码>"
|
||||
# 0) 正常请求(直连 worker)→ code==0,data 含 annotated_image_base64
|
||||
curl -s -H "$TOK" -X POST http://<worker>:28187/api/v1/face/measure \
|
||||
-F image_file=@tests/fixtures/frontal.jpg | python -m json.tool
|
||||
# 鉴权) 不带 token → 401
|
||||
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://<worker>:28187/api/v1/face/measure -F image_file=@tests/fixtures/frontal.jpg
|
||||
# 就绪) /health → 200
|
||||
curl -s http://<worker>:28187/health
|
||||
nvidia-smi # 确认推理时 GPU 被占用
|
||||
```
|
||||
|
||||
**完成标准**:worker 直连返回真实数据(base64 图)、鉴权生效、`/health` 就绪、GPU 在用。
|
||||
|
||||
> 端到端(经网关的 HTTPS 冒烟)见 [`网关-开发任务书.md`](网关-开发任务书.md) 的验证。
|
||||
|
||||
---
|
||||
|
||||
## 12. 总交付清单(worker 侧 Definition of Done)
|
||||
|
||||
- [ ] `requirements.txt`(torch CUDA 版)/ `.gitignore` / `scripts/download_weights.sh`
|
||||
- [ ] `face_analysis/`:`detector.py`、`pose.py`、`calibration.py`、`hair_segmenter.py`、`measure.py`、`annotation.py`、`face_mesh_landmarks.py`、`fonts/`、`weights/`
|
||||
- [ ] `app.py` 中 `/api/v1/face/measure` 真实实现(移除该接口 Mock),**返回 `annotated_image_base64`**
|
||||
- [ ] worker 鉴权中间件(`X-Internal-Token`)+ 配置文件 + `/health` 就绪态
|
||||
- [ ] `tests/`:fixtures + 单元 + 集成 + 数值回归 + §15 三层精度,`pytest` 全绿
|
||||
- [ ] worker(GPU 机 :28187)部署冒烟通过,GPU 在用
|
||||
- [ ] worker 返回 data 含 `annotated_image_base64`、`hairline_source`、`head_pose`,业务字段与 `docs/接口文档.md` 对齐
|
||||
|
||||
> 网关侧的交付清单在 [`网关-开发任务书.md`](网关-开发任务书.md)(另一台机器开发),本任务书不含。
|
||||
|
||||
---
|
||||
|
||||
## 13. 风险与降级开关(提醒 agent)
|
||||
|
||||
1. **torch 装不上 / 太重**:若环境受限,先交付「方案 A only」版本(跳过阶段五的分割,`hairline_source` 恒为 `"estimated"`),把方案 B 标记为 TODO,但**其余阶段照常**。在交付说明里明确写出。
|
||||
2. **数值不合理**(如 cm 量级离谱):优先怀疑 px_per_cm(虹膜点是否检出)和分辨率方向判断,而非盲目调比例常数。
|
||||
3. **不确定字段命名**:以 `docs/接口文档.md` 为唯一权威,冲突时以文档为准并在 PR 说明里指出。
|
||||
|
||||
---
|
||||
|
||||
## 14. 测试素材清单(已就位于 `tests/fixtures/`)
|
||||
|
||||
以下夹具**已全部创建完毕**,agent 直接使用即可,无需再拷贝/生成:
|
||||
|
||||
| 文件 | 尺寸(W×H) | 大小 | 来源 | 用途 |
|
||||
|------|-----------|------|------|------|
|
||||
| `frontal.jpg` | 682×811 | 94KB | 真实样本(原 `image/test.jpg`) | **主用例**:阶段二~八全部功能验证 + 数值基线 |
|
||||
| `hard_longhair.jpg` | 864×1152 | 131KB | 真实样本(原 `image/qwerqwe.jpg`) | **困难样本**:分割鲁棒性、`max_num_faces=1` 只取最大脸、姿态 |
|
||||
| `lowres.png` | 406×571 | 226KB | 真实样本(原 `image/image.png`,已带标注线) | **1002 拒绝用例**(短边 406 < 600);勿当干净输入 |
|
||||
| `landscape.jpg` | 1000×1200 | 114KB | 程序生成(非人脸风景) | **1001 用例**:无法识别人像 |
|
||||
| `corrupt.bin` | — | 2KB | 程序生成(伪 PNG 头 + 垃圾字节) | **1008 用例**:无法解码 |
|
||||
| _(1006 超大图)_ | — | >1MB | **测试时动态生成,不入库** | **1006 用例**:超过 1MB |
|
||||
|
||||
> **1006 超大图不提交进 git**(避免仓库膨胀,内容是随机噪声无信息量)。在 `tests/conftest.py` 里用 pytest fixture 临时生成;测 1006 仅看字节数、无需合法图片:
|
||||
> ```python
|
||||
> @pytest.fixture
|
||||
> def oversize_file(tmp_path):
|
||||
> p = tmp_path / "oversize.bin"
|
||||
> p.write_bytes(b"\x00" * (1_100_000)) # 1.1MB,刚过 1MB 红线
|
||||
> return p
|
||||
> ```
|
||||
> 手动 curl 验证时临时造一个即可:`head -c 1100000 /dev/urandom > /tmp/oversize.bin`
|
||||
|
||||
> 仍缺:明显侧脸图(测 1003)。无合规素材,agent 用 mock landmarks 构造大 yaw 验证阈值逻辑即可(见 §4 阶段三)。
|
||||
|
||||
### 分辨率门槛(已放宽,可配置)
|
||||
|
||||
- **默认门槛下调为:短边 ≥ 600、长边 ≥ 800**(环境变量 `MIN_SHORT_SIDE=600`、`MIN_LONG_SIDE=800`,技术方案 §8.3 已同步)。
|
||||
- 该门槛下:`frontal.jpg`(682×811)、`hard_longhair.jpg`(864×1152) 放行;`lowres.png`(406×571) 被 1002 拒绝——正好作拒绝用例,**功能测试无需再绕过校验**。
|
||||
- **门槛必须做成可配置,不要硬编码**:生产可通过环境变量随时调整,无需改代码。
|
||||
|
||||
**待确认事项(提交给需求方,不阻塞开发)**:
|
||||
1. 600/800 是否合适?过低会牺牲测量精度(虹膜/关键点像素太少),过高会拒掉大量真实上传图。建议上线后按实际拒绝率/精度反馈再调。
|
||||
2. `hard_longhair.jpg` 这类长发遮挡发际线的图,方案 B 大概率只能定位到中分缝;若分割结果不可靠应自动回退方案 A(`hairline_source="estimated"`)——确认这是可接受行为。
|
||||
|
||||
---
|
||||
|
||||
## 15. 精度 / 误差验证策略(三层)
|
||||
|
||||
> **核心认知**:管线分两层——**测量数学**(landmarks+尺度→cm)可以构造精确真值验证;**MediaPipe 检测**(图→landmarks 落点)无法合成真值,只能人工标注或间接验证。绝大多数可控 bug 在数学层,务必重点覆盖。
|
||||
|
||||
### Tier 1 — 合成真值,精确验证测量数学(必做,核心)
|
||||
|
||||
自己构造一组「已知真值」的关键点:坐标和 `px_per_cm` 都由测试设定,因此每一段的 cm/占比真值已知,算出来必须**分毫不差**(误差仅来自浮点,断言 < 1e-6)。这能精确验证 `calibration` / `measure_seven_eyes` / 方案A 推算 / 占比公式。
|
||||
|
||||
```python
|
||||
# tests/test_geometry_truth.py
|
||||
import numpy as np
|
||||
|
||||
class _LM: # 模拟 MediaPipe landmark.x/.y/.z
|
||||
def __init__(self, x, y, z=0.0): self.x, self.y, self.z = x, y, z
|
||||
|
||||
def build_synthetic_landmarks(px_per_cm=50.0, W=1000, H=1000):
|
||||
"""按已知 cm 几何摆放关键点,返回 (landmarks_list, ground_truth_dict)"""
|
||||
cx = W / 2
|
||||
def Y(cm_from_top): # cm → 归一化 y
|
||||
return (cm_from_top * px_per_cm) / H
|
||||
def X(px):
|
||||
return px / W
|
||||
|
||||
# 设定真值(cm):从头顶往下
|
||||
gt = {"top_court_cm": 4.0, "upper_court_cm": 5.0,
|
||||
"middle_court_cm": 6.0, "lower_court_cm": 5.0,
|
||||
"eye_width_cm": 3.0, "inter_eye_cm": 3.4, "face_width_cm": 14.0,
|
||||
"px_per_cm": px_per_cm}
|
||||
y_hairtop = 2.0
|
||||
y_hairline = y_hairtop + gt["top_court_cm"]
|
||||
y_brow = y_hairline + gt["upper_court_cm"]
|
||||
y_nose = y_brow + gt["middle_court_cm"]
|
||||
y_chin = y_nose + gt["lower_court_cm"]
|
||||
|
||||
lm = {i: _LM(X(cx), 0.0) for i in range(478)} # 占位
|
||||
# 纵向中轴点
|
||||
lm[9] = _LM(X(cx), Y(y_brow)); lm[151] = _LM(X(cx), Y(y_brow))
|
||||
lm[94] = _LM(X(cx), Y(y_nose))
|
||||
lm[152]= _LM(X(cx), Y(y_chin))
|
||||
# 七眼横向点(按真值 px 摆位,y 任意取眉下一行)
|
||||
ew = gt["eye_width_cm"] * px_per_cm
|
||||
ie = gt["inter_eye_cm"] * px_per_cm
|
||||
fw = gt["face_width_cm"] * px_per_cm
|
||||
eye_y = Y(y_brow + 2.0)
|
||||
lm[133] = _LM(X(cx - ie/2), eye_y); lm[33] = _LM(X(cx - ie/2 - ew), eye_y)
|
||||
lm[362] = _LM(X(cx + ie/2), eye_y); lm[263] = _LM(X(cx + ie/2 + ew), eye_y)
|
||||
lm[234] = _LM(X(cx - fw/2), eye_y); lm[454] = _LM(X(cx + fw/2), eye_y)
|
||||
# 虹膜边缘点:直径 = 1.17cm * px_per_cm,使尺度可被精确反解
|
||||
d = 1.17 * px_per_cm
|
||||
lm[469] = _LM(X(cx - ie/2 - ew/2 - d/2), eye_y); lm[471] = _LM(X(cx - ie/2 - ew/2 + d/2), eye_y)
|
||||
lm[474] = _LM(X(cx + ie/2 + ew/2 - d/2), eye_y); lm[476] = _LM(X(cx + ie/2 + ew/2 + d/2), eye_y)
|
||||
return [lm[i] for i in range(478)], gt
|
||||
|
||||
def test_scale_factor_exact():
|
||||
lm, gt = build_synthetic_landmarks(px_per_cm=50.0)
|
||||
from face_analysis.calibration import estimate_scale_factor
|
||||
assert abs(estimate_scale_factor(lm, 1000, 1000) - gt["px_per_cm"]) < 1e-6
|
||||
|
||||
def test_seven_eyes_exact():
|
||||
lm, gt = build_synthetic_landmarks()
|
||||
from face_analysis.measure import measure_seven_eyes
|
||||
r = measure_seven_eyes(lm, 1000, 1000)
|
||||
pc = gt["px_per_cm"]
|
||||
assert abs(r["eye_width_px"]/pc - gt["eye_width_cm"]) < 1e-6
|
||||
assert abs(r["face_width_px"]/pc - gt["face_width_cm"]) < 1e-6
|
||||
assert abs(r["inter_eye_distance_px"]/pc - gt["inter_eye_cm"]) < 1e-6
|
||||
# 方案A 推算、四庭占比同理,用 gt 的中/下庭做输入,断言推算的上/顶庭与 gt 关系一致
|
||||
```
|
||||
|
||||
> 注意:方案 A 因为是「按比例推算」,它推出的上/顶庭**不会**等于任意设定的真值——Tier 1 对方案 A 只验证「推算公式按既定比例正确执行」(给定中下庭,输出符合 0.25/0.22 比例关系),而非验证它贴近真实脸。这正是方案 A 循环论证局限的体现,文档已说明。方案 B 的真值验证用合成 mask(已知头发区域上沿)走 `locate_hairline_by_segmentation`。
|
||||
|
||||
### Tier 2 — 缩放不变性,真实图上可运行(必做)
|
||||
|
||||
用真实 `frontal.jpg` 跑完整管线,再把图**等比放大 2×** 重跑。物理量应满足:
|
||||
|
||||
- **占比(ratio)完全不变**(±0.5%)——放大不改变比例。
|
||||
- **cm 值基本不变**(±2%)——因为 px_per_cm 也随之放大,虹膜法自洽。
|
||||
|
||||
这用**真实 MediaPipe 输出**验证尺度处理无 bug,不需要人工真值。若放大后 cm 值漂移大,说明尺度链路有问题。
|
||||
|
||||
```python
|
||||
def test_scale_invariance():
|
||||
import cv2
|
||||
img = cv2.imread("tests/fixtures/frontal.jpg")
|
||||
big = cv2.resize(img, None, fx=2, fy=2, interpolation=cv2.INTER_CUBIC)
|
||||
r1 = run_measure(img); r2 = run_measure(big)
|
||||
for k in ["top","upper","middle","lower"]:
|
||||
assert abs(r1.ratio[k] - r2.ratio[k]) < 0.005 # 占比不变
|
||||
assert abs(r1.cm[k] - r2.cm[k]) / r1.cm[k] < 0.02 # cm 近似不变
|
||||
```
|
||||
|
||||
### Tier 3 — 检测落点定性评估(人工真值,抽样)
|
||||
|
||||
MediaPipe 落点准不准没有合成真值,只能:
|
||||
1. **可视化叠加**:把 5 个纵向点 + 眼角点画回原图存 PNG,人工/agent 目视确认落点正确(眉心在眉间、下巴在下颌最低点等)。
|
||||
2. **抽样人工标注**:对 2~3 张图手工标注真值关键点像素坐标存 `tests/fixtures/*_truth.json`,断言 MediaPipe 输出与标注的像素偏差 < 全脸高度的 3%。
|
||||
|
||||
```python
|
||||
def test_landmark_overlay():
|
||||
"""生成叠加图供人工核验,并断言关键点落在图像合理区域内"""
|
||||
# 画点存 tests/output/frontal_landmarks.png,断言各点坐标在 [0,W]/[0,H] 且顺序自上而下
|
||||
```
|
||||
|
||||
### 误差预期对照(写进基线表)
|
||||
|
||||
| 误差来源 | 验证手段 | 预期 |
|
||||
|----------|----------|------|
|
||||
| 测量数学(尺度/占比/七眼/脸宽) | Tier 1 合成真值 | ≈ 0(< 1e-6) |
|
||||
| 尺度链路一致性 | Tier 2 缩放不变性 | 占比 < 0.5%,cm < 2% |
|
||||
| MediaPipe 落点 | Tier 3 人工标注抽样 | < 3% 全脸高 |
|
||||
| 虹膜个体差异 + 透视 | 无法消除,文档声明 | cm ±5~15%(离虹膜平面越远越大) |
|
||||
| 方案 A 推算上/顶庭 | 固有局限 | 真实脸偏差可达 ±15%,故优先方案 B |
|
||||
|
||||
**完成标准(补充到阶段九)**:Tier 1 全部断言 < 1e-6;Tier 2 通过;Tier 3 叠加图人工确认 OK。
|
||||
|
||||
---
|
||||
|
||||
> **任务书版本**: v1.5 | **创建日期**: 2026-06-13(v1.5:拆出网关任务书到独立文档,本书聚焦 worker 侧)| 配套技术方案 v2.0 / 系统架构 v1.0 / 网关任务书 v1.0
|
||||
@@ -1,880 +0,0 @@
|
||||
# 接口 1:四庭七眼测量 — 技术实现方案
|
||||
|
||||
> 基于 MediaPipe Face Mesh(468 关键点)测量「眉心以下」+ 人脸解析分割(BiSeNet)获取「真实发际线/头顶」+ 人脸比例先验作为兜底
|
||||
|
||||
> 📌 **运行位置**:本文档描述的全部算法逻辑运行在 **高性能 worker(GPU 机)** 上,不在外网网关。系统已拆分为「外网网关 + worker」两层,详见 [`系统架构-网关与高性能后端.md`](系统架构-网关与高性能后端.md)。相对单机版有两处差异:
|
||||
> 1. **GPU 加速**:BiSeNet 改用 CUDA 推理(torch GPU 版),MediaPipe 仍 CPU。
|
||||
> 2. **标注图返回 base64**:worker **不落盘、不拼 URL**,把标注 PNG 以 `annotated_image_base64` 返回;落盘成 `annotated_image_url` 由网关完成(见架构文档 §9)。本文后续 §6/§8 的"保存到 static + 返回 URL"仅适用于单机版,拆分后改为返回 base64。
|
||||
> 3. **资源宽裕**:32G + GPU,无需单机版的 2核4G 并发限制;worker 自身并发=1 由网关保证。
|
||||
|
||||
---
|
||||
|
||||
## 1. 模型选型
|
||||
|
||||
### 1.1 调研结论
|
||||
|
||||
调研了以下人脸关键点检测模型:
|
||||
|
||||
| 模型 | 关键点数 | 覆盖范围 | Python 支持 | 备注 |
|
||||
|------|----------|----------|-------------|------|
|
||||
| **MediaPipe Face Mesh** | 468 / 478 | 额头中部 → 下巴(不含发际线以上) | ✅ `mediapipe` 包 | Google 官方,实时性能好 |
|
||||
| dlib 68-point (300-W) | 68 | 眉毛 → 下巴 | ✅ `dlib` | 经典方法,无额头覆盖 |
|
||||
| WFLW 98-point | 98 | 眉毛 → 下巴(额头仅 2 点) | ⚠️ 需额外模型 | 仍无头顶/发际线 |
|
||||
| 3DDFA_V2 | 68+ 3D mesh | 类似 MediaPipe | ⚠️ 推理较慢 | 3D 重建更完整 |
|
||||
| SPIGA | 68 | 眉毛 → 下巴 | ✅ | 实时性不如 MediaPipe |
|
||||
|
||||
**结论:没有任何「关键点检测模型」能直接给出「头顶」和「真实发际线」坐标**——所有关键点模型在额头以上方向都有盲区。
|
||||
|
||||
但「**人脸解析 / 头发分割模型**」可以直接把头发区域分割出来,从而得到**真实**的发际线与头顶位置(详见 §1.4 与 §4 方案 B)。因此本方案采用**双策略**:
|
||||
|
||||
- **眉心以下(中庭、下庭、七眼)**:MediaPipe Face Mesh 468 点直接实测,精度高。
|
||||
- **眉心以上(上庭、顶庭,即发际线与头顶)**:
|
||||
- **方案 B(主)**:人脸解析分割(BiSeNet)提取真实发际线/头顶 —— 这两庭是**真实测量值**。
|
||||
- **方案 A(兜底)**:当分割失败、光头、或被帽子/刘海遮挡时,退化为「人脸比例推算」。
|
||||
|
||||
> ⚠️ **重要**:旧版本仅用方案 A,存在「循环论证」缺陷 —— 用三庭标准比例反推发际线、再据此算占比,输出的顶庭/上庭占比几乎等于输入常数,不反映真实脸型。引入方案 B 后,顶上两庭才成为真正的测量结果。方案 A 仅作降级使用。
|
||||
|
||||
### 1.2 为什么用 468 点而非 478 点
|
||||
|
||||
478 点比 468 点多出 10 个虹膜(iris)关键点(索引 468–477),仅用于眼球追踪。四庭七眼测量不需要虹膜数据,468 点完全满足需求。使用经典 `mp.solutions.face_mesh` API,模型内置于 pip 包中,无需单独下载 `.task` 文件。
|
||||
|
||||
### 1.3 国内安装方式
|
||||
|
||||
```bash
|
||||
# 使用清华镜像安装 mediapipe 及依赖
|
||||
pip install mediapipe opencv-python pillow numpy -i https://pypi.tuna.tsinghua.edu.cn/simple/
|
||||
```
|
||||
|
||||
经典 Solutions API 模型文件已打包在 wheel 包内(路径:`mediapipe/modules/face_landmark/`),安装后直接可用,无需额外下载。
|
||||
|
||||
> ⚠️ **版本兼容性坑**:MediaPipe 0.10.x 对 numpy 2.x 支持不稳定,常出现 import 崩溃。**必须锁定 `numpy<2`(推荐 1.26.x)**,详见 §10。
|
||||
|
||||
### 1.4 发际线 / 头顶分割模型(方案 B 依赖)
|
||||
|
||||
关键点模型够不到的额头以上区域,用**人脸解析(face parsing)**模型补齐。这类模型对整张脸做像素级语义分割,类别中包含 `hair`(头发):
|
||||
|
||||
| 模型 | 训练集 | 类别数 | 体积 | Python 支持 | 备注 |
|
||||
|------|--------|--------|------|-------------|------|
|
||||
| **BiSeNet (face-parsing.PyTorch)** | CelebAMask-HQ | 19(含 hair/skin/眉眼鼻嘴等) | ~50 MB | ✅ PyTorch | 最常用,CPU 可跑(~0.3–1s/张) |
|
||||
| MODNet | 人像 matting | 前景/背景 | ~25 MB | ✅ | 只分前景,不区分头发 |
|
||||
| SegFormer-b0 face-parsing | CelebAMask-HQ | 19 | ~15 MB | ✅ HuggingFace | 更轻,需 transformers |
|
||||
|
||||
**选型:BiSeNet(face-parsing.PyTorch)**,社区成熟、权重易得、19 类直接含 `hair`。
|
||||
|
||||
拿到分割 mask 后:
|
||||
- **真实发际线** = 沿面部中轴线(用 §4 的 `brow_center_x` 作为 x),从上往下扫描,**头发区域 → 皮肤区域**的第一个交界 y 坐标。
|
||||
- **头顶** = 头发 mask 的**最高点**(最小 y)。
|
||||
|
||||
> 权重需单独下载,放入 `face_analysis/weights/`,不入 git(写进 `.gitignore`):
|
||||
> - `79999_iter.pth`(~53 MB)— BiSeNet 主权重。
|
||||
> - `resnet18-5c106cde.pth`(~45 MB)— BiSeNet 用的 resnet18 骨干。**离线/内网环境必须预放**:BiSeNet 初始化时会尝试用 `torch.utils.model_zoo` 联网下载该骨干,内网会失败。需把它放进 torch hub 缓存(`~/.cache/torch/hub/checkpoints/`)或改代码从本地路径加载。
|
||||
>
|
||||
> **本仓库已预先下载好上述权重 + 字体**(见根目录 `OFFLINE_ASSETS.md` 的 sha256 清单),内网机器无需联网,直接使用。
|
||||
|
||||
---
|
||||
|
||||
## 2. 关键点索引映射
|
||||
|
||||
MediaPipe Face Mesh 对 468 个点按固定拓扑编号,以下是四庭七眼测量所需的关键索引:
|
||||
|
||||
### 2.1 四庭纵向关键点
|
||||
|
||||
```
|
||||
★ 头顶 (hair_top) ← 方案A推算,非MediaPipe直接检测
|
||||
│ 顶庭 (~22%)
|
||||
★ 发际线 (hairline) ← 方案A推算,非MediaPipe直接检测
|
||||
│ 上庭 (~25%)
|
||||
★ 眉心 (brow_center) ← 索引 9 或 151(glabella,双眉间)
|
||||
│ 中庭 (~28%)
|
||||
★ 鼻翼下缘 (nose_bottom) ← 索引 94(subnasale / 人中顶部)
|
||||
│ 下庭 (~25%)
|
||||
★ 下巴尖 (chin_tip) ← 索引 152(menton)
|
||||
```
|
||||
|
||||
| 测量点 | MediaPipe 索引 | 说明 |
|
||||
|--------|----------------|------|
|
||||
| 头顶 | **无直接索引** | 由发际线 + 顶庭比例向上推算 |
|
||||
| 发际线 | **无直接索引** | 由眉心 + 上庭比例向上推算 |
|
||||
| 眉心 | **9** 或 **151** | glabella,双眉间中心点;两个点取中点 |
|
||||
| 鼻翼下缘 | **94** | subnasale,鼻小柱底部与人中交界处 |
|
||||
| 下巴尖 | **152** | menton,下颌最低点 |
|
||||
|
||||
### 2.2 七眼横向关键点
|
||||
|
||||
```
|
||||
左脸 左眼外角 左眼内角 右眼内角 右眼外角 右脸
|
||||
│ │ │ │ │ │
|
||||
234 ←────── 33 ───── 133 ──── 两眼间距 ──── 362 ───── 263 ──────→ 454
|
||||
│ │← 眼宽 →│ ← 两眼间距 → │← 眼宽 →│ │
|
||||
│←──────────────── 脸宽 ──────────────────────────────→│
|
||||
```
|
||||
|
||||
| 测量项目 | 左端索引 | 右端索引 | 说明 |
|
||||
|----------|----------|----------|------|
|
||||
| 左眼宽度 | 33(外眼角) | 133(内眼角) | 水平距离 |
|
||||
| 右眼宽度 | 263(外眼角) | 362(内眼角) | 水平距离 |
|
||||
| 两眼间距 | 133(左内眼角) | 362(右内眼角) | 内眦间距 |
|
||||
| 脸宽 | 234(左颧弓) | 454(右颧弓) | 面部最宽处水平距离 |
|
||||
|
||||
> 注:脸宽使用 face oval 轮廓上颧弓高度对应的点。索引 234(左)和 454(右)位于 cheekbone 高度,是 face oval 路径 `...→234→127→162→21→...` 和 `...→454→356→389→251→...` 上的点。
|
||||
|
||||
### 2.3 参考索引速查表
|
||||
|
||||
| 索引 | 解剖位置 | 所属区域 |
|
||||
|------|----------|----------|
|
||||
| 4 | 鼻尖 (nose tip) | 鼻子 |
|
||||
| 9, 151 | 眉间 / glabella | 眉心 |
|
||||
| 10 | 额头顶端 (forehead top) — 不是发际线 | 额头 |
|
||||
| 33 | 左眼外眼角 | 左眼 |
|
||||
| 94 | 鼻翼下缘 / subnasale | 鼻子底部 |
|
||||
| 133 | 左眼内眼角 | 左眼 |
|
||||
| 152 | 下巴尖 / menton | 下巴 |
|
||||
| 234 | 左脸颧弓处 | 面部轮廓 |
|
||||
| 263 | 右眼外眼角 | 右眼 |
|
||||
| 362 | 右眼内眼角 | 右眼 |
|
||||
| 454 | 右脸颧弓处 | 面部轮廓 |
|
||||
|
||||
**Face Oval 连通路径**(面部轮廓线,用于验证脸宽点选择):
|
||||
```
|
||||
10→338→297→332→284→251→389→356→454→323→361→288→397→365→379→378→400→377→152→148→176→149→150→136→172→58→132→93→234→127→162→21→54→103→67→109→(回到10)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 厘米换算方案
|
||||
|
||||
### 3.1 转换原理
|
||||
|
||||
MediaPipe 输出的关键点坐标是 **归一化像素坐标**:
|
||||
- `x ∈ [0, 1]`,归一化于图像宽度
|
||||
- `y ∈ [0, 1]`,归一化于图像高度
|
||||
- `z` 为相对深度(以头部中心为零点,向镜头方向为负)
|
||||
|
||||
需要将归一化坐标转为像素坐标,再通过**尺度参照物**转为厘米。
|
||||
|
||||
### 3.2 像素坐标恢复
|
||||
|
||||
```python
|
||||
def normalized_to_pixel(landmark, image_width, image_height):
|
||||
"""归一化坐标 → 像素坐标"""
|
||||
x_px = landmark.x * image_width
|
||||
y_px = landmark.y * image_height
|
||||
return x_px, y_px
|
||||
|
||||
def pixel_distance(p1, p2):
|
||||
"""两点像素距离"""
|
||||
return ((p1[0] - p2[0])**2 + (p1[1] - p2[1])**2) ** 0.5
|
||||
```
|
||||
|
||||
### 3.3 尺度校准:虹膜直径法
|
||||
|
||||
**原理**:人类虹膜直径高度稳定,成人平均 **11.7 mm**(标准差 ≈ 0.5 mm,约 4%),可作为天然标尺。
|
||||
|
||||
```python
|
||||
AVG_IRIS_DIAMETER_CM = 1.17 # 11.7 mm
|
||||
|
||||
# MediaPipe 虹膜关键点(开启 refine_landmarks=True 后可用)
|
||||
IRIS_LEFT_CENTER = 468 # 左眼虹膜中心
|
||||
IRIS_RIGHT_CENTER = 473 # 右眼虹膜中心
|
||||
|
||||
# 虹膜边界点(取上/下或左/右两个边缘点计算直径)
|
||||
IRIS_LEFT_LEFT = 469 # 左虹膜左边缘
|
||||
IRIS_LEFT_RIGHT = 471 # 左虹膜右边缘
|
||||
IRIS_RIGHT_LEFT = 474 # 右虹膜左边缘
|
||||
IRIS_RIGHT_RIGHT = 476 # 右虹膜右边缘
|
||||
|
||||
def estimate_scale_factor(landmarks, image_width, image_height):
|
||||
"""通过虹膜直径估算 px → cm 缩放因子
|
||||
|
||||
Returns:
|
||||
px_per_cm: 每厘米对应多少像素
|
||||
"""
|
||||
# 左眼虹膜像素直径
|
||||
iris_left_l = normalized_to_pixel(landmarks[IRIS_LEFT_LEFT], image_width, image_height)
|
||||
iris_left_r = normalized_to_pixel(landmarks[IRIS_LEFT_RIGHT], image_width, image_height)
|
||||
iris_left_diameter_px = pixel_distance(iris_left_l, iris_left_r)
|
||||
|
||||
# 右眼虹膜像素直径
|
||||
iris_right_l = normalized_to_pixel(landmarks[IRIS_RIGHT_LEFT], image_width, image_height)
|
||||
iris_right_r = normalized_to_pixel(landmarks[IRIS_RIGHT_RIGHT], image_width, image_height)
|
||||
iris_right_diameter_px = pixel_distance(iris_right_l, iris_right_r)
|
||||
|
||||
# 取平均,减少误差
|
||||
avg_iris_diameter_px = (iris_left_diameter_px + iris_right_diameter_px) / 2
|
||||
|
||||
px_per_cm = avg_iris_diameter_px / AVG_IRIS_DIAMETER_CM
|
||||
return px_per_cm
|
||||
```
|
||||
|
||||
> **注意**:虹膜关键点(索引 468–477)需要 `FaceMesh(refine_landmarks=True)` 才会输出。如果不启用 `refine_landmarks`,可用**眼宽**(外眼角→内眼角)作为替代标尺,人类平均眼裂宽度约 **27–30 mm**,精度略低。
|
||||
|
||||
> ⚠️ **透视局限(务必在 API 文档/返回里注明)**:虹膜法得到的 `px_per_cm` 只在**虹膜所在的深度平面**精确。下巴、额头、头顶与虹膜不共面,2D 照片存在透视投影,因此纵向(四庭)的 cm 换算会带系统误差,离虹膜平面越远(如头顶)误差越大。返回的 cm 值应理解为**近似值**,而非全脸恒定尺度下的精确测量。比例(ratio)受透视影响小于绝对 cm 值,建议前端优先展示比例。
|
||||
|
||||
### 3.4 备用校准:人脸比例法
|
||||
|
||||
若虹膜数据不可用,也可用 460 点基础模型的脸宽比例估算:
|
||||
|
||||
```python
|
||||
# 基于三庭五眼理想比例
|
||||
# 脸宽 (234→454 px) ≈ 5 眼宽 ≈ 5 × (脸宽的 1/5)
|
||||
# 已知脸宽距离的像素值,参考人脸统计平均脸宽 ~14 cm (女性) ~15 cm (男性)
|
||||
# 得 px_per_cm = face_width_px / 14.5 (粗略)
|
||||
```
|
||||
|
||||
此方法误差较大(±15%),建议优先使用虹膜法。对测量误差要求不严格的场景可接受。
|
||||
|
||||
---
|
||||
|
||||
## 4. 头顶 & 发际线定位(方案 B 主 / 方案 A 兜底)
|
||||
|
||||
> **决策流程**:先跑方案 B(分割)。若分割成功且发际线/头顶落在合理范围(发际线在眉心上方、头顶在发际线上方、各庭长度为正),用方案 B 结果;否则记录 `hairline_source = "estimated"` 并回退方案 A。方案 B 成功时 `hairline_source = "segmentation"`,需在返回 `data` 里透出该字段,方便前端/业务区分真实测量与估算。
|
||||
|
||||
### 4.0 方案 B(主):分割提取真实发际线 & 头顶
|
||||
|
||||
```python
|
||||
def locate_hairline_by_segmentation(hair_mask, brow_center_x, image_height):
|
||||
"""
|
||||
输入: hair_mask (H×W bool/uint8, True=头发像素), 面部中轴线 x, 图高
|
||||
输出: (hairline_y, hair_top_y) 像素坐标; 失败返回 None
|
||||
"""
|
||||
import numpy as np
|
||||
if hair_mask is None or hair_mask.sum() == 0:
|
||||
return None # 光头 / 分割失败 → 交给方案 A
|
||||
|
||||
cx = int(round(brow_center_x))
|
||||
# 在中轴线附近取一个窄列带(±3px)求稳,避免单列噪声
|
||||
band = hair_mask[:, max(0, cx - 3): cx + 4]
|
||||
col = band.any(axis=1) # 每一行在该列带是否有头发
|
||||
hair_rows = np.where(col)[0]
|
||||
if hair_rows.size == 0:
|
||||
return None
|
||||
|
||||
# 发际线 = 中轴线上「头发→皮肤」交界:即该列带头发像素中最靠下的连续头发块的下沿
|
||||
# 简化:取中轴线列上头发区域的最大 y(向下为正)作为发际线
|
||||
hairline_y = int(hair_rows.max())
|
||||
|
||||
# 头顶 = 整张头发 mask 的最高点(最小 y),更鲁棒地用全图而非单列
|
||||
top_rows = np.where(hair_mask.any(axis=1))[0]
|
||||
hair_top_y = int(top_rows.min())
|
||||
|
||||
# 合理性校验:头顶必须在发际线上方
|
||||
if hair_top_y >= hairline_y:
|
||||
return None
|
||||
return hairline_y, hair_top_y
|
||||
```
|
||||
|
||||
> 实际实现可对 mask 先做轻量形态学开运算去噪;发际线判定可改为"沿中轴线从上往下首次出现的 hair→non-hair 跳变",比单纯取 max 更贴合带刘海/碎发场景。具体阈值在拿到测试集后调。
|
||||
|
||||
### 4.1 方案 A(兜底)核心思路
|
||||
|
||||
> 仅当方案 B 不可用时启用。**注意其循环论证局限:顶上两庭为估算值,不反映真实脸型。**
|
||||
|
||||
MediaPipe 可以精确检测 **眉心、鼻翼下缘、下巴尖** 三个关键点(均位于面部中轴线)。利用「三庭五眼」标准比例,向上推算发际线和头顶位置。
|
||||
|
||||
### 4.2 比例参数
|
||||
|
||||
根据需求文档中的 Mock 数据反推(顶庭:上庭:中庭:下庭 = 22%:25%:28%:25%),以及经典三庭五眼理论(三庭等分),定义两套可选参数:
|
||||
|
||||
```
|
||||
方案比例(基于Mock数据):
|
||||
顶庭 : 上庭 : 中庭 : 下庭 = 0.22 : 0.25 : 0.28 : 0.25
|
||||
|
||||
经典三庭比例(上庭=中庭=下庭):
|
||||
上庭 : 中庭 : 下庭 = 1 : 1 : 1
|
||||
顶庭 ≈ 0.2 × 全脸高度(通过统计)
|
||||
```
|
||||
|
||||
实际采用混合策略:**以实测中庭和下庭为基准,按标准比例推算上庭和顶庭**。
|
||||
|
||||
### 4.3 推算公式
|
||||
|
||||
```python
|
||||
def estimate_vertical_landmarks(landmarks, image_width, image_height):
|
||||
"""
|
||||
输入: MediaPipe 468 landmarks + 图像尺寸
|
||||
输出: 5 个关键点像素坐标 + 各段像素距离
|
||||
"""
|
||||
# --- 1. 提取可直接检测的关键点 ---
|
||||
# 眉心 (glabella):索引 9 和 151 的中点
|
||||
glabella_9 = normalized_to_pixel(landmarks[9], image_width, image_height)
|
||||
glabella_151 = normalized_to_pixel(landmarks[151], image_width, image_height)
|
||||
brow_center_y = (glabella_9[1] + glabella_151[1]) / 2
|
||||
brow_center_x = (glabella_9[0] + glabella_151[0]) / 2
|
||||
|
||||
# 鼻翼下缘 (subnasale):索引 94
|
||||
nose_bottom = normalized_to_pixel(landmarks[94], image_width, image_height)
|
||||
|
||||
# 下巴尖 (menton):索引 152
|
||||
chin_tip = normalized_to_pixel(landmarks[152], image_width, image_height)
|
||||
|
||||
# --- 2. 计算实测段长度 (像素) ---
|
||||
middle_court_px = abs(brow_center_y - nose_bottom[1]) # 眉心 → 鼻翼下缘
|
||||
lower_court_px = abs(nose_bottom[1] - chin_tip[1]) # 鼻翼下缘 → 下巴尖
|
||||
|
||||
# --- 3. 推算上庭和顶庭 ---
|
||||
# 以中庭和下庭的平均值作为基准"一等份"(减小个体差异)
|
||||
one_unit_px = (middle_court_px + lower_court_px) / 2 # 一等份 ≈ 中庭/下庭的平均
|
||||
|
||||
# 上庭 ≈ 一等份(经典三庭等分)或根据实际中庭比例微调
|
||||
upper_court_px = one_unit_px * (0.25 / 0.265) # 上庭 25% vs 中庭/下庭平均 26.5%
|
||||
|
||||
# 顶庭 ≈ 中庭 × (22%/28%) 或 ≈ 0.79 × one_unit_px
|
||||
top_court_px = one_unit_px * (0.22 / 0.28) # 约 0.786 × one_unit_px
|
||||
|
||||
# --- 4. 推算头顶和发际线 Y 坐标 ---
|
||||
hairline_y = brow_center_y - upper_court_px
|
||||
hair_top_y = hairline_y - top_court_px
|
||||
|
||||
# --- 5. 计算全脸总高度 ---
|
||||
face_total_height_px = hair_top_y - chin_tip[1] # 注意 Y 轴方向(向下为正)
|
||||
|
||||
return {
|
||||
"hair_top": (brow_center_x, hair_top_y),
|
||||
"hairline": (brow_center_x, hairline_y),
|
||||
"brow_center": (brow_center_x, brow_center_y),
|
||||
"nose_bottom": (nose_bottom[0], nose_bottom[1]),
|
||||
"chin_tip": (chin_tip[0], chin_tip[1]),
|
||||
# 各段像素高度
|
||||
"top_court_px": top_court_px,
|
||||
"upper_court_px": upper_court_px,
|
||||
"middle_court_px": middle_court_px,
|
||||
"lower_court_px": lower_court_px,
|
||||
"face_total_height_px": face_total_height_px,
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 像素 → 厘米转换
|
||||
|
||||
```python
|
||||
def pixels_to_cm(vertical_result, px_per_cm):
|
||||
"""将像素距离转为厘米"""
|
||||
return {
|
||||
"top_court_cm": vertical_result["top_court_px"] / px_per_cm,
|
||||
"upper_court_cm": vertical_result["upper_court_px"] / px_per_cm,
|
||||
"middle_court_cm": vertical_result["middle_court_px"] / px_per_cm,
|
||||
"lower_court_cm": vertical_result["lower_court_px"] / px_per_cm,
|
||||
"face_total_height_cm": vertical_result["face_total_height_px"] / px_per_cm,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 七眼测量实现
|
||||
|
||||
七眼测量全部基于可直接检测的关键点(无需推算),精度较好。
|
||||
|
||||
```python
|
||||
def measure_seven_eyes(landmarks, image_width, image_height):
|
||||
"""
|
||||
测量眼宽、脸宽、两眼间距(像素)
|
||||
返回像素值,后续通过 px_per_cm 转为厘米
|
||||
"""
|
||||
# 左眼外/内角
|
||||
left_outer = normalized_to_pixel(landmarks[33], image_width, image_height)
|
||||
left_inner = normalized_to_pixel(landmarks[133], image_width, image_height)
|
||||
# 右眼内/外角
|
||||
right_inner = normalized_to_pixel(landmarks[362], image_width, image_height)
|
||||
right_outer = normalized_to_pixel(landmarks[263], image_width, image_height)
|
||||
# 脸宽
|
||||
left_cheek = normalized_to_pixel(landmarks[234], image_width, image_height)
|
||||
right_cheek = normalized_to_pixel(landmarks[454], image_width, image_height)
|
||||
|
||||
eye_width_px = pixel_distance(left_outer, left_inner) # 左眼宽(也可用右眼或平均)
|
||||
right_eye_width_px = pixel_distance(right_inner, right_outer)
|
||||
avg_eye_width_px = (eye_width_px + right_eye_width_px) / 2
|
||||
|
||||
inter_eye_px = pixel_distance(left_inner, right_inner) # 两眼间距
|
||||
face_width_px = pixel_distance(left_cheek, right_cheek) # 脸宽
|
||||
|
||||
return {
|
||||
"eye_width_px": avg_eye_width_px,
|
||||
"face_width_px": face_width_px,
|
||||
"inter_eye_distance_px": inter_eye_px,
|
||||
}
|
||||
```
|
||||
|
||||
### 占比计算
|
||||
|
||||
```python
|
||||
# 七眼比例(眼宽/脸宽,间距/脸宽)
|
||||
eye_width_ratio = eye_width_px / face_width_px
|
||||
inter_eye_ratio = inter_eye_px / face_width_px
|
||||
|
||||
# 四庭比例(各段 / 全脸总高)
|
||||
for court in ["top", "upper", "middle", "lower"]:
|
||||
ratios[f"{court}_court"] = result[f"{court}_court_px"] / face_total_height_px
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 标注图片生成
|
||||
|
||||
需求要求输出**仅包含标注图层、不含人物**的 PNG 图片,规格如下:
|
||||
|
||||
| 项目 | 要求 |
|
||||
|------|------|
|
||||
| 字体色 / 线色 | `#FFFFFF` 100% |
|
||||
| 字体 | PingFangSC-Regular 10pt |
|
||||
| 线宽 | 1pt |
|
||||
| 四庭数值位置 | 图片**左侧** |
|
||||
| 七眼间距数值 | **上下穿插**展示 |
|
||||
| 横线/竖线 | 渐变消失 |
|
||||
| 虚线 | 两侧带箭头 |
|
||||
|
||||
### 实现方案
|
||||
|
||||
使用 **Pillow (PIL)** 的 `ImageDraw` 生成透明底 PNG,画布尺寸与输入原图一致。
|
||||
|
||||
```python
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import math
|
||||
|
||||
def create_annotated_image(input_image_path, vertical_result, eye_result, px_per_cm):
|
||||
"""生成标注图层 PNG(透明底,仅标注)"""
|
||||
# 读取原图获取尺寸
|
||||
original = Image.open(input_image_path)
|
||||
width, height = original.size
|
||||
|
||||
# 创建透明画布 (RGBA, A=0)
|
||||
canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
|
||||
# ⚠️ 字体:PingFangSC 是 macOS 字体,Linux 服务器没有;且 ImageFont.load_default()
|
||||
# 不渲染中文(会出现方块/空白)。必须随仓库打包一个中文 TTF 并用绝对路径加载。
|
||||
# 已打包 Noto Sans CJK SC(= 思源黑体,同一套字体):face_analysis/fonts/NotoSansCJKsc-Regular.otf
|
||||
FONT_PATH = os.path.join(os.path.dirname(__file__), "fonts", "NotoSansCJKsc-Regular.otf")
|
||||
font = ImageFont.truetype(FONT_PATH, 10) # 字体缺失时直接抛错,避免静默降级成乱码
|
||||
|
||||
line_color = (255, 255, 255, 255) # #FFFFFF 100%
|
||||
line_width = 1 # 1pt
|
||||
|
||||
# --- 1. 绘制四庭水平分界线(渐变消失效果) ---
|
||||
courts = [
|
||||
("hair_top", vertical_result["hair_top"]),
|
||||
("hairline", vertical_result["hairline"]),
|
||||
("brow_center", vertical_result["brow_center"]),
|
||||
("nose_bottom", vertical_result["nose_bottom"]),
|
||||
("chin_tip", vertical_result["chin_tip"]),
|
||||
]
|
||||
|
||||
for name, (cx, cy) in courts:
|
||||
# 绘制从中心向两侧渐变的水平线
|
||||
draw_gradient_horizontal_line(draw, cx, cy, width, line_color, line_width)
|
||||
|
||||
# --- 2. 绘制四庭数值(左侧标注) ---
|
||||
court_values = [
|
||||
("顶庭", vertical_result["top_court_px"] / px_per_cm),
|
||||
("上庭", vertical_result["upper_court_px"] / px_per_cm),
|
||||
("中庭", vertical_result["middle_court_px"] / px_per_cm),
|
||||
("下庭", vertical_result["lower_court_px"] / px_per_cm),
|
||||
]
|
||||
|
||||
left_margin = 20
|
||||
for i, (label, cm_val) in enumerate(court_values):
|
||||
# 标注在对应段落中间高度
|
||||
y_start = courts[i][1][1]
|
||||
y_end = courts[i+1][1][1]
|
||||
y_mid = (y_start + y_end) / 2
|
||||
text = f"{label} {cm_val:.2f}cm"
|
||||
draw.text((left_margin, y_mid), text, fill=line_color, font=font)
|
||||
|
||||
# --- 3. 绘制七眼标注(上下穿插) ---
|
||||
# 眼宽标注在上方,间距标注在下方
|
||||
# (具体位置根据实际坐标布局)
|
||||
|
||||
# ... (详细绘制逻辑见完整实现)
|
||||
|
||||
# --- 4. 绘制虚线箭头 ---
|
||||
# 在分界点位置绘制水平虚线,两端带箭头
|
||||
|
||||
return canvas
|
||||
```
|
||||
|
||||
### 渐变线实现
|
||||
|
||||
> ⚠️ **性能**:逐像素 `draw.point` 在大图上极慢(每条线几百次 Python 调用,多条线 × 高分辨率图肉眼可感卡顿)。用 numpy 向量化生成一行渐变像素后整行写入,快几个数量级:
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
|
||||
def draw_gradient_horizontal_line(canvas: Image.Image, cx, cy, color, half_length=None):
|
||||
"""以 (cx, cy) 为中心,向两侧绘制渐变消失的水平线(numpy 向量化)"""
|
||||
arr = np.asarray(canvas) # RGBA, H×W×4
|
||||
h, w = arr.shape[:2]
|
||||
cy = int(round(cy)); cx = int(round(cx))
|
||||
if not (0 <= cy < h):
|
||||
return
|
||||
half = half_length or (w // 3)
|
||||
|
||||
xs = np.arange(w)
|
||||
dist = np.abs(xs - cx)
|
||||
alpha = np.clip(1.0 - dist / half, 0.0, 1.0) * color[3] # 线性衰减,超出 half 为 0
|
||||
mask = alpha > 0
|
||||
|
||||
row = arr[cy]
|
||||
row[mask, 0], row[mask, 1], row[mask, 2] = color[0], color[1], color[2]
|
||||
# 与已有 alpha 取较大值,避免覆盖其它线条
|
||||
row[mask, 3] = np.maximum(row[mask, 3], alpha[mask].astype(np.uint8))
|
||||
# 注意:需用可写数组(np.array(canvas) 复制),处理完用 Image.fromarray 写回画布
|
||||
```
|
||||
|
||||
> 实现时建议全程在一个 `np.zeros((h, w, 4), uint8)` 缓冲区上画线,最后 `Image.fromarray` 一次性转回,再用 `ImageDraw` 画文字/箭头。
|
||||
|
||||
### 虚线带箭头
|
||||
|
||||
```python
|
||||
def draw_dashed_line_with_arrows(draw, x1, y1, x2, y2, color, dash_len=6, gap_len=4):
|
||||
"""两点间画虚线,两端带箭头"""
|
||||
total_len = ((x2 - x1)**2 + (y2 - y1)**2) ** 0.5
|
||||
if total_len == 0:
|
||||
return
|
||||
|
||||
dx = (x2 - x1) / total_len
|
||||
dy = (y2 - y1) / total_len
|
||||
|
||||
# 画虚线
|
||||
pos = 0
|
||||
while pos < total_len:
|
||||
seg_end = min(pos + dash_len, total_len)
|
||||
draw.line([
|
||||
(x1 + dx * pos, y1 + dy * pos),
|
||||
(x1 + dx * seg_end, y1 + dy * seg_end)
|
||||
], fill=color, width=1)
|
||||
pos += dash_len + gap_len
|
||||
|
||||
# 两端箭头 (等腰三角形)
|
||||
arrow_size = 6
|
||||
# 左端箭头...
|
||||
# 右端箭头...
|
||||
```
|
||||
|
||||
> 标注图片的具体视觉样式建议在实现后根据实际效果微调,特别是虚线箭头的方向和位置。
|
||||
|
||||
---
|
||||
|
||||
## 7. 整体处理流程
|
||||
|
||||
```
|
||||
输入图片
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ 1. 预处理 │
|
||||
│ - 校验格式 (JPG/PNG) │
|
||||
│ - 校验分辨率 (短边≥600 长边≥800, 可配置)
|
||||
│ - 校验文件大小 (≤ 1MB) │
|
||||
│ - 校验人脸数量 (仅单人) │
|
||||
└──────────────┬──────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ 2. MediaPipe 推理 │
|
||||
│ - FaceMesh(static_image_mode=True,
|
||||
│ max_num_faces=1,
|
||||
│ refine_landmarks=True) │
|
||||
│ - 输出: 468+10 关键点 │
|
||||
│ - 无人脸 → 1001 │
|
||||
└──────────────┬──────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ 3. 姿态校验 (solvePnP) │
|
||||
│ - 解算 yaw/pitch/roll │
|
||||
│ - 超阈值 → 1003 (非正面照) │
|
||||
└──────────────┬──────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ 4. 关键点提取 + 发际线/头顶定位 │
|
||||
│ - 横向: 眼宽/脸宽/两眼间距(实测) │
|
||||
│ - 中/下庭: 眉心/鼻翼/下巴 (实测) │
|
||||
│ - 上/顶庭: 方案B分割(主)→A推算(兜底)│
|
||||
│ 记录 hairline_source │
|
||||
└──────────────┬──────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ 5. 尺度校准 │
|
||||
│ - 虹膜直径法: px_per_cm 估算 │
|
||||
└──────────────┬──────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ 6. 计算与生成 │
|
||||
│ - 像素 → 厘米 │
|
||||
│ - 计算占比 │
|
||||
│ - 生成标注图层 PNG │
|
||||
└──────────────┬──────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ 7. 输出 │
|
||||
│ - annotated_image_url (标注PNG) │
|
||||
│ - face_total_height_cm │
|
||||
│ - four_courts (含cm & ratios) │
|
||||
│ - seven_eyes (含cm & ratios) │
|
||||
│ - landmarks (5个点原图像素坐标) │
|
||||
│ - hairline_source ("segmentation"│
|
||||
│ / "estimated") │
|
||||
│ - head_pose (yaw/pitch/roll) │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 关键代码骨架
|
||||
|
||||
### 8.1 目录结构建议
|
||||
|
||||
```
|
||||
hair/
|
||||
├── app.py # 现有 FastAPI 应用
|
||||
├── face_analysis/
|
||||
│ ├── __init__.py
|
||||
│ ├── detector.py # MediaPipe Face Mesh 封装
|
||||
│ ├── hair_segmenter.py # 方案 B:BiSeNet 头发分割封装
|
||||
│ ├── pose.py # solvePnP 头部姿态估计 + 正面校验
|
||||
│ ├── measure.py # 四庭七眼测量逻辑(整合方案 B/A)
|
||||
│ ├── calibration.py # px→cm 尺度校准(虹膜法)
|
||||
│ ├── annotation.py # 标注图片生成(numpy 渐变线 + 中文字体)
|
||||
│ ├── face_mesh_landmarks.py # 关键点索引常量
|
||||
│ ├── fonts/
|
||||
│ │ └── NotoSansCJKsc-Regular.otf # 打包的中文字体(= 思源黑体)
|
||||
│ └── weights/ # 模型权重(不入 git,部署脚本拉取)
|
||||
│ ├── 79999_iter.pth # BiSeNet face-parsing 权重 ~53MB
|
||||
│ └── resnet18-5c106cde.pth # BiSeNet 骨干权重 ~45MB(离线必需,见下)
|
||||
├── static/
|
||||
│ └── annotations/ # 生成的标注 PNG 存放目录
|
||||
├── .gitignore # 忽略 face_analysis/weights/*.pth
|
||||
└── requirements.txt
|
||||
```
|
||||
|
||||
### 8.2 MediaPipe 封装 (`detector.py`)
|
||||
|
||||
```python
|
||||
import mediapipe as mp
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
mp_face_mesh = mp.solutions.face_mesh
|
||||
|
||||
class FaceMeshDetector:
|
||||
"""MediaPipe Face Mesh 封装,单例模式"""
|
||||
|
||||
def __init__(self):
|
||||
self.face_mesh = mp_face_mesh.FaceMesh(
|
||||
static_image_mode=True,
|
||||
max_num_faces=1, # 仅检测单人
|
||||
refine_landmarks=True, # 启用虹膜 + 唇部精细关键点
|
||||
min_detection_confidence=0.5,
|
||||
)
|
||||
|
||||
def detect(self, image: np.ndarray) -> list | None:
|
||||
"""
|
||||
检测人脸关键点
|
||||
Args:
|
||||
image: BGR numpy array (OpenCV 格式)
|
||||
Returns:
|
||||
landmarks: NormalizedLandmarkList,或 None
|
||||
"""
|
||||
rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
||||
results = self.face_mesh.process(rgb)
|
||||
|
||||
if results.multi_face_landmarks:
|
||||
return results.multi_face_landmarks[0] # 第一个人脸
|
||||
return None
|
||||
|
||||
def close(self):
|
||||
self.face_mesh.close()
|
||||
|
||||
# 全局单例
|
||||
detector = FaceMeshDetector()
|
||||
```
|
||||
|
||||
### 8.3 FastAPI 集成
|
||||
|
||||
```python
|
||||
# 在 app.py 中集成
|
||||
from face_analysis.measure import measure_face
|
||||
from face_analysis.annotation import create_annotated_image
|
||||
import cv2
|
||||
import numpy as np
|
||||
from io import BytesIO
|
||||
|
||||
@app.post("/api/v1/face/measure")
|
||||
async def face_measure(image_file: UploadFile = File(...)):
|
||||
# 1. 读取图片
|
||||
contents = await image_file.read()
|
||||
|
||||
# 2. 校验
|
||||
if len(contents) > 1_000_000:
|
||||
return err(1006, "文件超出 1 MB 限制")
|
||||
|
||||
nparr = np.frombuffer(contents, np.uint8)
|
||||
image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
||||
if image is None:
|
||||
return err(1008, "图片格式不支持")
|
||||
|
||||
h, w = image.shape[:2]
|
||||
# ⚠️ 竖拍人像通常 w=1080, h=1920;不要把 w/h 写反导致竖图被全部拒绝。
|
||||
# 用「短边/长边」判断,方向无关,竖拍横拍都兼容。
|
||||
# 门槛可配置(环境变量),默认放宽到 600/800 以适配真实用户上传图。
|
||||
min_short = int(os.getenv("MIN_SHORT_SIDE", "600"))
|
||||
min_long = int(os.getenv("MIN_LONG_SIDE", "800"))
|
||||
short_side, long_side = min(w, h), max(w, h)
|
||||
if short_side < min_short or long_side < min_long:
|
||||
return err(1002, "人像分辨率过低")
|
||||
|
||||
# 3. 人脸检测
|
||||
landmarks = detector.detect(image)
|
||||
if landmarks is None:
|
||||
return err(1001, "无法识别人像")
|
||||
|
||||
# 4. 测量计算
|
||||
result = measure_face(landmarks, w, h)
|
||||
|
||||
# 5. 生成标注图
|
||||
annotated = create_annotated_image(image, result)
|
||||
buf = BytesIO()
|
||||
annotated.save(buf, format="PNG")
|
||||
|
||||
# 6. 拆分架构下:返回 base64,由网关落盘改写成 annotated_image_url(见架构文档 §9)
|
||||
import base64
|
||||
data = result.to_response()
|
||||
data["annotated_image_base64"] = base64.b64encode(buf.getvalue()).decode()
|
||||
return ok(data)
|
||||
# —— 单机版(非拆分)才在此保存到 static/ 并返回 annotated_image_url ——
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 误差分析与局限
|
||||
|
||||
| 误差来源 | 影响范围 | 估算误差 | 缓解措施 |
|
||||
|----------|----------|----------|----------|
|
||||
| 头顶/发际线推算 | 顶庭、上庭 cm 值 | ±15% | 基于实测中庭下庭比例自适应 |
|
||||
| 虹膜直径个体差异 | 所有 cm 值 | ±5% | 左右眼平均;未来可接性别/年龄修正 |
|
||||
| 非正面照 | 所有横向测量 | ±20% | 前置校验偏航角(yaw),过大则返回 1003 |
|
||||
| 相机畸变 | 边缘区域坐标 | ±3% | 假设普通手机拍照,畸变可控 |
|
||||
| 人脸比例个体差异 | 推算的发际线/头顶 | ±10% | 无完美解决方案,方案 A 的自然局限 |
|
||||
|
||||
**前置姿态校验**(检测是否为正面照):
|
||||
|
||||
> 旧版本靠「双眼 y 差 + 鼻尖偏移」的经验阈值(0.03/0.08),不可解释、难调。**改用 `cv2.solvePnP` 解算真实头部欧拉角(yaw/pitch/roll,单位:度)**,阈值就能写成业务可读的"yaw>15° 拒绝",并把角度返回给前端做拍照引导。
|
||||
|
||||
```python
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
# 通用 3D 头部模型(单位 mm,近似),与下方 MediaPipe 索引一一对应
|
||||
_MODEL_POINTS = np.array([
|
||||
(0.0, 0.0, 0.0), # 鼻尖 -> 1(或 4)
|
||||
(0.0, -63.6, -12.5), # 下巴 -> 152
|
||||
(-43.3, 32.7, -26.0), # 左眼外角 -> 33
|
||||
(43.3, 32.7, -26.0), # 右眼外角 -> 263
|
||||
(-28.9, -28.9, -24.1), # 左嘴角 -> 61
|
||||
(28.9, -28.9, -24.1), # 右嘴角 -> 291
|
||||
], dtype=np.float64)
|
||||
_PNP_IDX = [1, 152, 33, 263, 61, 291]
|
||||
|
||||
def estimate_head_pose(landmarks, image_width, image_height):
|
||||
"""返回 (yaw, pitch, roll) 角度。solvePnP 失败返回 None。"""
|
||||
image_points = np.array([
|
||||
(landmarks[i].x * image_width, landmarks[i].y * image_height)
|
||||
for i in _PNP_IDX
|
||||
], dtype=np.float64)
|
||||
|
||||
focal = image_width # 近似焦距
|
||||
cam_matrix = np.array([[focal, 0, image_width / 2],
|
||||
[0, focal, image_height / 2],
|
||||
[0, 0, 1]], dtype=np.float64)
|
||||
dist = np.zeros((4, 1)) # 假设无畸变
|
||||
|
||||
ok, rvec, tvec = cv2.solvePnP(_MODEL_POINTS, image_points, cam_matrix, dist,
|
||||
flags=cv2.SOLVEPNP_ITERATIVE)
|
||||
if not ok:
|
||||
return None
|
||||
rot, _ = cv2.Rodrigues(rvec)
|
||||
sy = (rot[0, 0] ** 2 + rot[1, 0] ** 2) ** 0.5
|
||||
pitch = np.degrees(np.arctan2(-rot[2, 0], sy))
|
||||
yaw = np.degrees(np.arctan2(rot[1, 0], rot[0, 0]))
|
||||
roll = np.degrees(np.arctan2(rot[2, 1], rot[2, 2]))
|
||||
return yaw, pitch, roll
|
||||
|
||||
def check_frontal_face(landmarks, image_width, image_height,
|
||||
yaw_thr=15, pitch_thr=15, roll_thr=15):
|
||||
"""正面照判定:yaw/pitch/roll 均在阈值内才算正面。阈值待测试集标定。"""
|
||||
pose = estimate_head_pose(landmarks, image_width, image_height)
|
||||
if pose is None:
|
||||
return True # 解算失败时不拦截,交由后续逻辑
|
||||
yaw, pitch, roll = pose
|
||||
return abs(yaw) <= yaw_thr and abs(pitch) <= pitch_thr and abs(roll) <= roll_thr
|
||||
```
|
||||
|
||||
> 上面 `_MODEL_POINTS` 是常用近似头模,索引/坐标可在测试阶段微调。阈值 15° 为初始值,按 §11 收集的测试数据标定。
|
||||
|
||||
---
|
||||
|
||||
## 10. 依赖与版本
|
||||
|
||||
```
|
||||
# requirements.txt 新增
|
||||
mediapipe==0.10.14 # 经典 Solutions API(模型内置,无需额外下载)
|
||||
opencv-python==4.10.0 # 图片读取、处理、solvePnP 姿态估计
|
||||
Pillow==11.0.0 # 标注图生成(PNG 透明图层)
|
||||
numpy==1.26.4 # ⚠️ 必须 <2,否则 mediapipe 0.10.x import 崩溃
|
||||
|
||||
# 方案 B:头发分割(BiSeNet face-parsing)
|
||||
# 拆分架构:worker 有 GPU → 用 CUDA 版 torch(按 worker 的 CUDA 版本选 whl)
|
||||
torch==2.2.2 # GPU(CUDA)版,例如 cu121:--index-url https://download.pytorch.org/whl/cu121
|
||||
torchvision==0.17.2
|
||||
```
|
||||
|
||||
> ⚠️ **numpy 锁版本**:mediapipe 0.10.x 对 numpy 2.x 支持不稳定,务必锁 `numpy<2`(已验证 1.26.4 可用)。先用此组合跑通,再考虑升级。
|
||||
>
|
||||
> ⚠️ **torch GPU/CPU**:拆分架构下 worker 有独立 GPU,用 **CUDA 版 torch**(按 worker 实际 CUDA 版本选对应 whl index,如 cu118/cu121),BiSeNet 推理走 GPU。单机/无 GPU 环境回退 CPU 版(`--index-url .../whl/cpu`,~200MB)。BiSeNet 加载时把 `.to('cuda' if torch.cuda.is_available() else 'cpu')`。
|
||||
|
||||
> MediaPipe 0.10.x 的经典 Solutions API (`mp.solutions.face_mesh`) 仍稳定可用。如需迁移到 Tasks API,后续可平滑升级。
|
||||
|
||||
---
|
||||
|
||||
## 11. 待确认事项
|
||||
|
||||
1. **标注图片设计稿**:需求文档提到需要设计稿确认,当前 UI 规范(字体/颜色/线宽)按文档实现,后续可能需要根据设计师反馈微调
|
||||
2. **男女比例差异**:是否需要在 cm 换算中区分性别(男女脸宽均值不同)?当前使用虹膜直径法天然与性别无关
|
||||
3. **顶庭占比**:22% 为 Mock 数据值,实际部署后是否根据用户反馈调整比例参数
|
||||
4. **非正面照角度阈值**:具体多少度算「角度过大」?建议前期收集测试数据后定阈值
|
||||
|
||||
---
|
||||
|
||||
## 12. 实测基线数值表(frontal.jpg,682×811)
|
||||
|
||||
worker 侧首次实现后的实测值,作为数值回归基线(`tests/test_pipeline.py`)。
|
||||
|
||||
| 量 | 方案 A(兜底,mask=None) | 方案 B(分割,主) |
|
||||
|----|--------------------------|--------------------|
|
||||
| hairline_source | estimated | segmentation |
|
||||
| 顶庭 cm | 5.06 | 6.51 |
|
||||
| 上庭 cm | 6.07 | 4.08 |
|
||||
| 中庭 cm | 7.23(实测) | 7.23(实测) |
|
||||
| 下庭 cm | 5.64(实测) | 5.64(实测) |
|
||||
| 全脸高 cm | 24.01 | 23.47 |
|
||||
| 眼宽 cm | 2.52 | 2.52 |
|
||||
| 脸宽 cm | 12.49 | 12.49 |
|
||||
| 两眼间距 cm | 3.24 | 3.24 |
|
||||
| px_per_cm(虹膜法) | 15.06 | 15.06 |
|
||||
| head_pose (yaw/pitch/roll) | 15.73 / 20.93 / 3.40 | 同左 |
|
||||
|
||||
> 说明:中/下庭、七眼、px_per_cm、姿态在两方案下一致(均为实测);顶/上庭方案 A 为
|
||||
> 比例推算、方案 B 为分割实测,二者差异正体现"方案 B 取真实发际线/头顶"的价值。
|
||||
> 数值回归测试固定走方案 A(确定性、与 torch 无关),容差 1%。
|
||||
|
||||
### 运行环境实测要点(worker = RTX 5090)
|
||||
|
||||
- **GPU 架构兼容性**:本 worker 为 **RTX 5090(compute capability 12.0 / Blackwell, sm_120)**。
|
||||
锁定的 `torch==2.2.2+cu121` 仅编译到 **sm_90**,在 5090 上执行 CUDA 算子会报
|
||||
`CUDA error: no kernel image is available`。`hair_segmenter._select_device()` 已做
|
||||
一次小算子探测,失败自动回退 **CPU**(BiSeNet CPU 推理约 0.3–1s/张,方案 B 正常可用)。
|
||||
- **要真正用上 5090 GPU**:需换装支持 sm_120 的构建(**torch cu128,≥2.7**,配套
|
||||
torchvision),代码无需改动(`_select_device` 会自动选 CUDA)。可设 `FORCE_CPU=1` 强制 CPU。
|
||||
|
||||
---
|
||||
|
||||
> **文档版本**: v2.0
|
||||
> **创建日期**: 2026-06-13(v2.0 修订:修复循环论证/分辨率/字体/numpy 等问题,引入方案 B 分割 + solvePnP 姿态)
|
||||
> **依赖模型**: MediaPipe Face Mesh (468 landmarks) + BiSeNet face-parsing (头发分割)
|
||||
> **测量策略**: 眉心以下实测关键点 + 方案 B 分割取真实发际线/头顶(方案 A 比例推算兜底)
|
||||
@@ -0,0 +1,182 @@
|
||||
# 接口11 运行记录 — `image/hair_test.jpg`
|
||||
|
||||
> 实测时间:2026-07-15
|
||||
> 调用:`POST http://127.0.0.1:8187/api/v1/hairline/grow`
|
||||
> 鉴权:`X-Internal-Token: dev-shared-secret-2026`
|
||||
> 输入图:`image/hair_test.jpg`(1257×1495)
|
||||
> `hairline_id`:`chang_zhixian`(直线);其余全部走接口默认值
|
||||
> 业务结果:`code=0`,`rid=bc7205a4`
|
||||
> 产物目录:`docs/iface11_hair_test_run/`
|
||||
|
||||
---
|
||||
|
||||
## 1. 本次调用用到的全部默认参数
|
||||
|
||||
未在 Form 里显式传的参数均取 `app.py` / `generate_hairline_grow` 默认值;下表即本次实际生效值。
|
||||
|
||||
| 参数 | 本次值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `hairline_id` | `chang_zhixian` | **必填**。发际线类型 = change_hair 的 `hair_id`(直线) |
|
||||
| `gen_backend` | `swaphair` | 生成后端:换发型 LoRA |
|
||||
| `hairgrow_strength` | `0.75` | 仅 `hairgrow` 后端用;本次未走该路径 |
|
||||
| `is_hr` | `false` | 高清关闭(576×768 档,非 1152×1536) |
|
||||
| `seg_model` | `segformer` | 头发分割模型 |
|
||||
| `erode_cm` | `0.6` | baseline 参考内缩(cm);pushed 下影响很小 |
|
||||
| `hairline_push_cm` | `1.0` | 发际线内轮廓径向外推距离(cm) |
|
||||
| `hairline_edge` | `column` | 兼容入参;当前内轮廓提取不再按它分支 |
|
||||
| `swap_mode` | `ext_mask` | 把 pushed 遮罩作为 `ext_mask` 传给 swapHair |
|
||||
| `edge_erode_px` | `3` | 贴图前遮罩内缩像素 |
|
||||
| `denoising_strength` | `0.6` | 换发型 webui 重绘强度 |
|
||||
| `mb_levels` | `5` | 多频段金字塔层数 |
|
||||
| `blend_method` | `multiband` | 接缝融合:多频段金字塔 |
|
||||
| `color_match` | `true` | 融合前 Reinhard 颜色迁移 |
|
||||
| `color_match_strength` | `1.0` | 颜色迁移强度(全迁移) |
|
||||
| `mb_feather_px` | `1` | 多频段最细层掩码轻羽化 |
|
||||
| `transition_band_px` | `-1` | keep-region 过渡带:自动按层数 `2**n` |
|
||||
| `redraw` | `false` | 发际线带重绘关闭 |
|
||||
| `inpainting_fill` | `1` | change_hair 填充噪声 |
|
||||
| `mask_blur` | `11` | change_hair 遮罩边缘模糊像素 |
|
||||
| `mask_dilate_scale` | `1.0` | change_hair 遮罩膨胀缩放 |
|
||||
| `comfyui_prompt` | `null` | 仅 `redraw`+Flux-2 路用;本次未用 |
|
||||
| `mask_type` | `pushed`(固定) | 代码写死,不可选 |
|
||||
|
||||
图片入参:仅传了 `image_file`(三选一中的文件上传)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 返回元数据(无 base64)
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|-----|
|
||||
| `px_per_cm` | 47.5311(虹膜直径标定) |
|
||||
| `erode_px` | 29(≈ 0.6cm × px_per_cm) |
|
||||
| `hair_pixels` | 186798 |
|
||||
| `closed_pixels` | 191712 |
|
||||
| `mask_pixels` | 140299 |
|
||||
| `image_size` | 1257 × 1495 |
|
||||
| `timings_ms.mask` | 1462 |
|
||||
| `timings_ms.swap` | 5596 |
|
||||
| `timings_ms.blend` | 220 |
|
||||
| `redraw.enabled` | false |
|
||||
| 总耗时(curl) | ≈ 7.4 s |
|
||||
|
||||
完整精简 JSON:`docs/iface11_hair_test_run/response_meta.json`
|
||||
完整原始响应(含 base64):`docs/iface11_hair_test_run/response.json`
|
||||
|
||||
---
|
||||
|
||||
## 3. 管线分步说明与产物
|
||||
|
||||
管线:① pushed 遮罩 → ② swapHair 生成 → ③ 硬贴回 → ④ multiband 融合。
|
||||
各步图保存在 `docs/iface11_hair_test_run/steps/`。
|
||||
|
||||
### ①-a 发际线分割线(baseline)
|
||||
|
||||
- **做什么**:MediaPipe 关键点连成眉骨折线(中心为 151 眉心),并向左右边缘水平延长。
|
||||
- **图**:[`steps/baseline_overlay.jpg`](iface11_hair_test_run/steps/baseline_overlay.jpg)
|
||||
- **含义**:黄线 = baseline;151 中心点为后续径向外推圆心。
|
||||
|
||||
### ①-b 分割线上半区(upper)
|
||||
|
||||
- **做什么**:baseline 折线以上的多边形区域,作为后续裁剪范围。
|
||||
- **图**:[`steps/upper_overlay.jpg`](iface11_hair_test_run/steps/upper_overlay.jpg)
|
||||
- **含义**:青 = 上半区。
|
||||
|
||||
### ①-c 头发分割(hair_seg)
|
||||
|
||||
- **做什么**:SegFormer 得到头发二值掩码。
|
||||
- **图**:[`steps/hair_seg_overlay.jpg`](iface11_hair_test_run/steps/hair_seg_overlay.jpg)
|
||||
- **含义**:绿 = 原始头发像素(本次 `hair_pixels=186798`)。
|
||||
|
||||
### ①-d / ①-e(旧 eroded/closed 中间步)
|
||||
|
||||
- pushed 模式**不走**这两步;返回字段为空字符串。
|
||||
- `top_fill_overlay` / `closed_overlay`:本次无图。
|
||||
|
||||
### ①-f 头发内轮廓线(hairline)
|
||||
|
||||
- **做什么**:取头发朝脸一侧的内轮廓(额头弧 + 两侧到下颌),有序折线。
|
||||
- **图**:[`steps/hairline_overlay.jpg`](iface11_hair_test_run/steps/hairline_overlay.jpg)
|
||||
- **含义**:绿 = 内轮廓;黄 = baseline。
|
||||
|
||||
### ①-g 外推发际线(pushed)
|
||||
|
||||
- **做什么**:以眉心 151 为圆心,内轮廓逐点向外推 `hairline_push_cm=1.0`(≈ 47.5 px),与 baseline 组闭合区域。
|
||||
- **图**:[`steps/pushed_overlay.jpg`](iface11_hair_test_run/steps/pushed_overlay.jpg)
|
||||
- **含义**:青 = 外推线;红 = 外推遮罩区域。
|
||||
|
||||
### ① 最终遮罩
|
||||
|
||||
- **叠加图**:[`steps/mask_overlay.jpg`](iface11_hair_test_run/steps/mask_overlay.jpg) — 红 = 遮罩区(贴回/生成区)
|
||||
- **纯遮罩**:[`steps/mask.png`](iface11_hair_test_run/steps/mask.png) — 白 = 生成/贴回区
|
||||
- 本次 `mask_pixels=140299`;贴图前再内缩 `edge_erode_px=3`。
|
||||
|
||||
### ② 生成全帧(swap_raw)
|
||||
|
||||
- **做什么**:`gen_backend=swaphair` + `swap_mode=ext_mask`,把遮罩交给 change_hair(`:8801`),LoRA=`chang_zhixian`,`denoising_strength=0.6`。
|
||||
- **图**:[`steps/swap_raw.jpg`](iface11_hair_test_run/steps/swap_raw.jpg)
|
||||
- **含义**:生成结果已与原图同分辨率对齐;耗时约 5.6 s。
|
||||
|
||||
### ③ 严格按遮罩贴回(hard_paste)
|
||||
|
||||
- **做什么**:遮罩内用生成图,遮罩外保持原图,无融合。
|
||||
- **图**:[`steps/hard_paste.jpg`](iface11_hair_test_run/steps/hard_paste.jpg)
|
||||
- **含义**:用于对比接缝融合前后差异。
|
||||
|
||||
### ④ 融合权重 alpha + 最终结果
|
||||
|
||||
- **做法**:`blend_method=multiband`,`mb_levels=5`,`color_match=true`(强度 1.0),`mb_feather_px=1`。
|
||||
- **alpha**:[`steps/alpha.png`](iface11_hair_test_run/steps/alpha.png) — 白 = 更多采用生成图
|
||||
- **最终输出**:[`steps/final.jpg`](iface11_hair_test_run/steps/final.jpg)(副本:[`final.jpg`](iface11_hair_test_run/final.jpg))
|
||||
- **输入对照**:[`steps/input.jpg`](iface11_hair_test_run/steps/input.jpg)
|
||||
|
||||
### ⑤ 发际线带重绘(本次关闭)
|
||||
|
||||
`redraw=false`,故 `redraw_band_overlay` / `redraw_a` / `redraw_c` 均为空。
|
||||
|
||||
---
|
||||
|
||||
## 4. 最终输出
|
||||
|
||||
**主结果文件**:[`docs/iface11_hair_test_run/final.jpg`](iface11_hair_test_run/final.jpg)
|
||||
|
||||
含义:同一人、同一发型观感下,按直线发际线类型(`chang_zhixian`)压低发际线后的合成图;遮罩外像素保持原图不动。
|
||||
|
||||
---
|
||||
|
||||
## 5. 复现命令
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "http://127.0.0.1:8187/api/v1/hairline/grow" \
|
||||
-H "X-Internal-Token: dev-shared-secret-2026" \
|
||||
-F "image_file=@image/hair_test.jpg" \
|
||||
-F "hairline_id=chang_zhixian" \
|
||||
-o docs/iface11_hair_test_run/response.json
|
||||
```
|
||||
|
||||
(其余参数全部省略即可走默认值。)
|
||||
|
||||
---
|
||||
|
||||
## 6. 产物清单
|
||||
|
||||
```
|
||||
docs/接口11_hair_test运行记录.md ← 本文档
|
||||
docs/iface11_hair_test_run/
|
||||
final.jpg ← 最终结果
|
||||
response.json ← 完整 API 响应(含 base64)
|
||||
response_meta.json ← 去掉大图的元数据
|
||||
steps/
|
||||
input.jpg
|
||||
baseline_overlay.jpg
|
||||
upper_overlay.jpg
|
||||
hair_seg_overlay.jpg
|
||||
hairline_overlay.jpg
|
||||
pushed_overlay.jpg
|
||||
mask_overlay.jpg
|
||||
mask.png
|
||||
swap_raw.jpg
|
||||
hard_paste.jpg
|
||||
alpha.png
|
||||
final.jpg
|
||||
```
|
||||
@@ -1,246 +0,0 @@
|
||||
# 接口 2:C 端生发 — 技术实现方案(第一步:发际线遮罩渲染)
|
||||
|
||||
> 在 **高性能 worker(GPU 机)** 上实现,与接口 1 同机。对外接口经网关代理(见 [`系统架构-网关与高性能后端.md`](系统架构-网关与高性能后端.md))。
|
||||
> 发际线检测算法移植自 **head3d** 项目(已实现 502 点 mesh + UV 贴图方案)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 本期范围(第一步)
|
||||
|
||||
接口 2 输入用户正面照 + **性别**,按性别对应的发际线类型贴图,**逐张把发际线曲线渲染到照片上**,输出多张「叠加了建议发际线的预览图」,按固定顺序返回。
|
||||
|
||||
- **本期只做「渲染遮罩/预览图」**,不做真正的文生图生发(那是后续步骤)。当前 `image_url` 返回的是「原照片 + 发际线曲线叠加图」。
|
||||
- 排序 `order` 本期不计算,按贴图顺序 `1..N`。
|
||||
|
||||
---
|
||||
|
||||
## 1. 与现接口文档的差异(接口 2 需同步更新 `接口文档.md`)
|
||||
|
||||
| 项 | 现状 | 本期改为 |
|
||||
|----|------|----------|
|
||||
| 输入参数 | `beauty_enabled` | **新增必填 `gender`(`male`/`female`)**;`beauty_enabled` 保留但本期不生效 |
|
||||
| 输出 `results[]` 数量 | Mock 2 个 | = 该性别的贴图数量(**female 5 张 / male 4 张**) |
|
||||
| `results[].image_url` | 生发后图片 | **本期 = 发际线曲线叠加在原照片上的预览图** |
|
||||
| `results[].hairline_type` | 中文(花瓣形…) | **英文 key**(`flower`/`wave`/`heart`/`ellipse`/`straight`/`m`/`inverse_arc`) |
|
||||
| `results[].order` | 排序 | 本期固定 `1..N`(不排序) |
|
||||
| 错误码 1004(性别判断异常) | 待确认 | `gender` 改为必填入参 → **不再自动判别性别**;1004 仅在 `gender` 非法值时使用(或弃用) |
|
||||
|
||||
> ⚠️ 这是接口 2 的**有意契约变更**(加入参 + 改输出语义),需在 `接口文档.md` 接口 2 章节同步。其余 4 个接口契约不变。
|
||||
|
||||
### gender → 贴图集合
|
||||
|
||||
`hairline_texture/` 目录下贴图(512×512 RGBA,白色发际线曲线在顶部 UV 条带):
|
||||
|
||||
| gender | 贴图文件 | hairline_type (key) |
|
||||
|--------|----------|---------------------|
|
||||
| female | `girl_ellipse.png` | `ellipse` |
|
||||
| female | `girl_flower.png` | `flower` |
|
||||
| female | `girl_heart.png` | `heart` |
|
||||
| female | `girl_straight.png` | `straight` |
|
||||
| female | `girl_wave.png` | `wave` |
|
||||
| male | `man_ellipse.png` | `ellipse` |
|
||||
| male | `man_m.png` | `m` |
|
||||
| male | `man_straight.png` | `straight` |
|
||||
| male | `man_ inverse_arc.png` | `inverse_arc` |
|
||||
|
||||
> 注意 `man_ inverse_arc.png` 文件名里有个空格,代码里按 `gender + '_' + key` 生成文件名时需保留/清洗一致。建议**启动时扫描目录**建立 `{gender: [(key, path)]}` 映射,而不是硬编码文件名,并把文件名规范化(去空格)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 已从 head3d 复制到本项目的文件
|
||||
|
||||
全部放在 `hairline/` 包下(已复制,agent 直接用):
|
||||
|
||||
```
|
||||
hairline/
|
||||
├── __init__.py
|
||||
├── constants.py # 17 锚点、UV 偏移、分割类别、矢状-arc 常量、HF 模型 id
|
||||
├── obj_io.py # OBJ 读写
|
||||
├── face_landmarks.py # MediaPipe Tasks FaceLandmarker 封装(用 face_landmarker.task)
|
||||
├── face_parsing.py # SegFormer 人脸分割封装
|
||||
├── hairline_2d.py # 射线检测发际线 2D + 平滑 + 回退
|
||||
├── lift_3d.py # 2D→3D 矢状-arc 提升 + 中间行 + assemble 502 点
|
||||
├── extract_hairline.py # 主管线(image → 502 点),可复用 run()
|
||||
├── _index_map_data.py # 468→OBJ indexMap(build_extended_obj 用,本期渲染不需要)
|
||||
├── _mediapipe_subprocess.py# WSL 下子进程跑 MediaPipe 的兜底(可选)
|
||||
├── models/
|
||||
│ ├── face_landmarker.task # MediaPipe 模型(3.7MB,已复制)
|
||||
│ └── face-parsing/ # SegFormer 权重(离线,已下载,见 OFFLINE_ASSETS.md)
|
||||
│ ├── config.json
|
||||
│ ├── preprocessor_config.json
|
||||
│ └── model.safetensors
|
||||
├── mesh/
|
||||
│ ├── face_ext.obj # 502 点扩展 mesh + UV + 三角面(渲染器读这个)
|
||||
│ └── face.obj # 原始 468 点 mesh(参考/重生成用)
|
||||
└── reference/
|
||||
├── texture0.png # head3d 原 5 弧线贴图(核对 UV 用)
|
||||
└── uv_template.png # UV 布局参考
|
||||
```
|
||||
|
||||
发际线类型贴图在仓库根目录 `hairline_texture/`(用户提供,9 张)。
|
||||
|
||||
### 2.1 移植后需要修改的集成点
|
||||
|
||||
1. **`face_landmarks.py` 的 `DEFAULT_MODEL_PATH`**:原逻辑是 `dirname(dirname(__file__))/models/...`(head3d 里模块在 `python/` 子目录)。现在模块在 `hairline/` 根,该路径会指向 `hair/models/`,而模型在 `hairline/models/`。**改为** `os.path.join(os.path.dirname(__file__), "models", "face_landmarker.task")`。
|
||||
2. **`face_parsing.py` 离线加载**:`C.HF_FACE_PARSER_MODEL` 当前是 HF 在线 id `"jonathandinu/face-parsing"`。内网/离线改为本地目录:把 `constants.py` 的 `HF_FACE_PARSER_MODEL` 指向 `hairline/models/face-parsing` 的绝对路径(`from_pretrained` 支持本地目录);或设 `HF_HUB_OFFLINE=1`。
|
||||
3. **相对导入**:模块用 `from . import constants`,已加 `hairline/__init__.py`,作为包导入即可(`from hairline.extract_hairline import run`)。
|
||||
4. **GPU**:`FaceParser(device="cuda")`,worker 有 GPU。
|
||||
|
||||
---
|
||||
|
||||
## 3. 算法管线(整体)
|
||||
|
||||
```
|
||||
输入: 用户正面照 + gender
|
||||
│
|
||||
▼
|
||||
[A] head3d 管线(复用 hairline.extract_hairline 的步骤)
|
||||
- MediaPipe 468 点(face_landmarker.task)
|
||||
- SegFormer 人脸分割 → parse_map
|
||||
- 17 锚点射线检测发际线 → 17 个 2D 点 → 平滑
|
||||
- 矢状-arc 提升 → 502 点(归一化 x,y,z)
|
||||
│ 失败处理:无人脸→1001
|
||||
▼
|
||||
[B] 投影到图像像素
|
||||
- 502 点的 (x,y) × (W,H) → 502 个 2D 图像坐标
|
||||
- 读 face_ext.obj:UV(502) + 扩展三角面(涉及顶点 ≥468 的 64 个三角形)
|
||||
▼
|
||||
[C] 逐张贴图渲染(新写的服务端渲染器,本方案核心)
|
||||
for 每个该性别的发际线贴图 t:
|
||||
- 对每个扩展三角形:src=UV→贴图像素, dst=投影 2D 坐标 → cv2 仿射 warp
|
||||
- 累积成一张 RGBA 曲线层(贴图 alpha 控制曲线/透明)
|
||||
- 把曲线层 alpha 合成到原照片上 → 预览图
|
||||
▼
|
||||
[D] 输出
|
||||
results[] = N 个 {image(预览图), hairline_type(key), order=1..N}
|
||||
worker 侧每张图以 base64 返回(见 §6)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 渲染器(新代码,本期重点)★
|
||||
|
||||
head3d 把贴图渲染到照片是**浏览器 Three.js** 做的(`/preview` ortho overlay),**没有服务端实现**。本期新写一个 **OpenCV 逐三角形仿射 warp** 渲染器,无需 OpenGL 离屏上下文,确定性好、部署简单。
|
||||
|
||||
### 4.1 原理
|
||||
|
||||
face_ext.obj 的 502 顶点里:
|
||||
- `[0..467]` MediaPipe 点,其中 17 个 `MP_TOP_ANCHORS` 是发际线 ribbon 的**下边沿**;
|
||||
- `[468..484]` 中间行、`[485..501]` 发际线行,是 ribbon 的中、上两行。
|
||||
|
||||
这 34 个新点 + 17 个锚点之间连成 64 个三角形(ribbon),它们的 UV 落在贴图**顶部条带**(V_raw≈0.67..0.94,正是发际线曲线所在)。所以只要把**这 64 个三角形**按 UV→图像坐标 warp,就能把贴图里的发际线曲线贴到照片的额头/发际线区域。
|
||||
|
||||
### 4.2 步骤
|
||||
|
||||
```python
|
||||
# 伪代码
|
||||
def render_hairline_overlay(photo_bgr, points502_norm, ext_faces, uv502, texture_rgba):
|
||||
H, W = photo_bgr.shape[:2]
|
||||
# 502 点投影到图像像素
|
||||
img_xy = points502_norm[:, :2] * [W, H] # (502, 2)
|
||||
TW, TH = texture_rgba.shape[1], texture_rgba.shape[0] # 512, 512
|
||||
|
||||
overlay = np.zeros((H, W, 4), np.float32) # 累积曲线层 RGBA
|
||||
for (i, j, k) in ext_faces: # 仅扩展三角形(顶点含 ≥468)
|
||||
dst = img_xy[[i, j, k]].astype(np.float32) # 图像坐标
|
||||
# UV → 贴图像素。注意 flipY:贴图 y = (1 - v_raw) * TH
|
||||
src = np.array([[uv502[v][0]*TW, (1-uv502[v][1])*TH] for v in (i,j,k)], np.float32)
|
||||
M = cv2.getAffineTransform(src, dst)
|
||||
warped = cv2.warpAffine(texture_rgba, M, (W, H), flags=cv2.INTER_LINEAR,
|
||||
borderMode=cv2.BORDER_CONSTANT, borderValue=(0,0,0,0))
|
||||
# 三角形掩码,避免覆盖整张 warp 结果
|
||||
tri_mask = np.zeros((H, W), np.uint8)
|
||||
cv2.fillConvexPoly(tri_mask, dst.astype(np.int32), 255)
|
||||
sel = tri_mask > 0
|
||||
overlay[sel] = warped[sel] # 逐三角形写入(相邻共享边,覆盖等价)
|
||||
|
||||
# alpha 合成到原照片
|
||||
a = overlay[:, :, 3:4] / 255.0
|
||||
out = photo_bgr.astype(np.float32)
|
||||
out = out * (1 - a) + overlay[:, :, :3][..., ::-1] * a # RGBA→BGR 注意通道序
|
||||
return out.astype(np.uint8)
|
||||
```
|
||||
|
||||
### 4.3 注意点
|
||||
|
||||
- **通道序**:贴图是 RGBA,照片 OpenCV 是 BGR,合成时注意 R/B 调换。
|
||||
- **flipY**:face_ext.obj 的 UV 是 V_raw(V=1 对应贴图顶部),转贴图像素 y 要 `(1 - v)`,与 head3d Three.js `texture.flipY=true` 一致。
|
||||
- **只 warp 扩展三角形**:从 face_ext.obj 筛出顶点索引含 ≥468 的面(约 64 个)。不要 warp 整脸。
|
||||
- **抗锯齿/接缝**:逐三角形 `fillConvexPoly` 掩码可能在共享边留 1px 缝。可对 `tri_mask` 略膨胀,或最后对 overlay alpha 做轻微羽化。先跑通看效果再优化。
|
||||
- **裁剪到额头**:曲线层只在 ribbon 区域有内容(贴图其余透明),天然不会画到脸下半部。
|
||||
|
||||
---
|
||||
|
||||
## 5. 依赖
|
||||
|
||||
worker 已有(接口 1):`opencv-python`、`numpy`、`Pillow`、torch(CUDA)。接口 2 **新增**:
|
||||
|
||||
```
|
||||
mediapipe>=0.10 # Tasks Vision FaceLandmarker(注意与接口1的 solutions API 可共存)
|
||||
transformers>=4.40 # SegFormer 人脸分割
|
||||
# torch/torchvision 已由接口1引入(worker CUDA 版)
|
||||
```
|
||||
|
||||
> ⚠️ **两套人脸分割模型**:接口 1 用 BiSeNet(`79999_iter.pth`),接口 2 用 head3d 的 SegFormer(`jonathandinu/face-parsing`)。两者并存,显存/内存够(worker 32G+GPU)。后续可评估是否统一为一个分割模型,本期先各用各的,**不强行合并**。
|
||||
>
|
||||
> ⚠️ **MediaPipe API 差异**:接口 1 用 `mp.solutions.face_mesh`(468 点 + 虹膜 refine),接口 2 用 `mp.tasks.vision.FaceLandmarker`(读 `.task` 文件)。同一个 mediapipe 包都支持,但版本需兼容两者(建议先用一个版本把两接口都跑通)。
|
||||
|
||||
---
|
||||
|
||||
## 6. worker 集成(接口 2 handler)
|
||||
|
||||
在 `app.py` 替换 `/api/v1/hair/grow` 的 Mock:
|
||||
|
||||
```
|
||||
1. 解析图片(三选一)+ 读 gender(必填,male/female;非法→1004 或 1008 参数错误)
|
||||
2. 校验(大小/解码/分辨率,同接口1)
|
||||
3. 跑 hairline.extract_hairline 的步骤拿 502 点(无人脸→1001)
|
||||
4. 按 gender 取贴图集合(启动时扫描 hairline_texture/ 建映射)
|
||||
5. for 每张贴图: render_hairline_overlay → PNG
|
||||
6. results[] = [{image_base64, hairline_type, order}], 逐张 base64
|
||||
7. return ok({"results": results})
|
||||
```
|
||||
|
||||
- **拆分架构**:worker 返回 `results[].image_base64`,**不落盘不拼 URL**。网关把每个 `image_base64` 落盘改写成 `image_url`(架构文档 §9 的映射表需支持**数组里的图片字段** `results[].image`)。
|
||||
- 模型单例:`FaceLandmarker` 和 `FaceParser` 在模块加载时初始化一次,避免每请求重建。face_ext.obj 的 UV/faces 也只解析一次缓存。
|
||||
|
||||
---
|
||||
|
||||
## 7. 离线资产(内网部署)
|
||||
|
||||
接口 2 新增需要随项目带入内网的模型(已下载,登记到 `OFFLINE_ASSETS.md`):
|
||||
- `hairline/models/face_landmarker.task`(MediaPipe,~3.7MB)
|
||||
- `hairline/models/face-parsing/`(SegFormer:config + preprocessor + model.safetensors)
|
||||
|
||||
> SegFormer 加载方式改本地路径后,内网无需联网(见 §2.1)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 开发步骤与验证(agent 执行)
|
||||
|
||||
| 阶段 | 内容 | 验证 |
|
||||
|------|------|------|
|
||||
| **M0 跑通管线** | 修好集成点(§2.1),用一张人像跑 `hairline.extract_hairline.run()` 得 502 点 JSON | 502 点、valid_hairline 有 true |
|
||||
| **M1 解析 mesh** | 读 face_ext.obj 拿 UV + 扩展三角面(顶点≥468 的面),缓存 | 打印扩展面数(~64)、502 个 UV |
|
||||
| **M2 渲染器** | 实现 `render_hairline_overlay`,对 1 张贴图渲染 | 输出预览图,**目视**:发际线曲线贴在额头正确位置、跟随脸 |
|
||||
| **M3 全量 + 性别** | 扫描 `hairline_texture/` 建 gender→贴图映射,按性别渲染 N 张 | female 出 5 张、male 出 4 张,hairline_type 对 |
|
||||
| **M4 接 app.py** | handler + gender 必填 + base64 返回 | curl 验证 results 数量/字段;无人脸→1001;缺 gender→报错 |
|
||||
| **M5 网关映射** | 网关支持 `results[].image_base64`→`image_url`(网关任务书侧) | 端到端经网关返回 image_url,公网可访问 |
|
||||
|
||||
**M2 是关键里程碑**:渲染器对齐效果好不好,决定整个接口可用性,先用几张测试人像目视确认贴合。
|
||||
|
||||
---
|
||||
|
||||
## 9. 风险与待办
|
||||
|
||||
1. **新贴图 UV 是否与 texture0 完全一致**:本方案假设 9 张贴图沿用 head3d 的顶部条带 UV 布局(已肉眼确认曲线在顶部)。M2 渲染若位置偏移,核对贴图内容所在的 V 区间与 `UV_MIDDLE_DV/UV_HAIRLINE_DV`。
|
||||
2. **接缝/锯齿**:逐三角形 warp 的共享边接缝,M2 跑通后按 §4.3 优化。
|
||||
3. **歪头/非正面**:head3d 矢状-arc 假设近正脸,大角度发际线贴合差。可复用接口 1 的 solvePnP 做前置姿态校验(可选)。
|
||||
4. **秃头/高发际线/刘海**:SegFormer 找不到头发时射线回退几何外推,曲线可能偏高;valid_hairline 标记可用于提示。
|
||||
5. **排序**:本期 order=1..N。后续排序需定义依据(脸型/额型匹配度)。
|
||||
6. **真正的生发(文生图)**:本期只出遮罩/预览。下一步把预览图/曲线作为 ControlNet/inpaint 输入接文生图模型,再替换 `image_url` 为真实生发图。
|
||||
|
||||
---
|
||||
|
||||
> **文档版本**: v1.0 | **创建日期**: 2026-06-14 | 算法来源: head3d(502 点 mesh + UV)| 运行位置: worker(GPU)
|
||||
> **本期产出**: 发际线曲线叠加预览图(非最终生发图)
|
||||
@@ -15,6 +15,7 @@
|
||||
| 接口 | 方法 | 路径 |
|
||||
|------|------|------|
|
||||
| 1 四庭七眼测量 | POST | `/api/v1/face/measure` |
|
||||
| 6 四庭七眼测量 v2 | POST | `/api/v1/face/measure-v2` |
|
||||
| 2 C 端生发 | POST | `/api/v1/hair/grow` |
|
||||
| 3 B 端生发 | POST | `/api/v1/hair/grow-b` |
|
||||
| 4 用户特征 | POST | `/api/v1/face/features` |
|
||||
@@ -80,11 +81,12 @@
|
||||
| 1001 | 无法识别人像 | 图片中未检测到人脸 |
|
||||
| 1002 | 人像分辨率过低 | 低于最低分辨率要求 |
|
||||
| 1003 | 角度问题,非正面照 | 非正面 / 角度过大 |
|
||||
| 1004 | 性别标签判断异常 | 男女标签无法判定 **【待确认】** 是否作为错误 |
|
||||
| 1004 | gender 必填/非法 | 接口2/5 的 `gender` 缺失或非 `male`/`female` |
|
||||
| 1005 | 检测到多张人脸 | 默认仅支持单人,检测到 2 人或以上时返回 |
|
||||
| 1006 | 文件超出大小限制 | 单文件超过 1 MB |
|
||||
| 1007 | 图片参数错误 | file / url / base64 未传,或同时传了多个(三者严格互斥) |
|
||||
| 1008 | 图片格式不支持 | 非 JPG / PNG |
|
||||
| 1009 | 未授权 | 缺少或错误的 `X-Internal-Token`(`/api/*` 路径鉴权) |
|
||||
|
||||
---
|
||||
|
||||
@@ -107,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`(四庭,自上而下):
|
||||
|
||||
@@ -126,18 +130,26 @@
|
||||
| face_width_cm | number | 脸宽(cm) |
|
||||
| inter_eye_distance_cm | number | 两眼间距(cm) |
|
||||
| ratios | object | 七眼各段占脸宽的比例 |
|
||||
| eye1 | number \| null | 从左到右第 1 段宽度(cm):人头最左 → 左脸颊(左耳外侧段)。该侧耳朵不可见时为 null |
|
||||
| eye2 | number | 从左到右第 2 段宽度(cm):左脸颊 → 左眼外角 |
|
||||
| eye3 | number | 从左到右第 3 段宽度(cm):左眼外角 → 左眼内角(左眼宽度) |
|
||||
| eye4 | number | 从左到右第 4 段宽度(cm):左眼内角 → 右眼内角(两眼间距) |
|
||||
| eye5 | number | 从左到右第 5 段宽度(cm):右眼内角 → 右眼外角(右眼宽度) |
|
||||
| eye6 | number | 从左到右第 6 段宽度(cm):右眼外角 → 右脸颊 |
|
||||
| eye7 | number \| null | 从左到右第 7 段宽度(cm):右脸颊 → 人头最右(右耳外侧段)。该侧耳朵不可见时为 null |
|
||||
|
||||
> `eye1`~`eye7` 为从左到右共 7 段宽度,与标注图竖线一一对应。最左/最右端线取自耳朵分割外缘;某侧耳朵被头发或侧脸遮挡(不可见)时该侧端线省略,对应 `eye1` 或 `eye7` 为 `null`(键始终保留),实际有效段为 5 或 6 段。`eye3`/`eye5` 为左右眼宽、`eye4` 为两眼间距,与 `eye_width_cm` / `inter_eye_distance_cm` 语义一致。
|
||||
|
||||
### 标注图片(UI)规范
|
||||
|
||||
| 项目 | 要求 |
|
||||
|------|------|
|
||||
| 字体及线颜色 | `#FFFFFF` 100% |
|
||||
| 数值排布 | 四庭数值统一在图片**左侧**呈现;七眼间距**上下穿插**展示 |
|
||||
| 字体 | PingFangSC-Regular,字号 10pt |
|
||||
| 线宽 | 横线、竖线、虚线均为 1pt |
|
||||
| 线样式 | 横线、竖线渐变消失;虚线两侧呈现箭头 |
|
||||
|
||||
> **【待确认】** 标注图片需提供设计稿后才能最终确定样式。
|
||||
| 颜色 | 字体及所有线/箭头 `#FFFFFF` 100%,透明底 |
|
||||
| 尺寸 | 字号/线宽/虚线/箭头按图片**短边自适应缩放**(非固定 pt) |
|
||||
| 横线 | 5 条分界线(头顶/发际线/眉心/鼻翼下缘/下巴尖),两端渐变消失并略超出最外侧竖线;线名在线**右上方** |
|
||||
| 竖线 | 人头最左 + 七眼 6 点 + 人头最右(最外两条取自头发分割轮廓),两端渐变消失并略超出头顶/下巴 |
|
||||
| 数值排布 | 四庭数值(名 + 数值两行,**不带 cm**)统一在图片**左侧**呈现;七眼段宽**上下穿插**展示;底部统一标「单位cm」 |
|
||||
| 线样式 | 段宽/庭高用**虚线 + 实心三角双箭头**标示(箭头尖端落在虚线两端) |
|
||||
|
||||
### 响应示例(当前 Mock 返回值)
|
||||
|
||||
@@ -160,7 +172,9 @@
|
||||
"eye_width_cm": 3.44,
|
||||
"face_width_cm": 24.08,
|
||||
"inter_eye_distance_cm": 3.44,
|
||||
"ratios": { "eye_width": 0.143, "inter_eye_distance": 0.143 }
|
||||
"ratios": { "eye_width": 0.143, "inter_eye_distance": 0.143 },
|
||||
"eye1": 3.44, "eye2": 3.44, "eye3": 3.44, "eye4": 3.44,
|
||||
"eye5": 3.44, "eye6": 3.44, "eye7": 3.44
|
||||
},
|
||||
"landmarks": {
|
||||
"hair_top": { "x": 540, "y": 120 },
|
||||
@@ -175,11 +189,72 @@
|
||||
|
||||
---
|
||||
|
||||
## 接口 6:四庭七眼测量 v2 接口
|
||||
|
||||
**说明**:基于[接口 1](#接口-1四庭七眼测量标注接口)的变体。与接口 1 的差异:
|
||||
|
||||
- **去顶庭**:不画头顶横线、不返回顶庭数据。`four_courts` 仅含上/中/下庭,`landmarks` 无 `hair_top`,`face_total_height_cm` 为三庭之和(不含顶庭)。
|
||||
- **竖线范围**:纵向竖线从**发际线**画到**下巴尖**(接口 1 为头顶→下巴尖)。
|
||||
- **不画人头最左/最右端线**:仅画七眼 6 点(左脸颊/左眼外角/左眼内角/右眼内角/右眼外角/右脸颊)共 5 段标尺,不取头发轮廓的头部端线(接口 1 会多出最左/最右 2 条头部端线、共 7 段)。
|
||||
- 其余(实心三角箭头、虚线样式、字体、单位cm、七眼数据)与接口 1 一致。
|
||||
|
||||
**请求**:`POST /api/v1/face/measure-v2`
|
||||
|
||||
### 输入
|
||||
|
||||
与接口 1 完全相同。图片参数见「通用约定 → 图片传参字段」(`image_file` / `image_url` / `image_base64` 三选一)。本接口无其他专属参数。
|
||||
|
||||
### 输出(data)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| annotated_image_url | string | 标注图层 PNG URL(透明底,仅标注线/文字,不含人物) |
|
||||
| face_total_height_cm | number | 面部总高度(cm)= 上庭 + 中庭 + 下庭(**不含顶庭**) |
|
||||
| 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 有)。
|
||||
|
||||
### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"request_id": "mock-request-id",
|
||||
"data": {
|
||||
"annotated_image_url": "https://hair.xiangsilian.com/static/sample.jpg",
|
||||
"face_total_height_cm": 10.32,
|
||||
"four_courts": {
|
||||
"upper_court_cm": 3.44, "middle_court_cm": 3.44, "lower_court_cm": 3.44,
|
||||
"ratios": { "upper_court": 0.333, "middle_court": 0.333, "lower_court": 0.333 }
|
||||
},
|
||||
"seven_eyes": {
|
||||
"eye_width_cm": 3.44, "face_width_cm": 24.08, "inter_eye_distance_cm": 3.44,
|
||||
"ratios": { "eye_width": 0.143, "inter_eye_distance": 0.143 },
|
||||
"eye2": 3.0, "eye3": 3.44, "eye4": 3.44, "eye5": 3.44, "eye6": 3.0
|
||||
},
|
||||
"landmarks": {
|
||||
"hairline": { "x": 540, "y": 430 },
|
||||
"brow_center": { "x": 540, "y": 740 },
|
||||
"nose_bottom": { "x": 540, "y": 1050 },
|
||||
"chin_tip": { "x": 540, "y": 1360 }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 接口 2:C 端生发接口
|
||||
|
||||
**说明**:输入用户正面照 + 性别,按性别对应的发际线类型,逐张把建议发际线渲染到照片上,输出多张方案。
|
||||
**说明**:输入用户正面照 + 性别 + 发型序号(可多选),按指定发际线类型渲染发际线曲线透明 PNG + 生发图。
|
||||
|
||||
> **当前阶段(第一步)**:`image_url` 返回的是「**原照片 + 发际线曲线叠加的预览图**」,尚未做真正的文生图生发;后续会替换为生发后图片。实现见 [`接口2-C端生发-技术实现方案.md`](接口2-C端生发-技术实现方案.md)。
|
||||
> **每个方案返回两张图**:`image_url`=「发际线曲线**透明 PNG**(仅白色曲线,透明底,需叠加原图显示)」;`grown_image_url`=
|
||||
> 经 ComfyUI/Flux 的「植发 3 个月**生发后图片**」(完整人像照片)。两者均已实现,实现简述见 [`实现说明.md`](实现说明.md)。
|
||||
|
||||
**请求**:`POST /api/v1/hair/grow`
|
||||
|
||||
@@ -190,18 +265,28 @@
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| gender | string | **是** | 性别:`male` / `female`。决定使用的发际线贴图集合 |
|
||||
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),最多不超过该性别的预设数。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave, 6=bigflower, 7=clasicalflower;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight, 5=heart, 6=Softpetal。越界/非法返回 `1007` |
|
||||
| beauty_enabled | bool | 否 | 生发图是否带美颜效果,默认 false(当前阶段不生效) |
|
||||
| use_mask | bool | 否 | 是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
|
||||
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
|
||||
|
||||
### 输出(data)
|
||||
|
||||
`results`:发际线方案数组,**数量 = 该性别的发际线类型数**(`female` 5 个 / `male` 4 个)。每个元素:
|
||||
`results`:发际线方案数组,**数量 = 所选发型数**。每个元素:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| image_url | string | 方案预览图 URL(当前 = 发际线叠加图) |
|
||||
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`(female),`ellipse`/`m`/`straight`/`inverse_arc`(male) |
|
||||
| image_url | string | 发际线曲线**透明 PNG** URL(仅白色发际线曲线,透明底,**不含人物**,需前端叠加原图显示) |
|
||||
| grown_image_url | string | **生发后图片** URL(ComfyUI/Flux「植发 3 个月」效果图,完整人像照片) |
|
||||
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`/`bigflower`/`clasicalflower`(female),`ellipse`/`m`/`straight`/`inverse_arc`/`heart`/`Softpetal`(male) |
|
||||
| order | int | 排序序号(当前阶段固定 `1..N`,按贴图顺序,暂不计算合适度) |
|
||||
|
||||
> ⚠️ 生发图由本机 ComfyUI(Flux-2,端口 8182)生成,**一次请求生成指定发型的 1 张、同步返回**。
|
||||
> worker 侧返回 `image_base64` / `grown_image_base64`,
|
||||
> 网关落盘后改写为上表的 `image_url` / `grown_image_url`。
|
||||
>
|
||||
> 💡 `image_url` 为透明底 PNG,前端需用绝对定位叠加到原图上显示(参考[测试页](https://hair.xiangsilian.com/static/test_interface2.html)的 `.img-stack` 叠加结构)。
|
||||
|
||||
### 响应示例
|
||||
|
||||
```json
|
||||
@@ -211,41 +296,45 @@
|
||||
"request_id": "mock-request-id",
|
||||
"data": {
|
||||
"results": [
|
||||
{ "image_url": "https://hair.xiangsilian.com/static/annotations/uuid1.png", "hairline_type": "ellipse", "order": 1 },
|
||||
{ "image_url": "https://hair.xiangsilian.com/static/annotations/uuid2.png", "hairline_type": "flower", "order": 2 }
|
||||
{ "image_url": "https://hair.xiangsilian.com/static/annotations/uuid1.png", "grown_image_url": "https://hair.xiangsilian.com/static/annotations/grown1.png", "hairline_type": "ellipse", "order": 1 },
|
||||
{ "image_url": "https://hair.xiangsilian.com/static/annotations/uuid2.png", "grown_image_url": "https://hair.xiangsilian.com/static/annotations/grown2.png", "hairline_type": "flower", "order": 2 }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 识别失败时返回通用错误码(1001 / 1002 / 1003 等)。`gender` 缺失或非法值返回参数错误(1008);本接口已改为必填入参,不再自动判别性别(1004 不再使用)。
|
||||
> 识别失败时返回通用错误码(1001 / 1002 / 1003 等)。`gender` 缺失或非法值返回 **1004**;本接口已改为必填入参,不再自动判别性别。
|
||||
|
||||
---
|
||||
|
||||
## 接口 3:B 端生发接口
|
||||
|
||||
**说明**:输入医生/操作端的「划线图片」(在原图上标注了目标发际线),输出最合适的发际线图片 + 生发后图片。
|
||||
**说明**:医生/操作端在用户照片上用马克笔标注目标发际线后,**只需上传这一张划线图**。系统检测划线 →
|
||||
据此生成生发后图片。
|
||||
|
||||
**请求**:`POST /api/v1/hair/grow-b`
|
||||
|
||||
### 输入
|
||||
|
||||
划线图片同样支持「文件 / URL / base64」三种方式(字段:`marked_image_file` / `marked_image_url` / `marked_image_base64`)。
|
||||
划线图片支持「文件 / URL / base64」三选一(字段:`marked_image_file` / `marked_image_url` / `marked_image_base64`)。
|
||||
**不需要原始照片**(划线图本身就是用户照片 + 手绘线)。
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| marked_image_* | file / string | 是 | 已划线(标注发际线)的图片,三选一 |
|
||||
| original_image_* | file / string | 是 | 原始用户照片,需同时上传 |
|
||||
| marked_image_* | file / string | 是 | 已用马克笔标注发际线的图片,三选一 |
|
||||
| use_mask | bool | 否 | 是否画发际线,默认 `true`。`false` 时跳过划线检测、直接送划线图,模型仅凭手绘黑线生发,供测试对比 |
|
||||
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
|
||||
|
||||
### 输出(data)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| best_hairline_image_url | string | 最合适的发际线图片 |
|
||||
| hair_growth_image_url | string | 生发后图片 |
|
||||
| hairline_type | string | 发际线形,需返回 |
|
||||
| hair_growth_image_url | string | **生发后图片**(检测划线 → ComfyUI/Flux「植发 3 个月」效果)。worker 返回 `hair_growth_image_base64` |
|
||||
| hairline_type | string | 发际线形,手绘为定制,固定 `"custom"` |
|
||||
|
||||
### 响应示例(当前 Mock 返回值)
|
||||
> 未检测到划线(或无人脸)返回 **1001**。
|
||||
|
||||
### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -253,9 +342,8 @@
|
||||
"message": "success",
|
||||
"request_id": "mock-request-id",
|
||||
"data": {
|
||||
"best_hairline_image_url": "https://hair.xiangsilian.com/static/sample.jpg",
|
||||
"hair_growth_image_url": "https://hair.xiangsilian.com/static/sample.jpg",
|
||||
"hairline_type": "花瓣形"
|
||||
"hair_growth_image_url": "https://hair.xiangsilian.com/static/annotations/grown.png",
|
||||
"hairline_type": "custom"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -264,7 +352,7 @@
|
||||
|
||||
## 接口 4:用户特征接口
|
||||
|
||||
**说明**:输入用户照片,输出 N 个用户特征字段。
|
||||
**说明**:输入用户照片,由**火山方舟 豆包视觉模型**(`doubao-seed-1-6-vision`)分析,输出一大批面部特征。
|
||||
|
||||
**请求**:`POST /api/v1/face/features`
|
||||
|
||||
@@ -274,18 +362,21 @@
|
||||
|
||||
### 输出(data)
|
||||
|
||||
`data` 直接返回一个 **JSON 字符串**(`features`),其内部字段不固定、可随时调整,由业务方约定。当前优先返回的字段如下(仅作示例,最终以实际返回为准):
|
||||
`data.features` 是一个 **JSON 字符串**(不是对象,客户端 `JSON.parse()` 后用),**仅含以下 6 个英文字段**:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| face_shape | string | 脸形 |
|
||||
| eyebrow_shape | string | 眉形 |
|
||||
| facial_age | int | 面部年龄 |
|
||||
| dynamic_static_type | string | 动静类型 |
|
||||
| gender | string | 性别 |
|
||||
| gene_style | object | 面部特征对应面部标签的「基因风格」 |
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| face_shape | 脸形(脸型)|
|
||||
| eyebrow_shape | 眉形 |
|
||||
| facial_age | 面部年龄(区间字符串,如"18-25岁")|
|
||||
| dynamic_static_type | 动静类型(静态型/动态型)|
|
||||
| gender | 性别(男/女)|
|
||||
| gene_style | 基因风格(如"自然型")|
|
||||
|
||||
### 响应示例(当前 Mock 返回值)
|
||||
> 无人脸返回 `1001`(据 doubao「图片是否有人脸」判定)。⚠️ 本接口是唯一调**外网云模型**的接口,
|
||||
> worker 需可访问 `ark.cn-beijing.volces.com`;API Key 走 worker 配置/环境变量。
|
||||
|
||||
### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -293,38 +384,74 @@
|
||||
"message": "success",
|
||||
"request_id": "mock-request-id",
|
||||
"data": {
|
||||
"features": "{\"face_shape\": \"鹅蛋脸\", \"eyebrow_shape\": \"柳叶眉\", \"facial_age\": 26, \"dynamic_static_type\": \"静态\", \"gender\": \"女\", \"gene_style\": {\"label\": \"面部特征标签\", \"style\": \"基因风格示例\"}}"
|
||||
"features": "{\"face_shape\":\"鹅蛋脸\",\"eyebrow_shape\":\"平眉\",\"facial_age\":\"18-25岁\",\"dynamic_static_type\":\"静态型\",\"gender\":\"女\",\"gene_style\":\"少年型\"}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `features` 为字符串形式的 JSON,字段后续可随时增删,不固定。
|
||||
> `features` 为字符串形式的 JSON,固定上述 6 个字段。
|
||||
|
||||
---
|
||||
|
||||
## 接口 5:发际线 PNG 生成接口
|
||||
|
||||
**说明**:输入用户照片,返回 N 张用户发际线的 PNG 图片,并返回「最合适发际线」的面部中间点坐标。
|
||||
**说明**:入参同接口2(先选性别、再多选发型)。对每个选中发型返回 `middle` / `high` / `low` **三档**发际线叠图与**生发图**,并返回「最合适发际线」的面部中间点坐标。
|
||||
|
||||
**请求**:`POST /api/v1/hairline/generate`
|
||||
|
||||
### 输入
|
||||
|
||||
图片参数见「通用约定 → 图片传参字段」。本接口无其他专属参数。
|
||||
图片参数见「通用约定 → 图片传参字段」。专属参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| gender | string | **是** | 性别:`male` / `female`。决定发型集合(female 7 / male 6)。缺失/非法返回 `1004` |
|
||||
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),决定返回哪些发际线类型。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave, 6=bigflower, 7=clasicalflower;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight, 5=heart, 6=Softpetal。缺失/越界/非法返回 `1007` |
|
||||
| use_mask | bool | 否 | 生发是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
|
||||
| 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 张生发图。
|
||||
|
||||
### 输出(data)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| hairline_images | object[] | N 张用户发际线 PNG,**数量 N 不固定**,已按合适度排序,元素见下表 |
|
||||
| best_hairline_center_point | object | 最合适发际线的「面部中间点」坐标,原图像素:`{ "x": number, "y": number }` |
|
||||
| hairline_images | object[] | **选中发型**列表,**数量 = 所选发型数**,元素见下表 |
|
||||
| best_hairline_center_point | object \| null | **首个选中发型**的 **middle 档**发际线曲线「面部中间点」坐标,原图像素:`{ "x": number, "y": number }` |
|
||||
| high_hairline_center_point | object \| null | 同上,**high 档**发际线中点(发际线偏高) |
|
||||
| low_hairline_center_point | object \| null | 同上,**low 档**发际线中点(发际线偏低) |
|
||||
| face_measure | object \| null | **复用接口1**的四庭七眼测量**数值**(不含标注图)。独立流程,测量失败(无人脸/非正面/分割失败)时为 `null`,不影响发际线主结果。字段结构见下表 |
|
||||
|
||||
`hairline_images` 元素:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| image_url | string | 发际线 PNG 图片 URL |
|
||||
| order | int | 排序序号(1 = 最合适,依次递增) |
|
||||
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`/`bigflower`/`clasicalflower`(female),`ellipse`/`m`/`straight`/`inverse_arc`/`heart`/`Softpetal`(male) |
|
||||
| image_middle_url | string | middle 档发际线曲线**透明 PNG** URL(仅曲线,透明底,**不含人物**,需叠加原图显示) |
|
||||
| image_high_url | string | high 档发际线曲线**透明 PNG** URL(同上,high 档曲线) |
|
||||
| image_low_url | string | low 档发际线曲线**透明 PNG** URL(同上,low 档曲线) |
|
||||
| grown_image_url | string \| null | **生发后图片** URL(ComfyUI「植发」效果图,完整人像照片,生发失败或 `generate_grow_image=false` 时为 `null`) |
|
||||
| order | int | 发型序号(= 传入的 hair_style 值) |
|
||||
|
||||
> worker 侧返回 `image_middle_base64` / `image_high_base64` / `image_low_base64` / `grown_image_base64`,网关落盘后改写为上表对应的 `*_url`。
|
||||
>
|
||||
> 💡 三档 `image_*_url` 为透明底 PNG,前端需用绝对定位叠加到原图上显示(参考[测试页](https://hair.xiangsilian.com/static/test_interface5.html)的 `.img-stack` 叠加结构)。`grown_image_url` 是完整人像照片,直接显示即可。
|
||||
|
||||
`face_measure` 元素(与[接口1](#接口-1四庭七眼测量标注接口)的 `data` 同构,**不含** `annotated_image_*` 标注图字段):
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| face_total_height_cm | number | 全脸总高度(cm)= 四庭之和 |
|
||||
| four_courts | object | 四庭数据(顶/上/中/下庭 cm + 占比 ratios),结构同接口1 |
|
||||
| seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距 cm + 占比 ratios + eye1~eye7 从左到右 7 段宽度),结构同接口1 |
|
||||
| landmarks | object | 5 个纵向关键点像素坐标(hair_top/hairline/brow_center/nose_bottom/chin_tip),结构同接口1 |
|
||||
| hairline_source | string | 发际线来源:`segmentation`(真实分割)/ `estimated`(比例估算) |
|
||||
| head_pose | object | 头部姿态角度(yaw/pitch/roll,单位:度) |
|
||||
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` |
|
||||
| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:`{ "x": int, "y": int }` |
|
||||
|
||||
> `eye1`~`eye7` 为从左到右共 7 段宽度,eye1=左耳外段、eye7=右耳外段,某侧耳朵不可见时对应段为 `null`。详见接口1说明。
|
||||
|
||||
### 响应示例(当前 Mock 返回值)
|
||||
|
||||
@@ -335,14 +462,57 @@
|
||||
"request_id": "mock-request-id",
|
||||
"data": {
|
||||
"hairline_images": [
|
||||
{ "image_url": "https://hair.xiangsilian.com/static/sample.jpg", "order": 1 },
|
||||
{ "image_url": "https://hair.xiangsilian.com/static/sample.jpg", "order": 2 }
|
||||
{
|
||||
"hairline_type": "ellipse",
|
||||
"image_middle_url": "https://hair.xiangsilian.com/static/annotations/mid1.png",
|
||||
"image_high_url": "https://hair.xiangsilian.com/static/annotations/high1.png",
|
||||
"image_low_url": "https://hair.xiangsilian.com/static/annotations/low1.png",
|
||||
"grown_image_url": "https://hair.xiangsilian.com/static/annotations/grown1.png",
|
||||
"order": 1
|
||||
},
|
||||
{
|
||||
"hairline_type": "heart",
|
||||
"image_middle_url": "https://hair.xiangsilian.com/static/annotations/mid3.png",
|
||||
"image_high_url": "https://hair.xiangsilian.com/static/annotations/high3.png",
|
||||
"image_low_url": "https://hair.xiangsilian.com/static/annotations/low3.png",
|
||||
"grown_image_base64": null,
|
||||
"order": 3
|
||||
}
|
||||
],
|
||||
"best_hairline_center_point": { "x": 540, "y": 430 }
|
||||
"best_hairline_center_point": { "x": 540, "y": 430 },
|
||||
"high_hairline_center_point": { "x": 540, "y": 380 },
|
||||
"low_hairline_center_point": { "x": 540, "y": 480 },
|
||||
"face_measure": {
|
||||
"face_total_height_cm": 26.76,
|
||||
"four_courts": {
|
||||
"top_court_cm": 5.77, "upper_court_cm": 5.93,
|
||||
"middle_court_cm": 7.62, "lower_court_cm": 7.44,
|
||||
"ratios": { "top_court": 0.216, "upper_court": 0.222,
|
||||
"middle_court": 0.285, "lower_court": 0.278 }
|
||||
},
|
||||
"seven_eyes": {
|
||||
"eye_width_cm": 2.76, "face_width_cm": 15.08,
|
||||
"inter_eye_distance_cm": 3.9,
|
||||
"ratios": { "eye_width": 0.183, "inter_eye_distance": 0.259 },
|
||||
"eye1": null, "eye2": 3.0, "eye3": 2.76, "eye4": 3.9,
|
||||
"eye5": 2.76, "eye6": 3.0, "eye7": null
|
||||
},
|
||||
"landmarks": {
|
||||
"hair_top": { "x": 504, "y": 103 },
|
||||
"hairline": { "x": 504, "y": 228 },
|
||||
"brow_center": { "x": 504, "y": 357 },
|
||||
"nose_bottom": { "x": 505, "y": 522 },
|
||||
"chin_tip": { "x": 506, "y": 683 }
|
||||
},
|
||||
"hairline_source": "segmentation",
|
||||
"head_pose": { "yaw": -1.39, "pitch": 2.49, "roll": -0.06 }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 说明:生发失败的元素中,网关不改写 `null` 值,故字段名保持为 `grown_image_base64: null`(有值时才改写为 `grown_image_url`),与接口2生发失败项一致。
|
||||
|
||||
---
|
||||
|
||||
## 汇总:输入输出一览
|
||||
@@ -350,10 +520,11 @@
|
||||
| 接口 | 输入 | 主要输出 |
|
||||
|------|------|----------|
|
||||
| 1 四庭七眼测量 | 用户照片 | 标注 PNG(无人物)+ 四庭/七眼厘米数值与坐标 |
|
||||
| 2 C 端生发 | 用户照片 | 生发后图片 + 多张发际线(带类型与排序) |
|
||||
| 6 四庭七眼测量 v2 | 用户照片 | 同接口1,复刻实现 |
|
||||
| 2 C 端生发 | 用户照片 | 生发后图片 + 指定发际线预览(单/多张) |
|
||||
| 3 B 端生发 | 划线图片 | 最合适发际线图片 + 生发后图片 |
|
||||
| 4 用户特征 | 用户照片 | N 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格…) |
|
||||
| 5 发际线 PNG | 用户照片 | N 张发际线 PNG + 最合适发际线面部中间点坐标 |
|
||||
| 4 用户特征 | 用户照片 | 6 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格) |
|
||||
| 5 发际线 PNG | 用户照片 + gender + hair_style(多选) | 每个选中发型 middle/high/low 三档发际线叠图 + 生发图 + 最合适发际线面部中间点坐标 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
# 系统架构:外网网关 + 高性能后端(worker)
|
||||
|
||||
> 本文档描述「旷视五接口」的部署架构拆分。**接口文档(对外契约)完全不变**——客户端看到的 URL、请求/响应结构、错误码 1001–1008 全部保持原样。本文只改变内部如何处理这些请求。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
外网服务器(`hair.xiangsilian.com`)性能不足以跑 MediaPipe + BiSeNet + 标注图生成等重逻辑。因此拆分为两层:
|
||||
|
||||
- **外网网关(gateway)**:保持 HTTPS 对外接口不变,**自身不跑算法**,只做反向代理、健康检查、负载分发、鉴权、标注图托管。资源占用极小,可继续跑在现有外网机。
|
||||
- **高性能后端(worker)**:独立机器,**GPU + 32G 内存**,跑真正的算法逻辑。通过 `http://hair.xiangsilian.com:28187` 这类 `host:port` 暴露。
|
||||
|
||||
**核心诉求**:
|
||||
1. 对外接口与文档零变化。
|
||||
2. 网关可配置**多个 worker URL**,周期探测可用性,只把请求发给健康的 worker。
|
||||
3. **每个 worker 并发 = 1**(一次处理一个请求);多 worker 即可并发,**总并发 = 健康 worker 数**。
|
||||
|
||||
---
|
||||
|
||||
## 2. 拓扑总览
|
||||
|
||||
```
|
||||
HTTPS (对外,接口文档不变)
|
||||
┌────────┐ :443 ┌───────────────────────────┐
|
||||
│ 客户端 │ ───────▶ │ 外网网关 gateway │
|
||||
└────────┘ │ hair.xiangsilian.com │
|
||||
│ - 反向代理 5 个接口 │
|
||||
│ - 健康检查 worker 池 │
|
||||
│ - 空闲 worker 派发(并发=worker数)│
|
||||
│ - 共享密码鉴权 │
|
||||
│ - 标注图 base64→落盘→URL │
|
||||
│ - 无可用后端→1007 │
|
||||
└───────┬───────────┬─────────┘
|
||||
HTTP + 密码头 │ │
|
||||
┌───────────────────────┘ └─────────────┐
|
||||
▼ ▼
|
||||
┌──────────────────┐ ┌──────────────────┐
|
||||
│ worker #1 │ ...(可配置多个)... │ worker #N │
|
||||
│ :28187 GPU/32G │ │ :xxxxx GPU/32G │
|
||||
│ 跑完整 app.py │ │ 跑完整 app.py │
|
||||
│ + face_analysis │ │ + face_analysis │
|
||||
│ 并发=1 │ │ 并发=1 │
|
||||
└──────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 职责划分
|
||||
|
||||
| 能力 | 网关 gateway | worker |
|
||||
|------|:---:|:---:|
|
||||
| 对外 HTTPS、接口契约 | ✅ | ✗ |
|
||||
| 反向代理 5 个接口 | ✅ | ✗ |
|
||||
| 算法(MediaPipe/BiSeNet/测量/标注图) | ✗ | ✅ |
|
||||
| 入参校验(大小/格式/分辨率/人脸) | ✗(透传) | ✅ |
|
||||
| worker 健康检查 + 池管理 | ✅ | 提供 `/health` |
|
||||
| 负载分发(挑空闲 worker) | ✅ | ✗ |
|
||||
| 鉴权(共享密码) | 发送密码 | 校验密码 |
|
||||
| 标注 PNG 落盘 + 对外 URL | ✅ | 返回 base64 |
|
||||
| `/static/*` 静态托管 | ✅ | ✗ |
|
||||
| GPU | 不需要 | ✅ |
|
||||
|
||||
> **原则**:所有业务逻辑只在 worker 实现一份(worker 跑的就是完整 `app.py` + `face_analysis`)。网关是无状态的薄层,除了"健康池 + worker 忙闲状态"外不持有业务状态。
|
||||
|
||||
---
|
||||
|
||||
## 4. 网关配置文件
|
||||
|
||||
后端 URL 列表、共享密码、各项参数集中在一个配置文件,运维手动维护(密码定期轮换)。
|
||||
|
||||
`gateway/config.json`(示例):
|
||||
```json
|
||||
{
|
||||
"workers": [
|
||||
"http://hair.xiangsilian.com:28187",
|
||||
"http://10.0.0.12:28187"
|
||||
],
|
||||
"shared_password": "REPLACE_ME_ROTATE_PERIODICALLY",
|
||||
"accept_passwords": ["REPLACE_ME_ROTATE_PERIODICALLY"],
|
||||
"health_check": {
|
||||
"path": "/health",
|
||||
"interval_seconds": 8,
|
||||
"timeout_seconds": 3,
|
||||
"unhealthy_threshold": 2,
|
||||
"healthy_threshold": 1
|
||||
},
|
||||
"dispatch": {
|
||||
"per_worker_concurrency": 1,
|
||||
"queue_wait_seconds": 30,
|
||||
"request_timeout_seconds": 60,
|
||||
"retry_on_failure": true,
|
||||
"max_retries": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
- `workers`:worker 基址列表,手动增删。**增加一个就多一路并发**。
|
||||
- `shared_password`:网关调用 worker 时发送的密码。
|
||||
- `accept_passwords`:worker 端用(见 §7)——允许的密码列表,**轮换期可同时放新旧两个**,实现不停机改密码。网关侧也可放在 worker 的独立配置里。
|
||||
- `health_check`:探测周期、超时、连续失败几次判定下线、连续成功几次判定上线。
|
||||
- `dispatch.per_worker_concurrency`:**固定为 1**(当前约束)。
|
||||
- `dispatch.queue_wait_seconds`:所有 worker 都忙时请求最多排队多久,超时返回 1007。
|
||||
- `request_timeout_seconds`:单次转发到 worker 的超时。
|
||||
- `retry_on_failure` / `max_retries`:worker 转发失败时是否换一个 worker 重试。
|
||||
|
||||
> 配置变更后网关需 reload(可做成监听文件变更热加载,或重启网关进程)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 健康检查
|
||||
|
||||
网关后台**周期轮询**每个 worker 的 `/health`(已存在于 `app.py`,排除在 OpenAPI 之外):
|
||||
|
||||
- 间隔 `interval_seconds`,每次超时 `timeout_seconds`。
|
||||
- 连续失败 `unhealthy_threshold` 次 → 标记**下线**,停止派发。
|
||||
- 重新连续成功 `healthy_threshold` 次 → 标记**上线**,恢复派发。
|
||||
- 健康池 = 当前在线的 worker 集合。
|
||||
|
||||
**主动检查 + 被动剔除**双保险:除周期探测外,转发请求时若 worker 连接失败/超时,立即把它标记为不健康并(按配置)换一个 worker 重试。
|
||||
|
||||
> worker 的 `/health` 建议返回 **模型就绪状态**:只有 MediaPipe / BiSeNet 权重都加载完成才返回 200,否则返回 503——避免请求被派发到尚未热好的 worker。
|
||||
|
||||
---
|
||||
|
||||
## 6. 负载分发与并发
|
||||
|
||||
**每个 worker 并发 = 1**,所以分发逻辑很简单:
|
||||
|
||||
1. 网关维护每个健康 worker 的**忙/闲**状态。
|
||||
2. 新请求到来:从健康池里选**一个空闲 worker**,标记为忙,转发;收到响应(或失败)后标记为闲。
|
||||
3. 若当前**无空闲 worker**(全忙):请求进入**队列等待**,直到有 worker 空闲或等待超过 `queue_wait_seconds`(超时返回 1007)。
|
||||
4. 若**健康池为空**(无可用后端):直接返回 1007。
|
||||
|
||||
- **总并发能力 = 健康 worker 数量**。加机器即扩并发。
|
||||
- 选空闲 worker 的策略:任意(如先到先得 / 轮转空闲),并发=1 下无需最少连接数算法。
|
||||
|
||||
---
|
||||
|
||||
## 7. 鉴权(共享密码)
|
||||
|
||||
`:28187` 在公网可直接访问,必须鉴权防止他人直接打 worker。
|
||||
|
||||
- **网关 → worker**:每个转发请求带密码头,如 `X-Internal-Token: <shared_password>`。
|
||||
- **worker 校验**:worker 侧配置文件持有 `accept_passwords` 列表,收到请求校验 `X-Internal-Token` 是否在列表内;不匹配返回 HTTP 401,**不进入业务逻辑**。
|
||||
- **轮换**:运维改密码时,先把新密码加进 worker 的 `accept_passwords`(此时新旧都接受)→ 再把网关 `shared_password` 切到新值 → 确认无旧密码流量后从 worker 移除旧密码。全程不停机。
|
||||
- 密码**仅存配置文件**,不写日志、不进 git(配置文件加入 `.gitignore`,仓库只放 `config.example.json`)。
|
||||
|
||||
> 建议叠加防火墙:worker 防火墙只放行网关来源 IP(纵深防御)。密码是应用层兜底。
|
||||
|
||||
---
|
||||
|
||||
## 8. 单次请求处理流程
|
||||
|
||||
```
|
||||
客户端 ──(HTTPS, multipart/json, 接口文档原样)──▶ 网关
|
||||
│
|
||||
├─ 1. 选一个空闲健康 worker(无则排队/1007)
|
||||
├─ 2. 原样转发请求体 + 加 X-Internal-Token 头
|
||||
│ ──(HTTP)──▶ worker
|
||||
│ ├─ 校验密码(失败→401,网关视为该 worker 异常)
|
||||
│ ├─ 跑完整业务(校验/MediaPipe/BiSeNet/测量/标注图)
|
||||
│ └─ 返回标准信封;图片字段以 base64 形式(见 §9)
|
||||
├─ 3. 收到 worker 响应,标记该 worker 空闲
|
||||
├─ 4. 若响应含 base64 图片 → 落盘到网关 /static/annotations/{uuid}.png
|
||||
│ → 把字段改写成对外 URL(见 §9)
|
||||
└─ 5. 把最终(符合接口文档的)响应返回客户端
|
||||
```
|
||||
|
||||
- 网关**不解析也不校验**业务入参,原样透传(worker 负责全部校验与错误码)。worker 返回的 1001–1008 由网关**透传**给客户端。
|
||||
- 网关只在「无后端/排队超时」时**自行**返回 1007。
|
||||
|
||||
---
|
||||
|
||||
## 9. 标注图处理(base64 → 落盘 → URL 改写)★关键
|
||||
|
||||
接口文档里多个接口返回图片 URL(接口1 标注图、接口3 标记图、接口5 发际线图)。拆分后:
|
||||
|
||||
1. **worker 不落盘、不拼 URL**,而是把生成的 PNG 以 **base64** 放进响应的约定字段返回给网关。
|
||||
- 约定:worker 用 `*_base64` 字段承载图片,例如接口1 返回 `annotated_image_base64` 而非 `annotated_image_url`。
|
||||
2. **网关收到后**:对每个 `*_base64` 字段——
|
||||
- 解码 → 保存到网关本地 `static/annotations/{uuid}.png`;
|
||||
- 删除该 base64 字段,新增对应的 `*_url` 字段,值为 `https://hair.xiangsilian.com/static/annotations/{uuid}.png`;
|
||||
3. 客户端最终看到的字段名/URL **与接口文档完全一致**(如 `annotated_image_url`)。
|
||||
|
||||
> 网关持有一份「接口 → 图片字段」映射表(接口1: `annotated_image` ↔ 接口5: `hairline_image` 等),按表把内部 `*_base64` 转成对外 `*_url`。映射表以接口文档为准。
|
||||
>
|
||||
> 代价:内部 HTTP 多传一份 base64(图片放大约 1.33×)。worker↔网关若跨网络,单图 ~100KB–1MB 量级,可接受。
|
||||
>
|
||||
> 静态文件清理:网关 `static/annotations/` 会持续增长,需加定期清理(按时间或容量,定时任务),与拆分前同样的问题,由网关侧负责。
|
||||
|
||||
---
|
||||
|
||||
## 10. 错误处理
|
||||
|
||||
| 场景 | 返回 | 由谁 |
|
||||
|------|------|------|
|
||||
| 业务错误(无人脸/分辨率/非正面/超大/格式…) | 1001–1008(原样) | worker 产生,网关透传 |
|
||||
| **无可用 worker / 全忙排队超时** | **1007 系统错误** | 网关 |
|
||||
| worker 转发失败(连接/超时/5xx/401) | 按配置换 worker 重试;重试耗尽 → 1007 | 网关 |
|
||||
|
||||
- 复用 **1007**(系统错误)表示「基础设施层不可用」,**不新增错误码**,接口文档不动。
|
||||
- 局限:调用方无法从错误码区分"算法失败"与"后端全挂"(都是 1007)。如需区分,可在 `message` 文案上体现(如"后端服务暂不可用,请稍后重试"),但 `code` 保持 1007。
|
||||
|
||||
---
|
||||
|
||||
## 11. worker 侧相对单机方案的变化
|
||||
|
||||
worker 跑的就是「接口1 技术实现方案」里描述的完整逻辑,但有几处因拆分/硬件而变:
|
||||
|
||||
1. **GPU 加速**:worker 有独立 GPU,BiSeNet 改用 **CUDA** 推理(torch GPU 版),比 CPU 快很多;MediaPipe 仍 CPU(Python solutions API 仅 CPU)。
|
||||
2. **不落盘、返回 base64**:标注图相关接口的 handler 改为返回 `*_base64`,不再保存到本地 `/static`、不拼 URL(改由网关做,见 §9)。
|
||||
3. **`/health` 反映就绪**:模型加载完才返回 200。
|
||||
4. **鉴权中间件**:worker 增加一个校验 `X-Internal-Token` 的中间件/依赖(见 §7)。
|
||||
5. **资源宽裕**:32G 内存 + GPU,无需单机方案里 2核4G 的并发限制与降级开关;但 worker 自身仍是**并发=1**(由网关保证,不向 worker 发并发请求;worker 可不做内部并发控制,但建议 uvicorn 单 worker 进程以省显存)。
|
||||
|
||||
> 单机方案文档(接口1 技术实现方案)描述的「方案A/B、虹膜标定、标注图、误差验证」全部不变,只是运行位置从外网机挪到 GPU worker,并启用 GPU。
|
||||
|
||||
---
|
||||
|
||||
## 12. 部署
|
||||
|
||||
### 网关(外网机,hair.xiangsilian.com)
|
||||
- 新增 gateway 应用(轻量 FastAPI/asgi 代理)。
|
||||
- nginx 把 443 → 网关进程;网关再转发到 worker 池。
|
||||
- 托管 `/static/*`(标注图落盘目录)。
|
||||
- 配置文件 `gateway/config.json`(含 worker 列表 + 密码)。
|
||||
- systemd 管理网关进程。
|
||||
|
||||
### worker(GPU 机,:28187)
|
||||
- 部署完整 `app.py` + `face_analysis` + 模型权重(见 `OFFLINE_ASSETS.md`)。
|
||||
- torch 用 **GPU 版**(CUDA),其余依赖同单机方案。
|
||||
- uvicorn 监听 `0.0.0.0:28187`(仅经防火墙放行网关)。
|
||||
- 配置文件持有 `accept_passwords`。
|
||||
- systemd 管理 worker 进程;`/health` 供网关探测。
|
||||
|
||||
---
|
||||
|
||||
## 13. 安全注意
|
||||
|
||||
- `:28187` **公网可达 + HTTP 明文**:密码头会明文走公网。**强烈建议**给 worker 也套一层 TLS(worker 前置 nginx 终止 HTTPS,或网关↔worker 走内网/VPN/IP 白名单),否则密码可被中间人嗅探。
|
||||
- 若短期内只能 HTTP,务必靠**防火墙 IP 白名单**把 worker 限制为只接受网关来源,密码作为应用层兜底。
|
||||
- 密码不入 git、不写日志。
|
||||
- 网关对转发的请求体大小设上限(如 ≤2MB),防止被超大 body 拖垮。
|
||||
|
||||
---
|
||||
|
||||
## 14. 待确认 / 后续
|
||||
|
||||
1. **worker↔网关传输是否加 TLS**:当前 §13 标为强烈建议。若运维能给 worker 配 HTTPS 或限定内网,安全性更好。请确认部署条件。
|
||||
2. **worker host:port 形态**:`hair.xiangsilian.com:28187` 是端口转发到 GPU 机,还是 GPU 机直接持有该域名?影响防火墙与 TLS 方案。
|
||||
3. **多 worker 的物理分布**:是否都在同一内网?若跨公网,base64 图片传输与密码明文风险都需重新评估。
|
||||
4. **静态图清理策略**:网关 `static/annotations/` 的保留时长 / 清理触发条件。
|
||||
|
||||
---
|
||||
|
||||
> **文档版本**: v1.0 | **创建日期**: 2026-06-14 | 配套:接口1 技术方案 v2.0 / 开发任务书 / OFFLINE_ASSETS.md
|
||||
> **关键约束**: 接口文档不变;每 worker 并发=1;无后端→1007;标注图 base64→网关落盘→URL
|
||||
@@ -1,198 +0,0 @@
|
||||
# 外网网关 — 开发任务书(AI Agent 执行版)
|
||||
|
||||
> 在 **外网机(`hair.xiangsilian.com`)** 上开发。本任务书自包含,只负责**网关**这一层。
|
||||
> 配套:[`系统架构-网关与高性能后端.md`](系统架构-网关与高性能后端.md)(两端共享的契约,**先读**)。
|
||||
> worker(GPU 机)侧算法由另一份任务书负责:`接口1-四庭七眼测量-开发任务书.md`,本机不涉及。
|
||||
|
||||
> 执行者:AI coding agent。**严格按阶段顺序**执行,每阶段跑完「验证方法」通过后再进入下一阶段。
|
||||
|
||||
---
|
||||
|
||||
## 0. 网关是什么 / 不是什么
|
||||
|
||||
- **是**:一个无状态的**薄反向代理**。对外保持 HTTPS 接口与接口文档**完全不变**;对内把请求转发给高性能 worker 池,做健康检查、空闲派发、鉴权、把 worker 返回的 base64 标注图落盘成对外 URL。
|
||||
- **不是**:不跑任何算法(无 MediaPipe / torch / BiSeNet);不解析业务入参;不持有业务状态。所有业务逻辑和错误码(1001–1008)都来自 worker,网关原样透传。
|
||||
|
||||
**网关唯一自行产生的错误**:无可用 worker / 全忙排队超时 → `code: 1007`(复用系统错误,不新增错误码)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 总体约束
|
||||
|
||||
1. **对外契约零变化**:客户端看到的 URL、请求方式(multipart/form-data,三选一图片)、响应结构 `{code,message,request_id,data}`、字段名、错误码,全部与 `docs/接口文档.md` 一致。客户端**无感知**拆分。
|
||||
2. **代理全部 5 个接口**:`/api/v1/face/measure`、`/hair/grow`、`/hair/grow-b`、`/face/features`、`/hairline/generate`。worker 跑完整 app,网关统一代理。
|
||||
3. **技术栈**:Python + FastAPI + `httpx`(异步转发)+ uvicorn。**不引入** torch/mediapipe/opencv 等重依赖,网关保持轻量。
|
||||
4. **无状态**:除"健康池 + worker 忙闲状态"这点运行时状态外,不持久化业务数据。
|
||||
5. 密码、worker 列表等走**配置文件**,不硬编码、不入 git。
|
||||
|
||||
---
|
||||
|
||||
## 2. 配置文件
|
||||
|
||||
`gateway/config.json`(运维维护,**入 `.gitignore`**;仓库只放 `gateway/config.example.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"workers": [
|
||||
"http://hair.xiangsilian.com:28187",
|
||||
"http://10.0.0.12:28187"
|
||||
],
|
||||
"shared_password": "REPLACE_ME_ROTATE_PERIODICALLY",
|
||||
"public_base_url": "https://hair.xiangsilian.com",
|
||||
"static_dir": "static/annotations",
|
||||
"health_check": {
|
||||
"path": "/health",
|
||||
"interval_seconds": 8,
|
||||
"timeout_seconds": 3,
|
||||
"unhealthy_threshold": 2,
|
||||
"healthy_threshold": 1
|
||||
},
|
||||
"dispatch": {
|
||||
"per_worker_concurrency": 1,
|
||||
"queue_wait_seconds": 30,
|
||||
"request_timeout_seconds": 60,
|
||||
"retry_on_failure": true,
|
||||
"max_retries": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
字段含义见架构文档 §4。要点:`workers` 手动增删(**加一个就多一路并发**);`per_worker_concurrency` 固定 1;`shared_password` 网关调用 worker 时通过 `X-Internal-Token` 头发送。
|
||||
|
||||
---
|
||||
|
||||
## 3. 阶段一:骨架 + 配置加载
|
||||
|
||||
**开发步骤**
|
||||
1. 新建目录:
|
||||
```
|
||||
gateway/
|
||||
├── app.py # FastAPI 应用 + 5 接口代理路由
|
||||
├── config.py # 读取/校验 config.json
|
||||
├── config.example.json
|
||||
├── pool.py # 健康池 + worker 忙闲状态 + 派发
|
||||
├── forward.py # httpx 转发 + base64→URL 改写
|
||||
└── __init__.py
|
||||
static/annotations/ # 标注图落盘目录(.gitkeep)
|
||||
```
|
||||
2. `config.py`:加载 `config.json`,缺字段给默认值,启动时校验 `workers` 非空、密码非占位值(占位值打 WARNING)。
|
||||
3. `app.py`:建 FastAPI 应用,挂载 `/static`,加 `/gateway-health`(网关自身健康,区别于 worker 的 `/health`)。
|
||||
4. 更新根 `.gitignore`:忽略 `gateway/config.json`、`static/annotations/*`(保留 `.gitkeep`)。
|
||||
|
||||
**验证**
|
||||
```bash
|
||||
./venv/bin/uvicorn gateway.app:app --host 127.0.0.1 --port 8080 &
|
||||
curl -s http://127.0.0.1:8080/gateway-health # 200
|
||||
```
|
||||
启动日志打印解析出的 worker 列表与派发参数。
|
||||
|
||||
**完成标准**:网关起得来,配置正确加载,无 worker 时也不崩。
|
||||
|
||||
---
|
||||
|
||||
## 4. 阶段二:健康检查 + 空闲派发(并发=worker 数)
|
||||
|
||||
**开发步骤**
|
||||
1. `pool.py`:
|
||||
- 后台 asyncio 任务,每 `interval_seconds` 给每个 worker 发 `GET {worker}/health`(带 `X-Internal-Token`,`timeout_seconds` 超时)。
|
||||
- 连续失败 `unhealthy_threshold` 次 → 下线;重新连续成功 `healthy_threshold` 次 → 上线。维护在线集合。
|
||||
- 每个 worker 一个 `asyncio.Lock`(或容量=1 的信号量)表示忙闲(`per_worker_concurrency=1`)。
|
||||
2. 派发 `acquire_worker()`:从在线池里挑一个**空闲** worker 占用;全忙则 `await` 等待,最多 `queue_wait_seconds`,超时抛 `NoWorkerAvailable`;在线池为空也抛该异常。用完 `release`。
|
||||
3. 路由层捕获 `NoWorkerAvailable` → 返回 `{code:1007, message:"后端服务暂不可用,请稍后重试", ...}`。
|
||||
|
||||
**验证**
|
||||
- 配 2 个**假 worker**(本地起两个返回 `/health` 200 + 简单 echo 的 stub):并发打 3 个请求 → 前 2 个并行、第 3 个排队后成功。
|
||||
- 停掉 1 个 stub → `interval×unhealthy_threshold` 内自动下线,请求只走存活的。
|
||||
- 全停 → 请求返回 `code==1007`。
|
||||
```bash
|
||||
# 可用 python 起两个 stub:每个监听不同端口,/health 返回200,业务接口 sleep 1s 再回
|
||||
```
|
||||
|
||||
**完成标准**:健康检查上下线正确;并发=在线 worker 数;全忙排队、池空→1007。
|
||||
|
||||
---
|
||||
|
||||
## 5. 阶段三:鉴权头 + 转发 + 标注图改写
|
||||
|
||||
**开发步骤**
|
||||
1. `forward.py`:用 `httpx.AsyncClient` 把客户端请求**原样转发**到选中的 worker 对应路径:
|
||||
- 透传 method、multipart/form body、查询参数;附加 `X-Internal-Token: <shared_password>` 头。
|
||||
- `request_timeout_seconds` 超时;连接失败/超时/5xx/401 视为该 worker 异常 → 标记不健康,按 `max_retries` 换 worker 重试;耗尽 → 1007。
|
||||
- worker 返回的 1001–1008 业务响应**原样透传**给客户端(不要改 code)。
|
||||
2. **base64 → URL 改写**:拿到 worker 的 JSON 响应后,对约定的图片字段:
|
||||
- worker 用 `*_base64` 承载 PNG(如接口1 `annotated_image_base64`)。
|
||||
- 网关:解码 → 存 `static/annotations/{uuid}.png` → **删除 `*_base64`,新增 `*_url`** = `{public_base_url}/static/annotations/{uuid}.png`。
|
||||
- 维护一张「接口路径 → 图片字段名」映射表(接口1:`annotated_image`;接口3/5 待其实现后补;**以接口文档字段名为准**)。
|
||||
3. 5 个接口路由统一走「选 worker → 转发 → 改写 → 返回」一条链路。
|
||||
|
||||
**验证(端到端,最终冒烟)**
|
||||
```bash
|
||||
# 经网关(对外 HTTPS;客户端不需要 token——token 是网关→worker 内部的)
|
||||
curl -s -X POST https://hair.xiangsilian.com/api/v1/face/measure \
|
||||
-F image_file=@<一张人像> | python -m json.tool
|
||||
# 期望:与接口文档完全一致——data 含 annotated_image_url(不是 base64)
|
||||
curl -sI https://hair.xiangsilian.com/static/annotations/<uuid>.png # 200
|
||||
```
|
||||
- 对外响应字段/URL 与 `docs/接口文档.md` **逐一一致**。
|
||||
- 响应里**不应**出现 `*_base64`(已被网关消化)。
|
||||
- 直接打 worker 不带 token → 401(worker 侧行为);经网关正常。
|
||||
|
||||
**完成标准**:5 接口代理通;base64 落盘改 URL 正确;对外契约零变化;失败重试与 1007 生效。
|
||||
|
||||
---
|
||||
|
||||
## 6. 阶段四:静态托管 + 清理
|
||||
|
||||
**开发步骤**
|
||||
1. 网关 `app.mount("/static", StaticFiles(directory="static"), ...)` 托管落盘的标注图。
|
||||
2. 加**定期清理**:`static/annotations/` 会持续增长,按时间(如保留 N 天)或容量清理。可用进程内定时任务或外部 cron(任选,记录方案)。
|
||||
|
||||
**验证**
|
||||
- 生成的图能经 `https://hair.xiangsilian.com/static/annotations/<uuid>.png` 访问(200)。
|
||||
- 清理任务按规则删除过期文件,不误删近期文件。
|
||||
|
||||
**完成标准**:静态图可公网访问;清理任务可控。
|
||||
|
||||
---
|
||||
|
||||
## 7. 阶段五:部署
|
||||
|
||||
**开发步骤**
|
||||
1. nginx:443 → 网关进程(uvicorn)。沿用现有 `nginx/hair.conf` 风格。
|
||||
2. systemd 管理网关进程(可继续用 `hair.service`,或新建 `hair-gateway.service`)。
|
||||
3. `config.json` 就位(worker 列表 + 密码);`static/annotations/` 可写。
|
||||
|
||||
**验证**
|
||||
```bash
|
||||
sudo systemctl restart hair-gateway && sudo systemctl status hair-gateway
|
||||
# 端到端冒烟(同阶段三);journalctl 无 ERROR
|
||||
```
|
||||
|
||||
**完成标准**:线上经 HTTPS 走通全链路,标注图可访问,日志无异常。
|
||||
|
||||
---
|
||||
|
||||
## 8. 交付清单(网关侧 DoD)
|
||||
|
||||
- [ ] `gateway/`:`app.py`、`config.py`、`pool.py`、`forward.py`、`config.example.json`
|
||||
- [ ] 代理 5 个接口,对外契约/字段/URL 与接口文档**完全一致**
|
||||
- [ ] worker 池健康检查(上下线)+ 空闲派发(并发=在线 worker 数)+ 全忙排队/无后端→1007
|
||||
- [ ] 共享密码鉴权(`X-Internal-Token`,配置文件,可轮换)
|
||||
- [ ] base64 → 落盘 `static/annotations/` → 改写 `*_url`,响应无 `*_base64` 残留
|
||||
- [ ] 静态托管 + 定期清理
|
||||
- [ ] 端到端 HTTPS 冒烟通过,`annotated_image_url` 公网可访问
|
||||
- [ ] `config.json` 入 `.gitignore`,仓库只留 `config.example.json`
|
||||
|
||||
---
|
||||
|
||||
## 9. 风险与注意
|
||||
|
||||
1. **联调依赖 worker**:阶段四之前可用**本地 stub worker**(返回 `/health` 200 + 假的 base64 图)独立开发;worker 真机就绪后再换真实地址端到端联调。
|
||||
2. **接口文档是字段唯一权威**:图片字段映射表、对外字段名都以 `docs/接口文档.md` 为准,冲突时以文档为准并在 PR 说明指出。
|
||||
3. **安全(先跑通后处理,已知项)**:`:28187` 当前 HTTP 明文 + 公网可达,密码明文传输;后续建议加 TLS / 内网 / IP 白名单(见架构文档 §13/§14)。本阶段不阻塞。
|
||||
4. **图片字段未实现的接口**:接口 2/4/5 worker 暂为 mock,网关先按"无图片字段或透传"处理,待 worker 实现对应图片后再补映射。
|
||||
|
||||
---
|
||||
|
||||
> **文档版本**: v1.0 | **创建日期**: 2026-06-14 | 配套:系统架构 v1.0
|
||||
> **关键约束**: 接口文档不变;不跑算法;每 worker 并发=1;无后端→1007;base64→落盘→URL
|
||||
@@ -0,0 +1,146 @@
|
||||
# 网关侧改动清单(worker 近期变更引发)
|
||||
|
||||
> 给网关开发:以下是 worker/契约近期变化里**与网关有关**的点。标 ✅ 的我已在本仓库 `gateway/`
|
||||
> 改好(你 review/拉取即可);标 🔲 的是**建议你确认或改**。功能必需只有第 1 条。
|
||||
|
||||
---
|
||||
|
||||
## 1. ✅【功能必需】base64→URL 落盘扩展名按内容嗅探(支持 JPG)
|
||||
|
||||
**背景**:接口 **2/3/5 的返回图改成了 JPG**(体积约小 9×),**接口 1 标注图仍是 PNG**(含透明)。
|
||||
网关把 `*_base64` 落盘时若**硬编码 `.png`**,JPG 会被存成 `.png`(内容是 JPG、扩展名错)。
|
||||
|
||||
**改动**(`gateway/forward.py` 的 `rewrite_base64_to_url`,已改):
|
||||
```python
|
||||
# 原:filename = f"{uuid.uuid4().hex}.png"
|
||||
ext = "png" if img_bytes[:8] == b"\x89PNG\r\n\x1a\n" else "jpg" # 按内容嗅探
|
||||
filename = f"{uuid.uuid4().hex}.{ext}"
|
||||
```
|
||||
> 这样接口1 存 `.png`、接口2/3/5 存 `.jpg`,对外 URL 后缀也就正确。**若你的网关是独立部署/独立代码,按上面这两行改一下即可。**
|
||||
|
||||
---
|
||||
|
||||
## 2. 🔲【建议】生发接口超时调大
|
||||
|
||||
接口 **2(一次 N 张 Flux,~18s)/ 3(~6s)** 经 ComfyUI 同步出图较慢。
|
||||
`gateway/config.json` 的 `dispatch.request_timeout_seconds` 建议 **≥ 120**,否则网关会先超时换 worker 重试。
|
||||
|
||||
---
|
||||
|
||||
## 3. 🔲【确认】base64→URL 通用改写仍覆盖这些场景
|
||||
|
||||
- **数组里的图片字段**:接口2 `results[].image_base64` / `results[].grown_image_base64`、
|
||||
接口5 `hairline_images[].image_base64` 在数组元素内——改写要**递归进数组**(你现有的递归实现已覆盖)。
|
||||
- **可空字段**:接口2/3 的生发图(ComfyUI 没起/失败时)`*_base64` 为 **null** → 保留 null、不落盘。
|
||||
|
||||
---
|
||||
|
||||
## 4. 🔲【可选·仅影响 /docs】OpenAPI 表单声明
|
||||
|
||||
纯文档展示,不影响转发功能(网关是盲转发)。若想让网关 `/docs` 准确:
|
||||
- 接口2 `/hair/grow`、接口5 `/hairline/generate` 入参**新增必填 `gender`**(male/female)。
|
||||
- 接口3 `/hair/grow-b` 入参**只剩 `marked_image_*`**(已去掉 `original_image_*`)。
|
||||
- (`gateway/app.py` 里的 `_*_FORMS` 字典当前未被路由引用,所以不改也不影响实际行为。)
|
||||
|
||||
---
|
||||
|
||||
## 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 | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`)。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 的写法)。不改也不影响实际转发。
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 6. ✅【已完成】接口6 四庭七眼测量 v2(`/api/v1/face/measure-v2`)
|
||||
|
||||
**背景**:worker 侧接口 6 是接口 1 的**去顶庭变体**(不再完全一致):不画头顶横线/不返回顶庭数据、竖线范围发际线→下巴尖、不画人头最左/最右端线。worker 侧与接口 1 共用 `_face_measure_impl(variant="v6")`。
|
||||
|
||||
**网关已新增路由**(`gateway/app.py` 已实现,网关机器 pull 后生效):
|
||||
|
||||
```python
|
||||
# gateway/app.py
|
||||
|
||||
@app.post("/api/v1/face/measure-v2", tags=["人脸分析"])
|
||||
async def face_measure_v2(request: Request):
|
||||
"""接口6:四庭七眼测量 v2(去顶庭 + 去头部端线)"""
|
||||
return await _proxy(request, "/api/v1/face/measure-v2")
|
||||
```
|
||||
|
||||
**无需额外改动**:
|
||||
- 入参:与接口 1 完全相同(image_file/url/base64 三选一)
|
||||
- 出参:`annotated_image_base64` → 经现有 `rewrite_base64_to_url` 自动改写为 `annotated_image_url`
|
||||
- worker 侧 v6 差异(去顶庭字段、变体标注)由 `_face_measure_impl` 内部处理,网关透明转发
|
||||
|
||||
---
|
||||
|
||||
## 已经做好、无需再动的
|
||||
|
||||
- **接口4 在网关本机实现**(调豆包,不转发 worker)——已完成;config 里配 `ark`。
|
||||
- **接口4 业务错误 HTTP 状态**已统一为 200(与其余接口一致)。
|
||||
- **接口3 去 original / best_hairline**——网关盲转发,无需改(映射表里也没有 best_hairline)。
|
||||
|
||||
---
|
||||
|
||||
> 对外字段映射总表见 [`实现说明.md`](实现说明.md) §1;契约以 [`接口文档.md`](接口文档.md) 为准。
|
||||
@@ -1,9 +1,14 @@
|
||||
"""标注图层生成(透明底 RGBA PNG,仅标注、不含人物)。
|
||||
|
||||
规格(技术方案 §6):线/字色 #FFFFFF、字体 10pt、线宽 1pt、透明底。
|
||||
规格(技术方案 §6 + img1.png 版本5):线/字色 #FFFFFF、透明底。
|
||||
- 字号/线宽/虚线/箭头尺寸全部按图片尺寸自适应缩放(大图也清晰)。
|
||||
- 四庭水平分界线:numpy 向量化渐变消失(中间亮、两侧渐隐)。
|
||||
- 四庭 cm 数值:图片左侧。
|
||||
- 七眼标注:眼宽/两眼间距/脸宽,虚线带箭头,标签上下穿插。
|
||||
- 纵向竖线 8 条:人头最左 + 左脸颊/左眼外/内角/右眼内/外角/右脸颊 + 人头最右,
|
||||
把头宽切 7 段(七眼),段宽数值上下交替(上 3 / 下 4),带虚线双箭头。
|
||||
人头最左/最右取自耳朵分割外缘,看不到耳朵则省略该侧(最少 6 点 5 段)。
|
||||
- 四庭:图片左侧,「名」「数值(带 cm)」「百分比」三行换行,带竖向虚线双箭头。
|
||||
- 五条横线右侧标名:头顶/发际线/眉心/鼻翼下缘/下巴尖。
|
||||
- 每段数值直接带 cm 后缀,下方另起一行标百分比(不再单独标底部「单位cm」)。
|
||||
中文字体用打包的思源黑体绝对路径加载,缺字体直接抛错(不静默降级成方块)。
|
||||
"""
|
||||
import os
|
||||
@@ -12,115 +17,339 @@ import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
FONT_PATH = os.path.join(os.path.dirname(__file__), "fonts", "NotoSansCJKsc-Regular.otf")
|
||||
FONT_SIZE = 10
|
||||
LINE_COLOR = (255, 255, 255, 255) # #FFFFFF 100%
|
||||
LINE_WIDTH = 1
|
||||
|
||||
|
||||
def _load_font():
|
||||
def _load_font(size):
|
||||
if not os.path.isfile(FONT_PATH):
|
||||
raise FileNotFoundError(f"中文字体缺失:{FONT_PATH}(请按 OFFLINE_ASSETS.md 放置)")
|
||||
return ImageFont.truetype(FONT_PATH, FONT_SIZE)
|
||||
return ImageFont.truetype(FONT_PATH, size)
|
||||
|
||||
|
||||
def draw_gradient_horizontal_line(buf, cx, cy, color=LINE_COLOR, half_length=None):
|
||||
"""在 RGBA numpy 缓冲 buf 上,以 (cx,cy) 为中心画向两侧渐变消失的水平线。
|
||||
|
||||
numpy 向量化:一次性算整行 alpha,避免逐像素 draw.point。
|
||||
"""
|
||||
def draw_gradient_horizontal_line(buf, cx, cy, color=LINE_COLOR, half_length=None, width=1):
|
||||
"""在 RGBA numpy 缓冲 buf 上,以 (cx,cy) 为中心画向两侧渐变消失的水平线。"""
|
||||
h, w = buf.shape[:2]
|
||||
cy = int(round(cy)); cx = int(round(cx))
|
||||
if not (0 <= cy < h):
|
||||
return
|
||||
half = half_length or (w // 3)
|
||||
xs = np.arange(w)
|
||||
dist = np.abs(xs - cx)
|
||||
alpha = np.clip(1.0 - dist / half, 0.0, 1.0) * color[3]
|
||||
mask = alpha > 0
|
||||
row = buf[cy]
|
||||
row[mask, 0] = color[0]
|
||||
row[mask, 1] = color[1]
|
||||
row[mask, 2] = color[2]
|
||||
row[mask, 3] = np.maximum(row[mask, 3], alpha[mask].astype(np.uint8))
|
||||
for off in range(-(width // 2), width - width // 2):
|
||||
y = cy + off
|
||||
if not (0 <= y < h):
|
||||
continue
|
||||
row = buf[y]
|
||||
row[mask, 0] = color[0]
|
||||
row[mask, 1] = color[1]
|
||||
row[mask, 2] = color[2]
|
||||
row[mask, 3] = np.maximum(row[mask, 3], alpha[mask].astype(np.uint8))
|
||||
|
||||
|
||||
def draw_gradient_vertical_line(buf, cx, y0, y1, color=LINE_COLOR, fade=None, width=1):
|
||||
"""在 RGBA numpy 缓冲 buf 上画一条竖线,两端渐变消失(中间实、上下淡)。"""
|
||||
h, w = buf.shape[:2]
|
||||
cx = int(round(cx))
|
||||
y0, y1 = int(round(y0)), int(round(y1))
|
||||
y0, y1 = max(0, min(y0, y1)), min(h - 1, max(y0, y1))
|
||||
if y1 <= y0:
|
||||
return
|
||||
ys = np.arange(y0, y1 + 1)
|
||||
span = y1 - y0
|
||||
fade = fade or max(1, span // 5) # 仅两端 ~1/5 段渐隐
|
||||
d = np.minimum(ys - y0, y1 - ys) # 到最近端点的距离
|
||||
alpha = np.clip(d / fade, 0.0, 1.0) * color[3]
|
||||
m = alpha > 0
|
||||
for off in range(-(width // 2), width - width // 2):
|
||||
x = cx + off
|
||||
if not (0 <= x < w):
|
||||
continue
|
||||
col = buf[y0:y1 + 1, x]
|
||||
col[m, 0] = color[0]
|
||||
col[m, 1] = color[1]
|
||||
col[m, 2] = color[2]
|
||||
col[m, 3] = np.maximum(col[m, 3], alpha[m].astype(np.uint8))
|
||||
|
||||
|
||||
def draw_dashed_line_with_arrows(draw, x1, y1, x2, y2, color=LINE_COLOR,
|
||||
dash_len=6, gap_len=4, arrow_size=5):
|
||||
"""两点间画虚线,两端带箭头(等腰三角)。"""
|
||||
dash_len=6, gap_len=4, arrow_size=5, width=1):
|
||||
"""两点间画稀疏虚线主干,两端用实心三角箭头(尖端精确落在端点,便于对齐)。
|
||||
|
||||
虚线只画到「端点向内 arrow_len」处,箭头三角填补剩余,避免虚线穿出箭头。
|
||||
"""
|
||||
total = ((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5
|
||||
if total == 0:
|
||||
return
|
||||
dx = (x2 - x1) / total
|
||||
dy = (y2 - y1) / total
|
||||
pos = 0.0
|
||||
while pos < total:
|
||||
seg_end = min(pos + dash_len, total)
|
||||
nx, ny = -dy, dx # 法向量
|
||||
arrow_len = arrow_size * 2.0 # 三角沿线方向长度
|
||||
arrow_half = arrow_size # 三角底边半宽
|
||||
|
||||
# 主干虚线:两端各留出 arrow_len 给箭头
|
||||
pos = arrow_len
|
||||
main_end = max(arrow_len, total - arrow_len)
|
||||
while pos < main_end:
|
||||
seg_end = min(pos + dash_len, main_end)
|
||||
draw.line([(x1 + dx * pos, y1 + dy * pos),
|
||||
(x1 + dx * seg_end, y1 + dy * seg_end)], fill=color, width=LINE_WIDTH)
|
||||
(x1 + dx * seg_end, y1 + dy * seg_end)], fill=color, width=width)
|
||||
pos += dash_len + gap_len
|
||||
# 法向量(用于箭头两翼张开)
|
||||
nx, ny = -dy, dx
|
||||
for (ex, ey, sdx, sdy) in [(x1, y1, dx, dy), (x2, y2, -dx, -dy)]:
|
||||
p1 = (ex + sdx * arrow_size + nx * arrow_size * 0.6,
|
||||
ey + sdy * arrow_size + ny * arrow_size * 0.6)
|
||||
p2 = (ex + sdx * arrow_size - nx * arrow_size * 0.6,
|
||||
ey + sdy * arrow_size - ny * arrow_size * 0.6)
|
||||
draw.line([p1, (ex, ey)], fill=color, width=LINE_WIDTH)
|
||||
draw.line([p2, (ex, ey)], fill=color, width=LINE_WIDTH)
|
||||
|
||||
# 两端实心三角箭头:尖端=端点,底边在向内 arrow_len 处展开 ±arrow_half
|
||||
for (tipx, tipy, ix, iy) in [(x1, y1, dx, dy), (x2, y2, -dx, -dy)]:
|
||||
bx, by = tipx + ix * arrow_len, tipy + iy * arrow_len
|
||||
draw.polygon([(tipx, tipy),
|
||||
(bx + nx * arrow_half, by + ny * arrow_half),
|
||||
(bx - nx * arrow_half, by - ny * arrow_half)], fill=color)
|
||||
|
||||
|
||||
def create_annotated_image(image_bgr, measure_result):
|
||||
"""生成标注图层 PNG(透明底 RGBA,尺寸同原图)。返回 PIL.Image。"""
|
||||
def _text_size(draw, text, font):
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
return bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
|
||||
|
||||
_LINE_NAMES = {
|
||||
"hair_top": "头顶",
|
||||
"hairline": "发际线",
|
||||
"brow_center": "眉心",
|
||||
"nose_bottom": "鼻翼下缘",
|
||||
"chin_tip": "下巴尖",
|
||||
}
|
||||
|
||||
|
||||
def _grow_outward(start_col, fg_band, direction, limit):
|
||||
"""从 start_col 沿 direction(+1 右 / -1 左) 在前景带 fg_band 内逐列外扩。
|
||||
|
||||
用于回收被误标成「头发」的外耳轮廓:耳朵被头发遮挡时,外耳轮廓那一圈常被
|
||||
分割并入头发类,故耳朵掩膜外缘会偏内。这里把外缘沿紧邻的前景(耳∪发)向外
|
||||
延伸,最多 limit 列;一旦下一列无前景(背景间隙)立即停止,绝不窜到分离的
|
||||
那缕头发上。返回外扩后的列号。
|
||||
"""
|
||||
w = fg_band.shape[1]
|
||||
c = int(start_col)
|
||||
for _ in range(int(limit)):
|
||||
nc = c + direction
|
||||
if not (0 <= nc < w) or not fg_band[:, nc].any():
|
||||
break
|
||||
c = nc
|
||||
return c
|
||||
|
||||
|
||||
def _ear_edges_from_mask(ear_mask, hair_mask, y0, y1, left_cheek_x, right_cheek_x,
|
||||
face_center_x):
|
||||
"""从耳朵分割掩膜取人头最左/最右 x(仅在脸纵向范围 [y0,y1] 内统计)。
|
||||
|
||||
左线 = 脸中线左侧耳朵像素的最左列;右线 = 右侧耳朵像素的最右列;再沿紧邻的
|
||||
前景(耳∪发)按脸宽自适应外扩,回收被误标成头发的外耳轮廓(见 _grow_outward)。
|
||||
某侧耳朵不可见(被头发/侧脸遮挡 → 掩膜为空),或外缘未越过对应脸颊线(非真实
|
||||
头宽边缘)时该侧返回 None —— 即「看不到耳朵就不画这条线」。
|
||||
"""
|
||||
if ear_mask is None:
|
||||
return None, None
|
||||
m = np.asarray(ear_mask)
|
||||
if m.ndim == 3:
|
||||
m = m[..., 0]
|
||||
m = m > 0
|
||||
h = m.shape[0]
|
||||
y0 = max(0, int(y0)); y1 = min(h - 1, int(y1))
|
||||
if y1 <= y0:
|
||||
return None, None
|
||||
ear_band = m[y0:y1 + 1]
|
||||
cols = np.where(ear_band.any(axis=0))[0]
|
||||
if cols.size == 0:
|
||||
return None, None
|
||||
# 前景带 = 耳∪发(外耳轮廓常被误标为发),外扩上限按脸宽自适应
|
||||
fg_band = ear_band
|
||||
if hair_mask is not None:
|
||||
hm = np.asarray(hair_mask)
|
||||
if hm.ndim == 3:
|
||||
hm = hm[..., 0]
|
||||
fg_band = ear_band | (hm[y0:y1 + 1] > 0)
|
||||
grow = max(2, round(max(1.0, right_cheek_x - left_cheek_x) * 0.045))
|
||||
|
||||
left_cols = cols[cols < face_center_x]
|
||||
right_cols = cols[cols > face_center_x]
|
||||
# 左/右耳外缘(外扩后),且必须在对应脸颊线外侧(否则视为残缺/噪声,只保留脸颊线)
|
||||
head_l = head_r = None
|
||||
if right_cols.size:
|
||||
edge = _grow_outward(right_cols.max(), fg_band, +1, grow)
|
||||
head_r = float(edge) if edge >= right_cheek_x else None
|
||||
if left_cols.size:
|
||||
edge = _grow_outward(left_cols.min(), fg_band, -1, grow)
|
||||
head_l = float(edge) if edge <= left_cheek_x else None
|
||||
return head_l, head_r
|
||||
|
||||
|
||||
def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=None,
|
||||
variant="v1"):
|
||||
"""生成标注图层 PNG(透明底 RGBA,尺寸同原图)。返回 PIL.Image。
|
||||
|
||||
布局(对齐 img1.png 版本5):
|
||||
- 纵向竖线:人头最左 + 七眼 6 点 + 人头最右,切 7 段(七眼)。人头最左/最右
|
||||
取自耳朵分割掩膜的外缘(方案 B,BiSeNet 类 7/8);耳朵不可见(被头发/侧脸
|
||||
遮挡 → 掩膜空)或无掩膜时省略该侧端线,只画对应脸颊线。
|
||||
- 横向 5 条分界线:头顶/发际线/眉心/鼻翼下缘/下巴尖,右侧标名。
|
||||
- 四庭(顶/上/中/下庭)在左侧:名 + 数值(带 cm) + 百分比三行换行,竖向虚线双箭头。
|
||||
- 七眼段宽上下交替(上 3 / 下 4):数值(带 cm) 上、百分比(占头宽比)下,横向虚线双箭头。
|
||||
|
||||
variant="v6"(接口6):去掉头顶横线与顶庭(只画发际线/眉心/鼻翼下缘/下巴尖 4 条
|
||||
横线 + 上/中/下庭),竖线纵向范围改为发际线→下巴尖,且不画人头最左/最右端线
|
||||
(仅七眼 6 点 5 段,不取头部端)。
|
||||
"""
|
||||
h, w = image_bgr.shape[:2]
|
||||
v = measure_result.vertical
|
||||
pc = measure_result.px_per_cm
|
||||
|
||||
# --- 自适应尺寸:字号/线宽/虚线/箭头按短边缩放 ---
|
||||
s = min(w, h)
|
||||
font_size = max(9, round(s * 0.020)) # 字号上调一档
|
||||
line_w = max(1, round(s * 0.0022))
|
||||
dash_len = max(4, round(s * 0.008))
|
||||
gap_len = max(2, round(dash_len * 0.7)) # 虚线更稠密(间隙<划线)
|
||||
arrow_size = max(2, round(s * 0.0045)) # 箭头更小
|
||||
pad = max(4, round(s * 0.012)) # 文字与线的间距
|
||||
line_h = font_size + max(2, round(font_size * 0.18))
|
||||
|
||||
# --- 1. 四庭水平分界线(numpy 渐变) ---
|
||||
buf = np.zeros((h, w, 4), dtype=np.uint8)
|
||||
order = ["hair_top", "hairline", "brow_center", "nose_bottom", "chin_tip"]
|
||||
|
||||
# 发际线弃用(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"]
|
||||
ys = [v[name][1] for name in order]
|
||||
cx_line = v["brow_center"][0]
|
||||
|
||||
pts = measure_result.eyes["points"]
|
||||
seven_keys = ["left_cheek", "left_outer", "left_inner",
|
||||
"right_inner", "right_outer", "right_cheek"]
|
||||
base_xs = [pts[k][0] for k in seven_keys]
|
||||
if variant == "v6":
|
||||
xs = sorted(base_xs) # 接口6:仅七眼 6 点,不画人头最左/最右端线
|
||||
else:
|
||||
# 人头最左/最右:取自耳朵分割掩膜外缘(方案B,类7/8),脸纵向范围内统计。
|
||||
# 看不到耳朵(被头发/侧脸遮挡 → 掩膜空)或外缘未越过脸颊线则省略该侧端线。
|
||||
lcx, rcx = pts["left_cheek"][0], pts["right_cheek"][0]
|
||||
head_l, head_r = _ear_edges_from_mask(
|
||||
ear_mask, hair_mask, ys[0], ys[-1], lcx, rcx, (lcx + rcx) / 2)
|
||||
head_xs = [x for x in (head_l, head_r) if x is not None]
|
||||
xs = sorted(base_xs + head_xs) # 自左向右(6 或 7/8 点)
|
||||
|
||||
# 人脸/人头包围盒
|
||||
fx0, fx1 = xs[0], xs[-1]
|
||||
fy0, fy1 = ys[0], ys[-1]
|
||||
face_cx = (fx0 + fx1) / 2
|
||||
over = max(6, round(s * 0.030)) # 线超出包围盒的长度(参考图风格)
|
||||
face_half = (fx1 - fx0) / 2 + over # 横线超出最外侧竖线一点
|
||||
# 竖线纵向范围:v6 = 发际线→下巴尖(不超出);v1(含发际线弃用)= 头顶→下巴尖并两端超出一点
|
||||
v_top = fy0 if variant == "v6" else fy0 - over
|
||||
v_bot = fy1 if variant == "v6" else fy1 + over
|
||||
|
||||
# --- 1. 横向分界线(渐变,覆盖头宽并超出一点) ---
|
||||
for cy in ys:
|
||||
draw_gradient_horizontal_line(buf, cx_line, cy)
|
||||
draw_gradient_horizontal_line(buf, face_cx, cy, half_length=face_half, width=line_w)
|
||||
|
||||
# --- 2. 纵向竖线(渐变,覆盖 v_top→v_bot) ---
|
||||
for vx in xs:
|
||||
draw_gradient_vertical_line(buf, vx, v_top, v_bot, width=line_w)
|
||||
|
||||
canvas = Image.fromarray(buf, mode="RGBA")
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
font = _load_font()
|
||||
font = _load_font(font_size)
|
||||
|
||||
# --- 2. 四庭 cm 数值(左侧) ---
|
||||
court_labels = [
|
||||
("顶庭", measure_result.top_cm),
|
||||
("上庭", measure_result.upper_cm),
|
||||
("中庭", measure_result.middle_cm),
|
||||
("下庭", measure_result.lower_cm),
|
||||
]
|
||||
left_margin = 16
|
||||
for i, (label, cm_val) in enumerate(court_labels):
|
||||
y_mid = (ys[i] + ys[i + 1]) / 2 - FONT_SIZE / 2
|
||||
draw.text((left_margin, y_mid), f"{label} {cm_val:.2f}cm",
|
||||
fill=LINE_COLOR, font=font)
|
||||
# --- 2b. 每条横线在「中间线段」(两内眼角之间)中点画原点突出 ---
|
||||
# 注意:原点不在整条线的中点 face_cx,而在被竖线切出的中间段(左内眼角↔右内眼角)
|
||||
# 的正中,即脸的竖直中轴附近、两内眼角连线中点。
|
||||
li_x = pts["left_inner"][0]
|
||||
ri_x = pts["right_inner"][0]
|
||||
dot_cx = (li_x + ri_x) / 2
|
||||
dot_r = max(2, round(s * 0.0045)) # 原点半径,与 arrow_size 同档自适应
|
||||
for cy in ys:
|
||||
x0, y0 = dot_cx - dot_r, cy - dot_r
|
||||
x1, y1 = dot_cx + dot_r, cy + dot_r
|
||||
draw.ellipse((x0, y0, x1, y1), fill=LINE_COLOR)
|
||||
|
||||
# --- 3. 七眼标注(虚线箭头 + 上下穿插标签) ---
|
||||
pts = measure_result.eyes["points"]
|
||||
pc = measure_result.px_per_cm
|
||||
eye_y = (pts["left_inner"][1] + pts["right_inner"][1]) / 2
|
||||
# --- 3a. 横线右侧:线名(头顶/发际线/眉心/鼻翼下缘/下巴尖),文字纵向居中对齐到线 ---
|
||||
name_x = fx1 + over + pad # 移到横线右端外侧一点(往右)
|
||||
for i, name in enumerate(order):
|
||||
text = _LINE_NAMES[name]
|
||||
tw, _ = _text_size(draw, text, font)
|
||||
x = min(name_x, w - 2 - tw) # 右侧越界时回收
|
||||
# anchor="lm":x 为左、y 为竖直中点 → 文字中线正好压在横线上(与线对齐)
|
||||
draw.text((x, ys[i]), text, fill=LINE_COLOR, font=font, anchor="lm")
|
||||
|
||||
def hline(p_left, p_right, label, cm_val, above):
|
||||
y = eye_y
|
||||
draw_dashed_line_with_arrows(draw, p_left[0], y, p_right[0], y)
|
||||
text = f"{label} {cm_val:.2f}cm"
|
||||
tx = (p_left[0] + p_right[0]) / 2
|
||||
ty = y - FONT_SIZE - 4 if above else y + 4
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
tw = bbox[2] - bbox[0]
|
||||
draw.text((tx - tw / 2, ty), text, fill=LINE_COLOR, font=font)
|
||||
# --- 3b. 左侧四庭:名 + 数值两行(无 cm)+ 竖向虚线双箭头 ---
|
||||
# court_start:庭段在 order 里的起始索引。发际线弃用时 order 首位是头顶(无下界发际线,
|
||||
# 顶/上庭不标),中庭从眉心开始 → 跳过 order[0]。
|
||||
if getattr(measure_result, "hairline_discarded", False):
|
||||
court_cm = [measure_result.middle_cm, measure_result.lower_cm]
|
||||
court_name = ["中庭", "下庭"]
|
||||
n_court = 2
|
||||
court_start = 1
|
||||
elif variant == "v6":
|
||||
court_cm = [measure_result.upper_cm, measure_result.middle_cm, measure_result.lower_cm]
|
||||
court_name = ["上庭", "中庭", "下庭"]
|
||||
n_court = 3
|
||||
court_start = 0
|
||||
else:
|
||||
court_cm = [measure_result.top_cm, measure_result.upper_cm,
|
||||
measure_result.middle_cm, measure_result.lower_cm]
|
||||
court_name = ["顶庭", "上庭", "中庭", "下庭"]
|
||||
n_court = 4
|
||||
court_start = 0
|
||||
arrow_x = max(arrow_size + 1, fx0 - pad) # 竖箭头所在 x(脸左侧,贴近最左竖线)
|
||||
court_total = sum(court_cm) or 1.0 # 各庭占比分母 = 四庭(v6 三庭)之和
|
||||
for i in range(n_court):
|
||||
y_a, y_b = ys[court_start + i], ys[court_start + i + 1]
|
||||
# 竖向虚线双箭头,覆盖该庭高度(略收一点避免压到横线)
|
||||
inset = min(arrow_size, (y_b - y_a) * 0.12)
|
||||
draw_dashed_line_with_arrows(
|
||||
draw, arrow_x, y_a + inset, arrow_x, y_b - inset,
|
||||
dash_len=dash_len, gap_len=gap_len, arrow_size=arrow_size, width=line_w)
|
||||
# 名 + 数值(带 cm) + 百分比三行,右对齐到箭头左侧
|
||||
name = court_name[i]
|
||||
val = f"{court_cm[i]:.2f}cm"
|
||||
pct = f"{court_cm[i] / court_total * 100:.1f}%"
|
||||
nw, _ = _text_size(draw, name, font)
|
||||
vw, _ = _text_size(draw, val, font)
|
||||
pw, _ = _text_size(draw, pct, font)
|
||||
label_right = arrow_x - pad
|
||||
y_mid = (y_a + y_b) / 2
|
||||
y_top = y_mid - 1.5 * line_h
|
||||
draw.text((max(2, label_right - nw), y_top), name, fill=LINE_COLOR, font=font)
|
||||
draw.text((max(2, label_right - vw), y_top + line_h), val, fill=LINE_COLOR, font=font)
|
||||
draw.text((max(2, label_right - pw), y_top + 2 * line_h), pct, fill=LINE_COLOR, font=font)
|
||||
|
||||
# 眼宽(左眼,标签在上)、两眼间距(标签在下)、脸宽(标签在上)—— 上下穿插
|
||||
hline(pts["left_outer"], pts["left_inner"],
|
||||
"眼宽", measure_result.eye_width_cm, above=True)
|
||||
hline(pts["left_inner"], pts["right_inner"],
|
||||
"间距", measure_result.inter_eye_cm, above=False)
|
||||
hline(pts["left_cheek"], pts["right_cheek"],
|
||||
"脸宽", measure_result.face_width_cm, above=True)
|
||||
# --- 4. 七眼每段宽度:上下交替(上 3 / 下 4),横向虚线双箭头 + 数值(带 cm) + 百分比 ---
|
||||
# 每段两行:数值(带 cm) 上、百分比 下;百分比分母 = 整个头宽(七段之和)
|
||||
txt_off = arrow_size + pad * 2
|
||||
txt_block = 2 * line_h # 两行文字总高(数值 + 百分比)
|
||||
y_arrow_top = max(txt_off + txt_block + 2, fy0 - pad - arrow_size)
|
||||
y_arrow_bot = min(h - txt_off - txt_block - 2, fy1 + pad + arrow_size)
|
||||
head_w = (xs[-1] - xs[0]) or 1.0 # 头宽(像素)= 百分比分母
|
||||
for i in range(len(xs) - 1):
|
||||
x_a, x_b = xs[i], xs[i + 1]
|
||||
if x_b - x_a < 1:
|
||||
continue
|
||||
seg_cm = (x_b - x_a) / pc
|
||||
seg_pct = (x_b - x_a) / head_w * 100
|
||||
cx_seg = (x_a + x_b) / 2
|
||||
val = f"{seg_cm:.2f}cm"
|
||||
pct = f"{seg_pct:.1f}%"
|
||||
vw, _ = _text_size(draw, val, font)
|
||||
pw, _ = _text_size(draw, pct, font)
|
||||
inset = min(arrow_size, (x_b - x_a) * 0.12)
|
||||
on_top = (i % 2 == 1) # 奇数段在上 → 上 3 / 下 4
|
||||
y_arrow = y_arrow_top if on_top else y_arrow_bot
|
||||
draw_dashed_line_with_arrows(
|
||||
draw, x_a + inset, y_arrow, x_b - inset, y_arrow,
|
||||
dash_len=dash_len, gap_len=gap_len, arrow_size=arrow_size, width=line_w)
|
||||
# 数值行在上、百分比行在下;on_top 时整块置于箭头上方,否则下方
|
||||
text_top = (y_arrow - txt_off - txt_block) if on_top else (y_arrow + txt_off)
|
||||
draw.text((cx_seg - vw / 2, text_top), val, fill=LINE_COLOR, font=font)
|
||||
draw.text((cx_seg - pw / 2, text_top + line_h), pct, fill=LINE_COLOR, font=font)
|
||||
|
||||
return canvas
|
||||
|
||||
@@ -144,15 +373,16 @@ if __name__ == "__main__":
|
||||
print("未检出人脸")
|
||||
sys.exit(1)
|
||||
mask = None
|
||||
ears = None
|
||||
try:
|
||||
from face_analysis.hair_segmenter import get_segmenter
|
||||
mask = get_segmenter().segment_hair(img)
|
||||
mask, ears = get_segmenter().segment_hair_and_ears(img)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[warn] 分割不可用,回退方案 A:{e}")
|
||||
|
||||
result = measure_face(lms, mask, w, h)
|
||||
t0 = time.time()
|
||||
canvas = create_annotated_image(img, result)
|
||||
canvas = create_annotated_image(img, result, ear_mask=ears, hair_mask=mask)
|
||||
dt = time.time() - t0
|
||||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||
canvas.save(out)
|
||||
|
||||
@@ -79,7 +79,7 @@ class Resnet18(nn.Module):
|
||||
def init_weight(self):
|
||||
# 优先本地骨干权重(内网离线),缺失才回退 torch model_zoo(会查缓存)。
|
||||
if os.path.isfile(_LOCAL_RESNET18):
|
||||
state_dict = torch.load(_LOCAL_RESNET18, map_location="cpu")
|
||||
state_dict = torch.load(_LOCAL_RESNET18, map_location="cpu", weights_only=False)
|
||||
else:
|
||||
state_dict = modelzoo.load_url(resnet18_url)
|
||||
self_state_dict = self.state_dict()
|
||||
|
||||
@@ -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 的版本)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""方案 B:BiSeNet 头发分割 + 发际线/头顶定位。
|
||||
"""方案 B:BiSeNet 头发/耳朵分割 + 发际线/头顶定位。
|
||||
|
||||
加载 face-parsing BiSeNet(19 类,hair=17),对整图做像素级语义分割得到头发
|
||||
mask,再沿面部中轴线扫描得到真实发际线与头顶。GPU 可用时走 CUDA,否则 CPU。
|
||||
加载 face-parsing BiSeNet(CelebAMask-HQ 19 类,hair=17、l_ear=7、r_ear=8),对整图
|
||||
做像素级语义分割:得到头发 mask 用于沿面部中轴线扫描真实发际线与头顶,并得到耳朵
|
||||
mask 供标注图取人头最左/最右竖线(耳朵外缘)。GPU 可用时走 CUDA,否则 CPU。
|
||||
单例加载权重,避免每请求重载。详见技术方案 §1.4 / §4.0。
|
||||
"""
|
||||
import os
|
||||
@@ -15,6 +16,7 @@ import numpy as np
|
||||
|
||||
_WEIGHTS = os.path.join(os.path.dirname(__file__), "weights", "79999_iter.pth")
|
||||
HAIR_CLASS = 17 # CelebAMask-HQ 19 类中 hair 的索引
|
||||
EAR_CLASSES = (7, 8) # 7=l_ear / 8=r_ear(类名以人为参照,图像左右另行判定,不依赖类名)
|
||||
N_CLASSES = 19
|
||||
_INPUT_SIZE = 512 # BiSeNet 推理输入边长
|
||||
|
||||
@@ -50,7 +52,7 @@ class HairSegmenter:
|
||||
self._torch = torch
|
||||
self.device = _select_device(torch)
|
||||
self.net = BiSeNet(n_classes=N_CLASSES)
|
||||
state = torch.load(weights_path, map_location="cpu")
|
||||
state = torch.load(weights_path, map_location="cpu", weights_only=False)
|
||||
self.net.load_state_dict(state)
|
||||
self.net.to(self.device)
|
||||
self.net.eval()
|
||||
@@ -59,8 +61,8 @@ class HairSegmenter:
|
||||
transforms.Normalize((0.485, 0.456, 0.406), (0.229, 0.224, 0.225)),
|
||||
])
|
||||
|
||||
def segment_hair(self, image_bgr):
|
||||
"""返回 hair_mask(H×W bool,True=头发),尺寸同输入原图。"""
|
||||
def _parse(self, image_bgr):
|
||||
"""整图语义分割,返回原图尺寸的类别图(H×W int,值为 0–18 类别号)。"""
|
||||
torch = self._torch
|
||||
h, w = image_bgr.shape[:2]
|
||||
rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
|
||||
@@ -70,10 +72,49 @@ class HairSegmenter:
|
||||
with torch.no_grad():
|
||||
out = self.net(inp)[0] # 主输出 (1, C, 512, 512)
|
||||
parsing = out.squeeze(0).argmax(0).cpu().numpy() # (512, 512) 类别图
|
||||
hair_small = (parsing == HAIR_CLASS).astype(np.uint8)
|
||||
# 还原到原图尺寸(最近邻保持类别边界)
|
||||
hair_mask = cv2.resize(hair_small, (w, h), interpolation=cv2.INTER_NEAREST)
|
||||
return hair_mask.astype(bool)
|
||||
return cv2.resize(parsing.astype(np.int32), (w, h),
|
||||
interpolation=cv2.INTER_NEAREST)
|
||||
|
||||
def segment_hair(self, image_bgr):
|
||||
"""返回 hair_mask(H×W bool,True=头发),尺寸同输入原图。"""
|
||||
return self._parse(image_bgr) == HAIR_CLASS
|
||||
|
||||
def _parse_face_cropped(self, image_bgr, face_box):
|
||||
"""按人脸框裁剪后再分割,结果映射回原图尺寸(裁剪外填背景 0)。
|
||||
|
||||
BiSeNet 在 CelebAMask-HQ「紧裁对齐人脸」上训练,整张大场景图(全身/街拍,
|
||||
脸只占一小块、背景复杂)会严重欠分割、丢耳朵。先按人脸放大裁剪,让脸接近
|
||||
训练分布,耳朵/头发分割明显更稳。裁剪含足够上/侧边距以纳入发顶与双耳。
|
||||
"""
|
||||
h, w = image_bgr.shape[:2]
|
||||
x0, y0, x1, y1 = face_box
|
||||
fw, fh = max(1.0, x1 - x0), max(1.0, y1 - y0)
|
||||
cx0 = int(max(0, x0 - fw * 0.8)); cx1 = int(min(w, x1 + fw * 0.8))
|
||||
cy0 = int(max(0, y0 - fh * 1.0)); cy1 = int(min(h, y1 + fh * 0.5))
|
||||
if cx1 - cx0 < 2 or cy1 - cy0 < 2:
|
||||
return self._parse(image_bgr)
|
||||
full = np.zeros((h, w), dtype=np.int32)
|
||||
full[cy0:cy1, cx0:cx1] = self._parse(image_bgr[cy0:cy1, cx0:cx1])
|
||||
return full
|
||||
|
||||
def segment_hair_and_ears(self, image_bgr, face_box=None):
|
||||
"""单次推理返回 (hair_mask, ear_mask),均为 H×W bool,尺寸同原图。
|
||||
|
||||
ear_mask = 左耳(7) ∪ 右耳(8);耳朵被头发/侧脸遮挡时对应区域天然为空,
|
||||
正好用于「看不到耳朵就不画线」的判定。两类合并、左右按图像位置另判,
|
||||
不依赖以人为参照的类名(详见 EAR_CLASSES 注释)。
|
||||
|
||||
face_box=(x0,y0,x1,y1)(人脸关键点包围盒像素坐标)给定时先按人脸裁剪再
|
||||
分割(见 _parse_face_cropped),整张大场景图也能稳定分出耳朵;不给则整图分割。
|
||||
"""
|
||||
if face_box is None:
|
||||
parsing = self._parse(image_bgr)
|
||||
else:
|
||||
parsing = self._parse_face_cropped(image_bgr, face_box)
|
||||
hair_mask = parsing == HAIR_CLASS
|
||||
ear_mask = np.isin(parsing, EAR_CLASSES)
|
||||
return hair_mask, ear_mask
|
||||
|
||||
|
||||
_segmenter = None
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""接口10:头部外缘膨胀带遮罩。
|
||||
|
||||
先和接口9 一样得到**内缩后的基准遮罩**(含额头的闭合区域外缘朝151内缩 erode_cm、底线不动,默认1.2cm),
|
||||
在这个基础上:
|
||||
1. 取基准遮罩的**外轮廓线**(1px),去掉贴着底部分界线的那一段(只留头发/头部外缘弧线)。
|
||||
2. 把这条外轮廓线膨胀成带子(半径 = dilate_cm/2,即带子**总宽 ≈ dilate_cm**,默认 2cm)。
|
||||
3. 裁到分界线以上(不越过底线)。
|
||||
输出这条带子作为 mask。BiSeNet / SegFormer 两套并排对比,分步可视化。
|
||||
|
||||
两个可调参数:erode_cm(同接口9 的内缩,默认1.2)+ dilate_cm(带子总宽,默认2)。
|
||||
复用 `head_mask` 的构件,避免重复实现。
|
||||
"""
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from face_analysis.detector import detector
|
||||
from face_analysis.calibration import estimate_scale_factor
|
||||
from face_analysis.head_mask import (
|
||||
BASELINE_IDX, ERODE_CM, NoFaceError,
|
||||
_baseline_points, _upper_region_mask, _fill_to_baseline, _largest_cc,
|
||||
_bisenet_hair_mask, _segformer_hair_mask,
|
||||
_erode, _overlay, _draw_baseline, _b64png, _mask_png,
|
||||
)
|
||||
|
||||
DILATE_CM = 2.0 # 膨胀后带子总宽(厘米,默认;半径 = 总宽/2;可由入参覆盖)
|
||||
|
||||
|
||||
def _dilate(mask_bool, r):
|
||||
"""圆盘核膨胀半径 r(像素)。r<=0 原样返回。"""
|
||||
if r <= 0:
|
||||
return mask_bool.copy()
|
||||
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * r + 1, 2 * r + 1))
|
||||
return cv2.dilate(mask_bool.astype(np.uint8), k).astype(bool)
|
||||
|
||||
|
||||
def _baseline_line_mask(baseline_pts, w, h, thickness):
|
||||
"""底部分割线(含左右水平延长线)画成一条带厚度的线,用于从外轮廓里剔除底边。"""
|
||||
m = np.zeros((h, w), np.uint8)
|
||||
y_l = baseline_pts[0][1]
|
||||
y_r = baseline_pts[-1][1]
|
||||
chain = [(0, y_l)] + baseline_pts + [(w - 1, y_r)]
|
||||
for a, b in zip(chain[:-1], chain[1:]):
|
||||
cv2.line(m, a, b, 1, thickness)
|
||||
return m.astype(bool)
|
||||
|
||||
|
||||
def _outer_contour_no_bottom(region, baseline_band):
|
||||
"""区域外轮廓(1px)去掉贴着底部分界线的那一段。"""
|
||||
contour = region & ~_erode(region, 1)
|
||||
return contour & ~baseline_band
|
||||
|
||||
|
||||
def _model_result(image_bgr, hair_mask, upper, baseline_pts, baseline_band, r_erode, r_dilate, w):
|
||||
"""单个分割模型的分步结果(内缩后基准遮罩 / 外轮廓线 / 膨胀带 / 纯遮罩)。"""
|
||||
top_fill = _fill_to_baseline(hair_mask, upper) # 含额头,延伸到图底
|
||||
base = _largest_cc(_erode(top_fill, r_erode) & upper) # 接口9 内缩后的基准遮罩
|
||||
contour = _outer_contour_no_bottom(base, baseline_band) # 外轮廓,去底线
|
||||
band = _largest_cc(_dilate(contour, r_dilate) & upper) # 膨胀成带、裁到底线以上
|
||||
return {
|
||||
"base_pixels": int(base.sum()),
|
||||
"band_pixels": int(band.sum()),
|
||||
"base_mask_base64": _b64png(
|
||||
_draw_baseline(_overlay(image_bgr, base, (255, 150, 0)), baseline_pts, w)),
|
||||
"contour_base64": _b64png(
|
||||
_draw_baseline(_overlay(image_bgr, _dilate(contour, 2), (0, 255, 0)), baseline_pts, w)),
|
||||
"band_overlay_base64": _b64png(
|
||||
_draw_baseline(_overlay(image_bgr, band, (0, 0, 255)), baseline_pts, w)),
|
||||
"mask_base64": _mask_png(band),
|
||||
}
|
||||
|
||||
|
||||
def generate_head_band(image_bgr, erode_cm=ERODE_CM, dilate_cm=DILATE_CM):
|
||||
"""接口10 完整管线。返回可直接进 ok() 的 data dict。
|
||||
|
||||
erode_cm:基准遮罩外缘朝151 内缩距离(厘米,同接口9),页面可调,默认 1.2cm。
|
||||
dilate_cm:外轮廓线膨胀后带子总宽(厘米),页面可调,默认 2cm。
|
||||
未检出人脸抛 NoFaceError。单个分割模型异常不影响另一个(记为 {"error": ...})。
|
||||
"""
|
||||
h, w = image_bgr.shape[:2]
|
||||
landmarks = detector.detect(image_bgr)
|
||||
if landmarks is None:
|
||||
raise NoFaceError()
|
||||
|
||||
erode_cm = max(0.0, float(erode_cm))
|
||||
dilate_cm = max(0.0, float(dilate_cm))
|
||||
px_per_cm = estimate_scale_factor(landmarks, w, h)
|
||||
r_erode = int(round(erode_cm * px_per_cm)) # 内缩半径
|
||||
r_dilate = int(round((dilate_cm / 2.0) * px_per_cm)) # 膨胀半径 = 总宽/2
|
||||
baseline_pts = _baseline_points(landmarks, w, h)
|
||||
upper = _upper_region_mask(baseline_pts, w, h)
|
||||
# 剔除底边用的分界线带:几像素宽即可,独立于膨胀/内缩半径
|
||||
baseline_band = _dilate(_baseline_line_mask(baseline_pts, w, h, 5), 2)
|
||||
|
||||
baseline_viz = _draw_baseline(image_bgr, baseline_pts, w)
|
||||
data = {
|
||||
"px_per_cm": round(float(px_per_cm), 4),
|
||||
"erode_cm": round(erode_cm, 2),
|
||||
"erode_px": r_erode,
|
||||
"dilate_cm": round(dilate_cm, 2),
|
||||
"dilate_radius_px": r_dilate,
|
||||
"image_size": {"width": w, "height": h},
|
||||
"baseline_landmarks": [
|
||||
{"index": idx, "x": p[0], "y": p[1]}
|
||||
for idx, p in zip(BASELINE_IDX, baseline_pts)
|
||||
],
|
||||
"steps_common": {
|
||||
"landmarks_baseline_base64": _b64png(baseline_viz),
|
||||
"upper_region_base64": _b64png(_overlay(baseline_viz, upper, (0, 200, 0))),
|
||||
},
|
||||
}
|
||||
|
||||
seg_fns = {
|
||||
"bisenet": lambda: _bisenet_hair_mask(image_bgr, landmarks, w, h),
|
||||
"segformer": lambda: _segformer_hair_mask(image_bgr),
|
||||
}
|
||||
for name, fn in seg_fns.items():
|
||||
try:
|
||||
hair_mask = fn()
|
||||
data[name] = _model_result(image_bgr, hair_mask, upper, baseline_pts,
|
||||
baseline_band, r_erode, r_dilate, w)
|
||||
except Exception as ex: # noqa: BLE001 单模型失败不影响整体
|
||||
data[name] = {"error": f"{type(ex).__name__}: {ex}"}
|
||||
return data
|
||||
@@ -0,0 +1,226 @@
|
||||
"""接口9:头发遮罩生成。
|
||||
|
||||
流程(详见需求讨论):
|
||||
1. MediaPipe 关键点检测。
|
||||
2. 底部分割线 = 关键点 [162,71,68,104,69,108,151,337,299,333,298,301,389] 的连线(左端162→中心151→右端389),
|
||||
再把左端点 162 水平延伸到图片最左边、右端点 389 水平延伸到图片最右边。
|
||||
3. 上半区 = 分割线以上区域(多边形填充:左边缘→弧线→右边缘→上边缘闭合)。
|
||||
4. 头发分割:BiSeNet 与 SegFormer 各出一张 hair_mask(两套供对比)。
|
||||
5. 闭合区域(含额头):每列从最顶端头发像素向下填充到分割线,把头发与画线之间的额头皮肤
|
||||
也包进来(不再从发际线割断),底边即分割线。
|
||||
6. 外缘内缩 erode_cm(默认 1.2cm,可调)、底线不动:对「填充到图底的实心块」做半径 r 的腐蚀,
|
||||
再与上半区相交。腐蚀只把外轮廓(顶/两侧)朝内(朝 151)收 r;平底边是相交后才产生的,
|
||||
所以底线纹丝不动。cm→像素用虹膜标定(calibration.estimate_scale_factor)。
|
||||
|
||||
对外返回每一步叠加在原图上的可视化图(base64 PNG,data URI),供测试页逐步展示。
|
||||
"""
|
||||
import base64
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from face_analysis.detector import detector
|
||||
from face_analysis.calibration import estimate_scale_factor, normalized_to_pixel
|
||||
|
||||
# 底部分割线关键点(图像上从左到右,眉骨弧线 → 中心 151 → 右侧对称)
|
||||
# 左端 104 → 中心 151 → 右端 333;首末点向图片左右边缘水平延长
|
||||
# BASELINE_IDX = [104, 69, 108, 151, 337, 299, 333]
|
||||
# BASELINE_IDX = [34, 139, 71, 68, 104, 69, 108, 151, 337, 299, 333, 298, 301, 368, 264]
|
||||
BASELINE_IDX = [71, 68, 104, 69, 108, 151, 337, 299, 333, 298, 301]
|
||||
CENTER_IDX = 151 # 内缩方向的目标点(额头中心)
|
||||
ERODE_CM = 1.2 # 外缘内缩距离(厘米,默认;可由入参覆盖)
|
||||
SEGFORMER_HAIR = 13 # jonathandinu/face-parsing 中 hair 类索引
|
||||
|
||||
|
||||
class NoFaceError(Exception):
|
||||
"""未检测到人脸。"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 几何:分割线与上半区
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _px(landmarks, idx, w, h):
|
||||
p = landmarks.landmark[idx]
|
||||
return (int(round(p.x * w)), int(round(p.y * h)))
|
||||
|
||||
|
||||
def _baseline_points(landmarks, w, h):
|
||||
"""额头弧线各关键点的像素坐标(按 BASELINE_IDX 顺序,左→右)。"""
|
||||
return [_px(landmarks, i, w, h) for i in BASELINE_IDX]
|
||||
|
||||
|
||||
def _upper_region_mask(baseline_pts, w, h):
|
||||
"""分割线以上区域(bool,H×W)。
|
||||
|
||||
多边形顶点:左上角 →(0, y左端)→ 弧线各点 →(w-1, y右端)→ 右上角,闭合后填充。
|
||||
左端/右端为两段水平延长线(向图片左右边缘延伸)。
|
||||
"""
|
||||
x0, y0 = baseline_pts[0]
|
||||
x1, y1 = baseline_pts[-1]
|
||||
poly = [(0, 0), (0, y0)] + baseline_pts + [(w - 1, y1), (w - 1, 0)]
|
||||
mask = np.zeros((h, w), np.uint8)
|
||||
cv2.fillPoly(mask, [np.array(poly, np.int32)], 1)
|
||||
return mask.astype(bool)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 头发分割(两套)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _bisenet_hair_mask(image_bgr, landmarks, w, h):
|
||||
"""BiSeNet(接口1 同款):先按人脸框裁剪再分割,稳住小脸大图。"""
|
||||
from face_analysis.hair_segmenter import get_segmenter
|
||||
pxs = [normalized_to_pixel(p, w, h) for p in landmarks.landmark]
|
||||
face_box = (min(p[0] for p in pxs), min(p[1] for p in pxs),
|
||||
max(p[0] for p in pxs), max(p[1] for p in pxs))
|
||||
hair_mask, _ear = get_segmenter().segment_hair_and_ears(image_bgr, face_box=face_box)
|
||||
return np.asarray(hair_mask, dtype=bool)
|
||||
|
||||
|
||||
def _segformer_hair_mask(image_bgr):
|
||||
"""复用接口2/3 的 SegFormer 单例(hairline.service.get_parser),
|
||||
共用权重与设备策略(SEG_DEVICE,默认 cpu;本机 5090 上 CUDA 内核不可用故走 CPU)。"""
|
||||
from hairline.service import get_parser
|
||||
rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
|
||||
labels = get_parser().parse(rgb)
|
||||
return labels == SEGFORMER_HAIR
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 形态学 & 可视化
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _erode(mask_bool, r):
|
||||
"""圆盘核腐蚀半径 r(像素)。r<=0 原样返回。"""
|
||||
if r <= 0:
|
||||
return mask_bool.copy()
|
||||
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * r + 1, 2 * r + 1))
|
||||
return cv2.erode(mask_bool.astype(np.uint8), k).astype(bool)
|
||||
|
||||
|
||||
def _overlay(image, mask_bool, color, alpha=0.45):
|
||||
"""把纯色以 alpha 叠加到 mask 区域上(非 mask 区域保持原样)。"""
|
||||
out = image.copy()
|
||||
if mask_bool.any():
|
||||
out[mask_bool] = (out[mask_bool] * (1 - alpha)
|
||||
+ np.array(color, np.float32) * alpha).astype(np.uint8)
|
||||
return out
|
||||
|
||||
|
||||
def _draw_baseline(image, baseline_pts, w):
|
||||
"""画分割线(含左右水平延长线)+ 关键点,中心点 151 标红。"""
|
||||
out = image.copy()
|
||||
y0 = baseline_pts[0][1]
|
||||
y1 = baseline_pts[-1][1]
|
||||
chain = [(0, y0)] + baseline_pts + [(w - 1, y1)]
|
||||
for a, b in zip(chain[:-1], chain[1:]):
|
||||
cv2.line(out, a, b, (0, 255, 255), 2, cv2.LINE_AA)
|
||||
for idx, p in zip(BASELINE_IDX, baseline_pts):
|
||||
col = (0, 0, 255) if idx == CENTER_IDX else (0, 200, 0)
|
||||
cv2.circle(out, p, 4, col, -1, cv2.LINE_AA)
|
||||
cv2.putText(out, str(idx), (p[0] + 4, p[1] - 6),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.5, col, 1, cv2.LINE_AA)
|
||||
return out
|
||||
|
||||
|
||||
def _b64png(bgr):
|
||||
"""BGR 图 → data URI(PNG base64)。gateway 会把 *_base64 字段落盘改成 *_url。"""
|
||||
ok, buf = cv2.imencode(".png", bgr)
|
||||
return "data:image/png;base64," + base64.b64encode(buf.tobytes()).decode()
|
||||
|
||||
|
||||
def _mask_png(mask_bool):
|
||||
"""纯遮罩:白(255)为遮罩、黑为背景。"""
|
||||
m = (mask_bool.astype(np.uint8)) * 255
|
||||
return _b64png(cv2.cvtColor(m, cv2.COLOR_GRAY2BGR))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 主入口
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _largest_cc(mask_bool):
|
||||
"""保留最大连通域,去掉背景里孤立的杂散头发列。空掩膜原样返回。"""
|
||||
m = mask_bool.astype(np.uint8)
|
||||
if m.sum() == 0:
|
||||
return mask_bool
|
||||
n, labels, stats, _ = cv2.connectedComponentsWithStats(m, connectivity=8)
|
||||
if n <= 2: # 只有背景 + 一个前景
|
||||
return mask_bool
|
||||
largest = 1 + int(np.argmax(stats[1:, cv2.CC_STAT_AREA]))
|
||||
return labels == largest
|
||||
|
||||
|
||||
def _fill_to_baseline(hair_mask, upper):
|
||||
"""含额头的实心区域:每列从最顶端头发像素向下填充(延伸到图底,未按基线裁剪)。
|
||||
|
||||
这样头发与画线之间的额头皮肤被包进闭合区域(不再被割断);未裁剪到基线是为了
|
||||
后续腐蚀时底线不动(腐蚀在延伸到图底的实心块上做,再与上半区相交切平底边)。
|
||||
"""
|
||||
has_hair = (hair_mask & upper).astype(np.uint8)
|
||||
return np.maximum.accumulate(has_hair, axis=0).astype(bool)
|
||||
|
||||
|
||||
def _model_result(image_bgr, hair_mask, upper, baseline_pts, r, w):
|
||||
"""单个分割模型的分步结果(闭合区域 / 最终遮罩 / 可视化)。"""
|
||||
top_fill = _fill_to_baseline(hair_mask, upper) # 含额头,延伸到图底
|
||||
closed = _largest_cc(top_fill & upper) # 闭合区域:头发+额头,底=基线
|
||||
final = _largest_cc(_erode(top_fill, r) & upper) # 外缘朝151内缩 r、底线不动
|
||||
return {
|
||||
"hair_pixels": int(hair_mask.sum()),
|
||||
"closed_pixels": int(closed.sum()),
|
||||
"mask_pixels": int(final.sum()),
|
||||
"hair_mask_base64": _b64png(_overlay(image_bgr, hair_mask, (255, 150, 0))),
|
||||
"closed_region_base64": _b64png(
|
||||
_draw_baseline(_overlay(image_bgr, closed, (255, 150, 0)), baseline_pts, w)),
|
||||
"final_overlay_base64": _b64png(
|
||||
_draw_baseline(_overlay(image_bgr, final, (0, 0, 255)), baseline_pts, w)),
|
||||
"mask_base64": _mask_png(final),
|
||||
}
|
||||
|
||||
|
||||
def generate_head_mask(image_bgr, erode_cm=ERODE_CM):
|
||||
"""接口9 完整管线。返回可直接进 ok() 的 data dict。
|
||||
|
||||
erode_cm:外缘朝 151 内缩的距离(厘米),页面可调,默认 1cm。
|
||||
未检出人脸抛 NoFaceError。单个分割模型异常不影响另一个(记为 {"error": ...})。
|
||||
"""
|
||||
h, w = image_bgr.shape[:2]
|
||||
landmarks = detector.detect(image_bgr)
|
||||
if landmarks is None:
|
||||
raise NoFaceError()
|
||||
|
||||
erode_cm = max(0.0, float(erode_cm))
|
||||
px_per_cm = estimate_scale_factor(landmarks, w, h)
|
||||
r = int(round(erode_cm * px_per_cm))
|
||||
baseline_pts = _baseline_points(landmarks, w, h)
|
||||
upper = _upper_region_mask(baseline_pts, w, h)
|
||||
|
||||
baseline_viz = _draw_baseline(image_bgr, baseline_pts, w)
|
||||
data = {
|
||||
"px_per_cm": round(float(px_per_cm), 4),
|
||||
"erode_cm": round(erode_cm, 2),
|
||||
"erode_px": r,
|
||||
"image_size": {"width": w, "height": h},
|
||||
"baseline_landmarks": [
|
||||
{"index": idx, "x": p[0], "y": p[1]}
|
||||
for idx, p in zip(BASELINE_IDX, baseline_pts)
|
||||
],
|
||||
"steps_common": {
|
||||
"landmarks_baseline_base64": _b64png(baseline_viz),
|
||||
"upper_region_base64": _b64png(_overlay(baseline_viz, upper, (0, 200, 0))),
|
||||
},
|
||||
}
|
||||
|
||||
seg_fns = {
|
||||
"bisenet": lambda: _bisenet_hair_mask(image_bgr, landmarks, w, h),
|
||||
"segformer": lambda: _segformer_hair_mask(image_bgr),
|
||||
}
|
||||
for name, fn in seg_fns.items():
|
||||
try:
|
||||
hair_mask = fn()
|
||||
data[name] = _model_result(image_bgr, hair_mask, upper, baseline_pts, r, w)
|
||||
except Exception as ex: # noqa: BLE001 单模型失败不影响整体
|
||||
data[name] = {"error": f"{type(ex).__name__}: {ex}"}
|
||||
return data
|
||||
@@ -12,9 +12,9 @@ from face_analysis.calibration import (
|
||||
estimate_scale_factor, normalized_to_pixel, pixel_distance, _lm_list,
|
||||
)
|
||||
from face_analysis.face_mesh_landmarks import (
|
||||
GLABELLA_9, GLABELLA_151, NOSE_BOTTOM, CHIN_TIP,
|
||||
GLABELLA_9, NOSE_BOTTOM, CHIN_TIP,
|
||||
LEFT_EYE_OUTER, LEFT_EYE_INNER, RIGHT_EYE_INNER, RIGHT_EYE_OUTER,
|
||||
LEFT_CHEEK, RIGHT_CHEEK,
|
||||
LEFT_CHEEK, RIGHT_CHEEK, LEFT_POSITION, RIGHT_POSITION,
|
||||
)
|
||||
from face_analysis.hair_segmenter import locate_hairline_by_segmentation
|
||||
|
||||
@@ -24,10 +24,8 @@ _TOP_RATIO = 0.22 / 0.28 # 顶庭 ÷ 中庭(≈ 0.786)
|
||||
|
||||
|
||||
def _brow_center(lm, w, h):
|
||||
"""眉心 = 索引 9 / 151 中点。"""
|
||||
g9 = normalized_to_pixel(lm[GLABELLA_9], w, h)
|
||||
g151 = normalized_to_pixel(lm[GLABELLA_151], w, h)
|
||||
return (g9[0] + g151[0]) / 2, (g9[1] + g151[1]) / 2
|
||||
"""眉心 = 索引 9(眉间上点)。"""
|
||||
return normalized_to_pixel(lm[GLABELLA_9], w, h)
|
||||
|
||||
|
||||
def estimate_vertical_landmarks(landmarks, image_width, image_height):
|
||||
@@ -144,22 +142,47 @@ def measure_seven_eyes(landmarks, image_width, image_height):
|
||||
}
|
||||
|
||||
|
||||
def pt_or_none(vertical, name):
|
||||
"""vertical dict 的点 → {"x","y"},值为 None 时返回 None。"""
|
||||
v = vertical.get(name)
|
||||
if v is None:
|
||||
return None
|
||||
return {"x": int(round(v[0])), "y": int(round(v[1]))}
|
||||
|
||||
|
||||
class MeasureResult:
|
||||
"""测量结果,提供 to_response() 输出与接口文档同构的 data 字段。"""
|
||||
|
||||
def __init__(self, vertical, eyes, px_per_cm, hairline_source, head_pose):
|
||||
# 发际线弃用阈值:发际线离头顶(顶庭)< 此值时判定分割不可靠,弃用发际线。
|
||||
# hairline 与 hair_top 几乎重合(如稀疏头发中轴漏检只剩一小撮),说明发际线
|
||||
# 定位无意义 → 顶/上庭置 null、标注图不画头顶/发际线。
|
||||
HAIRLINE_DISCARD_TOP_CM = 0.7
|
||||
|
||||
def __init__(self, vertical, eyes, px_per_cm, hairline_source, head_pose,
|
||||
landmarks=None, image_width=None, image_height=None):
|
||||
self.vertical = vertical
|
||||
self.eyes = eyes
|
||||
self.px_per_cm = px_per_cm
|
||||
self.hairline_source = hairline_source
|
||||
self.head_pose = head_pose # (yaw, pitch, roll) 或 None
|
||||
# 原始 mediapipe 点集 + 图像尺寸,供 to_response 输出 21/251 号定位点
|
||||
self.landmarks = landmarks
|
||||
self.w = image_width
|
||||
self.h = image_height
|
||||
|
||||
# 各庭厘米
|
||||
self.top_cm = vertical["top_court_px"] / px_per_cm
|
||||
self.upper_cm = vertical["upper_court_px"] / px_per_cm
|
||||
self.middle_cm = vertical["middle_court_px"] / px_per_cm
|
||||
self.lower_cm = vertical["lower_court_px"] / px_per_cm
|
||||
self.face_total_cm = self.top_cm + self.upper_cm + self.middle_cm + self.lower_cm
|
||||
# 发际线弃用判定:顶庭(头顶→发际线)过小视为发际线贴近头顶、不可靠。
|
||||
# 弃用时 hairline_source 改为 "discarded",face_total 只算中庭+下庭。
|
||||
self.hairline_discarded = self.top_cm < self.HAIRLINE_DISCARD_TOP_CM
|
||||
if self.hairline_discarded:
|
||||
self.hairline_source = "discarded"
|
||||
self.face_total_cm = self.middle_cm + self.lower_cm
|
||||
else:
|
||||
self.face_total_cm = self.top_cm + self.upper_cm + self.middle_cm + self.lower_cm
|
||||
|
||||
# 七眼厘米
|
||||
self.eye_width_cm = eyes["eye_width_px"] / px_per_cm
|
||||
@@ -167,46 +190,88 @@ class MeasureResult:
|
||||
self.inter_eye_cm = eyes["inter_eye_distance_px"] / px_per_cm
|
||||
|
||||
def to_response(self):
|
||||
total_px = (self.vertical["top_court_px"] + self.vertical["upper_court_px"]
|
||||
+ self.vertical["middle_court_px"] + self.vertical["lower_court_px"])
|
||||
fw_px = self.eyes["face_width_px"]
|
||||
|
||||
def pt(name):
|
||||
x, y = self.vertical[name]
|
||||
return {"x": int(round(x)), "y": int(round(y))}
|
||||
|
||||
data = {
|
||||
"face_total_height_cm": round(self.face_total_cm, 2),
|
||||
"four_courts": {
|
||||
"top_court_cm": round(self.top_cm, 2),
|
||||
"upper_court_cm": round(self.upper_cm, 2),
|
||||
"middle_court_cm": round(self.middle_cm, 2),
|
||||
"lower_court_cm": round(self.lower_cm, 2),
|
||||
"ratios": {
|
||||
"top_court": round(self.vertical["top_court_px"] / total_px, 3),
|
||||
"upper_court": round(self.vertical["upper_court_px"] / total_px, 3),
|
||||
"middle_court": round(self.vertical["middle_court_px"] / total_px, 3),
|
||||
"lower_court": round(self.vertical["lower_court_px"] / total_px, 3),
|
||||
# 发际线弃用:顶/上庭相关字段置 null(保留键),ratio 分母只算中下庭;
|
||||
# landmarks.hair_top/hairline 置 null。否则按四庭正常输出。
|
||||
if self.hairline_discarded:
|
||||
base_px = (self.vertical["middle_court_px"] + self.vertical["lower_court_px"])
|
||||
data = {
|
||||
"face_total_height_cm": round(self.face_total_cm, 2),
|
||||
"four_courts": {
|
||||
"top_court_cm": None,
|
||||
"upper_court_cm": None,
|
||||
"middle_court_cm": round(self.middle_cm, 2),
|
||||
"lower_court_cm": round(self.lower_cm, 2),
|
||||
"ratios": {
|
||||
"top_court": None,
|
||||
"upper_court": None,
|
||||
"middle_court": round(self.vertical["middle_court_px"] / base_px, 3),
|
||||
"lower_court": round(self.vertical["lower_court_px"] / base_px, 3),
|
||||
},
|
||||
},
|
||||
},
|
||||
"seven_eyes": {
|
||||
"eye_width_cm": round(self.eye_width_cm, 2),
|
||||
"face_width_cm": round(self.face_width_cm, 2),
|
||||
"inter_eye_distance_cm": round(self.inter_eye_cm, 2),
|
||||
"ratios": {
|
||||
"eye_width": round(self.eyes["eye_width_px"] / fw_px, 3),
|
||||
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / fw_px, 3),
|
||||
"seven_eyes": {
|
||||
"eye_width_cm": round(self.eye_width_cm, 2),
|
||||
"face_width_cm": round(self.face_width_cm, 2),
|
||||
"inter_eye_distance_cm": round(self.inter_eye_cm, 2),
|
||||
"ratios": {
|
||||
"eye_width": round(self.eyes["eye_width_px"] / self.eyes["face_width_px"], 3),
|
||||
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / self.eyes["face_width_px"], 3),
|
||||
},
|
||||
},
|
||||
},
|
||||
"landmarks": {
|
||||
"hair_top": pt("hair_top"),
|
||||
"hairline": pt("hairline"),
|
||||
"brow_center": pt("brow_center"),
|
||||
"nose_bottom": pt("nose_bottom"),
|
||||
"chin_tip": pt("chin_tip"),
|
||||
},
|
||||
"hairline_source": self.hairline_source,
|
||||
}
|
||||
"landmarks": {
|
||||
"hair_top": None,
|
||||
"hairline": None,
|
||||
"brow_center": pt_or_none(self.vertical, "brow_center"),
|
||||
"nose_bottom": pt_or_none(self.vertical, "nose_bottom"),
|
||||
"chin_tip": pt_or_none(self.vertical, "chin_tip"),
|
||||
},
|
||||
"hairline_source": self.hairline_source,
|
||||
}
|
||||
else:
|
||||
total_px = (self.vertical["top_court_px"] + self.vertical["upper_court_px"]
|
||||
+ self.vertical["middle_court_px"] + self.vertical["lower_court_px"])
|
||||
data = {
|
||||
"face_total_height_cm": round(self.face_total_cm, 2),
|
||||
"four_courts": {
|
||||
"top_court_cm": round(self.top_cm, 2),
|
||||
"upper_court_cm": round(self.upper_cm, 2),
|
||||
"middle_court_cm": round(self.middle_cm, 2),
|
||||
"lower_court_cm": round(self.lower_cm, 2),
|
||||
"ratios": {
|
||||
"top_court": round(self.vertical["top_court_px"] / total_px, 3),
|
||||
"upper_court": round(self.vertical["upper_court_px"] / total_px, 3),
|
||||
"middle_court": round(self.vertical["middle_court_px"] / total_px, 3),
|
||||
"lower_court": round(self.vertical["lower_court_px"] / total_px, 3),
|
||||
},
|
||||
},
|
||||
"seven_eyes": {
|
||||
"eye_width_cm": round(self.eye_width_cm, 2),
|
||||
"face_width_cm": round(self.face_width_cm, 2),
|
||||
"inter_eye_distance_cm": round(self.inter_eye_cm, 2),
|
||||
"ratios": {
|
||||
"eye_width": round(self.eyes["eye_width_px"] / self.eyes["face_width_px"], 3),
|
||||
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / self.eyes["face_width_px"], 3),
|
||||
},
|
||||
},
|
||||
"landmarks": {
|
||||
"hair_top": pt_or_none(self.vertical, "hair_top"),
|
||||
"hairline": pt_or_none(self.vertical, "hairline"),
|
||||
"brow_center": pt_or_none(self.vertical, "brow_center"),
|
||||
"nose_bottom": pt_or_none(self.vertical, "nose_bottom"),
|
||||
"chin_tip": pt_or_none(self.vertical, "chin_tip"),
|
||||
},
|
||||
"hairline_source": self.hairline_source,
|
||||
}
|
||||
# left/right_position:mediapipe 21/251 号定位点(原图像素,与 landmarks 同坐标系)。
|
||||
# landmarks 缺省(如测试直构 MeasureResult)时不输出,保持向后兼容。
|
||||
if self.landmarks is not None and self.w and self.h:
|
||||
lm = _lm_list(self.landmarks)
|
||||
|
||||
def _pt_lm(idx):
|
||||
px, py = normalized_to_pixel(lm[idx], self.w, self.h)
|
||||
return {"x": int(round(px)), "y": int(round(py))}
|
||||
|
||||
data["left_position"] = _pt_lm(LEFT_POSITION)
|
||||
data["right_position"] = _pt_lm(RIGHT_POSITION)
|
||||
if self.head_pose is not None:
|
||||
yaw, pitch, roll = self.head_pose
|
||||
data["head_pose"] = {
|
||||
@@ -220,7 +285,8 @@ def measure_face(landmarks, hair_mask, image_width, image_height, head_pose=None
|
||||
vertical, source = decide_vertical(landmarks, image_width, image_height, hair_mask)
|
||||
eyes = measure_seven_eyes(landmarks, image_width, image_height)
|
||||
px_per_cm = estimate_scale_factor(landmarks, image_width, image_height)
|
||||
return MeasureResult(vertical, eyes, px_per_cm, source, head_pose)
|
||||
return MeasureResult(vertical, eyes, px_per_cm, source, head_pose,
|
||||
landmarks, image_width, image_height)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -50,12 +50,21 @@ def estimate_head_pose(landmarks, image_width, image_height):
|
||||
[0, 0, 1]], dtype=np.float64)
|
||||
dist = np.zeros((4, 1)) # 假设无畸变
|
||||
|
||||
success, rvec, _tvec = cv2.solvePnP(
|
||||
success, rvec, tvec = cv2.solvePnP(
|
||||
_MODEL_POINTS, image_points, cam_matrix, dist,
|
||||
flags=cv2.SOLVEPNP_ITERATIVE,
|
||||
)
|
||||
if not success:
|
||||
return None
|
||||
# ITERATIVE 偶发收敛到相机后方的翻转解(tz<0),此时 roll 落在 ±180° 附近,
|
||||
# 会把真正的正面照误判为 1003。改用 SQPNP 重解正深度解。
|
||||
if float(tvec[2, 0]) < 0:
|
||||
ok2, rvec2, tvec2 = cv2.solvePnP(
|
||||
_MODEL_POINTS, image_points, cam_matrix, dist,
|
||||
flags=cv2.SOLVEPNP_SQPNP,
|
||||
)
|
||||
if ok2 and float(tvec2[2, 0]) > 0:
|
||||
rvec = rvec2
|
||||
rot, _ = cv2.Rodrigues(rvec)
|
||||
# 在「相机坐标系」(x右 y下 z内) 下抽取 Tait-Bryan 欧拉角,物理含义对齐:
|
||||
# yaw = 绕 Y(竖轴)转 → 左右扭头
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""接口4:用户面部特征分析(调用火山方舟 豆包视觉模型 doubao-seed-1-6-vision)。
|
||||
|
||||
算法来源:/home/xsl/fuyan(FaceArk.py)。worker 把图片以 base64 data URI 传给方舟
|
||||
多模态模型,模型返回一大堆人脸特征 JSON;本模块解析后映射出接口4 的英文优先字段
|
||||
(face_shape 等),并保留 doubao 返回的全部中文字段。
|
||||
|
||||
⚠️ 这是**唯一调外网云模型**的接口(其余接口全本地)。API Key 走配置/环境变量,不入 git。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger("hair.worker")
|
||||
|
||||
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")
|
||||
|
||||
# doubao 中文键 → 接口4 英文优先字段(仅保留这 6 项)
|
||||
_KEY_MAP = {
|
||||
"脸型": "face_shape",
|
||||
"眉形": "eyebrow_shape",
|
||||
"面部年龄": "facial_age",
|
||||
"动静类型": "dynamic_static_type",
|
||||
"性别": "gender",
|
||||
"基因风格": "gene_style",
|
||||
}
|
||||
|
||||
# 仅请求接口4 需要的 6 个字段(+「图片是否有人脸」用于 1001 判定,不进最终输出)
|
||||
_PROMPT = (
|
||||
"分析一下图片告诉我以下特征,只要答案,格式为json字符串,"
|
||||
"图片是否有人脸(有人/没人) "
|
||||
"脸型(圆形脸/心形脸/菱形脸/鹅蛋脸/方形脸/长形脸/瓜子脸) 眉形 "
|
||||
"面部年龄(给出区间年龄) 动静类型(静态型/动态型) 性别(男/女) "
|
||||
"基因风格(戏剧型/睿智型/自然型/古典型/优雅型/浪漫型/前卫型/少女型/少年型)"
|
||||
)
|
||||
|
||||
_client = None # 缓存的 Ark client(api_key 变更时自动重建)
|
||||
_client_key: str | None = None # _client 构建时使用的 api_key,用于检测配置变更
|
||||
|
||||
|
||||
def _load_api_key() -> str | None:
|
||||
"""ARK_API_KEY 环境变量优先,否则读 worker_config.json / gateway/config.json 的 ark_api_key。"""
|
||||
key = os.getenv("ARK_API_KEY")
|
||||
if key:
|
||||
return key
|
||||
base = os.path.dirname(__file__)
|
||||
for cfg_name in ("worker_config.json", "gateway/config.json"):
|
||||
cfg = os.path.join(base, cfg_name)
|
||||
if os.path.isfile(cfg):
|
||||
try:
|
||||
with open(cfg, encoding="utf-8") as f:
|
||||
v = json.load(f).get("ark_api_key")
|
||||
if v:
|
||||
return v
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("读取 %s ark_api_key 失败:%s", cfg_name, e)
|
||||
return None
|
||||
|
||||
|
||||
def get_client():
|
||||
"""返回 Ark client。
|
||||
|
||||
client 全局缓存,但每次都会重新读取 api_key —— 一旦配置(环境变量 /
|
||||
worker_config.json / gateway/config.json 的 ark_api_key)发生变化,
|
||||
自动重建 client。这样换 key 后无需重启进程。
|
||||
"""
|
||||
global _client, _client_key
|
||||
key = _load_api_key()
|
||||
if not key:
|
||||
raise RuntimeError("缺少火山方舟 API Key(设 ARK_API_KEY 或 worker_config.json.ark_api_key)")
|
||||
# client 未建、或 key 变了 → 重建
|
||||
if _client is None or key != _client_key:
|
||||
from volcenginesdkarkruntime import Ark
|
||||
_client = Ark(base_url=ARK_BASE_URL, api_key=key)
|
||||
_client_key = key
|
||||
return _client
|
||||
|
||||
|
||||
def _parse_json(text: str) -> dict:
|
||||
"""去掉 ```json 包裹后解析。"""
|
||||
s = text.strip()
|
||||
if s.startswith("```"):
|
||||
s = s.strip("`")
|
||||
if s[:4].lower() == "json":
|
||||
s = s[4:]
|
||||
return json.loads(s.strip())
|
||||
|
||||
|
||||
def _image_to_url(image_bytes: bytes = None, image_url: str = None) -> str:
|
||||
"""优先用现成 URL;否则把字节转 base64 data URI(doubao 兼容)。"""
|
||||
if image_url:
|
||||
return image_url
|
||||
fmt = "png" if image_bytes[:8] == b"\x89PNG\r\n\x1a\n" else "jpeg"
|
||||
return f"data:image/{fmt};base64," + base64.b64encode(image_bytes).decode()
|
||||
|
||||
|
||||
def analyze_features(image_bytes: bytes = None, image_url: str = None):
|
||||
"""调 doubao 视觉模型分析人脸特征。
|
||||
|
||||
Returns: dict —— 仅含接口4 的 6 个英文字段(face_shape/eyebrow_shape/facial_age/
|
||||
dynamic_static_type/gender/gene_style);**无人脸返回 None**(调用方据此判 1001)。
|
||||
"""
|
||||
url = _image_to_url(image_bytes, image_url)
|
||||
resp = get_client().chat.completions.create(
|
||||
model=ARK_MODEL,
|
||||
messages=[{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "image_url", "image_url": {"url": url}},
|
||||
{"type": "text", "text": _PROMPT},
|
||||
],
|
||||
}],
|
||||
max_tokens=1024, # 限制输出长度,模型秒回
|
||||
temperature=0, # 固定输出,无随机采样,提速+结果稳定
|
||||
stream=False, # 关闭流式,单次返回结果更快
|
||||
extra_body={
|
||||
"thinking": {
|
||||
"type": "disabled", # 彻底关闭深度思考模式,提速50%+
|
||||
},
|
||||
},
|
||||
)
|
||||
text = resp.choices[0].message.content
|
||||
logger.info("doubao raw response (first 500 chars): %s", text[:500])
|
||||
try:
|
||||
raw = _parse_json(text) # doubao 原始中文字段
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
logger.error("doubao 返回非 JSON,原文: %s", text[:1000])
|
||||
raise RuntimeError(f"豆包模型返回格式异常,无法解析为 JSON:{text[:200]}") from e
|
||||
if not has_face(raw):
|
||||
return None
|
||||
# 只保留 6 个英文字段(doubao 缺某字段则跳过)
|
||||
return {en: raw[zh] for zh, en in _KEY_MAP.items() if zh in raw}
|
||||
|
||||
|
||||
def has_face(features: dict) -> bool:
|
||||
"""据 doubao 的「图片是否有人脸」判断。"""
|
||||
v = features.get("图片是否有人脸") or features.get("是否有人") or ""
|
||||
return "没人" not in str(v) and "没有" not in str(v)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
path = sys.argv[1] if len(sys.argv) > 1 else "tests/fixtures/frontal.jpg"
|
||||
with open(path, "rb") as f:
|
||||
feats = analyze_features(image_bytes=f.read())
|
||||
if feats is None:
|
||||
print("无人脸(1001)")
|
||||
else:
|
||||
print(json.dumps(feats, ensure_ascii=False, indent=2))
|
||||
@@ -0,0 +1,339 @@
|
||||
INFO: Started server process [26440]
|
||||
INFO: Waiting for application startup.
|
||||
2026-07-01 23:35:15 [INFO] gateway.config: 配置加载完成 | workers=['http://127.0.0.1:8187'] | public_base_url=http://127.0.0.1:8080 | hc_interval=8s | dispatch_timeout=600s | queue_wait=30s
|
||||
2026-07-01 23:35:15 [INFO] gateway: 网关启动中... workers=['http://127.0.0.1:8187']
|
||||
2026-07-01 23:35:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:35:16 [INFO] gateway.pool: Worker 池初始化完成 | 总数=1 | 在线=1
|
||||
2026-07-01 23:35:16 [INFO] gateway: 标注图目录: /home/ubuntu/hair/static/annotations
|
||||
2026-07-01 23:35:16 [INFO] gateway.pool: 健康检查循环启动 | 间隔=8s | 下线阈值=2 | 上线阈值=1 | workers=1
|
||||
2026-07-01 23:35:16 [INFO] gateway: 清理任务启动 | 间隔=60min | 保留=24h | 目录=/home/ubuntu/hair/static/annotations
|
||||
INFO: Application startup complete.
|
||||
INFO: Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit)
|
||||
2026-07-01 23:35:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
INFO: 127.0.0.1:35872 - "GET /gateway-health HTTP/1.1" 200 OK
|
||||
2026-07-01 23:35:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:35:27 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/face/measure "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:35:27 [INFO] gateway.forward: base64→URL: annotated_image_base64 → http://127.0.0.1:8080/static/annotations/422b7786adc4486989d7f8770df40693.png (21864 bytes)
|
||||
INFO: 127.0.0.1:57220 - "POST /api/v1/face/measure HTTP/1.1" 200 OK
|
||||
2026-07-01 23:35:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
INFO: 127.0.0.1:57232 - "GET /static/annotations/422b7786adc4486989d7f8770df40693.png HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:57240 - "GET /gateway-health HTTP/1.1" 200 OK
|
||||
2026-07-01 23:35:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:35:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:35:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:36:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:36:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:36:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:36:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:36:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:36:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:36:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:37:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:37:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:37:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:37:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:37:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:37:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:37:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:37:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:38:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:38:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:38:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:38:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:38:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:38:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:38:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:39:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:39:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:39:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:39:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:39:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:39:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:39:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:39:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:40:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:40:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:40:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:40:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:40:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:40:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:40:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:41:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:41:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:41:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:41:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:41:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:41:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:41:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:41:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:42:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:42:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:42:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:42:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:42:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:42:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:42:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:43:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:43:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:43:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:43:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:43:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:43:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:43:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:43:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:44:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:44:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:44:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:44:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:44:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:44:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:44:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:45:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:45:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:45:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:45:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:45:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:45:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:45:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:45:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:46:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:46:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:46:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:46:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:46:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:46:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:46:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:47:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:47:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:47:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:47:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:47:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:47:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:47:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:47:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:48:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:48:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:48:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:48:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:48:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:48:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:48:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:49:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:49:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:49:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:49:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:49:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:49:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:49:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:49:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:50:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:50:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:50:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:50:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:50:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:50:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:50:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:51:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:51:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:51:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:51:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:51:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:51:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:51:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:51:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:52:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
INFO: 127.0.0.1:44470 - "GET /gateway-health HTTP/1.1" 200 OK
|
||||
2026-07-01 23:52:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:52:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:52:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:52:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:52:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:52:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:53:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:53:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:53:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:53:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:53:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:53:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:53:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
INFO: 111.192.98.24:6017 - "GET / HTTP/1.1" 200 OK
|
||||
INFO: 111.192.98.24:6017 - "GET /favicon.ico HTTP/1.1" 404 Not Found
|
||||
2026-07-01 23:53:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:54:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:54:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:54:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:54:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:54:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:54:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:54:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
INFO: 127.0.0.1:39090 - "GET /docs HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:39092 - "GET / HTTP/1.1" 200 OK
|
||||
2026-07-01 23:55:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:55:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
INFO: 127.0.0.1:56172 - "GET /static/test_interface1.html HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56174 - "GET /static/test_interface1.html HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56184 - "GET /static/test_interface2.html HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56196 - "GET /static/test_interface2.html HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56202 - "GET /static/test_interface3.html HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56206 - "GET /static/test_interface3.html HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56212 - "GET /static/test_interface4.html HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56214 - "GET /static/test_interface4.html HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56218 - "GET /static/test_interface5.html HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56230 - "GET /static/test_interface5.html HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56242 - "GET /static/test_interface6.html HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56256 - "GET /static/test_interface6.html HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56272 - "GET /static/test_interface7.html HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56278 - "GET /static/test_interface7.html HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56282 - "GET /static/integration.html HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56284 - "GET /static/integration.html HTTP/1.1" 200 OK
|
||||
2026-07-01 23:55:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:55:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:55:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:55:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:55:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:55:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
INFO: 111.192.98.24:6434 - "GET /static/test_interface1.html HTTP/1.1" 200 OK
|
||||
2026-07-01 23:56:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:56:08 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/face/measure "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:56:08 [INFO] gateway.forward: base64→URL: annotated_image_base64 → http://127.0.0.1:8080/static/annotations/8a70c12c19ef4868af555ef84eaeafae.png (35965 bytes)
|
||||
INFO: 111.192.98.24:6435 - "POST /api/v1/face/measure HTTP/1.1" 200 OK
|
||||
2026-07-01 23:56:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:56:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:56:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:56:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:56:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:56:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:57:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:57:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:57:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:57:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:57:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:57:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:57:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:57:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:58:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
INFO: Shutting down
|
||||
INFO: Waiting for application shutdown.
|
||||
2026-07-01 23:58:08 [INFO] gateway: 网关关闭中...
|
||||
2026-07-01 23:58:08 [INFO] gateway: 清理任务已停止
|
||||
2026-07-01 23:58:08 [INFO] gateway.pool: 健康检查循环已停止
|
||||
2026-07-01 23:58:08 [INFO] gateway.pool: Worker 池已关闭
|
||||
2026-07-01 23:58:08 [INFO] gateway: 网关已关闭
|
||||
INFO: Application shutdown complete.
|
||||
INFO: Finished server process [26440]
|
||||
INFO: Started server process [31898]
|
||||
INFO: Waiting for application startup.
|
||||
2026-07-01 23:58:10 [INFO] gateway.config: 配置加载完成 | workers=['http://127.0.0.1:8187'] | public_base_url=http://117.50.213.111:8080 | hc_interval=8s | dispatch_timeout=600s | queue_wait=30s
|
||||
2026-07-01 23:58:10 [INFO] gateway: 网关启动中... workers=['http://127.0.0.1:8187']
|
||||
2026-07-01 23:58:10 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:58:10 [INFO] gateway.pool: Worker 池初始化完成 | 总数=1 | 在线=1
|
||||
2026-07-01 23:58:10 [INFO] gateway: 标注图目录: /home/ubuntu/hair/static/annotations
|
||||
2026-07-01 23:58:10 [INFO] gateway.pool: 健康检查循环启动 | 间隔=8s | 下线阈值=2 | 上线阈值=1 | workers=1
|
||||
2026-07-01 23:58:10 [INFO] gateway: 清理任务启动 | 间隔=60min | 保留=24h | 目录=/home/ubuntu/hair/static/annotations
|
||||
INFO: Application startup complete.
|
||||
INFO: Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit)
|
||||
2026-07-01 23:58:10 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
INFO: 127.0.0.1:33732 - "GET /gateway-health HTTP/1.1" 200 OK
|
||||
2026-07-01 23:58:18 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:58:26 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:58:28 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/face/measure "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:58:28 [INFO] gateway.forward: base64→URL: annotated_image_base64 → http://117.50.213.111:8080/static/annotations/478efab4566a46c3af0065ad0ffafb67.png (21864 bytes)
|
||||
INFO: 127.0.0.1:32982 - "POST /api/v1/face/measure HTTP/1.1" 200 OK
|
||||
INFO: 117.50.213.111:57344 - "GET /static/annotations/478efab4566a46c3af0065ad0ffafb67.png HTTP/1.1" 200 OK
|
||||
2026-07-01 23:58:34 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:58:42 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:58:50 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:58:54 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/face/measure "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:58:54 [INFO] gateway.forward: base64→URL: annotated_image_base64 → http://117.50.213.111:8080/static/annotations/88d5eec74def44fdad5dd6f7b22e7be4.png (35965 bytes)
|
||||
INFO: 111.192.98.24:7030 - "POST /api/v1/face/measure HTTP/1.1" 200 OK
|
||||
INFO: 111.192.98.24:7030 - "GET /static/annotations/88d5eec74def44fdad5dd6f7b22e7be4.png HTTP/1.1" 200 OK
|
||||
2026-07-01 23:58:58 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
INFO: 111.192.98.24:7031 - "GET /static/test_interface2.html HTTP/1.1" 200 OK
|
||||
2026-07-01 23:59:06 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:59:14 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:59:22 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:59:27 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/hair/grow "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:59:27 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/8f770fef8bc14827b419698c90ebd6fa.jpg (105970 bytes)
|
||||
2026-07-01 23:59:27 [INFO] gateway.forward: base64→URL: grown_image_base64 → http://117.50.213.111:8080/static/annotations/6f592882d9c84c1b916ac327f4e0b2af.jpg (113320 bytes)
|
||||
INFO: 111.192.98.24:7062 - "POST /api/v1/hair/grow HTTP/1.1" 200 OK
|
||||
INFO: 111.192.98.24:7062 - "GET /static/annotations/8f770fef8bc14827b419698c90ebd6fa.jpg HTTP/1.1" 200 OK
|
||||
INFO: 111.192.98.24:7166 - "GET /static/annotations/6f592882d9c84c1b916ac327f4e0b2af.jpg HTTP/1.1" 200 OK
|
||||
2026-07-01 23:59:30 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:59:38 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:59:46 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:59:48 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/hair/grow "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:59:48 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/ed790d4888f742caa6625b3cf4704a77.jpg (105641 bytes)
|
||||
2026-07-01 23:59:48 [INFO] gateway.forward: base64→URL: grown_image_base64 → http://117.50.213.111:8080/static/annotations/e4aed9d67e864d31909b30a58e08c16e.jpg (111495 bytes)
|
||||
INFO: 111.192.98.24:5164 - "POST /api/v1/hair/grow HTTP/1.1" 200 OK
|
||||
INFO: 111.192.98.24:5164 - "GET /static/annotations/ed790d4888f742caa6625b3cf4704a77.jpg HTTP/1.1" 200 OK
|
||||
INFO: 111.192.98.24:5188 - "GET /static/annotations/e4aed9d67e864d31909b30a58e08c16e.jpg HTTP/1.1" 200 OK
|
||||
2026-07-01 23:59:54 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
INFO: 111.192.98.24:5213 - "GET /static/test_interface3.html HTTP/1.1" 200 OK
|
||||
2026-07-02 00:00:02 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:00:10 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:00:16 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/hair/grow-b "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:00:16 [INFO] gateway.forward: base64→URL: hair_growth_image_base64 → http://117.50.213.111:8080/static/annotations/0891a4b9375e4f58814502e893b12bf6.jpg (152092 bytes)
|
||||
INFO: 111.192.98.24:5212 - "POST /api/v1/hair/grow-b HTTP/1.1" 200 OK
|
||||
INFO: 111.192.98.24:5212 - "GET /static/annotations/0891a4b9375e4f58814502e893b12bf6.jpg HTTP/1.1" 200 OK
|
||||
2026-07-02 00:00:18 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
INFO: 111.192.98.24:5332 - "GET /static/test_interface4.html HTTP/1.1" 200 OK
|
||||
2026-07-02 00:00:26 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:00:34 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:00:39 [ERROR] gateway: 接口4 豆包调用失败
|
||||
Traceback (most recent call last):
|
||||
File "/home/ubuntu/hair/gateway/app.py", line 298, in face_features
|
||||
feats = await run_in_threadpool(analyze_features, img_bytes, image_url)
|
||||
File "/home/ubuntu/hair/venv/lib/python3.10/site-packages/starlette/concurrency.py", line 37, in run_in_threadpool
|
||||
return await anyio.to_thread.run_sync(func)
|
||||
File "/home/ubuntu/hair/venv/lib/python3.10/site-packages/anyio/to_thread.py", line 63, in run_sync
|
||||
return await get_async_backend().run_sync_in_worker_thread(
|
||||
File "/home/ubuntu/hair/venv/lib/python3.10/site-packages/anyio/_backends/_asyncio.py", line 2596, in run_sync_in_worker_thread
|
||||
return await future
|
||||
File "/home/ubuntu/hair/venv/lib/python3.10/site-packages/anyio/_backends/_asyncio.py", line 1029, in run
|
||||
result = context.run(func, *args)
|
||||
File "/home/ubuntu/hair/face_features.py", line 98, in analyze_features
|
||||
resp = get_client().chat.completions.create(
|
||||
File "/home/ubuntu/hair/face_features.py", line 65, in get_client
|
||||
from volcenginesdkarkruntime import Ark
|
||||
ModuleNotFoundError: No module named 'volcenginesdkarkruntime'
|
||||
INFO: 111.192.98.24:5331 - "POST /api/v1/face/features HTTP/1.1" 200 OK
|
||||
2026-07-02 00:00:42 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
INFO: 111.192.98.24:5436 - "GET /static/test_interface5.html HTTP/1.1" 200 OK
|
||||
2026-07-02 00:00:50 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:00:58 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:01:00 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/hairline/generate "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/4516a9491a1c41f4af048020f6709870.jpg (105674 bytes)
|
||||
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/ad51e082bb5540ea843259cdc1e76e6b.jpg (105970 bytes)
|
||||
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/12a6f7745c9c461ca963f4d49c5267ef.jpg (105735 bytes)
|
||||
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/316d8352062848e4b28337ef233d820e.jpg (105641 bytes)
|
||||
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/2ae45e3579b0419ab8bff5e3129ab26e.jpg (105705 bytes)
|
||||
INFO: 111.192.98.24:5435 - "POST /api/v1/hairline/generate HTTP/1.1" 200 OK
|
||||
INFO: 111.192.98.24:5435 - "GET /static/annotations/4516a9491a1c41f4af048020f6709870.jpg HTTP/1.1" 200 OK
|
||||
INFO: 111.192.98.24:5466 - "GET /static/annotations/ad51e082bb5540ea843259cdc1e76e6b.jpg HTTP/1.1" 200 OK
|
||||
INFO: 111.192.98.24:5467 - "GET /static/annotations/12a6f7745c9c461ca963f4d49c5267ef.jpg HTTP/1.1" 200 OK
|
||||
INFO: 111.192.98.24:5469 - "GET /static/annotations/316d8352062848e4b28337ef233d820e.jpg HTTP/1.1" 200 OK
|
||||
INFO: 111.192.98.24:5471 - "GET /static/annotations/2ae45e3579b0419ab8bff5e3129ab26e.jpg HTTP/1.1" 200 OK
|
||||
2026-07-02 00:01:06 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:01:14 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:01:22 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:01:30 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:01:38 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:01:46 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:01:54 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:02:02 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:02:10 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:02:18 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:02:26 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:02:34 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:02:42 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:02:50 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:02:58 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
INFO: Shutting down
|
||||
INFO: Waiting for application shutdown.
|
||||
2026-07-02 00:02:59 [INFO] gateway: 网关关闭中...
|
||||
2026-07-02 00:02:59 [INFO] gateway: 清理任务已停止
|
||||
2026-07-02 00:02:59 [INFO] gateway.pool: 健康检查循环已停止
|
||||
2026-07-02 00:02:59 [INFO] gateway.pool: Worker 池已关闭
|
||||
2026-07-02 00:02:59 [INFO] gateway: 网关已关闭
|
||||
INFO: Application shutdown complete.
|
||||
INFO: Finished server process [31898]
|
||||
@@ -5,14 +5,25 @@
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from fastapi import FastAPI, Request
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, File, Form, Request, UploadFile
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from gateway.config import load_config
|
||||
from gateway.logging_middleware import (
|
||||
get_stats,
|
||||
init_logging as _init_req_logging,
|
||||
request_logging_middleware,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 日志
|
||||
@@ -37,6 +48,9 @@ async def lifespan(app: FastAPI):
|
||||
cfg = load_config()
|
||||
logger.info("网关启动中... workers=%s", cfg["workers"])
|
||||
|
||||
# 初始化请求日志
|
||||
_init_req_logging(cfg)
|
||||
|
||||
# 初始化健康池(阶段二实现)
|
||||
try:
|
||||
from gateway.pool import init_pool, shutdown_pool as _pool_shutdown
|
||||
@@ -99,6 +113,9 @@ static_root.mkdir(parents=True, exist_ok=True)
|
||||
(static_root / "annotations").mkdir(parents=True, exist_ok=True)
|
||||
app.mount("/static", StaticFiles(directory=str(static_root)), name="static")
|
||||
|
||||
# 请求日志中间件(在所有路由之前,静态文件之后)
|
||||
app.middleware("http")(request_logging_middleware)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 健康检查(网关自身)
|
||||
@@ -179,14 +196,280 @@ async def health():
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def index():
|
||||
cfg = load_config()
|
||||
return {
|
||||
"service": "旷视五接口 — 网关",
|
||||
"version": "0.1.0",
|
||||
"docs": f"{cfg['public_base_url']}/docs",
|
||||
"docs": "/docs",
|
||||
"stats": "/admin/stats",
|
||||
"integration_guide": "/static/integration.html",
|
||||
"test_pages": {
|
||||
"if1_measure": "/static/test_interface1.html",
|
||||
"if2_hair_grow": "/static/test_interface2.html",
|
||||
"if3_hair_grow_b": "/static/test_interface3.html",
|
||||
"if4_features": "/static/test_interface4.html",
|
||||
"if5_hairline": "/static/test_interface5.html",
|
||||
"if6_measure_v2": "/static/test_interface6.html",
|
||||
"if7_hair_grow_v2": "/static/test_interface7.html",
|
||||
"if9_head_mask": "/static/test_interface9.html",
|
||||
"if10_head_band": "/static/test_interface10.html",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 请求统计仪表盘 HTML
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_STATS_PAGE_HTML = """<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>网关请求统计</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
|
||||
.container { max-width: 1300px; margin: 0 auto; padding: 24px; }
|
||||
h1 { font-size: 22px; margin-bottom: 4px; }
|
||||
.subtitle { color: #888; font-size: 13px; margin-bottom: 20px; }
|
||||
.nav { margin-bottom: 20px; }
|
||||
.nav a { color: #2563eb; text-decoration: none; font-size: 13px; }
|
||||
.nav a:hover { text-decoration: underline; }
|
||||
|
||||
/* 汇总卡片 */
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 14px; margin-bottom: 24px; }
|
||||
.stat-card { background: #fff; border-radius: 12px; padding: 18px 20px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
|
||||
.stat-card .value { font-size: 28px; font-weight: 700; color: #111827; }
|
||||
.stat-card .label { font-size: 11px; color: #9ca3af; text-transform: uppercase; letter-spacing: .5px; margin-top: 4px; }
|
||||
.stat-card.ok .value { color: #059669; }
|
||||
.stat-card.warn .value { color: #d97706; }
|
||||
|
||||
/* 表格 */
|
||||
.section { margin-bottom: 24px; }
|
||||
.section h2 { font-size: 16px; margin-bottom: 10px; color: #374151; }
|
||||
.table-wrap { background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 9px 14px; text-align: left; border-bottom: 1px solid #f1f5f9; font-size: 13px; }
|
||||
th { background: #f8fafc; font-weight: 700; color: #475569; font-size: 11px; text-transform: uppercase; letter-spacing: .3px; white-space: nowrap; }
|
||||
tr:hover td { background: #fafbfc; }
|
||||
td.mono { font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; }
|
||||
.badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 11px; font-weight: 700; }
|
||||
.badge-ok { background: #d1fae5; color: #065f46; }
|
||||
.badge-err { background: #fee2e2; color: #991b1b; }
|
||||
.badge-other { background: #f3f4f6; color: #6b7280; }
|
||||
.duration-fast { color: #059669; }
|
||||
.duration-mid { color: #d97706; }
|
||||
.duration-slow { color: #dc2626; }
|
||||
|
||||
.footer { text-align: right; font-size: 12px; color: #9ca3af; margin-top: 20px; }
|
||||
.auto-refresh { display: flex; align-items: center; gap: 8px; }
|
||||
.auto-refresh input { accent-color: #2563eb; }
|
||||
.empty { text-align: center; padding: 40px; color: #9ca3af; font-size: 14px; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
th, td { padding: 6px 8px; font-size: 12px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>📊 网关请求统计</h1>
|
||||
<p class="subtitle">实时请求监控 | 每 3 秒自动刷新</p>
|
||||
<div class="nav">
|
||||
<a href="/">← 返回首页</a> |
|
||||
<a href="/docs">API 文档</a> |
|
||||
<a href="/static/integration.html">接入指南</a>
|
||||
</div>
|
||||
|
||||
<!-- 汇总卡片 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card"><div class="value" id="totalCount">—</div><div class="label">请求总数</div></div>
|
||||
<div class="stat-card ok"><div class="value" id="successRate">—</div><div class="label">成功率(code=0)</div></div>
|
||||
<div class="stat-card"><div class="value" id="avgTime">—</div><div class="label">平均响应时间</div></div>
|
||||
<div class="stat-card"><div class="value" id="minTime">—</div><div class="label">最短响应</div></div>
|
||||
<div class="stat-card warn"><div class="value" id="maxTime">—</div><div class="label">最长响应</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 按接口 -->
|
||||
<div class="section">
|
||||
<h2>📋 按接口统计</h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>路径</th><th>请求数</th><th>平均耗时</th><th>最大耗时</th><th>成功率</th></tr></thead>
|
||||
<tbody id="endpointTable"><tr><td class="empty" colspan="5">暂无数据</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 按 Worker -->
|
||||
<div class="section">
|
||||
<h2>🖥️ 按 GPU Worker 统计</h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Worker</th><th>请求数</th><th>平均耗时</th><th>成功率</th></tr></thead>
|
||||
<tbody id="workerTable"><tr><td class="empty" colspan="4">暂无数据</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最近请求 -->
|
||||
<div class="section">
|
||||
<h2>🕐 最近请求(最新 100 条)</h2>
|
||||
<div class="table-wrap" style="max-height:600px;overflow:auto;">
|
||||
<table>
|
||||
<thead><tr><th>时间</th><th>方法</th><th>路径</th><th>Worker</th><th>HTTP</th><th>业务码</th><th>耗时</th><th>客户端 IP</th></tr></thead>
|
||||
<tbody id="recentTable"><tr><td class="empty" colspan="8">暂无数据</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<label class="auto-refresh">
|
||||
<input type="checkbox" id="autoRefresh" checked onchange="toggleAutoRefresh()"> 自动刷新(3s)
|
||||
</label>
|
||||
<span style="margin-left:16px" id="lastUpdated">加载中…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let _timer = null;
|
||||
|
||||
function formatDuration(ms) {
|
||||
if (ms < 1000) return ms.toFixed(1) + 'ms';
|
||||
if (ms < 60000) return (ms / 1000).toFixed(2) + 's';
|
||||
return (ms / 60000).toFixed(1) + 'min';
|
||||
}
|
||||
|
||||
function durationClass(ms) {
|
||||
if (ms < 500) return 'duration-fast';
|
||||
if (ms < 2000) return 'duration-mid';
|
||||
return 'duration-slow';
|
||||
}
|
||||
|
||||
function badgeClass(code) {
|
||||
if (code === 0) return 'badge-ok';
|
||||
if (code !== null && code !== undefined && code !== 0) return 'badge-err';
|
||||
return 'badge-other';
|
||||
}
|
||||
|
||||
function badgeText(code) {
|
||||
if (code === 0) return 'OK';
|
||||
if (code !== null && code !== undefined) return 'ERR ' + code;
|
||||
return '—';
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const r = await fetch('/admin/stats/json');
|
||||
const data = await r.json();
|
||||
const s = data.summary;
|
||||
|
||||
document.getElementById('totalCount').textContent = s.total.toLocaleString();
|
||||
document.getElementById('successRate').textContent = s.success_rate + '%';
|
||||
document.getElementById('avgTime').textContent = formatDuration(s.avg_duration_ms);
|
||||
document.getElementById('minTime').textContent = formatDuration(s.min_duration_ms);
|
||||
document.getElementById('maxTime').textContent = formatDuration(s.max_duration_ms);
|
||||
|
||||
// 按接口
|
||||
let ehtml = '';
|
||||
if (data.endpoints.length === 0) {
|
||||
ehtml = '<tr><td class="empty" colspan="5">暂无数据</td></tr>';
|
||||
} else {
|
||||
data.endpoints.forEach(function(e) {
|
||||
ehtml += '<tr>' +
|
||||
'<td class="mono">' + e.path + '</td>' +
|
||||
'<td>' + e.count + '</td>' +
|
||||
'<td class="' + durationClass(e.avg_duration_ms) + '">' + formatDuration(e.avg_duration_ms) + '</td>' +
|
||||
'<td>' + formatDuration(e.max_duration_ms) + '</td>' +
|
||||
'<td><span class="badge ' + badgeClass(0) + '" style="opacity:' + (e.success_rate / 100) + '">' + e.success_rate + '%</span></td>' +
|
||||
'</tr>';
|
||||
});
|
||||
}
|
||||
document.getElementById('endpointTable').innerHTML = ehtml;
|
||||
|
||||
// 按 Worker
|
||||
let whtml = '';
|
||||
if (!data.workers || data.workers.length === 0) {
|
||||
whtml = '<tr><td class="empty" colspan="4">暂无数据</td></tr>';
|
||||
} else {
|
||||
data.workers.forEach(function(w) {
|
||||
whtml += '<tr>' +
|
||||
'<td class="mono">' + w.worker + '</td>' +
|
||||
'<td>' + w.count + '</td>' +
|
||||
'<td class="' + durationClass(w.avg_duration_ms) + '">' + formatDuration(w.avg_duration_ms) + '</td>' +
|
||||
'<td><span class="badge ' + badgeClass(0) + '" style="opacity:' + (w.success_rate / 100) + '">' + w.success_rate + '%</span></td>' +
|
||||
'</tr>';
|
||||
});
|
||||
}
|
||||
document.getElementById('workerTable').innerHTML = whtml;
|
||||
|
||||
// 最近请求
|
||||
let rhtml = '';
|
||||
if (data.recent.length === 0) {
|
||||
rhtml = '<tr><td class="empty" colspan="8">暂无数据</td></tr>';
|
||||
} else {
|
||||
data.recent.forEach(function(entry) {
|
||||
var ts = entry.timestamp.replace('T', ' ').substring(0, 23);
|
||||
var workerDisplay = entry.worker || '—';
|
||||
// 短 worker 显示:只取主机部分
|
||||
if (workerDisplay.length > 30) {
|
||||
workerDisplay = workerDisplay.replace(/^https?:\/\//, '').substring(0, 28) + '…';
|
||||
}
|
||||
rhtml += '<tr>' +
|
||||
'<td class="mono">' + ts + '</td>' +
|
||||
'<td>' + entry.method + '</td>' +
|
||||
'<td class="mono">' + entry.path + '</td>' +
|
||||
'<td class="mono" style="font-size:11px">' + workerDisplay + '</td>' +
|
||||
'<td>' + entry.status_code + '</td>' +
|
||||
'<td><span class="badge ' + badgeClass(entry.response_code) + '">' + badgeText(entry.response_code) + '</span></td>' +
|
||||
'<td class="' + durationClass(entry.duration_ms) + '">' + formatDuration(entry.duration_ms) + '</td>' +
|
||||
'<td class="mono">' + entry.client_ip + '</td>' +
|
||||
'</tr>';
|
||||
});
|
||||
}
|
||||
document.getElementById('recentTable').innerHTML = rhtml;
|
||||
|
||||
document.getElementById('lastUpdated').textContent = '最后更新: ' + new Date().toLocaleTimeString();
|
||||
} catch(err) {
|
||||
document.getElementById('lastUpdated').textContent = '加载失败: ' + err.message;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
var checked = document.getElementById('autoRefresh').checked;
|
||||
if (checked) {
|
||||
_timer = setInterval(refresh, 3000);
|
||||
} else {
|
||||
clearInterval(_timer);
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
refresh();
|
||||
_timer = setInterval(refresh, 3000);
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 请求统计页面
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/admin/stats", include_in_schema=False)
|
||||
async def admin_stats():
|
||||
"""请求统计仪表盘(HTML 页面)。"""
|
||||
return HTMLResponse(content=_STATS_PAGE_HTML)
|
||||
|
||||
|
||||
@app.get("/admin/stats/json", include_in_schema=False)
|
||||
async def admin_stats_json():
|
||||
"""请求统计数据(JSON,供页面轮询)。"""
|
||||
return get_stats()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 代理路由
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -203,7 +486,7 @@ def _proxy(request: Request, path: str):
|
||||
|
||||
# 声明各接口的 form 参数用于 OpenAPI schema(实际转发直接读 Request)
|
||||
_MEASURE_FORMS = {
|
||||
"image_file": {"type": "file", "description": "上传图片文件(JPG/PNG,≤ 1 MB)"},
|
||||
"image_file": {"type": "file", "description": "上传图片文件(JPG/PNG)"},
|
||||
"image_url": {"type": "string", "description": "图片 URL"},
|
||||
"image_base64": {"type": "string", "description": "图片 base64(需带前缀)"},
|
||||
}
|
||||
@@ -217,9 +500,6 @@ _GROW_B_FORMS = {
|
||||
"marked_image_file": {"type": "file", "description": "划线图片文件"},
|
||||
"marked_image_url": {"type": "string", "description": "划线图片 URL"},
|
||||
"marked_image_base64": {"type": "string", "description": "划线图片 base64"},
|
||||
"original_image_file": {"type": "file", "description": "原始用户照片文件"},
|
||||
"original_image_url": {"type": "string", "description": "原始用户照片 URL"},
|
||||
"original_image_base64": {"type": "string", "description": "原始用户照片 base64"},
|
||||
}
|
||||
|
||||
|
||||
@@ -229,6 +509,12 @@ async def face_measure(request: Request):
|
||||
return await _proxy(request, "/api/v1/face/measure")
|
||||
|
||||
|
||||
@app.post("/api/v1/face/measure-v2", tags=["人脸分析"])
|
||||
async def face_measure_v2(request: Request):
|
||||
"""接口6:四庭七眼测量标注 v2(去顶庭 + 去头部端线)"""
|
||||
return await _proxy(request, "/api/v1/face/measure-v2")
|
||||
|
||||
|
||||
@app.post("/api/v1/hair/grow", tags=["生发"])
|
||||
async def hair_grow(request: Request):
|
||||
"""接口2:C端生发"""
|
||||
@@ -242,12 +528,82 @@ async def hair_grow_b(request: Request):
|
||||
|
||||
|
||||
@app.post("/api/v1/face/features", tags=["人脸分析"])
|
||||
async def face_features(request: Request):
|
||||
"""接口4:用户特征分析"""
|
||||
return await _proxy(request, "/api/v1/face/features")
|
||||
async def face_features(
|
||||
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(需带前缀)"),
|
||||
):
|
||||
"""接口4:用户特征分析 — 本机直接调豆包视觉模型,不经过 worker。"""
|
||||
import uuid as _uuid
|
||||
|
||||
# 三选一校验
|
||||
provided = [x for x in (image_file, image_url, image_base64) if x]
|
||||
if len(provided) != 1:
|
||||
return JSONResponse(status_code=200, content={
|
||||
"code": 1007, "message": "图片参数错误:必须且只能传 image_file / image_url / image_base64 其中一个",
|
||||
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
|
||||
})
|
||||
|
||||
img_bytes = None
|
||||
if image_file:
|
||||
img_bytes = await image_file.read()
|
||||
elif image_base64:
|
||||
b64 = image_base64
|
||||
if "," in b64:
|
||||
b64 = b64.split(",", 1)[1]
|
||||
try:
|
||||
img_bytes = base64.b64decode(b64)
|
||||
except Exception:
|
||||
return JSONResponse(status_code=200, content={
|
||||
"code": 1008, "message": "图片格式不支持(base64 解码失败)",
|
||||
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
|
||||
})
|
||||
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from face_features import analyze_features
|
||||
|
||||
try:
|
||||
feats = await run_in_threadpool(analyze_features, img_bytes, image_url)
|
||||
except Exception as ex:
|
||||
logger.exception("接口4 豆包调用失败")
|
||||
return JSONResponse(status_code=200, content={
|
||||
"code": 1007, "message": f"分析服务异常:{ex}",
|
||||
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
|
||||
})
|
||||
|
||||
if feats is None:
|
||||
return JSONResponse(status_code=200, content={
|
||||
"code": 1001, "message": "无法识别人像",
|
||||
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
|
||||
})
|
||||
|
||||
return JSONResponse(status_code=200, content={
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"request_id": f"gw-{_uuid.uuid4().hex[:8]}",
|
||||
"data": {"features": json.dumps(feats, ensure_ascii=False)},
|
||||
})
|
||||
|
||||
|
||||
@app.post("/api/v1/hairline/generate", tags=["人脸分析"])
|
||||
async def hairline_generate(request: Request):
|
||||
"""接口5:发际线PNG生成"""
|
||||
return await _proxy(request, "/api/v1/hairline/generate")
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
|
||||
@app.post("/api/v1/head/mask", tags=["人脸分析"])
|
||||
async def head_mask(request: Request):
|
||||
"""接口9:头发遮罩生成 + 分步可视化"""
|
||||
return await _proxy(request, "/api/v1/head/mask")
|
||||
|
||||
|
||||
@app.post("/api/v1/head/band", tags=["人脸分析"])
|
||||
async def head_band(request: Request):
|
||||
"""接口10:头部外缘膨胀带遮罩 + 分步可视化"""
|
||||
return await _proxy(request, "/api/v1/head/band")
|
||||
|
||||
@@ -13,10 +13,11 @@
|
||||
"unhealthy_threshold": 2,
|
||||
"healthy_threshold": 1
|
||||
},
|
||||
"ark_api_key": "",
|
||||
"dispatch": {
|
||||
"per_worker_concurrency": 1,
|
||||
"queue_wait_seconds": 30,
|
||||
"request_timeout_seconds": 60,
|
||||
"request_timeout_seconds": 600,
|
||||
"retry_on_failure": true,
|
||||
"max_retries": 1
|
||||
},
|
||||
|
||||
@@ -25,7 +25,7 @@ DEFAULTS = {
|
||||
"dispatch": {
|
||||
"per_worker_concurrency": 1,
|
||||
"queue_wait_seconds": 30,
|
||||
"request_timeout_seconds": 60,
|
||||
"request_timeout_seconds": 600,
|
||||
"retry_on_failure": True,
|
||||
"max_retries": 1,
|
||||
},
|
||||
@@ -33,6 +33,13 @@ DEFAULTS = {
|
||||
"interval_minutes": 60,
|
||||
"max_age_hours": 24,
|
||||
},
|
||||
"request_log": {
|
||||
"enabled": True,
|
||||
"log_file": "gateway/request_log.jsonl",
|
||||
"buffer_size": 2000,
|
||||
"max_file_lines": 10000,
|
||||
"max_file_age_days": 7,
|
||||
},
|
||||
}
|
||||
|
||||
_config_cache = None
|
||||
|
||||
@@ -102,8 +102,9 @@ def rewrite_base64_to_url(
|
||||
if key.endswith("_base64") and isinstance(value, str):
|
||||
img_bytes = _decode_base64_value(value)
|
||||
if img_bytes is not None:
|
||||
# 生成文件名并落盘
|
||||
filename = f"{uuid.uuid4().hex}.png"
|
||||
# 按内容嗅探扩展名:PNG(接口1标注图,含透明) / JPEG(接口2/3/5 照片)
|
||||
ext = "png" if img_bytes[:8] == b"\x89PNG\r\n\x1a\n" else "jpg"
|
||||
filename = f"{uuid.uuid4().hex}.{ext}"
|
||||
filepath = Path(static_dir) / filename
|
||||
filepath.write_bytes(img_bytes)
|
||||
|
||||
@@ -161,6 +162,8 @@ async def proxy_request(request: Request, path: str) -> JSONResponse:
|
||||
worker = None
|
||||
try:
|
||||
worker = await acquire_worker(cfg)
|
||||
# 记录当前使用的 worker,供日志中间件读取
|
||||
request.state.worker_url = worker.url
|
||||
except NoWorkerAvailable:
|
||||
logger.warning("无可用 worker,返回 1007")
|
||||
return JSONResponse(
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
"""请求日志中间件:为每个请求记录时间、路径、耗时等,并提供统计查询。
|
||||
|
||||
- 内存环形缓冲区(最近 N 条)
|
||||
- JSON Lines 文件持久化(自动轮转)
|
||||
- ASGI 中间件透明捕获请求/响应
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger("gateway.logging_middleware")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 数据结构
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestLogEntry:
|
||||
"""单条请求日志。"""
|
||||
timestamp: str # ISO-8601
|
||||
method: str
|
||||
path: str
|
||||
status_code: int
|
||||
duration_ms: float
|
||||
client_ip: str
|
||||
worker: str = "" # 处理请求的 worker URL(空串表示网关本地处理)
|
||||
response_code: Optional[int] = None # 响应 JSON 中的 code 字段
|
||||
request_id: Optional[str] = None # 响应 JSON 中的 request_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 环形缓冲区
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RingBuffer:
|
||||
"""固定大小的环形缓冲区,线程安全。"""
|
||||
|
||||
def __init__(self, maxlen: int = 2000):
|
||||
self._deque: deque = deque(maxlen=maxlen)
|
||||
|
||||
def append(self, entry: RequestLogEntry) -> None:
|
||||
self._deque.append(entry)
|
||||
|
||||
def snapshot(self) -> List[RequestLogEntry]:
|
||||
"""返回当前缓冲区副本(最新在前)。"""
|
||||
return list(reversed(self._deque))
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._deque)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JSON Lines 文件写入(含轮转)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class LogFileWriter:
|
||||
"""追加写入 JSON Lines 日志文件,自动按行数 / 天数轮转。
|
||||
|
||||
轮转策略:保留 1 个备份 (.jsonl.1),不保留更多历史。
|
||||
"""
|
||||
|
||||
def __init__(self, filepath: str, max_lines: int = 10000, max_age_days: int = 7):
|
||||
self.filepath = Path(filepath)
|
||||
self.max_lines = max_lines
|
||||
self.max_age_seconds = max_age_days * 86400
|
||||
|
||||
def write(self, entry: RequestLogEntry) -> None:
|
||||
try:
|
||||
self._maybe_rotate()
|
||||
self.filepath.parent.mkdir(parents=True, exist_ok=True)
|
||||
line = json.dumps(asdict(entry), ensure_ascii=False) + "\n"
|
||||
with open(self.filepath, "a", encoding="utf-8") as f:
|
||||
f.write(line)
|
||||
except Exception:
|
||||
logger.warning("写入请求日志失败", exc_info=True)
|
||||
|
||||
def _maybe_rotate(self) -> None:
|
||||
if not self.filepath.exists():
|
||||
return
|
||||
|
||||
# 按天数轮转
|
||||
mtime = self.filepath.stat().st_mtime
|
||||
if time.time() - mtime > self.max_age_seconds:
|
||||
self._rotate()
|
||||
return
|
||||
|
||||
# 按行数轮转
|
||||
try:
|
||||
with open(self.filepath, "r", encoding="utf-8") as f:
|
||||
count = sum(1 for _ in f)
|
||||
if count >= self.max_lines:
|
||||
self._rotate()
|
||||
except Exception:
|
||||
pass # 读不到就算了,下次再说
|
||||
|
||||
def _rotate(self) -> None:
|
||||
backup = self.filepath.with_suffix(".jsonl.1")
|
||||
if backup.exists():
|
||||
backup.unlink()
|
||||
try:
|
||||
self.filepath.rename(backup)
|
||||
logger.info("请求日志已轮转: %s → %s", self.filepath.name, backup.name)
|
||||
except Exception:
|
||||
logger.warning("日志轮转失败", exc_info=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 模块级全局状态(由 init_logging 初始化)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_buffer: Optional[RingBuffer] = None
|
||||
_writer: Optional[LogFileWriter] = None
|
||||
|
||||
|
||||
def init_logging(cfg: dict) -> None:
|
||||
"""初始化日志缓冲区与文件写入器。"""
|
||||
global _buffer, _writer
|
||||
|
||||
log_cfg = cfg.get("request_log", {})
|
||||
if not log_cfg.get("enabled", True):
|
||||
logger.info("请求日志已禁用")
|
||||
return
|
||||
|
||||
log_file = log_cfg.get("log_file", "gateway/request_log.jsonl")
|
||||
log_path = Path(log_file)
|
||||
if not log_path.is_absolute():
|
||||
log_path = Path(__file__).resolve().parent.parent / log_file
|
||||
|
||||
_buffer = RingBuffer(maxlen=log_cfg.get("buffer_size", 2000))
|
||||
_writer = LogFileWriter(
|
||||
filepath=str(log_path),
|
||||
max_lines=log_cfg.get("max_file_lines", 10000),
|
||||
max_age_days=log_cfg.get("max_file_age_days", 7),
|
||||
)
|
||||
|
||||
# 从历史日志文件加载最近 N 条到缓冲区
|
||||
buffer_size = log_cfg.get("buffer_size", 2000)
|
||||
loaded = _load_from_logfile(str(log_path), buffer_size)
|
||||
if loaded > 0:
|
||||
logger.info("从日志文件恢复 %d 条历史记录", loaded)
|
||||
|
||||
logger.info("请求日志已启用 | 缓冲=%d | 文件=%s",
|
||||
buffer_size, log_path)
|
||||
|
||||
|
||||
def _load_from_logfile(filepath: str, max_entries: int) -> int:
|
||||
"""从 JSON Lines 日志文件读取最近 max_entries 条到缓冲区。"""
|
||||
try:
|
||||
p = Path(filepath)
|
||||
if not p.exists():
|
||||
return 0
|
||||
|
||||
# 从文件末尾反向读取(高效处理大文件)
|
||||
with open(p, "rb") as f:
|
||||
# 估算:每条约 200 bytes,读最后 max_entries * 250 bytes 足够
|
||||
chunk_size = max_entries * 250
|
||||
f.seek(0, 2) # 文件末尾
|
||||
file_size = f.tell()
|
||||
read_size = min(chunk_size, file_size)
|
||||
f.seek(max(0, file_size - read_size))
|
||||
raw = f.read().decode("utf-8", errors="replace")
|
||||
|
||||
# 跳过可能不完整的第一行
|
||||
lines = raw.split("\n")
|
||||
if file_size > read_size:
|
||||
# 第一行可能不完整,跳过
|
||||
lines = lines[1:]
|
||||
# 去掉末尾空行
|
||||
lines = [l for l in lines if l.strip()]
|
||||
|
||||
# 只取最后 max_entries 条
|
||||
lines = lines[-max_entries:]
|
||||
|
||||
count = 0
|
||||
for line in lines:
|
||||
try:
|
||||
data = json.loads(line)
|
||||
entry = RequestLogEntry(
|
||||
timestamp=data.get("timestamp", ""),
|
||||
method=data.get("method", ""),
|
||||
path=data.get("path", ""),
|
||||
status_code=data.get("status_code", 0),
|
||||
duration_ms=data.get("duration_ms", 0.0),
|
||||
client_ip=data.get("client_ip", ""),
|
||||
worker=data.get("worker", ""),
|
||||
response_code=data.get("response_code"),
|
||||
request_id=data.get("request_id"),
|
||||
)
|
||||
_buffer.append(entry)
|
||||
count += 1
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
continue
|
||||
|
||||
return count
|
||||
except Exception:
|
||||
logger.warning("从日志文件恢复历史记录失败", exc_info=True)
|
||||
return 0
|
||||
|
||||
|
||||
def is_initialized() -> bool:
|
||||
return _buffer is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ASGI 中间件
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _should_log(path: str) -> bool:
|
||||
"""只记录 API 请求(/api/ 路径),跳过静态文件、健康检查等。"""
|
||||
return path.startswith("/api/")
|
||||
|
||||
|
||||
async def request_logging_middleware(request, call_next):
|
||||
"""记录每个请求的耗时、状态码等信息。"""
|
||||
|
||||
# 未初始化或不需要记录的路径 → 直接放行
|
||||
if _buffer is None or not _should_log(request.url.path):
|
||||
return await call_next(request)
|
||||
|
||||
start = time.perf_counter()
|
||||
|
||||
# 获取客户端 IP(优先级:X-Forwarded-For > X-Real-IP > client.host)
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
client_ip = forwarded.split(",")[0].strip()
|
||||
else:
|
||||
real_ip = request.headers.get("x-real-ip")
|
||||
if real_ip:
|
||||
client_ip = real_ip.strip()
|
||||
|
||||
response = await call_next(request)
|
||||
duration_ms = round((time.perf_counter() - start) * 1000, 2)
|
||||
|
||||
# 读取 worker URL(由 forward.py 在转发时写入 request.state)
|
||||
worker_url = getattr(request.state, "worker_url", None) or ""
|
||||
|
||||
# 提取响应 body 并解析业务字段(仅 JSON 响应)
|
||||
response_code = None
|
||||
request_id = None
|
||||
content_type = response.headers.get("content-type", "")
|
||||
|
||||
if "application/json" in content_type or "application/json" in (response.media_type or ""):
|
||||
# 读取 body(兼容 body_iterator 和 body 两种属性)
|
||||
body = getattr(response, "body", None)
|
||||
if body is None:
|
||||
body = b""
|
||||
async for chunk in response.body_iterator:
|
||||
body += chunk
|
||||
try:
|
||||
data = json.loads(body)
|
||||
response_code = data.get("code")
|
||||
request_id = data.get("request_id")
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
pass
|
||||
|
||||
# 如果读取了 body_iterator,需要重建响应
|
||||
if not hasattr(response, "body") or response.body is None:
|
||||
from starlette.responses import Response as StarletteResponse
|
||||
response = StarletteResponse(
|
||||
content=body,
|
||||
status_code=response.status_code,
|
||||
headers=dict(response.headers),
|
||||
media_type=response.media_type,
|
||||
)
|
||||
|
||||
# 记录
|
||||
now = datetime.datetime.utcnow()
|
||||
entry = RequestLogEntry(
|
||||
timestamp=now.strftime("%Y-%m-%dT%H:%M:%S.") +
|
||||
f"{now.microsecond // 1000:03d}Z",
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
status_code=response.status_code,
|
||||
duration_ms=duration_ms,
|
||||
client_ip=client_ip,
|
||||
worker=worker_url,
|
||||
response_code=response_code,
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
_buffer.append(entry)
|
||||
if _writer is not None:
|
||||
_writer.write(entry)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 统计查询
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_stats() -> Dict[str, Any]:
|
||||
"""基于缓冲区数据计算统计摘要,返回给统计页面使用。"""
|
||||
if _buffer is None:
|
||||
return {
|
||||
"summary": {"total": 0, "success_rate": 0, "avg_duration_ms": 0,
|
||||
"min_duration_ms": 0, "max_duration_ms": 0},
|
||||
"endpoints": [],
|
||||
"workers": [],
|
||||
"recent": [],
|
||||
"last_updated": datetime.datetime.utcnow().isoformat() + "Z",
|
||||
}
|
||||
|
||||
snapshot = _buffer.snapshot()
|
||||
total = len(snapshot)
|
||||
|
||||
if total == 0:
|
||||
return {
|
||||
"summary": {"total": 0, "success_rate": 0, "avg_duration_ms": 0,
|
||||
"min_duration_ms": 0, "max_duration_ms": 0},
|
||||
"endpoints": [],
|
||||
"workers": [],
|
||||
"recent": [],
|
||||
"last_updated": datetime.datetime.utcnow().isoformat() + "Z",
|
||||
}
|
||||
|
||||
# 汇总指标
|
||||
durations = [e.duration_ms for e in snapshot]
|
||||
ok_count = sum(1 for e in snapshot if e.response_code == 0)
|
||||
|
||||
# 按路径聚合
|
||||
by_path: Dict[str, dict] = {}
|
||||
for e in snapshot:
|
||||
path = e.path
|
||||
if path not in by_path:
|
||||
by_path[path] = {"count": 0, "total_duration": 0.0, "ok": 0}
|
||||
by_path[path]["count"] += 1
|
||||
by_path[path]["total_duration"] += e.duration_ms
|
||||
if e.response_code == 0:
|
||||
by_path[path]["ok"] += 1
|
||||
|
||||
endpoints = sorted(
|
||||
({
|
||||
"path": path,
|
||||
"count": v["count"],
|
||||
"avg_duration_ms": round(v["total_duration"] / v["count"], 1),
|
||||
"max_duration_ms": round(
|
||||
max(e.duration_ms for e in snapshot if e.path == path), 1),
|
||||
"success_rate": round(v["ok"] / v["count"] * 100, 1),
|
||||
} for path, v in by_path.items()),
|
||||
key=lambda x: -x["count"],
|
||||
)
|
||||
|
||||
# 最近 100 条(最新在前)
|
||||
recent_100 = snapshot[:100]
|
||||
recent = [
|
||||
{
|
||||
"timestamp": e.timestamp,
|
||||
"method": e.method,
|
||||
"path": e.path,
|
||||
"status_code": e.status_code,
|
||||
"duration_ms": e.duration_ms,
|
||||
"client_ip": e.client_ip,
|
||||
"worker": e.worker,
|
||||
"response_code": e.response_code,
|
||||
"request_id": e.request_id,
|
||||
}
|
||||
for e in recent_100
|
||||
]
|
||||
|
||||
# 按 worker 聚合
|
||||
by_worker: Dict[str, dict] = {}
|
||||
for e in snapshot:
|
||||
w = e.worker or "(网关本地)"
|
||||
if w not in by_worker:
|
||||
by_worker[w] = {"count": 0, "total_duration": 0.0, "ok": 0}
|
||||
by_worker[w]["count"] += 1
|
||||
by_worker[w]["total_duration"] += e.duration_ms
|
||||
if e.response_code == 0:
|
||||
by_worker[w]["ok"] += 1
|
||||
|
||||
workers = sorted(
|
||||
({
|
||||
"worker": w,
|
||||
"count": v["count"],
|
||||
"avg_duration_ms": round(v["total_duration"] / v["count"], 1),
|
||||
"success_rate": round(v["ok"] / v["count"] * 100, 1) if v["count"] else 0,
|
||||
} for w, v in by_worker.items()),
|
||||
key=lambda x: -x["count"],
|
||||
)
|
||||
|
||||
return {
|
||||
"summary": {
|
||||
"total": total,
|
||||
"success_rate": round(ok_count / total * 100, 1),
|
||||
"avg_duration_ms": round(sum(durations) / len(durations), 1),
|
||||
"min_duration_ms": round(min(durations), 1),
|
||||
"max_duration_ms": round(max(durations), 1),
|
||||
},
|
||||
"endpoints": endpoints,
|
||||
"workers": workers,
|
||||
"recent": recent,
|
||||
"last_updated": datetime.datetime.utcnow().isoformat() + "Z",
|
||||
}
|
||||
@@ -223,15 +223,24 @@ def create_app(port: int, password: str) -> FastAPI:
|
||||
if x_internal_token != password:
|
||||
return JSONResponse(status_code=401, content={"detail": "unauthorized"})
|
||||
delay = float(request.query_params.get("delay", "1"))
|
||||
_png = f"data:image/png;base64,{TINY_PNG_BASE64}"
|
||||
data = {
|
||||
"hairline_images": [
|
||||
{
|
||||
"image_base64": f"data:image/png;base64,{TINY_PNG_BASE64}",
|
||||
"hairline_type": "ellipse",
|
||||
"image_middle_base64": _png,
|
||||
"image_high_base64": _png,
|
||||
"image_low_base64": _png,
|
||||
"grown_image_base64": _png,
|
||||
"order": 1,
|
||||
},
|
||||
{
|
||||
"image_base64": f"data:image/png;base64,{TINY_PNG_BASE64}",
|
||||
"order": 2,
|
||||
"hairline_type": "heart",
|
||||
"image_middle_base64": _png,
|
||||
"image_high_base64": _png,
|
||||
"image_low_base64": _png,
|
||||
"grown_image_base64": None,
|
||||
"order": 3,
|
||||
},
|
||||
],
|
||||
"best_hairline_center_point": {"x": 540, "y": 430},
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
[Unit]
|
||||
Description=Hair Worker (GPU) - 四庭七眼测量 接口1
|
||||
After=network.target
|
||||
Description=hair GPU worker FastAPI (0.0.0.0:8187)
|
||||
After=network-online.target comfyui.service change_hair-hair.service
|
||||
Wants=comfyui.service change_hair-hair.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=xsl
|
||||
WorkingDirectory=/home/xsl/hair
|
||||
# 鉴权密码:优先 worker_config.json;也可在此用环境变量覆盖
|
||||
# Environment=WORKER_ACCEPT_PASSWORDS=your-strong-secret
|
||||
# 分辨率门槛(可选,默认 600/800)
|
||||
# Environment=MIN_SHORT_SIDE=600
|
||||
# Environment=MIN_LONG_SIDE=800
|
||||
ExecStart=/home/xsl/hair/venv/bin/uvicorn app:app --host 0.0.0.0 --port 8187
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
User=ubuntu
|
||||
WorkingDirectory=/home/ubuntu/hair
|
||||
ExecStart=/home/ubuntu/hair/venv/bin/uvicorn app:app --host 0.0.0.0 --port 8187
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
{
|
||||
"1": {
|
||||
"inputs": {
|
||||
"scheduler": "simple",
|
||||
"steps": 6,
|
||||
"denoise": 1,
|
||||
"model": [
|
||||
"2",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "BasicScheduler",
|
||||
"_meta": {
|
||||
"title": "基本调度器"
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"inputs": {
|
||||
"max_shift": 1.15,
|
||||
"base_shift": 0.5,
|
||||
"width": [
|
||||
"14",
|
||||
0
|
||||
],
|
||||
"height": [
|
||||
"14",
|
||||
1
|
||||
],
|
||||
"model": [
|
||||
"16",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ModelSamplingFlux",
|
||||
"_meta": {
|
||||
"title": "采样算法(Flux)"
|
||||
}
|
||||
},
|
||||
"3": {
|
||||
"inputs": {
|
||||
"vae_name": "flux2-vae.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "加载VAE"
|
||||
}
|
||||
},
|
||||
"5": {
|
||||
"inputs": {
|
||||
"conditioning": [
|
||||
"19",
|
||||
0
|
||||
],
|
||||
"latent": [
|
||||
"13",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ReferenceLatent",
|
||||
"_meta": {
|
||||
"title": "参考Latent"
|
||||
}
|
||||
},
|
||||
"6": {
|
||||
"inputs": {
|
||||
"noise_seed": 808990860769642
|
||||
},
|
||||
"class_type": "RandomNoise",
|
||||
"_meta": {
|
||||
"title": "随机噪波"
|
||||
}
|
||||
},
|
||||
"7": {
|
||||
"inputs": {
|
||||
"width": [
|
||||
"14",
|
||||
0
|
||||
],
|
||||
"height": [
|
||||
"14",
|
||||
1
|
||||
],
|
||||
"batch_size": 1
|
||||
},
|
||||
"class_type": "EmptySD3LatentImage",
|
||||
"_meta": {
|
||||
"title": "空Latent图像(SD3)"
|
||||
}
|
||||
},
|
||||
"8": {
|
||||
"inputs": {
|
||||
"sampler_name": "euler"
|
||||
},
|
||||
"class_type": "KSamplerSelect",
|
||||
"_meta": {
|
||||
"title": "K采样器选择"
|
||||
}
|
||||
},
|
||||
"9": {
|
||||
"inputs": {
|
||||
"noise": [
|
||||
"6",
|
||||
0
|
||||
],
|
||||
"guider": [
|
||||
"20",
|
||||
0
|
||||
],
|
||||
"sampler": [
|
||||
"8",
|
||||
0
|
||||
],
|
||||
"sigmas": [
|
||||
"1",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"7",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"_meta": {
|
||||
"title": "自定义采样器(高级)"
|
||||
}
|
||||
},
|
||||
"10": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"9",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"3",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": {
|
||||
"title": "VAE解码"
|
||||
}
|
||||
},
|
||||
"13": {
|
||||
"inputs": {
|
||||
"pixels": [
|
||||
"44",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"3",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEEncode",
|
||||
"_meta": {
|
||||
"title": "VAE编码"
|
||||
}
|
||||
},
|
||||
"14": {
|
||||
"inputs": {
|
||||
"image": [
|
||||
"44",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "GetImageSize+",
|
||||
"_meta": {
|
||||
"title": "🔧 Get Image Size"
|
||||
}
|
||||
},
|
||||
"16": {
|
||||
"inputs": {
|
||||
"unet_name": "flux2.0/flux-2-klein-9b-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-1784045785080.png [input]"
|
||||
},
|
||||
"class_type": "LoadImage",
|
||||
"_meta": {
|
||||
"title": "加载图像"
|
||||
}
|
||||
},
|
||||
"31": {
|
||||
"inputs": {
|
||||
"image": [
|
||||
"26",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "easy imageSize",
|
||||
"_meta": {
|
||||
"title": "图像尺寸"
|
||||
}
|
||||
},
|
||||
"32": {
|
||||
"inputs": {
|
||||
"aspect_ratio": "custom",
|
||||
"proportional_width": [
|
||||
"31",
|
||||
0
|
||||
],
|
||||
"proportional_height": [
|
||||
"31",
|
||||
1
|
||||
],
|
||||
"fit": "letterbox",
|
||||
"method": "lanczos",
|
||||
"round_to_multiple": "8",
|
||||
"scale_to_side": "None",
|
||||
"scale_to_length": 1024,
|
||||
"background_color": "#000000",
|
||||
"image": [
|
||||
"26",
|
||||
0
|
||||
],
|
||||
"mask": [
|
||||
"37",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "LayerUtility: ImageScaleByAspectRatio V2",
|
||||
"_meta": {
|
||||
"title": "LayerUtility: ImageScaleByAspectRatio V2"
|
||||
}
|
||||
},
|
||||
"33": {
|
||||
"inputs": {
|
||||
"masks": [
|
||||
"26",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "Mask Fill Holes",
|
||||
"_meta": {
|
||||
"title": "遮罩填充漏洞"
|
||||
}
|
||||
},
|
||||
"36": {
|
||||
"inputs": {
|
||||
"masks": [
|
||||
"33",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "Convert Masks to Images",
|
||||
"_meta": {
|
||||
"title": "遮罩到图像"
|
||||
}
|
||||
},
|
||||
"37": {
|
||||
"inputs": {
|
||||
"method": "intensity",
|
||||
"image": [
|
||||
"39",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "Image To Mask",
|
||||
"_meta": {
|
||||
"title": "图像到遮罩"
|
||||
}
|
||||
},
|
||||
"39": {
|
||||
"inputs": {
|
||||
"upscale_method": "nearest-exact",
|
||||
"width": [
|
||||
"31",
|
||||
0
|
||||
],
|
||||
"height": [
|
||||
"31",
|
||||
1
|
||||
],
|
||||
"crop": "disabled",
|
||||
"image": [
|
||||
"36",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ImageScale",
|
||||
"_meta": {
|
||||
"title": "缩放图像"
|
||||
}
|
||||
},
|
||||
"44": {
|
||||
"inputs": {
|
||||
"mask_opacity": 1,
|
||||
"mask_color": "FFFF00",
|
||||
"pass_through": true,
|
||||
"image": [
|
||||
"32",
|
||||
0
|
||||
],
|
||||
"mask": [
|
||||
"32",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "ImageAndMaskPreview",
|
||||
"_meta": {
|
||||
"title": "图像与遮罩预览"
|
||||
}
|
||||
},
|
||||
"45": {
|
||||
"inputs": {
|
||||
"images": [
|
||||
"44",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "PreviewImage",
|
||||
"_meta": {
|
||||
"title": "预览图像"
|
||||
}
|
||||
},
|
||||
"53": {
|
||||
"inputs": {
|
||||
"rgthree_comparer": {
|
||||
"images": [
|
||||
{
|
||||
"name": "A",
|
||||
"selected": true,
|
||||
"url": "/api/view?filename=rgthree.compare._temp_kzrpg_00019_.png&type=temp&subfolder=&rand=0.8964945384546902"
|
||||
},
|
||||
{
|
||||
"name": "B",
|
||||
"selected": true,
|
||||
"url": "/api/view?filename=rgthree.compare._temp_kzrpg_00020_.png&type=temp&subfolder=&rand=0.6762414189274947"
|
||||
}
|
||||
]
|
||||
},
|
||||
"image_a": [
|
||||
"62",
|
||||
0
|
||||
],
|
||||
"image_b": [
|
||||
"26",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "Image Comparer (rgthree)",
|
||||
"_meta": {
|
||||
"title": "图像对比"
|
||||
}
|
||||
},
|
||||
"60": {
|
||||
"inputs": {
|
||||
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
||||
},
|
||||
"class_type": "JjkText",
|
||||
"_meta": {
|
||||
"title": "Text"
|
||||
}
|
||||
},
|
||||
"61": {
|
||||
"inputs": {
|
||||
"clip_name": "qwen_3_8b_fp8mixed.safetensors",
|
||||
"type": "flux2",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": {
|
||||
"title": "加载CLIP"
|
||||
}
|
||||
},
|
||||
"62": {
|
||||
"inputs": {
|
||||
"method": "mkl",
|
||||
"strength": 1,
|
||||
"multithread": true,
|
||||
"image_ref": [
|
||||
"26",
|
||||
0
|
||||
],
|
||||
"image_target": [
|
||||
"10",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ColorMatch",
|
||||
"_meta": {
|
||||
"title": "Color Match"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
"""ComfyUI 客户端:用 add_hair.json / add_hair2.json 工作流跑生发图(Flux-2 inpaint)。
|
||||
|
||||
worker 不跑 Flux,只把「划线图 + 遮罩」的 RGBA 上传到远端 ComfyUI
|
||||
(默认 http://10.60.74.221:8188,可用环境变量 COMFYUI_URL 覆盖),
|
||||
替换工作流节点 26 的输入图、随机 seed,提交 /prompt,轮询 /history,取回 /view 输出。
|
||||
ComfyUI 若开启了 HTTP Basic Auth(user `admin` + 密码),所有请求都带凭据。
|
||||
|
||||
支持多工作流:run() 可通过 workflow_path 指定不同工作流 JSON,自动检测 SaveImage 输出节点。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
|
||||
COMFYUI_URL = os.getenv("COMFYUI_URL", "http://127.0.0.1:8188").rstrip("/")
|
||||
_WORKFLOW_DEFAULT = os.getenv(
|
||||
"ADD_HAIR_WORKFLOW",
|
||||
os.path.join(os.path.dirname(os.path.dirname(__file__)), "add_hair.json"),
|
||||
)
|
||||
COMFY_TIMEOUT = float(os.getenv("COMFYUI_TIMEOUT", "600")) # 单张出图最长等待(秒)
|
||||
_REPO = os.path.dirname(os.path.dirname(__file__))
|
||||
|
||||
_INPUT_NODE = "26" # LoadImage:外部输入图(含 alpha 遮罩)
|
||||
_SEED_NODE = "6" # RandomNoise
|
||||
_PROMPT_NODE = "60" # JjkText:提示词
|
||||
|
||||
_wf_cache: dict[str, dict] = {} # path → workflow JSON
|
||||
_wf_output_node: dict[str, str] = {} # path → SaveImage 节点 ID
|
||||
|
||||
|
||||
def _comfy_auth():
|
||||
"""ComfyUI Basic Auth 凭据 (user, password)。
|
||||
|
||||
user:环境变量 COMFYUI_USER,默认 admin。
|
||||
password:环境变量 COMFYUI_PASSWORD → worker_config.json.comfyui_password → password.txt。
|
||||
无密码则返回 None(不带鉴权,兼容未开启 auth 的实例)。
|
||||
"""
|
||||
user = os.getenv("COMFYUI_USER", "admin")
|
||||
pw = os.getenv("COMFYUI_PASSWORD")
|
||||
if not pw:
|
||||
cfg = os.path.join(_REPO, "worker_config.json")
|
||||
if os.path.isfile(cfg):
|
||||
try:
|
||||
with open(cfg, encoding="utf-8") as f:
|
||||
pw = json.load(f).get("comfyui_password")
|
||||
except Exception: # noqa: BLE001
|
||||
pw = None
|
||||
if not pw:
|
||||
pwfile = os.path.join(_REPO, "password.txt")
|
||||
if os.path.isfile(pwfile):
|
||||
with open(pwfile, encoding="utf-8") as f:
|
||||
pw = f.read().strip()
|
||||
return (user, pw) if pw else None
|
||||
|
||||
|
||||
def _load_workflow(workflow_path: str | None = None) -> dict:
|
||||
"""加载工作流 JSON(按路径缓存)。自动检测 SaveImage 节点 ID。"""
|
||||
path = workflow_path or _WORKFLOW_DEFAULT
|
||||
if path not in _wf_cache:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
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 _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, 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)
|
||||
client_id = uuid.uuid4().hex
|
||||
with httpx.Client(base_url=COMFYUI_URL, timeout=30.0, auth=_comfy_auth()) as cli:
|
||||
# 1. 上传输入图(含 alpha 遮罩)到 ComfyUI input 目录
|
||||
fname = f"hair_{client_id}.png"
|
||||
r = cli.post("/upload/image", files={"image": (fname, rgba_png_bytes, "image/png")},
|
||||
data={"overwrite": "true", "type": "input"})
|
||||
r.raise_for_status()
|
||||
up = r.json()
|
||||
name = (up.get("subfolder") + "/" if up.get("subfolder") else "") + up["name"]
|
||||
|
||||
# 2. 改工作流:节点26 输入图 + 随机 seed
|
||||
try:
|
||||
import io as _io
|
||||
from PIL import Image as _Img
|
||||
_sz = _Img.open(_io.BytesIO(rgba_png_bytes)).size
|
||||
logging.getLogger("hair.worker").info(
|
||||
"ComfyUI 输入尺寸 %dx%d workflow=%s", _sz[0], _sz[1], os.path.basename(path))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
wf = copy.deepcopy(_load_workflow(path))
|
||||
wf[_INPUT_NODE]["inputs"]["image"] = name
|
||||
wf[_SEED_NODE]["inputs"]["noise_seed"] = random.randint(0, 2**63 - 1)
|
||||
if prompt is not None:
|
||||
wf[_PROMPT_NODE]["inputs"]["text"] = prompt
|
||||
|
||||
# 诊断:落盘实际提交的工作流 + 输入图,便于和手动 ComfyUI 跑的对比
|
||||
try:
|
||||
import os as _os
|
||||
_diag = _os.path.join(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))),
|
||||
"log", "comfyui_last_submit")
|
||||
_os.makedirs(_diag, exist_ok=True)
|
||||
with open(_os.path.join(_diag, "workflow.json"), "w", encoding="utf-8") as _f:
|
||||
json.dump(wf, _f, ensure_ascii=False, indent=2)
|
||||
with open(_os.path.join(_diag, "input.png"), "wb") as _f:
|
||||
_f.write(rgba_png_bytes)
|
||||
with open(_os.path.join(_diag, "prompt.txt"), "w", encoding="utf-8") as _f:
|
||||
_f.write(prompt if prompt is not None else "(None=用工作流内置默认)")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
# 3. 提交(front=True 时插队到队列最前)
|
||||
payload = {"prompt": wf, "client_id": client_id}
|
||||
if front:
|
||||
payload["front"] = True
|
||||
r = cli.post("/prompt", json=payload)
|
||||
r.raise_for_status()
|
||||
prompt_id = r.json()["prompt_id"]
|
||||
|
||||
# 4. 轮询 /history
|
||||
deadline = time.time() + timeout
|
||||
outputs = None
|
||||
while time.time() < deadline:
|
||||
hr = cli.get(f"/history/{prompt_id}")
|
||||
hr.raise_for_status()
|
||||
hist = hr.json()
|
||||
if prompt_id in hist:
|
||||
entry = hist[prompt_id]
|
||||
status = entry.get("status", {})
|
||||
if status.get("status_str") == "error":
|
||||
raise RuntimeError(f"ComfyUI 执行报错: {status}")
|
||||
outputs = entry.get("outputs")
|
||||
if outputs and output_node in outputs:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
if not outputs or output_node not in outputs:
|
||||
raise TimeoutError(f"ComfyUI 出图超时({timeout}s) prompt_id={prompt_id}")
|
||||
|
||||
# 5. 取回输出图
|
||||
imgs = outputs[output_node].get("images") or []
|
||||
if not imgs:
|
||||
raise RuntimeError("ComfyUI 输出无图像")
|
||||
info = imgs[0]
|
||||
vr = cli.get("/view", params={"filename": info["filename"],
|
||||
"subfolder": info.get("subfolder", ""),
|
||||
"type": info.get("type", "output")})
|
||||
vr.raise_for_status()
|
||||
return vr.content
|
||||
|
||||
|
||||
def ping() -> bool:
|
||||
"""探测 ComfyUI 是否在线(/system_stats)。"""
|
||||
try:
|
||||
with httpx.Client(base_url=COMFYUI_URL, timeout=3.0, auth=_comfy_auth()) as cli:
|
||||
return cli.get("/system_stats").status_code == 200
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
inp = sys.argv[1] if len(sys.argv) > 1 else "tests/output/comfy_input.png"
|
||||
print("ComfyUI:", COMFYUI_URL, "online:", ping())
|
||||
with open(inp, "rb") as f:
|
||||
png = run(f.read())
|
||||
out = "tests/output/grown.png"
|
||||
with open(out, "wb") as f:
|
||||
f.write(png)
|
||||
print(f"生发图已存 {out}({len(png)} bytes)")
|
||||
@@ -162,4 +162,9 @@ PARSE_NECK_L = 16
|
||||
PARSE_NECK = 17
|
||||
PARSE_CLOTH = 18
|
||||
|
||||
HF_FACE_PARSER_MODEL = "jonathandinu/face-parsing"
|
||||
# 内网/离线:指向本地权重目录(transformers from_pretrained 支持本地路径)。
|
||||
# 在线 id 为 "jonathandinu/face-parsing",权重已放到 hairline/models/face-parsing/。
|
||||
import os as _os
|
||||
HF_FACE_PARSER_MODEL = _os.path.join(
|
||||
_os.path.dirname(_os.path.abspath(__file__)), "models", "face-parsing"
|
||||
)
|
||||
|
||||
@@ -11,8 +11,9 @@ from __future__ import annotations
|
||||
import os
|
||||
import numpy as np
|
||||
|
||||
# 模型在 hairline/models/ 下(模块即在 hairline/ 根),故只取一层 dirname。
|
||||
DEFAULT_MODEL_PATH = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
os.path.dirname(os.path.abspath(__file__)),
|
||||
"models", "face_landmarker.task",
|
||||
)
|
||||
|
||||
|
||||
@@ -186,6 +186,53 @@ def smooth_hairline_corner_aware(
|
||||
return out
|
||||
|
||||
|
||||
def clamp_hairline_to_silhouette(
|
||||
hairline_norm: np.ndarray,
|
||||
parse_map: np.ndarray,
|
||||
margin_px: float = 2.0,
|
||||
) -> np.ndarray:
|
||||
"""把发际线点的 y 钳制在 (skin∪hair) silhouette 上沿之下(不含 margin 以上)。
|
||||
|
||||
根因(见 issue:男性 ellipse 发际线贴到头部外面):`sample_hairline` 对射线
|
||||
未命中 hair 像素的锚点会 fallback 成「锚点 + 固定 0.18 归一化偏移」,与头部实际
|
||||
大小/位置无关 —— 短发/剃光头场景下这个偏移量常常把点顶到头部轮廓外面的背景里,
|
||||
在有效/失效锚点交界处形成尖角,被贴图上的不透明像素蒙到就会露出戳出头部的线条。
|
||||
|
||||
本函数在几何检测之后追加一步「安全网」:对每个点按其 x 所在列,取 silhouette
|
||||
(SegFormer skin∪hair 类,近似头部实际轮廓)上沿 y,若点比这个上沿还高(y 更
|
||||
小),直接钳制到 上沿 + margin_px —— 保证曲线永远不会跑到头部轮廓外面的背景。
|
||||
"""
|
||||
h, w = parse_map.shape
|
||||
cols_with_head, top_y = _head_top_y_per_column(parse_map, use_full_hair=True)
|
||||
if cols_with_head.size == 0:
|
||||
return hairline_norm
|
||||
out = hairline_norm.copy()
|
||||
for i in range(out.shape[0]):
|
||||
x_px = float(out[i, 0]) * w
|
||||
idx = int(np.searchsorted(cols_with_head, x_px))
|
||||
idx = min(max(idx, 0), cols_with_head.size - 1)
|
||||
sil_y = float(top_y[idx]) + margin_px
|
||||
y_px = float(out[i, 1]) * h
|
||||
if y_px < sil_y:
|
||||
out[i, 1] = sil_y / h
|
||||
return out
|
||||
|
||||
|
||||
def sample_hairline_clamped(
|
||||
landmarks_norm: np.ndarray,
|
||||
parse_map: np.ndarray,
|
||||
fallback_extrapolation: float = 0.18,
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""策略 A(baseline + 头部轮廓钳制):与默认 `sample_hairline` 完全一致的检测,
|
||||
额外用 `clamp_hairline_to_silhouette` 兜底 —— 检测失效 fallback 出的点不再可能
|
||||
跑到头部外面的背景,而是贴着头部实际轮廓顶部。改动小、风险低,只在检测失效/
|
||||
fallback 越界时才生效,正常长发照片的结果与 baseline 完全一致。
|
||||
"""
|
||||
hairline, valid = sample_hairline(landmarks_norm, parse_map, fallback_extrapolation)
|
||||
hairline = clamp_hairline_to_silhouette(hairline, parse_map)
|
||||
return hairline, valid
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Alternative hairline sampling strategies.
|
||||
#
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""接口3:马克笔手绘发际线检测(黑帽响应图 + 端点锚定 Dijkstra 最小路径)。
|
||||
|
||||
源自 /home/xsl/headmark 调研结论:全局灰度阈值不可用(笔迹平均灰度反高于阈值、
|
||||
与皮肤阴影分布重叠);黑帽变换响应"比局部邻域暗的细结构",叠加 ROI + 两鬓角锚点间
|
||||
最小代价路径,对抬头纹/眉毛/发丝鲁棒。复用接口2 的 ROI(额头上部 ∩ 头部分割)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from skimage.graph import route_through_array
|
||||
|
||||
from .mask import forehead_upper_region, head_silhouette
|
||||
|
||||
# 鬓角锚点(MediaPipe canonical 索引):21 左、251 右
|
||||
ANCHOR_LEFT = 21
|
||||
ANCHOR_RIGHT = 251
|
||||
# 拒识阈值:路径平均黑帽响应低于此值 → 判"未检测到画线"(待真实图标定)
|
||||
MIN_MEAN_RESPONSE = 8.0
|
||||
|
||||
|
||||
def _blackhat(gray: np.ndarray, w: int) -> np.ndarray:
|
||||
k = max(15, int(w * 0.025) | 1)
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k))
|
||||
return cv2.morphologyEx(gray, cv2.MORPH_BLACKHAT, kernel).astype(np.float32)
|
||||
|
||||
|
||||
def _snap_anchor(bh_roi: np.ndarray, x: int, y: int, w: int):
|
||||
"""在 (x,y) 周围窗口内吸附到黑帽响应最大处,返回 (row, col)。"""
|
||||
win = max(8, int(w * 0.03))
|
||||
h, ww = bh_roi.shape
|
||||
x0, x1 = max(0, x - win), min(ww, x + win)
|
||||
y0, y1 = max(0, y - win), min(h, y + win)
|
||||
sub = bh_roi[y0:y1, x0:x1]
|
||||
if sub.size == 0 or sub.max() <= 0:
|
||||
return (int(np.clip(y, 0, h - 1)), int(np.clip(x, 0, ww - 1)))
|
||||
dy, dx = np.unravel_index(int(np.argmax(sub)), sub.shape)
|
||||
return (y0 + dy, x0 + dx)
|
||||
|
||||
|
||||
def detect_marker_hairline(marked_bgr: np.ndarray, landmarks_mp: np.ndarray,
|
||||
parse_map: np.ndarray, min_mean_response: float = MIN_MEAN_RESPONSE):
|
||||
"""检测手绘发际线,返回路径 (N,2) row,col;未检出/被拒识返回 None。"""
|
||||
h, w = marked_bgr.shape[:2]
|
||||
roi = cv2.bitwise_and(forehead_upper_region(landmarks_mp, w, h),
|
||||
head_silhouette(parse_map)) > 0
|
||||
if roi.sum() == 0:
|
||||
return None
|
||||
|
||||
gray = cv2.cvtColor(marked_bgr, cv2.COLOR_BGR2GRAY)
|
||||
bh = _blackhat(gray, w)
|
||||
bh_roi = bh * roi
|
||||
|
||||
al = _snap_anchor(bh_roi, int(landmarks_mp[ANCHOR_LEFT, 0] * w),
|
||||
int(landmarks_mp[ANCHOR_LEFT, 1] * h), w)
|
||||
ar = _snap_anchor(bh_roi, int(landmarks_mp[ANCHOR_RIGHT, 0] * w),
|
||||
int(landmarks_mp[ANCHOR_RIGHT, 1] * h), w)
|
||||
|
||||
cost = (bh.max() - bh) + 1.0
|
||||
cost[~roi] = 1e6 # 禁止路径走出 ROI
|
||||
path, _ = route_through_array(cost, al, ar, fully_connected=True, geometric=True)
|
||||
path = np.asarray(path)
|
||||
|
||||
# 拒识:路径平均黑帽响应过低 → 没画线(强行找出的伪路径)
|
||||
if float(bh[path[:, 0], path[:, 1]].mean()) < min_mean_response:
|
||||
return None
|
||||
return path
|
||||
|
||||
|
||||
def path_to_curve_mask(path: np.ndarray, h: int, w: int, thickness: int = 3) -> np.ndarray:
|
||||
"""把路径画成曲线 mask(uint8 0/255),用作遮罩下边界 / 重画干净线。"""
|
||||
m = np.zeros((h, w), np.uint8)
|
||||
pts = path[:, ::-1].reshape(-1, 1, 2) # (row,col)→(x,y)
|
||||
cv2.polylines(m, [pts], False, 255, thickness, lineType=cv2.LINE_AA)
|
||||
return m
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
from .service import get_landmarker, get_parser
|
||||
|
||||
path_img = sys.argv[1] if len(sys.argv) > 1 else "/home/xsl/headmark/test_image/input1.png"
|
||||
img = cv2.imread(path_img)
|
||||
if img is None:
|
||||
print(f"无法读取 {path_img}"); sys.exit(1)
|
||||
h, w = img.shape[:2]
|
||||
rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
||||
lm = get_landmarker().detect(rgb)
|
||||
if lm is None:
|
||||
print("未检出人脸"); sys.exit(1)
|
||||
pm = get_parser().parse(rgb)
|
||||
p = detect_marker_hairline(img, lm, pm)
|
||||
if p is None:
|
||||
print("未检测到发际线划线(拒识)"); sys.exit(0)
|
||||
print(f"检测到画线:{len(p)} 点")
|
||||
vis = img.copy()
|
||||
cv2.polylines(vis, [p[:, ::-1].reshape(-1, 1, 2)], False, (0, 0, 255), 2)
|
||||
import os
|
||||
os.makedirs("tests/output", exist_ok=True)
|
||||
name = os.path.splitext(os.path.basename(path_img))[0]
|
||||
cv2.imwrite(f"tests/output/marker_{name}.png", vis)
|
||||
print(f"saved tests/output/marker_{name}.png")
|
||||
@@ -0,0 +1,151 @@
|
||||
"""接口2 第二步:inpaint 遮罩 + 黑色发际线划线合成(参考 headmark 5步法)。
|
||||
|
||||
算法(用 hairline_texture_black 渲染黑线替代 headmark 的手绘检测):
|
||||
① 额头上部区域:MediaPipe 额头边界关键点连线,向上+两侧补到图像边缘填充
|
||||
② 头部轮廓:SegFormer 头部类(hair∪skin∪…,排除 bg/neck/cloth)
|
||||
③ ROI = ① ∩ ②
|
||||
④ 渲染黑色发际线 → 烧进照片(marked) + 得到曲线像素
|
||||
⑤ mask = ROI 中"发际线曲线以上",闭运算去洞 + 最大连通域 + 轻羽化
|
||||
合成 RGBA:RGB=marked,alpha=255×(1−mask)(透明=重绘区,对齐 ComfyUI mask=1−alpha)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from .render import load_ext_mesh, load_texture_rgba, render_hairline_overlay, build_overlay_layer
|
||||
|
||||
# headmark 额头边界关键点(MediaPipe canonical 索引,左→右沿上额)
|
||||
FOREHEAD_LANDMARKS = [21, 68, 104, 69, 108, 151, 337, 299, 333, 298, 251]
|
||||
# SegFormer 头部类(含 skin..hat;排除 bg=0 / ear_r=15 / neck_l=16 / neck=17 / cloth=18)
|
||||
_HEAD_CLASSES = list(range(1, 15))
|
||||
|
||||
|
||||
def forehead_upper_region(landmarks_mp: np.ndarray, w: int, h: int) -> np.ndarray:
|
||||
"""headmark step1:额头边界关键点以上的"上部区域"填充 mask(uint8 0/255)。"""
|
||||
pts = [(int(landmarks_mp[i, 0] * w), int(landmarks_mp[i, 1] * h)) for i in FOREHEAD_LANDMARKS]
|
||||
left_ext = (0, pts[0][1])
|
||||
right_ext = (w - 1, pts[-1][1])
|
||||
polygon = np.array([left_ext] + pts + [right_ext, (w - 1, 0), (0, 0)], dtype=np.int32)
|
||||
m = np.zeros((h, w), np.uint8)
|
||||
cv2.fillPoly(m, [polygon], 255)
|
||||
return m
|
||||
|
||||
|
||||
def head_silhouette(parse_map: np.ndarray) -> np.ndarray:
|
||||
"""headmark step2:SegFormer 头部轮廓 mask(uint8 0/255)。"""
|
||||
return (np.isin(parse_map, _HEAD_CLASSES).astype(np.uint8) * 255)
|
||||
|
||||
|
||||
def _curve_bottom_per_column(curve_mask: np.ndarray):
|
||||
"""每列发际线曲线的**最低**像素 y(线下沿),返回 (xs, ys) 仅含有曲线的列。"""
|
||||
ys_idx, xs_idx = np.where(curve_mask > 0)
|
||||
if xs_idx.size == 0:
|
||||
return None, None
|
||||
w = curve_mask.shape[1]
|
||||
bottom = np.full(w, -1, np.int32)
|
||||
np.maximum.at(bottom, xs_idx, ys_idx)
|
||||
cols = np.where(bottom >= 0)[0]
|
||||
return cols, bottom[cols]
|
||||
|
||||
|
||||
def _above_curve_region(curve_mask: np.ndarray, h: int, w: int) -> np.ndarray:
|
||||
"""由发际线曲线得到"曲线以上"区域(uint8 0/255)。
|
||||
|
||||
曲线 x 跨度内逐列插值出下沿 y_line(x),两侧按端点 y 水平延伸;
|
||||
above = 所有 y ≤ y_line(x)。曲线缺失(极端)则返回全 1(交给 ROI 兜底)。
|
||||
"""
|
||||
cols, ybot = _curve_bottom_per_column(curve_mask)
|
||||
if cols is None:
|
||||
return np.full((h, w), 255, np.uint8)
|
||||
x0, x1 = int(cols.min()), int(cols.max())
|
||||
# 全列插值 y_line:[x0,x1] 内线性插值,两侧水平延伸
|
||||
yline = np.interp(np.arange(w), cols, ybot,
|
||||
left=float(ybot[0]), right=float(ybot[-1])).astype(np.int32)
|
||||
yy = np.arange(h)[:, None] # (h,1)
|
||||
above = (yy <= yline[None, :]).astype(np.uint8) * 255 # (h,w)
|
||||
return above
|
||||
|
||||
|
||||
def _clean_mask(mask: np.ndarray, w: int) -> np.ndarray:
|
||||
"""闭运算去洞 + 取最大连通域填充 + 轻羽化。"""
|
||||
k = max(3, (int(w * 0.015) | 1))
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k))
|
||||
closed = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
|
||||
cnts, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
out = np.zeros_like(mask)
|
||||
if cnts:
|
||||
largest = max(cnts, key=cv2.contourArea)
|
||||
cv2.drawContours(out, [largest], -1, 255, -1)
|
||||
# 轻羽化(柔化边缘,利于扩散衔接)
|
||||
out = cv2.GaussianBlur(out, (0, 0), sigmaX=max(1.0, w * 0.004))
|
||||
return out
|
||||
|
||||
|
||||
def mask_from_curve(curve_mask: np.ndarray, landmarks_mp: np.ndarray,
|
||||
parse_map: np.ndarray) -> np.ndarray:
|
||||
"""由发际线曲线 + ROI(额头上部 ∩ 头部) 围成"曲线以上"闭合遮罩(uint8 0..255)。
|
||||
|
||||
接口2(模板渲染曲线) 与 接口3(检测路径曲线) 共用。
|
||||
"""
|
||||
h, w = curve_mask.shape[:2]
|
||||
roi = cv2.bitwise_and(forehead_upper_region(landmarks_mp, w, h),
|
||||
head_silhouette(parse_map))
|
||||
above = _above_curve_region(curve_mask, h, w)
|
||||
return _clean_mask(cv2.bitwise_and(roi, above), w)
|
||||
|
||||
|
||||
def build_inpaint_mask(photo_bgr: np.ndarray, landmarks_mp: np.ndarray,
|
||||
parse_map: np.ndarray, points502: np.ndarray,
|
||||
black_texture_rgba: np.ndarray):
|
||||
"""接口2:返回 (marked_bgr 划线图, mask uint8 0..255 重绘区)。"""
|
||||
h, w = photo_bgr.shape[:2]
|
||||
uv, ext_faces = load_ext_mesh()
|
||||
marked = render_hairline_overlay(photo_bgr, points502, ext_faces, uv, black_texture_rgba)
|
||||
overlay = build_overlay_layer(h, w, points502, ext_faces, uv, black_texture_rgba)
|
||||
curve_mask = (overlay[:, :, 3] > 40).astype(np.uint8) * 255
|
||||
mask = mask_from_curve(curve_mask, landmarks_mp, parse_map)
|
||||
return marked, mask
|
||||
|
||||
|
||||
def compose_comfy_rgba(marked_bgr: np.ndarray, mask: np.ndarray) -> Image.Image:
|
||||
"""合成 ComfyUI LoadImage 用的 RGBA:RGB=划线图,alpha=255−mask(透明=重绘区)。"""
|
||||
rgb = cv2.cvtColor(marked_bgr, cv2.COLOR_BGR2RGB)
|
||||
alpha = (255 - mask).astype(np.uint8)
|
||||
rgba = np.dstack([rgb, alpha])
|
||||
return Image.fromarray(rgba, mode="RGBA")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys, os
|
||||
from .service import get_landmarker, get_parser
|
||||
|
||||
path = sys.argv[1] if len(sys.argv) > 1 else "tests/fixtures/frontal.jpg"
|
||||
tex_name = sys.argv[2] if len(sys.argv) > 2 else "girl_straight"
|
||||
img = cv2.imread(path)
|
||||
h, w = img.shape[:2]
|
||||
rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
||||
|
||||
from .hairline_2d import sample_hairline, smooth_hairline
|
||||
from .lift_3d import lift_hairline_to_3d, build_middle_row, assemble_full
|
||||
lm = get_landmarker().detect(rgb)
|
||||
parse_map = get_parser().parse(rgb)
|
||||
h2d, valid = sample_hairline(lm, parse_map); h2d = smooth_hairline(h2d, valid)
|
||||
h3d = lift_hairline_to_3d(lm, h2d); mid = build_middle_row(lm, h3d)
|
||||
pts = assemble_full(lm, mid, h3d)
|
||||
|
||||
black = load_texture_rgba(f"hairline_texture_black/{tex_name}.png")
|
||||
marked, mask = build_inpaint_mask(img, lm, parse_map, pts, black)
|
||||
os.makedirs("tests/output", exist_ok=True)
|
||||
cv2.imwrite("tests/output/mask_marked.png", marked)
|
||||
cv2.imwrite("tests/output/mask_binary.png", mask)
|
||||
# 三联可视化:划线图 / ROI / mask 叠加
|
||||
upper = forehead_upper_region(lm, w, h); head = head_silhouette(parse_map)
|
||||
roi = cv2.bitwise_and(upper, head)
|
||||
vis = marked.copy()
|
||||
vis[roi > 0] = (vis[roi > 0] * 0.6 + np.array([0, 40, 0])).clip(0, 255).astype(np.uint8)
|
||||
vis[mask > 128] = (vis[mask > 128] * 0.4 + np.array([0, 0, 150])).clip(0, 255).astype(np.uint8)
|
||||
cv2.imwrite("tests/output/mask_vis.png", vis)
|
||||
compose_comfy_rgba(marked, mask).save("tests/output/comfy_input.png")
|
||||
print(f"saved mask_marked/mask_binary/mask_vis/comfy_input;mask 像素 {int((mask>128).sum())}")
|
||||
@@ -0,0 +1,78 @@
|
||||
"""直接调 ComfyUI 重绘 — 替代 local_test HTTP 服务。
|
||||
|
||||
将 local_test/app.py 的核心逻辑(遮罩处理 + ComfyUI 调用)提取为 Python 函数,
|
||||
不再需要独立 Flask 服务。使用 0716add-hair-api.json 工作流(steps=4)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageFilter
|
||||
|
||||
from . import comfyui
|
||||
|
||||
logger = logging.getLogger("hair.worker")
|
||||
|
||||
_DEFAULT_PROMPT = "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
||||
_REPO = os.path.dirname(os.path.dirname(__file__))
|
||||
_REPAINT_WORKFLOW = os.path.join(_REPO, "0716add-hair-api.json")
|
||||
|
||||
|
||||
def _process_mask_to_rgba(image_bytes: bytes, mask_bytes: bytes) -> bytes:
|
||||
"""将分开的 image + mask 处理为 ComfyUI 用的 RGBA PNG bytes。
|
||||
|
||||
复制 local_test/app.py 的遮罩处理逻辑:
|
||||
1. 加载 image 为 RGB
|
||||
2. 加载 mask 为 RGBA,取所有通道 max 值(支持红/白/alpha 遮罩)
|
||||
3. resize mask 到与 image 一致
|
||||
4. 高斯模糊(radius=4) 柔化边缘
|
||||
5. alpha = 255 - mask(绘制区=255 → alpha=0 → 重绘区)
|
||||
6. 合成 RGBA PNG
|
||||
"""
|
||||
image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
|
||||
mask_img = Image.open(io.BytesIO(mask_bytes)).convert("RGBA")
|
||||
mask_arr = np.array(mask_img)
|
||||
mask_data = np.max(mask_arr, axis=2) # (H, W) uint8
|
||||
|
||||
mask_data_img = Image.fromarray(mask_data, mode="L")
|
||||
if mask_data_img.size != image.size:
|
||||
mask_data_img = mask_data_img.resize(image.size, Image.LANCZOS)
|
||||
mask_data_img = mask_data_img.filter(ImageFilter.GaussianBlur(radius=4))
|
||||
|
||||
# ComfyUI LoadImage: mask = 1.0 - (alpha/255)
|
||||
# alpha=0 -> mask=1.0 (inpaint), alpha=255 -> mask=0.0 (keep)
|
||||
comfyui_alpha = Image.eval(mask_data_img, lambda x: 255 - x)
|
||||
|
||||
r, g, b = image.split()
|
||||
rgba = Image.merge("RGBA", (r, g, b, comfyui_alpha))
|
||||
|
||||
buf = io.BytesIO()
|
||||
rgba.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def run_redraw(image_bytes: bytes, mask_bytes: bytes,
|
||||
prompt: str | None = None, timeout: float = 300.0,
|
||||
front: bool = False) -> bytes:
|
||||
"""直接调 ComfyUI 重绘 — 替代 local_test /api/generate。
|
||||
|
||||
Args:
|
||||
image_bytes: 人物图片字节(JPG/PNG)
|
||||
mask_bytes: 遮罩图片字节(支持红/白/alpha 遮罩格式)
|
||||
prompt: 提示词,None 用默认 "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
||||
timeout: ComfyUI 超时秒数
|
||||
front: True 时任务插到 ComfyUI 队列最前(接口2 时延敏感路径用)
|
||||
|
||||
Returns:
|
||||
重绘后的 PNG 图片字节
|
||||
|
||||
Raises:
|
||||
RuntimeError: ComfyUI 执行失败
|
||||
TimeoutError: ComfyUI 超时
|
||||
"""
|
||||
rgba_png = _process_mask_to_rgba(image_bytes, mask_bytes)
|
||||
return comfyui.run(rgba_png, timeout=timeout, prompt=prompt,
|
||||
workflow_path=_REPAINT_WORKFLOW, front=front)
|
||||
@@ -0,0 +1,113 @@
|
||||
"""接口2 渲染器:把发际线类型贴图按 502 点 mesh 贴到照片上(OpenCV 逐三角仿射 warp)。
|
||||
|
||||
原理(技术方案 §4):face_ext.obj 的 502 顶点里,[468..485) 中间行 + [485..502) 发际线行
|
||||
与 17 个 MP 顶部锚点连成 ~64 个 ribbon 三角形,其 UV 落在贴图顶部条带(发际线曲线所在)。
|
||||
只 warp 这些扩展三角形,即可把贴图里的发际线曲线贴到额头/发际线区域,且天然只画在 ribbon 区。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import os
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from .obj_io import read_obj
|
||||
from ._index_map_data import INDEX_MAP_468
|
||||
|
||||
_MESH_PATH = os.path.join(os.path.dirname(__file__), "mesh", "face_ext.obj")
|
||||
_N_MP = 468
|
||||
|
||||
_mesh_cache = None
|
||||
_INDEX_MAP = np.asarray(INDEX_MAP_468, dtype=np.int64) # OBJ顶点i → MP点索引
|
||||
|
||||
|
||||
def mp_order_to_obj_order(points502_mp: np.ndarray) -> np.ndarray:
|
||||
"""把 MP 顺序的 502 点重排成 face_ext.obj 的顶点顺序。
|
||||
|
||||
extract_hairline 输出为 MP 顺序:[0..468) MP / [468..485) middle / [485..502) hairline。
|
||||
而 face_ext.obj 的前 468 顶点经 INDEX_MAP_468 重排(obj_i → mp_i);扩展顶点
|
||||
[468..502) 两侧同序,直接对应。
|
||||
"""
|
||||
out = np.empty_like(points502_mp)
|
||||
out[:_N_MP] = points502_mp[_INDEX_MAP] # obj[0..468) = mp[INDEX_MAP]
|
||||
out[_N_MP:] = points502_mp[_N_MP:] # 扩展行同序
|
||||
return out
|
||||
|
||||
|
||||
def load_ext_mesh(obj_path: str = _MESH_PATH):
|
||||
"""解析 face_ext.obj,返回 (uv502, ext_faces)。结果缓存。
|
||||
|
||||
- uv502: (502, 2) float32,每个顶点的 UV(V_raw,V=1 对应贴图顶部)。
|
||||
- ext_faces: list[(i,j,k)],仅保留顶点索引含 ≥468 的扩展三角形(ribbon)。
|
||||
obj 中 v 与 vt 一一对应(face 用相同索引),故按位置索引取 UV。
|
||||
"""
|
||||
global _mesh_cache
|
||||
if _mesh_cache is not None:
|
||||
return _mesh_cache
|
||||
|
||||
mesh = read_obj(obj_path)
|
||||
n_v = len(mesh.positions)
|
||||
uv = np.zeros((n_v, 2), dtype=np.float32)
|
||||
for face in mesh.faces:
|
||||
for pi, ti, _ni in face:
|
||||
if ti >= 0 and pi >= 0:
|
||||
uv[pi] = mesh.texcoords[ti]
|
||||
|
||||
ext_faces = []
|
||||
for face in mesh.faces:
|
||||
idx = [pi for (pi, _t, _n) in face]
|
||||
if max(idx) >= _N_MP: # 含扩展顶点 → ribbon 三角形
|
||||
ext_faces.append(tuple(idx))
|
||||
|
||||
_mesh_cache = (uv, ext_faces)
|
||||
return _mesh_cache
|
||||
|
||||
|
||||
def load_texture_rgba(path: str) -> np.ndarray:
|
||||
"""读发际线贴图为 (H, W, 4) uint8 RGBA。"""
|
||||
return np.array(Image.open(path).convert("RGBA"))
|
||||
|
||||
|
||||
def render_hairline_overlay(photo_bgr: np.ndarray,
|
||||
points502_norm: np.ndarray,
|
||||
ext_faces,
|
||||
uv502: np.ndarray,
|
||||
texture_rgba: np.ndarray) -> np.ndarray:
|
||||
"""把 texture_rgba 的发际线曲线渲染到 photo_bgr 上,返回 BGR 预览图。
|
||||
|
||||
points502_norm: (502, 3) 归一化坐标(x,y ∈ [0,1]),**MP 顺序**(extract_hairline 输出)。
|
||||
"""
|
||||
H, W = photo_bgr.shape[:2]
|
||||
overlay = build_overlay_layer(H, W, points502_norm, ext_faces, uv502, texture_rgba)
|
||||
# alpha 合成(RGBA→BGR:贴图 RGB 顺序需反成 BGR)
|
||||
a = overlay[:, :, 3:4] / 255.0
|
||||
rgb = overlay[:, :, :3][..., ::-1] # RGB→BGR
|
||||
out = photo_bgr.astype(np.float32) * (1.0 - a) + rgb * a
|
||||
return np.clip(out, 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
def build_overlay_layer(H, W, points502_norm, ext_faces, uv502, texture_rgba) -> np.ndarray:
|
||||
"""渲染发际线曲线层,返回 (H, W, 4) float32 RGBA(未合成到照片)。
|
||||
|
||||
供渲染合成(render_hairline_overlay)与遮罩(mask.py 取 alpha=曲线像素)共用。
|
||||
"""
|
||||
TH, TW = texture_rgba.shape[:2]
|
||||
pts_obj = mp_order_to_obj_order(points502_norm)
|
||||
img_xy = pts_obj[:, :2] * np.array([W, H], dtype=np.float32)
|
||||
overlay = np.zeros((H, W, 4), np.float32)
|
||||
tex = texture_rgba.astype(np.float32)
|
||||
for (i, j, k) in ext_faces:
|
||||
dst = img_xy[[i, j, k]].astype(np.float32)
|
||||
# UV → 贴图像素;flipY:贴图 y = (1 - v_raw) * TH(与 head3d Three.js flipY=true 一致)
|
||||
src = np.array([[uv502[v][0] * TW, (1.0 - uv502[v][1]) * TH] for v in (i, j, k)],
|
||||
dtype=np.float32)
|
||||
if cv2.contourArea(dst.astype(np.int32)) < 1.0: # 退化三角形跳过
|
||||
continue
|
||||
M = cv2.getAffineTransform(src, dst)
|
||||
warped = cv2.warpAffine(tex, M, (W, H), flags=cv2.INTER_LINEAR,
|
||||
borderMode=cv2.BORDER_CONSTANT, borderValue=(0, 0, 0, 0))
|
||||
tri_mask = np.zeros((H, W), np.uint8)
|
||||
cv2.fillConvexPoly(tri_mask, dst.astype(np.int32), 255)
|
||||
sel = tri_mask > 0
|
||||
overlay[sel] = warped[sel]
|
||||
return overlay
|
||||
@@ -0,0 +1,622 @@
|
||||
"""接口2 服务层:模型单例 + 性别贴图映射 + 「照片→N 张发际线预览图」管线。
|
||||
|
||||
把 head3d 的 extract_hairline 步骤包成单例复用(避免每请求重建模型),再按性别
|
||||
对每张贴图调 render.render_hairline_overlay 生成预览图。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import glob
|
||||
import os
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from . import constants as C
|
||||
from . import comfyui
|
||||
from .face_landmarks import FaceLandmarker
|
||||
from .face_parsing import FaceParser
|
||||
from .hairline_2d import (
|
||||
smooth_hairline, sample_hairline_clamped,
|
||||
)
|
||||
from .lift_3d import lift_hairline_to_3d, build_middle_row, assemble_full
|
||||
from .render import load_ext_mesh, load_texture_rgba, render_hairline_overlay, build_overlay_layer
|
||||
from .mask import build_inpaint_mask, compose_comfy_rgba, mask_from_curve
|
||||
from .marker_detect import detect_marker_hairline, path_to_curve_mask
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("hair.worker")
|
||||
|
||||
# 接口2 女性发型 key → change_hair hair_id(chang_*)映射:换发型+Flux-2 整帧重绘用。
|
||||
# 与接口12 final 的 5 型一一对应。female 6/7(bigflower/clasicalflower)无对应 LoRA,
|
||||
# 走与男性一致的原生生发(ComfyUI add_hair)管线,故不在本表。
|
||||
_FEMALE_KEY_TO_CHANG = {
|
||||
"ellipse": "chang_tuoyuan", # 椭圆
|
||||
"flower": "chang_huaban", # 花瓣
|
||||
"heart": "chang_xinxing", # 心形
|
||||
"straight": "chang_zhixian", # 直线
|
||||
"wave": "chang_bolang", # 波浪
|
||||
}
|
||||
|
||||
# 发际线贴图显式顺序表:决定 hair_style 序号(1-indexed)。
|
||||
# 不再依赖文件名字母序——字母序会因新增/重命名文件而错位,破坏现有前端/客户端取值。
|
||||
# key 须与 _gender_key 派生结果一致(已去空格):如 "inverse_arc"(源 man_ inverse_arc.png)、
|
||||
# "Softpetal"(源 man_Soft petal.png,大写 S 保留)。表外未知 key 兜底排到末尾。
|
||||
_HAIRSTYLE_ORDER = {
|
||||
"female": ["ellipse", "flower", "heart", "straight", "wave",
|
||||
"bigflower", "clasicalflower"], # 1..7
|
||||
"male": ["ellipse", "inverse_arc", "m", "straight", "heart", "Softpetal"], # 1..6
|
||||
}
|
||||
|
||||
_REPO = os.path.dirname(os.path.dirname(__file__))
|
||||
_TEXTURE_DIR = os.path.join(_REPO, "hairline_texture")
|
||||
_BLACK_TEXTURE_DIR = os.path.join(_REPO, "hairline_texture_black")
|
||||
|
||||
# 三接口(接口2女重绘 / 接口2男 / 接口3)统一的 ComfyUI 重绘 prompt。
|
||||
# 关键:ComfyUI 单卡显存装不下 Flux(7.7G)+qwen CLIP(3.9G) 同驻,靠缓存 CLIP 文本条件避免重载。
|
||||
# prompt 不同会使缓存失效 → 重载 CLIP 并挤出 Flux(每次 +4s)。三接口用同一字符串即可全程命中。
|
||||
# 与 app.py 接口2/接口3 的默认 prompt 保持一致;可用 REDRAW_PROMPT 覆盖。
|
||||
_REDRAW_PROMPT = os.getenv("REDRAW_PROMPT", "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
|
||||
|
||||
# 接口2 女重绘整条管线(swapHair + ComfyUI)送模型前限边。真实照片常达 1257x1495:
|
||||
# 全分辨率 ComfyUI 重绘要 13~21s 且激活显存把模型挤出。女性路径含 swapHair(SD WebUI ~5.3s
|
||||
# 固定地板) + ComfyUI 两段串行。1024 档画质更好但部分大图会踩 12s 线,
|
||||
# 默认压到 896 兜底(ComfyUI ~4s,女性总耗时 9~11s);追画质可设 REDRAW_MAX_SIDE=1024。
|
||||
_REDRAW_MAX_SIDE = int(os.getenv("REDRAW_MAX_SIDE", "896"))
|
||||
|
||||
def _call_local_redraw(image_png_bytes, mask_png_bytes, timeout=300.0):
|
||||
"""直接调 ComfyUI 重绘(替代原 local_test HTTP 服务)。
|
||||
|
||||
传 final 图 + 纯红遮罩 PNG,返回重绘后的 PNG bytes。
|
||||
失败抛异常(调用方负责 try/except 跳过)。
|
||||
"""
|
||||
from .redraw import run_redraw
|
||||
img = cv2.imdecode(np.frombuffer(image_png_bytes, np.uint8), cv2.IMREAD_UNCHANGED)
|
||||
scale = 1.0
|
||||
orig_w = orig_h = 0
|
||||
if img is not None:
|
||||
orig_h, orig_w = img.shape[:2]
|
||||
m = max(orig_h, orig_w)
|
||||
if _REDRAW_MAX_SIDE > 0 and m > _REDRAW_MAX_SIDE:
|
||||
scale = _REDRAW_MAX_SIDE / float(m)
|
||||
nw, nh = max(1, round(orig_w * scale)), max(1, round(orig_h * scale))
|
||||
msk = cv2.imdecode(np.frombuffer(mask_png_bytes, np.uint8), cv2.IMREAD_UNCHANGED)
|
||||
img_s = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_AREA)
|
||||
msk_s = cv2.resize(msk, (nw, nh), interpolation=cv2.INTER_NEAREST)
|
||||
image_png_bytes = cv2.imencode(".png", img_s)[1].tobytes()
|
||||
mask_png_bytes = cv2.imencode(".png", msk_s)[1].tobytes()
|
||||
logger.info("接口2女 缩图送 Comfy: %dx%d → %dx%d (max_side=%d)",
|
||||
orig_w, orig_h, nw, nh, _REDRAW_MAX_SIDE)
|
||||
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前,避免排在接口3/5 的批量任务后面
|
||||
out = run_redraw(image_png_bytes, mask_png_bytes, timeout=timeout,
|
||||
prompt=_REDRAW_PROMPT, front=True)
|
||||
if scale < 1.0 and out:
|
||||
out = _upscale_png_to(out, orig_w, orig_h)
|
||||
return out
|
||||
|
||||
# 发际线贴图档位:middle=默认(hairline_texture/),high/low 各自独立文件夹。
|
||||
_TEXTURE_DIRS = {
|
||||
"middle": _TEXTURE_DIR,
|
||||
"high": os.path.join(_REPO, "hairline_texture_high"),
|
||||
"low": os.path.join(_REPO, "hairline_texture_low"),
|
||||
}
|
||||
|
||||
# torch 2.7.1+cu128 已支持 RTX 5090 (sm_120),SegFormer 走 GPU(~0.05s/张)
|
||||
_SEG_DEVICE = os.getenv("SEG_DEVICE", "cuda")
|
||||
|
||||
_landmarker = None
|
||||
_parser = None
|
||||
_texture_maps: dict = {} # {level: {gender: [(key, path)]}},按档位缓存
|
||||
|
||||
|
||||
def get_landmarker() -> FaceLandmarker:
|
||||
global _landmarker
|
||||
if _landmarker is None:
|
||||
_landmarker = FaceLandmarker(static_image_mode=True)
|
||||
return _landmarker
|
||||
|
||||
|
||||
def get_parser() -> FaceParser:
|
||||
global _parser
|
||||
if _parser is None:
|
||||
_parser = FaceParser(device=_SEG_DEVICE)
|
||||
return _parser
|
||||
|
||||
|
||||
def _gender_key(stem: str):
|
||||
"""文件名 stem → (gender, key);非 girl_/man_ 前缀返回 (None, None)。"""
|
||||
if stem.startswith("girl_"):
|
||||
return "female", stem[5:].replace(" ", "").strip()
|
||||
if stem.startswith("man_"):
|
||||
return "male", stem[4:].replace(" ", "").strip()
|
||||
return None, None
|
||||
|
||||
|
||||
def get_texture_map(level: str = "middle") -> dict:
|
||||
"""扫描指定档位贴图目录建 {gender: [(key, path)]},按显式顺序表排序、按档位缓存。
|
||||
|
||||
level:middle(默认) / high / low,分别对应 hairline_texture[/_high|/_low]。
|
||||
文件名规范化去空格(如 `man_ inverse_arc.png` → key `inverse_arc`)。
|
||||
|
||||
排序依据 _HAIRSTYLE_ORDER:表内 key 按表序、表外未知 key 兜底排到末尾(再按字母序),
|
||||
保证新增/重命名文件不会打乱现有 hair_style 序号。
|
||||
"""
|
||||
if level not in _TEXTURE_DIRS:
|
||||
raise ValueError(f"hairline_level 必须是 middle/high/low,收到 {level!r}")
|
||||
cached = _texture_maps.get(level)
|
||||
if cached is not None:
|
||||
return cached
|
||||
mapping: dict[str, list] = {"female": [], "male": []}
|
||||
for path in sorted(glob.glob(os.path.join(_TEXTURE_DIRS[level], "*.png"))):
|
||||
stem = os.path.splitext(os.path.basename(path))[0]
|
||||
gender, key = _gender_key(stem)
|
||||
if gender:
|
||||
mapping[gender].append((key, path))
|
||||
for g in mapping:
|
||||
order = _HAIRSTYLE_ORDER.get(g, [])
|
||||
idx = {k: i for i, k in enumerate(order)}
|
||||
mapping[g].sort(key=lambda kp: (idx.get(kp[0], len(idx)), kp[0]))
|
||||
_texture_maps[level] = mapping
|
||||
return mapping
|
||||
|
||||
|
||||
def extract_502(image_bgr: np.ndarray):
|
||||
"""照片(BGR) → (points502 MP序, valid17)。无人脸返回 (None, None)。"""
|
||||
ctx = extract_context(image_bgr)
|
||||
if ctx is None:
|
||||
return None, None
|
||||
return ctx["points"], ctx["valid"]
|
||||
|
||||
|
||||
def extract_context(image_bgr: np.ndarray):
|
||||
"""照片(BGR) → {landmarks, parse_map, points, valid}。无人脸返回 None。
|
||||
|
||||
发际线几何检测固定用 `sample_hairline_clamped`(射线检测 + 头部轮廓钳制):
|
||||
短发/剃光头照片(如 man_test.jpg)中间锚点检测失效时,纯射线检测的固定 fallback
|
||||
偏移会把点顶到头部轮廓外面的背景,产生"发际线贴到头部外面"的视觉 bug;钳制兜底后
|
||||
fallback 点不会再跑出头部轮廓,正常长发照片结果与旧行为一致。
|
||||
"""
|
||||
rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
|
||||
landmarks = get_landmarker().detect(rgb)
|
||||
if landmarks is None:
|
||||
return None
|
||||
parse_map = get_parser().parse(rgb)
|
||||
hairline_2d, valid = sample_hairline_clamped(landmarks, parse_map)
|
||||
hairline_2d = smooth_hairline(hairline_2d, valid)
|
||||
hairline_3d = lift_hairline_to_3d(landmarks, hairline_2d)
|
||||
middle_3d = build_middle_row(landmarks, hairline_3d)
|
||||
points = assemble_full(landmarks, middle_3d, hairline_3d)
|
||||
return {"landmarks": landmarks, "parse_map": parse_map, "points": points, "valid": valid}
|
||||
|
||||
|
||||
def _black_texture_path(white_path: str) -> str:
|
||||
"""白贴图路径 → 同名黑贴图路径(hairline_texture_black/)。"""
|
||||
return os.path.join(_BLACK_TEXTURE_DIR, os.path.basename(white_path))
|
||||
|
||||
|
||||
def generate_previews(image_bgr: np.ndarray, gender: str):
|
||||
"""生成该性别全部发际线预览图(仅预览,不生发)。
|
||||
|
||||
Returns: list[dict] {"hairline_type", "image_bgr", "order"};无人脸返回 None。
|
||||
"""
|
||||
if gender not in ("male", "female"):
|
||||
raise ValueError(f"gender 必须是 male/female,收到 {gender!r}")
|
||||
ctx = extract_context(image_bgr)
|
||||
if ctx is None:
|
||||
return None
|
||||
uv, ext_faces = load_ext_mesh()
|
||||
results = []
|
||||
for order, (key, path) in enumerate(get_texture_map()[gender], start=1):
|
||||
preview = render_hairline_overlay(image_bgr, ctx["points"], ext_faces, uv,
|
||||
load_texture_rgba(path))
|
||||
results.append({"hairline_type": key, "image_bgr": preview, "order": order})
|
||||
return results
|
||||
|
||||
|
||||
def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = True,
|
||||
prompt: str = None, hair_styles: list[int] | None = None,
|
||||
workflow_path: str | None = None):
|
||||
"""指定发际线类型:发际线透明叠图(白线 RGBA) + 生发图(ComfyUI)。
|
||||
|
||||
hair_styles(1-indexed 列表):指定生成哪几张发际线(按贴图排序)。female: 1..7,male: 1..6。
|
||||
为 None 时生成全部(兼容旧调用)。
|
||||
use_mask(默认 True):是否启用 inpaint 遮罩,用于测试对比(同接口3)。
|
||||
False 时用**干净原图 + 空遮罩**送 ComfyUI(不烧黑色模板线)。
|
||||
prompt(默认 None):ComfyUI 提示词,非 None 时替换工作流节点60文本。
|
||||
workflow_path(默认 None):ComfyUI 工作流 JSON 路径,None 用默认 add_hair.json。
|
||||
Returns: list[dict] {"hairline_type","order","overlay"((H,W,4) RGBA 透明层),
|
||||
"grown_png"(bytes 或 None)}。
|
||||
无人脸返回 None。某张 ComfyUI 失败时该项 grown_png=None,不抛异常。
|
||||
"""
|
||||
if gender not in ("male", "female"):
|
||||
raise ValueError(f"gender 必须是 male/female,收到 {gender!r}")
|
||||
ctx = extract_context(image_bgr)
|
||||
if ctx is None:
|
||||
return None
|
||||
uv, ext_faces = load_ext_mesh()
|
||||
|
||||
textures = get_texture_map()[gender] # [(key, path), ...] 已排序
|
||||
if hair_styles is not None:
|
||||
items = [(s, textures[s - 1]) for s in hair_styles]
|
||||
else:
|
||||
items = list(enumerate(textures, start=1))
|
||||
|
||||
# 禁用遮罩:干净原图 + 空遮罩,与模板无关 → 只跑一次 ComfyUI,下面 N 项复用
|
||||
shared_grown = None
|
||||
if not use_mask:
|
||||
try:
|
||||
h, w = image_bgr.shape[:2]
|
||||
img_s, msk_s, gsc = _prep_comfy_input(image_bgr, np.zeros((h, w), np.uint8))
|
||||
buf = io.BytesIO()
|
||||
compose_comfy_rgba(img_s, msk_s).save(buf, format="PNG", compress_level=1)
|
||||
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前
|
||||
shared_grown = comfyui.run(buf.getvalue(), prompt=prompt,
|
||||
workflow_path=workflow_path, front=True)
|
||||
if gsc < 1.0 and shared_grown:
|
||||
shared_grown = _upscale_png_to(shared_grown, w, h)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("接口2 生发图失败(无遮罩):%s", e)
|
||||
|
||||
results = []
|
||||
h, w = image_bgr.shape[:2]
|
||||
for order, (key, white_path) in items:
|
||||
white = load_texture_rgba(white_path)
|
||||
overlay = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)
|
||||
|
||||
if not use_mask:
|
||||
grown_png = shared_grown
|
||||
else:
|
||||
grown_png = None
|
||||
try:
|
||||
black = load_texture_rgba(_black_texture_path(white_path))
|
||||
marked, mask = build_inpaint_mask(
|
||||
image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black)
|
||||
m_s, msk_s, gsc = _prep_comfy_input(marked, mask)
|
||||
buf = io.BytesIO()
|
||||
compose_comfy_rgba(m_s, msk_s).save(buf, format="PNG", compress_level=1)
|
||||
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前
|
||||
grown_png = comfyui.run(buf.getvalue(), prompt=prompt,
|
||||
workflow_path=workflow_path, front=True)
|
||||
if gsc < 1.0 and grown_png:
|
||||
grown_png = _upscale_png_to(grown_png, w, h)
|
||||
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
|
||||
logger.warning("接口2 生发图失败 type=%s:%s", key, e)
|
||||
|
||||
results.append({"hairline_type": key, "order": order,
|
||||
"overlay": overlay, "grown_png": grown_png})
|
||||
return results
|
||||
|
||||
|
||||
def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | None,
|
||||
redraw_defaults: dict,
|
||||
redraw_max_side: int | None = None,
|
||||
unet_name: str | None = None,
|
||||
prompt: str | None = None):
|
||||
"""接口2 女性专用:发际线透明叠图(同 generate_grow_results)+ 生发图。
|
||||
|
||||
生发图来源按发型分两路:
|
||||
- 换发型(chang_* 表内,1..5):female key → change_hair 的 chang_* hair_id,调
|
||||
face_analysis.hairline_grow.generate_hairline_redraw(= 接口12 final 管线,参数用
|
||||
redraw_defaults)拿到 ④ final(接缝融合基底)+ ⑤-② 纯红遮罩 PNG,再**后端直接调
|
||||
ComfyUI**(0716add-hair-api.json 工作流)完成发际线带重绘。
|
||||
- 原生生发(表外,6/7 bigflower/clasicalflower 等):无对应 change_hair LoRA,改走与男性
|
||||
一致的原生生发(ComfyUI add_hair),由 _grow_native_one 完成(黑模板 + inpaint 遮罩)。
|
||||
|
||||
overlay 仍是发际线曲线透明层(与 generate_grow_results 完全一致)。
|
||||
prompt 仅用于原生生发分支(换发型分支的提示词由 redraw 流程内部固定)。
|
||||
Returns: list[dict] {"hairline_type","order","overlay","grown_png"(jpg bytes 或 None)};
|
||||
无人脸返回 None。单个发型换发型/重绘失败时 grown_png=None,不抛异常。
|
||||
"""
|
||||
from face_analysis.hairline_grow import generate_hairline_redraw, NoFaceError
|
||||
from face_analysis.head_mask import SEGFORMER_HAIR
|
||||
|
||||
ctx = extract_context(image_bgr)
|
||||
if ctx is None:
|
||||
return None
|
||||
uv, ext_faces = load_ext_mesh()
|
||||
|
||||
# 复用 extract_context 已算好的 SegFormer parse_map,避免 generate_hairline_redraw 内部重复分割
|
||||
hair_mask_reuse = (ctx["parse_map"] == SEGFORMER_HAIR)
|
||||
|
||||
textures = get_texture_map()["female"] # [(key, path), ...] 已排序
|
||||
if hair_styles is not None:
|
||||
items = [(s, textures[s - 1]) for s in hair_styles]
|
||||
else:
|
||||
items = list(enumerate(textures, start=1))
|
||||
|
||||
results = []
|
||||
h, w = image_bgr.shape[:2]
|
||||
|
||||
# 重绘管线(swapHair + ComfyUI)统一降分辨率:真实照片 swap(SD WebUI)~5s、blend、ComfyUI
|
||||
# 均随分辨率线性下降。overlay 预览仍用全分辨率;grown_png 最后放大回原尺寸。
|
||||
redraw_img = image_bgr
|
||||
hair_mask_redraw = hair_mask_reuse
|
||||
if _REDRAW_MAX_SIDE > 0 and max(h, w) > _REDRAW_MAX_SIDE:
|
||||
redraw_img, _rs = _downscale_max_side(image_bgr, _REDRAW_MAX_SIDE)
|
||||
_nh, _nw = redraw_img.shape[:2]
|
||||
if hair_mask_redraw is not None:
|
||||
hair_mask_redraw = cv2.resize(hair_mask_reuse.astype(np.uint8), (_nw, _nh),
|
||||
interpolation=cv2.INTER_NEAREST).astype(bool)
|
||||
logger.info("接口2女 管线降分辨率: %dx%d → %dx%d (max_side=%d)",
|
||||
w, h, _nw, _nh, _REDRAW_MAX_SIDE)
|
||||
|
||||
for order, (key, white_path) in items:
|
||||
white = load_texture_rgba(white_path)
|
||||
overlay = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)
|
||||
|
||||
grown_png = None
|
||||
chang_id = _FEMALE_KEY_TO_CHANG.get(key)
|
||||
if chang_id is None:
|
||||
# 无对应 change_hair LoRA(如 bigflower/clasicalflower)→ 走与男性一致的原生生发
|
||||
logger.info("接口2 女 key=%s 无 chang_id,走原生生发(ComfyUI add_hair)", key)
|
||||
grown_png = _grow_native_one(image_bgr, ctx, white_path,
|
||||
prompt=prompt, unet_name=unet_name)
|
||||
else:
|
||||
try:
|
||||
import time as _t
|
||||
_ts0 = _t.perf_counter()
|
||||
data = generate_hairline_redraw(redraw_img, chang_id, hair_mask=hair_mask_redraw, **redraw_defaults)
|
||||
_ts1 = _t.perf_counter()
|
||||
steps = data.get("steps") or {}
|
||||
# ④ final(接缝融合基底)+ ⑤-② 纯红遮罩 PNG
|
||||
final_b64 = steps.get("final_base64") or ""
|
||||
mask_b64 = steps.get("redraw_band_mask_base64") or ""
|
||||
if not final_b64 or not mask_b64:
|
||||
logger.warning("接口2 换发型:type=%s final/遮罩缺失(final=%d mask=%d)",
|
||||
key, len(final_b64), len(mask_b64))
|
||||
else:
|
||||
# 去掉 data URI 前缀
|
||||
if final_b64.startswith("data:"):
|
||||
final_b64 = final_b64.split(",", 1)[1]
|
||||
if mask_b64.startswith("data:"):
|
||||
mask_b64 = mask_b64.split(",", 1)[1]
|
||||
final_bytes = base64.b64decode(final_b64)
|
||||
mask_bytes = base64.b64decode(mask_b64)
|
||||
# 后端直接调 ComfyUI 重绘,返回重绘后的 PNG
|
||||
_tr0 = _t.perf_counter()
|
||||
grown_png = _call_local_redraw(final_bytes, mask_bytes)
|
||||
_tr1 = _t.perf_counter()
|
||||
_tm = data.get("timings_ms") or {}
|
||||
logger.info("接口2女 分段计时 type=%s: swapHair管线=%.2fs (mask=%dms swap=%dms blend=%dms), ComfyUI重绘=%.2fs",
|
||||
key, _ts1 - _ts0,
|
||||
_tm.get("mask", 0), _tm.get("swap", 0), _tm.get("blend", 0),
|
||||
_tr1 - _tr0)
|
||||
if grown_png is None:
|
||||
logger.warning("接口2 换发型:type=%s 重绘结果为空", key)
|
||||
elif redraw_img is not image_bgr:
|
||||
# 管线在降分辨率图上跑,结果放大回原尺寸
|
||||
grown_png = _upscale_png_to(grown_png, w, h)
|
||||
except NoFaceError:
|
||||
logger.warning("接口2 换发型:type=%s 未检出人脸", key)
|
||||
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
|
||||
logger.warning("接口2 换发型图失败 type=%s:%s", key, e)
|
||||
|
||||
results.append({"hairline_type": key, "order": order,
|
||||
"overlay": overlay, "grown_png": grown_png})
|
||||
return results
|
||||
|
||||
|
||||
def _grow_from_texture(image_bgr: np.ndarray, ctx: dict, white_path: str | None,
|
||||
use_mask: bool, prompt: str | None):
|
||||
"""对单个发际线做生发(ComfyUI)。黑模板固定取 hairline_texture_black/(middle),
|
||||
与 hairline_level 无关(high/low 贴图与 middle 同名,basename 映射即落回 middle 黑模板)。
|
||||
use_mask=False 时用干净原图+空遮罩(与贴图无关,white_path 可为 None)。
|
||||
失败返回 None,不抛异常。
|
||||
"""
|
||||
try:
|
||||
if use_mask:
|
||||
black = load_texture_rgba(_black_texture_path(white_path))
|
||||
marked, mask = build_inpaint_mask(
|
||||
image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black)
|
||||
else:
|
||||
h, w = image_bgr.shape[:2]
|
||||
marked, mask = image_bgr, np.zeros((h, w), np.uint8)
|
||||
buf = io.BytesIO()
|
||||
compose_comfy_rgba(marked, mask).save(buf, format="PNG", compress_level=1)
|
||||
return comfyui.run(buf.getvalue(), prompt=prompt)
|
||||
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
|
||||
logger.warning("接口5 生发图失败:%s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _grow_native_one(image_bgr: np.ndarray, ctx: dict, white_path: str,
|
||||
prompt: str | None = None, unet_name: str | None = None):
|
||||
"""对单个发际线做原生生发(ComfyUI add_hair),与男性 generate_grow_results 一致。
|
||||
|
||||
接口2 女性新发型(bigflower/clasicalflower 等)无对应 change_hair LoRA,改走此路径:
|
||||
黑模板 → build_inpaint_mask → 限边(_prep_comfy_input)→ comfyui.run(front=True)。
|
||||
失败返回 None,不抛异常。结果按限边前原图尺寸放大回原尺寸(仅展示对齐)。
|
||||
"""
|
||||
try:
|
||||
h, w = image_bgr.shape[:2]
|
||||
black = load_texture_rgba(_black_texture_path(white_path))
|
||||
marked, mask = build_inpaint_mask(
|
||||
image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black)
|
||||
m_s, msk_s, gsc = _prep_comfy_input(marked, mask)
|
||||
buf = io.BytesIO()
|
||||
compose_comfy_rgba(m_s, msk_s).save(buf, format="PNG", compress_level=1)
|
||||
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前
|
||||
grown_png = comfyui.run(buf.getvalue(), prompt=prompt, front=True, unet_name=unet_name)
|
||||
if gsc < 1.0 and grown_png:
|
||||
grown_png = _upscale_png_to(grown_png, w, h)
|
||||
return grown_png
|
||||
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
|
||||
logger.warning("接口2 女原生生发图失败:%s", e)
|
||||
return None
|
||||
|
||||
|
||||
def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
|
||||
hair_styles: list[int], use_mask: bool = True,
|
||||
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 取首个选中发型三档各自的发际线中点。
|
||||
"""
|
||||
if gender not in ("male", "female"):
|
||||
raise ValueError(f"gender 必须是 male/female,收到 {gender!r}")
|
||||
if not hair_styles:
|
||||
raise ValueError("hair_styles 必填且不能为空")
|
||||
ctx = extract_context(image_bgr)
|
||||
if ctx is None:
|
||||
return None
|
||||
h, w = image_bgr.shape[:2]
|
||||
uv, ext_faces = load_ext_mesh()
|
||||
lm = ctx["landmarks"]
|
||||
# 面部中轴 x = 眉心(9/151 中点)
|
||||
face_cx = float((lm[9, 0] + lm[151, 0]) / 2 * w)
|
||||
|
||||
# 三档贴图表(同性别、同 key 顺序,因三个文件夹同名)
|
||||
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 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):
|
||||
"""从某档发际线透明叠图取面部中轴处的发际线中点 (x,y),无像素返回 None。"""
|
||||
ys, xs = np.where(overlay[:, :, 3] > 40)
|
||||
if not xs.size:
|
||||
return None
|
||||
near = np.abs(xs - face_cx) <= max(2, int(w * 0.02))
|
||||
col_ys = ys[near] if near.any() else ys[np.argsort(np.abs(xs - face_cx))[:20]]
|
||||
return (int(round(face_cx)), int(round(float(col_ys.mean()))))
|
||||
|
||||
images, best_centers = [], None
|
||||
for s in hair_styles: # s = 1-indexed 发型序号
|
||||
key, mid_path = tex_by_level["middle"][s - 1]
|
||||
overlays = {}
|
||||
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 黑模板(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)发际线中点
|
||||
if best_centers is None:
|
||||
best_centers = {lv: _center_of(overlays[lv]) for lv in _TEXTURE_DIRS}
|
||||
return {"images": images, "best_centers": best_centers}
|
||||
|
||||
|
||||
# 接口3 送 ComfyUI 前限边,降低峰值显存,避免与接口2 切换时把 Flux 挤出。
|
||||
# 统一 prompt 后 Flux 不再被 CLIP 挤出,接口3 可用较高分辨率。可用 GROW_B_MAX_SIDE 覆盖。
|
||||
_GROW_B_MAX_SIDE = int(os.getenv("GROW_B_MAX_SIDE", "1024"))
|
||||
|
||||
def _downscale_max_side(img_bgr: np.ndarray, max_side: int) -> tuple[np.ndarray, float]:
|
||||
"""长边超过 max_side 时等比例缩小;返回 (图, scale),scale=新/旧。"""
|
||||
h, w = img_bgr.shape[:2]
|
||||
m = max(h, w)
|
||||
if max_side <= 0 or m <= max_side:
|
||||
return img_bgr, 1.0
|
||||
scale = max_side / float(m)
|
||||
nw = max(1, int(round(w * scale)))
|
||||
nh = max(1, int(round(h * scale)))
|
||||
out = cv2.resize(img_bgr, (nw, nh), interpolation=cv2.INTER_AREA)
|
||||
return out, scale
|
||||
|
||||
|
||||
def _upscale_png_to(png_bytes: bytes, out_w: int, out_h: int) -> bytes:
|
||||
"""把 Comfy 输出 PNG 双线性拉回原图尺寸(仅展示对齐,不增加推理细节)。"""
|
||||
arr = np.frombuffer(png_bytes, np.uint8)
|
||||
img = cv2.imdecode(arr, cv2.IMREAD_UNCHANGED)
|
||||
if img is None:
|
||||
return png_bytes
|
||||
if img.shape[1] == out_w and img.shape[0] == out_h:
|
||||
return png_bytes
|
||||
resized = cv2.resize(img, (out_w, out_h), interpolation=cv2.INTER_LINEAR)
|
||||
ok, buf = cv2.imencode(".png", resized)
|
||||
return buf.tobytes() if ok else png_bytes
|
||||
|
||||
|
||||
def _prep_comfy_input(img_bgr: np.ndarray, mask: np.ndarray) -> tuple[np.ndarray, np.ndarray, float]:
|
||||
"""单段 ComfyUI 生发(接口2男 / 接口3)送图前限边到 GROW_B_MAX_SIDE。
|
||||
返回 (缩后图, 缩后遮罩, scale);scale<1 时调用方需把结果放大回原尺寸。"""
|
||||
h, w = img_bgr.shape[:2]
|
||||
if _GROW_B_MAX_SIDE <= 0 or max(h, w) <= _GROW_B_MAX_SIDE:
|
||||
return img_bgr, mask, 1.0
|
||||
out, scale = _downscale_max_side(img_bgr, _GROW_B_MAX_SIDE)
|
||||
nh, nw = out.shape[:2]
|
||||
msk = cv2.resize(mask, (nw, nh), interpolation=cv2.INTER_NEAREST)
|
||||
logger.info("接口2男/接口3 缩图送 Comfy: %dx%d → %dx%d (max_side=%d)",
|
||||
w, h, nw, nh, _GROW_B_MAX_SIDE)
|
||||
return out, msk, scale
|
||||
|
||||
|
||||
def generate_grow_b(marked_bgr: np.ndarray, use_mask: bool = True, prompt: str = None):
|
||||
"""接口3:检测医生手绘发际线 → 遮罩 → 送 ComfyUI 生发(仅需划线图一张)。
|
||||
|
||||
检测路径只用来**建遮罩**;ComfyUI 输入图用 **marked 原图**(含医生手绘线,
|
||||
工作流提示词会清除黑线再生发)。
|
||||
|
||||
进 Comfy 前若长边 > GROW_B_MAX_SIDE(默认 896)会先等比例缩小,降低峰值显存;
|
||||
输出再拉回原图尺寸。
|
||||
|
||||
use_mask(默认 True):是否启用自动检测的遮罩,用于测试对比。
|
||||
- True:检测手绘线 → 建遮罩 → alpha=255−mask(透明区=重绘区,节点44 画黄色参考区)。
|
||||
- False:跳过检测,直接送划线图,alpha 全 255(空遮罩,节点26 mask 为空),
|
||||
模型仅凭医生黑线参考生发。无需改工作流,唯一变量是遮罩。
|
||||
Returns: {"grown_png": bytes 或 None, "status": "ok"|"no_face"|"no_line"}。
|
||||
"""
|
||||
orig_h, orig_w = marked_bgr.shape[:2]
|
||||
marked_bgr, _scale = _downscale_max_side(marked_bgr, _GROW_B_MAX_SIDE)
|
||||
if _scale < 1.0:
|
||||
logger.info(
|
||||
"接口3 缩图送 Comfy: %dx%d → %dx%d (max_side=%d)",
|
||||
orig_w, orig_h, marked_bgr.shape[1], marked_bgr.shape[0], _GROW_B_MAX_SIDE,
|
||||
)
|
||||
|
||||
h, w = marked_bgr.shape[:2]
|
||||
if use_mask:
|
||||
rgb = cv2.cvtColor(marked_bgr, cv2.COLOR_BGR2RGB)
|
||||
landmarks = get_landmarker().detect(rgb)
|
||||
if landmarks is None:
|
||||
return {"grown_png": None, "status": "no_face"}
|
||||
parse_map = get_parser().parse(rgb)
|
||||
path = detect_marker_hairline(marked_bgr, landmarks, parse_map)
|
||||
if path is None:
|
||||
return {"grown_png": None, "status": "no_line"}
|
||||
line_w = max(2, int(w * 0.006))
|
||||
curve_mask = path_to_curve_mask(path, h, w, thickness=max(3, line_w))
|
||||
mask = mask_from_curve(curve_mask, landmarks, parse_map)
|
||||
else:
|
||||
mask = np.zeros((h, w), np.uint8) # 空遮罩:alpha 全 255,跳过检测
|
||||
|
||||
buf = io.BytesIO()
|
||||
compose_comfy_rgba(marked_bgr, mask).save(buf, format="PNG", compress_level=1) # marked + 遮罩
|
||||
grown_png = comfyui.run(buf.getvalue(), prompt=prompt)
|
||||
if _scale < 1.0 and grown_png:
|
||||
grown_png = _upscale_png_to(grown_png, orig_w, orig_h)
|
||||
return {"grown_png": grown_png, "status": "ok"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
g = sys.argv[2] if len(sys.argv) > 2 else "female"
|
||||
img = cv2.imread(sys.argv[1] if len(sys.argv) > 1 else "tests/fixtures/frontal.jpg")
|
||||
os.makedirs("tests/output", exist_ok=True)
|
||||
print("texture map:", {k: [kp[0] for kp in v] for k, v in get_texture_map().items()})
|
||||
res = generate_previews(img, g)
|
||||
if res is None:
|
||||
print("无人脸")
|
||||
sys.exit(1)
|
||||
for r in res:
|
||||
out = f"tests/output/preview_{g}_{r['hairline_type']}.png"
|
||||
cv2.imwrite(out, r["image_bgr"])
|
||||
print(f" order={r['order']} type={r['hairline_type']} -> {out}")
|
||||
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 6.8 KiB |