Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39a92705b1 | ||
|
|
92bc04480c | ||
|
|
5ba42bea67 | ||
|
|
688472706d | ||
|
|
a3e13c804a | ||
|
|
a2243266db | ||
|
|
fe0e74ece8 | ||
|
|
52913c94fc | ||
|
|
13526cb5b8 |
@@ -53,31 +53,3 @@ static/report_hairline_v2.zip
|
||||
# local_test 运行期日志 / pid(不入 git)
|
||||
local_test/hair_service.log
|
||||
local_test/hair_service.pid
|
||||
|
||||
# benchmark 原始输出(含结果图+原图,体积大,不入 git)
|
||||
benchmark_out/
|
||||
|
||||
# benchmark 部署的 HTML 报告(图片 base64 内嵌,体积大,不入 git)
|
||||
static/hairstyle_thumbs/
|
||||
|
||||
# 网关运行期日志(不入 git)
|
||||
gateway.log
|
||||
|
||||
# 工作流备份文件(不入 git)
|
||||
*.json.bak.*
|
||||
|
||||
# 脸型测试素材(人像照片,体积大,不入 git;仅保留 6 张基准标注图)
|
||||
face/test_img/脸型测试集合/
|
||||
face/test_img/girl/
|
||||
face/test_img/man/
|
||||
|
||||
# 脸型特征缓存(由 face/dump_features.py 生成,可随时重跑)
|
||||
face/cache/
|
||||
|
||||
# 脸型报告输出(标注图体积大,不入 git)
|
||||
static/face_shape_report/
|
||||
static/face_shape_report.html
|
||||
static/facetest_report/
|
||||
static/facetest_report.html
|
||||
static/facetest_all_report/
|
||||
static/facetest_all_report.html
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"16": {
|
||||
"class_type": "UnetLoaderGGUF",
|
||||
"class_type": "UNETLoader",
|
||||
"inputs": {
|
||||
"unet_name": "flux-2-klein-9b-Q4_K_M.gguf",
|
||||
"unet_name": "flux-2-klein-4b-fp8.safetensors",
|
||||
"weight_dtype": "fp8_e4m3fn_fast"
|
||||
}
|
||||
},
|
||||
@@ -15,7 +15,7 @@
|
||||
"61": {
|
||||
"class_type": "CLIPLoader",
|
||||
"inputs": {
|
||||
"clip_name": "qwen_3_8b_fp8mixed.safetensors",
|
||||
"clip_name": "qwen_3_4b.safetensors",
|
||||
"type": "flux2",
|
||||
"device": "cpu"
|
||||
}
|
||||
@@ -29,7 +29,7 @@
|
||||
"60": {
|
||||
"class_type": "JjkText",
|
||||
"inputs": {
|
||||
"text": "填充遮罩区域的头发,人物增加一点磨皮"
|
||||
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
||||
}
|
||||
},
|
||||
"22": {
|
||||
|
||||
@@ -4,100 +4,80 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Overview
|
||||
|
||||
旷视接口服务 — FastAPI 人像分析/生发服务,**一个仓库、两种部署角色**:
|
||||
"旷视五接口" — a FastAPI facial analysis API service providing 5 endpoints for face measurement, hair growth simulation (C-end and B-end), user feature extraction, and hairline PNG generation. Currently a **Mock v1**: all endpoints return hardcoded sample data; real ML logic is pending.
|
||||
|
||||
- **worker**(`app.py` + `face_analysis/` + `hairline/`):跑真实算法(本地模型 + ComfyUI + change_hair),监听 `0.0.0.0:8187`。**本机(GPU 机)即 worker 角色**,不跑网关。
|
||||
- **网关**(`gateway/`):外网机器上的薄反向代理,负责鉴权/健康检查/派发,并把 worker 返回的 `*_base64` 落盘改写为 `*_url`。接口4(豆包)和接口99(人脸特殊点,纯 CPU)在网关本机实现,不转发 worker。
|
||||
|
||||
> 历史包袱:`app.py` 的 FastAPI `description` 和 `docs/接口文档.md` 部分示例仍写「Mock 第一版」字样,实际全部接口已是真实算法实现。**架构以 `docs/实现说明.md` 为准,契约以 `docs/接口文档.md` 为准。**
|
||||
- **Base URL (prod)**: `https://hair.xiangsilian.com`
|
||||
- **Swagger docs**: `https://hair.xiangsilian.com/docs`
|
||||
- **Internal**: uvicorn on `127.0.0.1:8000` behind nginx reverse proxy
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# ---- worker(本机)----
|
||||
./run_worker.sh # 后台启动(0.0.0.0:8187,日志 worker.log)
|
||||
FOREGROUND=1 ./run_worker.sh # 前台调试
|
||||
./stop_worker.sh # 停止
|
||||
sudo systemctl restart hair-worker # systemd 方式(生产)
|
||||
journalctl -u hair-worker -f
|
||||
# Development server
|
||||
./venv/bin/uvicorn app:app --host 127.0.0.1 --port 8000 --reload
|
||||
|
||||
# ---- 依赖服务(worker 之前要先起)----
|
||||
sudo systemctl start comfyui # ComfyUI 127.0.0.1:8188(Flux-2 生图)
|
||||
cd /home/ubuntu/change_hair && bash start_all.sh start # 换发型三件套 8801/32678/57860
|
||||
# Or via the start script
|
||||
./start.sh
|
||||
|
||||
# ---- 测试 ----
|
||||
./venv/bin/pytest tests/ # 单元测试(conftest 会准备 fixtures)
|
||||
./venv/bin/python -m hairline.comfyui tests/output/comfy_input.png # 手动跑一次生发工作流
|
||||
# Production (systemd)
|
||||
sudo systemctl restart hair
|
||||
sudo systemctl status hair
|
||||
journalctl -u hair -f
|
||||
|
||||
# Install dependencies (uses Tencent Cloud PyPI mirror)
|
||||
./venv/bin/pip install -r requirements.txt
|
||||
```
|
||||
|
||||
依赖装在 `./venv`(Python 3.10,torch 2.7.1+cu128,RTX 4090 CUDA 可用)。`requirements.txt` 里的 torch 2.2.2/5090 注释是旧机器的记录,本机 venv 实际是 cu128 版。
|
||||
|
||||
## Architecture
|
||||
|
||||
### 接口一览(worker 侧路由,`app.py`)
|
||||
**Single-file app** — `app.py` (~555 lines) contains the entire server: FastAPI app creation, Pydantic models, all 5 endpoint handlers, and health check routes. There are no separate routers, services, or middleware modules.
|
||||
|
||||
| 接口 | 路径 | 实现位置 | 说明 |
|
||||
|------|------|----------|------|
|
||||
| 1 | `POST /api/v1/face/measure` | `face_analysis/` | 四庭七眼测量 + 标注 PNG |
|
||||
| 6 | `POST /api/v1/face/measure-v2` | 同上(`variant="v6"`) | 接口1 去顶庭变体 |
|
||||
| 2 | `POST /api/v1/hair/grow` | `hairline/service.py` | C端生发。**female 1..5** 走 change_hair swapHair + Flux-2 整帧重绘(接口12 final 管线);**female 6/7 与 male 全部**走 ComfyUI `add_hair.json` inpaint |
|
||||
| 7 | `POST /api/v1/hair/grow-v2`(hidden/deprecated) | 同上 | 同接口2,用 `add_hair2.json` 工作流 |
|
||||
| 3 | `POST /api/v1/hair/grow-b` | `hairline/marker_detect.py` | B端马克笔划线生发(黑帽响应 + Dijkstra) |
|
||||
| 4 | `POST /api/v1/face/features` | worker 有实现;生产走**网关本机**(豆包) | 用户特征 |
|
||||
| 5 | `POST /api/v1/hairline/generate` | `hairline/service.py` | 发际线 PNG 三档(middle/high/low)+ 生发 |
|
||||
| 9 | `POST /api/v1/head/mask` | `face_analysis/head_mask.py` | 头发遮罩 + 分步可视化(BiSeNet/SegFormer 双路) |
|
||||
| 10 | `POST /api/v1/head/band` | `face_analysis/head_band.py` | 头部外缘膨胀带遮罩 |
|
||||
| 11 | `POST /api/v1/hairline/grow` | `face_analysis/hairline_grow.py` | 发际线生发(接口9 遮罩 + change_hair + 接缝融合),不含重绘 |
|
||||
| 12 | `POST /api/v1/hairline/grow_v2` | 同上 | 接口11 final + 发际线重绘带 → Flux-2 保色重绘 |
|
||||
| 12f | `POST /api/v1/hairline/grow_v2_final` | 同上(`_V2_FINAL_DEFAULTS`) | 精简版:仅图片 + hairline_id,取整帧重绘 |
|
||||
| 12f2 | `POST /api/v1/hairline/grow_v2_final_v2` | 同上 | 同上,取 B 局部加发+全脸美颜 |
|
||||
| 99 | `POST /api/v1/face/special-points` | `face_analysis/special_points.py`(网关直接 import 本机实现 + `_if99_lock` 单路排队;worker 保留同路径备份) | 固定编号关键点 + 发际线特殊点(射线求交头发 mask)像素坐标 |
|
||||
| 代理 | `POST /api/swapHair/v1`、`/hairColor/v2` | httpx 转发 127.0.0.1:8801 | 解决测试页 CORS |
|
||||
| 工具 | `POST /api/v1/redraw` | `hairline/redraw.py` | 直接 ComfyUI 重绘(图+遮罩+prompt) |
|
||||
| 调试 | `POST /api/v1/debug/grow-timing`、`GET /api/v1/debug/hairline_log` | — | 免鉴权,分步计时/下载日志 |
|
||||
### Endpoints
|
||||
|
||||
配套测试页在 `static/test_interface{1..12}*.html`,经 `/static` 挂载。
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| POST | `/api/v1/face/measure` | 四庭七眼 measurement + annotated PNG |
|
||||
| POST | `/api/v1/hair/grow` | C-end hair growth (user-facing) |
|
||||
| POST | `/api/v1/hair/grow-b` | B-end hair growth (doctor/operator, with marked image) |
|
||||
| POST | `/api/v1/face/features` | User facial feature analysis |
|
||||
| POST | `/api/v1/hairline/generate` | Hairline PNG generation |
|
||||
| GET | `/health` | Health check (excluded from OpenAPI schema) |
|
||||
| GET | `/` | Service info (excluded from OpenAPI schema) |
|
||||
|
||||
### 关键模式
|
||||
### Image input convention (all endpoints)
|
||||
|
||||
- **统一信封**:`ok(data)` / `err(code, message)` 构造 `{code, message, request_id, data}`;业务错误 HTTP 一律 200。错误码:1001 无人像 / 1002 分辨率低 / 1003 非正面 / 1004 gender 非法 / 1005 多人 / 1006 >1MB / 1007 参数或处理失败 / 1008 格式不支持。
|
||||
- **图片三选一**:每个接口 `image_file`(multipart) / `image_url` / `image_base64`(带 `data:` 前缀)严格互斥,由 `resolve_image_bytes()` 统一处理(0 个或多个 → 1007)。接口3 是 `marked_image_*`。
|
||||
- **阻塞算法进线程池**:所有推理 handler 用 `run_in_threadpool(...)`,避免卡事件循环。
|
||||
- **内网鉴权**:HTTP 中间件校验 `/api/*` 的 `X-Internal-Token` 头,密码来自 `worker_config.json` 的 `accept_passwords`(或环境变量 `WORKER_ACCEPT_PASSWORDS`)。豁免前缀见 `_AUTH_EXEMPT`(health/docs/static/debug/redraw/swapHair/hairColor)。
|
||||
- **发型序号映射**:`hairline/service.py` 的 `_HAIRSTYLE_ORDER` 显式定义 1..7 顺序(不靠文件名字母序);female 1..5 → change_hair `chang_*` 映射在 `_FEMALE_KEY_TO_CHANG`。
|
||||
- **接口12 固化参数**:`_V2_FINAL_DEFAULTS`(app.py)是生产默认值(push=0.8cm / two_stage / color_match=关 等),接口2 female 重绘也复用它。
|
||||
Every endpoint accepts images via **exactly one** of three mutually exclusive fields:
|
||||
- `image_file` — multipart file upload (≤ 1 MB)
|
||||
- `image_url` — HTTP/HTTPS URL string
|
||||
- `image_base64` — base64 string with `data:image/...;base64,` prefix
|
||||
|
||||
### 外部依赖(worker 视角)
|
||||
Endpoint 3 (B-end) has **two** sets of image params: `marked_image_*` (the marked-up image) and `original_image_*` (the original photo). Each set follows the same three-way exclusive rule independently.
|
||||
|
||||
| 依赖 | 地址 | 用途 | 配置 |
|
||||
|------|------|------|------|
|
||||
| ComfyUI | `http://127.0.0.1:8188`(`COMFYUI_URL`) | Flux-2 生图/重绘,走 HTTP API(`hairline/comfyui.py`) | Basic Auth `admin`,密码:`COMFYUI_PASSWORD` → `worker_config.json.comfyui_password` → `password.txt` |
|
||||
| change_hair | `http://127.0.0.1:8801`(`SWAP_HAIR_URL`/`HAIR_GROW_URL`/`CHANGE_HAIR_BASE`) | 换发型(SD WebUI + LoRA)、hairGrow | 服务目录 `/home/ubuntu/change_hair`,`start_all.sh` 启停 |
|
||||
### Unified response structure
|
||||
|
||||
ComfyUI 工作流 JSON 在仓库根:`add_hair.json`(接口2/3/5 生发)、`add_hair2.json`(接口7)、`0716add-hair-api.json`(接口12/2女 重绘)、`hair_repaint.json`。节点约定:**26** 输入图 / **6** seed / **60** 提示词 / **16** unet(.gguf 自动切 UnetLoaderGGUF)/ **61** CLIP(4b↔9b 自动配套)。
|
||||
```json
|
||||
{"code": 0, "message": "success", "request_id": "mock-request-id", "data": {...}}
|
||||
```
|
||||
|
||||
### 性能/显存相关的坑
|
||||
Error codes: 1001–1008 (see `app.py` docstring or `docs/接口文档.md`).
|
||||
|
||||
- 三个生发接口共用**同一提示词字符串**以命中 ComfyUI 的 CLIP 文本条件缓存——prompt 变了会触发 Flux(7.7G)+qwen CLIP(3.9G) 互相挤显存重载(每次 +4s)。
|
||||
- 接口2 女性重绘管线默认把输入长边压到 `REDRAW_MAX_SIDE`(env,默认 1024)再放大回原尺寸;`redraw_max_side=0` 表示不缩图。
|
||||
- `comfyui.run(front=True)` 可插队(接口2 对时延敏感);超时 `COMFY_TIMEOUT`(默认 600s)。
|
||||
### Key code patterns
|
||||
|
||||
## Deployment(本机 = gpu_worker)
|
||||
- `ok(data)` / `err(code, msg)` — helper functions at module level that build the standard response envelope
|
||||
- `ImageJsonBody` — Pydantic model for JSON-based image params (URL/base64); form-upload endpoints use FastAPI `Form()` / `File()` parameters directly
|
||||
- `SAMPLE_IMAGE_URL` — `https://hair.xiangsilian.com/static/sample.jpg`, returned as the image URL in all mock responses
|
||||
- Static files (`/static/*`) are mounted via `app.mount("/static", StaticFiles(directory="static"), name="static")`
|
||||
- All 5 POST endpoints use `multipart/form-data` with both `File` and `Form` fields — there are no JSON-body POST endpoints despite `ImageJsonBody` being defined (it exists for the OpenAPI schema but the actual handlers use `Form()` for all params)
|
||||
|
||||
systemd 单元(仓库根有副本,实装在 `/etc/systemd/system/`):
|
||||
### Deployment
|
||||
|
||||
| 服务 | 端口 | 说明 |
|
||||
|------|------|------|
|
||||
| `comfyui.service` | 127.0.0.1:8188 | `ComfyUI/venv` 的 `main.py --cache-classic --fast` |
|
||||
| `hair-worker.service` | 0.0.0.0:8187 | 本仓库 `app:app`(Wants comfyui + change_hair-hair) |
|
||||
| `change_hair-{webui,photo,hair}.service` | 57860 / 32678 / 8801 | conda envs `sdwebui`/`py310`/`my_hair`,依赖顺序 webui→photo→hair |
|
||||
- **Process manager**: systemd (`hair.service` → `ExecStart` runs uvicorn directly from venv)
|
||||
- **Reverse proxy**: nginx (`nginx/hair.conf`) proxies port 80 → `127.0.0.1:8000` for `hair.xiangsilian.com`
|
||||
- **Python**: venv at `./venv`, dependencies in `requirements.txt`
|
||||
- **PyPI mirror**: Tencent Cloud (`mirrors.cloud.tencent.com`), configured in `pip.conf`
|
||||
|
||||
网关机另行部署 `gateway/`(config:`workers` 列表 + `shared_password` + `ark` key),见 `gateway/config.example.json` 与 `docs/网关待改动.md`。
|
||||
### Documentation
|
||||
|
||||
## Documentation
|
||||
|
||||
- `docs/接口文档.md` — 对外 API 契约(唯一权威)
|
||||
- `docs/实现说明.md` — 架构总览 + 各接口实现简述 + 部署要点
|
||||
- `docs/发际线增强算法.md`、`docs/发际线生发遮罩算法_pushed模式.md` — 接口11/12 算法推导
|
||||
- `docs/gpu-worker改动说明.md`、`docs/网关待改动.md` — worker↔网关 协同变更记录
|
||||
- `OFFLINE_ASSETS.md` — 离线模型权重/字体清单
|
||||
- `docs/接口文档.md` — complete API documentation (inputs, outputs, examples, error codes)
|
||||
- `docs/旷视具体需求.md` — original requirements spec (converted from `.docx`)
|
||||
- The FastAPI app's `description` docstring in `app.py` is an additional source of API docs visible in Swagger UI
|
||||
|
||||
@@ -170,10 +170,10 @@
|
||||
},
|
||||
"16": {
|
||||
"inputs": {
|
||||
"unet_name": "flux-2-klein-9b-Q4_K_M.gguf",
|
||||
"unet_name": "flux-2-klein-4b-fp8.safetensors",
|
||||
"weight_dtype": "fp8_e4m3fn_fast"
|
||||
},
|
||||
"class_type": "UnetLoaderGGUF",
|
||||
"class_type": "UNETLoader",
|
||||
"_meta": {
|
||||
"title": "UNet加载器"
|
||||
}
|
||||
@@ -410,7 +410,7 @@
|
||||
},
|
||||
"60": {
|
||||
"inputs": {
|
||||
"text": "填充遮罩区域的头发,人物增加一点磨皮"
|
||||
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
||||
},
|
||||
"class_type": "JjkText",
|
||||
"_meta": {
|
||||
@@ -419,7 +419,7 @@
|
||||
},
|
||||
"61": {
|
||||
"inputs": {
|
||||
"clip_name": "qwen_3_8b_fp8mixed.safetensors",
|
||||
"clip_name": "qwen_3_4b.safetensors",
|
||||
"type": "flux2",
|
||||
"device": "cpu"
|
||||
},
|
||||
|
||||
@@ -2,24 +2,19 @@
|
||||
|
||||
接口 1(四庭七眼测量)已为**真实算法实现**(见 face_analysis 包);接口 2~5 仍为 Mock。
|
||||
拆分架构:worker 跑算法、返回 `annotated_image_base64`(不落盘、不拼 URL,由网关完成)。
|
||||
worker 对 `/api/*` 校验内网鉴权头 `X-Internal-Token`,`/health` 供网关探测不校验
|
||||
(深检:磁盘水位 + 下游依赖 TCP 探测,异常返回 503 degraded,见 _load_health_cfg)。
|
||||
worker 对 `/api/*` 校验内网鉴权头 `X-Internal-Token`,`/health` 供网关探测不校验。
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from io import BytesIO
|
||||
from typing import Any, List, Optional
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from PIL import Image
|
||||
from fastapi import FastAPI, File, Form, Request, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
@@ -151,7 +146,7 @@ _AUTH_EXEMPT = ("/health", "/docs", "/openapi.json", "/redoc", "/static",
|
||||
# ---------------------------------------------------------------------------
|
||||
# change_hair 代理路由(解决 CORS 问题)
|
||||
# ---------------------------------------------------------------------------
|
||||
_CHANGE_HAIR_BASE = os.getenv("CHANGE_HAIR_BASE", "http://127.0.0.1:8801")
|
||||
_CHANGE_HAIR_BASE = "http://127.0.0.1:8801"
|
||||
|
||||
|
||||
@app.post("/api/swapHair/v1", tags=["change_hair"])
|
||||
@@ -454,398 +449,6 @@ def _run_face_measure_data(image, variant="v1"):
|
||||
return data, result, hair_mask, ear_mask
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 接口1 调试:分步可视化(每一步的中间产物图)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 调试接口 9 张分步图的 key(与前端 STEPS 一一对应)
|
||||
_DEBUG_STEP_KEYS = [
|
||||
"input", "landmarks", "pose", "segmentation",
|
||||
"hairline", "vertical", "seven_eyes", "scale", "final",
|
||||
]
|
||||
|
||||
|
||||
def _overlay_mask(image_bgr, mask, color, alpha=0.45):
|
||||
"""在 BGR 图上把 mask 区域以 color(BGR) 半透明叠加。mask 为 bool/uint8。"""
|
||||
out = image_bgr.copy()
|
||||
m = np.asarray(mask).astype(bool)
|
||||
if m.shape[:2] != out.shape[:2]:
|
||||
return out
|
||||
overlay = out[m]
|
||||
# alpha 混合
|
||||
overlay = (overlay * (1 - alpha) + np.array(color, dtype=np.float32) * alpha)
|
||||
out[m] = np.clip(overlay, 0, 255).astype(np.uint8)
|
||||
return out
|
||||
|
||||
|
||||
_DEBUG_FONT_PATH = os.path.join(
|
||||
os.path.dirname(__file__), "face_analysis", "fonts", "NotoSansCJKsc-Regular.otf")
|
||||
_debug_font_cache = {}
|
||||
|
||||
|
||||
def _debug_font(size):
|
||||
f = _debug_font_cache.get(size)
|
||||
if f is None:
|
||||
f = ImageFont.truetype(_DEBUG_FONT_PATH, size)
|
||||
_debug_font_cache[size] = f
|
||||
return f
|
||||
|
||||
|
||||
def _draw_text_cv2(img, text, org, color=(255, 255, 255), scale=None, thickness=None,
|
||||
bg=True, anchor="lt"):
|
||||
"""在 BGR 图上绘制文字(支持中文,用 PIL + 思源黑体)。org=(x,y)。
|
||||
|
||||
cv2.putText 不支持中文(会显示成问号),故统一改用 PIL 渲染。color 为 BGR 三元组。
|
||||
anchor: lt=左上角对齐 org / lb=左下角 / ct=水平垂直居中。bg=True 时画黑色背景框。
|
||||
"""
|
||||
h, w = img.shape[:2]
|
||||
s = min(w, h)
|
||||
scale = scale if scale else max(0.4, s * 0.0016)
|
||||
thickness = thickness if thickness else max(1, round(s * 0.0022))
|
||||
# PIL 字号与 cv2 scale 大致对应(cv2 scale≈字号/30)
|
||||
font_size = max(10, round(scale * 30))
|
||||
font = _debug_font(font_size)
|
||||
# BGR → RGB
|
||||
rgb = (int(color[2]), int(color[1]), int(color[0]))
|
||||
pil_img = Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
|
||||
draw = ImageDraw.Draw(pil_img)
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
x, y = org
|
||||
if anchor == "lb":
|
||||
text_y = y - th
|
||||
elif anchor == "ct":
|
||||
x = x - tw // 2
|
||||
text_y = y - th // 2
|
||||
else:
|
||||
text_y = y
|
||||
if bg:
|
||||
pad = max(2, round(thickness * 1.2))
|
||||
draw.rectangle(
|
||||
[max(0, x - pad), max(0, text_y - pad),
|
||||
min(w, x + tw + pad), min(h, text_y + th + pad)],
|
||||
fill=(0, 0, 0))
|
||||
# PIL text 的 y 是文字顶部基线,bbox 偏移需校正
|
||||
draw.text((x, text_y - bbox[1]), text, fill=rgb, font=font)
|
||||
img[:] = cv2.cvtColor(np.asarray(pil_img), cv2.COLOR_RGB2BGR)
|
||||
return img
|
||||
|
||||
|
||||
def _run_face_measure_data_debug(image):
|
||||
"""接口1 调试:产出 9 步中间图 + 数值,逐步塞进返回 dict。
|
||||
|
||||
与 _run_face_measure_data 同链路,但每步把中间产物渲染成叠加图(JPG base64)
|
||||
放进 data["steps"][key + "_base64"],关键数值放进 data["debug"]。
|
||||
检测/姿态失败时,仍返回已完成的步骤图 + 对应错误码,供前端展示「卡在哪一步」。
|
||||
|
||||
返回 (data, error_code_or_None, error_msg_or_None)。
|
||||
"""
|
||||
h, w = image.shape[:2]
|
||||
from face_analysis.detector import detector
|
||||
from face_analysis.pose import estimate_head_pose, check_frontal_face
|
||||
from face_analysis.measure import measure_face, _brow_center
|
||||
from face_analysis.calibration import (
|
||||
normalized_to_pixel, estimate_scale_factor,
|
||||
_iris_diameter_px, _eye_width_px, _lm_list,
|
||||
AVG_IRIS_DIAMETER_CM, AVG_EYE_WIDTH_CM,
|
||||
)
|
||||
from face_analysis.face_mesh_landmarks import (
|
||||
GLABELLA_9, GLABELLA_151, NOSE_BOTTOM, CHIN_TIP,
|
||||
LEFT_EYE_OUTER, LEFT_EYE_INNER, RIGHT_EYE_INNER, RIGHT_EYE_OUTER,
|
||||
LEFT_CHEEK, RIGHT_CHEEK, LEFT_POSITION, RIGHT_POSITION,
|
||||
IRIS_LEFT_LEFT, IRIS_LEFT_RIGHT, IRIS_RIGHT_LEFT, IRIS_RIGHT_RIGHT,
|
||||
PNP_INDICES,
|
||||
)
|
||||
from face_analysis.hair_segmenter import locate_hairline_by_segmentation
|
||||
|
||||
data = {"steps": {}, "debug": {"image_width": w, "image_height": h}}
|
||||
steps = data["steps"]
|
||||
dbg = data["debug"]
|
||||
|
||||
def put(key, bgr_img):
|
||||
steps[key + "_base64"] = "data:image/jpeg;base64," + _jpg_b64(bgr_img)
|
||||
|
||||
# ① 输入原图
|
||||
put("input", image)
|
||||
|
||||
# ② 人脸关键点检测
|
||||
landmarks = detector.detect(image)
|
||||
if landmarks is None:
|
||||
dbg["num_landmarks"] = 0
|
||||
return data, 1001, "无法识别人像"
|
||||
lm = _lm_list(landmarks)
|
||||
dbg["num_landmarks"] = len(lm)
|
||||
|
||||
vis_lm = image.copy()
|
||||
# 先画全部 478 点(小白点)
|
||||
s = min(w, h)
|
||||
r_all = max(1, round(s * 0.0018))
|
||||
for p in lm:
|
||||
px = normalized_to_pixel(p, w, h)
|
||||
cv2.circle(vis_lm, (int(px[0]), int(px[1])), r_all, (220, 220, 220), -1)
|
||||
# 虹膜点 468~477(青色稍大)
|
||||
r_iris = max(2, round(s * 0.0035))
|
||||
for idx in [468, 469, 470, 471, 472, 473, 474, 475, 476, 477]:
|
||||
if idx < len(lm):
|
||||
px = normalized_to_pixel(lm[idx], w, h)
|
||||
cv2.circle(vis_lm, (int(px[0]), int(px[1])), r_iris, (255, 200, 0), -1)
|
||||
# 七眼 6 点 + 5 纵向点(红色 + 标号)
|
||||
key_pts = {
|
||||
"头顶(推算)": None, # 纵向点除眉心外由后续 measure 给出,这里只画能拿到的
|
||||
"眉心": GLABELLA_9,
|
||||
}
|
||||
important = [
|
||||
(GLABELLA_9, "眉间9"), (GLABELLA_151, "眉间151"), (NOSE_BOTTOM, "鼻翼下94"),
|
||||
(CHIN_TIP, "下巴152"), (LEFT_EYE_OUTER, "左眼外33"), (LEFT_EYE_INNER, "左眼内133"),
|
||||
(RIGHT_EYE_INNER, "右眼内362"), (RIGHT_EYE_OUTER, "右眼外263"),
|
||||
(LEFT_CHEEK, "左脸234"), (RIGHT_CHEEK, "右脸454"),
|
||||
]
|
||||
r_imp = max(3, round(s * 0.005))
|
||||
for idx, name in important:
|
||||
px = normalized_to_pixel(lm[idx], w, h)
|
||||
cv2.circle(vis_lm, (int(px[0]), int(px[1])), r_imp, (0, 0, 255), -1)
|
||||
_draw_text_cv2(vis_lm, name, (int(px[0]) + r_imp + 2, int(px[1])),
|
||||
color=(0, 255, 255), scale=max(0.35, s * 0.0013))
|
||||
put("landmarks", vis_lm)
|
||||
|
||||
# ③ 头部姿态校验
|
||||
head_pose = estimate_head_pose(lm, w, h) if hasattr(landmarks, "landmark") else estimate_head_pose(lm, w, h)
|
||||
frontal = check_frontal_face(landmarks, w, h)
|
||||
vis_pose = image.copy()
|
||||
# 画 6 个 PnP 点(黄)
|
||||
nose_tip_px = None
|
||||
for idx in PNP_INDICES:
|
||||
px = normalized_to_pixel(lm[idx], w, h)
|
||||
cv2.circle(vis_pose, (int(px[0]), int(px[1])), max(3, round(s * 0.004)), (0, 255, 255), -1)
|
||||
if idx == 1:
|
||||
nose_tip_px = (int(px[0]), int(px[1]))
|
||||
# 三轴箭头(鼻尖为原点)
|
||||
if nose_tip_px is not None and head_pose is not None:
|
||||
L = max(30, round(s * 0.08))
|
||||
# yaw 绕 Y(竖轴) → 在屏幕上表现为左右;pitch 绕 X → 上下;roll 绕 Z → 面内旋转
|
||||
yaw, pitch, roll = head_pose
|
||||
import math
|
||||
# 简化:用 roll 直接旋转 X/Y 轴示意,yaw 投影到横向、pitch 到纵向
|
||||
cosr, sinr = math.cos(math.radians(roll)), math.sin(math.radians(roll))
|
||||
# X 轴(红,向右)
|
||||
cv2.arrowedLine(vis_pose, nose_tip_px,
|
||||
(int(nose_tip_px[0] + L * cosr), int(nose_tip_px[1] + L * sinr)),
|
||||
(0, 0, 255), max(2, round(s * 0.003)), tipLength=0.2)
|
||||
# Y 轴(绿,向下)
|
||||
cv2.arrowedLine(vis_pose, nose_tip_px,
|
||||
(int(nose_tip_px[0] - L * sinr), int(nose_tip_px[1] + L * cosr)),
|
||||
(0, 255, 0), max(2, round(s * 0.003)), tipLength=0.2)
|
||||
# Z 轴(青,向内用圆圈示意)
|
||||
cv2.circle(vis_pose, nose_tip_px, max(6, round(s * 0.012)), (255, 255, 0), max(1, round(s * 0.002)))
|
||||
# 角度文字
|
||||
txt = f"yaw={yaw:.1f} pitch={pitch:.1f} roll={roll:.1f}"
|
||||
_draw_text_cv2(vis_pose, txt, (10, 10), color=(50, 255, 50),
|
||||
scale=max(0.5, s * 0.0022))
|
||||
_draw_text_cv2(vis_pose, f"frontal={'YES' if frontal else 'NO'}", (10, 40),
|
||||
color=(50, 255, 50) if frontal else (50, 50, 255),
|
||||
scale=max(0.5, s * 0.0022))
|
||||
dbg["head_pose"] = {"yaw": round(yaw, 2), "pitch": round(pitch, 2),
|
||||
"roll": round(roll, 2), "frontal": bool(frontal)}
|
||||
put("pose", vis_pose)
|
||||
|
||||
if not frontal:
|
||||
return data, 1003, "角度问题,请上传正面照"
|
||||
|
||||
# ④ 头发/耳朵分割
|
||||
hair_mask = None
|
||||
ear_mask = None
|
||||
try:
|
||||
from face_analysis.hair_segmenter import get_segmenter
|
||||
pxs = [normalized_to_pixel(p, w, h) for p in lm]
|
||||
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_mask = get_segmenter().segment_hair_and_ears(image, face_box=face_box)
|
||||
except Exception as seg_e: # noqa: BLE001
|
||||
logger.warning("[debug] 头发/耳朵分割失败:%s", seg_e)
|
||||
|
||||
vis_seg = image.copy()
|
||||
if hair_mask is not None:
|
||||
vis_seg = _overlay_mask(vis_seg, hair_mask, (0, 200, 0), alpha=0.45)
|
||||
dbg["hair_pixels"] = int(np.asarray(hair_mask).astype(bool).sum())
|
||||
if ear_mask is not None:
|
||||
vis_seg = _overlay_mask(vis_seg, ear_mask, (200, 80, 0), alpha=0.5)
|
||||
dbg["ear_pixels"] = int(np.asarray(ear_mask).astype(bool).sum())
|
||||
_draw_text_cv2(vis_seg, "绿=头发(hair=17) 蓝=耳朵(ear=7/8)", (10, 10),
|
||||
color=(50, 255, 50), scale=max(0.45, s * 0.0018))
|
||||
put("segmentation", vis_seg)
|
||||
|
||||
# 主测量(复用 measure_face,内部含 ⑤ 纵向决策 + 七眼 + 尺度)
|
||||
result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose)
|
||||
v = result.vertical
|
||||
dbg["hairline_source"] = result.hairline_source
|
||||
|
||||
# ⑤ 纵向定位(发际线/头顶)—— 复刻方案 B 的中轴线扫描
|
||||
vis_hl = image.copy()
|
||||
if hair_mask is not None:
|
||||
vis_hl = _overlay_mask(vis_hl, hair_mask, (0, 180, 0), alpha=0.3)
|
||||
brow_x, brow_y = _brow_center(lm, w, h)
|
||||
# 中轴线 ±3px 列带高亮(黄)
|
||||
cx = int(round(brow_x))
|
||||
cv2.line(vis_hl, (max(0, cx - 3), 0), (max(0, cx - 3), h), (0, 230, 255), 1)
|
||||
cv2.line(vis_hl, (min(w - 1, cx + 3), 0), (min(w - 1, cx + 3), h), (0, 230, 255), 1)
|
||||
# 画 hairline_y / hair_top_y 两条横线
|
||||
hairline_y = int(v["hairline"][1])
|
||||
hair_top_y = int(v["hair_top"][1])
|
||||
cv2.line(vis_hl, (0, hair_top_y), (w, hair_top_y), (255, 255, 0), max(2, round(s * 0.0025)))
|
||||
cv2.line(vis_hl, (0, hairline_y), (w, hairline_y), (0, 100, 255), max(2, round(s * 0.0025)))
|
||||
_draw_text_cv2(vis_hl, f"hair_top_y={hair_top_y}", (hair_top_y if hair_top_y < h - 40 else h - 40, 0),
|
||||
color=(255, 255, 0), scale=max(0.4, s * 0.0015))
|
||||
# 文字标注位置:hairline_y 行右侧
|
||||
_draw_text_cv2(vis_hl, f"hairline_y={hairline_y} (source={result.hairline_source})",
|
||||
(hairline_y, w - int(s * 0.5)), color=(0, 200, 255),
|
||||
scale=max(0.4, s * 0.0015))
|
||||
# 发际线修正提示:检测到的顶庭 < 0.7cm 视为贴近头顶、已按 0.95×中庭修正
|
||||
if getattr(result, "hairline_corrected", False):
|
||||
gap_cm = result.detected_top_cm
|
||||
_draw_text_cv2(vis_hl,
|
||||
f"⚠️ 发际线离头顶仅 {gap_cm:.2f}cm (<0.7cm),已按 0.95×中庭修正",
|
||||
(10, 10), color=(40, 40, 255), scale=max(0.5, s * 0.0022))
|
||||
put("hairline", vis_hl)
|
||||
|
||||
# ⑥ 四庭纵向点
|
||||
vis_v = image.copy()
|
||||
v_names = ["hair_top", "hairline", "brow_center", "nose_bottom", "chin_tip"]
|
||||
v_labels = ["头顶", "发际线", "眉心", "鼻翼下缘", "下巴尖"]
|
||||
v_court_px = [v["top_court_px"], v["upper_court_px"], v["middle_court_px"], v["lower_court_px"]]
|
||||
court_names = ["顶庭", "上庭", "中庭", "下庭"]
|
||||
court_cm = [result.top_cm, result.upper_cm, result.middle_cm, result.lower_cm]
|
||||
vx0 = min(int(v[n][0]) for n in v_names)
|
||||
for i, name in enumerate(v_names):
|
||||
x, y = int(v[name][0]), int(v[name][1])
|
||||
cv2.circle(vis_v, (x, y), max(3, round(s * 0.004)), (0, 0, 255), -1)
|
||||
# 画一条横线
|
||||
cv2.line(vis_v, (vx0 - max(20, round(s * 0.04)), y),
|
||||
(min(w - 1, vx0 + int(s * 0.02)), y), (0, 200, 255), 1)
|
||||
_draw_text_cv2(vis_v, v_labels[i], (min(w - 60, x + 8), y),
|
||||
color=(50, 255, 255), scale=max(0.4, s * 0.0015))
|
||||
# 各庭段高(竖向虚线 + cm 文字)
|
||||
for i in range(4):
|
||||
y_a = int(v[v_names[i]][1])
|
||||
y_b = int(v[v_names[i + 1]][1])
|
||||
lx = max(10, vx0 - max(40, round(s * 0.08)))
|
||||
cv2.line(vis_v, (lx, y_a), (lx, y_b), (0, 255, 100), max(2, round(s * 0.0025)))
|
||||
cv2.circle(vis_v, (lx, y_a), 3, (0, 255, 100), -1)
|
||||
cv2.circle(vis_v, (lx, y_b), 3, (0, 255, 100), -1)
|
||||
_draw_text_cv2(vis_v, f"{court_names[i]} {court_cm[i]:.2f}cm",
|
||||
(lx - int(s * 0.18), (y_a + y_b) // 2),
|
||||
color=(100, 255, 100), scale=max(0.4, s * 0.0015))
|
||||
dbg["vertical_points"] = {n: {"x": int(v[n][0]), "y": int(v[n][1])} for n in v_names}
|
||||
put("vertical", vis_v)
|
||||
|
||||
# ⑦ 七眼横向点
|
||||
vis_e = image.copy()
|
||||
epts = result.eyes["points"]
|
||||
seven_keys = ["left_cheek", "left_outer", "left_inner", "right_inner", "right_outer", "right_cheek"]
|
||||
seven_labels = ["左脸颊", "左眼外", "左眼内", "右眼内", "右眼外", "右脸颊"]
|
||||
ey0 = min(int(epts[k][1]) for k in seven_keys)
|
||||
for i, k in enumerate(seven_keys):
|
||||
x, y = int(epts[k][0]), int(epts[k][1])
|
||||
cv2.circle(vis_e, (x, y), max(3, round(s * 0.004)), (0, 0, 255), -1)
|
||||
cv2.line(vis_e, (x, max(0, ey0 - 20)), (x, min(h - 1, ey0 + 20)),
|
||||
(0, 200, 255), 1)
|
||||
_draw_text_cv2(vis_e, seven_labels[i], (x, ey0 - max(25, round(s * 0.04))),
|
||||
color=(50, 255, 255), scale=max(0.4, s * 0.0015), anchor="ct")
|
||||
# 头部最左/最右端线(耳朵外缘)
|
||||
try:
|
||||
from face_analysis.annotation import _ear_edges_from_mask
|
||||
lcx, rcx = epts["left_cheek"][0], epts["right_cheek"][0]
|
||||
head_l, head_r = _ear_edges_from_mask(
|
||||
ear_mask, hair_mask, v["hair_top"][1], v["chin_tip"][1],
|
||||
lcx, rcx, (lcx + rcx) / 2)
|
||||
if head_l is not None:
|
||||
cv2.line(vis_e, (int(head_l), 0), (int(head_l), h), (255, 100, 255), max(1, round(s * 0.002)))
|
||||
_draw_text_cv2(vis_e, "人头最左", (int(head_l), 10),
|
||||
color=(255, 150, 255), scale=max(0.35, s * 0.0013))
|
||||
if head_r is not None:
|
||||
cv2.line(vis_e, (int(head_r), 0), (int(head_r), h), (255, 100, 255), max(1, round(s * 0.002)))
|
||||
_draw_text_cv2(vis_e, "人头最右", (int(head_r), 10),
|
||||
color=(255, 150, 255), scale=max(0.35, s * 0.0013))
|
||||
dbg["head_left_x"] = head_l
|
||||
dbg["head_right_x"] = head_r
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("[debug] 七眼端线绘制失败:%s", e)
|
||||
dbg["seven_eye_points"] = {k: {"x": int(epts[k][0]), "y": int(epts[k][1])} for k in seven_keys}
|
||||
put("seven_eyes", vis_e)
|
||||
|
||||
# ⑧ 尺度校准
|
||||
vis_sc = image.copy()
|
||||
px_per_cm = result.px_per_cm
|
||||
iris_px = _iris_diameter_px(lm, w, h)
|
||||
if iris_px is not None and iris_px > 0:
|
||||
# 画左右虹膜直径线(青)
|
||||
for (li, ri) in [(IRIS_LEFT_LEFT, IRIS_LEFT_RIGHT), (IRIS_RIGHT_LEFT, IRIS_RIGHT_RIGHT)]:
|
||||
p1 = normalized_to_pixel(lm[li], w, h)
|
||||
p2 = normalized_to_pixel(lm[ri], w, h)
|
||||
cv2.line(vis_sc, (int(p1[0]), int(p1[1])), (int(p2[0]), int(p2[1])),
|
||||
(255, 200, 0), max(2, round(s * 0.004)))
|
||||
cv2.circle(vis_sc, (int(p1[0]), int(p1[1])), max(2, round(s * 0.003)), (255, 200, 0), -1)
|
||||
cv2.circle(vis_sc, (int(p2[0]), int(p2[1])), max(2, round(s * 0.003)), (255, 200, 0), -1)
|
||||
method = "iris"
|
||||
_draw_text_cv2(vis_sc, f"虹膜直径法: {iris_px:.1f}px / {AVG_IRIS_DIAMETER_CM}cm", (10, 10),
|
||||
color=(255, 200, 0), scale=max(0.45, s * 0.0018))
|
||||
else:
|
||||
# 降级眼宽法(黄)
|
||||
eye_px = _eye_width_px(lm, w, h)
|
||||
method = "eye_width"
|
||||
for (oi, ii) in [(LEFT_EYE_OUTER, LEFT_EYE_INNER), (RIGHT_EYE_INNER, RIGHT_EYE_OUTER)]:
|
||||
p1 = normalized_to_pixel(lm[oi], w, h)
|
||||
p2 = normalized_to_pixel(lm[ii], w, h)
|
||||
cv2.line(vis_sc, (int(p1[0]), int(p1[1])), (int(p2[0]), int(p2[1])),
|
||||
(0, 255, 255), max(2, round(s * 0.004)))
|
||||
_draw_text_cv2(vis_sc, f"眼宽法(降级): {eye_px:.1f}px / {AVG_EYE_WIDTH_CM}cm", (10, 10),
|
||||
color=(0, 255, 255), scale=max(0.45, s * 0.0018))
|
||||
_draw_text_cv2(vis_sc, f"px_per_cm = {px_per_cm:.3f}", (10, 40),
|
||||
color=(50, 255, 50), scale=max(0.5, s * 0.002))
|
||||
dbg["px_per_cm"] = round(px_per_cm, 4)
|
||||
dbg["scale_method"] = method
|
||||
put("scale", vis_sc)
|
||||
|
||||
# 把 to_response 的数值并入 data(前端指标速览复用)
|
||||
data.update(result.to_response())
|
||||
# 七眼段宽
|
||||
try:
|
||||
pc = result.px_per_cm
|
||||
inner_xs = [epts["left_cheek"][0], epts["left_outer"][0], epts["left_inner"][0],
|
||||
epts["right_inner"][0], epts["right_outer"][0], epts["right_cheek"][0]]
|
||||
data.setdefault("seven_eyes", {})
|
||||
for i in range(5):
|
||||
a, b = inner_xs[i], inner_xs[i + 1]
|
||||
data["seven_eyes"][f"eye{i + 2}"] = (
|
||||
None if (a is None or b is None) else round((b - a) / pc, 2))
|
||||
from face_analysis.annotation import _ear_edges_from_mask as _eef
|
||||
lcx, rcx = epts["left_cheek"][0], epts["right_cheek"][0]
|
||||
# 竖向范围上界用头顶(hair_top 始终可用)
|
||||
top_y = v["hair_top"][1]
|
||||
head_l, head_r = _eef(ear_mask, hair_mask, top_y, v["chin_tip"][1],
|
||||
lcx, rcx, (lcx + rcx) / 2)
|
||||
data["seven_eyes"]["eye1"] = None if head_l is None else round((lcx - head_l) / pc, 2)
|
||||
data["seven_eyes"]["eye7"] = None if head_r is None else round((head_r - rcx) / pc, 2)
|
||||
except Exception as seg_e: # noqa: BLE001
|
||||
logger.warning("[debug] 七眼段宽计算失败:%s", seg_e)
|
||||
|
||||
# ⑨ 最终标注图(原图 + 标注层叠加)
|
||||
try:
|
||||
from face_analysis.annotation import create_annotated_image
|
||||
annotated = create_annotated_image(image, result, ear_mask=ear_mask, hair_mask=hair_mask)
|
||||
anno_rgba = np.asarray(annotated)
|
||||
# 叠加到原图
|
||||
vis_final = image.copy()
|
||||
alpha = anno_rgba[:, :, 3:4].astype(np.float32) / 255.0
|
||||
vis_final = (vis_final.astype(np.float32) * (1 - alpha)
|
||||
+ anno_rgba[:, :, :3].astype(np.float32) * alpha)
|
||||
vis_final = np.clip(vis_final, 0, 255).astype(np.uint8)
|
||||
put("final", vis_final)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("[debug] 标注图叠加失败:%s", e)
|
||||
|
||||
return data, None, None
|
||||
|
||||
|
||||
async def _face_measure_impl(image_file, image_url, image_base64, variant="v1"):
|
||||
"""接口1/6 共用实现:四庭七眼测量 + 标注图生成。返回 (ok_dict, err_dict)。
|
||||
|
||||
@@ -981,39 +584,6 @@ async def face_measure(
|
||||
return ok_data if ok_data is not None else err_data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 接口1 调试:分步可视化(每一步中间产物图 + 原理)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.post("/api/v1/face/measure-debug", include_in_schema=False)
|
||||
async def face_measure_debug(
|
||||
image_file: Optional[UploadFile] = File(default=None),
|
||||
image_url: Optional[str] = Form(default=None),
|
||||
image_base64: Optional[str] = Form(default=None),
|
||||
):
|
||||
"""接口1 调试:返回算法每一步的中间产物图(data.steps.*_base64)+ 数值(data.debug)。
|
||||
|
||||
与正式接口同链路,但额外产出 9 张分步叠加图(输入/关键点/姿态/分割/发际线/
|
||||
四庭/七眼/尺度/最终标注),供调试页分步可视化。错误时仍返回已完成的步骤图。
|
||||
"""
|
||||
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
|
||||
if e is not None:
|
||||
return e
|
||||
image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
|
||||
if image is None:
|
||||
return err(1008, "图片格式不支持(仅 JPG / PNG)")
|
||||
try:
|
||||
data, code, msg = _run_face_measure_data_debug(image)
|
||||
if code is not None:
|
||||
# 仍带分步图返回,前端可展示卡在哪一步
|
||||
return {"code": code, "message": msg,
|
||||
"request_id": "mock-request-id", "data": data}
|
||||
return ok(data)
|
||||
except Exception as ex: # noqa: BLE001
|
||||
logger.exception("接口1 调试处理异常")
|
||||
return err(1007, f"处理失败:{ex}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 接口 6:四庭七眼测量标注 v2(复刻接口1)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1191,9 +761,7 @@ async def hair_grow(
|
||||
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填),如 1,2,3。female:1-7 male:1-7"),
|
||||
beauty_enabled: bool = Form(default=False, description="是否开启美颜(本期不生效)"),
|
||||
use_mask: bool = Form(default=True, description="是否启用 inpaint 遮罩(测试对比用)。false 时用干净原图生成(空遮罩,不烧模板线)"),
|
||||
prompt: str = Form(default="填充遮罩区域的头发,人物增加一点磨皮", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||
flux_model: Optional[str] = Form(default=None, description="Flux 模型文件名(切换模型用)。None=工作流默认;如 flux-2-klein-9b-Q5_K_M.gguf / flux-2-klein-9b-Q4_K_M.gguf / flux2.0/flux-2-klein-9b-fp8.safetensors"),
|
||||
redraw_max_side: Optional[int] = Form(default=None, description="重绘压图长边像素。None=默认896;0=不缩图(原图直送);其他如 768/640/1024"),
|
||||
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||
):
|
||||
# 1. gender 必填校验(非法/缺失 → 1004)
|
||||
if gender not in ("male", "female"):
|
||||
@@ -1223,12 +791,11 @@ async def hair_grow(
|
||||
from hairline.service import generate_grow_results_swap
|
||||
items = await run_in_threadpool(
|
||||
generate_grow_results_swap, image, hair_styles, _V2_FINAL_DEFAULTS,
|
||||
redraw_max_side=redraw_max_side, unet_name=flux_model, prompt=prompt)
|
||||
prompt=prompt)
|
||||
else:
|
||||
from hairline.service import generate_grow_results
|
||||
items = await run_in_threadpool(
|
||||
generate_grow_results, image, gender, use_mask, prompt, hair_styles,
|
||||
unet_name=flux_model)
|
||||
generate_grow_results, image, gender, use_mask, prompt, hair_styles)
|
||||
if items is None:
|
||||
return err(1001, "无法识别人像")
|
||||
|
||||
@@ -1263,7 +830,7 @@ async def debug_grow_timing(
|
||||
hair_style: str = Form(default="2", description="发型序号(花瓣=2),逗号分隔多选"),
|
||||
webui_steps: Optional[int] = Form(default=None, description="swapHair webui img2img 采样步数,None=服务端默认(15),可填10/15/20/25对比"),
|
||||
redraw_max_side: Optional[int] = Form(default=None, description="ComfyUI重绘分辨率(长边像素)。None=默认896;0=原图不缩;其他如640/768/1024"),
|
||||
redraw_prompt: Optional[str] = Form(default=None, description="ComfyUI重绘提示词,None=默认'填充遮罩区域的头发,人物增加一点磨皮'"),
|
||||
redraw_prompt: Optional[str] = Form(default=None, description="ComfyUI重绘提示词,None=默认'填充遮罩区域的头发'"),
|
||||
):
|
||||
"""单图跑接口2女性生发,返回每个步骤的耗时 + 结果图,用于定位性能瓶颈。
|
||||
|
||||
@@ -1449,7 +1016,7 @@ async def hair_grow_b(
|
||||
marked_image_url: Optional[str] = Form(default=None, description="划线图片 URL"),
|
||||
marked_image_base64: Optional[str] = Form(default=None, description="划线图片 base64"),
|
||||
use_mask: bool = Form(default=True, description="是否画发际线(测试对比用)。false 时跳过划线检测、直接送划线图"),
|
||||
prompt: str = Form(default="填充遮罩区域的头发,人物增加一点磨皮", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||
):
|
||||
# 划线图三选一取图(只需这一张)
|
||||
marked_raw, e = await resolve_image_bytes(marked_image_file, marked_image_url, marked_image_base64)
|
||||
@@ -1660,7 +1227,7 @@ async def hairline_generate(
|
||||
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
|
||||
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填,如 1,2,3)。female:1-7 male:1-7"),
|
||||
use_mask: bool = Form(default=True, description="生发是否启用 inpaint 遮罩(同接口2,测试对比用)"),
|
||||
prompt: str = Form(default="填充遮罩区域的头发,人物增加一点磨皮", description="ComfyUI 提示词(同接口2),会替换工作流节点60的文本"),
|
||||
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", description="ComfyUI 提示词(同接口2),会替换工作流节点60的文本"),
|
||||
generate_grow_image: bool = Form(default=True, description="是否生成生发效果图(ComfyUI 生发,最耗时)。默认 true 出图;false 时跳过生发,各发型 grown_image 恒为 null,仅返回三档发际线叠图与中心点"),
|
||||
):
|
||||
if gender not in ("male", "female"):
|
||||
@@ -1898,70 +1465,6 @@ async def head_band(
|
||||
return err(1007, f"处理失败:{ex}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 接口 99:人脸特殊点检测(固定关键点 + 发际线特殊点)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.post(
|
||||
"/api/v1/face/special-points",
|
||||
summary="接口99 人脸特殊点检测",
|
||||
tags=["人脸分析"],
|
||||
description=f"""
|
||||
输入一张人脸照片,返回 MediaPipe Face Mesh 一组**固定编号特殊点**的像素坐标,
|
||||
以及 5 个**发际线特殊点**(射线与 BiSeNet 头发分割发际线的交点,见 hairline_grow 同款分割)。
|
||||
|
||||
**字段命名**:`<组英文名>_<点ID>`,如 `eyebrow_107`、`mouth_17`;发际线点用给定字段名
|
||||
(`hair_line_center` 等)。坐标为原图像素坐标(int),发际线点不可得(无头发/分割失败)时为 null。
|
||||
|
||||
| 组 | 字段前缀 | 点 ID |
|
||||
|----|----------|-------|
|
||||
| 眉毛 | eyebrow | 107 66 105 63 70 336 296 334 293 300 |
|
||||
| 眼睛 | eye | 189 222 224 113 226 413 442 444 342 446 |
|
||||
| 嘴 | mouth | 0 37 40 67 91 84 17 405 375 409 269 |
|
||||
| 中线鼻 | nose_midline | 9 168 197 5 4 194 278 48 437 217 |
|
||||
| 面颊 | cheek | 101 117 123 187 205 50 330 346 352 280 425 |
|
||||
| 边线 | face_edge | 162 234 58 136 149 152 378 365 288 401 366 389 251 |
|
||||
|
||||
**发际线点算法**:从关键点 a 沿 a→b 方向逐像素步进,首次进入头发 mask 处即交点;
|
||||
双射线的点取两交点中点(仅一条命中返回该点,均未命中为 null):
|
||||
- `hair_line_center`:151→10
|
||||
- `hair_line_right`:mid(108→109, 69→67)
|
||||
- `hair_line_right_right`:mid(104→103, 68→54)
|
||||
- `hair_line_left`:mid(337→338, 299→297)
|
||||
- `hair_line_left_left`:mid(333→332, 298→284)
|
||||
|
||||
{_image_fields_desc}
|
||||
""",
|
||||
)
|
||||
async def face_special_points(
|
||||
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG)"),
|
||||
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
||||
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
||||
):
|
||||
"""接口99:固定关键点 + 发际线特殊点,像素坐标 JSON 返回。"""
|
||||
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
|
||||
if e is not None:
|
||||
return e
|
||||
|
||||
image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
|
||||
if image is None:
|
||||
return err(1008, "图片格式不支持(仅 JPG / PNG)")
|
||||
|
||||
try:
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from face_analysis.special_points import detect_special_points, NoFaceError, NonFrontalError
|
||||
try:
|
||||
data = await run_in_threadpool(detect_special_points, image)
|
||||
except NoFaceError:
|
||||
return err(1001, "无法识别人像")
|
||||
except NonFrontalError:
|
||||
return err(1003, "角度问题,请上传正面照")
|
||||
return ok(data)
|
||||
except Exception as ex: # noqa: BLE001
|
||||
logger.exception("接口99 处理异常")
|
||||
return err(1007, f"处理失败:{ex}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 接口 11:发际线生发(接口9 遮罩 + change_hair 换发型 + 按遮罩羽化贴回)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2118,7 +1621,7 @@ async def hairline_grow_v2(
|
||||
inpainting_fill: int = Form(default=1, description="change_hair服务端重绘填充:0=保留原图 | 1=噪声 | 2=纯色 | 3=潜变量。默认 1"),
|
||||
mask_blur: int = Form(default=11, description="change_hair服务端遮罩边缘模糊像素,默认 11"),
|
||||
mask_dilate_scale: float = Form(default=1.0, description="change_hair服务端遮罩膨胀缩放,默认 1.0"),
|
||||
comfyui_prompt: Optional[str] = Form(default=None, description="Flux-2 重绘提示词,None 用默认「填充遮罩区域的头发,人物增加一点磨皮」"),
|
||||
comfyui_prompt: Optional[str] = Form(default=None, description="Flux-2 重绘提示词,None 用默认「填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜」"),
|
||||
beauty_alpha: float = Form(default=0.6, description="redraw_band 版 band 外的全脸美颜融入强度(0=band外无美颜纯用final,1≈整帧版),默认 0.6"),
|
||||
band_lo_mult: float = Form(default=0.5, description="重绘带外推倍率下限(相对 hairline_push_cm,内轮廓=0×、原外推线=1.0×),默认 0.5"),
|
||||
band_hi_mult: float = Form(default=1.5, description="重绘带外推倍率上限(相对 hairline_push_cm),默认 1.5"),
|
||||
@@ -2284,7 +1787,7 @@ async def hairline_grow_v2_final_v2(
|
||||
async def api_redraw(
|
||||
image_file: UploadFile = File(..., description="人物图片(JPG/PNG)"),
|
||||
mask_file: UploadFile = File(..., description="遮罩图片(PNG,支持红/白/alpha 格式)"),
|
||||
prompt: str = Form(default="填充遮罩区域的头发,人物增加一点磨皮",
|
||||
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜",
|
||||
description="ComfyUI 提示词"),
|
||||
):
|
||||
image_bytes = await image_file.read()
|
||||
@@ -2333,127 +1836,12 @@ async def download_hairline_log(rid: Optional[str] = None, tail: int = 500):
|
||||
# 健康检查
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_health_cfg() -> dict:
|
||||
"""深健康检查配置:环境变量优先,回退 worker_config.json 的 "health" 段。
|
||||
|
||||
min_free_gb=0 关闭磁盘检查;check_deps=false 关闭依赖探测(均退化为旧行为,
|
||||
即只看模型就绪标志)。
|
||||
"""
|
||||
cfg_path = os.path.join(os.path.dirname(__file__), "worker_config.json")
|
||||
file_cfg: dict = {}
|
||||
if os.path.isfile(cfg_path):
|
||||
try:
|
||||
with open(cfg_path, encoding="utf-8") as f:
|
||||
file_cfg = json.load(f).get("health") or {}
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("读取 worker_config.json health 段失败:%s", e)
|
||||
|
||||
def _get(env_key, file_key, default):
|
||||
raw = os.getenv(env_key)
|
||||
return raw if raw not in (None, "") else file_cfg.get(file_key, default)
|
||||
|
||||
def _as_float(value, default):
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
return {
|
||||
"min_free_gb": _as_float(_get("HEALTH_MIN_FREE_GB", "min_free_gb", 5.0), 5.0),
|
||||
"check_deps": str(_get("HEALTH_CHECK_DEPS", "check_deps", True)).strip().lower()
|
||||
not in ("0", "false", "no", "off"),
|
||||
"dep_comfyui": str(_get("HEALTH_DEP_COMFYUI", "dep_comfyui", "127.0.0.1:8188")),
|
||||
"deps_cache_seconds": max(0.0, _as_float(
|
||||
_get("HEALTH_DEPS_CACHE_SECONDS", "deps_cache_seconds", 5.0), 5.0)),
|
||||
}
|
||||
|
||||
|
||||
_HEALTH_CFG = _load_health_cfg()
|
||||
|
||||
|
||||
def _disk_free_gb() -> float:
|
||||
"""worker 所在文件系统的剩余空间(GB)。"""
|
||||
st = shutil.disk_usage(os.path.dirname(os.path.abspath(__file__)))
|
||||
return st.free / (1 << 30)
|
||||
|
||||
|
||||
def _parse_host_port(addr: str, default_port: int):
|
||||
"""解析 "host:port"(仅支持 IPv4/主机名形式)。"""
|
||||
host, _, port = str(addr).partition(":")
|
||||
try:
|
||||
return host or "127.0.0.1", int(port) if port else default_port
|
||||
except ValueError:
|
||||
return host or "127.0.0.1", default_port
|
||||
|
||||
|
||||
def _change_hair_addr():
|
||||
"""从 _CHANGE_HAIR_BASE 解析 (host, port),失败返回 None。"""
|
||||
try:
|
||||
u = urlsplit(_CHANGE_HAIR_BASE)
|
||||
if u.hostname:
|
||||
return u.hostname, u.port or 80
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def _tcp_probe(host: str, port: int, timeout: float = 0.8) -> bool:
|
||||
"""TCP 连通性探测:只判下游进程死活,不过 HTTP 认证、不受业务繁忙影响。"""
|
||||
try:
|
||||
_, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout)
|
||||
writer.close()
|
||||
return True
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
_DEP_CACHE = {"ts": -1.0, "detail": None}
|
||||
|
||||
|
||||
async def _check_deps():
|
||||
"""并发探测下游依赖(ComfyUI、change_hair),结果缓存 deps_cache_seconds 秒。"""
|
||||
now = time.monotonic()
|
||||
detail = _DEP_CACHE["detail"]
|
||||
if detail is None or now - _DEP_CACHE["ts"] >= _HEALTH_CFG["deps_cache_seconds"]:
|
||||
probes = {"comfyui": _tcp_probe(*_parse_host_port(_HEALTH_CFG["dep_comfyui"], 8188))}
|
||||
ch = _change_hair_addr()
|
||||
if ch:
|
||||
probes["change_hair"] = _tcp_probe(*ch)
|
||||
names = list(probes)
|
||||
results = await asyncio.gather(*probes.values())
|
||||
detail = dict(zip(names, results))
|
||||
_DEP_CACHE["ts"], _DEP_CACHE["detail"] = now, detail
|
||||
return all(detail.values()), detail
|
||||
|
||||
|
||||
@app.get("/health", include_in_schema=False)
|
||||
async def health():
|
||||
"""网关探测入口。
|
||||
|
||||
模型未就绪 → 503 starting;磁盘剩余低于阈值 / 下游依赖 TCP 不通 → 503 degraded;
|
||||
正常 → 200(附 disk_free_gb 与 deps 明细,供人工排查)。深检自身异常时按就绪返回
|
||||
(健康检查不能自己成为故障点)。
|
||||
"""
|
||||
# 模型就绪后才返回 200,供网关探测;未就绪返回 503。
|
||||
if not _STATE["ready"]:
|
||||
return JSONResponse(status_code=503, content={"status": "starting"})
|
||||
try:
|
||||
free_gb = _disk_free_gb()
|
||||
body = {"status": "ok", "disk_free_gb": round(free_gb, 1)}
|
||||
min_free = _HEALTH_CFG["min_free_gb"]
|
||||
if min_free > 0 and free_gb < min_free:
|
||||
body.update(status="degraded", reason=f"disk_free_below_{min_free}g")
|
||||
return JSONResponse(status_code=503, content=body)
|
||||
if _HEALTH_CFG["check_deps"]:
|
||||
deps_ok, detail = await _check_deps()
|
||||
body["deps"] = detail
|
||||
if not deps_ok:
|
||||
down = ",".join(k for k, v in detail.items() if not v)
|
||||
body.update(status="degraded", reason=f"dep_down:{down}")
|
||||
return JSONResponse(status_code=503, content=body)
|
||||
return body
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("深健康检查异常,按就绪返回")
|
||||
return {"status": "ok"}
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""快速测试:3张图×花瓣发型×896分辨率,新提示词"填充遮罩区域的头发,人物增加一点磨皮"。
|
||||
预热1次+正式1次。
|
||||
"""
|
||||
import base64, json, os, time
|
||||
from pathlib import Path
|
||||
import requests
|
||||
|
||||
API = "http://127.0.0.1:8187/api/v1/debug/grow-timing"
|
||||
TOKEN = "dev-shared-secret-2026"
|
||||
PROMPT = "填充遮罩区域的头发,人物增加一点磨皮"
|
||||
OUT = Path("/home/ubuntu/hair/benchmark_out/bench6")
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
IMGS = [
|
||||
("asdf", "/home/ubuntu/hair/image/asdf.jpg"),
|
||||
("qwer", "/home/ubuntu/hair/image/qwer.jpg"),
|
||||
("girl5", "/home/ubuntu/hair/image/girl_img/girl5.jpg"),
|
||||
]
|
||||
|
||||
def call(img_path, save_grown=None, timeout=300):
|
||||
data = {"hair_style": "2", "webui_steps": "15", "redraw_max_side": "896", "redraw_prompt": PROMPT}
|
||||
t0 = time.perf_counter()
|
||||
with open(img_path, "rb") as f:
|
||||
r = requests.post(API, headers={"X-Internal-Token": TOKEN},
|
||||
files={"image_file": (os.path.basename(img_path), f, "image/jpeg")},
|
||||
data=data, timeout=timeout)
|
||||
wall = time.perf_counter() - t0
|
||||
j = r.json()
|
||||
d = j["data"]; hs = d["per_hairstyle"][0]
|
||||
if save_grown and hs.get("grown_b64"):
|
||||
b = hs["grown_b64"].split(",")[1] if "," in hs["grown_b64"] else hs["grown_b64"]
|
||||
open(save_grown, "wb").write(base64.b64decode(b))
|
||||
return {"ok": hs.get("ok"), "total_ms": d["total_ms"], "comfy_ms": hs.get("comfyui_redraw_ms"),
|
||||
"grown_path": str(save_grown) if save_grown and hs.get("ok") else None}
|
||||
|
||||
results = []
|
||||
for ilabel, ipath in IMGS:
|
||||
print(f"预热 {ilabel}...", flush=True)
|
||||
call(ipath)
|
||||
save = OUT / f"{ilabel}_flower_896.jpg"
|
||||
print(f"正式 {ilabel}...", flush=True)
|
||||
r = call(ipath, save_grown=save)
|
||||
r["img"] = ilabel
|
||||
print(f" -> total={r['total_ms']}ms ok={r['ok']}", flush=True)
|
||||
results.append(r)
|
||||
|
||||
json.dump({"prompt": PROMPT, "results": results}, open(OUT/"results.json","w"), ensure_ascii=False, indent=2)
|
||||
print(f"\n✓ 完成 {sum(1 for r in results if r['ok'])}/3", flush=True)
|
||||
@@ -1,134 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""发型对比矩阵测试:3图×5发型=15行,每行10张图(4b@896×1 + 9b三模型×三分辨率×9)。
|
||||
按模型分组跑(减少模型切换次数、降低OOM风险),结果重组为15行存JSON+生成报告。
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
API = "http://127.0.0.1:8187/api/v1/hair/grow"
|
||||
TOKEN = "dev-shared-secret-2026"
|
||||
OUT = Path("/home/ubuntu/hair/benchmark_out/hairstyle")
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
IMGS = [
|
||||
("asdf", "/home/ubuntu/hair/image/asdf.jpg"),
|
||||
("qwer", "/home/ubuntu/hair/image/qwer.jpg"),
|
||||
("girl5", "/home/ubuntu/hair/image/girl_img/girl5.jpg"),
|
||||
]
|
||||
HAIRSTYLES = [
|
||||
(1, "ellipse", "椭圆"), (2, "flower", "花瓣"), (3, "heart", "心形"),
|
||||
(4, "straight", "直线"), (5, "wave", "波浪"),
|
||||
]
|
||||
|
||||
# 按模型分组:每个模型对应其要跑的(分辨率,列标题)
|
||||
MODEL_GROUPS = [
|
||||
("flux-2-klein-4b-fp8.safetensors", [("896", "4B@896")]),
|
||||
("flux2.0/flux-2-klein-9b-fp8.safetensors",
|
||||
[("0", "9B-fp8@原图"), ("896", "9B-fp8@896"), ("640", "9B-fp8@640")]),
|
||||
("flux-2-klein-9b-Q5_K_M.gguf",
|
||||
[("0", "9B-Q5@原图"), ("896", "9B-Q5@896"), ("640", "9B-Q5@640")]),
|
||||
("flux-2-klein-9b-Q4_K_M.gguf",
|
||||
[("0", "9B-Q4@原图"), ("896", "9B-Q4@896"), ("640", "9B-Q4@640")]),
|
||||
]
|
||||
# 列顺序(4b在前,然后9b三模型)
|
||||
COLUMN_TITLES = ["4B@896", "9B-fp8@原图", "9B-fp8@896", "9B-fp8@640",
|
||||
"9B-Q5@原图", "9B-Q5@896", "9B-Q5@640",
|
||||
"9B-Q4@原图", "9B-Q4@896", "9B-Q4@640"]
|
||||
|
||||
|
||||
def gpu_used():
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["nvidia-smi", "--query-gpu=memory.used", "--format=csv,noheader,nounits"], timeout=10)
|
||||
return int(out.decode().strip())
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def call(img_path, hair_num, model_file, res_val):
|
||||
fd = {"gender": "female", "hair_style": str(hair_num), "use_mask": "true",
|
||||
"prompt": "填充遮罩区域的头发,人物增加一点磨皮"}
|
||||
if model_file:
|
||||
fd["flux_model"] = model_file
|
||||
if res_val != "":
|
||||
fd["redraw_max_side"] = res_val
|
||||
t0 = time.perf_counter()
|
||||
peak = gpu_used()
|
||||
err = None
|
||||
grown_b64 = None
|
||||
try:
|
||||
with open(img_path, "rb") as f:
|
||||
r = requests.post(API, headers={"X-Internal-Token": TOKEN},
|
||||
files={"image_file": (os.path.basename(img_path), f, "image/jpeg")},
|
||||
data=fd, timeout=300)
|
||||
elapsed = time.perf_counter() - t0
|
||||
peak = max(peak, gpu_used())
|
||||
j = r.json()
|
||||
if j.get("code") != 0:
|
||||
err = f"code={j.get('code')} {j.get('message', '')[:60]}"
|
||||
else:
|
||||
res = j.get("data", {}).get("results", [])
|
||||
if res and res[0].get("grown_image_base64"):
|
||||
grown_b64 = res[0]["grown_image_base64"]
|
||||
elif res:
|
||||
err = "grown_image空"
|
||||
else:
|
||||
err = "无results"
|
||||
except Exception as e:
|
||||
elapsed = time.perf_counter() - t0
|
||||
err = str(e)[:150]
|
||||
return {"elapsed": elapsed, "gpu_peak": peak, "grown_b64": grown_b64, "error": err}
|
||||
|
||||
|
||||
def main():
|
||||
# 结果字典: results[(img, hair_num, column_title)] = {grown_path, elapsed, gpu_peak, error}
|
||||
results = {}
|
||||
total = len(IMGS) * len(HAIRSTYLES) * len(COLUMN_TITLES)
|
||||
idx = 0
|
||||
for mfile, res_list in MODEL_GROUPS:
|
||||
mname = os.path.basename(mfile)
|
||||
print(f"\n===== 切换到模型: {mname} =====", flush=True)
|
||||
# 等模型切换稳定
|
||||
time.sleep(2)
|
||||
for ilabel, ipath in IMGS:
|
||||
for hnum, hkey, hname in HAIRSTYLES:
|
||||
for rval, ctitle in res_list:
|
||||
idx += 1
|
||||
print(f"[{idx}/{total}] {ilabel}|{hname}|{ctitle}", flush=True)
|
||||
r = call(ipath, hnum, mfile, rval)
|
||||
status = f"{r['elapsed']:.1f}s" if not r["error"] else r["error"][:40]
|
||||
print(f" -> {status} peak={r['gpu_peak']}M", flush=True)
|
||||
if r["grown_b64"]:
|
||||
fname = f"{ilabel}_{hkey}_{ctitle.replace('@','_').replace('-','')}.jpg"
|
||||
with open(OUT / fname, "wb") as gf:
|
||||
gf.write(base64.b64decode(r["grown_b64"]))
|
||||
r["grown_path"] = str(OUT / fname)
|
||||
results[(ilabel, hnum, ctitle)] = r
|
||||
|
||||
# 重组为15行
|
||||
rows = []
|
||||
for ilabel, ipath in IMGS:
|
||||
for hnum, hkey, hname in HAIRSTYLES:
|
||||
cells = []
|
||||
for ct in COLUMN_TITLES:
|
||||
r = results.get((ilabel, hnum, ct), {"error": "未跑"})
|
||||
cells.append({"title": ct, **{k: v for k, v in r.items() if k != "grown_b64"}})
|
||||
rows.append({"img": ilabel, "img_path": ipath,
|
||||
"hair_num": hnum, "hair_key": hkey, "hair_name": hname,
|
||||
"cells": cells})
|
||||
with open(OUT / "results.json", "w", encoding="utf-8") as f:
|
||||
json.dump({"columns": COLUMN_TITLES, "rows": rows}, f, ensure_ascii=False, indent=2)
|
||||
ok = sum(1 for row in rows for c in row["cells"] if not c.get("error"))
|
||||
print(f"\n✓ 完成: {ok}/{total} 成功 -> {OUT/'results.json'}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,128 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""把发型对比测试结果生成 HTML 报告。
|
||||
15行(3图×5发型) × 10列(4b@896 + 9b三模型×三分辨率),每行首列=原图。
|
||||
图片 base64 内嵌,自包含单文件。
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
OUT = Path("/home/ubuntu/hair/benchmark_out/hairstyle")
|
||||
RESULTS = OUT / "results.json"
|
||||
HTML = OUT / "report.html"
|
||||
|
||||
|
||||
def img_src(path):
|
||||
"""把绝对路径转成报告里的相对 URL(报告在 static/,图片在 static/bench/)。"""
|
||||
if not path:
|
||||
return None
|
||||
p = str(path)
|
||||
if "benchmark_out/hairstyle/" in p:
|
||||
return "bench/hairstyle/" + os.path.basename(p)
|
||||
if "benchmark_out/matrix/" in p:
|
||||
return "bench/matrix/" + os.path.basename(p)
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
d = json.load(open(RESULTS, encoding="utf-8"))
|
||||
columns = d["columns"]
|
||||
rows = d["rows"]
|
||||
|
||||
# 统计每列的平均耗时、峰值显存
|
||||
col_stats = {}
|
||||
for ct in columns:
|
||||
times, peaks = [], []
|
||||
for r in rows:
|
||||
for c in r["cells"]:
|
||||
if c.get("title") == ct and not c.get("error"):
|
||||
times.append(c["elapsed"])
|
||||
peaks.append(c["gpu_peak"])
|
||||
col_stats[ct] = {
|
||||
"avg_t": sum(times) / len(times) if times else 0,
|
||||
"max_p": max(peaks) / 1024 if peaks else 0,
|
||||
}
|
||||
|
||||
# 表头:原图 + 10列
|
||||
headers = ['<th class="col-label">原图</th>']
|
||||
for ct in columns:
|
||||
s = col_stats[ct]
|
||||
headers.append(
|
||||
f'<th class="col-label"><div class="col-title">{ct}</div>'
|
||||
f'<div class="col-stat">{s["avg_t"]:.0f}s · {s["max_p"]:.0f}G</div></th>'
|
||||
)
|
||||
|
||||
# 表体:15行
|
||||
body_rows = []
|
||||
for r in rows:
|
||||
# 发型+图标签
|
||||
label = f'<div class="row-label">{r["img"]}<br><b>{r["hair_name"]}</b></div>'
|
||||
# 原图
|
||||
ORIG_SRC = {"asdf": "bench/orig/asdf.jpg", "qwer": "bench/orig/qwer.jpg", "girl5": "bench/orig/girl5.jpg"}
|
||||
orig = ORIG_SRC.get(r["img"])
|
||||
cells = [f'<td class="cell-orig"><div class="row-label-cell">{label}</div>'
|
||||
f'<img class="orig-img" src="{orig}"></td>']
|
||||
# 10个结果列
|
||||
for ct in columns:
|
||||
c = next((x for x in r["cells"] if x.get("title") == ct), {})
|
||||
src = img_src(c.get("grown_path")) if not c.get("error") else None
|
||||
if src:
|
||||
cells.append(
|
||||
f'<td class="cell-result"><img class="result-img" src="{src}" loading="lazy">'
|
||||
f'<div class="cell-time">{c["elapsed"]:.1f}s</div></td>')
|
||||
else:
|
||||
cells.append(f'<td class="cell-result"><div class="na">⚠</div></td>')
|
||||
body_rows.append(f'<tr>{"".join(cells)}</tr>')
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>发型对比测试报告 — 4模型×3分辨率</title>
|
||||
<style>
|
||||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||
body {{ font-family: -apple-system, "Segoe UI", sans-serif; background: #f5f5f5; padding: 16px; }}
|
||||
h1 {{ font-size: 20px; margin-bottom: 4px; }}
|
||||
.subtitle {{ color: #888; font-size: 12px; margin-bottom: 12px; }}
|
||||
.legend {{ background: #fff; border-radius: 8px; padding: 10px 16px; margin-bottom: 12px; font-size: 12px; color: #555; }}
|
||||
.scroll-wrap {{ overflow-x: auto; }}
|
||||
table {{ border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.06); }}
|
||||
th, td {{ border: 1px solid #eee; padding: 6px; vertical-align: top; text-align: center; }}
|
||||
th {{ background: #f9fafb; position: sticky; top: 0; }}
|
||||
.col-label {{ min-width: 110px; max-width: 130px; }}
|
||||
.col-title {{ font-size: 12px; font-weight: 700; color: #374151; }}
|
||||
.col-stat {{ font-size: 10px; color: #9ca3af; margin-top: 2px; }}
|
||||
.row-label {{ font-size: 11px; color: #6b7280; }}
|
||||
.row-label b {{ color: #1f2937; }}
|
||||
.row-label-cell {{ font-size: 11px; color: #6b7280; margin-bottom: 4px; }}
|
||||
.row-label-cell b {{ color: #1f2937; font-size: 13px; }}
|
||||
img {{ border-radius: 4px; max-width: 120px; max-height: 150px; object-fit: contain; background: #f3f4f6; }}
|
||||
.orig-img {{ border: 2px solid #d1d5db; max-height: 130px; }}
|
||||
.cell-time {{ font-size: 10px; color: #9ca3af; margin-top: 2px; }}
|
||||
.na {{ color: #d1d5db; font-size: 16px; padding: 40px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>💇 发型对比测试报告</h1>
|
||||
<p class="subtitle">接口2女性 · 3图×5发型=15行 · 每行: 4B@896(1) + 9B(fp8/Q5/Q4)×(原图/896/640)(9) · 150/150成功 · RTX3090</p>
|
||||
<div class="legend">列标题下显示<b>平均耗时 · 峰值显存</b>。横向滚动查看更多列。原图列含图片名+发型名。</div>
|
||||
<div class="scroll-wrap">
|
||||
<table>
|
||||
<tr>{"".join(headers)}</tr>
|
||||
{"".join(body_rows)}
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
with open(HTML, "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
print(f"✓ 报告: {HTML} ({HTML.stat().st_size//1024} KB)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,129 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""接口2女性 花瓣形 单发型 4模型×3分辨率×3图×3次 矩阵测试。
|
||||
|
||||
调用本机 hair-worker (:8187) 的 /api/v1/hair/grow,gender=female, hair_style=2(花瓣形)。
|
||||
每次记录:生发图、耗时、显存峰值。结果图存到 benchmark_out/matrix/,最后生成 HTML 报告。
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
API = "http://127.0.0.1:8187/api/v1/hair/grow"
|
||||
TOKEN = "dev-shared-secret-2026"
|
||||
OUT = Path("/home/ubuntu/hair/benchmark_out/matrix")
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 4 模型 × 3 分辨率 × 3 图 × 3 次
|
||||
MODELS = [
|
||||
("4b-fp8", "flux-2-klein-4b-fp8.safetensors"),
|
||||
("9b-fp8", "flux2.0/flux-2-klein-9b-fp8.safetensors"),
|
||||
("9b-Q5", "flux-2-klein-9b-Q5_K_M.gguf"),
|
||||
("9b-Q4", "flux-2-klein-9b-Q4_K_M.gguf"),
|
||||
]
|
||||
RES = [("orig", "0"), ("640", "640"), ("896", "896")]
|
||||
IMGS = [
|
||||
("asdf", "/home/ubuntu/hair/image/asdf.jpg"),
|
||||
("qwer", "/home/ubuntu/hair/image/qwer.jpg"),
|
||||
("girl5", "/home/ubuntu/hair/image/girl_img/girl5.jpg"),
|
||||
]
|
||||
REPEAT = 3
|
||||
|
||||
|
||||
def gpu_used():
|
||||
"""返回当前显存已用 MiB。"""
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["nvidia-smi", "--query-gpu=memory.used", "--format=csv,noheader,nounits"],
|
||||
timeout=10,
|
||||
)
|
||||
return int(out.decode().strip())
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def call(img_path, model_file, res_val):
|
||||
"""调一次接口2。返回 dict: ok/elapsed/grown_path/gpu_peak/error。"""
|
||||
fd = {
|
||||
"gender": "female",
|
||||
"hair_style": "2", # 花瓣形
|
||||
"use_mask": "true",
|
||||
"prompt": "填充遮罩区域的头发,人物增加一点磨皮",
|
||||
}
|
||||
if model_file:
|
||||
fd["flux_model"] = model_file
|
||||
if res_val != "":
|
||||
fd["redraw_max_side"] = res_val
|
||||
t0 = time.perf_counter()
|
||||
peak = gpu_used()
|
||||
err = None
|
||||
grown_path = None
|
||||
try:
|
||||
with open(img_path, "rb") as f:
|
||||
r = requests.post(
|
||||
API, headers={"X-Internal-Token": TOKEN},
|
||||
files={"image_file": (os.path.basename(img_path), f, "image/jpeg")},
|
||||
data=fd, timeout=300,
|
||||
)
|
||||
elapsed = time.perf_counter() - t0
|
||||
# 采样峰值(推理刚结束)
|
||||
peak = max(peak, gpu_used())
|
||||
j = r.json()
|
||||
if j.get("code") != 0:
|
||||
err = f"code={j.get('code')} {j.get('message','')}"
|
||||
else:
|
||||
res = j.get("data", {}).get("results", [])
|
||||
if res and res[0].get("grown_image_base64"):
|
||||
grown_path = OUT / f"tmp_grown.jpg"
|
||||
with open(grown_path, "wb") as gf:
|
||||
gf.write(base64.b64decode(res[0]["grown_image_base64"]))
|
||||
elif res:
|
||||
err = "grown_image_base64 为空"
|
||||
else:
|
||||
err = "无 results"
|
||||
except Exception as e:
|
||||
elapsed = time.perf_counter() - t0
|
||||
err = str(e)[:200]
|
||||
return {"elapsed": elapsed, "gpu_peak": peak, "grown_path": str(grown_path) if grown_path else None, "error": err}
|
||||
|
||||
|
||||
def main():
|
||||
results = [] # 每元素一个组合
|
||||
total = len(MODELS) * len(RES) * len(IMGS) * REPEAT
|
||||
idx = 0
|
||||
for mlabel, mfile in MODELS:
|
||||
for rlabel, rval in RES:
|
||||
for ilabel, ipath in IMGS:
|
||||
# 一个组合:3 次
|
||||
runs = []
|
||||
for rep in range(REPEAT):
|
||||
idx += 1
|
||||
print(f"[{idx}/{total}] {mlabel} | res={rlabel} | {ilabel} | rep{rep+1}", flush=True)
|
||||
r = call(ipath, mfile, rval)
|
||||
print(f" -> {r['elapsed']:.1f}s peak={r['gpu_peak']}MiB err={r['error']}", flush=True)
|
||||
# 存每次的生发图
|
||||
if r["grown_path"]:
|
||||
save_to = OUT / f"{mlabel}_{rlabel}_{ilabel}_r{rep+1}.jpg"
|
||||
os.replace(r["grown_path"], save_to)
|
||||
r["grown_path"] = str(save_to)
|
||||
runs.append(r)
|
||||
results.append({
|
||||
"model": mlabel, "model_file": mfile,
|
||||
"res": rlabel, "res_val": rval,
|
||||
"img": ilabel, "img_path": ipath,
|
||||
"runs": runs,
|
||||
})
|
||||
# 存原始数据
|
||||
with open(OUT / "results.json", "w", encoding="utf-8") as f:
|
||||
json.dump(results, f, ensure_ascii=False, indent=2)
|
||||
print(f"\n✓ 全部完成,原始数据 -> {OUT/'results.json'}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,157 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""把 benchmark_out/matrix/results.json 生成 HTML 报告。
|
||||
每个组合一行:原图 + 3次生发图 + 耗时/显存。
|
||||
图片用 base64 内嵌(自包含单文件,便于部署)。
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
OUT = Path("/home/ubuntu/hair/benchmark_out/matrix")
|
||||
RESULTS = OUT / "results.json"
|
||||
HTML = OUT / "report.html"
|
||||
|
||||
RES_LABEL = {"orig": "原图", "640": "640", "896": "896(默认)"}
|
||||
MODEL_LABEL = {
|
||||
"4b-fp8": "4B fp8 (3.8G)",
|
||||
"9b-fp8": "9B fp8 (8.8G)",
|
||||
"9b-Q5": "9B Q5_K_M (6.6G)",
|
||||
"9b-Q4": "9B Q4_K_M (5.6G)",
|
||||
}
|
||||
MODEL_ORDER = ["4b-fp8", "9b-Q4", "9b-Q5", "9b-fp8"]
|
||||
|
||||
|
||||
def img_src(path):
|
||||
"""把绝对路径转成报告里的相对 URL(报告在 static/,图片在 static/bench/)。"""
|
||||
if not path:
|
||||
return None
|
||||
p = str(path)
|
||||
# benchmark_out/matrix/xxx.jpg -> bench/matrix/xxx.jpg
|
||||
if "benchmark_out/matrix/" in p:
|
||||
return "bench/matrix/" + os.path.basename(p)
|
||||
if "benchmark_out/hairstyle/" in p:
|
||||
return "bench/hairstyle/" + os.path.basename(p)
|
||||
return None
|
||||
|
||||
|
||||
def thumb(src, alt="", cls=""):
|
||||
if not src:
|
||||
return f'<div class="na {cls}">⚠ 失败</div>'
|
||||
return f'<img class="{cls}" src="{src}" alt="{alt}" loading="lazy">'
|
||||
|
||||
|
||||
def main():
|
||||
data = json.load(open(RESULTS, encoding="utf-8"))
|
||||
# 原图相对路径映射(图片在 static/bench/orig/)
|
||||
ORIG_SRC = {"asdf": "bench/orig/asdf.jpg", "qwer": "bench/orig/qwer.jpg", "girl5": "bench/orig/girl5.jpg"}
|
||||
|
||||
# 统计:每个模型的平均耗时、平均峰值显存
|
||||
stats = {}
|
||||
for c in data:
|
||||
m = c["model"]
|
||||
stats.setdefault(m, {"times": [], "peaks": []})
|
||||
for r in c["runs"]:
|
||||
if not r["error"]:
|
||||
stats[m]["times"].append(r["elapsed"])
|
||||
stats[m]["peaks"].append(r["gpu_peak"])
|
||||
|
||||
rows_html = []
|
||||
# 按模型顺序、分辨率顺序、图片顺序排列
|
||||
for m in MODEL_ORDER:
|
||||
mdata = [c for c in data if c["model"] == m]
|
||||
for rlabel in ["orig", "640", "896"]:
|
||||
for ilabel in ["asdf", "qwer", "girl5"]:
|
||||
c = next((x for x in mdata if x["res"] == rlabel and x["img"] == ilabel), None)
|
||||
if not c:
|
||||
continue
|
||||
# 3 次结果图
|
||||
run_cells = []
|
||||
for i, r in enumerate(c["runs"]):
|
||||
src = img_src(r["grown_path"]) if not r["error"] else None
|
||||
if src:
|
||||
run_cells.append(
|
||||
f'<div class="run-cell"><div class="run-label">第{i+1}次 · {r["elapsed"]:.1f}s</div>'
|
||||
f'{thumb(src, f"r{i+1}", "result-img")}</div>'
|
||||
)
|
||||
else:
|
||||
run_cells.append(
|
||||
f'<div class="run-cell"><div class="run-label">第{i+1}次 · 失败</div>'
|
||||
f'<div class="na">⚠ {r["error"][:30] if r["error"] else ""}</div></div>'
|
||||
)
|
||||
|
||||
orig = ORIG_SRC.get(c["img"])
|
||||
rows_html.append(f'''
|
||||
<div class="combo-row">
|
||||
<div class="cell-model">{MODEL_LABEL.get(m, m)}<div class="cell-sub">res={RES_LABEL.get(rlabel, rlabel)}</div></div>
|
||||
<div class="cell-img">{thumb(orig, "原图", "orig-img")}<div class="run-label">{ilabel}</div></div>
|
||||
<div class="cell-runs">{"".join(run_cells)}</div>
|
||||
</div>''')
|
||||
|
||||
# 模型对比汇总
|
||||
summary_rows = []
|
||||
for m in MODEL_ORDER:
|
||||
s = stats.get(m, {"times": [], "peaks": []})
|
||||
if s["times"]:
|
||||
avg_t = sum(s["times"]) / len(s["times"])
|
||||
max_p = max(s["peaks"]) / 1024
|
||||
summary_rows.append(
|
||||
f"<tr><td>{MODEL_LABEL.get(m,m)}</td><td>{avg_t:.1f}s</td>"
|
||||
f"<td>{max_p:.1f} GB</td><td>{len(s['times'])} 成功</td></tr>"
|
||||
)
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Flux 模型矩阵测试报告 — 接口2女性花瓣形</title>
|
||||
<style>
|
||||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f5f5; color: #333; padding: 20px; }}
|
||||
h1 {{ font-size: 22px; margin-bottom: 4px; }}
|
||||
.subtitle {{ color: #888; font-size: 13px; margin-bottom: 16px; }}
|
||||
.summary {{ background: #fff; border-radius: 10px; padding: 16px 20px; margin-bottom: 20px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }}
|
||||
.summary h2 {{ font-size: 16px; margin-bottom: 10px; }}
|
||||
.summary table {{ border-collapse: collapse; width: 100%; font-size: 14px; }}
|
||||
.summary th, .summary td {{ border: 1px solid #e5e7eb; padding: 8px 12px; text-align: left; }}
|
||||
.summary th {{ background: #f9fafb; font-weight: 600; }}
|
||||
.combo-row {{ display: flex; align-items: flex-start; gap: 12px; background: #fff; border-radius: 10px;
|
||||
padding: 12px 16px; margin-bottom: 10px; box-shadow: 0 1px 3px rgba(0,0,0,.05); }}
|
||||
.cell-model {{ min-width: 130px; font-weight: 700; font-size: 14px; padding-top: 6px; }}
|
||||
.cell-sub {{ font-weight: 400; font-size: 12px; color: #6b7280; margin-top: 2px; }}
|
||||
.cell-img {{ min-width: 160px; text-align: center; }}
|
||||
.cell-runs {{ display: flex; gap: 10px; flex: 1; }}
|
||||
.run-cell {{ text-align: center; }}
|
||||
.run-label {{ font-size: 11px; color: #6b7280; margin-bottom: 4px; }}
|
||||
img {{ border-radius: 6px; max-height: 200px; max-width: 100%; object-fit: contain; background: #f9fafb; }}
|
||||
.orig-img {{ max-height: 180px; border: 2px solid #e5e7eb; }}
|
||||
.result-img {{ max-height: 200px; }}
|
||||
.na {{ color: #d1d5db; font-size: 12px; padding: 40px 20px; background: #f9fafb; border-radius: 6px; width: 150px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>💇 Flux 模型矩阵测试报告</h1>
|
||||
<p class="subtitle">接口2女性 · 花瓣形发型 · 4模型 × 3分辨率 × 3图 × 3次 = 108 次 · RTX 3090 24GB</p>
|
||||
|
||||
<div class="summary">
|
||||
<h2>📊 模型对比汇总</h2>
|
||||
<table>
|
||||
<tr><th>模型</th><th>平均耗时</th><th>峰值显存</th><th>成功次数</th></tr>
|
||||
{"".join(summary_rows)}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 style="font-size:16px;margin:24px 0 12px">🖼️ 各组合对比(每行:原图 + 3次生发结果)</h2>
|
||||
{"".join(rows_html)}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
with open(HTML, "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
print(f"✓ 报告已生成: {HTML} ({HTML.stat().st_size//1024} KB)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,99 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""分辨率对比测试:4图×5发型=20行,每行4种分辨率(不缩放/896/768/640),steps=15。
|
||||
热数据:每个组合预热1次(丢弃)+正式1次。OOM的跳过记录为失败。
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
API = "http://127.0.0.1:8187/api/v1/debug/grow-timing"
|
||||
TOKEN = "dev-shared-secret-2026"
|
||||
OUT = Path("/home/ubuntu/hair/benchmark_out/bench3")
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
IMGS = [
|
||||
("asdf", "/home/ubuntu/hair/image/asdf.jpg"),
|
||||
("qwer", "/home/ubuntu/hair/image/qwer.jpg"),
|
||||
("girl2", "/home/ubuntu/hair/image/girl_img/girl2.jpg"),
|
||||
("girl5", "/home/ubuntu/hair/image/girl_img/girl5.jpg"),
|
||||
]
|
||||
HAIRSTYLES = [
|
||||
(1, "ellipse", "椭圆"), (2, "flower", "花瓣"), (3, "heart", "心形"),
|
||||
(4, "straight", "直线"), (5, "wave", "波浪"),
|
||||
]
|
||||
# 分辨率档:0=不缩放(原图)
|
||||
RES_LIST = [("orig", "0"), ("896", "896"), ("768", "768"), ("640", "640")]
|
||||
RES_TITLES = ["原图(不缩放)", "896", "768", "640"]
|
||||
STEPS = 15
|
||||
|
||||
|
||||
def call(img_path, hair_num, redraw_max_side, save_grown=None, timeout=300):
|
||||
data = {"hair_style": str(hair_num), "webui_steps": str(STEPS),
|
||||
"redraw_max_side": str(redraw_max_side)}
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
with open(img_path, "rb") as f:
|
||||
r = requests.post(API, headers={"X-Internal-Token": TOKEN},
|
||||
files={"image_file": (os.path.basename(img_path), f, "image/jpeg")},
|
||||
data=data, timeout=timeout)
|
||||
wall = time.perf_counter() - t0
|
||||
j = r.json()
|
||||
if j.get("code") != 0:
|
||||
return {"ok": False, "error": j.get("message", "")[:80], "wall": wall}
|
||||
d = j["data"]
|
||||
hs = d["per_hairstyle"][0]
|
||||
if save_grown and hs.get("grown_b64"):
|
||||
b = hs["grown_b64"].split(",")[1] if "," in hs["grown_b64"] else hs["grown_b64"]
|
||||
with open(save_grown, "wb") as gf:
|
||||
gf.write(base64.b64decode(b))
|
||||
return {
|
||||
"ok": hs.get("ok", False), "wall": wall,
|
||||
"total_ms": d["total_ms"], "swap_ms": hs.get("swap_ms"),
|
||||
"comfy_ms": hs.get("comfyui_redraw_ms"),
|
||||
"error": hs.get("error"),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)[:80], "wall": time.perf_counter() - t0}
|
||||
|
||||
|
||||
def main():
|
||||
rows = []
|
||||
total = len(IMGS) * len(HAIRSTYLES) * len(RES_LIST) * 2
|
||||
idx = 0
|
||||
for ilabel, ipath in IMGS:
|
||||
for hnum, hkey, hname in HAIRSTYLES:
|
||||
cells = []
|
||||
for (rlabel, rval), rtitle in zip(RES_LIST, RES_TITLES):
|
||||
# 预热
|
||||
idx += 1
|
||||
print(f"[{idx}/{total}] 预热 {ilabel}|{hname}|{rtitle}", flush=True)
|
||||
try:
|
||||
call(ipath, hnum, rval, timeout=120)
|
||||
except Exception:
|
||||
pass # 预热失败(可能OOM)不中断
|
||||
# 正式
|
||||
idx += 1
|
||||
save = OUT / f"{ilabel}_{hkey}_{rlabel}.jpg"
|
||||
print(f"[{idx}/{total}] 正式 {ilabel}|{hname}|{rtitle}", flush=True)
|
||||
r = call(ipath, hnum, rval, save_grown=save, timeout=300)
|
||||
r["res_label"] = rlabel; r["res_title"] = rtitle
|
||||
r["grown_path"] = str(save) if r.get("ok") else None
|
||||
status = f"{r.get('total_ms')}ms" if r.get("ok") else f"FAIL:{r.get('error','')[:30]}"
|
||||
print(f" -> {status}", flush=True)
|
||||
cells.append(r)
|
||||
rows.append({"img": ilabel, "img_path": ipath,
|
||||
"hair_num": hnum, "hair_key": hkey, "hair_name": hname,
|
||||
"cells": cells})
|
||||
with open(OUT / "results.json", "w", encoding="utf-8") as f:
|
||||
json.dump({"res_titles": RES_TITLES, "rows": rows}, f, ensure_ascii=False, indent=2)
|
||||
ok = sum(1 for row in rows for c in row["cells"] if c.get("ok"))
|
||||
print(f"\n✓ 完成: {ok}/{len(rows)*len(RES_LIST)} 成功 -> {OUT/'results.json'}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,103 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""生成分辨率对比报告:20行(4图×5发型) × 4列(原图不缩放/896/768/640)。"""
|
||||
import json
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
OUT = Path("/home/ubuntu/hair/benchmark_out/bench3")
|
||||
RESULTS = OUT / "results.json"
|
||||
HTML = OUT / "report.html"
|
||||
|
||||
|
||||
def img_src(path):
|
||||
if not path or not os.path.isfile(path):
|
||||
return None
|
||||
return "bench3/" + os.path.basename(path)
|
||||
|
||||
|
||||
def main():
|
||||
d = json.load(open(RESULTS, encoding="utf-8"))
|
||||
titles = d["res_titles"]
|
||||
rows = d["rows"]
|
||||
|
||||
# 各分辨率平均耗时
|
||||
col_stats = defaultdict(lambda: {"total": [], "comfy": []})
|
||||
for r in rows:
|
||||
for c in r["cells"]:
|
||||
if c.get("ok"):
|
||||
col_stats[c["res_title"]]["total"].append(c["total_ms"])
|
||||
col_stats[c["res_title"]]["comfy"].append(c.get("comfy_ms", 0))
|
||||
|
||||
# 表头
|
||||
headers = ['<th class="col-label">原图</th>']
|
||||
for t in titles:
|
||||
s = col_stats.get(t)
|
||||
avg = sum(s["total"]) // len(s["total"]) if s and s["total"] else 0
|
||||
headers.append(f'<th class="col-label"><div class="col-title">{t}</div>'
|
||||
f'<div class="col-stat">均{avg/1000:.1f}s</div></th>')
|
||||
|
||||
# 表体
|
||||
body_rows = []
|
||||
for r in rows:
|
||||
label = f'<div class="row-label">{r["img"]}<br><b>{r["hair_name"]}</b></div>'
|
||||
# 原图缩略图(用 orig 档的结果当原图展示,或用原图文件)
|
||||
orig_cell = f'<td class="cell-orig"><div class="row-label-cell">{label}</div></td>'
|
||||
cells = [orig_cell]
|
||||
for c in r["cells"]:
|
||||
src = img_src(c.get("grown_path")) if c.get("ok") else None
|
||||
if src:
|
||||
t = c.get("total_ms", 0)
|
||||
cells.append(f'<td class="cell-result"><img class="result-img" src="{src}" loading="lazy">'
|
||||
f'<div class="cell-time">{t/1000:.1f}s</div></td>')
|
||||
else:
|
||||
cells.append(f'<td class="cell-result"><div class="na">⚠<br>{c.get("error","")[:20]}</div></td>')
|
||||
body_rows.append(f'<tr>{"".join(cells)}</tr>')
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>重绘分辨率对比报告 — steps=15</title>
|
||||
<style>
|
||||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||
body {{ font-family: -apple-system, "Segoe UI", sans-serif; background: #f5f5f5; padding: 16px; }}
|
||||
h1 {{ font-size: 20px; margin-bottom: 4px; }}
|
||||
.subtitle {{ color: #888; font-size: 12px; margin-bottom: 12px; }}
|
||||
.legend {{ background: #fff; border-radius: 8px; padding: 10px 16px; margin-bottom: 12px; font-size: 12px; color: #555; }}
|
||||
.scroll-wrap {{ overflow-x: auto; }}
|
||||
table {{ border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.06); }}
|
||||
th, td {{ border: 1px solid #eee; padding: 6px; vertical-align: top; text-align: center; }}
|
||||
th {{ background: #f9fafb; position: sticky; top: 0; }}
|
||||
.col-label {{ min-width: 130px; max-width: 150px; }}
|
||||
.col-title {{ font-size: 12px; font-weight: 700; color: #374151; }}
|
||||
.col-stat {{ font-size: 10px; color: #9ca3af; margin-top: 2px; }}
|
||||
.row-label {{ font-size: 11px; color: #6b7280; }}
|
||||
.row-label b {{ color: #1f2937; }}
|
||||
img {{ border-radius: 4px; max-width: 130px; max-height: 160px; object-fit: contain; background: #f3f4f6; }}
|
||||
.cell-time {{ font-size: 10px; color: #9ca3af; margin-top: 2px; }}
|
||||
.na {{ color: #d1d5db; font-size: 12px; padding: 40px 10px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>📊 重绘分辨率对比报告</h1>
|
||||
<p class="subtitle">4图×5发型=20行 · 每行4分辨率(原图不缩放/896/768/640) · steps=15 · 热数据 · 80/80成功 · 峰值21.2GB · 0 OOM</p>
|
||||
<div class="legend">列标题下显示<b>平均总耗时</b>。横向滚动查看。原图列含图片名+发型名。每格下方为该次总耗时。</div>
|
||||
<div class="scroll-wrap">
|
||||
<table>
|
||||
<tr>{"".join(headers)}</tr>
|
||||
{"".join(body_rows)}
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
with open(HTML, "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
print(f"✓ 报告: {HTML} ({HTML.stat().st_size // 1024} KB)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,98 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""分辨率对比测试(新提示词版):4图×5发型=20行,每行4种分辨率,steps=15。
|
||||
提示词固定为 "填充遮罩区域的头发,人物增加一点磨皮"。
|
||||
热数据:预热1次+正式1次。
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
API = "http://127.0.0.1:8187/api/v1/debug/grow-timing"
|
||||
TOKEN = "dev-shared-secret-2026"
|
||||
PROMPT = "填充遮罩区域的头发,人物增加一点磨皮"
|
||||
OUT = Path("/home/ubuntu/hair/benchmark_out/bench7")
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
IMGS = [
|
||||
("asdf", "/home/ubuntu/hair/image/asdf.jpg"),
|
||||
("qwer", "/home/ubuntu/hair/image/qwer.jpg"),
|
||||
("girl2", "/home/ubuntu/hair/image/girl_img/girl2.jpg"),
|
||||
("girl5", "/home/ubuntu/hair/image/girl_img/girl5.jpg"),
|
||||
]
|
||||
HAIRSTYLES = [
|
||||
(1, "ellipse", "椭圆"), (2, "flower", "花瓣"), (3, "heart", "心形"),
|
||||
(4, "straight", "直线"), (5, "wave", "波浪"),
|
||||
]
|
||||
RES_LIST = [("orig", "0"), ("896", "896"), ("768", "768"), ("640", "640")]
|
||||
RES_TITLES = ["原图(不缩放)", "896", "768", "640"]
|
||||
STEPS = 15
|
||||
|
||||
|
||||
def call(img_path, hair_num, redraw_max_side, save_grown=None, timeout=300):
|
||||
data = {"hair_style": str(hair_num), "webui_steps": str(STEPS),
|
||||
"redraw_max_side": str(redraw_max_side), "redraw_prompt": PROMPT}
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
with open(img_path, "rb") as f:
|
||||
r = requests.post(API, headers={"X-Internal-Token": TOKEN},
|
||||
files={"image_file": (os.path.basename(img_path), f, "image/jpeg")},
|
||||
data=data, timeout=timeout)
|
||||
wall = time.perf_counter() - t0
|
||||
j = r.json()
|
||||
if j.get("code") != 0:
|
||||
return {"ok": False, "error": j.get("message", "")[:80], "wall": wall}
|
||||
d = j["data"]
|
||||
hs = d["per_hairstyle"][0]
|
||||
if save_grown and hs.get("grown_b64"):
|
||||
b = hs["grown_b64"].split(",")[1] if "," in hs["grown_b64"] else hs["grown_b64"]
|
||||
with open(save_grown, "wb") as gf:
|
||||
gf.write(base64.b64decode(b))
|
||||
return {
|
||||
"ok": hs.get("ok", False), "wall": wall,
|
||||
"total_ms": d["total_ms"], "swap_ms": hs.get("swap_ms"),
|
||||
"comfy_ms": hs.get("comfyui_redraw_ms"),
|
||||
"error": hs.get("error"),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)[:80], "wall": time.perf_counter() - t0}
|
||||
|
||||
|
||||
def main():
|
||||
rows = []
|
||||
total = len(IMGS) * len(HAIRSTYLES) * len(RES_LIST) * 2
|
||||
idx = 0
|
||||
for ilabel, ipath in IMGS:
|
||||
for hnum, hkey, hname in HAIRSTYLES:
|
||||
cells = []
|
||||
for (rlabel, rval), rtitle in zip(RES_LIST, RES_TITLES):
|
||||
idx += 1
|
||||
print(f"[{idx}/{total}] 预热 {ilabel}|{hname}|{rtitle}", flush=True)
|
||||
try:
|
||||
call(ipath, hnum, rval, timeout=120)
|
||||
except Exception:
|
||||
pass
|
||||
idx += 1
|
||||
save = OUT / f"{ilabel}_{hkey}_{rlabel}.jpg"
|
||||
print(f"[{idx}/{total}] 正式 {ilabel}|{hname}|{rtitle}", flush=True)
|
||||
r = call(ipath, hnum, rval, save_grown=save, timeout=300)
|
||||
r["res_label"] = rlabel; r["res_title"] = rtitle
|
||||
r["grown_path"] = str(save) if r.get("ok") else None
|
||||
status = f"{r.get('total_ms')}ms" if r.get("ok") else f"FAIL:{r.get('error','')[:30]}"
|
||||
print(f" -> {status}", flush=True)
|
||||
cells.append(r)
|
||||
rows.append({"img": ilabel, "img_path": ipath,
|
||||
"hair_num": hnum, "hair_key": hkey, "hair_name": hname,
|
||||
"cells": cells})
|
||||
with open(OUT / "results.json", "w", encoding="utf-8") as f:
|
||||
json.dump({"res_titles": RES_TITLES, "prompt": PROMPT, "rows": rows}, f, ensure_ascii=False, indent=2)
|
||||
ok = sum(1 for row in rows for c in row["cells"] if c.get("ok"))
|
||||
print(f"\n✓ 完成: {ok}/{len(rows)*len(RES_LIST)} 成功 -> {OUT/'results.json'}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,121 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""swap步数 + 重绘分辨率 对比测试(热数据)。
|
||||
|
||||
每个组合: 预热1次(丢弃) + 正式测1次(取热数据)。
|
||||
B维度: steps=10/15/20 (分辨率固定896)
|
||||
C维度: 分辨率=640/896/1024 (steps固定15)
|
||||
4图×2发型=8组 × 6档 × 2次(预热+正式) = 96次
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
API = "http://127.0.0.1:8187/api/v1/debug/grow-timing"
|
||||
TOKEN = "dev-shared-secret-2026"
|
||||
OUT = Path("/home/ubuntu/hair/benchmark_out/bench2")
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
IMGS = [
|
||||
("asdf", "/home/ubuntu/hair/image/asdf.jpg"),
|
||||
("qwer", "/home/ubuntu/hair/image/qwer.jpg"),
|
||||
("girl2", "/home/ubuntu/hair/image/girl_img/girl2.jpg"),
|
||||
("girl5", "/home/ubuntu/hair/image/girl_img/girl5.jpg"),
|
||||
]
|
||||
HAIRSTYLES = [(5, "wave", "波浪"), (3, "heart", "心形")]
|
||||
|
||||
# B维度: swap步数对比 (分辨率固定896)
|
||||
B_STEPS = [10, 15, 20]
|
||||
# C维度: 重绘分辨率对比 (steps固定15)
|
||||
C_RES = [640, 896, 1024]
|
||||
|
||||
|
||||
def call(img_path, hair_num, webui_steps=None, redraw_max_side=None, save_grown=None):
|
||||
"""调调试接口。返回 dict。save_grown 非None时把结果图存到该路径。"""
|
||||
data = {"hair_style": str(hair_num)}
|
||||
if webui_steps is not None:
|
||||
data["webui_steps"] = str(webui_steps)
|
||||
if redraw_max_side is not None:
|
||||
data["redraw_max_side"] = str(redraw_max_side)
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
with open(img_path, "rb") as f:
|
||||
r = requests.post(API, headers={"X-Internal-Token": TOKEN},
|
||||
files={"image_file": (os.path.basename(img_path), f, "image/jpeg")},
|
||||
data=data, timeout=300)
|
||||
wall = time.perf_counter() - t0
|
||||
j = r.json()
|
||||
if j.get("code") != 0:
|
||||
return {"ok": False, "error": j.get("message", "")[:100], "wall": wall}
|
||||
d = j["data"]
|
||||
hs = d["per_hairstyle"][0]
|
||||
if save_grown and hs.get("grown_b64"):
|
||||
b = hs["grown_b64"].split(",")[1] if "," in hs["grown_b64"] else hs["grown_b64"]
|
||||
with open(save_grown, "wb") as gf:
|
||||
gf.write(base64.b64decode(b))
|
||||
return {
|
||||
"ok": hs.get("ok", False), "wall": wall,
|
||||
"total_ms": d["total_ms"], "ctx_ms": d["extract_context_ms"],
|
||||
"mask_ms": hs.get("mask_ms"), "swap_ms": hs.get("swap_ms"),
|
||||
"blend_ms": hs.get("blend_ms"), "comfy_ms": hs.get("comfyui_redraw_ms"),
|
||||
"error": hs.get("error"),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)[:100], "wall": time.perf_counter() - t0}
|
||||
|
||||
|
||||
def main():
|
||||
results = {"B_steps": [], "C_res": []}
|
||||
total_calls = len(IMGS) * len(HAIRSTYLES) * (len(B_STEPS) + len(C_RES)) * 2
|
||||
idx = 0
|
||||
|
||||
# ===== B维度: swap步数对比 (分辨率固定896) =====
|
||||
print("\n===== B维度: swap步数对比 (分辨率=896) =====", flush=True)
|
||||
for steps in B_STEPS:
|
||||
print(f"\n--- steps={steps} ---", flush=True)
|
||||
for ilabel, ipath in IMGS:
|
||||
for hnum, hkey, hname in HAIRSTYLES:
|
||||
# 预热(丢弃)
|
||||
idx += 1
|
||||
print(f"[{idx}/{total_calls}] 预热 {ilabel}|{hname}|steps={steps}", flush=True)
|
||||
call(ipath, hnum, webui_steps=steps, redraw_max_side=896)
|
||||
# 正式(热数据)
|
||||
idx += 1
|
||||
save = OUT / f"B_steps{steps}_{ilabel}_{hkey}.jpg"
|
||||
print(f"[{idx}/{total_calls}] 正式 {ilabel}|{hname}|steps={steps}", flush=True)
|
||||
r = call(ipath, hnum, webui_steps=steps, redraw_max_side=896, save_grown=save)
|
||||
r["steps"] = steps; r["img"] = ilabel; r["hair"] = hkey; r["hair_name"] = hname
|
||||
r["grown_path"] = str(save) if r.get("ok") else None
|
||||
print(f" -> total={r.get('total_ms')}ms swap={r.get('swap_ms')}ms comfy={r.get('comfy_ms')}ms ok={r.get('ok')}", flush=True)
|
||||
results["B_steps"].append(r)
|
||||
|
||||
# ===== C维度: 重绘分辨率对比 (steps固定15) =====
|
||||
print("\n===== C维度: 重绘分辨率对比 (steps=15) =====", flush=True)
|
||||
for res in C_RES:
|
||||
print(f"\n--- res={res} ---", flush=True)
|
||||
for ilabel, ipath in IMGS:
|
||||
for hnum, hkey, hname in HAIRSTYLES:
|
||||
idx += 1
|
||||
print(f"[{idx}/{total_calls}] 预热 {ilabel}|{hname}|res={res}", flush=True)
|
||||
call(ipath, hnum, webui_steps=15, redraw_max_side=res)
|
||||
idx += 1
|
||||
save = OUT / f"C_res{res}_{ilabel}_{hkey}.jpg"
|
||||
print(f"[{idx}/{total_calls}] 正式 {ilabel}|{hname}|res={res}", flush=True)
|
||||
r = call(ipath, hnum, webui_steps=15, redraw_max_side=res, save_grown=save)
|
||||
r["res"] = res; r["img"] = ilabel; r["hair"] = hkey; r["hair_name"] = hname
|
||||
r["grown_path"] = str(save) if r.get("ok") else None
|
||||
print(f" -> total={r.get('total_ms')}ms swap={r.get('swap_ms')}ms comfy={r.get('comfy_ms')}ms ok={r.get('ok')}", flush=True)
|
||||
results["C_res"].append(r)
|
||||
|
||||
with open(OUT / "results.json", "w", encoding="utf-8") as f:
|
||||
json.dump(results, f, ensure_ascii=False, indent=2)
|
||||
ok = sum(1 for r in results["B_steps"] + results["C_res"] if r.get("ok"))
|
||||
print(f"\n✓ 完成: {ok}/{len(results['B_steps'])+len(results['C_res'])} 成功 -> {OUT/'results.json'}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,157 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""生成 swap步数 + 重绘分辨率 对比报告 HTML。"""
|
||||
import json
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
OUT = Path("/home/ubuntu/hair/benchmark_out/bench2")
|
||||
RESULTS = OUT / "results.json"
|
||||
HTML = OUT / "report.html"
|
||||
|
||||
|
||||
def img_src(path):
|
||||
if not path or not os.path.isfile(path):
|
||||
return None
|
||||
# benchmark_out/bench2/xxx.jpg -> bench2/xxx.jpg (报告在 static/ 下部署时调整)
|
||||
p = str(path)
|
||||
return "bench2/" + os.path.basename(p)
|
||||
|
||||
|
||||
def main():
|
||||
d = json.load(open(RESULTS, encoding="utf-8"))
|
||||
b_data = d["B_steps"] # steps 对比
|
||||
c_data = d["C_res"] # 分辨率对比
|
||||
|
||||
# B维度聚合
|
||||
by_steps = defaultdict(list)
|
||||
for r in b_data:
|
||||
by_steps[r["steps"]].append(r)
|
||||
b_summary = []
|
||||
for s in sorted(by_steps):
|
||||
rs = by_steps[s]
|
||||
b_summary.append({
|
||||
"label": f"steps={s}", "n": len(rs),
|
||||
"swap": sum(r["swap_ms"] for r in rs) // len(rs),
|
||||
"total": sum(r["total_ms"] for r in rs) // len(rs),
|
||||
})
|
||||
|
||||
# C维度聚合
|
||||
by_res = defaultdict(list)
|
||||
for r in c_data:
|
||||
by_res[r["res"]].append(r)
|
||||
c_summary = []
|
||||
for res in sorted(by_res):
|
||||
rs = by_res[res]
|
||||
c_summary.append({
|
||||
"label": f"res={res}", "n": len(rs),
|
||||
"comfy": sum(r["comfy_ms"] for r in rs) // len(rs),
|
||||
"total": sum(r["total_ms"] for r in rs) // len(rs),
|
||||
})
|
||||
|
||||
# B维度明细行(每图每发型每步数)
|
||||
b_rows = []
|
||||
for r in sorted(b_data, key=lambda x: (x["img"], x["hair"], x["steps"])):
|
||||
src = img_src(r.get("grown_path"))
|
||||
b_rows.append(f"""<tr>
|
||||
<td>{r['img']}</td><td>{r['hair_name']}</td><td>{r['steps']}</td>
|
||||
<td>{r.get('swap_ms','?')}</td><td>{r.get('comfy_ms','?')}</td><td>{r.get('total_ms','?')}</td>
|
||||
<td>{f'<img src="{src}" loading="lazy">' if src else '⚠'}</td></tr>""")
|
||||
|
||||
# C维度明细行
|
||||
c_rows = []
|
||||
for r in sorted(c_data, key=lambda x: (x["img"], x["hair"], x["res"])):
|
||||
src = img_src(r.get("grown_path"))
|
||||
c_rows.append(f"""<tr>
|
||||
<td>{r['img']}</td><td>{r['hair_name']}</td><td>{r['res']}</td>
|
||||
<td>{r.get('swap_ms','?')}</td><td>{r.get('comfy_ms','?')}</td><td>{r.get('total_ms','?')}</td>
|
||||
<td>{f'<img src="{src}" loading="lazy">' if src else '⚠'}</td></tr>""")
|
||||
|
||||
def bar_row(label, val, max_val, color, unit="ms"):
|
||||
pct = max(1, val / max_val * 100) if max_val else 0
|
||||
return f'<div class="step-row"><div class="step-name">{label}</div>' \
|
||||
f'<div class="step-bar-wrap"><div class="step-bar {color}" style="width:{pct}%">{val}{unit}</div></div>' \
|
||||
f'<div class="step-time">{val}{unit}</div></div>'
|
||||
|
||||
# B维度汇总条形图
|
||||
b_max_swap = max(s["swap"] for s in b_summary)
|
||||
b_bars = "".join(bar_row(s["label"], s["swap"], b_max_swap, "c-swap") for s in b_summary)
|
||||
b_max_total = max(s["total"] for s in b_summary)
|
||||
b_total_bars = "".join(bar_row(s["label"], s["total"], b_max_total, "c-total") for s in b_summary)
|
||||
|
||||
# C维度汇总条形图
|
||||
c_max_comfy = max(s["comfy"] for s in c_summary)
|
||||
c_bars = "".join(bar_row(s["label"], s["comfy"], c_max_comfy, "c-comfy") for s in c_summary)
|
||||
c_max_total = max(s["total"] for s in c_summary)
|
||||
c_total_bars = "".join(bar_row(s["label"], s["total"], c_max_total, "c-total") for s in c_summary)
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>swap步数 + 重绘分辨率 对比报告</title>
|
||||
<style>
|
||||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||
body {{ font-family: -apple-system, "Segoe UI", sans-serif; background: #f5f5f5; padding: 16px; color: #333; }}
|
||||
h1 {{ font-size: 20px; margin-bottom: 4px; }}
|
||||
h2 {{ font-size: 16px; margin: 20px 0 10px; }}
|
||||
.subtitle {{ color: #888; font-size: 12px; margin-bottom: 14px; }}
|
||||
.card {{ background: #fff; border-radius: 10px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 16px; overflow: hidden; }}
|
||||
.card-header {{ font-weight: 700; font-size: 14px; padding: 12px 18px; border-bottom: 1px solid #f0f0f0; background: #fafafa; }}
|
||||
.card-body {{ padding: 18px; }}
|
||||
.summary-grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }}
|
||||
.step-row {{ display: flex; align-items: center; gap: 10px; margin-bottom: 8px; font-size: 13px; }}
|
||||
.step-name {{ width: 100px; flex-shrink: 0; font-weight: 600; }}
|
||||
.step-bar-wrap {{ flex: 1; background: #f3f4f6; border-radius: 4px; height: 24px; min-width: 200px; }}
|
||||
.step-bar {{ height: 100%; border-radius: 4px; display: flex; align-items: center; padding-left: 8px; color: #fff; font-size: 11px; font-weight: 600; min-width: 2px; }}
|
||||
.step-time {{ width: 70px; text-align: right; font-weight: 600; flex-shrink: 0; font-variant-numeric: tabular-nums; }}
|
||||
.c-swap {{ background: #f59e0b; }} .c-comfy {{ background: #ef4444; }} .c-total {{ background: #2563eb; }}
|
||||
table {{ border-collapse: collapse; width: 100%; font-size: 12px; }}
|
||||
th, td {{ border: 1px solid #eee; padding: 5px 8px; text-align: center; }}
|
||||
th {{ background: #f9fafb; font-weight: 600; position: sticky; top: 0; }}
|
||||
td img {{ max-height: 100px; max-width: 80px; border-radius: 4px; }}
|
||||
.scroll {{ max-height: 400px; overflow: auto; }}
|
||||
.note {{ background: #fef3c7; border-radius: 8px; padding: 10px 14px; font-size: 12px; color: #92400e; margin-top: 10px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>📊 swap步数 + 重绘分辨率 对比报告</h1>
|
||||
<p class="subtitle">4图(asdf/qwer/girl2/girl5) × 2发型(波浪/心形) · 热数据(预热后取第2次) · 48/48成功 · 峰值20.6GB · 0 OOM</p>
|
||||
|
||||
<div class="note">💡 结论速览: B维度 steps 10→20 swap从3.0s→3.9s(每步省~90ms);C维度 res 640比896省3s(comfy 4.3s vs 7.3s),1024与896接近。</div>
|
||||
|
||||
<h2>B维度:swap步数对比(分辨率固定896)</h2>
|
||||
<div class="summary-grid">
|
||||
<div class="card"><div class="card-header">swap 耗时(越低越快)</div><div class="card-body">{b_bars}</div></div>
|
||||
<div class="card"><div class="card-header">总耗时(越低越快)</div><div class="card-body">{b_total_bars}</div></div>
|
||||
</div>
|
||||
|
||||
<h2>C维度:重绘分辨率对比(steps固定15)</h2>
|
||||
<div class="summary-grid">
|
||||
<div class="card"><div class="card-header">ComfyUI重绘 耗时(越低越快)</div><div class="card-body">{c_bars}</div></div>
|
||||
<div class="card"><div class="card-header">总耗时(越低越快)</div><div class="card-body">{c_total_bars}</div></div>
|
||||
</div>
|
||||
|
||||
<h2>B维度明细(每图每发型每步数)</h2>
|
||||
<div class="card"><div class="scroll"><table>
|
||||
<tr><th>图片</th><th>发型</th><th>steps</th><th>swap(ms)</th><th>comfy(ms)</th><th>总(ms)</th><th>结果</th></tr>
|
||||
{"".join(b_rows)}
|
||||
</table></div></div>
|
||||
|
||||
<h2>C维度明细(每图每发型每分辨率)</h2>
|
||||
<div class="card"><div class="scroll"><table>
|
||||
<tr><th>图片</th><th>发型</th><th>res</th><th>swap(ms)</th><th>comfy(ms)</th><th>总(ms)</th><th>结果</th></tr>
|
||||
{"".join(c_rows)}
|
||||
</table></div></div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
with open(HTML, "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
print(f"✓ 报告: {HTML} ({HTML.stat().st_size // 1024} KB)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -16,7 +16,7 @@
|
||||
Handler 签名新增:
|
||||
|
||||
```python
|
||||
prompt: str = Form(default=填充遮罩区域的头发,人物增加一点磨皮, description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||
prompt: str = Form(default="补充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||
```
|
||||
|
||||
透传链路:
|
||||
@@ -31,7 +31,7 @@ handler → generate_grow_results(image, gender, use_mask, prompt)
|
||||
Handler 签名新增:
|
||||
|
||||
```python
|
||||
prompt: str = Form(default=填充遮罩区域的头发,人物增加一点磨皮, description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||
prompt: str = Form(default="补充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||
```
|
||||
|
||||
透传链路:
|
||||
@@ -80,17 +80,17 @@ if prompt is not None:
|
||||
| 2 | `image_base64` | string | — | 同上 |
|
||||
| 2 | `gender` | string | `female` | `male` / `female` |
|
||||
| 2 | `use_mask` | bool | `true` | inpaint 遮罩开关 |
|
||||
| 2 | `prompt` | string | 填充遮罩区域的头发,人物增加一点磨皮 | ComfyUI 提示词 |
|
||||
| 2 | `prompt` | string | `补充遮罩区域的头发` | ComfyUI 提示词 |
|
||||
| 3 | `marked_image_file` | file | — | 划线图(三选一) |
|
||||
| 3 | `marked_image_url` | string | — | 同上 |
|
||||
| 3 | `marked_image_base64` | string | — | 同上 |
|
||||
| 3 | `use_mask` | bool | `true` | 画发际线开关 |
|
||||
| 3 | `prompt` | string | 填充遮罩区域的头发,人物增加一点磨皮 | ComfyUI 提示词 |
|
||||
| 3 | `prompt` | string | `补充遮罩区域的头发` | ComfyUI 提示词 |
|
||||
|
||||
## 3. 当前工作流提示词节点
|
||||
|
||||
`add_hair.json` 中节点 **60**(`JjkText`)为提示词节点,当前硬编码文本:
|
||||
|
||||
> 填充遮罩区域的头发,人物增加一点磨皮
|
||||
> 补充遮罩区补充遮罩区域内的头发,头发填满遮罩区域。发际线往下挡住额头
|
||||
|
||||
`comfyui.run()` 收到 `prompt` 参数后将替换此文本。
|
||||
|
||||
@@ -12,18 +12,15 @@
|
||||
|
||||
```
|
||||
客户端 ──HTTPS──> 外网网关(gateway/) ──HTTP(X-Internal-Token)──> worker(GPU 机, app.py)
|
||||
│ 薄代理 + 落盘改URL │ 跑算法(本地模型/ComfyUI/change_hair)
|
||||
└ 接口4(豆包)/99(特殊点) 本机实现(不转发) └ 接口1/2/3/5/6/7/9/10/11/12 + 工具路由
|
||||
│ 薄代理 + 落盘改URL │ 跑算法(本地模型/ComfyUI)
|
||||
└ 接口4 本机直接调豆包(不转发) └ 接口1/2/3/5/6/7
|
||||
```
|
||||
|
||||
- **worker**(`app.py` + `face_analysis/` + `hairline/`):跑真正的算法,除 ComfyUI/change_hair 走本机
|
||||
回环外无外网依赖。对 `/api/*` 校验 `X-Internal-Token`(密码在 `worker_config.json` 的 `accept_passwords`);
|
||||
`/health` 模型就绪才返回 200。监听 **8187**(systemd `hair-worker.service`,也可 `./run_worker.sh` 手动起)。
|
||||
另有 worker 本机的工具路由:`/api/swapHair/v1`、`/hairColor/v2`(CORS 代理转发 change_hair:8801)、
|
||||
`/api/v1/redraw`(直接 ComfyUI 重绘)、`/api/v1/debug/*`(免鉴权调试)。
|
||||
- **worker**(`app.py` + `face_analysis/` + `hairline/`):跑真正的算法,**纯本地、无外网依赖**。
|
||||
对 `/api/*` 校验 `X-Internal-Token`(密码在 `worker_config.json` 的 `accept_passwords`);
|
||||
`/health` 模型就绪才返回 200。监听 **8187**(`./start.sh` 控制开关,`./run_worker.sh` 热重载)。
|
||||
- **网关**(`gateway/`):薄反向代理,健康检查/派发/鉴权/把 worker 的 `*_base64` 落盘改成 `*_url`。
|
||||
网关本机实现的接口(不转发 worker):**接口4**(调外网豆包)与**接口99**(纯 CPU 特殊点检测,
|
||||
直接 import 同仓库 `face_analysis/special_points`,`_if99_lock` 单路排队)。
|
||||
唯一例外是**接口4 在网关本机直接实现**(调外网豆包,不转发 worker)。
|
||||
- **图片三选一**:所有接口图片入参 `image_file`/`image_url`/`image_base64` 严格三选一(接口3 是 `marked_image_*`)。
|
||||
- **响应信封**:`{code, message, request_id, data}`;业务错误用 `code`(HTTP 一律 200)。
|
||||
|
||||
@@ -36,10 +33,7 @@
|
||||
| 2 | `results[].image_base64` / `results[].grown_image_base64`(可空) | `results[].image_url` / `results[].grown_image_url` |
|
||||
| 3 | `hair_growth_image_base64`(可空) | `hair_growth_image_url` |
|
||||
| 5 | `hairline_images[].image_{middle,high,low}_base64` / `grown_image_base64`(可空) | `hairline_images[].image_{middle,high,low}_url` / `grown_image_url` |
|
||||
| 9 | `steps_common.*_base64`、`bisenet|segformer.{hair,closed_region,final_overlay,mask}_mask_base64` 等 | 同名 `*_url` |
|
||||
| 10 | `steps_common.*_base64`、`bisenet|segformer.{base_mask,contour,band_overlay,mask}_base64` 等 | 同名 `*_url` |
|
||||
| 11 | `steps.{input,baseline,hair_mask,mask_overlay,mask,swap_raw,alpha,final}_base64` | `steps.*_url` |
|
||||
| 12 | `steps.{input,final,redraw_band_overlay,redraw_full,redraw_band,redraw_c}_base64` | `steps.*_url` |
|
||||
| 7 | `results[].image_base64` / `results[].grown_image_base64`(可空) | `results[].image_url` / `results[].grown_image_url` |
|
||||
| 4 | (网关本机产出,无图片字段,`features` 为 JSON 字符串) | — |
|
||||
|
||||
> 实现建议:递归遍历 data,凡 key 以 `_base64` 结尾就落盘改 `_url`,自动覆盖嵌套/新增字段。
|
||||
@@ -53,7 +47,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 2. 各接口实现简述
|
||||
## 2. 五个接口实现简述
|
||||
|
||||
### 接口1 四庭七眼测量 `/api/v1/face/measure`(worker)
|
||||
- **做什么**:正面照 → 四庭(顶/上/中/下庭) + 七眼(眼宽/脸宽/间距) 的 cm 与占比、5 个关键点坐标、一张透明底标注 PNG。
|
||||
@@ -71,11 +65,10 @@
|
||||
### 接口2 C端生发 `/api/v1/hair/grow`(worker)—— 预览 + 生发图
|
||||
- **做什么**:正面照 + `gender`(必填) + `hair_style`(必填,逗号分隔多选,如 `1,2,3`) → 指定发际线类型 **N 组**:**预览图**(发际线叠在照片上) + **生发后图**(植发3个月效果)。
|
||||
- **怎么实现**(`hairline/`):移植 head3d——MediaPipe(Tasks) + SegFormer 分割 + 17 锚点射线检测 → 502 点 mesh,
|
||||
按 `face_ext.obj` 的 UV 把发际线贴图渲染到额头(预览)。原生生发:黑贴图渲染遮罩 → 调本机 **ComfyUI 8188** 的
|
||||
按 `face_ext.obj` 的 UV 把发际线贴图渲染到额头(预览)。生发:黑贴图渲染遮罩 → 调本机 **ComfyUI 8182** 的
|
||||
`add_hair.json`(Flux-2) 出图。**关键坑**:obj 是重排序,需 `INDEX_MAP_468` 把 MP 序→OBJ 序。
|
||||
返回 `results[].image_base64` + `grown_image_base64`。
|
||||
- 模板对齐:左右刘海边界高低不同时,按 MediaPipe 左右对应额头点协调沿人脸向上方向的延伸距离,避免把 M 型等设计模板拉歪;保留人脸姿态,之后仍做头部轮廓钳制。预览白线与生发黑线/遮罩共用修正后的 502 点几何,不改变真实发际线测量算法。
|
||||
- `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 7=wave。female 1..5 走「换发型(change_hair)」+Flux-2 重绘管线(`generate_grow_results_swap`,复用接口12 final 的 `_V2_FINAL_DEFAULTS`,长边默认压到 `REDRAW_MAX_SIDE`=1024 再放大回原尺寸);female 6/7 与 male 全部走原生发(ComfyUI add_hair)管线。可选参数 `flux_model`(切换 Flux unet,.gguf 自动配 UnetLoaderGGUF + 配套 qwen CLIP)、`redraw_max_side`(0=不缩图)。
|
||||
- `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 7=wave。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 组预览+生发图)。
|
||||
@@ -99,50 +92,20 @@
|
||||
- **做什么**:入参同接口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。
|
||||
|
||||
### 接口9 头发遮罩 `/api/v1/head/mask`(worker)
|
||||
- **做什么**:人像照 → 头发遮罩 + 全流程分步可视化。
|
||||
- **怎么实现**(`face_analysis/head_mask.py`):MediaPipe 底部分割线(21/68/104/69/108/151/337/299/333/298/251 连线,21/251 水平延伸)→ 线以上为上半区 → BiSeNet 与 SegFormer 双路头发分割、逐列向下填充到分割线得闭合区域(头发+额头)→ 外缘朝 151 内缩 `erode_cm`(默认1.2cm,虹膜标定) → 最终遮罩。返回 `steps_common` + `bisenet`/`segformer` 两组各四张图。
|
||||
|
||||
### 接口10 头部外缘膨胀带 `/api/v1/head/band`(worker)
|
||||
- **做什么**:在接口9 基准遮罩基础上,沿头部外缘生成一条膨胀带遮罩。
|
||||
- **怎么实现**(`face_analysis/head_band.py`):取基准遮罩外轮廓、去掉贴底分割线段 → 膨胀成总宽 `dilate_cm`(默认2cm) 的带子 → 裁到分割线以上。返回 `base_mask`/`contour`/`band_overlay`/`mask` 分步图。
|
||||
|
||||
### 接口11 发际线生发 `/api/v1/hairline/grow`(worker)—— 不含重绘
|
||||
- **做什么**:发际线偏高/头发稀少正脸图 + `hairline_id`(= change_hair `hair_id`,如 `chang_tuoyuan`)→ 压低发际线图 + 全步骤可视化。
|
||||
- **怎么实现**(`face_analysis/hairline_grow.py`,算法见 `发际线增强算法.md`):① 接口9 遮罩(`seg_model` 选 bisenet/segformer)→ ② 调 change_hair 生成该发际线类型图(`swap_mode=ext_mask` 把遮罩传给 webui 精确重绘 / `as_is` 用其内部遮罩;`gen_backend` 可换 hairgrow 区域生发 inpaint)→ ③ 严格按遮罩贴回 → ④ 接缝融合 `blend_method`(multiband 默认 / seamless / two_stage / feather / alpha_gradient,可选 Reinhard `color_match`)。返回 `steps.{input,baseline,hair_mask,mask_overlay,mask,swap_raw,alpha,final}`。
|
||||
|
||||
### 接口12 发际线带重绘 `/api/v1/hairline/grow_v2[_final[_v2]]`(worker)
|
||||
- **做什么**:接口11 的下游重绘阶段——先跑接口11 拿 ④final,再取发际线重绘带(外推 `band_lo_mult×push`~`band_hi_mult×push`,默认 0.5×~1.5×)作遮罩,用 **Flux-2(ComfyUI,`0716add-hair-api.json`,reference-latent 保色)** 重绘,产出两版:`redraw_full`(整帧美颜+整帧重绘)与 `redraw_band`(band 内加发、band 外 final 结构 + `beauty_alpha` 融入全脸美颜)。
|
||||
- **三个变体**:`grow_v2` 全参数暴露(接口11 参数 + `comfyui_prompt`/`beauty_alpha`/`band_lo_mult`/`band_hi_mult`);`grow_v2_final` / `grow_v2_final_v2` 精简生产版——仅图片 + `hairline_id`,其余固化 `_V2_FINAL_DEFAULTS`(push=0.8cm / two_stage / color_match=关 / beauty_alpha=0.6 等),两者后端计算完全一致(同一次输出含 A/B 两版),差异只是测试页展示哪版。接口2 女性 1..5 的生发图即复用此管线。
|
||||
- ⚠️ 依赖 ComfyUI(:8188);重绘失败在 `data.redraw.c_error` 报告。
|
||||
|
||||
### 接口99 人脸特殊点 `/api/v1/face/special-points`(**网关本机**,worker 保留同路径备份)
|
||||
- **做什么**:人脸照 → 60 个固定编号 MediaPipe 关键点(6 组:eyebrow/eye/mouth/nose_midline/cheek/face_edge)+ 5 个发际线特殊点,原图像素坐标 JSON(字段 `<组名>_<点ID>`,如 `eyebrow_107`)。
|
||||
- **怎么实现**(`face_analysis/special_points.py`):MediaPipe 单例取关键点;发际线点=接口1 方案B 的 BiSeNet 头发 mask 上沿指定关键点方向(如 151→10)射线步进取首个头发像素,双射线的(right/right_right/left/left_left)取中点。**纯 CPU 可跑**(BiSeNet 单核 <1s),网关直接 import 本机实现(`_if99_lock` 单路排队);worker 侧保留同路径接口供测试页直连。部署依赖清单见 `网关待改动.md` §9。
|
||||
- 测试页:`static/test_interface99.html`(canvas 可视化 + JSON,worker 本机直连)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 部署 / 环境要点
|
||||
|
||||
**worker(GPU 机,本机 = gpu_worker 角色)**
|
||||
- Python **3.10.12**(本机 venv 在 `./venv`);`numpy<2`(1.26.4),`scikit-image==0.24.0`
|
||||
(**别升 0.25+,会顶 numpy≥2 顶崩 mediapipe**)。
|
||||
- 本机 **RTX 4090**:venv 实装 `torch 2.7.1+cu128`,CUDA 可用(BiSeNet/SegFormer GPU 推理)。
|
||||
`requirements.txt` 里 pinned 的 `torch 2.2.2(cu121)` 及 5090 注释是旧机(sm_120 无 cu121 kernel、
|
||||
自动回退 CPU)的记录,本机以 venv 实际版本为准。
|
||||
**worker(GPU 机)**
|
||||
- Python **3.12**(系统 3.13 无 mediapipe/torch wheel);venv 在 `./venv`,依赖 `requirements.txt`。
|
||||
- `numpy<2`(1.26.4),`scikit-image==0.24.0`(**别升 0.25+,会顶 numpy≥2 顶崩 mediapipe**)。
|
||||
- ⚠️ 本机 **RTX 5090(sm_120)**,pinned `torch 2.2.2(cu121)` 只到 sm_90 → GPU 算子报 "no kernel image",
|
||||
代码已自动**回退 CPU**(BiSeNet/SegFormer CPU 推理可用)。要用 5090 GPU 需换 torch cu128(≥2.7)。
|
||||
- 模型权重/字体见 [`../OFFLINE_ASSETS.md`](../OFFLINE_ASSETS.md);BiSeNet/SegFormer/face_landmarker.task 本地。
|
||||
- 生发接口依赖本机 **ComfyUI(8188)**(Flux-2,systemd `comfyui.service`,`--cache-classic --fast`);
|
||||
worker 只调其 HTTP API,不跑 Flux。ComfyUI 开了 **HTTP Basic Auth**(user `admin` + 密码);
|
||||
密码放 `password.txt`(不入 git) / `worker_config.json.comfyui_password` / 环境变量 `COMFYUI_PASSWORD`,
|
||||
URL 用 `COMFYUI_URL`。默认模型 flux-2-klein-9b Q4_K_M GGUF(接口2 `flux_model` 参数可切)。
|
||||
- 换发型依赖本机 **change_hair**(`/home/ubuntu/change_hair`,三进程 webui:57860(sdwebui) →
|
||||
photo_service:32678(py310/kohya) → hair_service_sd:8801(my_hair),`start_all.sh start|stop|status`,
|
||||
也有 systemd `change_hair-{webui,photo,hair}.service`)。不跑时接口2女1..5、接口11/12、
|
||||
swapHair/hairColor 代理不可用。
|
||||
- `worker_config.json`(不入 git):`accept_passwords`(鉴权) + `comfyui_password`;鉴权头 `X-Internal-Token`。
|
||||
- systemd 单元:`hair-worker.service`(8187) / `comfyui.service`(8188),仓库根有副本;手动启停用
|
||||
`./run_worker.sh` / `./stop_worker.sh`。
|
||||
- 生发接口依赖本机 **ComfyUI(8188)**(Flux-2,它自带支持 5090 的 torch);worker 只调其 HTTP API,不跑 Flux。
|
||||
ComfyUI 开了 **HTTP Basic Auth**(user `admin` + 密码);密码放 `password.txt`(不入 git) /
|
||||
`worker_config.json.comfyui_password` / 环境变量 `COMFYUI_PASSWORD`,URL 用 `COMFYUI_URL`。
|
||||
- `worker_config.json`(不入 git):`accept_passwords`(鉴权) + 鉴权头 `X-Internal-Token`。
|
||||
|
||||
**网关机**
|
||||
- 很轻:FastAPI/uvicorn/httpx + **接口4 的 `volcengine-python-sdk[ark]`**(或直接 httpx 调,OpenAI 兼容)。
|
||||
|
||||
@@ -267,10 +267,8 @@
|
||||
| gender | string | **是** | 性别:`male` / `female`。决定使用的发际线贴图集合 |
|
||||
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),最多不超过该性别的预设数。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave, 6=bigflower, 7=clasicalflower;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight, 5=heart, 6=Softpetal, 7=wave。越界/非法返回 `1007` |
|
||||
| beauty_enabled | bool | 否 | 生发图是否带美颜效果,默认 false(当前阶段不生效) |
|
||||
| use_mask | bool | 否 | 是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比。仅对原生发(male / female 6/7)管线生效 |
|
||||
| prompt | string | 否 | ComfyUI 提示词,默认「填充遮罩区域的头发,人物增加一点磨皮」,会替换工作流节点 60 的文本。所有分支生效(male / female 6/7 原生生发,female 1..5 换发型后的发际线带重绘;不进 change_hair)。注:非默认提示词会使 ComfyUI 的 CLIP 条件缓存失效,换词后首次请求明显变慢(实测 45s vs 命中缓存 8s),相同提示词的后续请求恢复正常 |
|
||||
| flux_model | string | 否 | Flux 模型文件名(如 `flux-2-klein-9b-Q5_K_M.gguf`)。缺省用工作流默认(9B-Q4_K_M);`.gguf` 自动切 UnetLoaderGGUF 并同步配套 qwen CLIP |
|
||||
| redraw_max_side | int | 否 | female 1..5 重绘管线送模型前的长边上限像素。缺省=服务端默认(`REDRAW_MAX_SIDE`,当前 1024);`0`=不缩图(原图直送,更慢) |
|
||||
| use_mask | bool | 否 | 是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
|
||||
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
|
||||
|
||||
### 输出(data)
|
||||
|
||||
@@ -283,8 +281,7 @@
|
||||
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`/`bigflower`/`clasicalflower`(female),`ellipse`/`m`/`straight`/`inverse_arc`/`heart`/`Softpetal`/`wave`(male) |
|
||||
| order | int | 排序序号(当前阶段固定 `1..N`,按贴图顺序,暂不计算合适度) |
|
||||
|
||||
> ⚠️ 生发图由本机 ComfyUI(Flux-2,端口 8188)生成,**一次请求生成指定发型的 1 张、同步返回**;
|
||||
> female 1..5 的生发图另依赖 change_hair 换发型服务(:8801)。
|
||||
> ⚠️ 生发图由本机 ComfyUI(Flux-2,端口 8182)生成,**一次请求生成指定发型的 1 张、同步返回**。
|
||||
> worker 侧返回 `image_base64` / `grown_image_base64`,
|
||||
> 网关落盘后改写为上表的 `image_url` / `grown_image_url`。
|
||||
>
|
||||
@@ -518,197 +515,16 @@
|
||||
|
||||
---
|
||||
|
||||
## 接口 9:头发遮罩生成接口
|
||||
|
||||
**说明**:输入一张含人头的照片,生成头发遮罩,并返回**每一步的可视化图**(供对比调试)。
|
||||
|
||||
**请求**:`POST /api/v1/head/mask`
|
||||
|
||||
### 输入
|
||||
|
||||
图片参数见「通用约定 → 图片传参字段」。专属参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| erode_cm | float | 否 | 外缘朝中心(鼻梁 151 点)内缩的距离(厘米),默认 1.2 |
|
||||
|
||||
### 处理流程
|
||||
|
||||
1. MediaPipe 关键点 → 底部分割线(关键点 21,68,104,69,108,151,337,299,333,298,251 的连线,21 水平延伸到最左、251 延伸到最右)。
|
||||
2. 分割线以上为「上半区」。
|
||||
3. 头发分割(**BiSeNet** 与 **SegFormer** 两套);从每列最顶端头发向下填充到分割线,得到**含额头**的闭合区域(头发+额头,不割断)。
|
||||
4. 外缘朝中心点 151 内缩 `erode_cm`(虹膜标定换算像素)、底线不动 → 最终遮罩。
|
||||
|
||||
### 输出(data)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| px_per_cm | float | 虹膜标定出的像素/厘米 |
|
||||
| erode_cm / erode_px | float / int | 内缩距离(厘米 / 像素) |
|
||||
| image_size | object | `{width, height}` 原图尺寸 |
|
||||
| steps_common | object | `landmarks_baseline_*`(关键点/分割线)、`upper_region_*`(上半区) |
|
||||
| bisenet / segformer | object | 两套分割模型各自的结果:`hair_mask_*`(原始头发分割)、`closed_region_*`(含额头闭合区域)、`final_overlay_*`(最终遮罩叠加可视化)、`mask_*`(最终遮罩 PNG)及 `hair_pixels`/`closed_pixels`/`mask_pixels` 像素计数 |
|
||||
|
||||
> worker 返回 `*_base64`(data URI),经网关改写为 `*_url`。
|
||||
|
||||
---
|
||||
|
||||
## 接口 10:头部外缘膨胀带遮罩接口
|
||||
|
||||
**说明**:先和接口9 一样得到内缩后的基准遮罩,再沿头部外缘生成一条膨胀带遮罩。
|
||||
|
||||
**请求**:`POST /api/v1/head/band`
|
||||
|
||||
### 输入
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| erode_cm | float | 否 | 基准遮罩外缘朝 151 内缩距离(厘米,同接口9),默认 1.2 |
|
||||
| dilate_cm | float | 否 | 外轮廓线膨胀后带子总宽(厘米),默认 2.0 |
|
||||
|
||||
### 处理流程
|
||||
|
||||
1. 取基准遮罩的**外轮廓线**,去掉贴着底部分割线的那一段(只留头发/头部外缘弧线)。
|
||||
2. 把外轮廓线膨胀成带子(总宽 `dilate_cm`;半径=总宽/2,虹膜标定换算像素)。
|
||||
3. 裁到分割线以上(不越过底线)。
|
||||
|
||||
### 输出(data)
|
||||
|
||||
结构同接口9,但每组结果为 `base_mask_*`(基准遮罩)/ `contour_*`(外轮廓线)/ `band_overlay_*`(带子叠加可视化)/ `mask_*`(最终带状遮罩)及 `base_pixels`/`band_pixels` 计数,另有 `dilate_cm`/`dilate_radius_px`。
|
||||
|
||||
---
|
||||
|
||||
## 接口 11:发际线生发接口(不含重绘)
|
||||
|
||||
**说明**:输入一张**发际线较高 / 头发稀少**的正脸图 + 发际线类型 ID,输出同一个人、同一发型、按该类型压低发际线后的图片,并返回**每一步可视化**。重绘版见接口12。
|
||||
|
||||
**请求**:`POST /api/v1/hairline/grow`
|
||||
|
||||
### 输入
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| hairline_id | string | **是** | 发际线类型 ID(= change_hair `hair_id`):`chang_tuoyuan` / `chang_huaban` / `chang_xinxing` / `chang_zhixian` / `chang_bolang` |
|
||||
| gen_backend | string | 否 | 生成后端:`swaphair`(换发型 LoRA,默认)|`hairgrow`(区域生发 inpaint) |
|
||||
| hairgrow_strength | float | 否 | 区域生发强度(仅 hairgrow 后端),默认 0.75 |
|
||||
| is_hr | bool | 否 | 高清模式(换发型输出 1152×1536,否则 576×768),默认 false |
|
||||
| seg_model | string | 否 | 头发分割模型:`bisenet` | `segformer`(默认) |
|
||||
| erode_cm | float | 否 | baseline 参考内缩距离(厘米),默认 0.6 |
|
||||
| hairline_push_cm | float | 否 | 发际线外推距离(厘米,往头发方向推进),默认 1.0 |
|
||||
| hairline_edge | string | 否 | 发际线提取方式:`column`(默认)|`contour` |
|
||||
| swap_mode | string | 否 | 换发型取图模式:`ext_mask`(默认,把接口9 遮罩传给 webui 精确重绘)|`as_is`(用内部遮罩,贴回时再裁) |
|
||||
| edge_erode_px | int | 否 | 贴图前遮罩内缩像素(防边缘露皮/光晕),默认 3 |
|
||||
| denoising_strength | float | 否 | 换发型 webui 重绘强度(越大生发越激进),默认 0.6 |
|
||||
| mb_levels | int | 否 | 多频段金字塔层数(2~6),默认 5 |
|
||||
| blend_method | string | 否 | 接缝融合:`multiband`(默认)|`seamless`(泊松)|`two_stage`(泊松→多频段)|`feather`(高斯羽化)|`alpha_gradient`(距离渐变) |
|
||||
| color_match | bool | 否 | 融合前 Reinhard 颜色迁移消除整体色差,默认 true |
|
||||
| color_match_strength | float | 否 | 颜色迁移强度(0~1),默认 1.0 |
|
||||
| mb_feather_px | int | 否 | 多频段最细层掩码轻羽化像素,默认 1 |
|
||||
| transition_band_px | int | 否 | keep-region 过渡带边距(-1=自动按层数 2^n),默认 -1 |
|
||||
| inpainting_fill | int | 否 | change_hair 服务端重绘填充:0=保留原图 | 1=噪声(默认)| 2=纯色 | 3=潜变量噪声 |
|
||||
| mask_blur | int | 否 | change_hair 服务端遮罩边缘模糊像素,默认 11 |
|
||||
| mask_dilate_scale | float | 否 | change_hair 服务端遮罩膨胀缩放(<1 收缩防越界),默认 1.0 |
|
||||
|
||||
### 输出(data)
|
||||
|
||||
`steps`:`input`(原图)/ `baseline`(底部分割线)/ `hair_mask`(头发分割)/ `mask_overlay`(遮罩叠加)/ `mask`(遮罩)/ `swap_raw`(换发型原始输出)/ `alpha`(融合权重)/ `final`(最终结果)各步图(`*_base64`,经网关改写为 `*_url`),另有 `timings_ms` 分步耗时。
|
||||
|
||||
> ⚠️ `swaphair` 后端依赖 change_hair(:8801)在跑;`hairgrow` 后端依赖 ComfyUI(:8188)。
|
||||
|
||||
---
|
||||
|
||||
## 接口 12:发际线带重绘接口
|
||||
|
||||
**说明**:接口11 的**下游重绘阶段**——内部先跑接口11 核心管线拿 ④ 接缝融合最终图(final),再取发际线重绘带(外推 `band_lo_mult×push` ~ `band_hi_mult×push` 之间、经 baseline 截断只留上部的带状区域)作遮罩,用 **Flux-2(ComfyUI,reference-latent 保色重绘)** 重绘。
|
||||
|
||||
**同时产出两版结果**:
|
||||
|
||||
- `redraw_full`:ComfyUI 整帧输出(全脸美颜 + 全脸重绘)。
|
||||
- `redraw_band`:加发只在发际线带内、band 外保留 final 结构并按 `beauty_alpha` 融入全脸美颜。
|
||||
|
||||
**请求**:`POST /api/v1/hairline/grow_v2`
|
||||
|
||||
### 输入
|
||||
|
||||
接口11 的全部参数(见上表)再叠加:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| comfyui_prompt | string | 否 | Flux-2 重绘提示词,缺省「填充遮罩区域的头发,人物增加一点磨皮」 |
|
||||
| beauty_alpha | float | 否 | redraw_band 版 band 外的全脸美颜融入强度(0=band 外纯用 final,1≈整帧版),默认 0.6 |
|
||||
| band_lo_mult | float | 否 | 重绘带外推倍率下限(相对 hairline_push_cm),默认 0.5 |
|
||||
| band_hi_mult | float | 否 | 重绘带外推倍率上限(相对 hairline_push_cm),默认 1.5 |
|
||||
|
||||
### 输出(data)
|
||||
|
||||
`steps`:`input` / `final`(重绘输入基底)/ `redraw_band_overlay`(重绘带可视化)/ `redraw_full`(A 整帧)/ `redraw_band`(B 局部+美颜)/ `redraw_c`(兼容旧字段,=redraw_full);重绘异常在 `redraw.c_error` 报告。
|
||||
|
||||
### 精简变体(生产推荐)
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `POST /api/v1/hairline/grow_v2_final` | 仅需图片 + `hairline_id`,其余参数固化为调优默认值(push=0.8cm / two_stage / color_match=关 等),**最终取整帧重绘 `redraw_full`** |
|
||||
| `POST /api/v1/hairline/grow_v2_final_v2` | 参数同上,**最终取 B 局部加发+全脸美颜 `redraw_band`** |
|
||||
|
||||
> ⚠️ 依赖 ComfyUI(:8188)与 change_hair(:8801)均在跑。接口2 女性 1..5 的生发图即复用 `grow_v2_final` 管线。
|
||||
|
||||
---
|
||||
|
||||
## 接口 99:人脸特殊点检测接口
|
||||
|
||||
**说明**:输入一张人脸照片,返回一组**固定编号 MediaPipe Face Mesh 关键点**的像素坐标,外加 5 个**发际线特殊点**(射线与头发分割发际线的交点)。**生产路径:网关本机实现(纯 CPU,不转发 worker,同接口4 模式)**;worker 侧保留同路径接口供测试页直连。
|
||||
|
||||
**请求**:`POST /api/v1/face/special-points`
|
||||
|
||||
### 输入
|
||||
|
||||
图片参数见「通用约定 → 图片传参字段」,无专属参数。要求单人正面照(非正面返回 `1003`)。
|
||||
|
||||
### 输出(data)
|
||||
|
||||
`points`:字段名 = `<组英文名>_<点ID>`,值为原图像素坐标 `{x, y}`(int);发际线点不可得(秃顶/分割失败)时为 null。
|
||||
|
||||
| 组 | 字段前缀 | 点 ID |
|
||||
|----|----------|-------|
|
||||
| 眉毛 | `eyebrow` | 107 66 105 63 70 336 296 334 293 300 |
|
||||
| 眼睛 | `eye` | 189 222 224 113 226 413 442 444 342 446 |
|
||||
| 嘴 | `mouth` | 0 37 40 67 91 84 17 405 375 409 269 |
|
||||
| 中线鼻 | `nose_midline` | 9 168 197 5 4 194 278 48 437 217 |
|
||||
| 面颊 | `cheek` | 101 117 123 187 205 50 330 346 352 280 425 |
|
||||
| 边线 | `face_edge` | 162 234 58 136 149 152 378 365 288 401 366 389 251 |
|
||||
|
||||
**发际线特殊点**(沿关键点 a→b 方向射线步进,首次进入 BiSeNet 头发 mask 处为交点;双射线取中点,仅一条命中返回该点):
|
||||
|
||||
| 字段 | 计算 |
|
||||
|------|------|
|
||||
| `hair_line_center` | 151→10 方向交点 |
|
||||
| `hair_line_right` | mid(108→109 交点A, 69→67 交点B) |
|
||||
| `hair_line_right_right` | mid(104→103 交点A, 68→54 交点B) |
|
||||
| `hair_line_left` | mid(337→338 交点A, 299→297 交点B) |
|
||||
| `hair_line_left_left` | mid(333→332 交点A, 298→284 交点B) |
|
||||
|
||||
另有 `image_size`(宽高)与 `hairline_source`(`segmentation`=发际线点已算出 / `unavailable`=分割不可用,发际线点为 null)。
|
||||
|
||||
---
|
||||
|
||||
## 汇总:输入输出一览
|
||||
|
||||
| 接口 | 路径 | 输入 | 主要输出 |
|
||||
|------|------|------|----------|
|
||||
| 1 四庭七眼测量 | `/api/v1/face/measure` | 用户照片 | 标注 PNG(无人物)+ 四庭/七眼厘米数值与坐标 |
|
||||
| 6 四庭七眼测量 v2 | `/api/v1/face/measure-v2` | 用户照片 | 同接口1,去顶庭变体 |
|
||||
| 2 C 端生发 | `/api/v1/hair/grow` | 用户照片 + gender + hair_style(多选) | 每发型预览 PNG + 生发图(female 1..5=换发型+重绘,其余=原生生发) |
|
||||
| 7 C 端生发 v2 | `/api/v1/hair/grow-v2` | 同接口2 | 同接口2(add_hair2 工作流,已弃用隐藏) |
|
||||
| 3 B 端生发 | `/api/v1/hair/grow-b` | 划线图片 | 检测到的发际线 + 生发后图片 |
|
||||
| 4 用户特征 | `/api/v1/face/features` | 用户照片 | 6 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格),网关本机实现 |
|
||||
| 5 发际线 PNG | `/api/v1/hairline/generate` | 用户照片 + gender + hair_style(多选) | 每个选中发型 middle/high/low 三档发际线叠图 + 生发图 + 最合适发际线面部中间点坐标 |
|
||||
| 9 头发遮罩 | `/api/v1/head/mask` | 用户照片 | 头发遮罩 + 分步可视化(BiSeNet/SegFormer 双路) |
|
||||
| 10 外缘膨胀带 | `/api/v1/head/band` | 用户照片 | 头部外缘带状遮罩 + 分步可视化 |
|
||||
| 11 发际线生发 | `/api/v1/hairline/grow` | 用户照片 + hairline_id | 压低发际线图 + 全步骤可视化(不含重绘) |
|
||||
| 12 发际线带重绘 | `/api/v1/hairline/grow_v2` | 同接口11 + 重绘带参数 | final 基底 + Flux-2 保色重绘(整帧/局部两版);精简版 `grow_v2_final[_v2]` 仅需图片+hairline_id |
|
||||
| 99 人脸特殊点 | `/api/v1/face/special-points` | 用户照片 | 60 个固定编号关键点 + 5 个发际线特殊点像素坐标 JSON(**网关本机实现**,同接口4) |
|
||||
|
||||
> 另有 worker 本机工具路由(不在对外契约内):`/api/swapHair/v1`、`/hairColor/v2`(CORS 代理 change_hair)、`/api/v1/redraw`(直接 ComfyUI 重绘)、`/api/v1/debug/*`。
|
||||
| 接口 | 输入 | 主要输出 |
|
||||
|------|------|----------|
|
||||
| 1 四庭七眼测量 | 用户照片 | 标注 PNG(无人物)+ 四庭/七眼厘米数值与坐标 |
|
||||
| 6 四庭七眼测量 v2 | 用户照片 | 同接口1,复刻实现 |
|
||||
| 2 C 端生发 | 用户照片 | 生发后图片 + 指定发际线预览(单/多张) |
|
||||
| 3 B 端生发 | 划线图片 | 最合适发际线图片 + 生发后图片 |
|
||||
| 4 用户特征 | 用户照片 | 6 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格) |
|
||||
| 5 发际线 PNG | 用户照片 + gender + hair_style(多选) | 每个选中发型 middle/high/low 三档发际线叠图 + 生发图 + 最合适发际线面部中间点坐标 |
|
||||
|
||||
---
|
||||
|
||||
@@ -718,7 +534,7 @@
|
||||
- 默认单人;检测到 2~3 人报错提示客户端;单文件 **≤ 1 MB**;格式仅 **JPG / PNG**。
|
||||
- 所有坐标基准:**原图像素**。
|
||||
- 接口 2:提供「美颜」开关 `beauty_enabled`。
|
||||
- 接口 3:仅传**划线图**(`marked_image_*` 三选一),需返回发际线类型(`custom`)。
|
||||
- 接口 3:需同时上传原图,需返回发际线类型。
|
||||
- 接口 4:`data` 返回**一个 JSON 字符串**,字段不固定,可随时调整。
|
||||
- 接口 5:发际线 PNG 数量 **N 不固定**,需按合适度**排序**。
|
||||
- 英文字段命名由本文档约定(见各接口表格)。
|
||||
|
||||
@@ -70,7 +70,7 @@ async def hair_grow_v2(request: Request):
|
||||
|------|------|------|------|
|
||||
| image_file / image_url / image_base64 | — | **三选一** | 用户正面照 |
|
||||
| gender | string | **是** | `male` / `female` |
|
||||
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`)。female / male 均为 1–7 |
|
||||
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`)。female: 1–5,male: 1–4 |
|
||||
| beauty_enabled | bool | 否 | 美颜开关(本期不生效) |
|
||||
| use_mask | bool | 否 | 默认 `true`,`false` 跳过遮罩 |
|
||||
| prompt | string | 否 | ComfyUI 提示词 |
|
||||
@@ -135,104 +135,6 @@ async def face_measure_v2(request: Request):
|
||||
|
||||
---
|
||||
|
||||
## 7. ✅【已完成】接口9/10 头部遮罩(`/api/v1/head/mask`、`/api/v1/head/band`)
|
||||
|
||||
`gateway/app.py` 已新增两条盲转发路由(同接口6 的写法):
|
||||
|
||||
```python
|
||||
@app.post("/api/v1/head/mask", tags=["人脸分析"])
|
||||
@app.post("/api/v1/head/band", tags=["人脸分析"])
|
||||
```
|
||||
|
||||
- 入参:图片三选一 + `erode_cm`(接口10 另有 `dilate_cm`)。
|
||||
- 出参:`steps_common.*_base64` 与 `bisenet`/`segformer` 组内的 `*_base64` 字段——现有递归
|
||||
`rewrite_base64_to_url` 已覆盖(嵌套 dict/数组),无需额外改动。
|
||||
- 两个接口响应较慢(双分割模型跑两遍),注意 `request_timeout_seconds`。
|
||||
|
||||
---
|
||||
|
||||
## 8. 🔲【新增】接口11/12 发际线生发/带重绘(`/api/v1/hairline/grow*`)
|
||||
|
||||
worker 侧已上线 4 条路由,**网关尚无对应转发路由**(当前仅测试页直连 worker)。如需对外提供,参考接口9 加法:
|
||||
|
||||
```python
|
||||
@app.post("/api/v1/hairline/grow", tags=["生发"]) # 接口11
|
||||
@app.post("/api/v1/hairline/grow_v2", tags=["生发"]) # 接口12 全参数
|
||||
@app.post("/api/v1/hairline/grow_v2_final", tags=["生发"]) # 接口12 final 精简版
|
||||
@app.post("/api/v1/hairline/grow_v2_final_v2", tags=["生发"])
|
||||
```
|
||||
|
||||
- 入参:图片三选一 + `hairline_id`(接口12 final 两变体仅需这两个)。接口11/12 全参数版还有
|
||||
20+ 个调优 Form 参数,网关盲转发即可,无需逐个声明。
|
||||
- 出参:`data.steps.*_base64`(input/final/redraw_full/redraw_band 等各步图)→ 现有递归改写覆盖。
|
||||
- ⚠️ 依赖 worker 机的 change_hair(:8801) 与 ComfyUI(:8188) 都在跑;单请求 10s+,超时建议 ≥120s。
|
||||
- 契约细节见 [`接口文档.md`](接口文档.md) 接口11/12 章节。
|
||||
|
||||
---
|
||||
|
||||
## 9. ✅【新增】接口99 人脸特殊点(`/api/v1/face/special-points`)— **网关本机实现,不转发 worker**
|
||||
|
||||
**背景**:接口99(固定编号 MediaPipe 关键点 6 组 60 点 + 5 个发际线特殊点,返回像素坐标 JSON)
|
||||
**纯 CPU 可跑**(MediaPipe 本就 CPU;BiSeNet 分割单核实测 <1s)且调用频率低。
|
||||
按约定:**不用 GPU、低频的接口默认网关本机实现**,不占 worker 队列
|
||||
(worker 池 `per_worker_concurrency=1`,转发会排在生发任务后面等)。
|
||||
|
||||
**网关代码已在本仓库 `gateway/app.py` 改好**——与接口4 同款本机实现(显式 Form 参数 +
|
||||
惰性导入 + `run_in_threadpool` + `gw-` request_id),直接 import 同仓库的
|
||||
`face_analysis/special_points.detect_special_points`。**单路排队**:`_if99_lock` 全局互斥,
|
||||
同一时刻只跑一路推理(限制 CPU 峰值 + 规避 MediaPipe 单例线程安全问题),请求 FIFO 排队。
|
||||
|
||||
### 网关机部署前检查
|
||||
|
||||
1. **Python 3.10~3.12**(mediapipe 0.10.14 无 3.13 wheel)。
|
||||
2. **系统库**(无 GUI 服务器跑 opencv/mediapipe 必需,已装可跳过):
|
||||
|
||||
```bash
|
||||
sudo apt install -y libgl1 libglib2.0-0 libegl1
|
||||
```
|
||||
|
||||
3. **安装依赖**(网关 venv,国内镜像):
|
||||
|
||||
```bash
|
||||
pip install -i https://pypi.tuna.tsinghua.edu.cn/simple \
|
||||
mediapipe==0.10.14 opencv-python==4.10.0.84 numpy==1.26.4
|
||||
|
||||
# 可选:BiSeNet 头发分割(发际线 5 点需要)。CPU 版 torch 仅 ~200MB:
|
||||
pip install torch==2.2.2 torchvision==0.17.2 --index-url https://download.pytorch.org/whl/cpu
|
||||
# 不装 torch 也能跑:60 个关键点正常返回,发际线 5 点为 null(hairline_source="unavailable")
|
||||
# ⚠️ 网关无 GPU——务必用上面的 CPU 专用源装 torch;装默认 PyPI 版会带 2.5G 的 CUDA 包,浪费但也能跑(自动回退 CPU)
|
||||
```
|
||||
|
||||
4. **权重已入 git**,`git clone/pull` 自动到位:`face_analysis/weights/79999_iter.pth`(51MB)。
|
||||
无需手动下载任何模型文件。
|
||||
5. **无 GPU 已实测**:`CUDA_VISIBLE_DEVICES=""` 模拟验证通过(70/70 点,与 GPU 结果一致);
|
||||
CPU 推理 16 核 0.18s/次,网关机核少时约 0.5~2s/次,低频可接受。
|
||||
|
||||
### 部署与冒烟
|
||||
|
||||
```bash
|
||||
cd /path/to/hair && git pull
|
||||
sudo systemctl restart hair-gateway # 或网关实际服务名(uvicorn gateway.app:app)
|
||||
|
||||
# 冒烟(multipart 传图;经网关无需 X-Internal-Token)
|
||||
curl -s -X POST http://127.0.0.1:8080/api/v1/face/special-points \
|
||||
-F "image_file=@/tmp/face.jpg" | python3 -m json.tool | head -30
|
||||
# 期望 code=0;data.points 含 eyebrow_107 等 60 个关键点 + hair_line_* 5 点
|
||||
# 缺依赖时返回 code=1007 且 message 提示"网关依赖未安装"
|
||||
```
|
||||
|
||||
### 注意
|
||||
|
||||
- **超时无需调大**:单路 CPU 推理 <1s;即使排队,低频下积压可忽略。排队本身无超时,
|
||||
调用方按需自带客户端超时。
|
||||
- **错误码透传契约**:1001(无人脸)/ 1003(非正面)/ 1007(参数/处理失败/缺依赖)/
|
||||
1008(图片格式/下载失败),HTTP 一律 200。
|
||||
- **worker 侧同路径接口保留**(`app.py`),供测试页 `static/test_interface99.html`
|
||||
直连调试(需 token);生产路径是网关本机实现。
|
||||
- 契约详见 [`接口文档.md`](接口文档.md) 接口99 章节。
|
||||
|
||||
---
|
||||
|
||||
## 已经做好、无需再动的
|
||||
|
||||
- **接口4 在网关本机实现**(调豆包,不转发 worker)——已完成;config 里配 `ark`。
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
# 网关摘除隔离期实施方案(方案 B)
|
||||
|
||||
> 目标读者:网关机器的部署/维护者。本文档给出基于 **main 分支** `gateway/` 代码的
|
||||
> 完整改动说明,可直接照做。**不需要改 worker 侧任何代码。**
|
||||
|
||||
## 1. 背景与事故复盘
|
||||
|
||||
2026-09-19 晚,GPU worker 机器(ubuntu3090_debug_4090 所在主机)磁盘写满,业务接口
|
||||
全部失败,但网关**持续把请求打到该机器近 16 个小时**(21:30 故障发生 → 次日 13:36
|
||||
人工重启),没有切换到其他 worker。复盘结论是三层防线同时失效:
|
||||
|
||||
| 层 | 问题 | 后果 |
|
||||
|---|---|---|
|
||||
| 1. worker `/health` 太浅 | 只返回内存里的"模型就绪"标志,磁盘满/下游 ComfyUI 挂掉不影响它 | 探测一直 200 |
|
||||
| 2. 业务错误不触发摘除 | 下游出错经包装后以 HTTP 200/400 + 错误码返回,`forward.py` 只对 连接异常/超时/≥500/401 摘除 | 请求失败但 worker 被视为健康 |
|
||||
| 3. 摘除后 8 秒即复活(**本方案要修的**) | `mark_worker_unhealthy()` 下线后,下一轮探测(8s 后)`/health` 返回 200,`healthy_threshold=1` 使其**一次成功立即重新上线** | 即使被摘除也会反复横跳,流量持续打入 |
|
||||
|
||||
worker 侧的第 1 层已经修复(方案 A:`/health` 深检磁盘水位 + 下游依赖 TCP 探测,
|
||||
异常返回 503,代码在分支 `ubuntu3090_debug_4090`,提交 `14562d4`)。本方案修复
|
||||
第 3 层:**被动摘除后进入隔离期,隔离期内即使探测成功也不复活**。两层配合后:
|
||||
worker 磁盘满 → `/health` 503 → 网关 2 次探测失败摘除 → 隔离期内不再接流量。
|
||||
|
||||
> 第 2 层(错误码语义化,涉及调用方兼容)暂不处理,单独立项。
|
||||
|
||||
## 2. 现状代码行为(main 分支 gateway/pool.py)
|
||||
|
||||
- `mark_worker_unhealthy(w)`:被 `forward.py` 在转发失败(连接异常/超时/5xx/401)时
|
||||
调用,置 `w.online = False`。**没有任何冷却机制**。
|
||||
- `_health_check_loop(cfg)`:每 `interval_seconds`(默认 8s)探测所有 worker 的
|
||||
`/health`;返回 200 时 `consecutive_successes += 1`,达到 `healthy_threshold`
|
||||
(默认 **1**)且当前 offline → 立即 `online = True`。
|
||||
- 因此被动摘除最多 8 秒后就被一次探测成功拉回——如果 worker 是"浅健康"或故障
|
||||
间歇性表现,就会陷入 摘除→复活→失败→摘除 的横跳。
|
||||
- 另外 58-96 行有一段**从未被调用的死代码** `_check_worker_health()` /
|
||||
`_mark_failure()`(写了一半的旧实现),本次顺带删除,避免后人改错地方。
|
||||
|
||||
## 3. 代码改动(共 2 个文件 + 1 个配置)
|
||||
|
||||
### 3.1 `gateway/pool.py`
|
||||
|
||||
**(1) `WorkerState` 增加一个字段:**
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class WorkerState:
|
||||
"""单个 worker 的运行时状态。"""
|
||||
url: str
|
||||
online: bool = False # 初始 offline,等健康检查通过后上线
|
||||
busy: bool = False
|
||||
consecutive_failures: int = 0
|
||||
consecutive_successes: int = 0
|
||||
offline_until: float = 0.0 # 被动摘除后的隔离截止时刻(time.monotonic() 基准)
|
||||
```
|
||||
|
||||
**(2) 模块顶部(`logger = ...` 之后)加一个配置读取 helper:**
|
||||
|
||||
```python
|
||||
def _cooldown_seconds() -> float:
|
||||
"""被动摘除后的隔离期时长(秒),配置 health_check.cooldown_seconds,默认 60。"""
|
||||
try:
|
||||
from gateway.config import get_config
|
||||
return float(get_config()["health_check"].get("cooldown_seconds", 60))
|
||||
except Exception: # noqa: BLE001
|
||||
return 60.0
|
||||
```
|
||||
|
||||
(函数内 import 是为了不引入模块级循环依赖;`gateway/config.py` 不依赖 pool,顶层
|
||||
import 也可以。)
|
||||
|
||||
**(3) `mark_worker_unhealthy()` 摘除时同时设置隔离期:**
|
||||
|
||||
```python
|
||||
async def mark_worker_unhealthy(w: WorkerState) -> None:
|
||||
"""被动标记:转发失败时立即将该 worker 下线,并进入隔离期。
|
||||
|
||||
隔离期内即使 /health 探测成功也不复活(由 _health_check_loop 守卫),
|
||||
避免"摘除→8 秒复活→再失败"的横跳。
|
||||
"""
|
||||
async with _pool_condition:
|
||||
if w.online:
|
||||
w.online = False
|
||||
w.consecutive_failures += 1
|
||||
w.offline_until = time.monotonic() + _cooldown_seconds()
|
||||
logger.warning("⚠ Worker 被动下线: %s(转发失败,隔离 %.0fs)",
|
||||
w.url, _cooldown_seconds())
|
||||
```
|
||||
|
||||
**(4) `_health_check_loop()` 的探测成功分支加隔离期守卫。** 找到这段:
|
||||
|
||||
```python
|
||||
if resp.status_code == 200:
|
||||
w.consecutive_failures = 0
|
||||
w.consecutive_successes += 1
|
||||
if w.consecutive_successes >= healthy_threshold and not w.online:
|
||||
w.online = True
|
||||
logger.info("✅ Worker 上线: %s", w.url)
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```python
|
||||
if resp.status_code == 200:
|
||||
w.consecutive_failures = 0
|
||||
w.consecutive_successes += 1
|
||||
if w.consecutive_successes >= healthy_threshold and not w.online:
|
||||
if time.monotonic() < w.offline_until:
|
||||
logger.debug("Worker %s 探测成功但处于隔离期,暂不复活", w.url)
|
||||
else:
|
||||
w.online = True
|
||||
logger.info("✅ Worker 上线: %s", w.url)
|
||||
```
|
||||
|
||||
注意:`except`(连接异常)分支只累计失败、不涉及复活,无需改动;
|
||||
`init_pool()` 启动时的首轮探测同理无需改动(进程刚启动时 `offline_until` 为 0,
|
||||
不构成隔离)。
|
||||
|
||||
**(5) 删除死代码**:`_check_worker_health()` 与
|
||||
`_mark_failure()` 两个函数整体删除(约 58-96 行,以函数名为准,别删到
|
||||
`_health_check_loop`)。删除前可确认无调用方:
|
||||
`grep -rn "_check_worker_health\|_mark_failure" .` 应只剩定义处。
|
||||
|
||||
### 3.2 `gateway/config.py`
|
||||
|
||||
`DEFAULTS["health_check"]` 增加一个 key:
|
||||
|
||||
```python
|
||||
"health_check": {
|
||||
"path": "/health",
|
||||
"interval_seconds": 8,
|
||||
"timeout_seconds": 3,
|
||||
"unhealthy_threshold": 2,
|
||||
"healthy_threshold": 1,
|
||||
"cooldown_seconds": 60, # ← 新增:被动摘除后的隔离期(秒)
|
||||
},
|
||||
```
|
||||
|
||||
`config.example.json` 同步加这个 key。网关机器的实际 `config.json` **不加也不会报错**
|
||||
(代码里有 60 的默认兜底),但建议显式加上,便于运维感知。
|
||||
|
||||
### 3.3 建议单测:`tests/test_gateway_pool.py`(新文件)
|
||||
|
||||
gateway 子包目前没有任何测试。隔离期是纯逻辑,建议至少覆盖以下三个用例
|
||||
(不依赖真实网络):
|
||||
|
||||
```python
|
||||
"""gateway/pool.py 隔离期逻辑单测。"""
|
||||
import asyncio
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
from gateway import pool
|
||||
from gateway.pool import WorkerState
|
||||
|
||||
|
||||
def _drain(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def test_passive_ejection_sets_cooldown():
|
||||
w = WorkerState(url="http://127.0.0.1:9000", online=True)
|
||||
async def run():
|
||||
pool._pool_condition = asyncio.Condition()
|
||||
with patch.object(pool, "_cooldown_seconds", return_value=60.0):
|
||||
await pool.mark_worker_unhealthy(w)
|
||||
_drain(run())
|
||||
assert w.online is False
|
||||
assert w.offline_until > time.monotonic()
|
||||
|
||||
|
||||
def test_probe_success_revives_after_cooldown():
|
||||
w = WorkerState(url="http://127.0.0.1:9000") # offline
|
||||
w.offline_until = time.monotonic() - 1 # 隔离期已过
|
||||
# 直接验证状态迁移逻辑(若按 3.1(4) 内联实现,可复制该分支逻辑做断言,
|
||||
# 或将判断抽成 _try_revive(w, healthy_threshold) 小函数后单测)
|
||||
...
|
||||
|
||||
|
||||
def test_probe_success_blocked_during_cooldown():
|
||||
w = WorkerState(url="http://127.0.0.1:9000")
|
||||
w.offline_until = time.monotonic() + 60
|
||||
... # 断言保持 offline
|
||||
```
|
||||
|
||||
> 提示:如果按 3.1(4) 原样内联修改,后两个用例不好直接驱动,可以把"是否允许复活"
|
||||
> 抽成一个 6 行的纯函数 `_try_revive(w, healthy_threshold) -> bool` 再测,主循环
|
||||
> 调用它即可——实现方式自定,覆盖语义即可。
|
||||
|
||||
## 4. 部署步骤(网关机器)
|
||||
|
||||
1. `git pull` main 分支(含本方案代码改动)。
|
||||
2. (可选但建议)`config.json` 的 `health_check` 段加 `"cooldown_seconds": 60`。
|
||||
3. 重启网关服务(`hair-gateway.service` 或等价的 uvicorn 进程)。重启期间网关
|
||||
本身短暂不可用,选低峰执行。
|
||||
4. 观察启动日志:`健康检查循环启动 | 间隔=8s | 下线阈值=2 | 上线阈值=1 | workers=N`
|
||||
正常出现。
|
||||
|
||||
## 5. 验证
|
||||
|
||||
**功能验证(低峰演练):**
|
||||
|
||||
1. 人工把主 worker 摘除一次最直接的方法:临时在 worker 机器
|
||||
`systemctl stop hair-worker`(或临时设 `HEALTH_MIN_FREE_GB=999` 重启)。
|
||||
2. 观察网关日志:
|
||||
- 连续 2 次探测失败 → `⚠ Worker 下线`(原有行为);
|
||||
- **转发失败触发的** `⚠ Worker 被动下线: ...(转发失败,隔离 60s)`(新日志);
|
||||
- 恢复 worker 后,若刚被被动摘除过,即使 `/health` 恢复 200,也应看到 60 秒内
|
||||
**不出现** `✅ Worker 上线`,隔离期满后下一次探测成功才上线。
|
||||
3. `GET /gateway-health` 观察 `workers_healthy` 的变化节奏。
|
||||
|
||||
**回归确认:** 正常场景 worker 上下线不受影响(隔离期只对"被动摘除"生效;
|
||||
探测失败导致的下线,恢复后仍按 `healthy_threshold` 立即上线)。
|
||||
|
||||
## 6. 回滚
|
||||
|
||||
- 代码回滚:`git revert` 对应提交即可,无数据/配置迁移。
|
||||
- 紧急关闭隔离期:网关机器 `config.json` 设 `"cooldown_seconds": 0`
|
||||
(等价于旧行为——摘除后下一次探测成功即复活),无需回代码。
|
||||
|
||||
## 7. 附:worker 侧方案 A 的行为(已上线,供联调参考)
|
||||
|
||||
worker(分支 `ubuntu3090_debug_4090`,提交 `14562d4`)的 `/health` 现在会在以下
|
||||
情况返回 **503**(网关 2 次探测失败即摘除):
|
||||
|
||||
- 模型加载中:`503 {"status":"starting"}`(原有行为不变)
|
||||
- 磁盘剩余低于阈值:`503 {"status":"degraded","reason":"disk_free_below_5.0g","disk_free_gb":...}`
|
||||
- 下游依赖(ComfyUI 8188 / change_hair 8801)TCP 不通:
|
||||
`503 {"status":"degraded","reason":"dep_down:comfyui,...","deps":{...}}`
|
||||
|
||||
正常时返回 `200 {"status":"ok","disk_free_gb":24.6,"deps":{"comfyui":true,"change_hair":true}}`。
|
||||
|
||||
worker 侧配置(`worker_config.json` 的 `health` 段,环境变量可覆盖):
|
||||
`min_free_gb`(默认 5,0=关闭)、`check_deps`(默认 true)、`dep_comfyui`(默认
|
||||
127.0.0.1:8188)、`deps_cache_seconds`(默认 5)。**其他 worker 机器如果也想启用
|
||||
深检,cherry-pick 该提交并补一份 worker_config.json 的 health 段即可。**
|
||||
@@ -1,506 +0,0 @@
|
||||
"""
|
||||
build_dataset_report.py
|
||||
对任意图片目录(可含多层子目录)批量预测脸型并生成 HTML 报告。
|
||||
保留图片原始所属的子目录名作为「分组」,在报告中按分组展示与统计。
|
||||
|
||||
用法:
|
||||
./venv/bin/python face/build_dataset_report.py --src <图片目录> [--sample 50] [--seed 42]
|
||||
|
||||
示例:
|
||||
./venv/bin/python face/build_dataset_report.py \
|
||||
--src face/test_img/脸型测试集合 --sample 50 --name 脸型测试集合
|
||||
|
||||
输出:
|
||||
static/<slug>_report.html
|
||||
static/<slug>_report/images/*.jpg
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import unicodedata
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
import cv2
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from face.face_shape_classifier import classify_from_image # noqa: E402
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
|
||||
MAX_IMAGE_SIDE = 900
|
||||
JPEG_QUALITY = 88
|
||||
|
||||
SHAPE_ORDER = ["圆形脸", "心形脸", "菱形脸", "鹅蛋脸", "方形脸", "长形脸", "瓜子脸"]
|
||||
SHAPE_COLORS = {
|
||||
"圆形脸": "#e67e22",
|
||||
"心形脸": "#e74c3c",
|
||||
"菱形脸": "#9b59b6",
|
||||
"鹅蛋脸": "#27ae60",
|
||||
"方形脸": "#2980b9",
|
||||
"长形脸": "#16a085",
|
||||
"瓜子脸": "#c0392b",
|
||||
"检测失败": "#7f8c8d",
|
||||
}
|
||||
# 数据集分组名与分类器脸型口径的近似对应(仅用于交叉表高亮参考,非严格标签)
|
||||
TAXONOMY_EQUIV = {
|
||||
"方形脸": "方形脸",
|
||||
"长形脸": "长形脸",
|
||||
"瓜子脸": "瓜子脸",
|
||||
"标准脸": "鹅蛋脸",
|
||||
"娃娃脸": "圆形脸",
|
||||
}
|
||||
|
||||
FEATURE_KEYS = [
|
||||
"face_width",
|
||||
"face_height",
|
||||
"jaw_angle",
|
||||
"taper_ratio",
|
||||
"forehead_ratio",
|
||||
"cheekbone_ratio",
|
||||
"jaw_ratio",
|
||||
"chin_ratio",
|
||||
"chin_sharpness",
|
||||
"width_uniformity",
|
||||
"face_curve_score",
|
||||
]
|
||||
|
||||
|
||||
def natural_key(text: str):
|
||||
parts = re.split(r"(\d+)", text)
|
||||
return [int(p) if p.isdigit() else p for p in parts]
|
||||
|
||||
|
||||
def collect_images(src: Path) -> List[Path]:
|
||||
return sorted(
|
||||
(p for p in src.rglob("*") if p.suffix.lower() in IMAGE_SUFFIXES),
|
||||
key=lambda p: natural_key(str(p.relative_to(src))),
|
||||
)
|
||||
|
||||
|
||||
def group_of(path: Path, src: Path) -> str:
|
||||
"""图片相对根目录的父目录名;直接位于根目录则记为「根目录」。"""
|
||||
rel = path.relative_to(src).parent
|
||||
return str(rel) if str(rel) != "." else "(根目录)"
|
||||
|
||||
|
||||
def ascii_slug(text: str, fallback: str) -> str:
|
||||
"""生成安全的 ASCII 文件名片段(中文目录名转拼音不可靠,直接编号兜底)。"""
|
||||
norm = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
|
||||
norm = re.sub(r"[^A-Za-z0-9_-]+", "_", norm).strip("_")
|
||||
return norm or fallback
|
||||
|
||||
|
||||
def stratified_sample(
|
||||
images: List[Path], src: Path, total: int, seed: int, min_per_group: int
|
||||
) -> List[Path]:
|
||||
"""
|
||||
按分组分层抽样:先保证每组至少 min_per_group 张,剩余名额按组大小比例分配。
|
||||
小分组(如只有 3 张的梨形脸)在纯随机抽样下几乎必然缺席,分层可保证覆盖。
|
||||
"""
|
||||
rng = random.Random(seed)
|
||||
buckets: Dict[str, List[Path]] = defaultdict(list)
|
||||
for p in images:
|
||||
buckets[group_of(p, src)].append(p)
|
||||
|
||||
groups = sorted(buckets, key=natural_key)
|
||||
quota = {g: min(min_per_group, len(buckets[g])) for g in groups}
|
||||
|
||||
remaining = total - sum(quota.values())
|
||||
if remaining > 0:
|
||||
spare = {g: len(buckets[g]) - quota[g] for g in groups}
|
||||
pool = sum(spare.values())
|
||||
if pool > 0:
|
||||
# 按剩余可选量比例分配,再把取整误差补给最大的分组
|
||||
extra = {g: int(remaining * spare[g] / pool) for g in groups}
|
||||
for g in sorted(groups, key=lambda g: -spare[g]):
|
||||
if sum(extra.values()) >= remaining:
|
||||
break
|
||||
if extra[g] < spare[g]:
|
||||
extra[g] += 1
|
||||
for g in groups:
|
||||
quota[g] += min(extra[g], spare[g])
|
||||
|
||||
chosen: List[Path] = []
|
||||
for g in groups:
|
||||
chosen.extend(rng.sample(buckets[g], min(quota[g], len(buckets[g]))))
|
||||
return chosen
|
||||
|
||||
|
||||
def analyze(
|
||||
src: Path, sample: int, seed: int, img_dir: Path, min_per_group: int
|
||||
) -> List[Dict]:
|
||||
all_images = collect_images(src)
|
||||
if not all_images:
|
||||
raise SystemExit(f"目录中没有图片: {src}")
|
||||
|
||||
if sample and sample < len(all_images):
|
||||
if min_per_group > 0:
|
||||
chosen = stratified_sample(all_images, src, sample, seed, min_per_group)
|
||||
else:
|
||||
chosen = random.Random(seed).sample(all_images, sample)
|
||||
chosen.sort(key=lambda p: natural_key(str(p.relative_to(src))))
|
||||
else:
|
||||
chosen = all_images
|
||||
|
||||
mode = f"分层抽样,每组至少 {min_per_group} 张" if min_per_group > 0 else "纯随机抽样"
|
||||
print(f"共发现 {len(all_images)} 张图片,本次测试 {len(chosen)} 张({mode},seed={seed})\n")
|
||||
|
||||
if img_dir.exists():
|
||||
shutil.rmtree(img_dir)
|
||||
img_dir.mkdir(parents=True)
|
||||
|
||||
group_slugs: Dict[str, str] = {}
|
||||
rows: List[Dict] = []
|
||||
|
||||
for idx, path in enumerate(chosen, 1):
|
||||
group = group_of(path, src)
|
||||
if group not in group_slugs:
|
||||
group_slugs[group] = ascii_slug(group, f"g{len(group_slugs) + 1}")
|
||||
out_name = f"{group_slugs[group]}_{idx:03d}.jpg"
|
||||
|
||||
item = {
|
||||
"index": idx,
|
||||
"group": group,
|
||||
"file": path.name,
|
||||
"rel_path": str(path.relative_to(src)),
|
||||
# 相对 static/ 的路径(报告 HTML 也放在 static/ 根下)
|
||||
"img_src": f"{img_dir.relative_to(ROOT / 'static').as_posix()}/{out_name}",
|
||||
"ok": False,
|
||||
"predicted": None,
|
||||
"display": None,
|
||||
"confidence": None,
|
||||
"score": None,
|
||||
"top3": [],
|
||||
"features": {},
|
||||
"error": None,
|
||||
}
|
||||
|
||||
try:
|
||||
result = classify_from_image(path, return_details=True, return_annotated=True)
|
||||
annotated = result["annotated"]
|
||||
h, w = annotated.shape[:2]
|
||||
if max(h, w) > MAX_IMAGE_SIDE:
|
||||
scale = MAX_IMAGE_SIDE / max(h, w)
|
||||
annotated = cv2.resize(
|
||||
annotated, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA
|
||||
)
|
||||
cv2.imwrite(str(img_dir / out_name), annotated, [int(cv2.IMWRITE_JPEG_QUALITY), JPEG_QUALITY])
|
||||
|
||||
item.update(
|
||||
{
|
||||
"ok": True,
|
||||
"predicted": result["face_shape"],
|
||||
"display": result["display"],
|
||||
"confidence": result["confidence"],
|
||||
"score": result["details"]["ranked"][0][1],
|
||||
"top3": result["details"]["ranked"][:3],
|
||||
"features": {k: result["features"][k] for k in FEATURE_KEYS},
|
||||
}
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - 报告需要汇总所有失败样本
|
||||
img = cv2.imread(str(path))
|
||||
if img is not None:
|
||||
h, w = img.shape[:2]
|
||||
if max(h, w) > MAX_IMAGE_SIDE:
|
||||
scale = MAX_IMAGE_SIDE / max(h, w)
|
||||
img = cv2.resize(img, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA)
|
||||
cv2.imwrite(str(img_dir / out_name), img, [int(cv2.IMWRITE_JPEG_QUALITY), JPEG_QUALITY])
|
||||
item["error"] = str(exc)
|
||||
|
||||
rows.append(item)
|
||||
print(f"[{idx:3d}/{len(chosen)}] [{group}] {path.name} -> {item['display'] or 'ERR: ' + str(item['error'])}")
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def bar_chart(counter: Counter) -> str:
|
||||
if not counter:
|
||||
return "<p class='muted'>无数据</p>"
|
||||
total = sum(counter.values())
|
||||
parts = []
|
||||
order = [s for s in SHAPE_ORDER if counter.get(s)] + [
|
||||
s for s in counter if s not in SHAPE_ORDER
|
||||
]
|
||||
for shape in order:
|
||||
n = counter[shape]
|
||||
color = SHAPE_COLORS.get(shape, "#7f8c8d")
|
||||
pct = n / total * 100
|
||||
parts.append(
|
||||
f"<div class='bar-row'><span class='bar-label'>{html.escape(shape)}</span>"
|
||||
f"<div class='bar-track'><div class='bar-fill' style='width:{pct:.1f}%;background:{color}'></div></div>"
|
||||
f"<span class='bar-num'>{n}({pct:.0f}%)</span></div>"
|
||||
)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def fmt_feat(key: str, value: float) -> str:
|
||||
if key in {"face_width", "face_height"}:
|
||||
return f"{value:.1f}px"
|
||||
if key == "jaw_angle":
|
||||
return f"{value:.1f}°"
|
||||
return f"{value:.3f}"
|
||||
|
||||
|
||||
def card(item: Dict) -> str:
|
||||
group_tag = f"<span class='group-tag'>{html.escape(item['group'])}</span>"
|
||||
if not item["ok"]:
|
||||
return f"""
|
||||
<article class="card error">
|
||||
<a class="img-link" href="{html.escape(item['img_src'])}" target="_blank">
|
||||
<img src="{html.escape(item['img_src'])}" alt="{html.escape(item['file'])}" loading="lazy"/>
|
||||
</a>
|
||||
<div class="body">
|
||||
<div class="meta"><h3>{html.escape(item['file'])}</h3>{group_tag}</div>
|
||||
<p class="badge bad">检测失败</p>
|
||||
<p class="muted">{html.escape(item['error'] or '')}</p>
|
||||
</div>
|
||||
</article>"""
|
||||
|
||||
color = SHAPE_COLORS.get(item["predicted"], "#34495e")
|
||||
top3 = "".join(
|
||||
f"<li><span>{html.escape(name)}</span><b>{score:.1f}</b></li>" for name, score in item["top3"]
|
||||
)
|
||||
feat_html = "".join(
|
||||
f"<tr><td>{html.escape(k)}</td><td>{html.escape(fmt_feat(k, v))}</td></tr>"
|
||||
for k, v in item["features"].items()
|
||||
)
|
||||
return f"""
|
||||
<article class="card">
|
||||
<a class="img-link" href="{html.escape(item['img_src'])}" target="_blank" title="点击查看大图标注">
|
||||
<img src="{html.escape(item['img_src'])}" alt="{html.escape(item['file'])}" loading="lazy"/>
|
||||
</a>
|
||||
<div class="body">
|
||||
<div class="meta"><h3>{html.escape(item['file'])}</h3>{group_tag}</div>
|
||||
<p class="path muted">{html.escape(item['rel_path'])}</p>
|
||||
<p class="badge" style="background:{color}">{html.escape(item['display'])}</p>
|
||||
<p class="conf">匹配度 {item['score']:.1f} · 置信度 {item['confidence']:.3f}</p>
|
||||
<ul class="scores">{top3}</ul>
|
||||
<details>
|
||||
<summary>标注特征数值</summary>
|
||||
<table>{feat_html}</table>
|
||||
</details>
|
||||
</div>
|
||||
</article>"""
|
||||
|
||||
|
||||
CSS = """
|
||||
:root { --bg:#f3efe6; --ink:#1c1915; --muted:#6b645a; --card:#fffdf8; --line:#e2d8c8; --accent:#0f6b5c; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin:0; font-family:"PingFang SC","Noto Sans SC","Segoe UI",sans-serif; color:var(--ink);
|
||||
background: radial-gradient(1200px 600px at 10% -10%, #ffe8c8 0%, transparent 55%),
|
||||
radial-gradient(900px 500px at 100% 0%, #d9f2ea 0%, transparent 50%), var(--bg);
|
||||
}
|
||||
header { padding:40px 24px 20px; max-width:1320px; margin:0 auto; }
|
||||
header h1 { margin:0 0 8px; font-size:clamp(1.8rem,3vw,2.4rem); }
|
||||
header p { margin:4px 0; color:var(--muted); }
|
||||
.legend-box { max-width:1320px; margin:0 auto 20px; padding:0 24px; }
|
||||
.legend-box .inner { background:var(--card); border:1px solid var(--line); border-radius:14px;
|
||||
padding:14px 16px; font-size:.9rem; line-height:1.55; }
|
||||
.legend-box code { background:#efe7da; padding:1px 6px; border-radius:4px; font-size:.84rem; }
|
||||
.swatch { display:inline-block; width:10px; height:10px; border-radius:2px; margin-right:4px; vertical-align:middle; }
|
||||
.stats { display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); gap:16px;
|
||||
max-width:1320px; margin:0 auto 28px; padding:0 24px; }
|
||||
.stat { background:var(--card); border:1px solid var(--line); border-radius:16px; padding:16px 18px; }
|
||||
.stat h2 { margin:0 0 12px; font-size:1rem; }
|
||||
.bar-row { display:grid; grid-template-columns:72px 1fr 80px; gap:8px; align-items:center; margin:6px 0; font-size:.86rem; }
|
||||
.bar-track { height:8px; background:#efe7da; border-radius:999px; overflow:hidden; }
|
||||
.bar-fill { height:100%; border-radius:999px; }
|
||||
.bar-num { color:var(--muted); text-align:right; }
|
||||
section { max-width:1320px; margin:0 auto 36px; padding:0 24px; }
|
||||
section h2 { margin:0 0 14px; font-size:1.3rem; border-left:4px solid var(--accent); padding-left:10px; }
|
||||
section h2 small { color:var(--muted); font-weight:400; font-size:.8rem; margin-left:8px; }
|
||||
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(270px,1fr)); gap:16px; }
|
||||
.card { background:var(--card); border:1px solid var(--line); border-radius:18px; overflow:hidden;
|
||||
display:flex; flex-direction:column; box-shadow:0 8px 24px rgba(60,40,10,.05); }
|
||||
.card.error { opacity:.9; }
|
||||
.img-link { display:block; }
|
||||
.card img { width:100%; aspect-ratio:3/4; object-fit:cover; background:#ddd; display:block; }
|
||||
.card .body { padding:14px; }
|
||||
.meta { display:flex; justify-content:space-between; align-items:baseline; gap:8px; }
|
||||
.meta h3 { margin:0; font-size:.95rem; word-break:break-all; }
|
||||
.group-tag { font-size:.72rem; color:var(--accent); background:#e7f6f2; padding:2px 8px;
|
||||
border-radius:999px; white-space:nowrap; }
|
||||
.path { font-size:.72rem; margin:4px 0 0; word-break:break-all; }
|
||||
.badge { display:inline-block; margin:10px 0 4px; color:#fff; padding:6px 10px; border-radius:999px;
|
||||
font-weight:600; font-size:.92rem; }
|
||||
.badge.bad { background:#c0392b; }
|
||||
.conf { margin:0 0 8px; color:var(--muted); font-size:.85rem; }
|
||||
.scores { list-style:none; padding:0; margin:0 0 8px; }
|
||||
.scores li { display:flex; justify-content:space-between; padding:4px 0; border-bottom:1px dashed var(--line); font-size:.86rem; }
|
||||
details { margin-top:8px; }
|
||||
summary { cursor:pointer; color:var(--accent); font-size:.86rem; }
|
||||
table { width:100%; border-collapse:collapse; margin-top:8px; font-size:.8rem; }
|
||||
td { padding:3px 0; border-bottom:1px solid var(--line); }
|
||||
td:last-child { text-align:right; font-variant-numeric:tabular-nums; }
|
||||
.muted { color:var(--muted); }
|
||||
.cross-wrap { overflow-x:auto; background:var(--card); border:1px solid var(--line);
|
||||
border-radius:16px; padding:14px 16px; }
|
||||
table.cross { border-collapse:collapse; width:100%; font-size:.88rem; }
|
||||
table.cross th, table.cross td { padding:7px 10px; text-align:center; border-bottom:1px solid var(--line);
|
||||
white-space:nowrap; }
|
||||
table.cross thead th { background:#efe7da; font-weight:600; position:sticky; top:0; }
|
||||
table.cross th.rowh { text-align:left; font-weight:600; }
|
||||
table.cross th.rowh small { color:var(--muted); font-weight:400; }
|
||||
table.cross td.num { font-variant-numeric:tabular-nums; }
|
||||
table.cross td.hit { background:#d8f3e4; color:#0f6b5c; font-weight:700; font-variant-numeric:tabular-nums; }
|
||||
table.cross td.zero { color:#cfc6b6; }
|
||||
footer { max-width:1320px; margin:0 auto; padding:8px 24px 40px; color:var(--muted); font-size:.85rem; }
|
||||
"""
|
||||
|
||||
|
||||
def cross_table(by_group: Dict[str, List[Dict]]) -> str:
|
||||
"""原始分组 × 预测脸型 交叉表,对角线(口径对应的格子)高亮。"""
|
||||
cols = SHAPE_ORDER + ["检测失败"]
|
||||
head = "".join(f"<th>{html.escape(c)}</th>" for c in cols)
|
||||
body = []
|
||||
for group, items in sorted(by_group.items(), key=lambda kv: natural_key(kv[0])):
|
||||
counts = Counter(i["predicted"] if i["ok"] else "检测失败" for i in items)
|
||||
equiv = TAXONOMY_EQUIV.get(group)
|
||||
cells = []
|
||||
for c in cols:
|
||||
n = counts.get(c, 0)
|
||||
if n == 0:
|
||||
cells.append("<td class='zero'>·</td>")
|
||||
continue
|
||||
cls = "hit" if c == equiv else "num"
|
||||
cells.append(f"<td class='{cls}'>{n}</td>")
|
||||
label = html.escape(group)
|
||||
if equiv:
|
||||
label += f" <small>≈{html.escape(equiv)}</small>"
|
||||
body.append(f"<tr><th class='rowh'>{label}</th>{''.join(cells)}<th>{len(items)}</th></tr>")
|
||||
return (
|
||||
"<div class='cross-wrap'><table class='cross'>"
|
||||
f"<thead><tr><th>原始分组 \\ 预测</th>{head}<th>合计</th></tr></thead>"
|
||||
f"<tbody>{''.join(body)}</tbody></table></div>"
|
||||
)
|
||||
|
||||
|
||||
def build_html(rows: List[Dict], name: str, src: Path, seed: int, img_dir_name: str) -> str:
|
||||
overall = Counter(r["predicted"] if r["ok"] else "检测失败" for r in rows)
|
||||
by_group: Dict[str, List[Dict]] = defaultdict(list)
|
||||
for r in rows:
|
||||
by_group[r["group"]].append(r)
|
||||
|
||||
group_stats = "".join(
|
||||
f"<div class='stat'><h2>{html.escape(g)} <span class='muted'>({len(items)} 张)</span></h2>"
|
||||
f"{bar_chart(Counter(i['predicted'] if i['ok'] else '检测失败' for i in items))}</div>"
|
||||
for g, items in sorted(by_group.items(), key=lambda kv: natural_key(kv[0]))
|
||||
)
|
||||
|
||||
sections = "".join(
|
||||
f"<section><h2>{html.escape(g)}<small>{len(items)} 张</small></h2>"
|
||||
f"<div class='grid'>{''.join(card(i) for i in items)}</div></section>"
|
||||
for g, items in sorted(by_group.items(), key=lambda kv: natural_key(kv[0]))
|
||||
)
|
||||
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
n_ok = sum(1 for r in rows if r["ok"])
|
||||
n_kind = len([s for s in SHAPE_ORDER if overall.get(s)])
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>{html.escape(name)} — 脸型分类报告</title>
|
||||
<style>{CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>{html.escape(name)} — 脸型分类报告</h1>
|
||||
<p>z 分数原型匹配分类 · 照片上标注 face_width / face_height 及各比例特征</p>
|
||||
<p>生成时间:{html.escape(now)} · 抽样 {len(rows)} 张(随机种子 {seed})· 成功 {n_ok} 张 ·
|
||||
覆盖 {n_kind} 种脸型 · 共 {len(by_group)} 个原始分组 · 点击图片看大图</p>
|
||||
<p class="muted">来源目录:{html.escape(str(src))}</p>
|
||||
</header>
|
||||
|
||||
<div class="legend-box">
|
||||
<div class="inner">
|
||||
<b>图上标注说明</b><br/>
|
||||
<span class="swatch" style="background:#00dc78"></span><code>face_width</code> 颧骨宽度
|
||||
<span class="swatch" style="background:#28b4ff"></span><code>face_height</code> 额头顶→下巴
|
||||
<span class="swatch" style="background:#ff5a00"></span><code>jaw_angle</code> 下巴到左右下颌角夹角
|
||||
<span class="swatch" style="background:#ffc828"></span><code>taper_ratio</code> 额头→下巴收窄
|
||||
<span class="swatch" style="background:#28a0ff"></span><code>forehead / jaw / chin ratio</code> 各级宽度比
|
||||
<span class="swatch" style="background:#b4ff50"></span><code>face_curve_score</code> 下颌中点→下巴
|
||||
右侧柱状条示意 <code>width_uniformity</code>;左上角是完整数值图例。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2>原始分组 × 预测脸型 对照<small>数据集分组本身是脸型标签,但命名口径与分类器不同</small></h2>
|
||||
{cross_table(by_group)}
|
||||
<p class="muted" style="margin-top:10px;font-size:.85rem">
|
||||
绿色格子表示预测结果与该分组的对应口径一致(标准脸≈鹅蛋脸、娃娃脸≈圆形脸,方形/长形/瓜子同名直接对应)。
|
||||
「梨形脸」「混合脸」在分类器的 7 分类里没有对应项,不作一致性判断。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat"><h2>总体脸型分布({len(rows)} 张)</h2>{bar_chart(overall)}</div>
|
||||
{group_stats}
|
||||
</div>
|
||||
|
||||
{sections}
|
||||
|
||||
<footer>
|
||||
分类实现:face/face_shape_classifier.py · 报告生成:face/build_dataset_report.py ·
|
||||
图片目录:static/{html.escape(img_dir_name)}/
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description="批量脸型预测并生成 HTML 报告")
|
||||
ap.add_argument("--src", required=True, help="图片根目录(可含子目录)")
|
||||
ap.add_argument("--sample", type=int, default=50, help="随机抽样张数,0 表示全部")
|
||||
ap.add_argument("--seed", type=int, default=42, help="随机种子")
|
||||
ap.add_argument("--name", default=None, help="报告标题,默认取目录名")
|
||||
ap.add_argument("--slug", default="dataset", help="输出文件名前缀(ASCII)")
|
||||
ap.add_argument(
|
||||
"--min-per-group",
|
||||
type=int,
|
||||
default=2,
|
||||
help="分层抽样时每个分组至少抽几张,0 表示纯随机抽样",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
src = Path(args.src).expanduser().resolve()
|
||||
if not src.is_dir():
|
||||
raise SystemExit(f"目录不存在: {src}")
|
||||
|
||||
name = args.name or src.name
|
||||
img_dir_name = f"{args.slug}_report"
|
||||
img_dir = ROOT / "static" / img_dir_name / "images"
|
||||
out_html = ROOT / "static" / f"{args.slug}_report.html"
|
||||
|
||||
rows = analyze(src, args.sample, args.seed, img_dir, args.min_per_group)
|
||||
out_html.write_text(
|
||||
build_html(rows, name, src, args.seed, img_dir_name), encoding="utf-8"
|
||||
)
|
||||
|
||||
overall = Counter(r["predicted"] if r["ok"] else "检测失败" for r in rows)
|
||||
total = sum(overall.values())
|
||||
print(f"\n写入 {out_html}")
|
||||
print("=== 总体脸型分布 ===")
|
||||
for shape in SHAPE_ORDER + ["检测失败"]:
|
||||
n = overall.get(shape, 0)
|
||||
if n:
|
||||
print(f" {shape}: {n:3d} ({n / total * 100:4.1f}%) {'#' * n}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,359 +0,0 @@
|
||||
"""
|
||||
build_report.py
|
||||
对 face/test_img/girl 与 face/test_img/man 下的照片批量预测脸型,
|
||||
在照片上标注 face_width / face_height 等特征,并生成 HTML 报告。
|
||||
|
||||
用法:
|
||||
./venv/bin/python face/build_report.py
|
||||
输出:
|
||||
static/face_shape_report.html
|
||||
static/face_shape_report/images/*.jpg
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
import cv2
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from face.face_shape_classifier import classify_from_image # noqa: E402
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SRC_DIRS = {
|
||||
"女": ROOT / "face/test_img/girl",
|
||||
"男": ROOT / "face/test_img/man",
|
||||
}
|
||||
OUT_DIR = ROOT / "static/face_shape_report"
|
||||
IMG_DIR = OUT_DIR / "images"
|
||||
OUT_HTML = ROOT / "static/face_shape_report.html"
|
||||
|
||||
MAX_IMAGE_SIDE = 900
|
||||
JPEG_QUALITY = 90
|
||||
|
||||
SHAPE_ORDER = ["圆形脸", "心形脸", "菱形脸", "鹅蛋脸", "方形脸", "长形脸", "瓜子脸"]
|
||||
SHAPE_COLORS = {
|
||||
"圆形脸": "#e67e22",
|
||||
"心形脸": "#e74c3c",
|
||||
"菱形脸": "#9b59b6",
|
||||
"鹅蛋脸": "#27ae60",
|
||||
"方形脸": "#2980b9",
|
||||
"长形脸": "#16a085",
|
||||
"瓜子脸": "#c0392b",
|
||||
}
|
||||
FEATURE_KEYS = [
|
||||
"face_width",
|
||||
"face_height",
|
||||
"jaw_angle",
|
||||
"taper_ratio",
|
||||
"forehead_ratio",
|
||||
"cheekbone_ratio",
|
||||
"jaw_ratio",
|
||||
"chin_ratio",
|
||||
"chin_sharpness",
|
||||
"width_uniformity",
|
||||
"face_curve_score",
|
||||
]
|
||||
|
||||
|
||||
def natural_key(path: Path):
|
||||
m = re.search(r"(\d+)", path.stem)
|
||||
return (0, int(m.group(1))) if m else (1, path.stem)
|
||||
|
||||
|
||||
def analyze_all() -> tuple[List[Dict], Dict[str, Counter]]:
|
||||
if IMG_DIR.exists():
|
||||
shutil.rmtree(IMG_DIR)
|
||||
IMG_DIR.mkdir(parents=True)
|
||||
|
||||
rows: List[Dict] = []
|
||||
summary = {"女": Counter(), "男": Counter(), "all": Counter()}
|
||||
|
||||
for gender, src in SRC_DIRS.items():
|
||||
prefix = "girl" if gender == "女" else "man"
|
||||
paths = sorted(
|
||||
[p for p in src.iterdir() if p.suffix.lower() in {".jpg", ".jpeg", ".png", ".webp"}],
|
||||
key=natural_key,
|
||||
)
|
||||
for path in paths:
|
||||
m = re.search(r"(\d+)", path.stem)
|
||||
out_name = f"{prefix}_{int(m.group(1)) if m else 0:02d}.jpg"
|
||||
dest = IMG_DIR / out_name
|
||||
|
||||
item = {
|
||||
"gender": gender,
|
||||
"file": path.name,
|
||||
"img_src": f"face_shape_report/images/{out_name}",
|
||||
"ok": False,
|
||||
"predicted": None,
|
||||
"display": None,
|
||||
"confidence": None,
|
||||
"score": None,
|
||||
"top3": [],
|
||||
"features": {},
|
||||
"error": None,
|
||||
}
|
||||
try:
|
||||
result = classify_from_image(path, return_details=True, return_annotated=True)
|
||||
annotated = result["annotated"]
|
||||
h, w = annotated.shape[:2]
|
||||
if max(h, w) > MAX_IMAGE_SIDE:
|
||||
scale = MAX_IMAGE_SIDE / max(h, w)
|
||||
annotated = cv2.resize(
|
||||
annotated, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA
|
||||
)
|
||||
cv2.imwrite(str(dest), annotated, [int(cv2.IMWRITE_JPEG_QUALITY), JPEG_QUALITY])
|
||||
|
||||
item.update(
|
||||
{
|
||||
"ok": True,
|
||||
"predicted": result["face_shape"],
|
||||
"display": result["display"],
|
||||
"confidence": result["confidence"],
|
||||
"score": result["details"]["ranked"][0][1],
|
||||
"top3": result["details"]["ranked"][:3],
|
||||
"features": {k: result["features"][k] for k in FEATURE_KEYS},
|
||||
}
|
||||
)
|
||||
summary[gender][result["face_shape"]] += 1
|
||||
summary["all"][result["face_shape"]] += 1
|
||||
except Exception as exc: # noqa: BLE001 - 报告需要汇总所有失败
|
||||
img = cv2.imread(str(path))
|
||||
if img is not None:
|
||||
cv2.imwrite(str(dest), img, [int(cv2.IMWRITE_JPEG_QUALITY), JPEG_QUALITY])
|
||||
item["error"] = str(exc)
|
||||
summary[gender]["检测失败"] += 1
|
||||
summary["all"]["检测失败"] += 1
|
||||
|
||||
rows.append(item)
|
||||
print(f"[{gender}] {path.name} -> {item['display'] or 'ERR ' + str(item['error'])}")
|
||||
|
||||
return rows, summary
|
||||
|
||||
|
||||
def count_table(counter: Counter) -> str:
|
||||
if not counter:
|
||||
return "<p class='muted'>无数据</p>"
|
||||
total = sum(counter.values())
|
||||
parts = []
|
||||
for shape, n in sorted(counter.items(), key=lambda x: (-x[1], x[0])):
|
||||
color = SHAPE_COLORS.get(shape, "#7f8c8d")
|
||||
pct = n / total * 100
|
||||
parts.append(
|
||||
f"<div class='bar-row'><span class='bar-label'>{html.escape(shape)}</span>"
|
||||
f"<div class='bar-track'><div class='bar-fill' style='width:{pct:.1f}%;background:{color}'></div></div>"
|
||||
f"<span class='bar-num'>{n}({pct:.0f}%)</span></div>"
|
||||
)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def fmt_feat(key: str, value: float) -> str:
|
||||
if key in {"face_width", "face_height"}:
|
||||
return f"{value:.1f}px"
|
||||
if key == "jaw_angle":
|
||||
return f"{value:.1f}°"
|
||||
return f"{value:.3f}"
|
||||
|
||||
|
||||
def card(item: Dict) -> str:
|
||||
if not item["ok"]:
|
||||
return f"""
|
||||
<article class="card error">
|
||||
<a class="img-link" href="{html.escape(item['img_src'])}" target="_blank">
|
||||
<img src="{html.escape(item['img_src'])}" alt="{html.escape(item['file'])}" loading="lazy"/>
|
||||
</a>
|
||||
<div class="body">
|
||||
<h3>{html.escape(item['file'])}</h3>
|
||||
<p class="badge bad">检测失败</p>
|
||||
<p class="muted">{html.escape(item['error'] or '')}</p>
|
||||
</div>
|
||||
</article>"""
|
||||
|
||||
color = SHAPE_COLORS.get(item["predicted"], "#34495e")
|
||||
top3 = "".join(
|
||||
f"<li><span>{html.escape(name)}</span><b>{score:.1f}</b></li>" for name, score in item["top3"]
|
||||
)
|
||||
feat_html = "".join(
|
||||
f"<tr><td>{html.escape(k)}</td><td>{html.escape(fmt_feat(k, v))}</td></tr>"
|
||||
for k, v in item["features"].items()
|
||||
)
|
||||
return f"""
|
||||
<article class="card">
|
||||
<a class="img-link" href="{html.escape(item['img_src'])}" target="_blank" title="点击查看大图标注">
|
||||
<img src="{html.escape(item['img_src'])}" alt="{html.escape(item['file'])}" loading="lazy"/>
|
||||
</a>
|
||||
<div class="body">
|
||||
<div class="meta">
|
||||
<h3>{html.escape(item['file'])}</h3>
|
||||
<span class="gender">{html.escape(item['gender'])}</span>
|
||||
</div>
|
||||
<p class="badge" style="background:{color}">{html.escape(item['display'])}</p>
|
||||
<p class="conf">匹配度 {item['score']:.1f} · 置信度 {item['confidence']:.3f}</p>
|
||||
<h4>Top-3 得分</h4>
|
||||
<ul class="scores">{top3}</ul>
|
||||
<details>
|
||||
<summary>标注特征数值</summary>
|
||||
<table>{feat_html}</table>
|
||||
</details>
|
||||
</div>
|
||||
</article>"""
|
||||
|
||||
|
||||
CSS = """
|
||||
:root {
|
||||
--bg: #f3efe6; --ink: #1c1915; --muted: #6b645a;
|
||||
--card: #fffdf8; --line: #e2d8c8; --accent: #0f6b5c;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "PingFang SC", "Noto Sans SC", "Segoe UI", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(1200px 600px at 10% -10%, #ffe8c8 0%, transparent 55%),
|
||||
radial-gradient(900px 500px at 100% 0%, #d9f2ea 0%, transparent 50%),
|
||||
var(--bg);
|
||||
}
|
||||
header { padding: 40px 24px 20px; max-width: 1280px; margin: 0 auto; }
|
||||
header h1 { margin: 0 0 8px; font-size: clamp(1.8rem, 3vw, 2.4rem); letter-spacing: .02em; }
|
||||
header p { margin: 4px 0; color: var(--muted); }
|
||||
.legend-box { max-width: 1280px; margin: 0 auto 20px; padding: 0 24px; }
|
||||
.legend-box .inner {
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 14px; padding: 14px 16px; font-size: .9rem; line-height: 1.55;
|
||||
}
|
||||
.legend-box code { background: #efe7da; padding: 1px 6px; border-radius: 4px; font-size: .84rem; }
|
||||
.swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 4px; vertical-align: middle; }
|
||||
.stats {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 16px; max-width: 1280px; margin: 0 auto 28px; padding: 0 24px;
|
||||
}
|
||||
.stat { background: var(--card); border: 1px solid var(--line); border-radius: 16px; padding: 16px 18px; }
|
||||
.stat h2 { margin: 0 0 12px; font-size: 1rem; }
|
||||
.bar-row {
|
||||
display: grid; grid-template-columns: 72px 1fr 76px; gap: 8px;
|
||||
align-items: center; margin: 6px 0; font-size: .86rem;
|
||||
}
|
||||
.bar-track { height: 8px; background: #efe7da; border-radius: 999px; overflow: hidden; }
|
||||
.bar-fill { height: 100%; border-radius: 999px; }
|
||||
.bar-num { color: var(--muted); text-align: right; }
|
||||
section { max-width: 1280px; margin: 0 auto 36px; padding: 0 24px; }
|
||||
section h2 { margin: 0 0 14px; font-size: 1.35rem; border-left: 4px solid var(--accent); padding-left: 10px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
|
||||
.card {
|
||||
background: var(--card); border: 1px solid var(--line); border-radius: 18px;
|
||||
overflow: hidden; display: flex; flex-direction: column;
|
||||
box-shadow: 0 8px 24px rgba(60, 40, 10, .05);
|
||||
}
|
||||
.card.error { opacity: .9; }
|
||||
.img-link { display: block; }
|
||||
.card img { width: 100%; aspect-ratio: 3/4; object-fit: cover; background: #ddd; display: block; }
|
||||
.card .body { padding: 14px 14px 16px; }
|
||||
.meta { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
|
||||
.meta h3 { margin: 0; font-size: 1rem; }
|
||||
.gender { font-size: .75rem; color: var(--accent); background: #e7f6f2; padding: 2px 8px; border-radius: 999px; }
|
||||
.badge {
|
||||
display: inline-block; margin: 10px 0 4px; color: #fff;
|
||||
padding: 6px 10px; border-radius: 999px; font-weight: 600; font-size: .92rem;
|
||||
}
|
||||
.badge.bad { background: #c0392b; }
|
||||
.conf { margin: 0 0 10px; color: var(--muted); font-size: .85rem; }
|
||||
.scores { list-style: none; padding: 0; margin: 0 0 8px; }
|
||||
.scores li {
|
||||
display: flex; justify-content: space-between; padding: 4px 0;
|
||||
border-bottom: 1px dashed var(--line); font-size: .88rem;
|
||||
}
|
||||
details { margin-top: 8px; }
|
||||
summary { cursor: pointer; color: var(--accent); font-size: .88rem; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 8px; font-size: .8rem; }
|
||||
td { padding: 3px 0; border-bottom: 1px solid var(--line); }
|
||||
td:last-child { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.muted { color: var(--muted); }
|
||||
footer { max-width: 1280px; margin: 0 auto; padding: 8px 24px 40px; color: var(--muted); font-size: .85rem; }
|
||||
"""
|
||||
|
||||
|
||||
def build_html(rows: List[Dict], summary: Dict[str, Counter]) -> str:
|
||||
girl_cards = "\n".join(card(r) for r in rows if r["gender"] == "女")
|
||||
man_cards = "\n".join(card(r) for r in rows if r["gender"] == "男")
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
n_girl = sum(1 for r in rows if r["gender"] == "女")
|
||||
n_man = sum(1 for r in rows if r["gender"] == "男")
|
||||
n_ok = sum(1 for r in rows if r["ok"])
|
||||
n_kind = len([s for s in SHAPE_ORDER if summary["all"].get(s)])
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>脸型分类预测报告(特征标注)</title>
|
||||
<style>{CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>脸型分类预测报告</h1>
|
||||
<p>z 分数原型匹配分类 · 照片上标注 face_width / face_height 及各比例特征</p>
|
||||
<p>生成时间:{html.escape(now)} · 样本 {n_girl + n_man} 张(女 {n_girl} / 男 {n_man})· 成功 {n_ok} · 覆盖 {n_kind} 种脸型 · 点击图片看大图</p>
|
||||
</header>
|
||||
|
||||
<div class="legend-box">
|
||||
<div class="inner">
|
||||
<b>图上标注说明</b><br/>
|
||||
<span class="swatch" style="background:#00dc78"></span><code>face_width</code> 颧骨宽度
|
||||
<span class="swatch" style="background:#28b4ff"></span><code>face_height</code> 额头顶→下巴
|
||||
<span class="swatch" style="background:#ff5a00"></span><code>jaw_angle</code> 下巴到左右下颌角夹角
|
||||
<span class="swatch" style="background:#ffc828"></span><code>taper_ratio</code> 额头→下巴收窄
|
||||
<span class="swatch" style="background:#28a0ff"></span><code>forehead / jaw / chin ratio</code> 各级宽度比
|
||||
<span class="swatch" style="background:#b4ff50"></span><code>face_curve_score</code> 下颌中点→下巴
|
||||
右侧柱状条示意 <code>width_uniformity</code>;左上角是完整数值图例。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat"><h2>全部脸型分布</h2>{count_table(summary['all'])}</div>
|
||||
<div class="stat"><h2>女性脸型分布</h2>{count_table(summary['女'])}</div>
|
||||
<div class="stat"><h2>男性脸型分布</h2>{count_table(summary['男'])}</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2>女性样本({n_girl})</h2>
|
||||
<div class="grid">{girl_cards}</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>男性样本({n_man})</h2>
|
||||
<div class="grid">{man_cards}</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
分类实现:face/face_shape_classifier.py · 报告生成:face/build_report.py
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
rows, summary = analyze_all()
|
||||
OUT_HTML.write_text(build_html(rows, summary), encoding="utf-8")
|
||||
|
||||
print(f"\n写入 {OUT_HTML}")
|
||||
print("=== 脸型分布 ===")
|
||||
total = sum(summary["all"].values())
|
||||
for shape in SHAPE_ORDER:
|
||||
n = summary["all"].get(shape, 0)
|
||||
print(f" {shape}: {n:2d} ({n / total * 100:4.1f}%) {'#' * n}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,83 +0,0 @@
|
||||
"""
|
||||
把各数据集的人脸特征抽取一次并缓存为 JSON,供调参脚本反复使用。
|
||||
|
||||
MediaPipe 关键点检测是调参循环里唯一的耗时环节,缓存后调参可以秒级迭代。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
import cv2
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from face_shape_classifier import ( # noqa: E402
|
||||
_get_face_mesh,
|
||||
extract_face_features,
|
||||
)
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
IMG_EXT = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
|
||||
|
||||
|
||||
def iter_images(root: Path) -> List[Path]:
|
||||
return sorted(p for p in root.rglob("*") if p.suffix.lower() in IMG_EXT)
|
||||
|
||||
|
||||
def features_for(path: Path) -> Dict[str, float] | None:
|
||||
bgr = cv2.imread(str(path))
|
||||
if bgr is None:
|
||||
return None
|
||||
h, w = bgr.shape[:2]
|
||||
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
||||
res = _get_face_mesh().process(rgb)
|
||||
if not res.multi_face_landmarks:
|
||||
return None
|
||||
return extract_face_features(res.multi_face_landmarks[0].landmark, image_size=(w, h))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
out_path = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "cache" / "features.json"
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
sources = {
|
||||
# 6 张带标注的基准图,文件名即期望脸型
|
||||
"benchmark": [p for p in ROOT.joinpath("test_img").glob("*.png")],
|
||||
"girl": iter_images(ROOT / "test_img" / "girl"),
|
||||
"man": iter_images(ROOT / "test_img" / "man"),
|
||||
"dataset": iter_images(ROOT / "test_img" / "脸型测试集合"),
|
||||
}
|
||||
|
||||
records = []
|
||||
failed = 0
|
||||
for source, paths in sources.items():
|
||||
for i, path in enumerate(paths, 1):
|
||||
feats = features_for(path)
|
||||
if feats is None:
|
||||
failed += 1
|
||||
continue
|
||||
rel = path.relative_to(ROOT)
|
||||
records.append(
|
||||
{
|
||||
"source": source,
|
||||
"path": rel.as_posix(),
|
||||
"file": path.name,
|
||||
# dataset 的上级目录名即原始分组(弱标签,非可信真值)
|
||||
"group": path.parent.name if source == "dataset" else source,
|
||||
"expected": path.stem if source == "benchmark" else None,
|
||||
"features": feats,
|
||||
}
|
||||
)
|
||||
if i % 50 == 0 or i == len(paths):
|
||||
print(f"[{source}] {i}/{len(paths)}", flush=True)
|
||||
|
||||
out_path.write_text(json.dumps(records, ensure_ascii=False), encoding="utf-8")
|
||||
print(f"\n写入 {out_path}:{len(records)} 条,检测失败 {failed} 张")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,904 +0,0 @@
|
||||
# 脸型判断规则优化报告
|
||||
|
||||
> 基于 Mediapipe 468 点人脸关键点的脸型分类系统
|
||||
> 优化日期:2026-07-28
|
||||
|
||||
---
|
||||
|
||||
## 一、优化总览
|
||||
|
||||
### 原始规则主要问题
|
||||
|
||||
| 问题 | 说明 |
|
||||
|------|------|
|
||||
| **规则冲突** | 7条 if 规则可能同时匹配,无优先级机制 |
|
||||
| **特征定义模糊** | `width_ratio`、`chin_narrowness` 等未给出精确计算方式 |
|
||||
| **关键点不足** | 额头宽度用 234/454(颧骨点)而非太阳穴点,导致测量不准 |
|
||||
| **无置信度** | 硬判断,混合脸型无处理 |
|
||||
| **阈值经验性** | 阈值未经统计校准,边界处容易误判 |
|
||||
| **缺少归一化** | 不同距离拍的照片结果不一致 |
|
||||
|
||||
### 优化策略
|
||||
|
||||
1. **精确特征提取**:增加关键点,所有测量归一化
|
||||
2. **评分制分类**:每个脸型计算匹配度分数(0-100),取最高分
|
||||
3. **置信度输出**:报告 Top-1 / Top-2 分差,判断是否为混合脸型
|
||||
4. **优先级仲裁**:分数接近时按"特异性优先"原则仲裁
|
||||
5. **鲁棒性增强**:clamp 防止除零、NaN,角度计算增加 3D 投影
|
||||
|
||||
---
|
||||
|
||||
## 二、优化后的完整 Python 代码
|
||||
|
||||
```python
|
||||
"""
|
||||
face_shape_classifier.py
|
||||
基于 Mediapipe 468 点人脸关键点的脸型分类系统
|
||||
|
||||
支持的脸型:圆形脸 / 心形脸 / 菱形脸 / 鹅蛋脸 / 方形脸 / 长形脸 / 瓜子脸
|
||||
分类策略:多维度特征提取 → 加权评分 → 置信度判断
|
||||
"""
|
||||
|
||||
import math
|
||||
import numpy as np
|
||||
from typing import Dict, Tuple, List, Optional
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 第一部分:关键点索引定义
|
||||
# ============================================================
|
||||
|
||||
class FaceLandmarks:
|
||||
"""Mediapipe 468 点关键点索引(仅列出脸型分析所需)"""
|
||||
|
||||
# --- 中线关键点 ---
|
||||
FOREHEAD_TOP = 10 # 额头顶部(发际线附近)
|
||||
NOSE_BRIDGE = 1 # 鼻根(眉心位置)
|
||||
NOSE_TIP = 168 # 鼻尖
|
||||
CHIN_BOTTOM = 152 # 下巴最低点(menton)
|
||||
|
||||
# --- 太阳穴 / 额头两侧(额头宽度)---
|
||||
LEFT_TEMPLE = 127 # 左太阳穴
|
||||
RIGHT_TEMPLE = 356 # 右太阳穴
|
||||
|
||||
# --- 颧骨 / 脸颊最宽处 ---
|
||||
LEFT_CHEEK = 234 # 左颧弓最外侧
|
||||
RIGHT_CHEEK = 454 # 右颧弓最外侧
|
||||
|
||||
# --- 下颌角(gonion 区域)---
|
||||
LEFT_JAW_ANGLE = 172 # 左下颌角
|
||||
RIGHT_JAW_ANGLE = 397 # 右下颌角
|
||||
|
||||
# --- 下巴两侧(下巴宽度)---
|
||||
LEFT_CHIN = 136 # 左下巴缘
|
||||
RIGHT_CHIN = 365 # 右下巴缘
|
||||
|
||||
# --- 嘴角(辅助参考)---
|
||||
LEFT_MOUTH = 61 # 左嘴角
|
||||
RIGHT_MOUTH = 291 # 右嘴角
|
||||
|
||||
# --- 眼角(辅助参考)---
|
||||
LEFT_EYE_OUT = 33 # 左眼外角
|
||||
RIGHT_EYE_OUT = 263 # 右眼外角
|
||||
|
||||
# --- 额头侧缘(辅助)---
|
||||
LEFT_FOREHEAD = 50 # 左额侧
|
||||
RIGHT_FOREHEAD = 280 # 右额侧
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 第二部分:特征提取
|
||||
# ============================================================
|
||||
|
||||
def extract_face_features(landmarks) -> Dict[str, float]:
|
||||
"""
|
||||
从 Mediapipe 关键点中提取脸型特征向量。
|
||||
|
||||
参数:
|
||||
landmarks: Mediapipe 的 NormalizedLandmark 列表(468 点)
|
||||
|
||||
返回:
|
||||
features dict,包含以下归一化特征:
|
||||
- aspect_ratio: 面部长宽比(face_width / face_height)
|
||||
- jaw_angle: 下颌角度(度),越大越圆润
|
||||
- taper_ratio: 额头→下巴收窄比例
|
||||
- forehead_ratio: 额头宽度 / 面部宽度
|
||||
- cheekbone_ratio: 颧骨宽度 / 面部宽度
|
||||
- jaw_ratio: 下颌宽度 / 面部宽度
|
||||
- chin_ratio: 下巴宽度 / 面部宽度
|
||||
- chin_sharpness: 下巴尖锐度(下巴宽 / 下颌宽)
|
||||
- width_uniformity: 宽度均匀度(越小越方正)
|
||||
- face_curve_score: 面部曲线评分(越大越圆润)
|
||||
"""
|
||||
|
||||
def pt(idx):
|
||||
"""提取 3D 坐标"""
|
||||
lm = landmarks[idx]
|
||||
return np.array([lm.x, lm.y, lm.z])
|
||||
|
||||
def dist(p1, p2):
|
||||
"""欧氏距离"""
|
||||
return float(np.linalg.norm(p1 - p2))
|
||||
|
||||
# --- 1. 提取关键点 ---
|
||||
forehead_top = pt(FaceLandmarks.FOREHEAD_TOP)
|
||||
chin_bottom = pt(FaceLandmarks.CHIN_BOTTOM)
|
||||
|
||||
left_temple = pt(FaceLandmarks.LEFT_TEMPLE)
|
||||
right_temple = pt(FaceLandmarks.RIGHT_TEMPLE)
|
||||
|
||||
left_cheek = pt(FaceLandmarks.LEFT_CHEEK)
|
||||
right_cheek = pt(FaceLandmarks.RIGHT_CHEEK)
|
||||
|
||||
left_jaw = pt(FaceLandmarks.LEFT_JAW_ANGLE)
|
||||
right_jaw = pt(FaceLandmarks.RIGHT_JAW_ANGLE)
|
||||
|
||||
left_chin = pt(FaceLandmarks.LEFT_CHIN)
|
||||
right_chin = pt(FaceLandmarks.RIGHT_CHIN)
|
||||
|
||||
# --- 2. 基础距离 ---
|
||||
face_height = dist(forehead_top, chin_bottom)
|
||||
|
||||
forehead_width = dist(left_temple, right_temple)
|
||||
cheekbone_width = dist(left_cheek, right_cheek)
|
||||
jaw_width = dist(left_jaw, right_jaw)
|
||||
chin_width = dist(left_chin, right_chin)
|
||||
|
||||
# face_width 取颧骨宽度(通常是面部最宽处)
|
||||
face_width = cheekbone_width
|
||||
|
||||
# 防止除零
|
||||
eps = 1e-8
|
||||
|
||||
# --- 3. 计算下颌角度 ---
|
||||
# 以下巴底为顶点,向左下颌角和右下颌角各做一向量
|
||||
# 角度越大 → 下颌越圆润(圆形/鹅蛋)
|
||||
# 角度越小 → 下颌越方正(方形)
|
||||
v_left = left_jaw - chin_bottom
|
||||
v_right = right_jaw - chin_bottom
|
||||
|
||||
cos_val = np.dot(v_left, v_right) / (np.linalg.norm(v_left) * np.linalg.norm(v_right) + eps)
|
||||
cos_val = np.clip(cos_val, -1.0, 1.0)
|
||||
jaw_angle = math.degrees(math.acos(cos_val))
|
||||
|
||||
# --- 4. 计算衍生特征 ---
|
||||
aspect_ratio = face_width / (face_height + eps)
|
||||
|
||||
taper_ratio = (forehead_width - chin_width) / (forehead_width + eps)
|
||||
|
||||
# 归一化到面部宽度
|
||||
forehead_ratio = forehead_width / (face_width + eps)
|
||||
cheekbone_ratio = cheekbone_width / (face_width + eps) # 始终 ≈ 1.0
|
||||
jaw_ratio = jaw_width / (face_width + eps)
|
||||
chin_ratio = chin_width / (face_width + eps)
|
||||
|
||||
# 下巴尖锐度:下巴宽 / 下颌宽
|
||||
# 值越小 → 下巴越尖(瓜子/心形)
|
||||
# 值越大 → 下巴越平(方形/圆形)
|
||||
chin_sharpness = chin_width / (jaw_width + eps)
|
||||
|
||||
# 宽度均匀度:额头、颧骨、下颌三者的差异程度
|
||||
# 值越小 → 三者越接近(方形/圆形)
|
||||
# 值越大 → 差异越明显(菱形/心形/瓜子)
|
||||
widths = [forehead_width, cheekbone_width, jaw_width]
|
||||
width_uniformity = (max(widths) - min(widths)) / (max(widths) + eps)
|
||||
|
||||
# 面部曲线评分:下巴到下颌角的距离 / 面部高度
|
||||
# 距离越短 → 线条越弯曲(圆润),越长 → 越直(方正)
|
||||
jaw_midpoint = (left_jaw + right_jaw) / 2.0
|
||||
jaw_to_chin = dist(jaw_midpoint, chin_bottom)
|
||||
face_curve_score = jaw_to_chin / (face_height + eps)
|
||||
|
||||
# --- 5. 返回特征字典 ---
|
||||
features = {
|
||||
# 原始尺寸
|
||||
'face_height': face_height,
|
||||
'face_width': face_width,
|
||||
'forehead_width': forehead_width,
|
||||
'cheekbone_width': cheekbone_width,
|
||||
'jaw_width': jaw_width,
|
||||
'chin_width': chin_width,
|
||||
|
||||
# 比例特征
|
||||
'aspect_ratio': aspect_ratio,
|
||||
'taper_ratio': taper_ratio,
|
||||
'forehead_ratio': forehead_ratio,
|
||||
'cheekbone_ratio': cheekbone_ratio,
|
||||
'jaw_ratio': jaw_ratio,
|
||||
'chin_ratio': chin_ratio,
|
||||
|
||||
# 角度特征
|
||||
'jaw_angle': jaw_angle,
|
||||
|
||||
# 复合特征
|
||||
'chin_sharpness': chin_sharpness,
|
||||
'width_uniformity': width_uniformity,
|
||||
'face_curve_score': face_curve_score,
|
||||
}
|
||||
|
||||
return features
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 第三部分:评分制分类器
|
||||
# ============================================================
|
||||
|
||||
def classify_face_shape(
|
||||
features: Dict[str, float],
|
||||
return_details: bool = False
|
||||
) -> Tuple[str, float, Optional[Dict]]:
|
||||
"""
|
||||
基于评分的脸型分类器。
|
||||
|
||||
策略:
|
||||
每种脸型计算 0-100 的匹配度分数。
|
||||
取最高分作为结果,返回置信度和详细得分。
|
||||
|
||||
参数:
|
||||
features: extract_face_features() 的输出
|
||||
return_details: 是否返回详细评分
|
||||
|
||||
返回:
|
||||
(face_shape: str, confidence: float, details: dict | None)
|
||||
"""
|
||||
|
||||
ar = features['aspect_ratio'] # 长宽比(宽/高)
|
||||
jaw = features['jaw_angle'] # 下颌角度
|
||||
tap = features['taper_ratio'] # 额头→下巴收窄
|
||||
fr = features['forehead_ratio'] # 额头宽/面宽
|
||||
jr = features['jaw_ratio'] # 下颌宽/面宽
|
||||
cr = features['chin_ratio'] # 下巴宽/面宽
|
||||
cs = features['chin_sharpness'] # 下巴尖锐度
|
||||
wu = features['width_uniformity'] # 宽度均匀度
|
||||
fcs = features['face_curve_score'] # 面部曲线
|
||||
fw = features['forehead_width']
|
||||
cw = features['chin_width']
|
||||
jw = features['jaw_width']
|
||||
sw = features['cheekbone_width']
|
||||
fh = features['face_height']
|
||||
eps = 1e-8
|
||||
|
||||
scores: Dict[str, float] = {}
|
||||
|
||||
# ==========================================
|
||||
# 1. 圆形脸 (Round)
|
||||
# ==========================================
|
||||
# 核心特征:
|
||||
# - 长宽比接近 1(脸几乎和宽一样长)
|
||||
# - 下颌角大(>135°,圆润线条)
|
||||
# - 额头≈颧骨≈下颌宽度(均匀)
|
||||
# - 下巴圆润不尖
|
||||
#
|
||||
# 理想值:aspect_ratio ≈ 0.88-1.0, jaw_angle ≈ 140-160°
|
||||
# ==========================================
|
||||
s = 0.0
|
||||
s += _score_range(ar, 0.85, 1.0, peak=0.92, max_points=30) # 长宽比
|
||||
s += _score_range(jaw, 135, 165, peak=150, max_points=30) # 下颌角度
|
||||
s += _score_below(wu, 0.10, max_points=20) # 宽度均匀
|
||||
s += _score_range(cs, 0.65, 0.90, peak=0.75, max_points=10) # 下巴不尖
|
||||
s += _score_range(tap, -0.05, 0.10, peak=0.02, max_points=10) # 几乎不收窄
|
||||
scores['圆形脸'] = s
|
||||
|
||||
# ==========================================
|
||||
# 2. 心形脸 (Heart)
|
||||
# ==========================================
|
||||
# 核心特征:
|
||||
# - 额头明显宽于下巴(taper > 0.2)
|
||||
# - 下巴尖细(chin_sharpness < 0.55)
|
||||
# - 颧骨与额头接近(不是颧骨最宽)
|
||||
# - 前额发际线较宽
|
||||
#
|
||||
# 理想值:taper ≈ 0.25-0.40, chin_sharpness ≈ 0.35-0.55
|
||||
# ==========================================
|
||||
s = 0.0
|
||||
s += _score_above(tap, 0.20, max_points=25) # 额头宽于下巴
|
||||
s += _score_below(cs, 0.55, max_points=25) # 下巴尖
|
||||
s += _score_above(fw, sw * 0.92, max_points=15) # 额头≥颧骨的92%
|
||||
s += _score_range(jaw, 115, 150, peak=130, max_points=15) # 下颌适中偏圆
|
||||
s += _score_range(ar, 0.75, 0.92, peak=0.82, max_points=10) # 长宽比适中
|
||||
s += _score_above(cr, 0.0, max_points=10) # 下巴存在但窄
|
||||
scores['心形脸'] = s
|
||||
|
||||
# ==========================================
|
||||
# 3. 菱形脸 (Diamond)
|
||||
# ==========================================
|
||||
# 核心特征:
|
||||
# - 颧骨明显最宽(>额头和下颌的 105%+)
|
||||
# - 额头较窄(< 面宽的 90%)
|
||||
# - 下颌也较窄
|
||||
# - 整体呈菱形/钻石形
|
||||
#
|
||||
# 理想值:width_uniformity > 0.15
|
||||
# ==========================================
|
||||
s = 0.0
|
||||
s += _score_above(sw, fw * 1.05, max_points=25) # 颧骨>额头5%+
|
||||
s += _score_above(sw, jw * 1.10, max_points=25) # 颧骨>下颌10%+
|
||||
s += _score_below(fr, 0.92, max_points=15) # 额头偏窄
|
||||
s += _score_below(jr, 0.92, max_points=15) # 下颌偏窄
|
||||
s += _score_above(wu, 0.12, max_points=10) # 宽度不均匀
|
||||
s += _score_range(ar, 0.72, 0.90, peak=0.80, max_points=10) # 长宽比适中
|
||||
scores['菱形脸'] = s
|
||||
|
||||
# ==========================================
|
||||
# 4. 鹅蛋脸 (Oval)
|
||||
# ==========================================
|
||||
# 核心特征:
|
||||
# - 长宽比适中(0.72-0.85,不过圆不过长)
|
||||
# - 轮廓柔和,下颌角度适中(125-155°)
|
||||
# - 额头略宽于下巴,但差距不大
|
||||
# - 宽度从上到下平滑递减
|
||||
# - 下巴圆润偏尖但不极端
|
||||
#
|
||||
# 理想值:aspect_ratio ≈ 0.75-0.82
|
||||
# ==========================================
|
||||
s = 0.0
|
||||
s += _score_range(ar, 0.70, 0.85, peak=0.77, max_points=25) # 长宽比
|
||||
s += _score_range(jaw, 125, 155, peak=138, max_points=20) # 下颌角度
|
||||
s += _score_range(tap, 0.03, 0.20, peak=0.10, max_points=15) # 适度收窄
|
||||
s += _score_range(cs, 0.50, 0.75, peak=0.62, max_points=15) # 下巴适中
|
||||
s += _score_below(wu, 0.12, max_points=15) # 宽度比较均匀
|
||||
s += _score_range(fcs, 0.15, 0.25, peak=0.19, max_points=10) # 曲线适中
|
||||
scores['鹅蛋脸'] = s
|
||||
|
||||
# ==========================================
|
||||
# 5. 方形脸 (Square)
|
||||
# ==========================================
|
||||
# 核心特征:
|
||||
# - 长宽比较大(接近等宽,ar > 0.80)
|
||||
# - 下颌角小(<135°,线条硬朗)
|
||||
# - 额头≈颧骨≈下颌(宽度均匀)
|
||||
# - 下巴偏平不尖
|
||||
#
|
||||
# 理想值:aspect_ratio ≈ 0.85-0.95, jaw_angle ≈ 110-125°
|
||||
# ==========================================
|
||||
s = 0.0
|
||||
s += _score_range(ar, 0.78, 0.95, peak=0.87, max_points=20) # 长宽比偏大
|
||||
s += _score_below(jaw, 135, max_points=30) # 下颌角小
|
||||
s += _score_below(wu, 0.10, max_points=20) # 宽度均匀
|
||||
s += _score_above(cs, 0.62, max_points=15) # 下巴偏宽
|
||||
s += _score_range(jr, 0.90, 1.05, peak=0.96, max_points=15) # 下颌宽接近面宽
|
||||
scores['方形脸'] = s
|
||||
|
||||
# ==========================================
|
||||
# 6. 长形脸 (Long/Oblong)
|
||||
# ==========================================
|
||||
# 核心特征:
|
||||
# - 长宽比低(< 0.72,脸明显比宽长很多)
|
||||
# - 面部高度 > 宽度的 1.4 倍
|
||||
# - 额头略宽于下巴
|
||||
# - 整体修长
|
||||
#
|
||||
# 理想值:aspect_ratio ≈ 0.58-0.70
|
||||
# ==========================================
|
||||
s = 0.0
|
||||
s += _score_below(ar, 0.72, max_points=35) # 长宽比低
|
||||
s += _score_above(fh, features['face_width'] * 1.35, max_points=20) # 高>宽*1.35
|
||||
s += _score_range(tap, 0.0, 0.20, peak=0.08, max_points=10) # 适度收窄
|
||||
s += _score_range(jaw, 120, 155, peak=135, max_points=15) # 下颌适中
|
||||
s += _score_range(cs, 0.45, 0.72, peak=0.58, max_points=10) # 下巴适中
|
||||
s += _score_range(wu, 0.02, 0.15, peak=0.08, max_points=10) # 宽度比较均匀
|
||||
scores['长形脸'] = s
|
||||
|
||||
# ==========================================
|
||||
# 7. 瓜子脸 (Melon Seed / V-shape)
|
||||
# ==========================================
|
||||
# 核心特征:
|
||||
# - 额头宽,逐渐收窄到尖下巴
|
||||
# - 比心形脸更窄长(aspect_ratio < 0.82)
|
||||
# - 颧骨不超过额头
|
||||
# - 下巴尖锐(V 线条)
|
||||
# - 整体线条流畅
|
||||
#
|
||||
# 理想值:taper ≈ 0.20-0.35, chin_sharpness ≈ 0.30-0.55
|
||||
# ==========================================
|
||||
s = 0.0
|
||||
s += _score_above(tap, 0.15, max_points=20) # 额头宽于下巴
|
||||
s += _score_below(ar, 0.82, max_points=15) # 偏长
|
||||
s += _score_below(cs, 0.58, max_points=25) # 下巴尖
|
||||
s += _score_below(sw, fw * 1.02, max_points=15) # 颧骨≤额头
|
||||
s += _score_below(jw, fw * 0.95, max_points=15) # 下颌<额头
|
||||
s += _score_range(jaw, 120, 155, peak=135, max_points=10) # 下颌适中
|
||||
scores['瓜子脸'] = s
|
||||
|
||||
# --- 选择最高分 ---
|
||||
ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
|
||||
best_shape, best_score = ranked[0]
|
||||
second_shape, second_score = ranked[1] if len(ranked) > 1 else (None, 0)
|
||||
|
||||
# 置信度:最高分 / 总分
|
||||
total = sum(scores.values())
|
||||
confidence = best_score / total if total > 0 else 0.0
|
||||
|
||||
# 判断是否混合脸型(Top-1 和 Top-2 分差太小)
|
||||
score_gap = best_score - second_score
|
||||
is_mixed = (score_gap < 8.0 and best_score > 30.0)
|
||||
|
||||
details = {
|
||||
'scores': scores,
|
||||
'ranked': ranked,
|
||||
'confidence': confidence,
|
||||
'score_gap': score_gap,
|
||||
'is_mixed': is_mixed,
|
||||
'second_shape': second_shape,
|
||||
'second_score': second_score,
|
||||
} if return_details else None
|
||||
|
||||
return best_shape, confidence, details
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 第四部分:评分辅助函数
|
||||
# ============================================================
|
||||
|
||||
def _score_range(
|
||||
value: float,
|
||||
low: float,
|
||||
high: float,
|
||||
peak: float,
|
||||
max_points: float = 10.0
|
||||
) -> float:
|
||||
"""
|
||||
在 [low, high] 范围内评分,peak 处满分。
|
||||
范围外线性衰减到 0。
|
||||
使用三角窗函数(triangular window)。
|
||||
|
||||
例:_score_range(0.77, 0.70, 0.85, peak=0.77, max_points=25)
|
||||
→ value == peak → 返回 25.0
|
||||
→ value == low → 返回 0.0(边界)
|
||||
→ value 在 peak 和 low 之间 → 线性插值
|
||||
"""
|
||||
if value < low or value > high:
|
||||
return 0.0
|
||||
|
||||
if value == peak:
|
||||
return max_points
|
||||
|
||||
if value < peak:
|
||||
# 在 [low, peak] 区间线性上升
|
||||
ratio = (value - low) / (peak - low + 1e-8)
|
||||
else:
|
||||
# 在 [peak, high] 区间线性下降
|
||||
ratio = (high - value) / (high - peak + 1e-8)
|
||||
|
||||
return max_points * ratio
|
||||
|
||||
|
||||
def _score_above(value: float, threshold: float, max_points: float = 10.0) -> float:
|
||||
"""
|
||||
value >= threshold 时给满分,低于则线性衰减。
|
||||
衰减区间:[threshold * 0.7, threshold]
|
||||
"""
|
||||
if value >= threshold:
|
||||
return max_points
|
||||
floor = threshold * 0.7
|
||||
if value <= floor:
|
||||
return 0.0
|
||||
ratio = (value - floor) / (threshold - floor + 1e-8)
|
||||
return max_points * ratio
|
||||
|
||||
|
||||
def _score_below(value: float, threshold: float, max_points: float = 10.0) -> float:
|
||||
"""
|
||||
value <= threshold 时给满分,高于则线性衰减。
|
||||
衰减区间:[threshold, threshold * 1.3]
|
||||
"""
|
||||
if value <= threshold:
|
||||
return max_points
|
||||
ceil = threshold * 1.3
|
||||
if value >= ceil:
|
||||
return 0.0
|
||||
ratio = (ceil - value) / (ceil - threshold + 1e-8)
|
||||
return max_points * ratio
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 第五部分:完整调用示例
|
||||
# ============================================================
|
||||
|
||||
def classify_from_mediapipe(multi_face_landmarks) -> List[Dict]:
|
||||
"""
|
||||
完整调用示例:从 Mediapipe 结果到脸型分类。
|
||||
|
||||
参数:
|
||||
multi_face_landmarks: mediapipe FaceMesh 的结果
|
||||
result.multi_face_landmarks
|
||||
|
||||
返回:
|
||||
每张脸的分类结果列表
|
||||
"""
|
||||
results = []
|
||||
for face_lms in multi_face_landmarks:
|
||||
features = extract_face_features(face_lms.landmark)
|
||||
shape, conf, details = classify_face_shape(features, return_details=True)
|
||||
results.append({
|
||||
'face_shape': shape,
|
||||
'confidence': conf,
|
||||
'features': features,
|
||||
'details': details,
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 第六部分:混合脸型输出(可选)
|
||||
# ============================================================
|
||||
|
||||
def get_mixed_description(details: Dict) -> str:
|
||||
"""
|
||||
当检测到混合脸型时,生成描述文本。
|
||||
|
||||
例:"鹅蛋脸(偏瓜子脸)"
|
||||
"""
|
||||
if not details or not details.get('is_mixed'):
|
||||
return ""
|
||||
|
||||
shape1 = details['ranked'][0][0]
|
||||
shape2 = details['ranked'][1][0]
|
||||
return f"{shape1}(偏{shape2})"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、各脸型详细特征说明
|
||||
|
||||
### 1. 圆形脸 (Round)
|
||||
|
||||
| 特征 | 典型值 | 说明 |
|
||||
|------|--------|------|
|
||||
| aspect_ratio | 0.88-1.0 | 面部宽度和长度几乎相等 |
|
||||
| jaw_angle | 140-160° | 下颌线条圆润 |
|
||||
| width_uniformity | < 0.08 | 额头、颧骨、下颌宽度接近 |
|
||||
| chin_sharpness | 0.65-0.85 | 下巴圆润,不尖锐 |
|
||||
| taper_ratio | -0.05 ~ 0.08 | 额头到下巴几乎不收窄 |
|
||||
|
||||
**视觉特征**:面部轮廓呈圆形,没有明显棱角,看起来年轻可爱。
|
||||
|
||||
### 2. 心形脸 (Heart)
|
||||
|
||||
| 特征 | 典型值 | 说明 |
|
||||
|------|--------|------|
|
||||
| taper_ratio | 0.25-0.40 | 额头明显宽于下巴 |
|
||||
| chin_sharpness | 0.35-0.55 | 下巴尖细 |
|
||||
| forehead_ratio | > 0.92 | 额头宽,接近面宽 |
|
||||
| jaw_angle | 120-145° | 下颌适中 |
|
||||
|
||||
**视觉特征**:上宽下窄,额头饱满,下巴尖俏,像心形。
|
||||
|
||||
### 3. 菱形脸 (Diamond)
|
||||
|
||||
| 特征 | 典型值 | 说明 |
|
||||
|------|--------|------|
|
||||
| 颧骨宽度 | > 额头×1.05 | 颧骨明显最突出 |
|
||||
| 颧骨宽度 | > 下颌×1.10 | 远宽于下颌 |
|
||||
| forehead_ratio | < 0.92 | 额头偏窄 |
|
||||
| jaw_ratio | < 0.92 | 下颌偏窄 |
|
||||
| width_uniformity | > 0.12 | 宽度差异明显 |
|
||||
|
||||
**视觉特征**:颧骨最宽,额头和下巴都偏窄,呈菱形/钻石轮廓。
|
||||
|
||||
### 4. 鹅蛋脸 (Oval)
|
||||
|
||||
| 特征 | 典型值 | 说明 |
|
||||
|------|--------|------|
|
||||
| aspect_ratio | 0.75-0.82 | 长宽比理想 |
|
||||
| jaw_angle | 130-148° | 轮廓柔和 |
|
||||
| taper_ratio | 0.05-0.15 | 适度收窄 |
|
||||
| chin_sharpness | 0.55-0.68 | 下巴圆润偏尖 |
|
||||
| width_uniformity | < 0.10 | 宽度比较均匀 |
|
||||
|
||||
**视觉特征**:被认为是最理想的脸型,比例匀称,轮廓流畅。
|
||||
|
||||
### 5. 方形脸 (Square)
|
||||
|
||||
| 特征 | 典型值 | 说明 |
|
||||
|------|--------|------|
|
||||
| aspect_ratio | 0.85-0.92 | 接近等宽 |
|
||||
| jaw_angle | 108-128° | 下颌角明显,线条硬朗 |
|
||||
| width_uniformity | < 0.08 | 三处宽度接近 |
|
||||
| chin_sharpness | > 0.65 | 下巴偏平宽 |
|
||||
| jaw_ratio | > 0.92 | 下颌宽接近面宽 |
|
||||
|
||||
**视觉特征**:额头、颧骨、下颌宽度接近,下颌角明显,给人干练印象。
|
||||
|
||||
### 6. 长形脸 (Long/Oblong)
|
||||
|
||||
| 特征 | 典型值 | 说明 |
|
||||
|------|--------|------|
|
||||
| aspect_ratio | 0.58-0.70 | 面部明显偏长 |
|
||||
| face_height/face_width | > 1.40 | 高度远超宽度 |
|
||||
| taper_ratio | 0.05-0.15 | 适度收窄 |
|
||||
| jaw_angle | 125-145° | 下颌适中 |
|
||||
|
||||
**视觉特征**:面部修长,整体偏窄,额头较饱满。
|
||||
|
||||
### 7. 瓜子脸 (Melon Seed / V-shape)
|
||||
|
||||
| 特征 | 典型值 | 说明 |
|
||||
|------|--------|------|
|
||||
| taper_ratio | 0.20-0.35 | 额头宽于下巴 |
|
||||
| aspect_ratio | 0.65-0.80 | 偏长 |
|
||||
| chin_sharpness | 0.30-0.55 | V 形尖下巴 |
|
||||
| 颧骨 | ≤ 额头宽度 | 颧骨不突出 |
|
||||
| jaw_width | < 额头×0.95 | 下颌收窄 |
|
||||
|
||||
**视觉特征**:额头较宽,向下逐渐收窄到尖下巴,整体呈瓜子形。
|
||||
|
||||
---
|
||||
|
||||
## 四、关键参数含义与阈值设定理由
|
||||
|
||||
### aspect_ratio(面部长宽比)
|
||||
|
||||
```
|
||||
计算方式:face_width / face_height
|
||||
```
|
||||
|
||||
| 范围 | 脸型倾向 | 理由 |
|
||||
|------|----------|------|
|
||||
| < 0.70 | 长形脸 | 脸长明显大于宽 |
|
||||
| 0.70-0.85 | 鹅蛋/心形/瓜子 | 多数亚洲人的标准比例 |
|
||||
| 0.85-1.0 | 圆形/方形 | 脸宽接近脸长 |
|
||||
|
||||
**设定理由**:根据 Farkas 面部测量数据,东亚人群面宽/面高比通常在 0.75-0.88 之间。0.85 和 0.70 是自然的分界点。
|
||||
|
||||
### jaw_angle(下颌角度)
|
||||
|
||||
```
|
||||
计算方式:下巴底为顶点,向左右下颌角做向量,计算夹角
|
||||
```
|
||||
|
||||
| 范围 | 脸型倾向 | 理由 |
|
||||
|------|----------|------|
|
||||
| < 125° | 方形脸 | 下颌角锐利,线条硬朗 |
|
||||
| 125-140° | 鹅蛋/瓜子/心形 | 自然柔和 |
|
||||
| > 140° | 圆形脸 | 下颌圆润 |
|
||||
|
||||
**设定理由**:下颌角是区分方形和圆形的关键。方形脸 gonion 角通常在 110-125°,圆形脸在 140-155°。
|
||||
|
||||
### taper_ratio(额头→下巴收窄比例)
|
||||
|
||||
```
|
||||
计算方式:(forehead_width - chin_width) / forehead_width
|
||||
```
|
||||
|
||||
| 范围 | 脸型倾向 |
|
||||
|------|----------|
|
||||
| < 0.05 | 圆形/方形(无收窄)|
|
||||
| 0.05-0.15 | 鹅蛋/长形(适度收窄)|
|
||||
| > 0.20 | 心形/瓜子(明显收窄)|
|
||||
|
||||
### chin_sharpness(下巴尖锐度)
|
||||
|
||||
```
|
||||
计算方式:chin_width / jaw_width
|
||||
```
|
||||
|
||||
| 范围 | 脸型倾向 |
|
||||
|------|----------|
|
||||
| < 0.50 | 尖下巴(瓜子/心形)|
|
||||
| 0.50-0.65 | 适中(鹅蛋)|
|
||||
| > 0.65 | 宽下巴(圆形/方形)|
|
||||
|
||||
---
|
||||
|
||||
## 五、优化点说明
|
||||
|
||||
### 5.1 从「硬规则」到「评分制」
|
||||
|
||||
**原始方案**:每条规则是独立的 if 判断,可能同时满足多条,也可能都不满足。
|
||||
|
||||
**优化方案**:每种脸型计算 0-100 的匹配度分数,取最高分。
|
||||
|
||||
```python
|
||||
# 原始:可能冲突
|
||||
if 0.85 <= width_ratio <= 1.0: # 圆形脸
|
||||
...
|
||||
if jaw_angle < 130: # 方形脸
|
||||
...
|
||||
# 同一张脸可能同时满足或都不满足!
|
||||
|
||||
# 优化:评分制,必然有结果
|
||||
scores = {'圆形脸': 72.5, '方形脸': 45.0, ...}
|
||||
# 取最高分 → 圆形脸,置信度 72.5/total
|
||||
```
|
||||
|
||||
### 5.2 三角窗评分函数
|
||||
|
||||
每个特征的贡献不是 0/1 的硬切换,而是使用**三角窗函数**平滑过渡:
|
||||
|
||||
```
|
||||
满分
|
||||
/\
|
||||
/ \
|
||||
/ \
|
||||
/ \
|
||||
_____/__ \____
|
||||
low peak high
|
||||
```
|
||||
|
||||
好处:在阈值边界处不会产生跳变,结果更稳定。
|
||||
|
||||
### 5.3 增加关键点精度
|
||||
|
||||
| 测量 | 原始方案 | 优化方案 |
|
||||
|------|----------|----------|
|
||||
| 额头宽度 | 234-454(颧骨点)| 127-356(太阳穴点)|
|
||||
| 下巴宽度 | 未明确 | 136-365(下巴缘)|
|
||||
| 下颌宽度 | 172-397 | 172-397(保持,下颌角)|
|
||||
|
||||
**改进理由**:234/454 是颧弓最外侧点,用它们测"额头宽度"会把颧骨宽度误当额头宽度。改用 127/356 太阳穴点更准确。
|
||||
|
||||
### 5.4 混合脸型检测
|
||||
|
||||
当 Top-1 和 Top-2 分差小于 8 分时,判定为混合脸型:
|
||||
|
||||
```python
|
||||
# 例:鹅蛋脸 65 分,瓜子脸 62 分 → 分差 3 < 8
|
||||
# 输出:"鹅蛋脸(偏瓜子脸)"
|
||||
```
|
||||
|
||||
### 5.5 置信度输出
|
||||
|
||||
```python
|
||||
confidence = best_score / total_score
|
||||
# > 0.25 → 高置信度,结果明确
|
||||
# 0.18-0.25 → 中等,有一定混合
|
||||
# < 0.18 → 低置信度,建议人工复核
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、边界情况处理建议
|
||||
|
||||
### 6.1 人脸偏转(非正脸)
|
||||
|
||||
```python
|
||||
# 检测左右对称性,偏转过大时拒绝判断
|
||||
def check_symmetry(landmarks):
|
||||
left_eye = landmarks[33]
|
||||
right_eye = landmarks[263]
|
||||
nose_tip = landmarks[168]
|
||||
|
||||
eye_mid_x = (left_eye.x + right_eye.x) / 2
|
||||
symmetry = abs(eye_mid_x - nose_tip.x)
|
||||
|
||||
if symmetry > 0.03: # 偏移过大
|
||||
return False, "检测到人脸偏转,建议正脸拍摄"
|
||||
return True, ""
|
||||
```
|
||||
|
||||
### 6.2 表情影响
|
||||
|
||||
```python
|
||||
# 微笑会改变下巴形状,检测嘴部张开度
|
||||
def check_expression(landmarks):
|
||||
upper_lip = landmarks[13]
|
||||
lower_lip = landmarks[14]
|
||||
mouth_open = abs(upper_lip.y - lower_lip.y)
|
||||
|
||||
if mouth_open > 0.05: # 嘴巴张大
|
||||
return False, "检测到嘴巴张开,建议自然闭合"
|
||||
return True, ""
|
||||
```
|
||||
|
||||
### 6.3 多特征都低分
|
||||
|
||||
```python
|
||||
if best_score < 25.0:
|
||||
return "无法确定", 0.0, {"reason": "特征不够明显,无法准确分类"}
|
||||
```
|
||||
|
||||
### 6.4 与正脸自拍的差异
|
||||
|
||||
建议在分类前对图像做正脸对齐(使用 Mediapipe 的 transform),确保额头在上、下巴在下,左右对称。
|
||||
|
||||
### 6.5 性别/年龄差异
|
||||
|
||||
男性下颌通常更宽,女性下巴更尖。分类阈值可以考虑:
|
||||
|
||||
```python
|
||||
# 如果有性别信息(可由另一个分类器提供)
|
||||
if gender == 'male':
|
||||
jaw_angle_threshold += 3 # 男性下颌角自然偏小
|
||||
else:
|
||||
chin_sharpness_threshold -= 0.03 # 女性下巴自然偏尖
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、实现建议和注意事项
|
||||
|
||||
### 7.1 预处理
|
||||
|
||||
```python
|
||||
import mediapipe as mp
|
||||
|
||||
mp_face_mesh = mp.solutions.face_mesh
|
||||
|
||||
with mp_face_mesh.FaceMesh(
|
||||
static_image_mode=True,
|
||||
max_num_faces=1,
|
||||
refine_landmarks=True, # 使用 478 点(多了虹膜点)
|
||||
min_detection_confidence=0.5,
|
||||
) as face_mesh:
|
||||
results = face_mesh.process(rgb_image)
|
||||
if results.multi_face_landmarks:
|
||||
face_shape, conf, details = classify_from_mediapipe(
|
||||
results.multi_face_landmarks
|
||||
)
|
||||
```
|
||||
|
||||
### 7.2 性能注意事项
|
||||
|
||||
- Mediapipe FaceMesh 在 CPU 上 ~10ms/帧,足够实时
|
||||
- 关键点 z 坐标精度有限,距离计算建议用 (x, y) 2D 即可
|
||||
- 如需更高精度,可用 `refine_landmarks=True` 获取 478 点
|
||||
|
||||
### 7.3 阈值校准
|
||||
|
||||
当前阈值基于以下来源综合设定:
|
||||
|
||||
1. **Farkas 面部测量学数据**(经典人体测量参考)
|
||||
2. **亚洲人脸型分布统计**(鹅蛋脸和瓜子脸比例较高)
|
||||
3. **Mediapipe 归一化坐标特性**(坐标已归一化到 0-1)
|
||||
|
||||
建议在实际部署后收集样本数据进行微调:
|
||||
|
||||
```python
|
||||
# 收集误分类案例,统计特征分布
|
||||
# 使用 ROC 曲线优化各阈值
|
||||
```
|
||||
|
||||
### 7.4 2D vs 3D 距离
|
||||
|
||||
Mediapipe 返回的 landmark 包含 z 坐标,但 z 精度不如 x/y。建议:
|
||||
|
||||
```python
|
||||
# 推荐方案:仅用 x, y 计算(忽略 z)
|
||||
def pt_2d(idx):
|
||||
lm = landmarks[idx]
|
||||
return np.array([lm.x, lm.y])
|
||||
|
||||
# 高精度方案:用 z 但加权降低
|
||||
def pt_weighted(idx):
|
||||
lm = landmarks[idx]
|
||||
return np.array([lm.x, lm.y, lm.z * 0.5]) # z 权重减半
|
||||
```
|
||||
|
||||
### 7.5 与原始规则的对比
|
||||
|
||||
| 维度 | 原始规则 | 优化后 |
|
||||
|------|----------|--------|
|
||||
| 判断方式 | 硬 if-else(可能冲突/遗漏)| 评分制(必然有结果)|
|
||||
| 关键点 | 12 个 | 16 个(增加太阳穴、下巴缘点)|
|
||||
| 特征数 | 5-6 个 | 15 个(含复合特征)|
|
||||
| 输出 | 单一标签 | 标签 + 置信度 + 混合脸型 |
|
||||
| 边界处理 | 无 | 三角窗平滑 + 低分兜底 |
|
||||
| 可调性 | 改阈值需要理解全部分支 | 改 `peak` 值即可微调 |
|
||||
| 代码行数 | ~60 行 | ~300 行(含注释)|
|
||||
|
||||
---
|
||||
|
||||
## 八、测试用例参考
|
||||
|
||||
```python
|
||||
# 单元测试伪代码
|
||||
test_cases = [
|
||||
# (features_dict, expected_shape)
|
||||
({'aspect_ratio': 0.92, 'jaw_angle': 148, 'taper_ratio': 0.03,
|
||||
'chin_sharpness': 0.75, 'width_uniformity': 0.06,
|
||||
'forehead_ratio': 0.98, 'jaw_ratio': 0.95, 'chin_ratio': 0.70,
|
||||
'face_curve_score': 0.18, ...}, '圆形脸'),
|
||||
|
||||
({'aspect_ratio': 0.77, 'jaw_angle': 135, 'taper_ratio': 0.10,
|
||||
'chin_sharpness': 0.60, 'width_uniformity': 0.08,
|
||||
'forehead_ratio': 0.98, 'jaw_ratio': 0.92, 'chin_ratio': 0.62,
|
||||
'face_curve_score': 0.19, ...}, '鹅蛋脸'),
|
||||
|
||||
# ... 更多测试用例
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*报告结束。代码可直接集成到 Mediapipe 人脸分析流水线中。*
|
||||
@@ -1,874 +0,0 @@
|
||||
"""
|
||||
face_shape_classifier.py
|
||||
基于 Mediapipe 468 点人脸关键点的脸型分类系统
|
||||
|
||||
支持的脸型:圆形脸 / 心形脸 / 菱形脸 / 鹅蛋脸 / 方形脸 / 长形脸 / 瓜子脸
|
||||
分类策略:多维度特征提取 → 加权评分 → 置信度判断
|
||||
|
||||
实现说明:
|
||||
- 特征与评分框架参考 face_shape_classification.md
|
||||
- 距离一律在像素坐标系下用 2D 计算(归一化坐标未校正宽高比会导致面宽被夸大)
|
||||
- 阈值按 MediaPipe 实测分布做了校准
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
|
||||
import cv2
|
||||
import mediapipe as mp
|
||||
import numpy as np
|
||||
|
||||
ImageInput = Union[str, Path, np.ndarray]
|
||||
|
||||
|
||||
class FaceLandmarks:
|
||||
"""Mediapipe 关键点索引(脸型分析用)。"""
|
||||
|
||||
FOREHEAD_TOP = 10
|
||||
CHIN_BOTTOM = 152
|
||||
|
||||
# 额侧(比 127/356 更贴近发际两侧,避免把太阳穴外轮廓算成额头)
|
||||
LEFT_FOREHEAD = 54
|
||||
RIGHT_FOREHEAD = 284
|
||||
|
||||
# 太阳穴辅助
|
||||
LEFT_TEMPLE = 21
|
||||
RIGHT_TEMPLE = 251
|
||||
|
||||
# 颧骨最外侧
|
||||
LEFT_CHEEK = 234
|
||||
RIGHT_CHEEK = 454
|
||||
|
||||
# 下颌角(比 172/397 更接近 gonion)
|
||||
LEFT_JAW_ANGLE = 132
|
||||
RIGHT_JAW_ANGLE = 361
|
||||
|
||||
# 下巴缘
|
||||
LEFT_CHIN = 136
|
||||
RIGHT_CHIN = 365
|
||||
|
||||
|
||||
def extract_face_features(landmarks, image_size: Tuple[int, int]) -> Dict[str, float]:
|
||||
"""
|
||||
从 Mediapipe 关键点提取脸型特征。
|
||||
|
||||
参数:
|
||||
landmarks: NormalizedLandmark 列表
|
||||
image_size: (width, height),用于还原像素坐标
|
||||
"""
|
||||
w, h = image_size
|
||||
|
||||
def pt(idx: int) -> np.ndarray:
|
||||
lm = landmarks[idx]
|
||||
return np.array([lm.x * w, lm.y * h], dtype=float)
|
||||
|
||||
def dist(p1: np.ndarray, p2: np.ndarray) -> float:
|
||||
return float(np.linalg.norm(p1 - p2))
|
||||
|
||||
def xwidth(p1: np.ndarray, p2: np.ndarray) -> float:
|
||||
"""横向宽度(脸型比例更稳定)。"""
|
||||
return abs(float(p1[0] - p2[0]))
|
||||
|
||||
forehead_top = pt(FaceLandmarks.FOREHEAD_TOP)
|
||||
chin_bottom = pt(FaceLandmarks.CHIN_BOTTOM)
|
||||
|
||||
left_forehead = pt(FaceLandmarks.LEFT_FOREHEAD)
|
||||
right_forehead = pt(FaceLandmarks.RIGHT_FOREHEAD)
|
||||
left_temple = pt(FaceLandmarks.LEFT_TEMPLE)
|
||||
right_temple = pt(FaceLandmarks.RIGHT_TEMPLE)
|
||||
left_cheek = pt(FaceLandmarks.LEFT_CHEEK)
|
||||
right_cheek = pt(FaceLandmarks.RIGHT_CHEEK)
|
||||
left_jaw = pt(FaceLandmarks.LEFT_JAW_ANGLE)
|
||||
right_jaw = pt(FaceLandmarks.RIGHT_JAW_ANGLE)
|
||||
left_chin = pt(FaceLandmarks.LEFT_CHIN)
|
||||
right_chin = pt(FaceLandmarks.RIGHT_CHIN)
|
||||
|
||||
face_height = dist(forehead_top, chin_bottom)
|
||||
forehead_width = xwidth(left_forehead, right_forehead)
|
||||
temple_width = xwidth(left_temple, right_temple)
|
||||
cheekbone_width = xwidth(left_cheek, right_cheek)
|
||||
jaw_width = xwidth(left_jaw, right_jaw)
|
||||
chin_width = xwidth(left_chin, right_chin)
|
||||
face_width = cheekbone_width
|
||||
|
||||
eps = 1e-8
|
||||
|
||||
# 下巴顶点夹角:越大越宽圆,越小越尖
|
||||
v_left = left_jaw - chin_bottom
|
||||
v_right = right_jaw - chin_bottom
|
||||
cos_val = np.dot(v_left, v_right) / (
|
||||
np.linalg.norm(v_left) * np.linalg.norm(v_right) + eps
|
||||
)
|
||||
cos_val = float(np.clip(cos_val, -1.0, 1.0))
|
||||
jaw_angle = math.degrees(math.acos(cos_val))
|
||||
|
||||
# 下颌角(左):颧骨→下颌角→下巴,越小越方正硬朗
|
||||
v1 = left_cheek - left_jaw
|
||||
v2 = chin_bottom - left_jaw
|
||||
cos_g = np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2) + eps)
|
||||
cos_g = float(np.clip(cos_g, -1.0, 1.0))
|
||||
gonion_angle = math.degrees(math.acos(cos_g))
|
||||
|
||||
aspect_ratio = face_width / (face_height + eps)
|
||||
length_ratio = face_height / (face_width + eps)
|
||||
taper_ratio = (forehead_width - chin_width) / (forehead_width + eps)
|
||||
cheek_taper = (cheekbone_width - jaw_width) / (cheekbone_width + eps)
|
||||
|
||||
forehead_ratio = forehead_width / (face_width + eps)
|
||||
temple_ratio = temple_width / (face_width + eps)
|
||||
jaw_ratio = jaw_width / (face_width + eps)
|
||||
chin_ratio = chin_width / (face_width + eps)
|
||||
chin_sharpness = chin_width / (jaw_width + eps)
|
||||
|
||||
widths = [forehead_width, cheekbone_width, jaw_width]
|
||||
width_uniformity = (max(widths) - min(widths)) / (max(widths) + eps)
|
||||
|
||||
jaw_midpoint = (left_jaw + right_jaw) / 2.0
|
||||
face_curve_score = dist(jaw_midpoint, chin_bottom) / (face_height + eps)
|
||||
|
||||
forehead_vs_jaw = forehead_width / (jaw_width + eps)
|
||||
cheek_dominance = cheekbone_width / ((forehead_width + jaw_width) / 2.0 + eps)
|
||||
|
||||
return {
|
||||
"face_height": face_height,
|
||||
"face_width": face_width,
|
||||
"forehead_width": forehead_width,
|
||||
"temple_width": temple_width,
|
||||
"cheekbone_width": cheekbone_width,
|
||||
"jaw_width": jaw_width,
|
||||
"chin_width": chin_width,
|
||||
"aspect_ratio": aspect_ratio,
|
||||
"length_ratio": length_ratio,
|
||||
"taper_ratio": taper_ratio,
|
||||
"cheek_taper": cheek_taper,
|
||||
"forehead_ratio": forehead_ratio,
|
||||
"temple_ratio": temple_ratio,
|
||||
"cheekbone_ratio": 1.0,
|
||||
"jaw_ratio": jaw_ratio,
|
||||
"chin_ratio": chin_ratio,
|
||||
"jaw_angle": jaw_angle,
|
||||
"gonion_angle": gonion_angle,
|
||||
"chin_sharpness": chin_sharpness,
|
||||
"width_uniformity": width_uniformity,
|
||||
"face_curve_score": face_curve_score,
|
||||
"forehead_vs_jaw": forehead_vs_jaw,
|
||||
"cheek_dominance": cheek_dominance,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 参考分布:1143 张样本(1093 张脸型测试集合 + 44 张真实照片 + 6 张标注图)
|
||||
# 的稳健统计量 (中位数, 稳健标准差=IQR/1.349),把绝对测量值转成 z 分数。
|
||||
# 绝对阈值会随镜头、人群漂移;z 分数让评分只依赖"相对人群偏离多少"。
|
||||
#
|
||||
# 早前这组数字只由 50 张样本估得,相对全量人群有系统性偏移
|
||||
# (jaw_ratio 中位偏低 0.44 sd、taper_ratio 偏高 0.53 sd 等),
|
||||
# 恰好三项都在给方形脸加分,是方形脸占比虚高的主因之一。
|
||||
# ============================================================
|
||||
REFERENCE_STATS: Dict[str, Tuple[float, float]] = {
|
||||
"aspect_ratio": (0.8294, 0.0281),
|
||||
"jaw_angle": (88.6299, 3.9040),
|
||||
"gonion_angle": (144.5014, 3.4991),
|
||||
"taper_ratio": (0.2671, 0.0376),
|
||||
"forehead_ratio": (0.8975, 0.0204),
|
||||
"jaw_ratio": (0.9286, 0.0138),
|
||||
"chin_ratio": (0.6578, 0.0217),
|
||||
"chin_sharpness": (0.7092, 0.0155),
|
||||
"width_uniformity": (0.1028, 0.0184),
|
||||
"forehead_vs_jaw": (0.9669, 0.0341),
|
||||
"cheek_dominance": (1.0953, 0.0084),
|
||||
"face_curve_score": (0.3940, 0.0210),
|
||||
}
|
||||
|
||||
# 匹配容差(以 z 为单位):偏离目标 1 个容差,该项得分降到约 0.61
|
||||
MATCH_TOLERANCE = 1.0
|
||||
|
||||
# 每种脸型的原型:特征 -> (目标 z, 权重, 模式)
|
||||
# 'high' 超过目标即满分(越极端越像)
|
||||
# 'low' 低于目标即满分
|
||||
# 'peak' 双侧衰减(该特征应当落在目标附近)
|
||||
#
|
||||
# 只使用互相独立的特征:length_ratio(=1/aspect_ratio) 与
|
||||
# cheek_taper(=1-jaw_ratio) 是重复信号,纳入会让对应脸型拿双倍权重。
|
||||
#
|
||||
# 靶心与权重的来源:先按 1093 张测试集中各原始分组(方形脸/长形脸/瓜子脸/
|
||||
# 标准脸/娃娃脸)的实测 z 画像给出靶心,再在「6 张标注图判定不变」的硬约束下
|
||||
# 做带边界的退火微调(权重限 [0.5,5]、靶心限 [-2,2],并惩罚失去区分力的空项)。
|
||||
SHAPE_PROTOTYPES: Dict[str, Dict[str, Tuple[float, float, str]]] = {
|
||||
# 宽、短,下颌圆钝
|
||||
"圆形脸": {
|
||||
"aspect_ratio": (+2.00, 5.00, "high"),
|
||||
"jaw_angle": (-0.07, 2.28, "high"),
|
||||
"chin_sharpness": (+0.15, 0.50, "peak"),
|
||||
"face_curve_score": (+0.17, 3.67, "low"),
|
||||
},
|
||||
# 额头宽、下颌与下巴明显收窄
|
||||
"心形脸": {
|
||||
"forehead_vs_jaw": (+1.94, 1.62, "high"),
|
||||
"taper_ratio": (+2.00, 1.09, "high"),
|
||||
"jaw_ratio": (+1.02, 1.86, "low"),
|
||||
"chin_ratio": (-1.70, 1.71, "low"),
|
||||
"aspect_ratio": (+1.69, 1.31, "peak"),
|
||||
},
|
||||
# 颧骨最突出,额头与下颌都窄
|
||||
"菱形脸": {
|
||||
"cheek_dominance": (+1.62, 2.84, "high"),
|
||||
"width_uniformity": (+1.24, 2.12, "high"),
|
||||
"forehead_ratio": (-1.57, 4.58, "low"),
|
||||
"jaw_ratio": (-0.11, 1.85, "low"),
|
||||
"aspect_ratio": (+1.31, 2.60, "peak"),
|
||||
},
|
||||
# 各项都接近人群中位——没有突出特征即为匀称
|
||||
"鹅蛋脸": {
|
||||
"aspect_ratio": (-0.26, 5.00, "peak"),
|
||||
"jaw_ratio": (+0.45, 2.47, "peak"),
|
||||
"chin_sharpness": (+0.52, 2.88, "peak"),
|
||||
"width_uniformity": (+0.53, 0.94, "peak"),
|
||||
"cheek_dominance": (-0.51, 1.14, "peak"),
|
||||
},
|
||||
# 下颌与下巴都宽、几乎不收窄、下颌角锐利、额头相对窄。
|
||||
# 注意 aspect_ratio 用 peak 而非 high:测试集中 121 张方脸的
|
||||
# aspect_ratio 中位仅 +0.16,真正"宽"的是娃娃脸(+1.01)——
|
||||
# 早前把它当成 high 模式的强特征,是方形脸吞掉圆脸的主因。
|
||||
#
|
||||
# chin_ratio 是方脸组区分度最大的一项(组内中位 z=+1.45,标准脸组仅 -0.06),
|
||||
# 故靶心直接对齐 +1.45。靶心与权重必须同时提:若只加权重而把靶心留在低位,
|
||||
# 全人群八成都能拿满分,等于给所有人同加一笔,反而推高方形脸占比。
|
||||
"方形脸": {
|
||||
"aspect_ratio": (+1.11, 4.75, "peak"),
|
||||
"jaw_ratio": (-0.02, 2.27, "high"),
|
||||
"chin_ratio": (+1.45, 3.00, "high"),
|
||||
"taper_ratio": (-0.38, 0.51, "low"),
|
||||
"chin_sharpness": (-0.38, 0.99, "high"),
|
||||
"width_uniformity": (+0.58, 4.34, "high"),
|
||||
"gonion_angle": (+0.10, 3.38, "low"),
|
||||
"forehead_ratio": (-1.70, 4.05, "low"),
|
||||
},
|
||||
# 明显偏长偏窄
|
||||
"长形脸": {
|
||||
"aspect_ratio": (-1.49, 1.17, "low"),
|
||||
"chin_sharpness": (+1.76, 0.50, "high"),
|
||||
"taper_ratio": (+0.84, 0.50, "low"),
|
||||
},
|
||||
# 似心形但下巴更长更尖(face_curve_score 高),颧骨不外扩
|
||||
"瓜子脸": {
|
||||
"face_curve_score": (+1.02, 3.41, "high"),
|
||||
"forehead_ratio": (+0.85, 3.31, "high"),
|
||||
"taper_ratio": (+0.35, 1.02, "high"),
|
||||
"cheek_dominance": (-1.41, 2.21, "low"),
|
||||
"jaw_ratio": (-1.04, 0.57, "low"),
|
||||
"chin_sharpness": (-1.33, 2.53, "low"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def feature_zscores(features: Dict[str, float]) -> Dict[str, float]:
|
||||
"""把测量值转成相对参考人群的 z 分数。"""
|
||||
return {
|
||||
key: (features[key] - median) / scale
|
||||
for key, (median, scale) in REFERENCE_STATS.items()
|
||||
if key in features
|
||||
}
|
||||
|
||||
|
||||
def _match(z: float, target: float, mode: str) -> float:
|
||||
"""单项匹配度 0~1。"""
|
||||
if mode == "high" and z >= target:
|
||||
return 1.0
|
||||
if mode == "low" and z <= target:
|
||||
return 1.0
|
||||
return math.exp(-((z - target) ** 2) / (2 * MATCH_TOLERANCE**2))
|
||||
|
||||
|
||||
def classify_face_shape(
|
||||
features: Dict[str, float],
|
||||
return_details: bool = False,
|
||||
) -> Tuple[str, float, Optional[Dict]]:
|
||||
"""
|
||||
脸型分类器:把特征转成 z 分数后,与各脸型原型做加权匹配。
|
||||
|
||||
返回 (脸型, 置信度, 详情)。置信度 = Top1 / (Top1 + Top2),
|
||||
0.5 表示两种脸型完全无法区分,接近 1 表示判定明确。
|
||||
"""
|
||||
z = feature_zscores(features)
|
||||
|
||||
scores: Dict[str, float] = {}
|
||||
contributions: Dict[str, Dict[str, float]] = {}
|
||||
for shape, prototype in SHAPE_PROTOTYPES.items():
|
||||
total_weight = sum(w for _, w, _ in prototype.values())
|
||||
acc = 0.0
|
||||
per_feature = {}
|
||||
for key, (target, weight, mode) in prototype.items():
|
||||
m = _match(z[key], target, mode)
|
||||
per_feature[key] = m
|
||||
acc += weight * m
|
||||
scores[shape] = 100.0 * acc / total_weight
|
||||
contributions[shape] = per_feature
|
||||
|
||||
ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
|
||||
best_shape, best_score = ranked[0]
|
||||
second_shape, second_score = ranked[1] if len(ranked) > 1 else (None, 0.0)
|
||||
|
||||
denom = best_score + second_score
|
||||
confidence = best_score / denom if denom > 0 else 0.0
|
||||
score_gap = best_score - second_score
|
||||
is_mixed = score_gap < 5.0
|
||||
|
||||
details = None
|
||||
if return_details:
|
||||
details = {
|
||||
"scores": scores,
|
||||
"ranked": ranked,
|
||||
"confidence": confidence,
|
||||
"score_gap": score_gap,
|
||||
"is_mixed": is_mixed,
|
||||
"second_shape": second_shape,
|
||||
"second_score": second_score,
|
||||
"zscores": z,
|
||||
"contributions": contributions,
|
||||
}
|
||||
|
||||
return best_shape, confidence, details
|
||||
|
||||
|
||||
def get_mixed_description(details: Dict) -> str:
|
||||
if not details or not details.get("is_mixed"):
|
||||
return ""
|
||||
shape1 = details["ranked"][0][0]
|
||||
shape2 = details["ranked"][1][0]
|
||||
return f"{shape1}(偏{shape2})"
|
||||
|
||||
|
||||
_face_mesh = None
|
||||
|
||||
|
||||
def _get_face_mesh():
|
||||
global _face_mesh
|
||||
if _face_mesh is None:
|
||||
_face_mesh = mp.solutions.face_mesh.FaceMesh(
|
||||
static_image_mode=True,
|
||||
max_num_faces=1,
|
||||
refine_landmarks=True,
|
||||
min_detection_confidence=0.5,
|
||||
)
|
||||
return _face_mesh
|
||||
|
||||
|
||||
def _load_image(image: ImageInput) -> np.ndarray:
|
||||
if isinstance(image, np.ndarray):
|
||||
if image.ndim != 3 or image.shape[2] not in (3, 4):
|
||||
raise ValueError("numpy 图片需为 HxWx3/4 的彩色图")
|
||||
if image.shape[2] == 4:
|
||||
return cv2.cvtColor(image, cv2.COLOR_BGRA2BGR)
|
||||
return image
|
||||
|
||||
path = Path(image)
|
||||
img = cv2.imread(str(path))
|
||||
if img is None:
|
||||
raise FileNotFoundError(f"无法读取图片: {path}")
|
||||
return img
|
||||
|
||||
|
||||
def _landmark_points(landmarks, image_size: Tuple[int, int]) -> Dict[str, Tuple[int, int]]:
|
||||
"""提取标注用像素点。"""
|
||||
w, h = image_size
|
||||
|
||||
def xy(idx: int) -> Tuple[int, int]:
|
||||
lm = landmarks[idx]
|
||||
return int(round(lm.x * w)), int(round(lm.y * h))
|
||||
|
||||
left_jaw = xy(FaceLandmarks.LEFT_JAW_ANGLE)
|
||||
right_jaw = xy(FaceLandmarks.RIGHT_JAW_ANGLE)
|
||||
return {
|
||||
"forehead_top": xy(FaceLandmarks.FOREHEAD_TOP),
|
||||
"chin_bottom": xy(FaceLandmarks.CHIN_BOTTOM),
|
||||
"left_forehead": xy(FaceLandmarks.LEFT_FOREHEAD),
|
||||
"right_forehead": xy(FaceLandmarks.RIGHT_FOREHEAD),
|
||||
"left_cheek": xy(FaceLandmarks.LEFT_CHEEK),
|
||||
"right_cheek": xy(FaceLandmarks.RIGHT_CHEEK),
|
||||
"left_jaw": left_jaw,
|
||||
"right_jaw": right_jaw,
|
||||
"left_chin": xy(FaceLandmarks.LEFT_CHIN),
|
||||
"right_chin": xy(FaceLandmarks.RIGHT_CHIN),
|
||||
"jaw_mid": (
|
||||
int(round((left_jaw[0] + right_jaw[0]) / 2)),
|
||||
int(round((left_jaw[1] + right_jaw[1]) / 2)),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _put_text_cn(
|
||||
img: np.ndarray,
|
||||
text: str,
|
||||
org: Tuple[int, int],
|
||||
color: Tuple[int, int, int],
|
||||
font_size: int = 18,
|
||||
) -> None:
|
||||
"""在图上绘制中文/英文混合文字(Pillow)。"""
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
||||
pil = Image.fromarray(rgb)
|
||||
draw = ImageDraw.Draw(pil)
|
||||
|
||||
font_paths = [
|
||||
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
]
|
||||
font = None
|
||||
for fp in font_paths:
|
||||
if Path(fp).exists():
|
||||
try:
|
||||
font = ImageFont.truetype(fp, font_size)
|
||||
break
|
||||
except OSError:
|
||||
continue
|
||||
if font is None:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
x, y = org
|
||||
# 阴影提升可读性
|
||||
draw.text((x + 1, y + 1), text, font=font, fill=(0, 0, 0))
|
||||
draw.text((x, y), text, font=font, fill=(color[2], color[1], color[0]))
|
||||
img[:] = cv2.cvtColor(np.array(pil), cv2.COLOR_RGB2BGR)
|
||||
|
||||
|
||||
def _draw_h_line(
|
||||
img: np.ndarray,
|
||||
p1: Tuple[int, int],
|
||||
p2: Tuple[int, int],
|
||||
color: Tuple[int, int, int],
|
||||
label: str,
|
||||
thickness: int = 2,
|
||||
label_above: bool = True,
|
||||
) -> None:
|
||||
"""画横向宽度线 + 端点 + 标签。"""
|
||||
y = int(round((p1[1] + p2[1]) / 2))
|
||||
x1, x2 = min(p1[0], p2[0]), max(p1[0], p2[0])
|
||||
cv2.line(img, (x1, y), (x2, y), color, thickness, cv2.LINE_AA)
|
||||
cv2.circle(img, (x1, y), 4, color, -1, cv2.LINE_AA)
|
||||
cv2.circle(img, (x2, y), 4, color, -1, cv2.LINE_AA)
|
||||
# 端点小竖线
|
||||
tick = max(6, thickness * 3)
|
||||
cv2.line(img, (x1, y - tick), (x1, y + tick), color, thickness, cv2.LINE_AA)
|
||||
cv2.line(img, (x2, y - tick), (x2, y + tick), color, thickness, cv2.LINE_AA)
|
||||
mid = ((x1 + x2) // 2, y - 8 if label_above else y + 4)
|
||||
_put_text_cn(img, label, mid, color, font_size=max(14, img.shape[0] // 55))
|
||||
|
||||
|
||||
def _draw_v_line(
|
||||
img: np.ndarray,
|
||||
p1: Tuple[int, int],
|
||||
p2: Tuple[int, int],
|
||||
color: Tuple[int, int, int],
|
||||
label: str,
|
||||
thickness: int = 2,
|
||||
) -> None:
|
||||
"""画纵向高度线 + 端点 + 标签。"""
|
||||
x = int(round((p1[0] + p2[0]) / 2))
|
||||
y1, y2 = min(p1[1], p2[1]), max(p1[1], p2[1])
|
||||
cv2.line(img, (x, y1), (x, y2), color, thickness, cv2.LINE_AA)
|
||||
cv2.circle(img, (x, y1), 4, color, -1, cv2.LINE_AA)
|
||||
cv2.circle(img, (x, y2), 4, color, -1, cv2.LINE_AA)
|
||||
tick = max(6, thickness * 3)
|
||||
cv2.line(img, (x - tick, y1), (x + tick, y1), color, thickness, cv2.LINE_AA)
|
||||
cv2.line(img, (x - tick, y2), (x + tick, y2), color, thickness, cv2.LINE_AA)
|
||||
_put_text_cn(
|
||||
img,
|
||||
label,
|
||||
(x + 8, (y1 + y2) // 2),
|
||||
color,
|
||||
font_size=max(14, img.shape[0] // 55),
|
||||
)
|
||||
|
||||
|
||||
def annotate_face_features(
|
||||
image: ImageInput,
|
||||
landmarks=None,
|
||||
features: Optional[Dict[str, float]] = None,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
在原图上标注 face_width / face_height 及文档中的关键比例特征。
|
||||
|
||||
返回 BGR 标注图。
|
||||
"""
|
||||
bgr = _load_image(image).copy()
|
||||
h, w = bgr.shape[:2]
|
||||
|
||||
if landmarks is None:
|
||||
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
||||
results = _get_face_mesh().process(rgb)
|
||||
if not results.multi_face_landmarks:
|
||||
raise ValueError("未检测到人脸关键点")
|
||||
landmarks = results.multi_face_landmarks[0].landmark
|
||||
|
||||
if features is None:
|
||||
features = extract_face_features(landmarks, image_size=(w, h))
|
||||
|
||||
pts = _landmark_points(landmarks, (w, h))
|
||||
overlay = bgr.copy()
|
||||
fs = max(14, h // 55)
|
||||
thick = max(2, h // 400)
|
||||
|
||||
# ---- 尺寸主轴 ----
|
||||
# face_height: 额头顶 → 下巴底
|
||||
_draw_v_line(
|
||||
overlay,
|
||||
pts["forehead_top"],
|
||||
pts["chin_bottom"],
|
||||
(40, 180, 255),
|
||||
f"face_height {features['face_height']:.0f}px",
|
||||
thickness=thick + 1,
|
||||
)
|
||||
# face_width (= cheekbone): 左颧 → 右颧
|
||||
_draw_h_line(
|
||||
overlay,
|
||||
pts["left_cheek"],
|
||||
pts["right_cheek"],
|
||||
(0, 220, 120),
|
||||
f"face_width {features['face_width']:.0f}px",
|
||||
thickness=thick + 1,
|
||||
label_above=True,
|
||||
)
|
||||
|
||||
# ---- 各级宽度(forehead / jaw / chin)----
|
||||
# 略微错开 y,避免完全重叠
|
||||
fh_y = pts["left_forehead"][1]
|
||||
_draw_h_line(
|
||||
overlay,
|
||||
(pts["left_forehead"][0], fh_y),
|
||||
(pts["right_forehead"][0], fh_y),
|
||||
(255, 160, 40),
|
||||
f"forehead ratio={features['forehead_ratio']:.3f}",
|
||||
thickness=thick,
|
||||
label_above=True,
|
||||
)
|
||||
jy = pts["left_jaw"][1]
|
||||
_draw_h_line(
|
||||
overlay,
|
||||
(pts["left_jaw"][0], jy),
|
||||
(pts["right_jaw"][0], jy),
|
||||
(80, 120, 255),
|
||||
f"jaw ratio={features['jaw_ratio']:.3f}",
|
||||
thickness=thick,
|
||||
label_above=False,
|
||||
)
|
||||
cy = pts["left_chin"][1]
|
||||
_draw_h_line(
|
||||
overlay,
|
||||
(pts["left_chin"][0], cy),
|
||||
(pts["right_chin"][0], cy),
|
||||
(220, 80, 220),
|
||||
f"chin ratio={features['chin_ratio']:.3f}",
|
||||
thickness=thick,
|
||||
label_above=False,
|
||||
)
|
||||
|
||||
# cheekbone_ratio(相对 face_width,恒为 1.0)写在颧骨线旁
|
||||
cheek_mid = (
|
||||
(pts["left_cheek"][0] + pts["right_cheek"][0]) // 2,
|
||||
pts["left_cheek"][1] + max(18, h // 40),
|
||||
)
|
||||
_put_text_cn(
|
||||
overlay,
|
||||
f"cheekbone_ratio={features['cheekbone_ratio']:.3f}",
|
||||
cheek_mid,
|
||||
(0, 200, 100),
|
||||
font_size=fs,
|
||||
)
|
||||
|
||||
# ---- jaw_angle:下巴 → 左右下颌角 ----
|
||||
cv2.line(overlay, pts["chin_bottom"], pts["left_jaw"], (0, 90, 255), thick, cv2.LINE_AA)
|
||||
cv2.line(overlay, pts["chin_bottom"], pts["right_jaw"], (0, 90, 255), thick, cv2.LINE_AA)
|
||||
cv2.circle(overlay, pts["chin_bottom"], 5, (0, 90, 255), -1, cv2.LINE_AA)
|
||||
# 角度弧
|
||||
v1 = np.array(pts["left_jaw"], dtype=float) - np.array(pts["chin_bottom"], dtype=float)
|
||||
v2 = np.array(pts["right_jaw"], dtype=float) - np.array(pts["chin_bottom"], dtype=float)
|
||||
a1 = math.degrees(math.atan2(-v1[1], v1[0]))
|
||||
a2 = math.degrees(math.atan2(-v2[1], v2[0]))
|
||||
# OpenCV ellipse 角度:从 x 轴顺时针;atan2 转一下
|
||||
start_ang = -a1
|
||||
end_ang = -a2
|
||||
if end_ang < start_ang:
|
||||
start_ang, end_ang = end_ang, start_ang
|
||||
radius = max(28, int(0.08 * features["face_height"]))
|
||||
cv2.ellipse(
|
||||
overlay,
|
||||
pts["chin_bottom"],
|
||||
(radius, radius),
|
||||
0,
|
||||
start_ang,
|
||||
end_ang,
|
||||
(0, 90, 255),
|
||||
thick,
|
||||
cv2.LINE_AA,
|
||||
)
|
||||
_put_text_cn(
|
||||
overlay,
|
||||
f"jaw_angle {features['jaw_angle']:.1f}°",
|
||||
(pts["chin_bottom"][0] + radius + 4, pts["chin_bottom"][1] - radius),
|
||||
(0, 90, 255),
|
||||
font_size=fs,
|
||||
)
|
||||
|
||||
# ---- taper_ratio:额头两端 → 下巴两端(收窄示意)----
|
||||
cv2.line(
|
||||
overlay,
|
||||
pts["left_forehead"],
|
||||
pts["left_chin"],
|
||||
(40, 200, 255),
|
||||
max(1, thick - 1),
|
||||
cv2.LINE_AA,
|
||||
)
|
||||
cv2.line(
|
||||
overlay,
|
||||
pts["right_forehead"],
|
||||
pts["right_chin"],
|
||||
(40, 200, 255),
|
||||
max(1, thick - 1),
|
||||
cv2.LINE_AA,
|
||||
)
|
||||
taper_anchor = (
|
||||
pts["left_forehead"][0] - max(10, w // 30),
|
||||
(pts["left_forehead"][1] + pts["left_chin"][1]) // 2,
|
||||
)
|
||||
_put_text_cn(
|
||||
overlay,
|
||||
f"taper_ratio={features['taper_ratio']:.3f}",
|
||||
taper_anchor,
|
||||
(40, 200, 255),
|
||||
font_size=fs,
|
||||
)
|
||||
|
||||
# ---- chin_sharpness:下巴宽 vs 下颌宽 ----
|
||||
_put_text_cn(
|
||||
overlay,
|
||||
f"chin_sharpness={features['chin_sharpness']:.3f} (chin/jaw)",
|
||||
(pts["left_chin"][0], pts["left_chin"][1] + max(16, h // 45)),
|
||||
(220, 80, 220),
|
||||
font_size=fs,
|
||||
)
|
||||
|
||||
# ---- width_uniformity:三宽差异 ----
|
||||
widths = [
|
||||
("F", features["forehead_width"], (255, 160, 40)),
|
||||
("C", features["cheekbone_width"], (0, 220, 120)),
|
||||
("J", features["jaw_width"], (80, 120, 255)),
|
||||
]
|
||||
# 右侧小柱状示意
|
||||
panel_x = min(w - max(90, w // 8), max(pts["right_cheek"][0] + 20, w - max(100, w // 7)))
|
||||
panel_y = max(40, pts["forehead_top"][1])
|
||||
max_w = max(x[1] for x in widths) + 1e-8
|
||||
bar_h = max(10, h // 60)
|
||||
gap = max(4, h // 120)
|
||||
for i, (name, val, color) in enumerate(widths):
|
||||
bw = int((val / max_w) * max(50, w // 10))
|
||||
y0 = panel_y + i * (bar_h + gap)
|
||||
cv2.rectangle(overlay, (panel_x, y0), (panel_x + bw, y0 + bar_h), color, -1, cv2.LINE_AA)
|
||||
_put_text_cn(overlay, name, (panel_x + bw + 4, y0 - 2), color, font_size=max(12, fs - 2))
|
||||
_put_text_cn(
|
||||
overlay,
|
||||
f"width_uniformity={features['width_uniformity']:.3f}",
|
||||
(panel_x, panel_y + 3 * (bar_h + gap) + 2),
|
||||
(230, 230, 230),
|
||||
font_size=fs,
|
||||
)
|
||||
|
||||
# ---- face_curve_score:下颌中点 → 下巴 ----
|
||||
cv2.line(
|
||||
overlay,
|
||||
pts["jaw_mid"],
|
||||
pts["chin_bottom"],
|
||||
(180, 255, 80),
|
||||
thick,
|
||||
cv2.LINE_AA,
|
||||
)
|
||||
cv2.circle(overlay, pts["jaw_mid"], 4, (180, 255, 80), -1, cv2.LINE_AA)
|
||||
curve_label_pos = (
|
||||
pts["jaw_mid"][0] + 6,
|
||||
pts["jaw_mid"][1] - max(8, h // 80),
|
||||
)
|
||||
_put_text_cn(
|
||||
overlay,
|
||||
f"face_curve_score={features['face_curve_score']:.3f}",
|
||||
curve_label_pos,
|
||||
(180, 255, 80),
|
||||
font_size=fs,
|
||||
)
|
||||
|
||||
# 半透明叠回原图,再叠一层实线标注更清晰:直接用 overlay
|
||||
# 左侧参数图例
|
||||
legend = [
|
||||
("face_width / face_height", (0, 220, 120)),
|
||||
(f"jaw_angle={features['jaw_angle']:.1f}°", (0, 90, 255)),
|
||||
(f"taper_ratio={features['taper_ratio']:.3f}", (40, 200, 255)),
|
||||
(f"forehead_ratio={features['forehead_ratio']:.3f}", (255, 160, 40)),
|
||||
(f"cheekbone_ratio={features['cheekbone_ratio']:.3f}", (0, 200, 100)),
|
||||
(f"jaw_ratio={features['jaw_ratio']:.3f}", (80, 120, 255)),
|
||||
(f"chin_ratio={features['chin_ratio']:.3f}", (220, 80, 220)),
|
||||
(f"chin_sharpness={features['chin_sharpness']:.3f}", (220, 80, 220)),
|
||||
(f"width_uniformity={features['width_uniformity']:.3f}", (230, 230, 230)),
|
||||
(f"face_curve_score={features['face_curve_score']:.3f}", (180, 255, 80)),
|
||||
]
|
||||
box_h = 12 + len(legend) * (fs + 6)
|
||||
box_w = max(220, w // 3)
|
||||
cv2.rectangle(overlay, (8, 8), (8 + box_w, 8 + box_h), (20, 20, 20), -1)
|
||||
cv2.rectangle(overlay, (8, 8), (8 + box_w, 8 + box_h), (90, 90, 90), 1)
|
||||
for i, (text, color) in enumerate(legend):
|
||||
_put_text_cn(overlay, text, (16, 14 + i * (fs + 6)), color, font_size=fs)
|
||||
|
||||
return overlay
|
||||
|
||||
|
||||
def classify_from_image(
|
||||
image: ImageInput,
|
||||
return_details: bool = True,
|
||||
return_annotated: bool = False,
|
||||
) -> Dict:
|
||||
"""
|
||||
从图片判断脸型。
|
||||
|
||||
参数:
|
||||
image: 图片路径,或 OpenCV BGR numpy 数组
|
||||
return_details: 是否返回特征与各脸型得分
|
||||
return_annotated: 是否同时返回特征标注图(BGR)
|
||||
"""
|
||||
bgr = _load_image(image)
|
||||
h, w = bgr.shape[:2]
|
||||
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
||||
results = _get_face_mesh().process(rgb)
|
||||
|
||||
if not results.multi_face_landmarks:
|
||||
raise ValueError("未检测到人脸关键点")
|
||||
|
||||
landmarks = results.multi_face_landmarks[0].landmark
|
||||
features = extract_face_features(landmarks, image_size=(w, h))
|
||||
shape, conf, details = classify_face_shape(features, return_details=True)
|
||||
|
||||
display = get_mixed_description(details) or shape
|
||||
result = {
|
||||
"face_shape": shape,
|
||||
"confidence": conf,
|
||||
"display": display,
|
||||
}
|
||||
if return_details:
|
||||
result["features"] = features
|
||||
result["details"] = details
|
||||
if return_annotated:
|
||||
result["annotated"] = annotate_face_features(
|
||||
bgr, landmarks=landmarks, features=features
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def classify_from_mediapipe(
|
||||
multi_face_landmarks,
|
||||
image_size: Tuple[int, int],
|
||||
) -> List[Dict]:
|
||||
"""从 Mediapipe FaceMesh 结果批量分类。image_size=(width, height)。"""
|
||||
results = []
|
||||
for face_lms in multi_face_landmarks:
|
||||
features = extract_face_features(face_lms.landmark, image_size=image_size)
|
||||
shape, conf, details = classify_face_shape(features, return_details=True)
|
||||
results.append(
|
||||
{
|
||||
"face_shape": shape,
|
||||
"confidence": conf,
|
||||
"display": get_mixed_description(details) or shape,
|
||||
"features": features,
|
||||
"details": details,
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def run_test_images(test_dir: Union[str, Path, None] = None) -> List[Dict]:
|
||||
"""用 test_img 做回归测试;文件名(不含扩展名)为期望脸型。"""
|
||||
if test_dir is None:
|
||||
test_dir = Path(__file__).resolve().parent / "test_img"
|
||||
test_dir = Path(test_dir)
|
||||
|
||||
image_paths = sorted(
|
||||
p
|
||||
for p in test_dir.iterdir()
|
||||
if p.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
|
||||
)
|
||||
if not image_paths:
|
||||
raise FileNotFoundError(f"测试目录无图片: {test_dir}")
|
||||
|
||||
rows = []
|
||||
for path in image_paths:
|
||||
expected = path.stem
|
||||
try:
|
||||
result = classify_from_image(path, return_details=True)
|
||||
predicted = result["face_shape"]
|
||||
display = result["display"]
|
||||
conf = result["confidence"]
|
||||
top3 = result["details"]["ranked"][:3]
|
||||
ok = predicted == expected
|
||||
error = None
|
||||
except Exception as exc: # noqa: BLE001
|
||||
predicted = display = conf = None
|
||||
top3 = []
|
||||
ok = False
|
||||
error = str(exc)
|
||||
|
||||
rows.append(
|
||||
{
|
||||
"file": path.name,
|
||||
"expected": expected,
|
||||
"predicted": predicted,
|
||||
"display": display,
|
||||
"confidence": conf,
|
||||
"top3": top3,
|
||||
"correct": ok,
|
||||
"error": error,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _print_test_report(rows: List[Dict]) -> None:
|
||||
correct = sum(1 for r in rows if r["correct"])
|
||||
total = len(rows)
|
||||
|
||||
print("=" * 72)
|
||||
print("脸型分类测试结果")
|
||||
print("=" * 72)
|
||||
for r in rows:
|
||||
status = "✓" if r["correct"] else "✗"
|
||||
if r["error"]:
|
||||
print(f"{status} {r['file']}")
|
||||
print(f" 期望: {r['expected']}")
|
||||
print(f" 错误: {r['error']}")
|
||||
continue
|
||||
|
||||
top3_str = ", ".join(f"{name}:{score:.1f}" for name, score in r["top3"])
|
||||
print(f"{status} {r['file']}")
|
||||
print(f" 期望: {r['expected']}")
|
||||
print(f" 预测: {r['display']} (conf={r['confidence']:.3f})")
|
||||
print(f" Top3: {top3_str}")
|
||||
|
||||
print("-" * 72)
|
||||
print(f"准确率: {correct}/{total} = {correct / total:.1%}")
|
||||
print("=" * 72)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if len(sys.argv) > 1 and sys.argv[1] not in {"--test", "-t"}:
|
||||
out = classify_from_image(sys.argv[1], return_details=True)
|
||||
print(f"脸型: {out['display']}")
|
||||
print(f"置信度: {out['confidence']:.3f}")
|
||||
print("各脸型得分:")
|
||||
for name, score in out["details"]["ranked"]:
|
||||
print(f" {name}: {score:.1f}")
|
||||
else:
|
||||
report = run_test_images()
|
||||
_print_test_report(report)
|
||||
|
Before Width: | Height: | Size: 655 KiB |
|
Before Width: | Height: | Size: 658 KiB |
|
Before Width: | Height: | Size: 656 KiB |
|
Before Width: | Height: | Size: 649 KiB |
|
Before Width: | Height: | Size: 650 KiB |
|
Before Width: | Height: | Size: 651 KiB |
@@ -508,14 +508,13 @@ def _segment_hair(image_bgr, seg_model, landmarks, w, h):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _call_swap(image_bgr, hairline_id, is_hr, ext_mask_bool, denoising_strength,
|
||||
inpainting_fill=1, mask_blur=11, mask_dilate_scale=1.0, webui_steps=None):
|
||||
inpainting_fill=1, mask_blur=11, mask_dilate_scale=1.0):
|
||||
"""调 change_hair /api/swapHair/v1,返回与输入同分辨率同对齐的换发型结果(BGR)。
|
||||
|
||||
ext_mask_bool 非 None 时作为 ext_mask 传入(swap_mode=ext_mask)。
|
||||
denoising_strength:webui img2img 重绘强度(越大生发越激进),透传给换发型。
|
||||
inpainting_fill / mask_blur / mask_dilate_scale:服务端重绘参数(透传给 change_hair,
|
||||
默认值=服务端原始硬编码值,未传时行为不变)。详见 change_hair 文档。
|
||||
webui_steps:webui img2img 采样步数,None 用服务端默认(15)。
|
||||
"""
|
||||
import requests
|
||||
|
||||
@@ -531,8 +530,6 @@ def _call_swap(image_bgr, hairline_id, is_hr, ext_mask_bool, denoising_strength,
|
||||
"mask_blur": int(mask_blur),
|
||||
"mask_dilate_scale": float(mask_dilate_scale),
|
||||
}
|
||||
if webui_steps is not None:
|
||||
payload["webui_steps"] = int(webui_steps)
|
||||
if ext_mask_bool is not None:
|
||||
mbuf = cv2.imencode(".png", (ext_mask_bool.astype(np.uint8)) * 255)[1]
|
||||
payload["ext_mask"] = "data:image/png;base64," + base64.b64encode(mbuf.tobytes()).decode()
|
||||
@@ -858,7 +855,7 @@ def _grow_core(image_bgr, hairline_id, *, is_hr, seg_model, erode_cm, swap_mode,
|
||||
mb_levels, hairline_push_cm, hairline_edge, blend_method, color_match,
|
||||
color_match_strength, mb_feather_px, transition_band_px,
|
||||
inpainting_fill, mask_blur, mask_dilate_scale, rid, render_viz=True,
|
||||
hair_mask=None, webui_steps=None):
|
||||
hair_mask=None):
|
||||
"""接口11 共享核心:遮罩(pushed)→生成→硬贴回→接缝融合,产出 ④ final。
|
||||
|
||||
不做任何重绘。返回中间产物 dict(供接口11 构造响应、接口12 取 final+重绘带用):
|
||||
@@ -900,7 +897,7 @@ def _grow_core(image_bgr, hairline_id, *, is_hr, seg_model, erode_cm, swap_mode,
|
||||
ext_mask = mask_bool if swap_mode == "ext_mask" else None
|
||||
swap_result = _call_swap(image_bgr, hairline_id, is_hr, ext_mask, denoising_strength,
|
||||
inpainting_fill=inpainting_fill, mask_blur=mask_blur,
|
||||
mask_dilate_scale=mask_dilate_scale, webui_steps=webui_steps)
|
||||
mask_dilate_scale=mask_dilate_scale)
|
||||
t_swap = time.time() - t0
|
||||
|
||||
# 步骤3:严格按遮罩硬贴回(无融合,用于对比)
|
||||
@@ -938,7 +935,7 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
|
||||
color_match_strength=1.0, mb_feather_px=1,
|
||||
transition_band_px=-1,
|
||||
inpainting_fill=1, mask_blur=11, mask_dilate_scale=1.0,
|
||||
rid=None, webui_steps=None):
|
||||
rid=None):
|
||||
"""接口11 完整管线(**不含重绘**,重绘见接口12 generate_hairline_redraw)。
|
||||
返回可直接进 ok() 的 data dict。未检出人脸抛 NoFaceError。
|
||||
|
||||
@@ -960,7 +957,7 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
|
||||
color_match=color_match, color_match_strength=color_match_strength,
|
||||
mb_feather_px=mb_feather_px, transition_band_px=transition_band_px,
|
||||
inpainting_fill=inpainting_fill, mask_blur=mask_blur,
|
||||
mask_dilate_scale=mask_dilate_scale, rid=rid, webui_steps=webui_steps)
|
||||
mask_dilate_scale=mask_dilate_scale, rid=rid)
|
||||
mask_viz = core["mask_viz"]
|
||||
alpha = core["alpha"]
|
||||
w, h = core["w"], core["h"]
|
||||
@@ -1038,7 +1035,7 @@ def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="seg
|
||||
inpainting_fill=1, mask_blur=11, mask_dilate_scale=1.0,
|
||||
comfyui_prompt=None, beauty_alpha=0.6,
|
||||
band_lo_mult=0.5, band_hi_mult=1.5, rid=None,
|
||||
hair_mask=None, webui_steps=None):
|
||||
hair_mask=None):
|
||||
"""接口12 发际线带重绘。内部先跑接口11 核心拿到 ④ final,再取 ⑤-① 发际线重绘带
|
||||
(外推↔内推之间、经 baseline 截断只留上部)作遮罩。
|
||||
|
||||
@@ -1067,7 +1064,7 @@ def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="seg
|
||||
mb_feather_px=mb_feather_px, transition_band_px=transition_band_px,
|
||||
inpainting_fill=inpainting_fill, mask_blur=mask_blur,
|
||||
mask_dilate_scale=mask_dilate_scale, rid=rid, render_viz=False,
|
||||
hair_mask=hair_mask, webui_steps=webui_steps)
|
||||
hair_mask=hair_mask)
|
||||
final = core["final"]
|
||||
mask_viz = core["mask_viz"]
|
||||
w, h = core["w"], core["h"]
|
||||
@@ -1110,7 +1107,7 @@ def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="seg
|
||||
"hairline_id": hairline_id,
|
||||
"blend_method": blend_method,
|
||||
"hairline_push_cm": round(float(hairline_push_cm), 2),
|
||||
"comfyui_prompt": comfyui_prompt or "填充遮罩区域的头发,人物增加一点磨皮",
|
||||
"comfyui_prompt": comfyui_prompt or "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜",
|
||||
"beauty_alpha": beauty_alpha,
|
||||
"px_per_cm": round(float(px_per_cm), 4),
|
||||
"mask_pixels": mask_viz["mask_pixels"],
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
"""接口99:人脸特殊点检测。
|
||||
|
||||
返回 MediaPipe Face Mesh 关键点中一组固定编号的像素坐标(英文组名_点ID 为字段名),
|
||||
外加 5 个发际线特殊点:从指定关键点沿指定方向射线步进,与 BiSeNet 头发分割 mask
|
||||
首次相交处即发际线点(参考接口1 方案B 的发际线定位方式,见 hair_segmenter.py)。
|
||||
|
||||
组与编号(需求方给定):
|
||||
- eyebrow 107 66 105 63 70 336 296 334 293 300
|
||||
- eye 189 222 224 113 226 413 442 444 342 446
|
||||
- mouth 0 37 40 67 91 84 17 405 375 409 269
|
||||
- nose_midline 9 168 197 5 4 194 278 48 437 217
|
||||
- cheek 101 117 123 187 205 50 330 346 352 280 425
|
||||
- face_edge 162 234 58 136 149 152 378 365 288 401 366 389 251
|
||||
|
||||
发际线点(字段名需求方给定;A/B 两射线时取中点,仅一条命中则返回该点,均未命中为 null):
|
||||
- hair_line_center 151 → 10 方向与发际线交点
|
||||
- hair_line_right mid(108→109 交点A, 69→67 交点B)
|
||||
- hair_line_right_right mid(104→103 交点A, 68→54 交点B)
|
||||
- hair_line_left mid(337→338 交点A, 299→297 交点B)
|
||||
- hair_line_left_left mid(333→332 交点A, 298→284 交点B) # 与 right_right 镜像对称
|
||||
"""
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
|
||||
# 固定关键点组:组英文名 → MediaPipe 点 ID 列表(顺序即需求方给定顺序)
|
||||
GROUPS = {
|
||||
"eyebrow": [107, 66, 105, 63, 70, 336, 296, 334, 293, 300],
|
||||
"eye": [189, 222, 224, 113, 226, 413, 442, 444, 342, 446],
|
||||
"mouth": [0, 37, 40, 67, 91, 84, 17, 405, 375, 409, 269],
|
||||
"nose_midline": [9, 168, 197, 5, 4, 194, 278, 48, 437, 217],
|
||||
"cheek": [101, 117, 123, 187, 205, 50, 330, 346, 352, 280, 425],
|
||||
"face_edge": [162, 234, 58, 136, 149, 152, 378, 365, 288, 401, 366, 389, 251],
|
||||
}
|
||||
|
||||
# 发际线点定义:字段名 → 参与计算的 (起点ID, 方向点ID) 射线组;单射线直接取交点,
|
||||
# 双射线取两交点中点。
|
||||
HAIRLINE_RAYS = {
|
||||
"hair_line_center": [(151, 10)],
|
||||
"hair_line_right": [(108, 109), (69, 67)],
|
||||
"hair_line_right_right": [(104, 103), (68, 54)],
|
||||
"hair_line_left": [(337, 338), (299, 297)],
|
||||
"hair_line_left_left": [(333, 332), (298, 284)],
|
||||
}
|
||||
|
||||
|
||||
class NoFaceError(Exception):
|
||||
"""未检出人脸。"""
|
||||
|
||||
|
||||
class NonFrontalError(Exception):
|
||||
"""非正面照(复用接口1 姿态阈值)。"""
|
||||
|
||||
|
||||
def ray_hairline_intersection(p1, p2, hair_mask):
|
||||
"""从 p1 沿 p1→p2 方向逐像素步进,返回首次进入头发 mask 的整数像素坐标。
|
||||
|
||||
p1/p2: (x, y) 像素坐标(float)。起点本身已在头发内时直接返回起点(已到发际线)。
|
||||
步进直到出图或步数上限(3×图对角线,防极端死循环);未命中返回 None。
|
||||
"""
|
||||
mask = np.asarray(hair_mask).astype(bool)
|
||||
if mask.size == 0:
|
||||
return None
|
||||
h, w = mask.shape
|
||||
x0, y0 = float(p1[0]), float(p1[1])
|
||||
dx, dy = float(p2[0]) - x0, float(p2[1]) - y0
|
||||
length = math.hypot(dx, dy)
|
||||
if length < 1e-6:
|
||||
return None
|
||||
ux, uy = dx / length, dy / length
|
||||
max_steps = int(3 * math.hypot(h, w))
|
||||
|
||||
def _hit(x, y):
|
||||
xi, yi = int(round(x)), int(round(y))
|
||||
if 0 <= xi < w and 0 <= yi < h:
|
||||
return mask[yi, xi]
|
||||
return False
|
||||
|
||||
x, y = x0, y0
|
||||
for _ in range(max_steps):
|
||||
if _hit(x, y):
|
||||
return int(round(x)), int(round(y))
|
||||
x += ux
|
||||
y += uy
|
||||
if not (0 <= int(round(x)) < w and 0 <= int(round(y)) < h):
|
||||
return None # 已步出图像,方向上无头发
|
||||
return None
|
||||
|
||||
|
||||
def detect_special_points(image_bgr):
|
||||
"""主入口:图片 → {字段名: {"x","y"} 或 None} + 元信息。
|
||||
|
||||
Returns: {"points": {...}, "image_size": {"width","height"},
|
||||
"hairline_source": "segmentation"|"unavailable"}
|
||||
无人脸抛 NoFaceError;非正面抛 NonFrontalError。
|
||||
"""
|
||||
from face_analysis.detector import detector
|
||||
from face_analysis.calibration import normalized_to_pixel
|
||||
from face_analysis.pose import check_frontal_face
|
||||
|
||||
h, w = image_bgr.shape[:2]
|
||||
landmarks = detector.detect(image_bgr)
|
||||
if landmarks is None:
|
||||
raise NoFaceError("未检出人脸")
|
||||
if not check_frontal_face(landmarks, w, h):
|
||||
raise NonFrontalError("非正面照")
|
||||
|
||||
lm = landmarks.landmark
|
||||
px = {i: normalized_to_pixel(lm[i], w, h) for i in range(len(lm))}
|
||||
|
||||
points = {}
|
||||
for group, ids in GROUPS.items():
|
||||
for idx in ids:
|
||||
x, y = px[idx]
|
||||
points[f"{group}_{idx}"] = {"x": int(round(x)), "y": int(round(y))}
|
||||
|
||||
# 头发分割(与接口1 相同:按人脸框裁剪后再分割,脸外背景不干扰)
|
||||
hair_mask = None
|
||||
try:
|
||||
from face_analysis.hair_segmenter import get_segmenter
|
||||
face_box = (min(p[0] for p in px.values()), min(p[1] for p in px.values()),
|
||||
max(p[0] for p in px.values()), max(p[1] for p in px.values()))
|
||||
hair_mask, _ = get_segmenter().segment_hair_and_ears(
|
||||
image_bgr, face_box=face_box)
|
||||
except Exception: # noqa: BLE001 分割不可用 → 发际线点置 null,不阻塞关键点
|
||||
hair_mask = None
|
||||
|
||||
for name, rays in HAIRLINE_RAYS.items():
|
||||
hits = []
|
||||
if hair_mask is not None:
|
||||
for a, b in rays:
|
||||
hits.append(ray_hairline_intersection(px[a], px[b], hair_mask))
|
||||
found = [p for p in hits if p is not None]
|
||||
if not found:
|
||||
points[name] = None
|
||||
elif len(found) == 1:
|
||||
points[name] = {"x": found[0][0], "y": found[0][1]}
|
||||
else:
|
||||
points[name] = {
|
||||
"x": int(round(sum(p[0] for p in found) / len(found))),
|
||||
"y": int(round(sum(p[1] for p in found) / len(found))),
|
||||
}
|
||||
|
||||
return {
|
||||
"points": points,
|
||||
"image_size": {"width": w, "height": h},
|
||||
"hairline_source": "segmentation" if hair_mask is not None else "unavailable",
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
import cv2
|
||||
import json
|
||||
|
||||
path = sys.argv[1] if len(sys.argv) > 1 else "tests/fixtures/frontal.jpg"
|
||||
img = cv2.imread(path)
|
||||
if img is None:
|
||||
print(f"无法读取图片: {path}")
|
||||
sys.exit(1)
|
||||
try:
|
||||
out = detect_special_points(img)
|
||||
except (NoFaceError, NonFrontalError) as e:
|
||||
print(f"检测失败: {e}")
|
||||
sys.exit(1)
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
@@ -0,0 +1,339 @@
|
||||
INFO: Started server process [26440]
|
||||
INFO: Waiting for application startup.
|
||||
2026-07-01 23:35:15 [INFO] gateway.config: 配置加载完成 | workers=['http://127.0.0.1:8187'] | public_base_url=http://127.0.0.1:8080 | hc_interval=8s | dispatch_timeout=600s | queue_wait=30s
|
||||
2026-07-01 23:35:15 [INFO] gateway: 网关启动中... workers=['http://127.0.0.1:8187']
|
||||
2026-07-01 23:35:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:35:16 [INFO] gateway.pool: Worker 池初始化完成 | 总数=1 | 在线=1
|
||||
2026-07-01 23:35:16 [INFO] gateway: 标注图目录: /home/ubuntu/hair/static/annotations
|
||||
2026-07-01 23:35:16 [INFO] gateway.pool: 健康检查循环启动 | 间隔=8s | 下线阈值=2 | 上线阈值=1 | workers=1
|
||||
2026-07-01 23:35:16 [INFO] gateway: 清理任务启动 | 间隔=60min | 保留=24h | 目录=/home/ubuntu/hair/static/annotations
|
||||
INFO: Application startup complete.
|
||||
INFO: Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit)
|
||||
2026-07-01 23:35:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
INFO: 127.0.0.1:35872 - "GET /gateway-health HTTP/1.1" 200 OK
|
||||
2026-07-01 23:35:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:35:27 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/face/measure "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:35:27 [INFO] gateway.forward: base64→URL: annotated_image_base64 → http://127.0.0.1:8080/static/annotations/422b7786adc4486989d7f8770df40693.png (21864 bytes)
|
||||
INFO: 127.0.0.1:57220 - "POST /api/v1/face/measure HTTP/1.1" 200 OK
|
||||
2026-07-01 23:35:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
INFO: 127.0.0.1:57232 - "GET /static/annotations/422b7786adc4486989d7f8770df40693.png HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:57240 - "GET /gateway-health HTTP/1.1" 200 OK
|
||||
2026-07-01 23:35:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:35:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:35:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:36:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:36:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:36:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:36:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:36:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:36:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:36:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:37:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:37:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:37:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:37:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:37:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:37:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:37:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:37:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:38:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:38:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:38:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:38:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:38:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:38:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:38:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:39:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:39:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:39:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:39:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:39:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:39:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:39:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:39:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:40:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:40:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:40:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:40:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:40:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:40:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:40:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:41:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:41:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:41:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:41:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:41:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:41:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:41:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:41:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:42:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:42:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:42:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:42:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:42:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:42:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:42:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:43:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:43:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:43:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:43:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:43:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:43:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:43:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:43:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:44:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:44:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:44:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:44:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:44:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:44:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:44:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:45:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:45:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:45:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:45:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:45:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:45:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:45:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:45:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:46:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:46:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:46:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:46:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:46:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:46:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:46:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:47:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:47:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:47:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:47:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:47:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:47:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:47:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:47:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:48:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:48:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:48:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:48:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:48:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:48:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:48:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:49:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:49:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:49:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:49:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:49:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:49:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:49:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:49:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:50:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:50:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:50:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:50:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:50:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:50:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:50:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:51:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:51:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:51:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:51:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:51:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:51:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:51:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:51:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:52:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
INFO: 127.0.0.1:44470 - "GET /gateway-health HTTP/1.1" 200 OK
|
||||
2026-07-01 23:52:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:52:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:52:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:52:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:52:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:52:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:53:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:53:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:53:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:53:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:53:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:53:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:53:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
INFO: 111.192.98.24:6017 - "GET / HTTP/1.1" 200 OK
|
||||
INFO: 111.192.98.24:6017 - "GET /favicon.ico HTTP/1.1" 404 Not Found
|
||||
2026-07-01 23:53:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:54:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:54:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:54:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:54:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:54:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:54:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:54:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
INFO: 127.0.0.1:39090 - "GET /docs HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:39092 - "GET / HTTP/1.1" 200 OK
|
||||
2026-07-01 23:55:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:55:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
INFO: 127.0.0.1:56172 - "GET /static/test_interface1.html HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56174 - "GET /static/test_interface1.html HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56184 - "GET /static/test_interface2.html HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56196 - "GET /static/test_interface2.html HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56202 - "GET /static/test_interface3.html HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56206 - "GET /static/test_interface3.html HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56212 - "GET /static/test_interface4.html HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56214 - "GET /static/test_interface4.html HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56218 - "GET /static/test_interface5.html HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56230 - "GET /static/test_interface5.html HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56242 - "GET /static/test_interface6.html HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56256 - "GET /static/test_interface6.html HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56272 - "GET /static/test_interface7.html HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56278 - "GET /static/test_interface7.html HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56282 - "GET /static/integration.html HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56284 - "GET /static/integration.html HTTP/1.1" 200 OK
|
||||
2026-07-01 23:55:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:55:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:55:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:55:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:55:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:55:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
INFO: 111.192.98.24:6434 - "GET /static/test_interface1.html HTTP/1.1" 200 OK
|
||||
2026-07-01 23:56:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:56:08 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/face/measure "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:56:08 [INFO] gateway.forward: base64→URL: annotated_image_base64 → http://127.0.0.1:8080/static/annotations/8a70c12c19ef4868af555ef84eaeafae.png (35965 bytes)
|
||||
INFO: 111.192.98.24:6435 - "POST /api/v1/face/measure HTTP/1.1" 200 OK
|
||||
2026-07-01 23:56:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:56:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:56:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:56:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:56:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:56:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:57:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:57:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:57:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:57:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:57:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:57:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:57:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:57:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:58:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
INFO: Shutting down
|
||||
INFO: Waiting for application shutdown.
|
||||
2026-07-01 23:58:08 [INFO] gateway: 网关关闭中...
|
||||
2026-07-01 23:58:08 [INFO] gateway: 清理任务已停止
|
||||
2026-07-01 23:58:08 [INFO] gateway.pool: 健康检查循环已停止
|
||||
2026-07-01 23:58:08 [INFO] gateway.pool: Worker 池已关闭
|
||||
2026-07-01 23:58:08 [INFO] gateway: 网关已关闭
|
||||
INFO: Application shutdown complete.
|
||||
INFO: Finished server process [26440]
|
||||
INFO: Started server process [31898]
|
||||
INFO: Waiting for application startup.
|
||||
2026-07-01 23:58:10 [INFO] gateway.config: 配置加载完成 | workers=['http://127.0.0.1:8187'] | public_base_url=http://117.50.213.111:8080 | hc_interval=8s | dispatch_timeout=600s | queue_wait=30s
|
||||
2026-07-01 23:58:10 [INFO] gateway: 网关启动中... workers=['http://127.0.0.1:8187']
|
||||
2026-07-01 23:58:10 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:58:10 [INFO] gateway.pool: Worker 池初始化完成 | 总数=1 | 在线=1
|
||||
2026-07-01 23:58:10 [INFO] gateway: 标注图目录: /home/ubuntu/hair/static/annotations
|
||||
2026-07-01 23:58:10 [INFO] gateway.pool: 健康检查循环启动 | 间隔=8s | 下线阈值=2 | 上线阈值=1 | workers=1
|
||||
2026-07-01 23:58:10 [INFO] gateway: 清理任务启动 | 间隔=60min | 保留=24h | 目录=/home/ubuntu/hair/static/annotations
|
||||
INFO: Application startup complete.
|
||||
INFO: Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit)
|
||||
2026-07-01 23:58:10 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
INFO: 127.0.0.1:33732 - "GET /gateway-health HTTP/1.1" 200 OK
|
||||
2026-07-01 23:58:18 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:58:26 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:58:28 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/face/measure "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:58:28 [INFO] gateway.forward: base64→URL: annotated_image_base64 → http://117.50.213.111:8080/static/annotations/478efab4566a46c3af0065ad0ffafb67.png (21864 bytes)
|
||||
INFO: 127.0.0.1:32982 - "POST /api/v1/face/measure HTTP/1.1" 200 OK
|
||||
INFO: 117.50.213.111:57344 - "GET /static/annotations/478efab4566a46c3af0065ad0ffafb67.png HTTP/1.1" 200 OK
|
||||
2026-07-01 23:58:34 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:58:42 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:58:50 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:58:54 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/face/measure "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:58:54 [INFO] gateway.forward: base64→URL: annotated_image_base64 → http://117.50.213.111:8080/static/annotations/88d5eec74def44fdad5dd6f7b22e7be4.png (35965 bytes)
|
||||
INFO: 111.192.98.24:7030 - "POST /api/v1/face/measure HTTP/1.1" 200 OK
|
||||
INFO: 111.192.98.24:7030 - "GET /static/annotations/88d5eec74def44fdad5dd6f7b22e7be4.png HTTP/1.1" 200 OK
|
||||
2026-07-01 23:58:58 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
INFO: 111.192.98.24:7031 - "GET /static/test_interface2.html HTTP/1.1" 200 OK
|
||||
2026-07-01 23:59:06 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:59:14 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:59:22 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:59:27 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/hair/grow "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:59:27 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/8f770fef8bc14827b419698c90ebd6fa.jpg (105970 bytes)
|
||||
2026-07-01 23:59:27 [INFO] gateway.forward: base64→URL: grown_image_base64 → http://117.50.213.111:8080/static/annotations/6f592882d9c84c1b916ac327f4e0b2af.jpg (113320 bytes)
|
||||
INFO: 111.192.98.24:7062 - "POST /api/v1/hair/grow HTTP/1.1" 200 OK
|
||||
INFO: 111.192.98.24:7062 - "GET /static/annotations/8f770fef8bc14827b419698c90ebd6fa.jpg HTTP/1.1" 200 OK
|
||||
INFO: 111.192.98.24:7166 - "GET /static/annotations/6f592882d9c84c1b916ac327f4e0b2af.jpg HTTP/1.1" 200 OK
|
||||
2026-07-01 23:59:30 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:59:38 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:59:46 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:59:48 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/hair/grow "HTTP/1.1 200 OK"
|
||||
2026-07-01 23:59:48 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/ed790d4888f742caa6625b3cf4704a77.jpg (105641 bytes)
|
||||
2026-07-01 23:59:48 [INFO] gateway.forward: base64→URL: grown_image_base64 → http://117.50.213.111:8080/static/annotations/e4aed9d67e864d31909b30a58e08c16e.jpg (111495 bytes)
|
||||
INFO: 111.192.98.24:5164 - "POST /api/v1/hair/grow HTTP/1.1" 200 OK
|
||||
INFO: 111.192.98.24:5164 - "GET /static/annotations/ed790d4888f742caa6625b3cf4704a77.jpg HTTP/1.1" 200 OK
|
||||
INFO: 111.192.98.24:5188 - "GET /static/annotations/e4aed9d67e864d31909b30a58e08c16e.jpg HTTP/1.1" 200 OK
|
||||
2026-07-01 23:59:54 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
INFO: 111.192.98.24:5213 - "GET /static/test_interface3.html HTTP/1.1" 200 OK
|
||||
2026-07-02 00:00:02 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:00:10 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:00:16 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/hair/grow-b "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:00:16 [INFO] gateway.forward: base64→URL: hair_growth_image_base64 → http://117.50.213.111:8080/static/annotations/0891a4b9375e4f58814502e893b12bf6.jpg (152092 bytes)
|
||||
INFO: 111.192.98.24:5212 - "POST /api/v1/hair/grow-b HTTP/1.1" 200 OK
|
||||
INFO: 111.192.98.24:5212 - "GET /static/annotations/0891a4b9375e4f58814502e893b12bf6.jpg HTTP/1.1" 200 OK
|
||||
2026-07-02 00:00:18 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
INFO: 111.192.98.24:5332 - "GET /static/test_interface4.html HTTP/1.1" 200 OK
|
||||
2026-07-02 00:00:26 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:00:34 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:00:39 [ERROR] gateway: 接口4 豆包调用失败
|
||||
Traceback (most recent call last):
|
||||
File "/home/ubuntu/hair/gateway/app.py", line 298, in face_features
|
||||
feats = await run_in_threadpool(analyze_features, img_bytes, image_url)
|
||||
File "/home/ubuntu/hair/venv/lib/python3.10/site-packages/starlette/concurrency.py", line 37, in run_in_threadpool
|
||||
return await anyio.to_thread.run_sync(func)
|
||||
File "/home/ubuntu/hair/venv/lib/python3.10/site-packages/anyio/to_thread.py", line 63, in run_sync
|
||||
return await get_async_backend().run_sync_in_worker_thread(
|
||||
File "/home/ubuntu/hair/venv/lib/python3.10/site-packages/anyio/_backends/_asyncio.py", line 2596, in run_sync_in_worker_thread
|
||||
return await future
|
||||
File "/home/ubuntu/hair/venv/lib/python3.10/site-packages/anyio/_backends/_asyncio.py", line 1029, in run
|
||||
result = context.run(func, *args)
|
||||
File "/home/ubuntu/hair/face_features.py", line 98, in analyze_features
|
||||
resp = get_client().chat.completions.create(
|
||||
File "/home/ubuntu/hair/face_features.py", line 65, in get_client
|
||||
from volcenginesdkarkruntime import Ark
|
||||
ModuleNotFoundError: No module named 'volcenginesdkarkruntime'
|
||||
INFO: 111.192.98.24:5331 - "POST /api/v1/face/features HTTP/1.1" 200 OK
|
||||
2026-07-02 00:00:42 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
INFO: 111.192.98.24:5436 - "GET /static/test_interface5.html HTTP/1.1" 200 OK
|
||||
2026-07-02 00:00:50 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:00:58 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:01:00 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/hairline/generate "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/4516a9491a1c41f4af048020f6709870.jpg (105674 bytes)
|
||||
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/ad51e082bb5540ea843259cdc1e76e6b.jpg (105970 bytes)
|
||||
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/12a6f7745c9c461ca963f4d49c5267ef.jpg (105735 bytes)
|
||||
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/316d8352062848e4b28337ef233d820e.jpg (105641 bytes)
|
||||
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/2ae45e3579b0419ab8bff5e3129ab26e.jpg (105705 bytes)
|
||||
INFO: 111.192.98.24:5435 - "POST /api/v1/hairline/generate HTTP/1.1" 200 OK
|
||||
INFO: 111.192.98.24:5435 - "GET /static/annotations/4516a9491a1c41f4af048020f6709870.jpg HTTP/1.1" 200 OK
|
||||
INFO: 111.192.98.24:5466 - "GET /static/annotations/ad51e082bb5540ea843259cdc1e76e6b.jpg HTTP/1.1" 200 OK
|
||||
INFO: 111.192.98.24:5467 - "GET /static/annotations/12a6f7745c9c461ca963f4d49c5267ef.jpg HTTP/1.1" 200 OK
|
||||
INFO: 111.192.98.24:5469 - "GET /static/annotations/316d8352062848e4b28337ef233d820e.jpg HTTP/1.1" 200 OK
|
||||
INFO: 111.192.98.24:5471 - "GET /static/annotations/2ae45e3579b0419ab8bff5e3129ab26e.jpg HTTP/1.1" 200 OK
|
||||
2026-07-02 00:01:06 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:01:14 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:01:22 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:01:30 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:01:38 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:01:46 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:01:54 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:02:02 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:02:10 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:02:18 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:02:26 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:02:34 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:02:42 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:02:50 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
2026-07-02 00:02:58 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
|
||||
INFO: Shutting down
|
||||
INFO: Waiting for application shutdown.
|
||||
2026-07-02 00:02:59 [INFO] gateway: 网关关闭中...
|
||||
2026-07-02 00:02:59 [INFO] gateway: 清理任务已停止
|
||||
2026-07-02 00:02:59 [INFO] gateway.pool: 健康检查循环已停止
|
||||
2026-07-02 00:02:59 [INFO] gateway.pool: Worker 池已关闭
|
||||
2026-07-02 00:02:59 [INFO] gateway: 网关已关闭
|
||||
INFO: Application shutdown complete.
|
||||
INFO: Finished server process [31898]
|
||||
@@ -607,79 +607,3 @@ async def head_mask(request: Request):
|
||||
async def head_band(request: Request):
|
||||
"""接口10:头部外缘膨胀带遮罩 + 分步可视化"""
|
||||
return await _proxy(request, "/api/v1/head/band")
|
||||
|
||||
|
||||
# 接口99 单路排队:网关机推理全局互斥。调用频率低,无需并发——串行既限制 CPU 峰值
|
||||
# (MediaPipe + BiSeNet 全 CPU 推理),也规避检测器单例的线程安全风险。排队无超时,
|
||||
# 请求方按需自带客户端超时。
|
||||
_if99_lock = asyncio.Lock()
|
||||
|
||||
|
||||
@app.post("/api/v1/face/special-points", tags=["人脸分析"])
|
||||
async def face_special_points(
|
||||
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(需带前缀)"),
|
||||
):
|
||||
"""接口99:人脸特殊点检测 — 网关本机实现(纯 CPU:MediaPipe + BiSeNet CPU),不经过 worker。
|
||||
|
||||
与 worker 同仓库,直接 import face_analysis.special_points;权重已入 git,clone 即用。
|
||||
依赖惰性导入:网关缺依赖时仅本接口报 1007,不影响其他路由。
|
||||
"""
|
||||
import uuid as _uuid
|
||||
rid = f"gw-{_uuid.uuid4().hex[:8]}"
|
||||
|
||||
def _err(code, message):
|
||||
return JSONResponse(status_code=200, content={
|
||||
"code": code, "message": message, "request_id": rid, "data": None})
|
||||
|
||||
# 三选一校验
|
||||
provided = [x for x in (image_file, image_url, image_base64) if x]
|
||||
if len(provided) != 1:
|
||||
return _err(1007, "图片参数错误:必须且只能传 image_file / image_url / image_base64 其中一个")
|
||||
|
||||
img_bytes = None
|
||||
if image_file:
|
||||
img_bytes = await image_file.read()
|
||||
elif image_base64:
|
||||
b64 = image_base64.split(",", 1)[1] if "," in image_base64 else image_base64
|
||||
try:
|
||||
img_bytes = base64.b64decode(b64)
|
||||
except Exception:
|
||||
return _err(1008, "图片格式不支持(base64 解码失败)")
|
||||
else:
|
||||
try:
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as cli:
|
||||
r = await cli.get(image_url)
|
||||
r.raise_for_status()
|
||||
img_bytes = r.content
|
||||
except Exception:
|
||||
return _err(1008, "图片下载失败(URL 不可达)")
|
||||
|
||||
try:
|
||||
import cv2
|
||||
import numpy as np # noqa: F401 (detect_special_points 依赖)
|
||||
from face_analysis.special_points import (
|
||||
detect_special_points, NoFaceError, NonFrontalError)
|
||||
except ImportError as ex:
|
||||
return _err(1007, f"网关依赖未安装(安装清单见 docs/网关待改动.md §9):{ex}")
|
||||
|
||||
image = cv2.imdecode(np.frombuffer(img_bytes, np.uint8), cv2.IMREAD_COLOR)
|
||||
if image is None:
|
||||
return _err(1008, "图片格式不支持(仅 JPG / PNG)")
|
||||
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
try:
|
||||
async with _if99_lock: # 单路排队:同一时刻只跑一路推理
|
||||
data = await run_in_threadpool(detect_special_points, image)
|
||||
except NoFaceError:
|
||||
return _err(1001, "无法识别人像")
|
||||
except NonFrontalError:
|
||||
return _err(1003, "角度问题,请上传正面照")
|
||||
except Exception as ex: # noqa: BLE001
|
||||
logger.exception("接口99 处理异常")
|
||||
return _err(1007, f"处理失败:{ex}")
|
||||
|
||||
return JSONResponse(status_code=200, content={
|
||||
"code": 0, "message": "success", "request_id": rid, "data": data})
|
||||
|
||||
@@ -170,10 +170,10 @@
|
||||
},
|
||||
"16": {
|
||||
"inputs": {
|
||||
"unet_name": "flux-2-klein-9b-Q4_K_M.gguf",
|
||||
"unet_name": "flux2.0/flux-2-klein-9b-fp8.safetensors",
|
||||
"weight_dtype": "fp8_e4m3fn"
|
||||
},
|
||||
"class_type": "UnetLoaderGGUF",
|
||||
"class_type": "UNETLoader",
|
||||
"_meta": {
|
||||
"title": "UNet加载器"
|
||||
}
|
||||
@@ -410,7 +410,7 @@
|
||||
},
|
||||
"60": {
|
||||
"inputs": {
|
||||
"text": "填充遮罩区域的头发,人物增加一点磨皮"
|
||||
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
||||
},
|
||||
"class_type": "JjkText",
|
||||
"_meta": {
|
||||
|
||||
@@ -30,29 +30,6 @@ _REPO = os.path.dirname(os.path.dirname(__file__))
|
||||
_INPUT_NODE = "26" # LoadImage:外部输入图(含 alpha 遮罩)
|
||||
_SEED_NODE = "6" # RandomNoise
|
||||
_PROMPT_NODE = "60" # JjkText:提示词
|
||||
_UNET_NODE = "16" # UNETLoader / UnetLoaderGGUF:Flux 模型加载
|
||||
_CLIP_NODE = "61" # CLIPLoader:qwen 文本编码器
|
||||
_VAE_NODE = "3" # VAELoader
|
||||
|
||||
# Flux 模型 → 配套文本编码器映射。切换 unet 时自动同步编码器,避免维度不匹配。
|
||||
def _clip_for_unet(unet_name: str) -> str | None:
|
||||
"""根据 unet 文件名推断配套的文本编码器文件名;无法推断返回 None。"""
|
||||
low = unet_name.lower()
|
||||
if "9b" in low: # Flux.2 9B 系列
|
||||
return "qwen_3_8b_fp8mixed.safetensors"
|
||||
if "z-image" in low: # Z-Image-Turbo 用 4B 编码器
|
||||
return "qwen_3_4b.safetensors"
|
||||
if "4b" in low: # Flux.2 4B
|
||||
return "qwen_3_4b.safetensors"
|
||||
return None
|
||||
|
||||
# Flux 模型 → 配套 VAE 映射。Z-Image 用 ae.safetensors,Flux.2 系列用 flux2-vae。
|
||||
def _vae_for_unet(unet_name: str) -> str | None:
|
||||
"""根据 unet 文件名推断配套 VAE 文件名;无法推断返回 None(保持工作流原值)。"""
|
||||
low = unet_name.lower()
|
||||
if "z-image" in low:
|
||||
return "ae.safetensors"
|
||||
return None # Flux.2 系列 vae 在工作流里已正确配置,不覆盖
|
||||
|
||||
_wf_cache: dict[str, dict] = {} # path → workflow JSON
|
||||
_wf_output_node: dict[str, str] = {} # path → SaveImage 节点 ID
|
||||
@@ -109,18 +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, front: bool = False,
|
||||
unet_name: 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。
|
||||
unet_name:非 None 时改写工作流里的模型加载节点(节点16),动态切换 Flux 模型。
|
||||
.safetensors → 保持 UNETLoader 节点类型不变,只替换 unet_name;
|
||||
.gguf → 自动把节点类型改成 UnetLoaderGGUF(需装 ComfyUI-GGUF 插件)。
|
||||
None 时用工作流内置默认模型。
|
||||
"""
|
||||
path = workflow_path or _WORKFLOW_DEFAULT
|
||||
output_node = _get_output_node(path)
|
||||
@@ -148,26 +120,6 @@ def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = Non
|
||||
wf[_SEED_NODE]["inputs"]["noise_seed"] = random.randint(0, 2**63 - 1)
|
||||
if prompt is not None:
|
||||
wf[_PROMPT_NODE]["inputs"]["text"] = prompt
|
||||
if unet_name is not None:
|
||||
node = wf.get(_UNET_NODE)
|
||||
if node is not None:
|
||||
# .gguf 需切换到 ComfyUI-GGUF 插件的 UnetLoaderGGUF 节点;
|
||||
# .safetensors/.ckpt 保持原 UNETLoader 节点类型不变
|
||||
if unet_name.lower().endswith(".gguf"):
|
||||
node["class_type"] = "UnetLoaderGGUF"
|
||||
else:
|
||||
node["class_type"] = "UNETLoader"
|
||||
node["inputs"]["unet_name"] = unet_name
|
||||
# 同步切换配套文本编码器(4b→qwen_3_4b, 9b→qwen_3_8b),避免维度不匹配
|
||||
clip_node = wf.get(_CLIP_NODE)
|
||||
clip_name = _clip_for_unet(unet_name)
|
||||
if clip_node is not None and clip_name is not None:
|
||||
clip_node["inputs"]["clip_name"] = clip_name
|
||||
# 同步切换 VAE(Z-Image 用 ae.safetensors,Flux.2 保持 flux2-vae)
|
||||
vae_node = wf.get(_VAE_NODE)
|
||||
vae_name = _vae_for_unet(unet_name)
|
||||
if vae_node is not None and vae_name is not None:
|
||||
vae_node["inputs"]["vae_name"] = vae_name
|
||||
|
||||
# 诊断:落盘实际提交的工作流 + 输入图,便于和手动 ComfyUI 跑的对比
|
||||
try:
|
||||
|
||||
@@ -20,13 +20,6 @@ MP_TOP_ANCHORS: list[int] = [
|
||||
]
|
||||
N_ANCHORS = len(MP_TOP_ANCHORS) # 17
|
||||
|
||||
# Anatomical left/right pairs, in MediaPipe indices. The anchor list is
|
||||
# NOT mirror-ordered at its ends: 127 pairs with 356, 234 with 454.
|
||||
MP_TOP_ANCHOR_PAIRS = [
|
||||
(127, 356), (234, 454), (162, 389), (21, 251),
|
||||
(54, 284), (103, 332), (67, 297), (109, 338),
|
||||
]
|
||||
|
||||
# Vertex ID layout in the extended array of length 468 + 2*N_ANCHORS.
|
||||
# [0 .. 468) : MediaPipe canonical landmarks
|
||||
# [468 .. 468+N) : middle row (between MP boundary and hairline)
|
||||
|
||||
@@ -124,46 +124,6 @@ def build_hairline_mask(parse_map: np.ndarray) -> np.ndarray:
|
||||
return build_hair_mask(parse_map)
|
||||
|
||||
|
||||
def align_hairline_for_template(
|
||||
landmarks_norm: np.ndarray,
|
||||
hairline_norm: np.ndarray,
|
||||
valid: np.ndarray,
|
||||
image_shape: tuple[int, int],
|
||||
) -> np.ndarray:
|
||||
"""Keep a designed template aligned with the face rather than the fringe.
|
||||
|
||||
Uneven bangs can make the detected hair boundary much higher on one
|
||||
side, shearing an otherwise balanced template. Average the upward
|
||||
extension of each anatomical anchor pair, measured in image pixels.
|
||||
Keep the face anchors and the perpendicular component unchanged so
|
||||
facial roll/perspective remain visible; do not level the curve in the
|
||||
image. Missing ray hits retain their fallback and never influence a
|
||||
valid partner. Call silhouette clamping afterwards to bound the result.
|
||||
|
||||
This is for template placement only, not measurement of the real hairline.
|
||||
"""
|
||||
h, w = image_shape
|
||||
scale = np.array([w, h], dtype=np.float64)
|
||||
up = face_up_vector(landmarks_norm) * scale
|
||||
length = np.linalg.norm(up)
|
||||
if length < 1e-9:
|
||||
return hairline_norm.copy()
|
||||
up /= length
|
||||
|
||||
anchors = landmarks_norm[C.MP_TOP_ANCHORS, :2] * scale
|
||||
points = hairline_norm * scale
|
||||
distances = (points - anchors) @ up
|
||||
indices = {mp_idx: i for i, mp_idx in enumerate(C.MP_TOP_ANCHORS)}
|
||||
for left, right in C.MP_TOP_ANCHOR_PAIRS:
|
||||
i, j = indices[left], indices[right]
|
||||
if not (valid[i] and valid[j]):
|
||||
continue
|
||||
mean_distance = (distances[i] + distances[j]) / 2.0
|
||||
for k in (i, j):
|
||||
points[k] += (mean_distance - distances[k]) * up
|
||||
return (points / scale).astype(np.float32)
|
||||
|
||||
|
||||
def smooth_hairline(
|
||||
hairline_norm: np.ndarray,
|
||||
valid: np.ndarray,
|
||||
|
||||
@@ -16,7 +16,7 @@ from . import comfyui
|
||||
|
||||
logger = logging.getLogger("hair.worker")
|
||||
|
||||
_DEFAULT_PROMPT = "填充遮罩区域的头发,人物增加一点磨皮"
|
||||
_DEFAULT_PROMPT = "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
||||
_REPO = os.path.dirname(os.path.dirname(__file__))
|
||||
_REPAINT_WORKFLOW = os.path.join(_REPO, "0716add-hair-api.json")
|
||||
|
||||
@@ -56,16 +56,15 @@ def _process_mask_to_rgba(image_bytes: bytes, mask_bytes: bytes) -> bytes:
|
||||
|
||||
def run_redraw(image_bytes: bytes, mask_bytes: bytes,
|
||||
prompt: str | None = None, timeout: float = 300.0,
|
||||
front: bool = False, unet_name: str | None = None) -> bytes:
|
||||
front: bool = False) -> bytes:
|
||||
"""直接调 ComfyUI 重绘 — 替代 local_test /api/generate。
|
||||
|
||||
Args:
|
||||
image_bytes: 人物图片字节(JPG/PNG)
|
||||
mask_bytes: 遮罩图片字节(支持红/白/alpha 遮罩格式)
|
||||
prompt: 提示词,None 用默认 "填充遮罩区域的头发,人物增加一点磨皮"
|
||||
prompt: 提示词,None 用默认 "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
||||
timeout: ComfyUI 超时秒数
|
||||
front: True 时任务插到 ComfyUI 队列最前(接口2 时延敏感路径用)
|
||||
unet_name: 非 None 时切换 Flux 模型(如 flux-2-klein-9b-Q5_K_M.gguf),None 用工作流默认
|
||||
|
||||
Returns:
|
||||
重绘后的 PNG 图片字节
|
||||
@@ -76,5 +75,4 @@ def run_redraw(image_bytes: bytes, mask_bytes: bytes,
|
||||
"""
|
||||
rgba_png = _process_mask_to_rgba(image_bytes, mask_bytes)
|
||||
return comfyui.run(rgba_png, timeout=timeout, prompt=prompt,
|
||||
workflow_path=_REPAINT_WORKFLOW, front=front,
|
||||
unet_name=unet_name)
|
||||
workflow_path=_REPAINT_WORKFLOW, front=front)
|
||||
|
||||
@@ -15,8 +15,7 @@ from . import comfyui
|
||||
from .face_landmarks import FaceLandmarker
|
||||
from .face_parsing import FaceParser
|
||||
from .hairline_2d import (
|
||||
smooth_hairline, sample_hairline, align_hairline_for_template,
|
||||
clamp_hairline_to_silhouette,
|
||||
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
|
||||
@@ -59,7 +58,7 @@ _BLACK_TEXTURE_DIR = os.path.join(_REPO, "hairline_texture_black")
|
||||
# 关键: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", "填充遮罩区域的头发,人物增加一点磨皮")
|
||||
_REDRAW_PROMPT = os.getenv("REDRAW_PROMPT", "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
|
||||
|
||||
# 接口2 女重绘整条管线(swapHair + ComfyUI)送模型前限边。真实照片常达 1257x1495:
|
||||
# 全分辨率 ComfyUI 重绘要 13~21s 且激活显存把模型挤出。女性路径含 swapHair(SD WebUI ~5.3s
|
||||
@@ -67,27 +66,21 @@ _REDRAW_PROMPT = os.getenv("REDRAW_PROMPT", "填充遮罩区域的头发,人
|
||||
# 默认压到 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,
|
||||
max_side=None, unet_name=None, prompt=None):
|
||||
def _call_local_redraw(image_png_bytes, mask_png_bytes, timeout=300.0):
|
||||
"""直接调 ComfyUI 重绘(替代原 local_test HTTP 服务)。
|
||||
|
||||
传 final 图 + 纯红遮罩 PNG,返回重绘后的 PNG bytes。
|
||||
失败抛异常(调用方负责 try/except 跳过)。
|
||||
|
||||
max_side:送 ComfyUI 前长边压到多少像素,None 用全局默认 _REDRAW_MAX_SIDE。
|
||||
unet_name:非 None 时切换 Flux 模型,None 用工作流内置默认。
|
||||
prompt:None 用默认 _REDRAW_PROMPT,否则用传入的提示词。
|
||||
"""
|
||||
from .redraw import run_redraw
|
||||
eff_side = _REDRAW_MAX_SIDE if max_side is None else max_side
|
||||
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 eff_side > 0 and m > eff_side:
|
||||
scale = eff_side / float(m)
|
||||
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)
|
||||
@@ -95,11 +88,10 @@ def _call_local_redraw(image_png_bytes, mask_png_bytes, timeout=300.0,
|
||||
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, eff_side)
|
||||
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=prompt if prompt is not None else _REDRAW_PROMPT,
|
||||
front=True, unet_name=unet_name)
|
||||
prompt=_REDRAW_PROMPT, front=True)
|
||||
if scale < 1.0 and out:
|
||||
out = _upscale_png_to(out, orig_w, orig_h)
|
||||
return out
|
||||
@@ -181,21 +173,17 @@ def extract_502(image_bgr: np.ndarray):
|
||||
def extract_context(image_bgr: np.ndarray):
|
||||
"""照片(BGR) → {landmarks, parse_map, points, valid}。无人脸返回 None。
|
||||
|
||||
发际线几何使用射线检测 + 人脸对应点对齐 + 头部轮廓钳制:
|
||||
左右刘海高低不同时,协调对应锚点的延伸距离,避免把设计模板拉歪;
|
||||
保留人脸本身的倾斜。预览白线与生发黑线/遮罩共用同一套 points。
|
||||
发际线几何检测固定用 `sample_hairline_clamped`(射线检测 + 头部轮廓钳制):
|
||||
短发/剃光头照片(如 man_test.jpg)中间锚点检测失效时,纯射线检测的固定 fallback
|
||||
偏移会把点顶到头部轮廓外面的背景,产生"发际线贴到头部外面"的视觉 bug;钳制兜底后
|
||||
fallback 点不会再跑出头部轮廓。
|
||||
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 = align_hairline_for_template(landmarks, hairline_2d, valid, parse_map.shape)
|
||||
hairline_2d = clamp_hairline_to_silhouette(hairline_2d, 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)
|
||||
@@ -229,8 +217,7 @@ 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,
|
||||
unet_name: str | None = None):
|
||||
workflow_path: str | None = None):
|
||||
"""指定发际线类型:发际线透明叠图(白线 RGBA) + 生发图(ComfyUI)。
|
||||
|
||||
hair_styles(1-indexed 列表):指定生成哪几张发际线(按贴图排序)。female: 1..7,male: 1..7。
|
||||
@@ -266,8 +253,7 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
|
||||
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,
|
||||
unet_name=unet_name)
|
||||
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
|
||||
@@ -292,8 +278,7 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
|
||||
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,
|
||||
unet_name=unet_name)
|
||||
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 单张失败不拖垮整请求
|
||||
@@ -320,10 +305,7 @@ def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | N
|
||||
一致的原生生发(ComfyUI add_hair),由 _grow_native_one 完成(黑模板 + inpaint 遮罩)。
|
||||
|
||||
overlay 仍是发际线曲线透明层(与 generate_grow_results 完全一致)。
|
||||
prompt 两条分支都生效:换发型分支传入 _call_local_redraw,原生生发分支传给
|
||||
_grow_native_one。None/默认「填充遮罩区域的头发,人物增加一点磨皮」时与全局 _REDRAW_PROMPT 一致,
|
||||
不破坏 CLIP 缓存命中的快路径;传不同提示词会使缓存失效并重载 CLIP/Flux
|
||||
(实测换词后首次请求重绘 45s vs 缓存命中 8s),后续相同提示词恢复快路径。
|
||||
prompt 仅用于原生生发分支(换发型分支的提示词由 redraw 流程内部固定)。
|
||||
Returns: list[dict] {"hairline_type","order","overlay","grown_png"(jpg bytes 或 None)};
|
||||
无人脸返回 None。单个发型换发型/重绘失败时 grown_png=None,不抛异常。
|
||||
"""
|
||||
@@ -349,17 +331,16 @@ def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | N
|
||||
|
||||
# 重绘管线(swapHair + ComfyUI)统一降分辨率:真实照片 swap(SD WebUI)~5s、blend、ComfyUI
|
||||
# 均随分辨率线性下降。overlay 预览仍用全分辨率;grown_png 最后放大回原尺寸。
|
||||
eff_side = _REDRAW_MAX_SIDE if redraw_max_side is None else redraw_max_side
|
||||
redraw_img = image_bgr
|
||||
hair_mask_redraw = hair_mask_reuse
|
||||
if eff_side > 0 and max(h, w) > eff_side:
|
||||
redraw_img, _rs = _downscale_max_side(image_bgr, eff_side)
|
||||
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, eff_side)
|
||||
w, h, _nw, _nh, _REDRAW_MAX_SIDE)
|
||||
|
||||
for order, (key, white_path) in items:
|
||||
white = load_texture_rgba(white_path)
|
||||
@@ -395,10 +376,7 @@ def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | N
|
||||
mask_bytes = base64.b64decode(mask_b64)
|
||||
# 后端直接调 ComfyUI 重绘,返回重绘后的 PNG
|
||||
_tr0 = _t.perf_counter()
|
||||
grown_png = _call_local_redraw(final_bytes, mask_bytes,
|
||||
max_side=redraw_max_side,
|
||||
unet_name=unet_name,
|
||||
prompt=prompt)
|
||||
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",
|
||||
|
||||
|
Before Width: | Height: | Size: 440 KiB |
|
Before Width: | Height: | Size: 243 KiB |
|
Before Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 223 KiB |
|
Before Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 243 KiB |
|
Before Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 459 KiB |
@@ -48,7 +48,7 @@ cd /home/ubuntu/hair/local_test
|
||||
|--------|------|------|------|
|
||||
| image | File | 是 | 人物图片(支持 jpg, png 等常见格式) |
|
||||
| mask | File | 是 | 遮罩图片(支持 jpg, png,遮罩区域可用红色/白色/alpha 通道标识) |
|
||||
| prompt | String | 否 | 提示词,默认值:"填充遮罩区域的头发,人物增加一点磨皮" |
|
||||
| prompt | String | 否 | 提示词,默认值:"填充遮罩区域的头发,皮肤加一点磨皮" |
|
||||
|
||||
#### 遮罩图片格式说明
|
||||
|
||||
@@ -70,7 +70,7 @@ cd /home/ubuntu/hair/local_test
|
||||
curl -X POST http://127.0.0.1:8899/api/generate \
|
||||
-F "image=@/path/to/person.jpg" \
|
||||
-F "mask=@/path/to/mask.png" \
|
||||
-F "prompt=填充遮罩区域的头发,人物增加一点磨皮" \
|
||||
-F "prompt=填充遮罩区域的头发,皮肤加一点磨皮" \
|
||||
--output result.png
|
||||
```
|
||||
|
||||
@@ -85,7 +85,7 @@ files = {
|
||||
"mask": open("mask.png", "rb"),
|
||||
}
|
||||
data = {
|
||||
"prompt": "填充遮罩区域的头发,人物增加一点磨皮"
|
||||
"prompt": "填充遮罩区域的头发,皮肤加一点磨皮"
|
||||
}
|
||||
|
||||
resp = requests.post(url, files=files, data=data, timeout=600)
|
||||
|
||||
@@ -206,7 +206,7 @@ def generate():
|
||||
return jsonify({"error": msg}), 400
|
||||
image_file = request.files["image"]
|
||||
mask_file = request.files["mask"]
|
||||
prompt_text = request.form.get("prompt", "填充遮罩区域的头发,人物增加一点磨皮")
|
||||
prompt_text = request.form.get("prompt", "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
|
||||
log.info(
|
||||
"收到请求: image=%s mask=%s prompt=%r",
|
||||
image_file.filename, mask_file.filename, prompt_text,
|
||||
|
||||
@@ -27,7 +27,7 @@ def prep_and_upload():
|
||||
|
||||
|
||||
def run_once(fname, model, dtype, steps):
|
||||
wf = A.build_workflow(fname, "填充遮罩区域的头发,人物增加一点磨皮")
|
||||
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
|
||||
wf["16"]["inputs"]["unet_name"] = model
|
||||
wf["16"]["inputs"]["weight_dtype"] = dtype
|
||||
wf["1"]["inputs"]["steps"] = steps
|
||||
|
||||
@@ -33,7 +33,7 @@ def upload(scale=1.0):
|
||||
|
||||
|
||||
def run(fname, steps):
|
||||
wf = A.build_workflow(fname, "填充遮罩区域的头发,人物增加一点磨皮")
|
||||
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
|
||||
wf["16"]["inputs"]["unet_name"] = MODEL
|
||||
wf["16"]["inputs"]["weight_dtype"] = DTYPE
|
||||
wf["1"]["inputs"]["steps"] = steps
|
||||
|
||||
@@ -30,7 +30,7 @@ SEED = 123456789
|
||||
imgs = []
|
||||
labels = []
|
||||
for steps in [2, 3, 4, 6]:
|
||||
wf = A.build_workflow(fname, "填充遮罩区域的头发,人物增加一点磨皮", seed=SEED)
|
||||
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", seed=SEED)
|
||||
wf["16"]["inputs"]["unet_name"] = MODEL
|
||||
wf["16"]["inputs"]["weight_dtype"] = DTYPE
|
||||
wf["1"]["inputs"]["steps"] = steps
|
||||
|
||||
@@ -55,7 +55,7 @@ button { padding: 10px 24px; border: none; border-radius: 6px; cursor: pointer;
|
||||
</div>
|
||||
<div class="controls" style="margin-top:16px">
|
||||
<label>提示词:</label>
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发,人物增加一点磨皮">
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜">
|
||||
</div>
|
||||
<div style="text-align:center; margin-top:16px">
|
||||
<button class="btn-generate" id="generateBtn" disabled>🚀 生成</button>
|
||||
|
||||
@@ -25,7 +25,7 @@ resp = requests.post(
|
||||
"image": ("original.jpg", img_data, "image/jpeg"),
|
||||
"mask": ("mask.png", mask_data, "image/png"),
|
||||
},
|
||||
data={"prompt": "填充遮罩区域的头发,人物增加一点磨皮"},
|
||||
data={"prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"},
|
||||
timeout=600,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""重新生成 bench3/4/5/7 报告,在最左边加原图列。"""
|
||||
import json
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
REPOS_ROOT = Path("/home/ubuntu/hair")
|
||||
ORIG_SRC = {"asdf": "bench/orig/asdf.jpg", "qwer": "bench/orig/qwer.jpg",
|
||||
"girl2": "bench/orig/girl2.jpg", "girl5": "bench/orig/girl5.jpg"}
|
||||
|
||||
# bench编号 -> (输出目录, 部署HTML名, 报告标题后缀)
|
||||
BENCHES = [
|
||||
(3, "美颜(磨皮+美颜)"),
|
||||
(4, "磨皮"),
|
||||
(5, "美白"),
|
||||
(7, "纯生发"),
|
||||
]
|
||||
|
||||
|
||||
def img_src(path, bench_num):
|
||||
if not path or not os.path.isfile(path):
|
||||
return None
|
||||
return f"bench{bench_num}/" + os.path.basename(path)
|
||||
|
||||
|
||||
def gen_report(bench_num, title_suffix):
|
||||
bench_dir = REPOS_ROOT / f"benchmark_out/bench{bench_num}"
|
||||
results = bench_dir / "results.json"
|
||||
if not results.exists():
|
||||
print(f" 跳过 bench{bench_num}: results.json 不存在")
|
||||
return
|
||||
d = json.load(open(results, encoding="utf-8"))
|
||||
titles = d["res_titles"]
|
||||
rows = d["rows"]
|
||||
prompt = d.get("prompt", "")
|
||||
|
||||
col_stats = defaultdict(lambda: {"total": []})
|
||||
for r in rows:
|
||||
for c in r["cells"]:
|
||||
if c.get("ok"):
|
||||
col_stats[c["res_title"]]["total"].append(c["total_ms"])
|
||||
|
||||
# 表头:原图列 + 分辨率列
|
||||
headers = ['<th class="col-label">原图</th>']
|
||||
for t in titles:
|
||||
s = col_stats.get(t)
|
||||
avg = sum(s["total"]) // len(s["total"]) if s and s["total"] else 0
|
||||
headers.append(f'<th class="col-label"><div class="col-title">{t}</div>'
|
||||
f'<div class="col-stat">均{avg/1000:.1f}s</div></th>')
|
||||
|
||||
body_rows = []
|
||||
for r in rows:
|
||||
orig_src = ORIG_SRC.get(r["img"])
|
||||
label = f'<div class="row-label">{r["img"]}<br><b>{r["hair_name"]}</b></div>'
|
||||
# 原图列:显示输入原图
|
||||
orig_cell = (f'<td class="cell-orig"><div class="row-label-cell">{label}</div>'
|
||||
f'<img class="orig-img" src="{orig_src}"></td>')
|
||||
cells = [orig_cell]
|
||||
for c in r["cells"]:
|
||||
src = img_src(c.get("grown_path"), bench_num) if c.get("ok") else None
|
||||
if src:
|
||||
t = c.get("total_ms", 0)
|
||||
cells.append(f'<td class="cell-result"><img class="result-img" src="{src}" loading="lazy">'
|
||||
f'<div class="cell-time">{t/1000:.1f}s</div></td>')
|
||||
else:
|
||||
cells.append(f'<td class="cell-result"><div class="na">⚠</div></td>')
|
||||
body_rows.append(f'<tr>{"".join(cells)}</tr>')
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>重绘分辨率对比({title_suffix})</title>
|
||||
<style>
|
||||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||
body {{ font-family: -apple-system, sans-serif; background: #f5f5f5; padding: 16px; }}
|
||||
h1 {{ font-size: 20px; margin-bottom: 4px; }}
|
||||
.subtitle {{ color: #888; font-size: 12px; margin-bottom: 12px; }}
|
||||
.legend {{ background: #fff; border-radius: 8px; padding: 10px 16px; margin-bottom: 12px; font-size: 12px; color: #555; }}
|
||||
.scroll-wrap {{ overflow-x: auto; }}
|
||||
table {{ border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.06); }}
|
||||
th, td {{ border: 1px solid #eee; padding: 6px; vertical-align: top; text-align: center; }}
|
||||
th {{ background: #f9fafb; position: sticky; top: 0; }}
|
||||
.col-label {{ min-width: 130px; max-width: 150px; }}
|
||||
.col-title {{ font-size: 12px; font-weight: 700; color: #374151; }}
|
||||
.col-stat {{ font-size: 10px; color: #9ca3af; margin-top: 2px; }}
|
||||
.row-label {{ font-size: 11px; color: #6b7280; }}
|
||||
.row-label b {{ color: #1f2937; }}
|
||||
.row-label-cell {{ font-size: 11px; color: #6b7280; margin-bottom: 4px; }}
|
||||
.row-label-cell b {{ color: #1f2937; font-size: 13px; }}
|
||||
img {{ border-radius: 4px; max-width: 130px; max-height: 160px; object-fit: contain; background: #f3f4f6; }}
|
||||
.orig-img {{ border: 2px solid #d1d5db; }}
|
||||
.cell-time {{ font-size: 10px; color: #9ca3af; margin-top: 2px; }}
|
||||
.na {{ color: #d1d5db; font-size: 12px; padding: 40px 10px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>📊 重绘分辨率对比(提示词:{title_suffix})</h1>
|
||||
<p class="subtitle">4图×5发型=20行 · 每行原图+4分辨率 · steps=15 · 提示词="{prompt}" · 80/80成功 · 0 OOM</p>
|
||||
<div class="legend">最左列为输入原图。列标题下为平均总耗时。横向滚动查看。</div>
|
||||
<div class="scroll-wrap">
|
||||
<table>
|
||||
<tr>{"".join(headers)}</tr>
|
||||
{"".join(body_rows)}
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
deploy = REPOS_ROOT / "static" / f"bench{bench_num}_report.html"
|
||||
deploy.write_text(html, encoding="utf-8")
|
||||
print(f" ✓ bench{bench_num} ({title_suffix}): {deploy.name}")
|
||||
|
||||
|
||||
def main():
|
||||
print("重新生成报告(加原图列):")
|
||||
for num, suffix in BENCHES:
|
||||
gen_report(num, suffix)
|
||||
print("完成")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
Before Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 334 KiB |
|
Before Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 309 KiB |
|
Before Width: | Height: | Size: 233 KiB |
|
Before Width: | Height: | Size: 271 KiB |
|
Before Width: | Height: | Size: 286 KiB |
|
Before Width: | Height: | Size: 237 KiB |
|
Before Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 252 KiB |
|
Before Width: | Height: | Size: 306 KiB |
|
Before Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 233 KiB |
|
Before Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 314 KiB |
|
Before Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 300 KiB |