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 |
@@ -17,6 +17,10 @@ password.txt
|
||||
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
|
||||
|
||||
@@ -27,3 +31,25 @@ 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"1": {
|
||||
"inputs": {
|
||||
"scheduler": "simple",
|
||||
"steps": 6,
|
||||
"steps": 4,
|
||||
"denoise": 1,
|
||||
"model": [
|
||||
"2",
|
||||
@@ -170,8 +170,8 @@
|
||||
},
|
||||
"16": {
|
||||
"inputs": {
|
||||
"unet_name": "flux2.0/flux-2-klein-9b-fp8.safetensors",
|
||||
"weight_dtype": "fp8_e4m3fn"
|
||||
"unet_name": "flux-2-klein-4b-fp8.safetensors",
|
||||
"weight_dtype": "fp8_e4m3fn_fast"
|
||||
},
|
||||
"class_type": "UNETLoader",
|
||||
"_meta": {
|
||||
@@ -410,7 +410,7 @@
|
||||
},
|
||||
"60": {
|
||||
"inputs": {
|
||||
"text": "补充遮罩区补充遮罩区域内的头发,头发填满遮罩区域。发际线往下挡住额头"
|
||||
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
||||
},
|
||||
"class_type": "JjkText",
|
||||
"_meta": {
|
||||
@@ -419,9 +419,9 @@
|
||||
},
|
||||
"61": {
|
||||
"inputs": {
|
||||
"clip_name": "qwen_3_8b_fp8mixed.safetensors",
|
||||
"clip_name": "qwen_3_4b.safetensors",
|
||||
"type": "flux2",
|
||||
"device": "default"
|
||||
"device": "cpu"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=ComfyUI (127.0.0.1:8188)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=ubuntu
|
||||
WorkingDirectory=/home/ubuntu/ComfyUI
|
||||
ExecStart=/home/ubuntu/ComfyUI/venv/bin/python main.py --listen 127.0.0.1 --port 8188 --cache-classic --fast
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
After Width: | Height: | Size: 304 KiB |
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"request_id": "mock-request-id",
|
||||
"data": {
|
||||
"hairline_id": "chang_zhixian",
|
||||
"gen_backend": "swaphair",
|
||||
"hairgrow_strength": 0.75,
|
||||
"is_hr": false,
|
||||
"seg_model": "segformer",
|
||||
"mask_type": "pushed",
|
||||
"erode_cm": 0.6,
|
||||
"swap_mode": "ext_mask",
|
||||
"blend_method": "multiband",
|
||||
"edge_erode_px": 3,
|
||||
"mb_levels": 5,
|
||||
"hairline_push_cm": 1.0,
|
||||
"hairline_edge": "column",
|
||||
"denoising_strength": 0.6,
|
||||
"color_match": true,
|
||||
"color_match_strength": 1.0,
|
||||
"mb_feather_px": 1,
|
||||
"transition_band_px": -1,
|
||||
"inpainting_fill": 1,
|
||||
"mask_blur": 11,
|
||||
"mask_dilate_scale": 1.0,
|
||||
"px_per_cm": 47.5311,
|
||||
"erode_px": 29,
|
||||
"hair_pixels": 186798,
|
||||
"closed_pixels": 191712,
|
||||
"mask_pixels": 140299,
|
||||
"image_size": {
|
||||
"width": 1257,
|
||||
"height": 1495
|
||||
},
|
||||
"timings_ms": {
|
||||
"mask": 1462,
|
||||
"swap": 5596,
|
||||
"blend": 220
|
||||
},
|
||||
"redraw": {
|
||||
"enabled": false
|
||||
},
|
||||
"_rid": "bc7205a4",
|
||||
"steps": {
|
||||
"input_base64": "<omitted 427407 chars>",
|
||||
"baseline_overlay_base64": "<omitted 435895 chars>",
|
||||
"upper_overlay_base64": "<omitted 384479 chars>",
|
||||
"hair_seg_overlay_base64": "<omitted 428039 chars>",
|
||||
"top_fill_overlay_base64": "",
|
||||
"closed_overlay_base64": "",
|
||||
"hairline_overlay_base64": "<omitted 445467 chars>",
|
||||
"pushed_overlay_base64": "<omitted 452803 chars>",
|
||||
"mask_overlay_base64": "<omitted 420375 chars>",
|
||||
"mask_base64": "<omitted 8026 chars>",
|
||||
"swap_raw_base64": "<omitted 340791 chars>",
|
||||
"hard_paste_base64": "<omitted 420679 chars>",
|
||||
"alpha_base64": "<omitted 7762 chars>",
|
||||
"final_base64": "<omitted 415811 chars>",
|
||||
"redraw_band_overlay_base64": "",
|
||||
"redraw_a_base64": "",
|
||||
"redraw_c_base64": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 304 KiB |
|
After Width: | Height: | Size: 314 KiB |
|
After Width: | Height: | Size: 326 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 313 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 332 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 282 KiB |
@@ -0,0 +1,177 @@
|
||||
# 接口3 B端生发 — 实现文档
|
||||
|
||||
> 文档日期:2026-07-18
|
||||
|
||||
---
|
||||
|
||||
## 一、接口概述
|
||||
|
||||
**接口3** 是 B端(医生/操作端)生发接口。医生在用户照片上手动用马克笔画出发际线后,只需上传这一张划线图,系统自动检测划线 → 生成遮罩 → 送 ComfyUI 生发,返回「植发3个月」效果图。
|
||||
|
||||
**与接口2 的核心区别**:
|
||||
|
||||
| 特性 | 接口2(C端生发) | 接口3(B端生发) |
|
||||
|------|----------------|----------------|
|
||||
| 输入 | 原始照片 | 划线图(含手绘线) |
|
||||
| 发际线来源 | 系统按发型模板自动生成 | 医生手绘标注 |
|
||||
| 发型类型 | ellipse/flower/heart/straight/wave | custom(自定义) |
|
||||
| 中间步骤 | extract_context + swapHair + ComfyUI重绘 | 划线检测 + 遮罩 + ComfyUI生发 |
|
||||
| 是否调 change_hair | 是(女性流程) | 否 |
|
||||
| ComfyUI 工作流 | 0716add-hair-api.json(重绘) | add_hair.json(生发) |
|
||||
| 典型耗时 | ~11s | ~6-8s |
|
||||
|
||||
---
|
||||
|
||||
## 二、接口定义
|
||||
|
||||
### 路由
|
||||
|
||||
```
|
||||
POST /api/v1/hair/grow-b
|
||||
```
|
||||
|
||||
### 入参
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `marked_image_file` | UploadFile | 三选一 | 划线图片文件(JPG/PNG) |
|
||||
| `marked_image_url` | str | 三选一 | 划线图片 URL |
|
||||
| `marked_image_base64` | str | 三选一 | 划线图片 base64 |
|
||||
| `use_mask` | bool | 否(默认True) | 是否自动检测划线并建遮罩。False时跳过检测,直接送划线图 |
|
||||
| `prompt` | str | 否 | ComfyUI 提示词,默认"补充遮罩区域的头发,加一点美颜" |
|
||||
|
||||
### 返回
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"hair_growth_image_base64": "iVBORw0KGgo...(生发图 JPG base64)",
|
||||
"hairline_type": "custom"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
错误码:
|
||||
- `1001`: 无法识别人像 / 未检测到发际线划线
|
||||
- `1007`: 处理失败
|
||||
- `1008`: 图片格式不支持
|
||||
|
||||
---
|
||||
|
||||
## 三、完整调用链
|
||||
|
||||
```
|
||||
POST /api/v1/hair/grow-b
|
||||
│
|
||||
├─ app.py hair_grow_b() [app.py:929]
|
||||
│ ├─ resolve_image_bytes() → marked_raw 解析图片(file/url/base64三选一)
|
||||
│ ├─ cv2.imdecode → marked_bgr 解码为 BGR
|
||||
│ └─ run_in_threadpool(generate_grow_b, ...)
|
||||
│
|
||||
├─ service.py generate_grow_b(marked_bgr, use_mask, prompt) [service.py:381]
|
||||
│ │
|
||||
│ ├─ 步骤1:人脸检测 + 头发分割(仅 use_mask=True 时)
|
||||
│ │ ├─ get_landmarker().detect(rgb) MediaPipe 478点人脸检测
|
||||
│ │ │ → landmarks(无人脸返回 no_face)
|
||||
│ │ ├─ get_parser().parse(rgb) SegFormer 面部分割(CPU ~0.9s)
|
||||
│ │ │ → parse_map(int label map)
|
||||
│ │ │
|
||||
│ ├─ 步骤2:手绘发际线检测(仅 use_mask=True 时)
|
||||
│ │ ├─ detect_marker_hairline(marked_bgr, landmarks, parse_map)
|
||||
│ │ │ │ [marker_detect.py:41]
|
||||
│ │ │ ├─ forehead_upper_region(landmarks) 额头上部 ROI
|
||||
│ │ │ ├─ head_silhouette(parse_map) 头部轮廓 ROI
|
||||
│ │ │ ├─ _blackhat(gray) 黑帽变换(响应比邻域暗的细结构)
|
||||
│ │ │ ├─ _snap_anchor(bh, 左鬓角21) 左锚点吸附
|
||||
│ │ │ ├─ _snap_anchor(bh, 右鬓角251) 右锚点吸附
|
||||
│ │ │ ├─ route_through_array(cost, 左, 右) Dijkstra最小代价路径
|
||||
│ │ │ └→ path (N,2) row,col(拒识返回 None → no_line)
|
||||
│ │ │
|
||||
│ │ ├─ path_to_curve_mask(path) 路径→曲线mask(uint8 0/255)
|
||||
│ │ └─ mask_from_curve(curve_mask, landmarks, parse_map)
|
||||
│ │ │ [mask.py]
|
||||
│ │ ├─ _above_curve_region(curve_mask) 曲线以上区域
|
||||
│ │ ├─ cv2.morphologyEx(闭运算) 填洞
|
||||
│ │ ├─ 最大连通域
|
||||
│ │ └─ 高斯羽化 → mask (uint8 0-255)
|
||||
│ │
|
||||
│ ├─ 步骤3:合成 RGBA PNG
|
||||
│ │ ├─ compose_comfy_rgba(marked_bgr, mask) RGB=原图,alpha=255×(1-mask)
|
||||
│ │ └─ PNG 编码 → rgba_png_bytes
|
||||
│ │
|
||||
│ └─ 步骤4:ComfyUI 生发
|
||||
│ └─ comfyui.run(rgba_png_bytes, prompt) [comfyui.py:87]
|
||||
│ ├─ 上传图片到 ComfyUI /upload/image
|
||||
│ ├─ 加载工作流 add_hair.json
|
||||
│ ├─ 替换节点26输入图 + 节点6随机seed + 节点60提示词
|
||||
│ ├─ POST /prompt 提交工作流
|
||||
│ ├─ 轮询 /history/{prompt_id}(间隔0.2s)
|
||||
│ └─ GET /view 取回输出 PNG → grown_png
|
||||
│
|
||||
└─ 返回 {"grown_png": bytes, "status": "ok"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、用到的模型和外部服务
|
||||
|
||||
| 模型/服务 | 用途 | 位置 | 设备 |
|
||||
|----------|------|------|------|
|
||||
| **FaceLandmarker** (MediaPipe) | 478点人脸检测 | hairline/face_landmarks.py | CPU |
|
||||
| **FaceParser** (SegFormer) | 面部分割(hair/skin/...) | hairline/face_parsing.py | CPU (5090不兼容cu121) |
|
||||
| **ComfyUI** (Flux-2) | 生发图生成 | hairline/comfyui.py → :8188 | GPU |
|
||||
|
||||
**注意**:接口3 **不调用** change_hair 服务(:8801),不需要 swapHair。这是它与接口2女性流程的关键区别。
|
||||
|
||||
---
|
||||
|
||||
## 五、核心算法:手绘发际线检测
|
||||
|
||||
### 5.1 为什么不用简单阈值?
|
||||
|
||||
手绘马克笔线条的灰度值与皮肤阴影、抬头纹等重叠,全局阈值无法区分。采用**黑帽变换 + Dijkstra最小路径**方案。
|
||||
|
||||
### 5.2 黑帽变换(Black Hat)
|
||||
|
||||
```python
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k))
|
||||
bh = cv2.morphologyEx(gray, cv2.MORPH_BLACKHAT, kernel)
|
||||
```
|
||||
|
||||
黑帽 = 闭运算 − 原图,响应"比局部邻域暗的细结构"(即马克笔线条),对抬头纹/眉毛/发丝鲁棒。
|
||||
|
||||
### 5.3 Dijkstra 最小代价路径
|
||||
|
||||
1. **ROI 限定**:额头上部 ∩ 头部轮廓(排除背景)
|
||||
2. **锚点**:左鬓角(21) / 右鬓角(251) MediaPipe 关键点
|
||||
3. **代价图**:`cost = (bh.max() - bh) + 1.0`,ROI外设 1e6
|
||||
4. **路径**:`route_through_array(cost, 左锚, 右锚)` — skimage 的 Dijkstra 实现
|
||||
|
||||
### 5.4 拒识机制
|
||||
|
||||
路径平均黑帽响应 < 8.0 → 判定"未画线",返回 `no_line`。
|
||||
|
||||
---
|
||||
|
||||
## 六、与接口1、接口2 的对比
|
||||
|
||||
| 维度 | 接口1 | 接口2 | 接口3 |
|
||||
|------|-------|-------|-------|
|
||||
| 功能 | 四庭七眼测量 | C端生发(5种发际线) | B端生发(手绘线) |
|
||||
| 路由 | /api/v1/face/measure | /api/v1/hair/grow | /api/v1/hair/grow-b |
|
||||
| 输入 | 正面照 | 正面照 | 划线图 |
|
||||
| MediaPipe | ✅ | ✅ | ✅ |
|
||||
| SegFormer | ✅ | ✅ | ✅ |
|
||||
| change_hair | ❌ | ✅(女性) | ❌ |
|
||||
| ComfyUI | ❌ | ✅(Flux-2重绘) | ✅(Flux-2生发) |
|
||||
| 典型耗时 | ~2s | ~11s | ~6-8s |
|
||||
| ComfyUI工作流 | — | 0716add-hair-api.json | add_hair.json |
|
||||
|
||||
---
|
||||
|
||||
## 七、测试
|
||||
|
||||
- **测试页面**:[static/test_interface3.html](file:///home/ubuntu/hair/static/test_interface3.html)
|
||||
- **测试图片**:[image/girl_img/girl13.jpg](file:///home/ubuntu/hair/image/girl_img/girl13.jpg)(需手动在图上画发际线后作为划线图上传)
|
||||
@@ -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 外推+遮罩 / 最终遮罩 / 生成 / 贴回 / 融合
|
||||
@@ -32,7 +32,7 @@
|
||||
| 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_base64` | `hairline_images[].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 字符串) | — |
|
||||
|
||||
@@ -56,9 +56,10 @@
|
||||
失败回退比例推算(方案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 完全一致(正面照 → 四庭七眼 cm 与占比 + 5 个关键点 + 标注 PNG)。
|
||||
- **怎么实现**:与接口 1 共用 `_face_measure_impl()`,零额外逻辑。对外路径 `/api/v1/face/measure-v2`。
|
||||
### 接口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)—— 预览 + 生发图
|
||||
@@ -67,7 +68,7 @@
|
||||
按 `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;male 1=ellipse 2=inverse_arc 3=m 4=straight。
|
||||
- `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 组预览+生发图)。
|
||||
@@ -88,8 +89,8 @@
|
||||
无人脸→1001。**唯一调外网的接口**:网关需可达 `ark.cn-beijing.volces.com`,API Key 走网关配置(不入 git)。
|
||||
|
||||
### 接口5 发际线PNG生成 `/api/v1/hairline/generate`(worker)
|
||||
- **做什么**:照片 + `gender`(必填) → N 张发际线叠加图(同接口2预览) + 最佳(order1)发际线曲线的面部中间点坐标。
|
||||
- **怎么实现**:复用接口2 的 502 点渲染管线,输出 N 张叠图 + `best_hairline_center_point`(眉心 x × 该处发际线 y)。无生发。
|
||||
- **做什么**:入参同接口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。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
# 接口11 运行记录 — `image/hair_test.jpg`
|
||||
|
||||
> 实测时间:2026-07-15
|
||||
> 调用:`POST http://127.0.0.1:8187/api/v1/hairline/grow`
|
||||
> 鉴权:`X-Internal-Token: dev-shared-secret-2026`
|
||||
> 输入图:`image/hair_test.jpg`(1257×1495)
|
||||
> `hairline_id`:`chang_zhixian`(直线);其余全部走接口默认值
|
||||
> 业务结果:`code=0`,`rid=bc7205a4`
|
||||
> 产物目录:`docs/iface11_hair_test_run/`
|
||||
|
||||
---
|
||||
|
||||
## 1. 本次调用用到的全部默认参数
|
||||
|
||||
未在 Form 里显式传的参数均取 `app.py` / `generate_hairline_grow` 默认值;下表即本次实际生效值。
|
||||
|
||||
| 参数 | 本次值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `hairline_id` | `chang_zhixian` | **必填**。发际线类型 = change_hair 的 `hair_id`(直线) |
|
||||
| `gen_backend` | `swaphair` | 生成后端:换发型 LoRA |
|
||||
| `hairgrow_strength` | `0.75` | 仅 `hairgrow` 后端用;本次未走该路径 |
|
||||
| `is_hr` | `false` | 高清关闭(576×768 档,非 1152×1536) |
|
||||
| `seg_model` | `segformer` | 头发分割模型 |
|
||||
| `erode_cm` | `0.6` | baseline 参考内缩(cm);pushed 下影响很小 |
|
||||
| `hairline_push_cm` | `1.0` | 发际线内轮廓径向外推距离(cm) |
|
||||
| `hairline_edge` | `column` | 兼容入参;当前内轮廓提取不再按它分支 |
|
||||
| `swap_mode` | `ext_mask` | 把 pushed 遮罩作为 `ext_mask` 传给 swapHair |
|
||||
| `edge_erode_px` | `3` | 贴图前遮罩内缩像素 |
|
||||
| `denoising_strength` | `0.6` | 换发型 webui 重绘强度 |
|
||||
| `mb_levels` | `5` | 多频段金字塔层数 |
|
||||
| `blend_method` | `multiband` | 接缝融合:多频段金字塔 |
|
||||
| `color_match` | `true` | 融合前 Reinhard 颜色迁移 |
|
||||
| `color_match_strength` | `1.0` | 颜色迁移强度(全迁移) |
|
||||
| `mb_feather_px` | `1` | 多频段最细层掩码轻羽化 |
|
||||
| `transition_band_px` | `-1` | keep-region 过渡带:自动按层数 `2**n` |
|
||||
| `redraw` | `false` | 发际线带重绘关闭 |
|
||||
| `inpainting_fill` | `1` | change_hair 填充噪声 |
|
||||
| `mask_blur` | `11` | change_hair 遮罩边缘模糊像素 |
|
||||
| `mask_dilate_scale` | `1.0` | change_hair 遮罩膨胀缩放 |
|
||||
| `comfyui_prompt` | `null` | 仅 `redraw`+Flux-2 路用;本次未用 |
|
||||
| `mask_type` | `pushed`(固定) | 代码写死,不可选 |
|
||||
|
||||
图片入参:仅传了 `image_file`(三选一中的文件上传)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 返回元数据(无 base64)
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|-----|
|
||||
| `px_per_cm` | 47.5311(虹膜直径标定) |
|
||||
| `erode_px` | 29(≈ 0.6cm × px_per_cm) |
|
||||
| `hair_pixels` | 186798 |
|
||||
| `closed_pixels` | 191712 |
|
||||
| `mask_pixels` | 140299 |
|
||||
| `image_size` | 1257 × 1495 |
|
||||
| `timings_ms.mask` | 1462 |
|
||||
| `timings_ms.swap` | 5596 |
|
||||
| `timings_ms.blend` | 220 |
|
||||
| `redraw.enabled` | false |
|
||||
| 总耗时(curl) | ≈ 7.4 s |
|
||||
|
||||
完整精简 JSON:`docs/iface11_hair_test_run/response_meta.json`
|
||||
完整原始响应(含 base64):`docs/iface11_hair_test_run/response.json`
|
||||
|
||||
---
|
||||
|
||||
## 3. 管线分步说明与产物
|
||||
|
||||
管线:① pushed 遮罩 → ② swapHair 生成 → ③ 硬贴回 → ④ multiband 融合。
|
||||
各步图保存在 `docs/iface11_hair_test_run/steps/`。
|
||||
|
||||
### ①-a 发际线分割线(baseline)
|
||||
|
||||
- **做什么**:MediaPipe 关键点连成眉骨折线(中心为 151 眉心),并向左右边缘水平延长。
|
||||
- **图**:[`steps/baseline_overlay.jpg`](iface11_hair_test_run/steps/baseline_overlay.jpg)
|
||||
- **含义**:黄线 = baseline;151 中心点为后续径向外推圆心。
|
||||
|
||||
### ①-b 分割线上半区(upper)
|
||||
|
||||
- **做什么**:baseline 折线以上的多边形区域,作为后续裁剪范围。
|
||||
- **图**:[`steps/upper_overlay.jpg`](iface11_hair_test_run/steps/upper_overlay.jpg)
|
||||
- **含义**:青 = 上半区。
|
||||
|
||||
### ①-c 头发分割(hair_seg)
|
||||
|
||||
- **做什么**:SegFormer 得到头发二值掩码。
|
||||
- **图**:[`steps/hair_seg_overlay.jpg`](iface11_hair_test_run/steps/hair_seg_overlay.jpg)
|
||||
- **含义**:绿 = 原始头发像素(本次 `hair_pixels=186798`)。
|
||||
|
||||
### ①-d / ①-e(旧 eroded/closed 中间步)
|
||||
|
||||
- pushed 模式**不走**这两步;返回字段为空字符串。
|
||||
- `top_fill_overlay` / `closed_overlay`:本次无图。
|
||||
|
||||
### ①-f 头发内轮廓线(hairline)
|
||||
|
||||
- **做什么**:取头发朝脸一侧的内轮廓(额头弧 + 两侧到下颌),有序折线。
|
||||
- **图**:[`steps/hairline_overlay.jpg`](iface11_hair_test_run/steps/hairline_overlay.jpg)
|
||||
- **含义**:绿 = 内轮廓;黄 = baseline。
|
||||
|
||||
### ①-g 外推发际线(pushed)
|
||||
|
||||
- **做什么**:以眉心 151 为圆心,内轮廓逐点向外推 `hairline_push_cm=1.0`(≈ 47.5 px),与 baseline 组闭合区域。
|
||||
- **图**:[`steps/pushed_overlay.jpg`](iface11_hair_test_run/steps/pushed_overlay.jpg)
|
||||
- **含义**:青 = 外推线;红 = 外推遮罩区域。
|
||||
|
||||
### ① 最终遮罩
|
||||
|
||||
- **叠加图**:[`steps/mask_overlay.jpg`](iface11_hair_test_run/steps/mask_overlay.jpg) — 红 = 遮罩区(贴回/生成区)
|
||||
- **纯遮罩**:[`steps/mask.png`](iface11_hair_test_run/steps/mask.png) — 白 = 生成/贴回区
|
||||
- 本次 `mask_pixels=140299`;贴图前再内缩 `edge_erode_px=3`。
|
||||
|
||||
### ② 生成全帧(swap_raw)
|
||||
|
||||
- **做什么**:`gen_backend=swaphair` + `swap_mode=ext_mask`,把遮罩交给 change_hair(`:8801`),LoRA=`chang_zhixian`,`denoising_strength=0.6`。
|
||||
- **图**:[`steps/swap_raw.jpg`](iface11_hair_test_run/steps/swap_raw.jpg)
|
||||
- **含义**:生成结果已与原图同分辨率对齐;耗时约 5.6 s。
|
||||
|
||||
### ③ 严格按遮罩贴回(hard_paste)
|
||||
|
||||
- **做什么**:遮罩内用生成图,遮罩外保持原图,无融合。
|
||||
- **图**:[`steps/hard_paste.jpg`](iface11_hair_test_run/steps/hard_paste.jpg)
|
||||
- **含义**:用于对比接缝融合前后差异。
|
||||
|
||||
### ④ 融合权重 alpha + 最终结果
|
||||
|
||||
- **做法**:`blend_method=multiband`,`mb_levels=5`,`color_match=true`(强度 1.0),`mb_feather_px=1`。
|
||||
- **alpha**:[`steps/alpha.png`](iface11_hair_test_run/steps/alpha.png) — 白 = 更多采用生成图
|
||||
- **最终输出**:[`steps/final.jpg`](iface11_hair_test_run/steps/final.jpg)(副本:[`final.jpg`](iface11_hair_test_run/final.jpg))
|
||||
- **输入对照**:[`steps/input.jpg`](iface11_hair_test_run/steps/input.jpg)
|
||||
|
||||
### ⑤ 发际线带重绘(本次关闭)
|
||||
|
||||
`redraw=false`,故 `redraw_band_overlay` / `redraw_a` / `redraw_c` 均为空。
|
||||
|
||||
---
|
||||
|
||||
## 4. 最终输出
|
||||
|
||||
**主结果文件**:[`docs/iface11_hair_test_run/final.jpg`](iface11_hair_test_run/final.jpg)
|
||||
|
||||
含义:同一人、同一发型观感下,按直线发际线类型(`chang_zhixian`)压低发际线后的合成图;遮罩外像素保持原图不动。
|
||||
|
||||
---
|
||||
|
||||
## 5. 复现命令
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "http://127.0.0.1:8187/api/v1/hairline/grow" \
|
||||
-H "X-Internal-Token: dev-shared-secret-2026" \
|
||||
-F "image_file=@image/hair_test.jpg" \
|
||||
-F "hairline_id=chang_zhixian" \
|
||||
-o docs/iface11_hair_test_run/response.json
|
||||
```
|
||||
|
||||
(其余参数全部省略即可走默认值。)
|
||||
|
||||
---
|
||||
|
||||
## 6. 产物清单
|
||||
|
||||
```
|
||||
docs/接口11_hair_test运行记录.md ← 本文档
|
||||
docs/iface11_hair_test_run/
|
||||
final.jpg ← 最终结果
|
||||
response.json ← 完整 API 响应(含 base64)
|
||||
response_meta.json ← 去掉大图的元数据
|
||||
steps/
|
||||
input.jpg
|
||||
baseline_overlay.jpg
|
||||
upper_overlay.jpg
|
||||
hair_seg_overlay.jpg
|
||||
hairline_overlay.jpg
|
||||
pushed_overlay.jpg
|
||||
mask_overlay.jpg
|
||||
mask.png
|
||||
swap_raw.jpg
|
||||
hard_paste.jpg
|
||||
alpha.png
|
||||
final.jpg
|
||||
```
|
||||
@@ -20,7 +20,6 @@
|
||||
| 3 B 端生发 | POST | `/api/v1/hair/grow-b` |
|
||||
| 4 用户特征 | POST | `/api/v1/face/features` |
|
||||
| 5 发际线 PNG 生成 | POST | `/api/v1/hairline/generate` |
|
||||
| 7 C 端生发 v2 | POST | `/api/v1/hair/grow-v2` |
|
||||
|
||||
---
|
||||
|
||||
@@ -87,6 +86,7 @@
|
||||
| 1006 | 文件超出大小限制 | 单文件超过 1 MB |
|
||||
| 1007 | 图片参数错误 | file / url / base64 未传,或同时传了多个(三者严格互斥) |
|
||||
| 1008 | 图片格式不支持 | 非 JPG / PNG |
|
||||
| 1009 | 未授权 | 缺少或错误的 `X-Internal-Token`(`/api/*` 路径鉴权) |
|
||||
|
||||
---
|
||||
|
||||
@@ -109,6 +109,8 @@
|
||||
| four_courts | object | 四庭数据,见下表 |
|
||||
| seven_eyes | object | 七眼数据,见下表 |
|
||||
| landmarks | object | 关键分界点坐标(头顶 / 发际线 / 眉心 / 鼻翼下缘 / 下巴尖),原图像素坐标 |
|
||||
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` |
|
||||
| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:`{ "x": int, "y": int }` |
|
||||
|
||||
`four_courts`(四庭,自上而下):
|
||||
|
||||
@@ -128,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 返回值)
|
||||
|
||||
@@ -162,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 },
|
||||
@@ -179,7 +191,12 @@
|
||||
|
||||
## 接口 6:四庭七眼测量 v2 接口
|
||||
|
||||
**说明**:功能与[接口 1](#接口-1四庭七眼测量标注接口)完全一致,复刻实现。输入用户正面照,返回四庭七眼测量数据和标注 PNG。
|
||||
**说明**:基于[接口 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`
|
||||
|
||||
@@ -189,15 +206,17 @@
|
||||
|
||||
### 输出(data)
|
||||
|
||||
与接口 1 完全相同。详见[接口 1 输出](#接口-1四庭七眼测量标注接口)。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| annotated_image_url | string | 标注图层 PNG URL(透明底,仅标注线/文字,不含人物) |
|
||||
| face_total_height_cm | number | 面部总高度(cm) |
|
||||
| four_courts | object | 四庭数据(顶庭/上庭/中庭/下庭,各含 cm 与 ratio) |
|
||||
| seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距,各含 cm 与 ratio) |
|
||||
| landmarks | object | 五个关键点像素坐标 |
|
||||
| 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 有)。
|
||||
|
||||
### 响应示例
|
||||
|
||||
@@ -208,18 +227,20 @@
|
||||
"request_id": "mock-request-id",
|
||||
"data": {
|
||||
"annotated_image_url": "https://hair.xiangsilian.com/static/sample.jpg",
|
||||
"face_total_height_cm": 13.76,
|
||||
"face_total_height_cm": 10.32,
|
||||
"four_courts": {
|
||||
"top_court_cm": 3.44, "upper_court_cm": 3.44, "middle_court_cm": 3.44, "lower_court_cm": 3.44,
|
||||
"ratios": { "top_court": 0.25, "upper_court": 0.25, "middle_court": 0.25, "lower_court": 0.25 }
|
||||
"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 }
|
||||
"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": {
|
||||
"hair_top": { "x": 540, "y": 120 }, "hairline": { "x": 540, "y": 430 },
|
||||
"brow_center": { "x": 540, "y": 740 }, "nose_bottom": { "x": 540, "y": 1050 },
|
||||
"hairline": { "x": 540, "y": 430 },
|
||||
"brow_center": { "x": 540, "y": 740 },
|
||||
"nose_bottom": { "x": 540, "y": 1050 },
|
||||
"chin_tip": { "x": 540, "y": 1360 }
|
||||
}
|
||||
}
|
||||
@@ -230,10 +251,10 @@
|
||||
|
||||
## 接口 2:C 端生发接口
|
||||
|
||||
**说明**:输入用户正面照 + 性别 + 发型序号(可多选),按指定发际线类型渲染预览图 + 生发图。
|
||||
**说明**:输入用户正面照 + 性别 + 发型序号(可多选),按指定发际线类型渲染发际线曲线透明 PNG + 生发图。
|
||||
|
||||
> **每个方案返回两张图**:`image_url`=「原照片 + 发际线曲线叠加的**预览图**」;`grown_image_url`=
|
||||
> 经 ComfyUI/Flux 的「植发 3 个月**生发后图片**」。两者均已实现,实现简述见 [`实现说明.md`](实现说明.md)。
|
||||
> **每个方案返回两张图**:`image_url`=「发际线曲线**透明 PNG**(仅白色曲线,透明底,需叠加原图显示)」;`grown_image_url`=
|
||||
> 经 ComfyUI/Flux 的「植发 3 个月**生发后图片**」(完整人像照片)。两者均已实现,实现简述见 [`实现说明.md`](实现说明.md)。
|
||||
|
||||
**请求**:`POST /api/v1/hair/grow`
|
||||
|
||||
@@ -244,9 +265,10 @@
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| gender | string | **是** | 性别:`male` / `female`。决定使用的发际线贴图集合 |
|
||||
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),最多不超过该性别的预设数。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight。越界/非法返回 `1007` |
|
||||
| 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)
|
||||
|
||||
@@ -254,14 +276,16 @@
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| image_url | string | 方案**预览图** URL(发际线曲线叠加图) |
|
||||
| grown_image_url | string | **生发后图片** URL(ComfyUI/Flux「植发 3 个月」效果图) |
|
||||
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`(female),`ellipse`/`m`/`straight`/`inverse_arc`(male) |
|
||||
| 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` 叠加结构)。
|
||||
|
||||
### 响应示例
|
||||
|
||||
@@ -299,6 +323,7 @@
|
||||
|------|------|------|------|
|
||||
| marked_image_* | file / string | 是 | 已用马克笔标注发际线的图片,三选一 |
|
||||
| use_mask | bool | 否 | 是否画发际线,默认 `true`。`false` 时跳过划线检测、直接送划线图,模型仅凭手绘黑线生发,供测试对比 |
|
||||
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
|
||||
|
||||
### 输出(data)
|
||||
|
||||
@@ -370,7 +395,7 @@
|
||||
|
||||
## 接口 5:发际线 PNG 生成接口
|
||||
|
||||
**说明**:输入用户照片,返回 N 张用户发际线的 PNG 图片,并返回「最合适发际线」的面部中间点坐标。
|
||||
**说明**:入参同接口2(先选性别、再多选发型)。对每个选中发型返回 `middle` / `high` / `low` **三档**发际线叠图与**生发图**,并返回「最合适发际线」的面部中间点坐标。
|
||||
|
||||
**请求**:`POST /api/v1/hairline/generate`
|
||||
|
||||
@@ -380,21 +405,53 @@
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| gender | string | **是** | 性别:`male` / `female`。决定返回的发际线集合(female 5 / male 4) |
|
||||
| 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 张发际线叠加图(发际线曲线叠在用户照片上,同接口2预览),**数量 = 该性别发际线数**,本期按贴图顺序,元素见下表 |
|
||||
| best_hairline_center_point | object | 最佳(order=1)发际线曲线的「面部中间点」坐标,原图像素:`{ "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 | 发际线叠加图 URL(worker 返回 `image_base64`,网关落盘后改写为 url) |
|
||||
| order | int | 排序序号(本期固定 `1..N`,暂不计算合适度) |
|
||||
| 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 返回值)
|
||||
|
||||
@@ -405,67 +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 }
|
||||
],
|
||||
"best_hairline_center_point": { "x": 540, "y": 430 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 接口 7:C 端生发 v2 接口
|
||||
|
||||
**说明**:功能与[接口 2](#接口-2c-端生发接口)完全一致,仅 ComfyUI 工作流不同——使用 `add_hair2.json` 替代 `add_hair.json`。
|
||||
|
||||
**请求**:`POST /api/v1/hair/grow-v2`
|
||||
|
||||
### 输入
|
||||
|
||||
与接口 2 完全相同。图片参数见「通用约定 → 图片传参字段」。专属参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| gender | string | **是** | 性别:`male` / `female`。决定使用的发际线贴图集合 |
|
||||
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`)。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight。越界/非法返回 `1007` |
|
||||
| beauty_enabled | bool | 否 | 生发图是否带美颜效果,默认 false(当前阶段不生效) |
|
||||
| use_mask | bool | 否 | 是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线) |
|
||||
|
||||
### 输出(data)
|
||||
|
||||
与接口 2 完全相同。`results`:发际线方案数组,**数量 = 所选发型数**。每个元素:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| image_url | string | 方案**预览图** URL(发际线曲线叠加图) |
|
||||
| grown_image_url | string | **生发后图片** URL(ComfyUI/Flux「植发 3 个月」效果图) |
|
||||
| hairline_type | string | 发际线类型 key |
|
||||
| order | int | 排序序号 |
|
||||
|
||||
> ⚠️ 与接口 2 的区别:本接口使用 `add_hair2.json` 工作流(Flux-2 Klein 9b),输入/遮罩节点同为 26,
|
||||
> SaveImage 输出节点为 75。
|
||||
|
||||
### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"request_id": "mock-request-id",
|
||||
"data": {
|
||||
"results": [
|
||||
{
|
||||
"image_url": "https://hair.xiangsilian.com/static/sample.jpg",
|
||||
"grown_image_url": "https://hair.xiangsilian.com/static/sample.jpg",
|
||||
"hairline_type": "ellipse",
|
||||
"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 },
|
||||
"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生发失败项一致。
|
||||
|
||||
---
|
||||
|
||||
## 汇总:输入输出一览
|
||||
@@ -477,8 +524,7 @@
|
||||
| 2 C 端生发 | 用户照片 | 生发后图片 + 指定发际线预览(单/多张) |
|
||||
| 3 B 端生发 | 划线图片 | 最合适发际线图片 + 生发后图片 |
|
||||
| 4 用户特征 | 用户照片 | 6 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格) |
|
||||
| 5 发际线 PNG | 用户照片 | N 张发际线 PNG + 最合适发际线面部中间点坐标 |
|
||||
| 7 C 端生发 v2 | 用户照片 + gender + hair_style | 同接口2,使用 add_hair2.json 工作流 |
|
||||
| 5 发际线 PNG | 用户照片 + gender + hair_style(多选) | 每个选中发型 middle/high/low 三档发际线叠图 + 生发图 + 最合适发际线面部中间点坐标 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -113,25 +113,25 @@ async def hair_grow_v2(request: Request):
|
||||
|
||||
---
|
||||
|
||||
## 6. 🔲【新增】接口6 四庭七眼测量 v2(`/api/v1/face/measure-v2`)
|
||||
## 6. ✅【已完成】接口6 四庭七眼测量 v2(`/api/v1/face/measure-v2`)
|
||||
|
||||
**背景**:worker 侧已新增接口 6,功能与接口 1 完全一致(复刻),共用同一实现。
|
||||
**背景**: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(复刻接口1)"""
|
||||
"""接口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 侧与接口 1 共用 `_face_measure_impl()`,逻辑零差异
|
||||
- worker 侧 v6 差异(去顶庭字段、变体标注)由 `_face_measure_impl` 内部处理,网关透明转发
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
- 四庭水平分界线:numpy 向量化渐变消失(中间亮、两侧渐隐)。
|
||||
- 纵向竖线 8 条:人头最左 + 左脸颊/左眼外/内角/右眼内/外角/右脸颊 + 人头最右,
|
||||
把头宽切 7 段(七眼),段宽数值上下交替(上 3 / 下 4),带虚线双箭头。
|
||||
- 四庭:图片左侧,「名」上「数值」下两行换行(不带 cm),带竖向虚线双箭头。
|
||||
人头最左/最右取自耳朵分割外缘,看不到耳朵则省略该侧(最少 6 点 5 段)。
|
||||
- 四庭:图片左侧,「名」「数值(带 cm)」「百分比」三行换行,带竖向虚线双箭头。
|
||||
- 五条横线右侧标名:头顶/发际线/眉心/鼻翼下缘/下巴尖。
|
||||
- 单位 cm 统一标在底部「单位cm」。
|
||||
- 每段数值直接带 cm 后缀,下方另起一行标百分比(不再单独标底部「单位cm」)。
|
||||
中文字体用打包的思源黑体绝对路径加载,缺字体直接抛错(不静默降级成方块)。
|
||||
"""
|
||||
import os
|
||||
@@ -116,42 +117,84 @@ _LINE_NAMES = {
|
||||
}
|
||||
|
||||
|
||||
def _head_edges_from_mask(hair_mask, y0, y1, left_cheek_x, right_cheek_x, min_gap):
|
||||
"""从头发分割掩膜取人头最左/最右 x(仅在脸纵向范围 [y0,y1] 内统计)。
|
||||
def _grow_outward(start_col, fg_band, direction, limit):
|
||||
"""从 start_col 沿 direction(+1 右 / -1 左) 在前景带 fg_band 内逐列外扩。
|
||||
|
||||
返回 (head_left_x, head_right_x);某侧无掩膜/向内噪声,或离脸颊线过近
|
||||
(间距 < min_gap)则该侧为 None —— 即与脸颊线太近时只保留脸颊线。
|
||||
用于回收被误标成「头发」的外耳轮廓:耳朵被头发遮挡时,外耳轮廓那一圈常被
|
||||
分割并入头发类,故耳朵掩膜外缘会偏内。这里把外缘沿紧邻的前景(耳∪发)向外
|
||||
延伸,最多 limit 列;一旦下一列无前景(背景间隙)立即停止,绝不窜到分离的
|
||||
那缕头发上。返回外扩后的列号。
|
||||
"""
|
||||
if hair_mask is None:
|
||||
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(hair_mask)
|
||||
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
|
||||
band = m[y0:y1 + 1] > 0
|
||||
cols = np.where(band.any(axis=0))[0]
|
||||
ear_band = m[y0:y1 + 1]
|
||||
cols = np.where(ear_band.any(axis=0))[0]
|
||||
if cols.size == 0:
|
||||
return None, None
|
||||
hl, hr = float(cols.min()), float(cols.max())
|
||||
# 仅当确实在脸颊外侧、且与脸颊线间距足够大时才采用
|
||||
return (hl if (left_cheek_x - hl) >= min_gap else None,
|
||||
hr if (hr - right_cheek_x) >= min_gap else 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, hair_mask=None):
|
||||
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);无掩膜(方案 A 兜底)时省略头部端线,
|
||||
只画 6 点 5 段。
|
||||
取自耳朵分割掩膜的外缘(方案 B,BiSeNet 类 7/8);耳朵不可见(被头发/侧脸
|
||||
遮挡 → 掩膜空)或无掩膜时省略该侧端线,只画对应脸颊线。
|
||||
- 横向 5 条分界线:头顶/发际线/眉心/鼻翼下缘/下巴尖,右侧标名。
|
||||
- 四庭(顶/上/中/下庭)在左侧:名 + 数值两行换行(无 cm),竖向虚线双箭头。
|
||||
- 七眼段宽数值上下交替(上 3 / 下 4,无 cm),横向虚线双箭头。
|
||||
- 底部统一标「单位cm」。
|
||||
- 四庭(顶/上/中/下庭)在左侧:名 + 数值(带 cm) + 百分比三行换行,竖向虚线双箭头。
|
||||
- 七眼段宽上下交替(上 3 / 下 4):数值(带 cm) 上、百分比(占头宽比)下,横向虚线双箭头。
|
||||
|
||||
variant="v6"(接口6):去掉头顶横线与顶庭(只画发际线/眉心/鼻翼下缘/下巴尖 4 条
|
||||
横线 + 上/中/下庭),竖线纵向范围改为发际线→下巴尖,且不画人头最左/最右端线
|
||||
(仅七眼 6 点 5 段,不取头部端)。
|
||||
"""
|
||||
h, w = image_bgr.shape[:2]
|
||||
v = measure_result.vertical
|
||||
@@ -159,31 +202,40 @@ def create_annotated_image(image_bgr, measure_result, hair_mask=None):
|
||||
|
||||
# --- 自适应尺寸:字号/线宽/虚线/箭头按短边缩放 ---
|
||||
s = min(w, h)
|
||||
font_size = max(11, round(s * 0.026)) # 字体更小
|
||||
font_size = max(9, round(s * 0.020)) # 字号上调一档
|
||||
line_w = max(1, round(s * 0.0022))
|
||||
dash_len = max(4, round(s * 0.013))
|
||||
gap_len = max(4, round(dash_len * 1.2)) # 虚线更稀疏(间隙>划线)
|
||||
arrow_size = max(2, round(s * 0.007)) # 箭头更小
|
||||
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))
|
||||
|
||||
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]
|
||||
|
||||
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]
|
||||
# 人头最左/最右:取自头发分割掩膜(方案B),脸纵向范围内统计;无掩膜则省略。
|
||||
# 与脸颊线间距 < 脸宽×8% 视为太近,只保留脸颊线(不画头部端线)。
|
||||
face_w = pts["right_cheek"][0] - pts["left_cheek"][0]
|
||||
min_gap = max(1.0, face_w * 0.08)
|
||||
head_l, head_r = _head_edges_from_mask(
|
||||
hair_mask, ys[0], ys[-1], pts["left_cheek"][0], pts["right_cheek"][0], min_gap)
|
||||
head_xs = [x for x in (head_l, head_r) if x is not None]
|
||||
xs = sorted(base_xs + head_xs) # 自左向右(6 或 7/8 点)
|
||||
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]
|
||||
@@ -191,77 +243,113 @@ def create_annotated_image(image_bgr, measure_result, hair_mask=None):
|
||||
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. 横向 5 条分界线(渐变,覆盖头宽并超出一点) ---
|
||||
# --- 1. 横向分界线(渐变,覆盖头宽并超出一点) ---
|
||||
for cy in ys:
|
||||
draw_gradient_horizontal_line(buf, face_cx, cy, half_length=face_half, width=line_w)
|
||||
|
||||
# --- 2. 纵向竖线(渐变,超出头顶/下巴一点) ---
|
||||
# --- 2. 纵向竖线(渐变,覆盖 v_top→v_bot) ---
|
||||
for vx in xs:
|
||||
draw_gradient_vertical_line(buf, vx, fy0 - over, fy1 + over, width=line_w)
|
||||
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_size)
|
||||
|
||||
# --- 3a. 横线右侧:线名(头顶/发际线/眉心/鼻翼下缘/下巴尖),文字在线上方 ---
|
||||
name_x = fx1 + pad
|
||||
name_gap = max(2, round(pad * 0.5)) # 文字底部到线的间距
|
||||
# --- 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)
|
||||
|
||||
# --- 3a. 横线右侧:线名(头顶/发际线/眉心/鼻翼下缘/下巴尖),文字纵向居中对齐到线 ---
|
||||
name_x = fx1 + over + pad # 移到横线右端外侧一点(往右)
|
||||
for i, name in enumerate(order):
|
||||
text = _LINE_NAMES[name]
|
||||
tw, th = _text_size(draw, text, font)
|
||||
tw, _ = _text_size(draw, text, font)
|
||||
x = min(name_x, w - 2 - tw) # 右侧越界时回收
|
||||
draw.text((x, max(2, ys[i] - th - name_gap)), text, fill=LINE_COLOR, font=font)
|
||||
# anchor="lm":x 为左、y 为竖直中点 → 文字中线正好压在横线上(与线对齐)
|
||||
draw.text((x, ys[i]), text, fill=LINE_COLOR, font=font, anchor="lm")
|
||||
|
||||
# --- 3b. 左侧四庭:名 + 数值两行(无 cm)+ 竖向虚线双箭头 ---
|
||||
court_cm = [measure_result.top_cm, measure_result.upper_cm,
|
||||
measure_result.middle_cm, measure_result.lower_cm]
|
||||
court_name = ["顶庭", "上庭", "中庭", "下庭"]
|
||||
# court_start:庭段在 order 里的起始索引。发际线弃用时 order 首位是头顶(无下界发际线,
|
||||
# 顶/上庭不标),中庭从眉心开始 → 跳过 order[0]。
|
||||
if getattr(measure_result, "hairline_discarded", False):
|
||||
court_cm = [measure_result.middle_cm, measure_result.lower_cm]
|
||||
court_name = ["中庭", "下庭"]
|
||||
n_court = 2
|
||||
court_start = 1
|
||||
elif variant == "v6":
|
||||
court_cm = [measure_result.upper_cm, measure_result.middle_cm, measure_result.lower_cm]
|
||||
court_name = ["上庭", "中庭", "下庭"]
|
||||
n_court = 3
|
||||
court_start = 0
|
||||
else:
|
||||
court_cm = [measure_result.top_cm, measure_result.upper_cm,
|
||||
measure_result.middle_cm, measure_result.lower_cm]
|
||||
court_name = ["顶庭", "上庭", "中庭", "下庭"]
|
||||
n_court = 4
|
||||
court_start = 0
|
||||
arrow_x = max(arrow_size + 1, fx0 - pad) # 竖箭头所在 x(脸左侧,贴近最左竖线)
|
||||
for i in range(4):
|
||||
y_a, y_b = ys[i], ys[i + 1]
|
||||
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}"
|
||||
val = f"{court_cm[i]:.2f}cm"
|
||||
pct = f"{court_cm[i] / court_total * 100:.1f}%"
|
||||
nw, _ = _text_size(draw, name, font)
|
||||
vw, _ = _text_size(draw, val, font)
|
||||
pw, _ = _text_size(draw, pct, font)
|
||||
label_right = arrow_x - pad
|
||||
y_mid = (y_a + y_b) / 2
|
||||
y_top = y_mid - line_h
|
||||
y_top = y_mid - 1.5 * line_h
|
||||
draw.text((max(2, label_right - nw), y_top), name, fill=LINE_COLOR, font=font)
|
||||
draw.text((max(2, label_right - vw), y_top + line_h), val, fill=LINE_COLOR, font=font)
|
||||
draw.text((max(2, label_right - pw), y_top + 2 * line_h), pct, fill=LINE_COLOR, font=font)
|
||||
|
||||
# --- 4. 七眼每段宽度:上下交替(上 3 / 下 4),横向虚线双箭头 + 数值(无 cm) ---
|
||||
# 文字与箭头间留出「箭头高度 + pad」,避免文字压住箭头
|
||||
txt_off = arrow_size + pad
|
||||
y_arrow_top = max(txt_off + font_size + 2, fy0 - pad - arrow_size)
|
||||
y_arrow_bot = min(h - txt_off - font_size - 2, fy1 + pad + arrow_size)
|
||||
# --- 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
|
||||
text = f"{seg_cm:.2f}"
|
||||
tw, th = _text_size(draw, text, font)
|
||||
val = f"{seg_cm:.2f}cm"
|
||||
pct = f"{seg_pct:.1f}%"
|
||||
vw, _ = _text_size(draw, val, font)
|
||||
pw, _ = _text_size(draw, pct, font)
|
||||
inset = min(arrow_size, (x_b - x_a) * 0.12)
|
||||
on_top = (i % 2 == 1) # 奇数段在上 → 上 3 / 下 4
|
||||
y_arrow = y_arrow_top if on_top else y_arrow_bot
|
||||
draw_dashed_line_with_arrows(
|
||||
draw, x_a + inset, y_arrow, x_b - inset, y_arrow,
|
||||
dash_len=dash_len, gap_len=gap_len, arrow_size=arrow_size, width=line_w)
|
||||
ty = (y_arrow - th - txt_off) if on_top else (y_arrow + txt_off)
|
||||
draw.text((cx_seg - tw / 2, ty), text, fill=LINE_COLOR, font=font)
|
||||
|
||||
# --- 5. 底部统一单位 ---
|
||||
unit = "单位cm"
|
||||
uw, uh = _text_size(draw, unit, font)
|
||||
draw.text(((w - uw) / 2, h - uh - max(2, pad)), unit, fill=LINE_COLOR, font=font)
|
||||
# 数值行在上、百分比行在下;on_top 时整块置于箭头上方,否则下方
|
||||
text_top = (y_arrow - txt_off - txt_block) if on_top else (y_arrow + txt_off)
|
||||
draw.text((cx_seg - vw / 2, text_top), val, fill=LINE_COLOR, font=font)
|
||||
draw.text((cx_seg - pw / 2, text_top + line_h), pct, fill=LINE_COLOR, font=font)
|
||||
|
||||
return canvas
|
||||
|
||||
@@ -285,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, hair_mask=mask)
|
||||
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(竖轴)转 → 左右扭头
|
||||
|
||||
@@ -37,7 +37,8 @@ _PROMPT = (
|
||||
"基因风格(戏剧型/睿智型/自然型/古典型/优雅型/浪漫型/前卫型/少女型/少年型)"
|
||||
)
|
||||
|
||||
_client = None
|
||||
_client = None # 缓存的 Ark client(api_key 变更时自动重建)
|
||||
_client_key: str | None = None # _client 构建时使用的 api_key,用于检测配置变更
|
||||
|
||||
|
||||
def _load_api_key() -> str | None:
|
||||
@@ -60,13 +61,21 @@ def _load_api_key() -> str | None:
|
||||
|
||||
|
||||
def get_client():
|
||||
global _client
|
||||
if _client is None:
|
||||
"""返回 Ark client。
|
||||
|
||||
client 全局缓存,但每次都会重新读取 api_key —— 一旦配置(环境变量 /
|
||||
worker_config.json / gateway/config.json 的 ark_api_key)发生变化,
|
||||
自动重建 client。这样换 key 后无需重启进程。
|
||||
"""
|
||||
global _client, _client_key
|
||||
key = _load_api_key()
|
||||
if not key:
|
||||
raise RuntimeError("缺少火山方舟 API Key(设 ARK_API_KEY 或 worker_config.json.ark_api_key)")
|
||||
# client 未建、或 key 变了 → 重建
|
||||
if _client is None or key != _client_key:
|
||||
from volcenginesdkarkruntime import Ark
|
||||
key = _load_api_key()
|
||||
if not key:
|
||||
raise RuntimeError("缺少火山方舟 API Key(设 ARK_API_KEY 或 worker_config.json.ark_api_key)")
|
||||
_client = Ark(base_url=ARK_BASE_URL, api_key=key)
|
||||
_client_key = key
|
||||
return _client
|
||||
|
||||
|
||||
@@ -104,9 +113,22 @@ def analyze_features(image_bytes: bytes = None, image_url: str = None):
|
||||
{"type": "text", "text": _PROMPT},
|
||||
],
|
||||
}],
|
||||
max_tokens=1024, # 限制输出长度,模型秒回
|
||||
temperature=0, # 固定输出,无随机采样,提速+结果稳定
|
||||
stream=False, # 关闭流式,单次返回结果更快
|
||||
extra_body={
|
||||
"thinking": {
|
||||
"type": "disabled", # 彻底关闭深度思考模式,提速50%+
|
||||
},
|
||||
},
|
||||
)
|
||||
text = resp.choices[0].message.content
|
||||
raw = _parse_json(text) # doubao 原始中文字段
|
||||
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 缺某字段则跳过)
|
||||
|
||||
@@ -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]
|
||||
@@ -15,10 +15,15 @@ from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, File, Form, Request, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
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,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 日志
|
||||
@@ -43,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
|
||||
@@ -105,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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 健康检查(网关自身)
|
||||
@@ -189,6 +200,7 @@ async def index():
|
||||
"service": "旷视五接口 — 网关",
|
||||
"version": "0.1.0",
|
||||
"docs": "/docs",
|
||||
"stats": "/admin/stats",
|
||||
"integration_guide": "/static/integration.html",
|
||||
"test_pages": {
|
||||
"if1_measure": "/static/test_interface1.html",
|
||||
@@ -196,11 +208,268 @@ async def index():
|
||||
"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()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 代理路由
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -217,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(需带前缀)"},
|
||||
}
|
||||
@@ -240,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端生发"""
|
||||
@@ -254,7 +529,7 @@ async def hair_grow_b(request: Request):
|
||||
|
||||
@app.post("/api/v1/face/features", tags=["人脸分析"])
|
||||
async def face_features(
|
||||
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG,≤ 1 MB)"),
|
||||
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(需带前缀)"),
|
||||
):
|
||||
@@ -271,14 +546,7 @@ async def face_features(
|
||||
|
||||
img_bytes = None
|
||||
if image_file:
|
||||
raw = await image_file.read()
|
||||
# TODO: 临时取消限制,后续恢复
|
||||
# if len(raw) > 1_000_000:
|
||||
# return JSONResponse(status_code=200, content={
|
||||
# "code": 1006, "message": "文件超出 1 MB 限制",
|
||||
# "request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
|
||||
# })
|
||||
img_bytes = raw
|
||||
img_bytes = await image_file.read()
|
||||
elif image_base64:
|
||||
b64 = image_base64
|
||||
if "," in b64:
|
||||
@@ -327,3 +595,15 @@ async def hairline_generate(request: Request):
|
||||
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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -162,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
"""ComfyUI 客户端:用 add_hair.json / add_hair2.json 工作流跑生发图(Flux-2 inpaint)。
|
||||
|
||||
worker 不跑 Flux,只把「划线图 + 遮罩」的 RGBA 上传到本机 ComfyUI(默认 8188),
|
||||
worker 不跑 Flux,只把「划线图 + 遮罩」的 RGBA 上传到远端 ComfyUI
|
||||
(默认 http://10.60.74.221:8188,可用环境变量 COMFYUI_URL 覆盖),
|
||||
替换工作流节点 26 的输入图、随机 seed,提交 /prompt,轮询 /history,取回 /view 输出。
|
||||
ComfyUI 开启了 HTTP Basic Auth(user `admin` + 密码),所有请求都带凭据。
|
||||
ComfyUI 若开启了 HTTP Basic Auth(user `admin` + 密码),所有请求都带凭据。
|
||||
|
||||
支持多工作流:run() 可通过 workflow_path 指定不同工作流 JSON,自动检测 SaveImage 输出节点。
|
||||
"""
|
||||
@@ -10,6 +11,7 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
@@ -84,11 +86,13 @@ def _get_output_node(workflow_path: str | None = None) -> str:
|
||||
|
||||
|
||||
def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = None,
|
||||
workflow_path: str | None = None) -> bytes:
|
||||
workflow_path: str | None = None, front: bool = False) -> bytes:
|
||||
"""提交一次生发任务,返回输出 PNG 字节。失败抛异常。
|
||||
|
||||
prompt:非 None 时替换工作流节点60(JjkText)的文本;None 时用工作流内置默认提示词。
|
||||
workflow_path:工作流 JSON 路径,None 则用默认 add_hair.json。
|
||||
front:True 时任务插到 ComfyUI 队列最前(server 端 "front" 字段,队列号取负)。
|
||||
接口2 对时延敏感用 True,避免排在接口3/5 的批量任务后面;其余接口保持 False。
|
||||
"""
|
||||
path = workflow_path or _WORKFLOW_DEFAULT
|
||||
output_node = _get_output_node(path)
|
||||
@@ -103,14 +107,40 @@ def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = Non
|
||||
name = (up.get("subfolder") + "/" if up.get("subfolder") else "") + up["name"]
|
||||
|
||||
# 2. 改工作流:节点26 输入图 + 随机 seed
|
||||
try:
|
||||
import io as _io
|
||||
from PIL import Image as _Img
|
||||
_sz = _Img.open(_io.BytesIO(rgba_png_bytes)).size
|
||||
logging.getLogger("hair.worker").info(
|
||||
"ComfyUI 输入尺寸 %dx%d workflow=%s", _sz[0], _sz[1], os.path.basename(path))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
wf = copy.deepcopy(_load_workflow(path))
|
||||
wf[_INPUT_NODE]["inputs"]["image"] = name
|
||||
wf[_SEED_NODE]["inputs"]["noise_seed"] = random.randint(0, 2**63 - 1)
|
||||
if prompt is not None:
|
||||
wf[_PROMPT_NODE]["inputs"]["text"] = prompt
|
||||
|
||||
# 3. 提交
|
||||
r = cli.post("/prompt", json={"prompt": wf, "client_id": client_id})
|
||||
# 诊断:落盘实际提交的工作流 + 输入图,便于和手动 ComfyUI 跑的对比
|
||||
try:
|
||||
import os as _os
|
||||
_diag = _os.path.join(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))),
|
||||
"log", "comfyui_last_submit")
|
||||
_os.makedirs(_diag, exist_ok=True)
|
||||
with open(_os.path.join(_diag, "workflow.json"), "w", encoding="utf-8") as _f:
|
||||
json.dump(wf, _f, ensure_ascii=False, indent=2)
|
||||
with open(_os.path.join(_diag, "input.png"), "wb") as _f:
|
||||
_f.write(rgba_png_bytes)
|
||||
with open(_os.path.join(_diag, "prompt.txt"), "w", encoding="utf-8") as _f:
|
||||
_f.write(prompt if prompt is not None else "(None=用工作流内置默认)")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
# 3. 提交(front=True 时插队到队列最前)
|
||||
payload = {"prompt": wf, "client_id": client_id}
|
||||
if front:
|
||||
payload["front"] = True
|
||||
r = cli.post("/prompt", json=payload)
|
||||
r.raise_for_status()
|
||||
prompt_id = r.json()["prompt_id"]
|
||||
|
||||
@@ -129,7 +159,7 @@ def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = Non
|
||||
outputs = entry.get("outputs")
|
||||
if outputs and output_node in outputs:
|
||||
break
|
||||
time.sleep(1.0)
|
||||
time.sleep(0.05)
|
||||
if not outputs or output_node not in outputs:
|
||||
raise TimeoutError(f"ComfyUI 出图超时({timeout}s) prompt_id={prompt_id}")
|
||||
|
||||
|
||||
@@ -186,6 +186,53 @@ def smooth_hairline_corner_aware(
|
||||
return out
|
||||
|
||||
|
||||
def clamp_hairline_to_silhouette(
|
||||
hairline_norm: np.ndarray,
|
||||
parse_map: np.ndarray,
|
||||
margin_px: float = 2.0,
|
||||
) -> np.ndarray:
|
||||
"""把发际线点的 y 钳制在 (skin∪hair) silhouette 上沿之下(不含 margin 以上)。
|
||||
|
||||
根因(见 issue:男性 ellipse 发际线贴到头部外面):`sample_hairline` 对射线
|
||||
未命中 hair 像素的锚点会 fallback 成「锚点 + 固定 0.18 归一化偏移」,与头部实际
|
||||
大小/位置无关 —— 短发/剃光头场景下这个偏移量常常把点顶到头部轮廓外面的背景里,
|
||||
在有效/失效锚点交界处形成尖角,被贴图上的不透明像素蒙到就会露出戳出头部的线条。
|
||||
|
||||
本函数在几何检测之后追加一步「安全网」:对每个点按其 x 所在列,取 silhouette
|
||||
(SegFormer skin∪hair 类,近似头部实际轮廓)上沿 y,若点比这个上沿还高(y 更
|
||||
小),直接钳制到 上沿 + margin_px —— 保证曲线永远不会跑到头部轮廓外面的背景。
|
||||
"""
|
||||
h, w = parse_map.shape
|
||||
cols_with_head, top_y = _head_top_y_per_column(parse_map, use_full_hair=True)
|
||||
if cols_with_head.size == 0:
|
||||
return hairline_norm
|
||||
out = hairline_norm.copy()
|
||||
for i in range(out.shape[0]):
|
||||
x_px = float(out[i, 0]) * w
|
||||
idx = int(np.searchsorted(cols_with_head, x_px))
|
||||
idx = min(max(idx, 0), cols_with_head.size - 1)
|
||||
sil_y = float(top_y[idx]) + margin_px
|
||||
y_px = float(out[i, 1]) * h
|
||||
if y_px < sil_y:
|
||||
out[i, 1] = sil_y / h
|
||||
return out
|
||||
|
||||
|
||||
def sample_hairline_clamped(
|
||||
landmarks_norm: np.ndarray,
|
||||
parse_map: np.ndarray,
|
||||
fallback_extrapolation: float = 0.18,
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""策略 A(baseline + 头部轮廓钳制):与默认 `sample_hairline` 完全一致的检测,
|
||||
额外用 `clamp_hairline_to_silhouette` 兜底 —— 检测失效 fallback 出的点不再可能
|
||||
跑到头部外面的背景,而是贴着头部实际轮廓顶部。改动小、风险低,只在检测失效/
|
||||
fallback 越界时才生效,正常长发照片的结果与 baseline 完全一致。
|
||||
"""
|
||||
hairline, valid = sample_hairline(landmarks_norm, parse_map, fallback_extrapolation)
|
||||
hairline = clamp_hairline_to_silhouette(hairline, parse_map)
|
||||
return hairline, valid
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Alternative hairline sampling strategies.
|
||||
#
|
||||
|
||||
@@ -0,0 +1,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)
|
||||
@@ -14,28 +14,100 @@ from . import constants as C
|
||||
from . import comfyui
|
||||
from .face_landmarks import FaceLandmarker
|
||||
from .face_parsing import FaceParser
|
||||
from .hairline_2d import sample_hairline, smooth_hairline
|
||||
from .hairline_2d import (
|
||||
smooth_hairline, sample_hairline_clamped,
|
||||
)
|
||||
from .lift_3d import lift_hairline_to_3d, build_middle_row, assemble_full
|
||||
from .render import load_ext_mesh, load_texture_rgba, render_hairline_overlay, build_overlay_layer
|
||||
from .mask import build_inpaint_mask, compose_comfy_rgba, mask_from_curve
|
||||
from .marker_detect import detect_marker_hairline, path_to_curve_mask
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("hair.worker")
|
||||
|
||||
# 接口2 女性发型 key → change_hair hair_id(chang_*)映射:换发型+Flux-2 整帧重绘用。
|
||||
# 与接口12 final 的 5 型一一对应。female 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")
|
||||
|
||||
# ⚠️ 本 worker 是 RTX 5090(sm_120),torch 2.2.2(cu121) 只编到 sm_90,CUDA 跑算子会报
|
||||
# "no kernel image"。SegFormer 默认走 CPU(~2.5s/张)。换 torch cu128 后可设 SEG_DEVICE=cuda。
|
||||
_SEG_DEVICE = os.getenv("SEG_DEVICE", "cpu")
|
||||
# 三接口(接口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_map = None
|
||||
_texture_maps: dict = {} # {level: {gender: [(key, path)]}},按档位缓存
|
||||
|
||||
|
||||
def get_landmarker() -> FaceLandmarker:
|
||||
@@ -61,24 +133,32 @@ def _gender_key(stem: str):
|
||||
return None, None
|
||||
|
||||
|
||||
def get_texture_map() -> dict:
|
||||
"""扫描 hairline_texture/ 建 {gender: [(key, path)]},按 key 排序、缓存。
|
||||
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 序号。
|
||||
"""
|
||||
global _texture_map
|
||||
if _texture_map is not None:
|
||||
return _texture_map
|
||||
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_DIR, "*.png"))):
|
||||
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:
|
||||
mapping[g].sort(key=lambda kp: kp[0])
|
||||
_texture_map = mapping
|
||||
return _texture_map
|
||||
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):
|
||||
@@ -90,13 +170,19 @@ def extract_502(image_bgr: np.ndarray):
|
||||
|
||||
|
||||
def extract_context(image_bgr: np.ndarray):
|
||||
"""照片(BGR) → {landmarks, parse_map, points, valid}。无人脸返回 None。"""
|
||||
"""照片(BGR) → {landmarks, parse_map, points, valid}。无人脸返回 None。
|
||||
|
||||
发际线几何检测固定用 `sample_hairline_clamped`(射线检测 + 头部轮廓钳制):
|
||||
短发/剃光头照片(如 man_test.jpg)中间锚点检测失效时,纯射线检测的固定 fallback
|
||||
偏移会把点顶到头部轮廓外面的背景,产生"发际线贴到头部外面"的视觉 bug;钳制兜底后
|
||||
fallback 点不会再跑出头部轮廓,正常长发照片结果与旧行为一致。
|
||||
"""
|
||||
rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
|
||||
landmarks = get_landmarker().detect(rgb)
|
||||
if landmarks is None:
|
||||
return None
|
||||
parse_map = get_parser().parse(rgb)
|
||||
hairline_2d, valid = sample_hairline(landmarks, parse_map)
|
||||
hairline_2d, valid = sample_hairline_clamped(landmarks, parse_map)
|
||||
hairline_2d = smooth_hairline(hairline_2d, valid)
|
||||
hairline_3d = lift_hairline_to_3d(landmarks, hairline_2d)
|
||||
middle_3d = build_middle_row(landmarks, hairline_3d)
|
||||
@@ -131,15 +217,16 @@ def generate_previews(image_bgr: np.ndarray, gender: str):
|
||||
def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = True,
|
||||
prompt: str = None, hair_styles: list[int] | None = None,
|
||||
workflow_path: str | None = None):
|
||||
"""指定发际线类型:预览图(白线) + 生发图(ComfyUI)。
|
||||
"""指定发际线类型:发际线透明叠图(白线 RGBA) + 生发图(ComfyUI)。
|
||||
|
||||
hair_styles(1-indexed 列表):指定生成哪几张发际线(按贴图排序)。female: 1..5,male: 1..4。
|
||||
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","image_bgr"(预览), "grown_png"(bytes 或 None)}。
|
||||
Returns: list[dict] {"hairline_type","order","overlay"((H,W,4) RGBA 透明层),
|
||||
"grown_png"(bytes 或 None)}。
|
||||
无人脸返回 None。某张 ComfyUI 失败时该项 grown_png=None,不抛异常。
|
||||
"""
|
||||
if gender not in ("male", "female"):
|
||||
@@ -160,16 +247,22 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
|
||||
if not use_mask:
|
||||
try:
|
||||
h, w = image_bgr.shape[:2]
|
||||
img_s, msk_s, gsc = _prep_comfy_input(image_bgr, np.zeros((h, w), np.uint8))
|
||||
buf = io.BytesIO()
|
||||
compose_comfy_rgba(image_bgr, np.zeros((h, w), np.uint8)).save(buf, format="PNG")
|
||||
shared_grown = comfyui.run(buf.getvalue(), prompt=prompt, workflow_path=workflow_path)
|
||||
compose_comfy_rgba(img_s, msk_s).save(buf, format="PNG", compress_level=1)
|
||||
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前
|
||||
shared_grown = comfyui.run(buf.getvalue(), prompt=prompt,
|
||||
workflow_path=workflow_path, front=True)
|
||||
if gsc < 1.0 and shared_grown:
|
||||
shared_grown = _upscale_png_to(shared_grown, w, h)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("接口2 生发图失败(无遮罩):%s", e)
|
||||
|
||||
results = []
|
||||
h, w = image_bgr.shape[:2]
|
||||
for order, (key, white_path) in items:
|
||||
white = load_texture_rgba(white_path)
|
||||
preview = render_hairline_overlay(image_bgr, ctx["points"], ext_faces, uv, white)
|
||||
overlay = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)
|
||||
|
||||
if not use_mask:
|
||||
grown_png = shared_grown
|
||||
@@ -179,24 +272,200 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
|
||||
black = load_texture_rgba(_black_texture_path(white_path))
|
||||
marked, mask = build_inpaint_mask(
|
||||
image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black)
|
||||
m_s, msk_s, gsc = _prep_comfy_input(marked, mask)
|
||||
buf = io.BytesIO()
|
||||
compose_comfy_rgba(marked, mask).save(buf, format="PNG")
|
||||
grown_png = comfyui.run(buf.getvalue(), prompt=prompt, workflow_path=workflow_path)
|
||||
compose_comfy_rgba(m_s, msk_s).save(buf, format="PNG", compress_level=1)
|
||||
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前
|
||||
grown_png = comfyui.run(buf.getvalue(), prompt=prompt,
|
||||
workflow_path=workflow_path, front=True)
|
||||
if gsc < 1.0 and grown_png:
|
||||
grown_png = _upscale_png_to(grown_png, w, h)
|
||||
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
|
||||
logger.warning("接口2 生发图失败 type=%s:%s", key, e)
|
||||
|
||||
results.append({"hairline_type": key, "order": order,
|
||||
"image_bgr": preview, "grown_png": grown_png})
|
||||
"overlay": overlay, "grown_png": grown_png})
|
||||
return results
|
||||
|
||||
|
||||
def generate_hairline_pngs(image_bgr: np.ndarray, gender: str):
|
||||
"""接口5:该性别全部发际线叠图(同接口2预览) + 最佳(order1)发际线曲线的面部中间点。
|
||||
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)+ 生发图。
|
||||
|
||||
Returns: {"images":[{hairline_type,order,image_bgr}], "best_center":(x,y)};无人脸 None。
|
||||
生发图来源按发型分两路:
|
||||
- 换发型(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
|
||||
@@ -206,20 +475,88 @@ def generate_hairline_pngs(image_bgr: np.ndarray, gender: str):
|
||||
# 面部中轴 x = 眉心(9/151 中点)
|
||||
face_cx = float((lm[9, 0] + lm[151, 0]) / 2 * w)
|
||||
|
||||
textures = get_texture_map()[gender]
|
||||
images, best_center = [], None
|
||||
for order, (key, path) in enumerate(textures, start=1):
|
||||
white = load_texture_rgba(path)
|
||||
preview = render_hairline_overlay(image_bgr, ctx["points"], ext_faces, uv, white)
|
||||
images.append({"hairline_type": key, "order": order, "image_bgr": preview})
|
||||
if order == 1: # 最佳发际线曲线的中点(面部中轴处的发际线 y)
|
||||
overlay = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)
|
||||
ys, xs = np.where(overlay[:, :, 3] > 40)
|
||||
if xs.size:
|
||||
near = np.abs(xs - face_cx) <= max(2, int(w * 0.02))
|
||||
col_ys = ys[near] if near.any() else ys[np.argsort(np.abs(xs - face_cx))[:20]]
|
||||
best_center = (int(round(face_cx)), int(round(float(col_ys.mean()))))
|
||||
return {"images": images, "best_center": best_center}
|
||||
# 三档贴图表(同性别、同 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):
|
||||
@@ -228,12 +565,23 @@ def generate_grow_b(marked_bgr: np.ndarray, use_mask: bool = True, prompt: str =
|
||||
检测路径只用来**建遮罩**;ComfyUI 输入图用 **marked 原图**(含医生手绘线,
|
||||
工作流提示词会清除黑线再生发)。
|
||||
|
||||
进 Comfy 前若长边 > GROW_B_MAX_SIDE(默认 896)会先等比例缩小,降低峰值显存;
|
||||
输出再拉回原图尺寸。
|
||||
|
||||
use_mask(默认 True):是否启用自动检测的遮罩,用于测试对比。
|
||||
- True:检测手绘线 → 建遮罩 → alpha=255−mask(透明区=重绘区,节点44 画黄色参考区)。
|
||||
- False:跳过检测,直接送划线图,alpha 全 255(空遮罩,节点26 mask 为空),
|
||||
模型仅凭医生黑线参考生发。无需改工作流,唯一变量是遮罩。
|
||||
Returns: {"grown_png": bytes 或 None, "status": "ok"|"no_face"|"no_line"}。
|
||||
"""
|
||||
orig_h, orig_w = marked_bgr.shape[:2]
|
||||
marked_bgr, _scale = _downscale_max_side(marked_bgr, _GROW_B_MAX_SIDE)
|
||||
if _scale < 1.0:
|
||||
logger.info(
|
||||
"接口3 缩图送 Comfy: %dx%d → %dx%d (max_side=%d)",
|
||||
orig_w, orig_h, marked_bgr.shape[1], marked_bgr.shape[0], _GROW_B_MAX_SIDE,
|
||||
)
|
||||
|
||||
h, w = marked_bgr.shape[:2]
|
||||
if use_mask:
|
||||
rgb = cv2.cvtColor(marked_bgr, cv2.COLOR_BGR2RGB)
|
||||
@@ -251,8 +599,10 @@ def generate_grow_b(marked_bgr: np.ndarray, use_mask: bool = True, prompt: str =
|
||||
mask = np.zeros((h, w), np.uint8) # 空遮罩:alpha 全 255,跳过检测
|
||||
|
||||
buf = io.BytesIO()
|
||||
compose_comfy_rgba(marked_bgr, mask).save(buf, format="PNG") # marked 原图 + 遮罩
|
||||
compose_comfy_rgba(marked_bgr, mask).save(buf, format="PNG", compress_level=1) # marked + 遮罩
|
||||
grown_png = comfyui.run(buf.getvalue(), prompt=prompt)
|
||||
if _scale < 1.0 and grown_png:
|
||||
grown_png = _upscale_png_to(grown_png, orig_w, orig_h)
|
||||
return {"grown_png": grown_png, "status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 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.8 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 |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 3.4 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 |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 5.9 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.3 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 7.2 KiB |