diff --git a/CLAUDE.md b/CLAUDE.md index 9c4a61d..13a1db5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co 旷视接口服务 — FastAPI 人像分析/生发服务,**一个仓库、两种部署角色**: - **worker**(`app.py` + `face_analysis/` + `hairline/`):跑真实算法(本地模型 + ComfyUI + change_hair),监听 `0.0.0.0:8187`。**本机(GPU 机)即 worker 角色**,不跑网关。 -- **网关**(`gateway/`):外网机器上的薄反向代理,负责鉴权/健康检查/派发,并把 worker 返回的 `*_base64` 落盘改写为 `*_url`。接口4(豆包)在网关本机实现,不转发 worker。 +- **网关**(`gateway/`):外网机器上的薄反向代理,负责鉴权/健康检查/派发,并把 worker 返回的 `*_base64` 落盘改写为 `*_url`。接口4(豆包)和接口99(人脸特殊点,纯 CPU)在网关本机实现,不转发 worker。 > 历史包袱:`app.py` 的 FastAPI `description` 和 `docs/接口文档.md` 部分示例仍写「Mock 第一版」字样,实际全部接口已是真实算法实现。**架构以 `docs/实现说明.md` 为准,契约以 `docs/接口文档.md` 为准。** @@ -51,7 +51,7 @@ cd /home/ubuntu/change_hair && bash start_all.sh start # 换发型三件套 88 | 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` | 固定编号关键点 + 发际线特殊点(射线求交头发 mask)像素坐标 | +| 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` | — | 免鉴权,分步计时/下载日志 | diff --git a/docs/实现说明.md b/docs/实现说明.md index 0f130b9..74beff5 100644 --- a/docs/实现说明.md +++ b/docs/实现说明.md @@ -13,7 +13,7 @@ ``` 客户端 ──HTTPS──> 外网网关(gateway/) ──HTTP(X-Internal-Token)──> worker(GPU 机, app.py) │ 薄代理 + 落盘改URL │ 跑算法(本地模型/ComfyUI/change_hair) - └ 接口4 本机直接调豆包(不转发) └ 接口1/2/3/5/6/7/9/10/11/12/99 + 工具路由 + └ 接口4(豆包)/99(特殊点) 本机实现(不转发) └ 接口1/2/3/5/6/7/9/10/11/12 + 工具路由 ``` - **worker**(`app.py` + `face_analysis/` + `hairline/`):跑真正的算法,除 ComfyUI/change_hair 走本机 @@ -22,7 +22,8 @@ 另有 worker 本机的工具路由:`/api/swapHair/v1`、`/hairColor/v2`(CORS 代理转发 change_hair:8801)、 `/api/v1/redraw`(直接 ComfyUI 重绘)、`/api/v1/debug/*`(免鉴权调试)。 - **网关**(`gateway/`):薄反向代理,健康检查/派发/鉴权/把 worker 的 `*_base64` 落盘改成 `*_url`。 - 唯一例外是**接口4 在网关本机直接实现**(调外网豆包,不转发 worker)。 + 网关本机实现的接口(不转发 worker):**接口4**(调外网豆包)与**接口99**(纯 CPU 特殊点检测, + 直接 import 同仓库 `face_analysis/special_points`,`_if99_lock` 单路排队)。 - **图片三选一**:所有接口图片入参 `image_file`/`image_url`/`image_base64` 严格三选一(接口3 是 `marked_image_*`)。 - **响应信封**:`{code, message, request_id, data}`;业务错误用 `code`(HTTP 一律 200)。 @@ -114,9 +115,9 @@ - **三个变体**:`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) +### 接口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)取中点。无图片字段,网关纯盲转发。 +- **怎么实现**(`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 本机直连)。 --- diff --git a/docs/接口文档.md b/docs/接口文档.md index f6c34da..b02a46c 100644 --- a/docs/接口文档.md +++ b/docs/接口文档.md @@ -656,7 +656,7 @@ ## 接口 99:人脸特殊点检测接口 -**说明**:输入一张人脸照片,返回一组**固定编号 MediaPipe Face Mesh 关键点**的像素坐标,外加 5 个**发际线特殊点**(射线与头发分割发际线的交点)。 +**说明**:输入一张人脸照片,返回一组**固定编号 MediaPipe Face Mesh 关键点**的像素坐标,外加 5 个**发际线特殊点**(射线与头发分割发际线的交点)。**生产路径:网关本机实现(纯 CPU,不转发 worker,同接口4 模式)**;worker 侧保留同路径接口供测试页直连。 **请求**:`POST /api/v1/face/special-points` @@ -706,7 +706,7 @@ | 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 | +| 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/*`。 diff --git a/docs/网关待改动.md b/docs/网关待改动.md index 47eb0ad..78f2b02 100644 --- a/docs/网关待改动.md +++ b/docs/网关待改动.md @@ -170,44 +170,58 @@ worker 侧已上线 4 条路由,**网关尚无对应转发路由**(当前仅 --- -## 9. ✅【新增】接口99 人脸特殊点(`/api/v1/face/special-points`) +## 9. ✅【新增】接口99 人脸特殊点(`/api/v1/face/special-points`)— **网关本机实现,不转发 worker** -**背景**:worker 侧新增接口99——固定编号 MediaPipe 关键点(6 组 60 点)+ 5 个发际线特殊点, -返回原图像素坐标 JSON(无图片字段)。 +**背景**:接口99(固定编号 MediaPipe 关键点 6 组 60 点 + 5 个发际线特殊点,返回像素坐标 JSON) +**纯 CPU 可跑**(MediaPipe 本就 CPU;BiSeNet 分割单核实测 <1s)且调用频率低。 +按约定:**不用 GPU、低频的接口默认网关本机实现**,不占 worker 队列 +(worker 池 `per_worker_concurrency=1`,转发会排在生发任务后面等)。 -**网关代码已在本仓库 `gateway/app.py` 改好**(同接口1 的盲转发写法,pull 后重启即生效): +**网关代码已在本仓库 `gateway/app.py` 改好**——与接口4 同款本机实现(显式 Form 参数 + +惰性导入 + `run_in_threadpool` + `gw-` request_id),直接 import 同仓库的 +`face_analysis/special_points.detect_special_points`。**单路排队**:`_if99_lock` 全局互斥, +同一时刻只跑一路推理(限制 CPU 峰值 + 规避 MediaPipe 单例线程安全问题),请求 FIFO 排队。 -```python -@app.post("/api/v1/face/special-points", tags=["人脸分析"]) -async def face_special_points(request: Request): - """接口99:人脸特殊点检测(固定关键点 + 发际线特殊点像素坐标)""" - return await _proxy(request, "/api/v1/face/special-points") -``` +### 网关机部署前检查 -**网关机部署/调试步骤**: +1. **Python 3.10~3.12**(mediapipe 0.10.14 无 3.13 wheel)。 +2. **安装依赖**(网关 venv,国内镜像): ```bash -cd /path/to/hair && git pull # 拉取本分支 -sudo systemctl restart hair-gateway # 或项目实际的网关服务名(uvicorn gateway.app:app) -# 验证路由已注册(应能在输出里看到 /api/v1/face/special-points) -curl -s http://127.0.0.1:8080/openapi.json | python3 -m json.tool | grep special-points -# 冒烟:经网关调一次(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 +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 -i https://pypi.tuna.tsinghua.edu.cn/simple \ + torch==2.2.2 torchvision==0.17.2 --index-url https://download.pytorch.org/whl/cpu +# 不装 torch 也能跑:60 个关键点正常返回,发际线 5 点为 null(hairline_source="unavailable") ``` -**无需额外改动**: -- 请求:multipart/form-data,仅图片三选一参数,盲转发即可。 -- 响应:`data.points` 全是坐标(`{"x":..,"y":..}`),**没有任何 `*_base64` 字段**, - 现有 `rewrite_base64_to_url` 递归改写天然跳过,原样返回。 -- 性能:纯本地推理(MediaPipe + BiSeNet,秒级),不依赖 ComfyUI/change_hair, - `request_timeout_seconds` 无需调大。 +3. **权重已入 git**,`git clone/pull` 自动到位:`face_analysis/weights/79999_iter.pth`(51MB)。 + 无需手动下载任何模型文件。 -**注意**: -- worker 侧测试页 `static/test_interface99.html` 是 worker 本机直连的(带 token 头), - 经网关调试请用 curl 或网关 `/docs`(网关只盲转发 `/api/*`,不代理 worker 的 `/static`)。 -- 契约详见 [`接口文档.md`](接口文档.md) 接口99 章节;worker 错误码 1001(无人脸)/1003(非正面) - /1007/1008 原样透传。 +### 部署与冒烟 + +```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 章节。 --- diff --git a/gateway/app.py b/gateway/app.py index a974996..20b846e 100644 --- a/gateway/app.py +++ b/gateway/app.py @@ -588,7 +588,77 @@ async def head_band(request: Request): 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(request: Request): - """接口99:人脸特殊点检测(固定关键点 + 发际线特殊点像素坐标)""" - return await _proxy(request, "/api/v1/face/special-points") +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})