From 8cd44848d672ba17790364f2e17d3b6999509854 Mon Sep 17 00:00:00 2001 From: xsl Date: Tue, 23 Jun 2026 20:27:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=8E=A5=E5=8F=A36):=20=E5=A4=8D=E5=88=BB?= =?UTF-8?q?=E6=8E=A5=E5=8F=A31=EF=BC=8C=E6=96=B0=E5=A2=9E=20/api/v1/face/m?= =?UTF-8?q?easure-v2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 抽取 _face_measure_impl() 共用实现,接口1/6 零逻辑差异 - 接口6 路径 POST /api/v1/face/measure-v2 - 入参/出参与接口1 完全一致 - 文档同步更新(接口文档、实现说明、网关待改动) Co-Authored-By: Claude --- app.py | 203 ++++++++++++++++++++++++++++++++++----------- docs/实现说明.md | 8 +- docs/接口文档.md | 55 +++++++++++- docs/网关待改动.md | 24 ++++++ 4 files changed, 239 insertions(+), 51 deletions(-) diff --git a/app.py b/app.py index 765d12e..d054567 100644 --- a/app.py +++ b/app.py @@ -316,9 +316,71 @@ class ImageJsonBody(BaseModel): # --------------------------------------------------------------------------- -# 接口 1:四庭七眼测量标注 +# 接口 1/6 共用实现 # --------------------------------------------------------------------------- + +async def _face_measure_impl(image_file, image_url, image_base64): + """接口1/6 共用实现:四庭七眼测量 + 标注图生成。返回 (ok_dict, err_dict)。""" + # 1. 三选一取图 + raw, e = await resolve_image_bytes(image_file, image_url, image_base64) + if e is not None: + return None, e + + # 2. 大小校验(≤ 1MB) + if len(raw) > MAX_FILE_BYTES: + return None, err(1006, "文件超出 1 MB 限制") + + # 3. 解码 + image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR) + if image is None: + return None, err(1008, "图片格式不支持(仅 JPG / PNG)") + + # 4. 分辨率(短边/长边,方向无关,可配置门槛) + h, w = image.shape[:2] + short_side, long_side = min(w, h), max(w, h) + if short_side < MIN_SHORT_SIDE or long_side < MIN_LONG_SIDE: + return None, err(1002, "人像分辨率过低") + + try: + from face_analysis.detector import detector + from face_analysis.pose import estimate_head_pose, check_frontal_face + from face_analysis.measure import measure_face + from face_analysis.annotation import create_annotated_image + + # 5. 人脸检测 + landmarks = detector.detect(image) + if landmarks is None: + return None, err(1001, "无法识别人像") + + # 6. 姿态校验 + if not check_frontal_face(landmarks, w, h): + return None, err(1003, "角度问题,请上传正面照") + head_pose = estimate_head_pose(landmarks, w, h) + + # 7. 头发分割(方案 B),失败传 None 由 measure 内部回退方案 A + hair_mask = None + try: + from face_analysis.hair_segmenter import get_segmenter + hair_mask = get_segmenter().segment_hair(image) + except Exception as seg_e: # noqa: BLE001 + logger.warning("头发分割失败,回退方案A:%s", seg_e) + + # 8. 测量 + 标注图 + result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose) + annotated = create_annotated_image(image, result) + buf = BytesIO() + annotated.save(buf, format="PNG") + + # 9. 拆分架构:返回 base64,不落盘不拼 URL(落盘改 URL 由网关完成) + data = result.to_response() + data["annotated_image_base64"] = base64.b64encode(buf.getvalue()).decode() + return ok(data), None + except Exception as ex: # noqa: BLE001 + logger.exception("接口1/6 处理异常") + return None, err(1007, f"处理失败:{ex}") + + @app.post( "/api/v1/face/measure", summary="接口1 四庭七眼测量标注", @@ -405,63 +467,106 @@ async def face_measure( image_url: Optional[str] = Form(default=None, description="图片 URL"), image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"), ): - # 1. 三选一取图 - raw, e = await resolve_image_bytes(image_file, image_url, image_base64) - if e is not None: - return e + """接口1:四庭七眼测量标注""" + ok_data, err_data = await _face_measure_impl(image_file, image_url, image_base64) + return ok_data if ok_data is not None else err_data - # 2. 大小校验(≤ 1MB) - if len(raw) > MAX_FILE_BYTES: - return err(1006, "文件超出 1 MB 限制") - # 3. 解码 - image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR) - if image is None: - return err(1008, "图片格式不支持(仅 JPG / PNG)") +# --------------------------------------------------------------------------- +# 接口 6:四庭七眼测量标注 v2(复刻接口1) +# --------------------------------------------------------------------------- - # 4. 分辨率(短边/长边,方向无关,可配置门槛) - h, w = image.shape[:2] - short_side, long_side = min(w, h), max(w, h) - if short_side < MIN_SHORT_SIDE or long_side < MIN_LONG_SIDE: - return err(1002, "人像分辨率过低") +@app.post( + "/api/v1/face/measure-v2", + summary="接口6 四庭七眼测量标注 v2", + tags=["人脸分析"], + description=f""" +输入用户正面照,返回: +- 标注好四庭七眼数据的 **PNG 图片**(仅标注图层,不含人物) +- 四庭(顶庭/上庭/中庭/下庭)各段**厘米数值及占比** +- 七眼(眼宽/脸宽/两眼间距)**厘米数值及占比** +- 五个关键分界点的**原图像素坐标**(头顶/发际线/眉心/鼻翼下缘/下巴尖) - try: - from face_analysis.detector import detector - from face_analysis.pose import estimate_head_pose, check_frontal_face - from face_analysis.measure import measure_face - from face_analysis.annotation import create_annotated_image +功能与接口1 完全一致,复刻实现。 - # 5. 人脸检测 - landmarks = detector.detect(image) - if landmarks is None: - return err(1001, "无法识别人像") +{_image_fields_desc} - # 6. 姿态校验 - if not check_frontal_face(landmarks, w, h): - return err(1003, "角度问题,请上传正面照") - head_pose = estimate_head_pose(landmarks, w, h) +图片同时支持 `multipart/form-data` 文件上传(字段名 `image_file`),或 JSON Body 传 `image_url` / `image_base64`。 - # 7. 头发分割(方案 B),失败传 None 由 measure 内部回退方案 A - hair_mask = None - try: - from face_analysis.hair_segmenter import get_segmenter - hair_mask = get_segmenter().segment_hair(image) - except Exception as seg_e: # noqa: BLE001 - logger.warning("头发分割失败,回退方案A:%s", seg_e) +--- - # 8. 测量 + 标注图 - result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose) - annotated = create_annotated_image(image, result) - buf = BytesIO() - annotated.save(buf, format="PNG") +**坐标说明**:所有坐标以原图像素为基准,原点为图片左上角,x 向右,y 向下。 - # 9. 拆分架构:返回 base64,不落盘不拼 URL(落盘改 URL 由网关完成) - data = result.to_response() - data["annotated_image_base64"] = base64.b64encode(buf.getvalue()).decode() - return ok(data) - except Exception as ex: # noqa: BLE001 - logger.exception("接口1 处理异常") - return err(1007, f"处理失败:{ex}") +**标注图片 UI 规范**(真实版本生效): +- 字体/线条颜色:`#FFFFFF 100%` +- 四庭数值在图片**左侧**呈现,七眼间距**上下穿插**展示 +- 字体:PingFangSC-Regular 10pt,线宽 1pt +- 横线/竖线渐变消失,虚线两侧带箭头 +""", + responses={ + 200: { + "description": "成功", + "content": { + "application/json": { + "example": { + "code": 0, + "message": "success", + "request_id": "mock-request-id", + "data": { + "annotated_image_url": SAMPLE_IMAGE_URL, + "face_total_height_cm": 13.76, + "four_courts": { + "top_court_cm": 3.44, + "upper_court_cm": 3.44, + "middle_court_cm": 3.44, + "lower_court_cm": 3.44, + "ratios": { + "top_court": 0.25, + "upper_court": 0.25, + "middle_court": 0.25, + "lower_court": 0.25, + }, + }, + "seven_eyes": { + "eye_width_cm": 3.44, + "face_width_cm": 24.08, + "inter_eye_distance_cm": 3.44, + "ratios": {"eye_width": 0.143, "inter_eye_distance": 0.143}, + }, + "landmarks": { + "hair_top": {"x": 540, "y": 120}, + "hairline": {"x": 540, "y": 430}, + "brow_center": {"x": 540, "y": 740}, + "nose_bottom": {"x": 540, "y": 1050}, + "chin_tip": {"x": 540, "y": 1360}, + }, + }, + } + } + }, + }, + 400: { + "description": "参数错误 / 图片识别失败", + "content": { + "application/json": { + "examples": { + "图片参数错误": {"value": {"code": 1007, "message": "图片参数错误:必须且只能传 image_file / image_url / image_base64 其中一个", "request_id": "x", "data": None}}, + "无法识别人像": {"value": {"code": 1001, "message": "无法识别人像", "request_id": "x", "data": None}}, + "多张人脸": {"value": {"code": 1005, "message": "检测到多张人脸,仅支持单人照片", "request_id": "x", "data": None}}, + } + } + }, + }, + }, +) +async def face_measure_v2( + image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG,≤ 1 MB)"), + image_url: Optional[str] = Form(default=None, description="图片 URL"), + image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"), +): + """接口6:四庭七眼测量标注 v2(复刻接口1)""" + ok_data, err_data = await _face_measure_impl(image_file, image_url, image_base64) + return ok_data if ok_data is not None else err_data # --------------------------------------------------------------------------- diff --git a/docs/实现说明.md b/docs/实现说明.md index 9594a9e..a7422d2 100644 --- a/docs/实现说明.md +++ b/docs/实现说明.md @@ -13,7 +13,7 @@ ``` 客户端 ──HTTPS──> 外网网关(gateway/) ──HTTP(X-Internal-Token)──> worker(GPU 机, app.py) │ 薄代理 + 落盘改URL │ 跑算法(本地模型/ComfyUI) - └ 接口4 本机直接调豆包(不转发) └ 接口1/2/3/5/7 + └ 接口4 本机直接调豆包(不转发) └ 接口1/2/3/5/6/7 ``` - **worker**(`app.py` + `face_analysis/` + `hairline/`):跑真正的算法,**纯本地、无外网依赖**。 @@ -29,6 +29,7 @@ | 接口 | worker 字段(内部) | 对外字段 | |------|--------------------|----------| | 1 | `annotated_image_base64` | `annotated_image_url` | +| 6 | `annotated_image_base64` | `annotated_image_url` | | 2 | `results[].image_base64` / `results[].grown_image_base64`(可空) | `results[].image_url` / `results[].grown_image_url` | | 3 | `hair_growth_image_base64`(可空) | `hair_growth_image_url` | | 5 | `hairline_images[].image_base64` | `hairline_images[].image_url` | @@ -55,6 +56,11 @@ 失败回退比例推算(方案A,`hairline_source` 透出)。标注图 numpy 向量化渐变线 + 思源黑体。返回 `annotated_image_base64`。 - 门槛可配:`MIN_SHORT_SIDE`/`MIN_LONG_SIDE`(默认600/800)、姿态阈值 `FRONTAL_*_THR`(默认30°)。 +### 接口6 四庭七眼测量 v2 `/api/v1/face/measure-v2`(worker)—— 复刻接口1 +- **做什么**:与接口 1 完全一致(正面照 → 四庭七眼 cm 与占比 + 5 个关键点 + 标注 PNG)。 +- **怎么实现**:与接口 1 共用 `_face_measure_impl()`,零额外逻辑。对外路径 `/api/v1/face/measure-v2`。 +- **网关改动**:新增路由 `POST /api/v1/face/measure-v2`,转发到 worker 同路径;base64→URL 改写无需改动。 + ### 接口2 C端生发 `/api/v1/hair/grow`(worker)—— 预览 + 生发图 - **做什么**:正面照 + `gender`(必填) + `hair_style`(必填,逗号分隔多选,如 `1,2,3`) → 指定发际线类型 **N 组**:**预览图**(发际线叠在照片上) + **生发后图**(植发3个月效果)。 - **怎么实现**(`hairline/`):移植 head3d——MediaPipe(Tasks) + SegFormer 分割 + 17 锚点射线检测 → 502 点 mesh, diff --git a/docs/接口文档.md b/docs/接口文档.md index 8164b9e..8566155 100644 --- a/docs/接口文档.md +++ b/docs/接口文档.md @@ -15,6 +15,7 @@ | 接口 | 方法 | 路径 | |------|------|------| | 1 四庭七眼测量 | POST | `/api/v1/face/measure` | +| 6 四庭七眼测量 v2 | POST | `/api/v1/face/measure-v2` | | 2 C 端生发 | POST | `/api/v1/hair/grow` | | 3 B 端生发 | POST | `/api/v1/hair/grow-b` | | 4 用户特征 | POST | `/api/v1/face/features` | @@ -176,6 +177,57 @@ --- +## 接口 6:四庭七眼测量 v2 接口 + +**说明**:功能与[接口 1](#接口-1四庭七眼测量标注接口)完全一致,复刻实现。输入用户正面照,返回四庭七眼测量数据和标注 PNG。 + +**请求**:`POST /api/v1/face/measure-v2` + +### 输入 + +与接口 1 完全相同。图片参数见「通用约定 → 图片传参字段」(`image_file` / `image_url` / `image_base64` 三选一)。本接口无其他专属参数。 + +### 输出(data) + +与接口 1 完全相同。详见[接口 1 输出](#接口-1四庭七眼测量标注接口)。 + +| 字段 | 类型 | 说明 | +|------|------|------| +| annotated_image_url | string | 标注图层 PNG URL(透明底,仅标注线/文字,不含人物) | +| face_total_height_cm | number | 面部总高度(cm) | +| four_courts | object | 四庭数据(顶庭/上庭/中庭/下庭,各含 cm 与 ratio) | +| seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距,各含 cm 与 ratio) | +| landmarks | object | 五个关键点像素坐标 | + +### 响应示例 + +```json +{ + "code": 0, + "message": "success", + "request_id": "mock-request-id", + "data": { + "annotated_image_url": "https://hair.xiangsilian.com/static/sample.jpg", + "face_total_height_cm": 13.76, + "four_courts": { + "top_court_cm": 3.44, "upper_court_cm": 3.44, "middle_court_cm": 3.44, "lower_court_cm": 3.44, + "ratios": { "top_court": 0.25, "upper_court": 0.25, "middle_court": 0.25, "lower_court": 0.25 } + }, + "seven_eyes": { + "eye_width_cm": 3.44, "face_width_cm": 24.08, "inter_eye_distance_cm": 3.44, + "ratios": { "eye_width": 0.143, "inter_eye_distance": 0.143 } + }, + "landmarks": { + "hair_top": { "x": 540, "y": 120 }, "hairline": { "x": 540, "y": 430 }, + "brow_center": { "x": 540, "y": 740 }, "nose_bottom": { "x": 540, "y": 1050 }, + "chin_tip": { "x": 540, "y": 1360 } + } + } +} +``` + +--- + ## 接口 2:C 端生发接口 **说明**:输入用户正面照 + 性别 + 发型序号(可多选),按指定发际线类型渲染预览图 + 生发图。 @@ -421,7 +473,8 @@ | 接口 | 输入 | 主要输出 | |------|------|----------| | 1 四庭七眼测量 | 用户照片 | 标注 PNG(无人物)+ 四庭/七眼厘米数值与坐标 | -| 2 C 端生发 | 用户照片 | 生发后图片 + 指定发际线预览(单张) | +| 6 四庭七眼测量 v2 | 用户照片 | 同接口1,复刻实现 | +| 2 C 端生发 | 用户照片 | 生发后图片 + 指定发际线预览(单/多张) | | 3 B 端生发 | 划线图片 | 最合适发际线图片 + 生发后图片 | | 4 用户特征 | 用户照片 | 6 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格) | | 5 发际线 PNG | 用户照片 | N 张发际线 PNG + 最合适发际线面部中间点坐标 | diff --git a/docs/网关待改动.md b/docs/网关待改动.md index f4acec7..1cdbe1c 100644 --- a/docs/网关待改动.md +++ b/docs/网关待改动.md @@ -109,6 +109,30 @@ async def hair_grow_v2(request: Request): 若想让网关 `/docs` 展示准确,在 `gateway/app.py` 新增 `_GROW_V2_FORMS`(或复用 `_GROW_FORMS` 并补充 `gender`/`hair_style` 字段),然后将路由函数签名改为显式声明 Form 参数(参考接口 4 的写法)。不改也不影响实际转发。 + + +--- + +## 6. 🔲【新增】接口6 四庭七眼测量 v2(`/api/v1/face/measure-v2`) + +**背景**:worker 侧已新增接口 6,功能与接口 1 完全一致(复刻),共用同一实现。 + +**网关需新增一个路由**: + +```python +# gateway/app.py + +@app.post("/api/v1/face/measure-v2", tags=["人脸分析"]) +async def face_measure_v2(request: Request): + """接口6:四庭七眼测量 v2(复刻接口1)""" + return await _proxy(request, "/api/v1/face/measure-v2") +``` + +**无需额外改动**: +- 入参:与接口 1 完全相同(image_file/url/base64 三选一) +- 出参:`annotated_image_base64` → 经现有 `rewrite_base64_to_url` 自动改写为 `annotated_image_url` +- worker 侧与接口 1 共用 `_face_measure_impl()`,逻辑零差异 + --- ## 已经做好、无需再动的