From 51baf96d78bea4caf3969e1a838153d140e59dc1 Mon Sep 17 00:00:00 2001 From: xsl Date: Sat, 7 Nov 2026 20:50:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(gateway):=20=E6=8E=A5=E5=85=A542=E5=92=8C7?= =?UTF-8?q?2=E5=B9=B6=E4=B8=BA42/52/72=E5=90=AF=E7=94=A8=E5=8F=AF=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E4=BB=A4=E7=89=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/README.md | 1 + docs/实现说明.md | 2 +- docs/接口42-52-72网关鉴权.md | 35 ++++ docs/接口52网关接入.md | 10 +- docs/接口文档.md | 91 +++++++++- docs/网关待改动.md | 2 +- face_analysis/face_ratios.py | 101 +++++++++++ face_analysis/special_points.py | 6 +- face_features.py | 2 + gateway/app.py | 112 +++++++++--- gateway/client_auth.py | 147 +++++++++++++++ gateway/config.example.json | 2 +- gateway/config.py | 6 +- gateway/logging_middleware.py | 2 +- hairline/face_landmarks.py | 31 +++- hairline/framing.py | 231 ++++++++++++++++++++++++ hairline/reference/framing_profile.json | 43 +++++ static/client_auth.js | 64 +++++++ static/integration.html | 3 +- static/test_interface42.html | 218 ++++++++++++++++++++++ static/test_interface52.html | 10 +- static/test_interface72.html | 135 ++++++++++++++ tests/test_gateway_client_auth.py | 95 ++++++++++ tests/test_gateway_interface52.py | 89 ++------- 24 files changed, 1312 insertions(+), 126 deletions(-) create mode 100644 docs/接口42-52-72网关鉴权.md create mode 100644 face_analysis/face_ratios.py create mode 100644 gateway/client_auth.py create mode 100644 hairline/framing.py create mode 100644 hairline/reference/framing_profile.json create mode 100644 static/client_auth.js create mode 100644 static/test_interface42.html create mode 100644 static/test_interface72.html create mode 100644 tests/test_gateway_client_auth.py diff --git a/docs/README.md b/docs/README.md index 201f07c..49066b0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,6 +7,7 @@ |------|------| | [实现说明.md](实现说明.md) | **实现总览**:架构、五个接口怎么实现、base64→URL 映射、错误码、部署/环境要点。先读这份。 | | [接口文档.md](接口文档.md) | 对外 API 契约(字段 / 错误码)。字段命名的唯一权威。 | +| [42/52/72 网关鉴权](接口42-52-72网关鉴权.md) | 登录、过期、刷新、令牌签名配置与测试页。 | | [接口52网关接入.md](接口52网关接入.md) | 52号请求透传、图片URL与空字段约定、公开测试页及网关部署步骤。 | | [旷视具体需求.md](旷视具体需求.md) | 原始需求。 | | [../OFFLINE_ASSETS.md](../OFFLINE_ASSETS.md) | worker 离线模型权重/字体清单(内网部署)。 | diff --git a/docs/实现说明.md b/docs/实现说明.md index 15da1f2..e3f2a8a 100644 --- a/docs/实现说明.md +++ b/docs/实现说明.md @@ -99,7 +99,7 @@ - **怎么实现**:复用接口2 的 502 点渲染管线,三档分别用 `hairline_texture[/_high|/_low]` 同名贴图渲染叠图;生发同接口2(ComfyUI inpaint),**黑模板固定取 `hairline_texture_black/`(middle)**,每发型 1 张生发图。`best_hairline_center_point`=眉心 x × 首个选中发型 middle 档发际线 y。 ### 接口52 发际线新规则 `/api/v1/hairline/generate-52`(网关透传) -- **客户端鉴权**:请求头`X-API-Token`,默认`yonghe1999`,可由`gateway/config.json`的`interface52_token`覆盖;不匹配返回HTTP401/code1009且不进入队列。客户端Token不传给worker,内部Token仍由网关添加。 +- **客户端鉴权**:登录后发送 `Authorization: Bearer `。校验失败返回 HTTP 401、`code=1009`,不进入队列。详见[客户端鉴权](接口42-52-72网关鉴权.md)。 - `main`新增网关路由和`static/test_interface52.html`。GPU worker负责中下庭均值定位、扶正裁切及生发;主备worker均需部署52号实现,网关只透传表单并附加内部Token。 - 非空图片使用原有`*_base64`→`*_url`落盘改写;空图片保留`*_base64: null`。metadata和坐标原样返回,middle叠加到`input_image_url`。旧接口、5号队列优先级及网关IP策略保持原样。 - 参数、空字段与部署步骤见[接口52网关接入](接口52网关接入.md)。worker分支的IP限制与算法不合入本次网关改动。 diff --git a/docs/接口42-52-72网关鉴权.md b/docs/接口42-52-72网关鉴权.md new file mode 100644 index 0000000..dbef631 --- /dev/null +++ b/docs/接口42-52-72网关鉴权.md @@ -0,0 +1,35 @@ +# 42、52、72 号接口网关鉴权 + +| 接口 | 方法与路径 | 执行位置 | +|---|---|---| +| 42 | `POST /api/v1/face/features-42` | 网关按接口 52 的规则扶正裁切,再用接口 4 的分析结果和 Ft/Zy 判定生成特征 | +| 52 | `POST /api/v1/hairline/generate-52` | 网关转发至 worker | +| 72 | `POST /api/v1/face/b-end-points` | 网关转发至 worker,底图与标注层转成 URL | + +客户端调用这三个接口时,在原请求上添加 `Authorization: Bearer `。缺少、过期、签名无效或误用刷新令牌时,返回 HTTP 401 和业务码 `1009`。网关验证通过后,52 和 72 继续使用独立的 `X-Internal-Token` 转发至 worker,客户端令牌不会传给 worker。 + +## 登录 + +`POST /api/v1/auth/login`,请求体为 JSON,包含 `username` 和 `password`。当前用户名为 `yonghe`,密码使用约定的服务端固定值。 + +成功时 HTTP 200、`code=0`,`data` 包含: + +| 字段 | 值 | +|---|---| +| `access_token` | 访问令牌 | +| `token_type` | `Bearer` | +| `expires_in` | 900 秒 | +| `refresh_token` | 刷新令牌 | +| `refresh_expires_in` | 604800 秒,即 7 天 | + +## 刷新 + +`POST /api/v1/auth/refresh`,请求体为 `{"refresh_token":"登录返回的刷新令牌"}`。成功后返回新的 `access_token`、`token_type` 和 `expires_in`。登录时的刷新令牌可以在原有效期内继续使用。到期后重新登录。登录或刷新失败时,响应包含 `code`、`message`、`request_id` 和 `data: null`。 + +## 网关配置 + +在 `main` 的网关环境中设置 `HAIR_API_TOKEN_SECRET`,或在未跟踪的 `gateway/config.json` 中设置 `api_token_secret`。使用独立且稳定的至少 32 字节随机值。签名密钥缺失时,签发或刷新返回 HTTP 503 和业务码 `1007`。更换签名密钥会使已签发令牌失效。 + +网关保留原有 Ark、MediaPipe 与 BiSeNet 配置,42 的裁切还使用 Pillow。部署 72 前,确认所有可接收该请求的 worker 已部署含 72 号的版本(例如 `ubuntu3090_debug` 的 `c9a47a7` 或后续版本)。52、72 沿用现有 worker 池与图片 URL 改写流程。 + +网关测试页为 `/static/test_interface42.html`、`/static/test_interface52.html` 和 `/static/test_interface72.html`,页面先登录再提交,访问令牌到期时自动刷新。 diff --git a/docs/接口52网关接入.md b/docs/接口52网关接入.md index c46d3e4..f207056 100644 --- a/docs/接口52网关接入.md +++ b/docs/接口52网关接入.md @@ -7,16 +7,16 @@ 客户端 → `POST /api/v1/hairline/generate-52` → 网关现有队列和worker池 → worker同路径 → 网关落盘非空图片并返回公网URL。 - 请求体原样转发,网关附加配置中的`X-Internal-Token`;客户端和公开测试页不携带内部密码。 -- 客户端须通过`X-API-Token`传入52号静态Token,默认`yonghe1999`。网关校验后才进入队列;该Token不转发给worker,与内部密码独立。 +- 客户端须登录取得访问令牌,通过 `Authorization: Bearer ` 发送。网关鉴权后进入队列。 - 沿用现有主备派发、隔离期、重试、日志和全局并发限制。原有5号插队优先级保持原样,52号走默认队列。 - 5号`/api/v1/hairline/generate`和其他接口保持原样;网关不新增IP限制。 - worker的中下庭规则、扶正、3:4裁切和生发结果由同一次worker响应提供,网关不重新计算坐标。 ## 请求 -请求头必填:`X-API-Token: yonghe1999`。缺少、为空或不匹配时返回HTTP401,响应为`{"code":1009,"message":"未授权:缺少或错误的 X-API-Token","request_id":"gw-…","data":null}`,不读取转发表单、不占用GPU队列。其他接口不增加这项校验。 +缺少、无效或过期的访问令牌返回 HTTP 401、`code=1009`。登录、刷新与部署配置见[客户端鉴权](接口42-52-72网关鉴权.md)。 + -可在`gateway/config.json`设置`"interface52_token": "自定义值"`;旧配置省略该项时自动使用`yonghe1999`。显式配置空值或非字符串会导致启动校验失败,不能用来关闭鉴权。配置修改后重启网关生效,测试页输入框改为相同值。 使用`multipart/form-data`;图片三选一:`image_file`、`image_url`、`image_base64`。必填`gender=male/female`和`hair_style=1..7`(支持逗号分隔多选)。 @@ -56,7 +56,7 @@ worker始终返回`*_base64`。网关沿用原有公共改写,不添加52号 ```bash curl -X POST https://hair.xiangsilian.com/api/v1/hairline/generate-52 \ - -H 'X-API-Token: yonghe1999' \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ -F 'image_file=@/path/to/photo.jpg' \ -F 'gender=male' -F 'hair_style=1' \ -F 'normalize_image=true' -F 'generate_grow_image=false' @@ -64,7 +64,7 @@ curl -X POST https://hair.xiangsilian.com/api/v1/hairline/generate-52 \ 成功时`code=0`,检查`input_image_url`和`hairline_images[].image_middle_url`可打开、尺寸一致;high/low仍为`*_base64: null`。访问测试页时无需输入内部Token。 -测试页新增“客户端Token”输入框,默认`yonghe1999`,只作为`X-API-Token`请求头发送;自定义后端Token时应同步填写该值。 +测试页先登录再提交图片,访问令牌过期时使用当前刷新令牌自动换取新的访问令牌。 ## 验证 diff --git a/docs/接口文档.md b/docs/接口文档.md index 2379d10..ccd2dd5 100644 --- a/docs/接口文档.md +++ b/docs/接口文档.md @@ -19,6 +19,8 @@ | 2 C 端生发 | POST | `/api/v1/hair/grow` | | 3 B 端生发 | POST | `/api/v1/hair/grow-b` | | 4 用户特征 | POST | `/api/v1/face/features` | +| 42 用户特征与两项判定 | POST | `/api/v1/face/features-42` | +| 72 B 端五个关键点 | POST | `/api/v1/face/b-end-points` | | 5 发际线 PNG 生成 | POST | `/api/v1/hairline/generate` | | 52 发际线新规则 | POST | `/api/v1/hairline/generate-52` | @@ -87,7 +89,7 @@ | 1006 | 文件超出大小限制 | 单文件超过 1 MB | | 1007 | 图片参数错误 | file / url / base64 未传,或同时传了多个(三者严格互斥) | | 1008 | 图片格式不支持 | 非 JPG / PNG | -| 1009 | 未授权 | worker内部`X-Internal-Token`错误;网关52号缺少或错误的客户端`X-API-Token`。HTTP401 | +| 1009 | 未授权 | worker内部`X-Internal-Token`错误;网关42/52/72号客户端 Bearer 令牌缺失、无效或过期。HTTP401 | --- @@ -445,6 +447,91 @@ --- +## 接口 72:B 端五个关键点 + +`POST /api/v1/face/b-end-points` 接受三选一图片参数 `image_file`、`image_url`、`image_base64`。复用接口 52 的扶正裁切与虹膜/眼宽厘米估算,返回坐标以处理后底图为准。使用[客户端 Bearer 鉴权](接口42-52-72网关鉴权.md)。 + +| 点号 | 位置 | 标注 | +|---|---|---| +| 1 | MediaPipe 4 | 白点 | +| 2 | HL center | 白点 | +| 3 | HL center 向画面右侧水平偏移 2 cm | 白点 | +| 4 | HL left | 白点 | +| 5 | HL left left | 白点 | +| 6 | MediaPipe 446 | 白点,兼作射线锚点 | + +六个白点的直径相同。标注层包含三条从起点向目标方向延伸并逐渐变淡的白色虚线(2→1、4→6、5→6),以及 2→3 白色虚线段。2→1 射线比默认可见长度短 15%,5→6 射线的绘制长度比默认长度多 30%,可见范围以图像边界为限。四个距离文字分别放在 2–1 中点左侧、2–3 中点上方、4–6 中点左侧和 5–6 中点右侧,距离保留一位小数,2–3 固定显示 `2.0 CM`。 + +| `data` 字段 | 内容 | +|---|---| +| `input_image_url` | 扶正裁切后的底图 | +| `annotated_image_url` | 与底图同尺寸的透明 PNG 标注层 | +| `points` | 按字符串点号 `"1"` 到 `"6"` 索引的像素坐标 `{x, y}` | +| `measurements_cm` | 四个距离,以 `2-1`、`2-3`、`4-6`、`5-6` 索引 | +| `image_size` | 处理后尺寸 `{width, height}` | +| `coordinate_space` | `processed_image` | +| `px_per_cm` | 估算的每厘米像素数 | + +Worker 直连时图片字段使用 `_base64` 后缀。检测不到人脸返回 `1001`,非正面或标注所需点位无法取得时返回 `1003`,图片无法解码返回 `1008`。 + +--- + +## 42号接口-加两个特征点 + +**请求** `POST /api/v1/face/features-42`,使用 `multipart/form-data`。 + +| 请求字段 | 类型 | 说明 | +|---|---|---| +| `image_file` | 文件 | 上传 JPG 或 PNG | +| `image_url` | 字符串 | 网关可访问的图片 URL | +| `image_base64` | 字符串 | 图片 Base64,支持带 `data:image/…;base64,` 前缀 | + +以上三个字段必须且只能传一个。图片按 EXIF 方向解码后,先检查原图的人脸完整性和俯拍角度。俯拍使用头部姿态估算的正向 pitch,超过服务端环境变量 `FRONTAL_PITCH_THR`(默认 30°)时拦截。通过质检后复用接口 52 的扶正裁切规则,再用处理后的图片做面部特征分析。 + +客户端通过 Bearer 令牌鉴权,详见[客户端鉴权](接口42-52-72网关鉴权.md)。 + +### 质检失败与异常响应 + +以下业务响应的 HTTP 状态均为 `200`。 + +| `code` | 情况 | `message` | +|---|---|---| +| `1001` | 原图未检测到人脸 | 无法识别人像,请上传包含完整人脸的照片 | +| `1003` | 人脸关键点触及或超出图片边界 | 人脸不完整,请保留完整的脸部轮廓 | +| `1003` | 估算的俯拍角度超过阈值 | 俯拍角度过大,请将相机调整至与脸部平视 | +| `1003` | 扶正裁切失败,无法保留所需人脸关键点 | 人脸过于贴近边缘,无法扶正裁切 | +| `1008` | URL 图片下载失败 | 图片下载失败(URL 不可达) | +| `1008` | Base64 解码失败 | 图片格式不支持(base64 解码失败) | +| `1008` | 图片无法读取 | 图片格式不支持(仅 JPG / PNG) | +| `1007` | 图片字段未按三选一传入 | 图片参数错误:必须且只能传 image_file / image_url / image_base64 其中一个 | +| `1007` | 图片预处理内部异常 | 图片预处理失败 | +| `1001` | 质检通过,但视觉模型未识别到人脸 | 无法识别人像 | +| `1007` | 视觉模型调用异常 | 分析服务异常:… | + +响应包含 `code`(整数业务码)、`message`(字符串提示)、`request_id`(字符串请求标识)和 `data`(失败时为 `null`)。三个 `1003` 情况可通过 `message` 区分,响应中没有独立的质检原因或角度字段。 + +```json +{ + "code": 1003, + "message": "俯拍角度过大,请将相机调整至与脸部平视", + "request_id": "gw-xxxxxxxx", + "data": null +} +``` + +### 成功响应 + +`data.features` 仍为 JSON 字符串,保留 main 网关接口 4 的七项特征(含 `contour_type`),在其中新增: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `zygoma_expansion` | boolean 或 null | 颧弓外扩 | +| `temple_hollowing` | boolean 或 null | 太阳穴凹陷 | + +Ft 两端保持横坐标并取平均高度,Zy 为 234 到 454 的距离。只有 Ft ÷ Zy 严格小于 0.8 时两项同时为 `true`,否则同时为 `false`。所需点位缺失或无法测量时两项均为 `null`。网关测量复用接口 99 的 MediaPipe 与 BiSeNet 依赖,裁切还需要 Pillow。 + +--- + ## 接口 5:发际线 PNG 生成接口 **说明**:入参同接口2(先选性别、再多选发型)。对每个选中发型返回 `middle` / `high` / `low` **三档**发际线叠图与**生发图**,并返回「最合适发际线」的面部中间点坐标。 @@ -574,7 +661,7 @@ **请求**:`POST /api/v1/hairline/generate-52`(网关透传至同路径GPU worker) -**客户端鉴权**:请求头必填`X-API-Token: yonghe1999`,缺少、为空或不匹配返回HTTP401/code1009,不进入worker队列。默认值可通过网关`gateway/config.json`的`interface52_token`覆盖,修改后重启网关;内部`X-Internal-Token`仍由网关注入,客户端无需提供。该校验仅用于网关52号。 +**客户端鉴权**:使用 `Authorization: Bearer `。登录、刷新和错误码见[客户端鉴权](接口42-52-72网关鉴权.md)。 **职责**:GPU worker返回base64并完成算法处理,网关负责透传参数、附加内部Token、保存非空图片及返回公网URL。网关不增加IP限制;网关出口IP须已在worker白名单内。部署与完整字段约定见[接口52网关接入](接口52网关接入.md)。 diff --git a/docs/网关待改动.md b/docs/网关待改动.md index f4c413a..6a3f33b 100644 --- a/docs/网关待改动.md +++ b/docs/网关待改动.md @@ -7,7 +7,7 @@ ## 接口52(2026-11-04 已接入main) -客户端新增请求头`X-API-Token`校验,默认`yonghe1999`,可在网关配置`interface52_token`覆盖;缺少或不匹配返回HTTP401/code1009,不进入队列。其他接口不增加该校验。 +42、52、72 统一采用[客户端登录与刷新令牌](接口42-52-72网关鉴权.md)。校验失败返回 HTTP 401、`code=1009`。 新增`POST /api/v1/hairline/generate-52`,由网关转发到GPU worker同路径。5号保持原样,52号的`normalize_image`等参数原样透传;非空图片沿用公共base64→URL改写,空图片仍保留`*_base64: null`。不在网关增加IP限制,不合入worker算法。 diff --git a/face_analysis/face_ratios.py b/face_analysis/face_ratios.py new file mode 100644 index 0000000..e37a0d1 --- /dev/null +++ b/face_analysis/face_ratios.py @@ -0,0 +1,101 @@ +"""Framed Ft and Zy measurements shared by interface 42 and the ratio preview.""" + +import math + +FT_POINTS = ("hair_line_right_right", "hair_line_left_left") +ZY_POINTS = ("face_edge_234", "face_edge_454") + + +class PortraitQualityError(ValueError): + """An input that must be rejected before feature analysis.""" + + def __init__(self, code, message): + super().__init__(message) + self.code = code + + +def prepare_feature_portrait(image_bytes): + """Check the source image before applying interface 52 framing.""" + import cv2 + import numpy as np + + from face_analysis.pose import PITCH_THRESHOLD, estimate_head_pose + from hairline.face_landmarks import get_landmarker, to_landmark_list + from hairline.framing import decode_portrait, normalize_portrait + + image = decode_portrait(image_bytes) + if image is None: + raise PortraitQualityError(1008, "图片格式不支持(仅 JPG / PNG)") + + landmarks = get_landmarker().detect( + cv2.cvtColor(image, cv2.COLOR_BGR2RGB), include_iris=True + ) + if landmarks is None: + raise PortraitQualityError(1001, "无法识别人像,请上传包含完整人脸的照片") + + h, w = image.shape[:2] + face_xy = landmarks[:468, :2] * [w, h] + if not np.isfinite(face_xy).all() or ( + (face_xy <= 0).any() or (face_xy >= [w - 1, h - 1]).any() + ): + raise PortraitQualityError(1003, "人脸不完整,请保留完整的脸部轮廓") + + pose = estimate_head_pose(to_landmark_list(landmarks), w, h) + # In the camera coordinate system, positive pitch points the face down. + if pose is not None and pose[1] > PITCH_THRESHOLD: + raise PortraitQualityError(1003, "俯拍角度过大,请将相机调整至与脸部平视") + + try: + image, landmarks, _ = normalize_portrait(image, landmarks) + except ValueError as exc: + raise PortraitQualityError(1003, "人脸过于贴近边缘,无法扶正裁切") from exc + return image, landmarks + + +def detect_framed_ratio_points(image): + """Use the interface 52 crop and return the endpoints in its coordinate space.""" + from hairline.framing import prepare_portrait + + image, landmarks, framing = prepare_portrait(image) + data = _detect_ratio_points(image, landmarks) + data.update(framing=framing, coordinate_space="processed_image") + return image, data + + +def _detect_ratio_points(image, landmarks): + from hairline.face_landmarks import to_landmark_list + + from .special_points import detect_special_points + + data = detect_special_points(image, landmarks_override=to_landmark_list(landmarks)) + points = data["points"] + data["ft_original_points"] = {key: points[key] for key in FT_POINTS} + a, b = (points[key] for key in FT_POINTS) + if a is not None and b is not None: + middle_y = (a["y"] + b["y"]) / 2 + points[FT_POINTS[0]] = {**a, "y": middle_y} + points[FT_POINTS[1]] = {**b, "y": middle_y} + + data["ft_alignment"] = "horizontal_at_mean_y" + return data + + +def analyze_ratio_features(image, landmarks) -> dict[str, bool | None]: + """Return both labels from the same measurement of the processed image.""" + from .special_points import NoFaceError, NonFrontalError + + matched = None + try: + data = _detect_ratio_points(image, landmarks) + except (NoFaceError, NonFrontalError): + pass + else: + ft_left, ft_right, zy_left, zy_right = ( + data["points"].get(key) for key in (*FT_POINTS, *ZY_POINTS) + ) + if all(point is not None for point in (ft_left, ft_right, zy_left, zy_right)): + ft = abs(ft_left["x"] - ft_right["x"]) + zy = math.hypot(zy_left["x"] - zy_right["x"], zy_left["y"] - zy_right["y"]) + if ft > 0 and zy > 0: + matched = ft / zy < 0.8 + return {"zygoma_expansion": matched, "temple_hollowing": matched} diff --git a/face_analysis/special_points.py b/face_analysis/special_points.py index ee14005..b5c116c 100644 --- a/face_analysis/special_points.py +++ b/face_analysis/special_points.py @@ -30,7 +30,7 @@ GROUPS = { "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], + "face_edge": [162, 234, 58, 136, 149, 152, 378, 365, 288, 401, 366, 389, 251, 454], } # 发际线点定义:字段名 → 参与计算的 (起点ID, 方向点ID) 射线组;单射线直接取交点, @@ -87,7 +87,7 @@ def ray_hairline_intersection(p1, p2, hair_mask): return None -def detect_special_points(image_bgr): +def detect_special_points(image_bgr, landmarks_override=None): """主入口:图片 → {字段名: {"x","y"} 或 None} + 元信息。 Returns: {"points": {...}, "image_size": {"width","height"}, @@ -99,7 +99,7 @@ def detect_special_points(image_bgr): from face_analysis.pose import check_frontal_face h, w = image_bgr.shape[:2] - landmarks = detector.detect(image_bgr) + landmarks = landmarks_override if landmarks_override is not None else detector.detect(image_bgr) if landmarks is None: raise NoFaceError("未检出人脸") if not check_frontal_face(landmarks, w, h): diff --git a/face_features.py b/face_features.py index 61e6074..89d5c0d 100644 --- a/face_features.py +++ b/face_features.py @@ -177,6 +177,8 @@ def analyze_features(image_bytes: bytes = None, image_url: str = None): return None # 豆包 5 项 + 本地脸型覆盖 feats = {en: raw[zh] for zh, en in _KEY_MAP.items() if zh in raw} + if "facial_age" not in feats and "面部年龄区间" in raw: + feats["facial_age"] = raw["面部年龄区间"] try: feats["face_shape"] = _local_face_shape(image_bytes, image_url) except ValueError as e: diff --git a/gateway/app.py b/gateway/app.py index b317daa..0b4bffa 100644 --- a/gateway/app.py +++ b/gateway/app.py @@ -6,17 +6,17 @@ import asyncio import base64 -import hmac import json import logging from contextlib import asynccontextmanager from pathlib import Path from typing import Optional -from fastapi import FastAPI, File, Form, Header, Request, UploadFile +from fastapi import Depends, FastAPI, File, Form, Request, UploadFile from fastapi.responses import HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles +from gateway.client_auth import client_auth_error, router as client_auth_router from gateway.config import load_config from gateway.logging_middleware import ( get_available_dates, @@ -91,6 +91,8 @@ app = FastAPI( lifespan=lifespan, ) +app.include_router(client_auth_router) + # 静态文件托管(阶段四完善) static_root = Path(__file__).resolve().parent.parent / "static" static_root.mkdir(parents=True, exist_ok=True) @@ -250,6 +252,8 @@ async def index(): "if2_hair_grow": "/static/test_interface2.html", "if3_hair_grow_b": "/static/test_interface3.html", "if4_features": "/static/test_interface4.html", + "if42_features": "/static/test_interface42.html", + "if72_b_end_points": "/static/test_interface72.html", "if5_hairline": "/static/test_interface5.html", "if52_hairline": "/static/test_interface52.html", "if6_measure_v2": "/static/test_interface6.html", @@ -596,6 +600,24 @@ async def face_features( image_base64: Optional[str] = Form(default=None, description="图片 base64(需带前缀)"), ): """接口4:用户特征分析 — 豆包 6 项 + 本机 MediaPipe 脸型(face/),不经过 worker。""" + return await _face_features_response(request, image_file, image_url, image_base64) + + +@app.post("/api/v1/face/features-42", summary="42号接口:用户特征与两项判定", tags=["人脸分析"]) +async def face_features_42( + request: Request, + 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"), + auth_error=Depends(client_auth_error), +): + """质检、扶正裁切后执行接口4分析,再加入颧弓外扩与太阳穴凹陷。""" + if auth_error is not None: + return auth_error + return await _face_features_response(request, image_file, image_url, image_base64, include_ratios=True) + + +async def _face_features_response(request, image_file, image_url, image_base64, *, include_ratios=False): import uuid as _uuid # 三选一校验 @@ -644,6 +666,25 @@ async def face_features( from fastapi.concurrency import run_in_threadpool from face_features import analyze_features + ratio_features = {} + if include_ratios: + from face_analysis.face_ratios import PortraitQualityError + + try: + img_bytes, ratio_features = await _prepare_features_42(img_bytes, image_url) + except PortraitQualityError as ex: + return JSONResponse(status_code=200, content={ + "code": ex.code, "message": str(ex), + "request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None, + }) + except Exception: + logger.exception("接口42 图片预处理失败") + return JSONResponse(status_code=200, content={ + "code": 1007, "message": "图片预处理失败", + "request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None, + }) + image_url = None + try: feats = await run_in_threadpool(analyze_features, img_bytes, image_url) except Exception as ex: @@ -663,10 +704,42 @@ async def face_features( "code": 0, "message": "success", "request_id": f"gw-{_uuid.uuid4().hex[:8]}", - "data": {"features": json.dumps(feats, ensure_ascii=False)}, + "data": {"features": json.dumps({**feats, **ratio_features}, ensure_ascii=False)}, }) +async def _prepare_features_42(image_bytes, image_url): + from fastapi.concurrency import run_in_threadpool + + if image_url: + import httpx + + try: + async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: + response = await client.get(image_url) + response.raise_for_status() + image_bytes = response.content + except httpx.HTTPError as exc: + from face_analysis.face_ratios import PortraitQualityError + + raise PortraitQualityError(1008, "图片下载失败(URL 不可达)") from exc + + async with _if99_lock: + return await run_in_threadpool(_preprocess_and_measure_42, image_bytes) + + +def _preprocess_and_measure_42(image_bytes): + import cv2 + from face_analysis.face_ratios import analyze_ratio_features, prepare_feature_portrait + + image, landmarks = prepare_feature_portrait(image_bytes) + success, encoded = cv2.imencode(".png", image) + if not success: + raise RuntimeError("处理后图片编码失败") + ratios = analyze_ratio_features(image, landmarks) + return encoded.tobytes(), ratios + + @app.post("/api/v1/hairline/generate", tags=["人脸分析"]) async def hairline_generate(request: Request): """接口5:发际线PNG生成""" @@ -675,30 +748,21 @@ async def hairline_generate(request: Request): @app.post("/api/v1/hairline/generate-52", tags=["人脸分析"], summary="接口52:发际线新规则与人像裁切") -async def hairline_generate_52( - request: Request, - api_token: Optional[str] = Header(default=None, alias="X-API-Token", - description="52号接口客户端Token,默认yonghe1999"), -): - """透传图片三选一、gender、hair_style及normalize_image等参数至GPU worker。 - - worker负责中下庭均值定位、扶正裁切和生发;网关沿用原有派发、鉴权与图片URL改写。 - 空图片保持原有 *_base64: null 字段。接口5保持原样。 - 客户端须传X-API-Token;缺少或不匹配时返回HTTP401/code1009,不进入派发队列。 - """ - from uuid import uuid4 - from gateway.config import DEFAULTS, get_config - - expected = get_config().get("interface52_token", DEFAULTS["interface52_token"]) - if (not isinstance(expected, str) or not expected or not api_token - or not hmac.compare_digest(api_token.encode("utf-8"), expected.encode("utf-8"))): - return JSONResponse(status_code=401, content={ - "code": 1009, "message": "未授权:缺少或错误的 X-API-Token", - "request_id": f"gw-{uuid4().hex[:8]}", "data": None, - }) +async def hairline_generate_52(request: Request, auth_error=Depends(client_auth_error)): + """鉴权后透传图片、gender、hair_style 与 normalize_image 至 GPU worker。""" + if auth_error is not None: + return auth_error return await _proxy(request, "/api/v1/hairline/generate-52") +@app.post("/api/v1/face/b-end-points", summary="72号接口:B 端五个关键点", tags=["人脸分析"]) +async def face_b_end_points(request: Request, auth_error=Depends(client_auth_error)): + """鉴权后转发到 worker,图片按网关规则返回 URL。""" + if auth_error is not None: + return auth_error + return await _proxy(request, "/api/v1/face/b-end-points") + + @app.post("/api/v1/hair/grow-v2", tags=["生发"]) async def hair_grow_v2(request: Request): """接口7:C端生发 v2(add_hair2 工作流)""" diff --git a/gateway/client_auth.py b/gateway/client_auth.py new file mode 100644 index 0000000..88e8726 --- /dev/null +++ b/gateway/client_auth.py @@ -0,0 +1,147 @@ +"""Expiring client credentials for the protected gateway interfaces.""" + +import base64 +import binascii +import hashlib +import hmac +import json +import os +import secrets +import time +from uuid import uuid4 + +from fastapi import APIRouter, Security +from fastapi.responses import JSONResponse +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from pydantic import BaseModel, Field + +from gateway.config import get_config + +ACCESS_TTL = 15 * 60 +REFRESH_TTL = 7 * 24 * 60 * 60 +_USERNAME = "yonghe" +_PASSWORD = "yonghe1999" + +router = APIRouter(prefix="/api/v1/auth", tags=["客户端鉴权"]) +_bearer = HTTPBearer(auto_error=False, scheme_name="ClientBearer") + + +class LoginRequest(BaseModel): + username: str = Field(max_length=128) + password: str = Field(max_length=256) + + +class RefreshRequest(BaseModel): + refresh_token: str = Field(max_length=1024) + + +def _response(code, message, *, status=200, data=None, challenge=False): + headers = {"Cache-Control": "no-store"} + if challenge: + headers["WWW-Authenticate"] = "Bearer" + return JSONResponse(status_code=status, headers=headers, content={ + "code": code, "message": message, "request_id": f"gw-{uuid4().hex[:8]}", "data": data, + }) + + +def _secret(): + value = os.getenv("HAIR_API_TOKEN_SECRET") + if value is None: + try: + value = get_config().get("api_token_secret") + except FileNotFoundError: + return None + if not isinstance(value, str) or len(value.encode("utf-8")) < 32: + return None + return value.encode("utf-8") + + +def _encode(raw): + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + +def _decode(value): + return base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True) + + +def _issue(secret, kind, ttl): + payload = _encode(json.dumps({ + "sub": _USERNAME, + "kind": kind, + "exp": int(time.time()) + ttl, + "nonce": secrets.token_urlsafe(16), + }, separators=(",", ":")).encode()) + signature = hmac.digest(secret, payload.encode("ascii"), hashlib.sha256) + return payload + "." + _encode(signature) + + +def _valid(token, secret, kind): + if not token or len(token) > 1024: + return False + try: + payload, signature = token.split(".") + expected = _encode(hmac.digest(secret, payload.encode("ascii"), hashlib.sha256)) + if not hmac.compare_digest(signature.encode("ascii"), expected.encode("ascii")): + return False + claims = json.loads(_decode(payload)) + return ( + isinstance(claims, dict) + and claims.get("sub") == _USERNAME + and claims.get("kind") == kind + and type(claims.get("exp")) is int + and time.time() < claims["exp"] + ) + except (ValueError, UnicodeError, binascii.Error): + return False + + +def _unconfigured(): + return _response(1007, "客户端令牌服务未配置", status=503) + + +def _unauthorized(message): + return _response(1009, message, status=401, challenge=True) + + +def _access_data(secret): + return { + "access_token": _issue(secret, "access", ACCESS_TTL), + "token_type": "Bearer", + "expires_in": ACCESS_TTL, + } + + +@router.post("/login", summary="用户名和密码换取访问及刷新令牌") +async def login(body: LoginRequest): + username_ok = hmac.compare_digest(body.username.encode("utf-8"), _USERNAME.encode("utf-8")) + password_ok = hmac.compare_digest(body.password.encode("utf-8"), _PASSWORD.encode("utf-8")) + if not (username_ok and password_ok): + return _unauthorized("用户名或密码错误") + secret = _secret() + if secret is None: + return _unconfigured() + data = _access_data(secret) + data.update(refresh_token=_issue(secret, "refresh", REFRESH_TTL), refresh_expires_in=REFRESH_TTL) + return _response(0, "success", data=data) + + +@router.post("/refresh", summary="刷新访问令牌") +async def refresh(body: RefreshRequest): + secret = _secret() + if secret is None: + return _unconfigured() + if not _valid(body.refresh_token, secret, "refresh"): + return _unauthorized("刷新令牌无效或已过期,请重新登录") + return _response(0, "success", data=_access_data(secret)) + + +async def client_auth_error(credentials: HTTPAuthorizationCredentials | None = Security(_bearer)): + """Return an error response for protected handlers before local work or dispatch.""" + if credentials is None: + return _unauthorized("请提供 Authorization: Bearer 访问令牌") + secret = _secret() + if secret is None: + return _unconfigured() + if not _valid(credentials.credentials, secret, "access"): + return _unauthorized("访问令牌无效或已过期") + return None diff --git a/gateway/config.example.json b/gateway/config.example.json index 1782edf..aef8c4a 100644 --- a/gateway/config.example.json +++ b/gateway/config.example.json @@ -1,5 +1,5 @@ { - "interface52_token": "yonghe1999", + "api_token_secret": "", "workers": [ "http://127.0.0.1:28187", "http://127.0.0.1:28188" diff --git a/gateway/config.py b/gateway/config.py index 7751d4a..f63b26b 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -13,7 +13,7 @@ logger = logging.getLogger("gateway.config") CONFIG_PATH = Path(__file__).resolve().parent / "config.json" DEFAULTS = { - "interface52_token": "yonghe1999", + "api_token_secret": "", "public_base_url": "https://hair.xiangsilian.com", "static_dir": "static/annotations", "primary_workers": [], # 主 worker 地址列表(须为 workers 的子集);空 = 全部视为主 worker @@ -77,10 +77,6 @@ def load_config() -> dict: cfg = _deep_merge(DEFAULTS, raw) # --- 校验 --- - client_token = cfg["interface52_token"] - if not isinstance(client_token, str) or not client_token: - raise ValueError("配置错误: interface52_token 必须是非空字符串") - workers: List[str] = cfg.get("workers", []) if not workers: raise ValueError("配置错误: workers 列表不能为空,至少需要一个 worker 地址") diff --git a/gateway/logging_middleware.py b/gateway/logging_middleware.py index 4184856..62b9bf1 100644 --- a/gateway/logging_middleware.py +++ b/gateway/logging_middleware.py @@ -223,7 +223,7 @@ def is_initialized() -> bool: def _should_log(path: str) -> bool: """只记录 API 请求(/api/ 路径),跳过静态文件、健康检查等。""" - return path.startswith("/api/") + return path.startswith("/api/") and not path.startswith("/api/v1/auth/") async def request_logging_middleware(request, call_next): diff --git a/hairline/face_landmarks.py b/hairline/face_landmarks.py index 04300e0..8bad850 100644 --- a/hairline/face_landmarks.py +++ b/hairline/face_landmarks.py @@ -44,18 +44,18 @@ class FaceLandmarker: self._detector = vision.FaceLandmarker.create_from_options(options) self._mp = mp - def detect(self, image_rgb: np.ndarray) -> np.ndarray | None: - """Returns (468, 3) float32 of normalized x, y and relative z, or None. + def detect(self, image_rgb: np.ndarray, include_iris: bool = False) -> np.ndarray | None: + """Returns (468, 3), or (478, 3) with iris, normalized float32 points. - The task model produces 478 landmarks (468 face + 10 iris); we return - only the first 468 to match the canonical FaceMesh topology used by - the SDK's OBJ file. + The task model produces 478 landmarks (468 face + 10 iris). By default + only the first 468 match the SDK's canonical FaceMesh topology; + include_iris=True retains all 478 for face-measurement calibration. """ mp_image = self._mp.Image(image_format=self._mp.ImageFormat.SRGB, data=image_rgb) result = self._detector.detect(mp_image) if not result.face_landmarks: return None - lm = result.face_landmarks[0][:468] + lm = result.face_landmarks[0] if include_iris else result.face_landmarks[0][:468] arr = np.array([[p.x, p.y, p.z] for p in lm], dtype=np.float32) return arr @@ -66,6 +66,25 @@ class FaceLandmarker: pass +def to_landmark_list(landmarks): + from mediapipe.framework.formats import landmark_pb2 + + result = landmark_pb2.NormalizedLandmarkList() + for x, y, z in landmarks: + result.landmark.add(x=float(x), y=float(y), z=float(z)) + return result + + +_landmarker = None + + +def get_landmarker() -> FaceLandmarker: + global _landmarker + if _landmarker is None: + _landmarker = FaceLandmarker(static_image_mode=True) + return _landmarker + + class SolutionsFaceLandmarker: """CPU-oriented fallback using mediapipe.solutions.face_mesh.""" diff --git a/hairline/framing.py b/hairline/framing.py new file mode 100644 index 0000000..44ae769 --- /dev/null +++ b/hairline/framing.py @@ -0,0 +1,231 @@ +"""Interface 52: upright face axis, then native-resolution reference framing.""" +from __future__ import annotations + +from functools import lru_cache +from io import BytesIO +import json +from pathlib import Path + +import cv2 +import numpy as np +from PIL import Image, ImageOps + +PROFILE_PATH = Path(__file__).parent / "reference/framing_profile.json" + + +def decode_portrait(raw): + """Respect phone EXIF orientation before detecting or transforming points.""" + try: + with Image.open(BytesIO(raw)) as image: + return cv2.cvtColor(np.array(ImageOps.exif_transpose(image).convert("RGB")), cv2.COLOR_RGB2BGR) + except Exception: + return None + + +@lru_cache(maxsize=1) +def load_profile(): + return json.loads(PROFILE_PATH.read_text(encoding="utf-8")) + + +def transform_points(points, matrix): + return np.asarray(points, np.float64) @ matrix[:, :2].T + matrix[:, 2] + + +def _rotation(landmarks, width, height): + xy = landmarks[:, :2].astype(np.float64) * [width, height] + delta = xy[152] - xy[9] + length = float(np.linalg.norm(delta)) + if not np.isfinite(xy).all() or length < 2 or delta[1] <= 0: + raise ValueError("9号点与152号点无效,无法扶正人脸") + down = delta / length + rotation = np.array([[down[1], -down[0]], [down[0], down[1]]], np.float64) + return xy, rotation, length + + +def _matrix(rotation, source_anchor, target_anchor): + return np.column_stack((rotation, np.asarray(target_anchor) - rotation @ source_anchor)) + + +def crop_fits(matrix, source_size, output_size): + w, h = output_size + corners = transform_points([[0, 0], [w-1, 0], [w-1, h-1], [0, h-1]], cv2.invertAffineTransform(matrix)) + sw, sh = source_size + return bool((corners[:, 0] >= -1e-7).all() and (corners[:, 0] <= sw-1+1e-7).all() + and (corners[:, 1] >= -1e-7).all() and (corners[:, 1] <= sh-1+1e-7).all()) + + +def _inscribed_frame(rotation, source_size, aspect=None): + """Largest centred valid rectangle: no generated background or upscaling.""" + sw, sh = source_size + ratio = sw / sh if aspect is None else aspect + source_center = np.array([(sw-1)/2, (sh-1)/2]) + low, high = 2.0, min(float(sh), sw/ratio) + for _ in range(48): + h = (low+high)/2 + w = h*ratio + matrix = _matrix(rotation, source_center, [(w-1)/2, (h-1)/2]) + if crop_fits(matrix, source_size, (w, h)): + low = h + else: + high = h + if aspect == .75: + h = max(4, int(low+1e-7)//4*4) + w = h//4*3 + else: + h, w = max(2, int(low+1e-7)), max(2, int(low*ratio+1e-7)) + matrix = _matrix(rotation, source_center, [(w-1)/2, (h-1)/2]) + if not crop_fits(matrix, source_size, (w, h)): + raise ValueError("图片边缘空间不足,无法得到有效扶正画面") + return matrix, (w, h) + + +def _tall_portrait_frame(xy, rotation, source_size, profile): + """Largest native 3:4 frame, positioned to retain the entire face.""" + _, size = _inscribed_frame(rotation, source_size, aspect=.75) + half = (np.array(size, np.float64)-1)/2 + extent = np.abs(rotation.T) @ half + low, high = extent, np.array(source_size)-1-extent + polygon = [np.array(p) for p in ((low[0], low[1]), (high[0], low[1]), + (high[0], high[1]), (low[0], high[1]))] + face = xy[:468] @ rotation.T + # The crop centre must satisfy both the source boundaries and the face + # bounds. Clip the feasible polygon in double precision to avoid padding. + face_low, face_high = face.max(axis=0)-half, face.min(axis=0)+half + for axis in range(2): + for normal, bound in ((rotation[axis], face_high[axis]), + (-rotation[axis], -face_low[axis])): + clipped = [] + for start, end in zip(polygon, polygon[1:]+polygon[:1]): + a, b = float(normal @ start-bound-1e-8), float(normal @ end-bound-1e-8) + if (a <= 0) != (b <= 0): + clipped.append(start+(end-start)*(a/(a-b))) + if b <= 0: + clipped.append(end) + polygon = clipped + if not polygon: + raise ValueError("3:4裁切窗口无法保留完整人脸") + target = np.array([profile["brow_x_ratio"]*size[0], profile["brow_y_ratio"]*size[1]]) + preferred = xy[9]-rotation.T @ (target-half) + # Euclidean projection keeps the brow as close as possible to its target. + candidates = [] + if (preferred >= low-1e-8).all() and (preferred <= high+1e-8).all(): + upright = rotation @ preferred + if (upright >= face_low-1e-8).all() and (upright <= face_high+1e-8).all(): + candidates.append(preferred) + for start, end in zip(polygon, polygon[1:]+polygon[:1]): + edge = end-start + length2 = float(edge @ edge) + t = np.clip(float((preferred-start) @ edge)/length2, 0, 1) if length2 > 1e-16 else 0 + candidates.append(start+t*edge) + centre = min(candidates, key=lambda p: float(np.sum((p-preferred)**2))) + matrix = _matrix(rotation, centre, half) + if not crop_fits(matrix, source_size, size): + raise ValueError("3:4裁切窗口超出原图有效范围") + return matrix, size + + +def _apply(photo, landmarks, matrix, size): + h, w = photo.shape[:2] + ow, oh = size + moved = np.asarray(landmarks, np.float64).copy() + moved[:, :2] = transform_points(landmarks[:, :2]*[w, h], matrix) / [ow, oh] + # MediaPipe Z is measured in normalized image-width units. + moved[:, 2] *= w/ow + result = cv2.warpAffine(photo, matrix, size, flags=cv2.INTER_LINEAR, + borderMode=cv2.BORDER_CONSTANT, borderValue=(0, 0, 0)) + return result, moved + + +def calibrate_reference(photo, landmarks): + """First straighten the actual reference; derive ratios from its final frame.""" + h, w = photo.shape[:2] + _xy, rotation, length = _rotation(landmarks, w, h) + matrix, size = _inscribed_frame(rotation, (w, h), aspect=.75) + image, moved = _apply(photo, landmarks, matrix, size) + ow, oh = size + b, c = moved[[9, 152], :2]*[ow, oh] + profile = { + "version": "2026-11-04-upright-9-152-subject-90pct-tall-3x4", + "source_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/misc/20261103_user.jpg", + "reference_image_url": "/static/framing_reference.png", + "reference_size": {"width": ow, "height": oh}, + "source_size": {"width": w, "height": h}, + "brow_9": {"x": float(b[0]), "y": float(b[1])}, + "chin_152": {"x": float(c[0]), "y": float(c[1])}, + "face_axis_length_px": length, + "brow_x_ratio": float(b[0]/ow), "brow_y_ratio": float(b[1]/oh), + "reference_width_per_face_length": ow/length, + "reference_height_per_face_length": oh/length, + "width_per_face_length": 2.5, "height_per_face_length": 3.0/.9, + "rotation_degrees": float(np.degrees(np.arctan2(rotation[0, 1], rotation[0, 0]))), + "axis_x_error_px": float(abs(b[0]-c[0])), + "source_to_reference": matrix.tolist(), + "landmark_source": "source_landmarks_transformed_without_redetection", + } + return image, moved, profile + + +def normalize_portrait(photo, landmarks, profile=None): + profile = load_profile() if profile is None else profile + h, w = photo.shape[:2] + xy, rotation, length = _rotation(landmarks, w, h) + # Round to an exact 3:4 frame without changing pixel scale. + target_h = max(4, round(profile["height_per_face_length"]*length/4)*4) + target_w = target_h//4*3 + matrix = _matrix(rotation, xy[9], [profile["brow_x_ratio"]*target_w, + profile["brow_y_ratio"]*target_h]) + cropped = crop_fits(matrix, (w, h), (target_w, target_h)) + aspect_cropped = False + aspect_skip_reason = None + if cropped: + size = (target_w, target_h) + elif h*3 > w*4: + try: + matrix, size = _tall_portrait_frame(xy, rotation, (w, h), profile) + aspect_cropped = True + except ValueError as ex: + aspect_skip_reason = str(ex) + matrix, size = _inscribed_frame(rotation, (w, h)) + else: + matrix, size = _inscribed_frame(rotation, (w, h)) + image, moved = _apply(photo, landmarks, matrix, size) + ow, oh = size + axis = moved[[9, 152], :2]*[ow, oh] + if not ((axis[:, 0] >= 0).all() and (axis[:, 0] < ow).all() + and (axis[:, 1] >= 0).all() and (axis[:, 1] < oh).all()): + raise ValueError("人脸过于贴近边缘,扶正后无法保留9号点与152号点") + metadata = { + "enabled": True, "standard_crop": cropped, "aspect_crop": aspect_cropped, + "aspect_crop_skip_reason": aspect_skip_reason, + "status": "reference_crop" if cropped else ("aspect_crop" if aspect_cropped else "rotation_only"), + "skip_reason": None if cropped else "insufficient_space", + "message": ("已扶正并按参考图裁切" if cropped else + "原图偏长,已扶正并裁成3:4,保留完整人脸" if aspect_cropped else + "3:4窗口无法完整保留人脸,已仅扶正" if aspect_skip_reason else + "空间不足,仅扶正,未按参考比例裁切"), + "rotation_degrees": float(np.degrees(np.arctan2(rotation[0, 1], rotation[0, 0]))), + "source_size": {"width": w, "height": h}, "output_size": {"width": ow, "height": oh}, + "requested_crop_size": {"width": target_w, "height": target_h}, + "face_axis_length_px": length, + "height_per_face_length": profile["height_per_face_length"], + "width_per_face_length": profile["height_per_face_length"]*.75, + "source_to_processed": matrix.tolist(), + "processed_to_source": cv2.invertAffineTransform(matrix).tolist(), + "axis_x_error_px": float(abs(axis[0, 0]-axis[1, 0])), + "profile_version": profile["version"], + "reference_image_url": profile["reference_image_url"], + } + return image, moved, metadata + + +class NoPortraitFaceError(ValueError): + """The portrait landmarker could not find a face.""" + + +def prepare_portrait(photo): + """Shared interface-52 preprocessing, including the same landmark model/profile.""" + from .face_landmarks import get_landmarker + landmarks = get_landmarker().detect(cv2.cvtColor(photo, cv2.COLOR_BGR2RGB), include_iris=True) + if landmarks is None: + raise NoPortraitFaceError("未检出人脸") + return normalize_portrait(photo, landmarks) diff --git a/hairline/reference/framing_profile.json b/hairline/reference/framing_profile.json new file mode 100644 index 0000000..c9a1c81 --- /dev/null +++ b/hairline/reference/framing_profile.json @@ -0,0 +1,43 @@ +{ + "version": "2026-11-04-upright-9-152-subject-90pct-tall-3x4", + "source_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/misc/20261103_user.jpg", + "reference_image_url": "/static/framing_reference.png", + "reference_size": { + "width": 786, + "height": 1048 + }, + "source_size": { + "width": 792, + "height": 1056 + }, + "brow_9": { + "x": 385.1878369059212, + "y": 360.65437126595765 + }, + "chin_152": { + "x": 385.18783690592113, + "y": 725.8251047982238 + }, + "face_axis_length_px": 365.1707335322661, + "brow_x_ratio": 0.4900608612034621, + "brow_y_ratio": 0.3441358504446161, + "reference_width_per_face_length": 2.152417835890317, + "reference_height_per_face_length": 2.8698904478537566, + "width_per_face_length": 2.5, + "height_per_face_length": 3.333333333333333, + "rotation_degrees": 0.2596248408989324, + "axis_x_error_px": 5.684341886080802e-14, + "source_to_reference": [ + [ + 0.9999897336401403, + 0.004531292786961665, + -5.386196599797756 + ], + [ + -0.004531292786961665, + 0.9999897336401403, + -2.202458197930582 + ] + ], + "landmark_source": "source_landmarks_transformed_without_redetection" +} diff --git a/static/client_auth.js b/static/client_auth.js new file mode 100644 index 0000000..3c42c8f --- /dev/null +++ b/static/client_auth.js @@ -0,0 +1,64 @@ +(() => { + const form = document.getElementById("client-auth"); + const message = document.getElementById("client-auth-status"); + const button = form.querySelector("button"); + let accessToken; + let refreshToken; + let accessExpiry = 0; + + async function requestToken(operation, data) { + const response = await fetch("/api/v1/auth/" + operation, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + const result = await response.json(); + if (!response.ok || result.code !== 0) throw new Error(result.message || "鉴权失败"); + accessToken = result.data.access_token; + accessExpiry = Date.now() + result.data.expires_in * 1000; + return result.data; + } + + form.addEventListener("submit", async (event) => { + event.preventDefault(); + button.disabled = true; + const data = new FormData(form); + try { + const result = await requestToken("login", { + username: data.get("username"), + password: data.get("password"), + }); + refreshToken = result.refresh_token; + message.textContent = "已登录。访问令牌过期时会自动刷新。"; + } catch (error) { + message.textContent = error.message; + } finally { + form.elements.namedItem("password").value = ""; + button.disabled = false; + } + }); + + async function refresh() { + if (!refreshToken) throw new Error("请先登录,再提交图片。"); + try { + await requestToken("refresh", { refresh_token: refreshToken }); + } catch (error) { + accessToken = refreshToken = undefined; + message.textContent = "请重新登录。"; + throw error; + } + } + + window.hairClientFetch = async (path, options) => { + if (Date.now() >= accessExpiry || !accessToken) await refresh(); + const send = () => { + const headers = new Headers(options.headers); + headers.set("Authorization", "Bearer " + accessToken); + return fetch(path, { ...options, headers }); + }; + const response = await send(); + if (response.status !== 401) return response; + await refresh(); + return send(); + }; +})(); diff --git a/static/integration.html b/static/integration.html index dedbd57..55313a0 100644 --- a/static/integration.html +++ b/static/integration.html @@ -270,7 +270,8 @@ console.log(features.gene_style); // "自然型"

52. 发际线新规则与裁切  POST  /api/v1/hairline/generate-52

-

客户端请求头必填 X-API-Token: yonghe1999。缺少或不匹配返回HTTP401、code=1009。可在网关配置interface52_token覆盖默认值;内部Token由网关添加。

+

42、52、72 统一使用 Authorization: Bearer <access_token>。通过 POST /api/v1/auth/login 登录,访问令牌有效 15 分钟,可通过 POST /api/v1/auth/refresh 刷新。缺少或无效时返回 HTTP 401、code 1009。

+

42 号特征测试 · 72 号关键点测试

网关透传至GPU worker执行。中下庭均值定位 middle,支持9→152扶正、人像裁切和矩形发际线预览。接口5保留原有三档实现;新功能使用此独立接口。

基础图片参数、gender、hair_style、use_mask、prompt、generate_grow_image沿用接口5。新增可选normalize_image,默认false,传true开启扶正与裁切;测试页显式开启。

非空图片返回*_url,空图片沿用*_base64: null。另有height_rule、framing、guide_landmarks、原图及处理后底图。middle和坐标使用当前底图,high/low字段为null。标准裁切高约3.3333L、宽2.5L;长图在保留人脸的前提下裁为3:4。

diff --git a/static/test_interface42.html b/static/test_interface42.html new file mode 100644 index 0000000..4a5f027 --- /dev/null +++ b/static/test_interface42.html @@ -0,0 +1,218 @@ + + + + + +接口42 — 用户特征分析与两项判定 测试页 + + + +
+

🔬 接口42 — 用户特征分析与两项判定 测试

+

POST /api/v1/face/features-42  |  质检 → 扶正裁切 → 面部特征与两项判定  |  data.features 为 JSON 字符串

+ +
+ + + + 登录后可调用接口。 +
+ +
+
+
+
+ + +
+
JPG/PNG 正面照  |  检测人脸完整性和俯拍角度,通过后扶正裁切
+ +
+
+ + +
+ + + + diff --git a/static/test_interface52.html b/static/test_interface52.html index 7ab64ac..c0fb5fd 100644 --- a/static/test_interface52.html +++ b/static/test_interface52.html @@ -103,10 +103,16 @@

上传正面照,选择性别与发型,查看按中下庭均值定位的设计曲线和生发效果。

目标上庭高度 =(中庭高度 + 下庭高度)÷ 2
上传图先扶正9→152,再裁切为高约 3.3333×L、宽 2.5×L,人物比原3L规则缩小约10%;L 是9号点到152号点的像素距离,眉心位置沿用参考图比例。长图会在保留人脸的前提下裁为3:4,其余空间不足时仅扶正。从眉心向上定位,各发型中点对齐同一目标。high、low 已停用,旧字段返回 null。
+
+ + + + 登录后可调用接口。 +
+
-
@@ -231,7 +237,7 @@ async function submitTest() { const fd = new FormData(); fd.append('image_file', f); fd.append('gender', $('gender').value); fd.append('hair_style', checked.join(',')); fd.append('generate_grow_image', $('genGrowImg').checked ? 'true' : 'false'); fd.append('normalize_image', 'true'); const _reqStart = performance.now(); try { - const r = await fetch(API_BASE + '/api/v1/hairline/generate-52', { method:'POST', headers: { 'X-API-Token': $('apiToken').value }, body:fd }); + const r = await window.hairClientFetch(API_BASE + '/api/v1/hairline/generate-52', { method:'POST', body:fd }); const json = await r.json(); const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2); $('jsonContent').textContent = JSON.stringify(json, null, 2); diff --git a/static/test_interface72.html b/static/test_interface72.html new file mode 100644 index 0000000..09e5a22 --- /dev/null +++ b/static/test_interface72.html @@ -0,0 +1,135 @@ + + + + + +72 号接口 · B 端五个关键点 + + + +
+

72 号接口 · B 端五个关键点

+

上传正面照,查看扶正裁切后的六个白点、四条虚线和厘米标注。

+
+ + + + 登录后可调用接口。 +
+ +
+
+ + + + +
+

请选择 JPG 或 PNG 图片。

+
+ +
+ + + diff --git a/tests/test_gateway_client_auth.py b/tests/test_gateway_client_auth.py new file mode 100644 index 0000000..bfd75e1 --- /dev/null +++ b/tests/test_gateway_client_auth.py @@ -0,0 +1,95 @@ +import importlib +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest +from fastapi.testclient import TestClient + +from gateway import client_auth +from gateway.logging_middleware import _should_log + +gateway = importlib.import_module("gateway.app") +LOGIN = "/api/v1/auth/login" +REFRESH = "/api/v1/auth/refresh" +FEATURES_42 = "/api/v1/face/features-42" +PROTECTED = [FEATURES_42, "/api/v1/hairline/generate-52", "/api/v1/face/b-end-points"] + + +@pytest.fixture +def client(monkeypatch): + monkeypatch.setenv("HAIR_API_TOKEN_SECRET", "gateway-test-signing-secret-at-least-32") + session = TestClient(gateway.app) + yield session + session.close() + + +def login(client): + response = client.post(LOGIN, json={"username": "yonghe", "password": "yonghe1999"}) + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store" + return response.json()["data"] + + +@pytest.mark.parametrize("path", PROTECTED) +def test_all_three_routes_reject_missing_static_and_refresh_tokens(client, monkeypatch, path): + proxy = AsyncMock() + features = AsyncMock() + monkeypatch.setattr(gateway, "_proxy", proxy) + monkeypatch.setattr(gateway, "_face_features_response", features) + refresh_token = login(client)["refresh_token"] + for headers in ({}, {"X-API-Token": "yonghe1999"}, {"Authorization": "Bearer " + refresh_token}): + result = client.post(path, headers=headers) + assert result.status_code == 401 + assert result.json()["code"] == 1009 + proxy.assert_not_awaited() + features.assert_not_awaited() + + +def test_login_expiry_tampering_and_refresh(client, monkeypatch): + clock = SimpleNamespace(now=1_000_000) + monkeypatch.setattr(client_auth, "time", SimpleNamespace(time=lambda: clock.now)) + proxy = AsyncMock(return_value={"code": 0}) + monkeypatch.setattr(gateway, "_proxy", proxy) + path = PROTECTED[-1] + assert client.post(LOGIN, json={"username": "yonghe", "password": "wrong"}).status_code == 401 + data = login(client) + assert data["expires_in"] == 900 and data["refresh_expires_in"] == 604800 + access, refresh = data["access_token"], data["refresh_token"] + headers = {"Authorization": f"Bearer {access}"} + assert client.post(path, headers=headers).status_code == 200 + assert client.post(path, headers={"Authorization": f"Bearer {access}x"}).status_code == 401 + assert client.post(REFRESH, json={"refresh_token": access}).status_code == 401 + clock.now += data["expires_in"] + assert client.post(path, headers=headers).status_code == 401 + response = client.post(REFRESH, json={"refresh_token": refresh}) + assert response.status_code == 200 and response.headers["cache-control"] == "no-store" + refreshed = response.json()["data"] + assert "refresh_token" not in refreshed and refreshed["access_token"] != access + assert client.post(path, headers={"Authorization": "Bearer " + refreshed["access_token"]}).status_code == 200 + clock.now += data["refresh_expires_in"] - data["expires_in"] + assert client.post(REFRESH, json={"refresh_token": refresh}).status_code == 401 + assert proxy.await_count == 2 + + +def test_unconfigured_secret_fails_closed_and_token_responses_are_not_logged(client, monkeypatch): + monkeypatch.setenv("HAIR_API_TOKEN_SECRET", "") + result = client.post(LOGIN, json={"username": "yonghe", "password": "yonghe1999"}) + assert result.status_code == 503 and result.json()["code"] == 1007 + assert not _should_log(LOGIN) and not _should_log(REFRESH) + + +def test_features42_combines_processed_photo_with_main_features(client, monkeypatch): + analyzer = Mock(return_value={"face_shape": "鹅蛋脸", "contour_type": "轮廓适中"}) + monkeypatch.setattr("face_features.analyze_features", analyzer) + prepared = AsyncMock(return_value=(b"processed", {"zygoma_expansion": True, "temple_hollowing": True})) + monkeypatch.setattr(gateway, "_prepare_features_42", prepared) + headers = {"Authorization": "Bearer " + login(client)["access_token"]} + response = client.post(FEATURES_42, headers=headers, files={"image_file": ("face.jpg", b"raw")}) + assert response.status_code == 200 and response.json()["code"] == 0 + assert json.loads(response.json()["data"]["features"]) == { + "face_shape": "鹅蛋脸", "contour_type": "轮廓适中", + "zygoma_expansion": True, "temple_hollowing": True, + } + prepared.assert_awaited_once_with(b"raw", None) + analyzer.assert_called_once_with(b"processed", None) diff --git a/tests/test_gateway_interface52.py b/tests/test_gateway_interface52.py index b1871f7..cd45815 100644 --- a/tests/test_gateway_interface52.py +++ b/tests/test_gateway_interface52.py @@ -15,75 +15,15 @@ from gateway import config, forward gateway = importlib.import_module("gateway.app") PATH = "/api/v1/hairline/generate-52" -CLIENT_HEADERS = {"X-API-Token": "yonghe1999"} - - -@pytest.mark.parametrize("headers", [ - {}, {"X-API-Token": ""}, {"X-API-Token": "wrong"}, - {"X-API-Token": "yonghe1999 "}, {"X-Internal-Token": "yonghe1999"}, -]) -def test_invalid_client_token_is_rejected_before_dispatch(monkeypatch, headers): - monkeypatch.setattr(config, "get_config", lambda: {}) - proxy = AsyncMock() - monkeypatch.setattr(gateway, "_proxy", proxy) +@pytest.fixture +def client_headers(monkeypatch): + monkeypatch.setenv("HAIR_API_TOKEN_SECRET", "gateway-test-signing-secret-at-least-32") client = TestClient(gateway.app) try: - response = client.post(PATH, headers=headers) - assert response.status_code == 401 - body = response.json() - assert body["code"] == 1009 and body["data"] is None - assert body["request_id"].startswith("gw-") + response = client.post("/api/v1/auth/login", json={"username": "yonghe", "password": "yonghe1999"}) finally: client.close() - proxy.assert_not_awaited() - - -def test_custom_token_replaces_default_only_on_interface52(monkeypatch): - monkeypatch.setattr(config, "get_config", lambda: {"interface52_token": "custom-client-token"}) - proxy = AsyncMock(return_value={"code": 0, "data": None}) - monkeypatch.setattr(gateway, "_proxy", proxy) - client = TestClient(gateway.app) - try: - assert client.post(PATH, headers=CLIENT_HEADERS).status_code == 401 - assert client.post(PATH, headers={"X-API-Token": "custom-client-token"}).status_code == 200 - assert client.post("/api/v1/hairline/generate").status_code == 200 - finally: - client.close() - assert proxy.await_count == 2 - - -@pytest.mark.parametrize("token", ["", None, 123, []]) -def test_invalid_configured_token_cannot_disable_auth(monkeypatch, token): - monkeypatch.setattr(config, "get_config", lambda: {"interface52_token": token}) - proxy = AsyncMock() - monkeypatch.setattr(gateway, "_proxy", proxy) - client = TestClient(gateway.app) - try: - assert client.post(PATH, headers=CLIENT_HEADERS).status_code == 401 - finally: - client.close() - proxy.assert_not_awaited() - - -@pytest.mark.parametrize("token", ["", None, 123, []]) -def test_startup_rejects_invalid_client_token_configuration(tmp_path, monkeypatch, token): - import json - path = tmp_path / "config.json" - path.write_text(json.dumps({"interface52_token": token})) - monkeypatch.setattr(config, "CONFIG_PATH", path) - monkeypatch.setattr(config, "_config_cache", None) - with pytest.raises(ValueError, match="interface52_token"): - config.load_config() - - -def test_existing_config_gets_default_client_token(tmp_path, monkeypatch): - import json - path = tmp_path / "config.json" - path.write_text(json.dumps({"workers": ["http://worker"], "shared_password": "test-internal-token", - "static_dir": str(tmp_path)})) - monkeypatch.setattr(config, "CONFIG_PATH", path) - monkeypatch.setattr(config, "_config_cache", None) - assert config.load_config()["interface52_token"] == "yonghe1999" + return {"Authorization": "Bearer " + response.json()["data"]["access_token"]} def image_bytes(fmt): @@ -113,14 +53,14 @@ def proxy_setup(tmp_path, monkeypatch): return cfg, acquire, release, release_worker, unhealthy -@pytest.mark.parametrize("path", ["/api/v1/hairline/generate", PATH]) -def test_hairline_routes_forward_to_their_own_worker_path(monkeypatch, path): +@pytest.mark.parametrize("path", ["/api/v1/hairline/generate", PATH, "/api/v1/face/b-end-points"]) +def test_routes_forward_to_their_own_worker_path(monkeypatch, path, client_headers): monkeypatch.setattr(config, "get_config", lambda: {}) proxy = AsyncMock(return_value={"code": 0, "data": None}) monkeypatch.setattr(gateway, "_proxy", proxy) client = TestClient(gateway.app, client=("203.0.113.9", 50000)) try: - assert client.post(path, headers=CLIENT_HEADERS if path == PATH else {}).json() == {"code": 0, "data": None} + assert client.post(path, headers=client_headers if path != "/api/v1/hairline/generate" else {}).json() == {"code": 0, "data": None} finally: client.close() proxy.assert_awaited_once() @@ -128,7 +68,7 @@ def test_hairline_routes_forward_to_their_own_worker_path(monkeypatch, path): @pytest.mark.parametrize("generate_grow_image", [False, True]) -def test_interface52_raw_forwarding_and_served_image_urls(proxy_setup, monkeypatch, generate_grow_image): +def test_interface52_raw_forwarding_and_served_image_urls(proxy_setup, monkeypatch, generate_grow_image, client_headers): cfg, acquire, release, release_worker, unhealthy = proxy_setup png, jpg = image_bytes("PNG"), image_bytes("JPEG") metadata = {"coordinate_space": "processed_image", "width": 6, "height": 8, @@ -163,7 +103,7 @@ def test_interface52_raw_forwarding_and_served_image_urls(proxy_setup, monkeypat client = TestClient(gateway.app, client=("203.0.113.9", 50000)) try: result = client.post(PATH, content=raw, headers={ - **CLIENT_HEADERS, + **client_headers, "Content-Type": source.headers["content-type"], "X-Internal-Token": "untrusted-client-token"}) assert result.status_code == 200 data = result.json()["data"] @@ -190,21 +130,21 @@ def test_interface52_raw_forwarding_and_served_image_urls(proxy_setup, monkeypat assert calls[0].headers["content-type"] == source.headers["content-type"] assert calls[0].headers["x-internal-token"] == cfg["shared_password"] assert calls[0].headers.get("x-forwarded-for") is None - assert calls[0].headers.get("x-api-token") is None + assert calls[0].headers.get("authorization") is None acquire.assert_awaited_once_with(cfg, 0) release.assert_called_once() release_worker.assert_awaited_once() unhealthy.assert_not_awaited() -def test_worker_ip_rejection_is_preserved(proxy_setup, monkeypatch): +def test_worker_ip_rejection_is_preserved(proxy_setup, monkeypatch, client_headers): _, _, release, release_worker, unhealthy = proxy_setup body = {"code": 1010, "message": "来源IP不在接口白名单", "data": None} upstream = httpx.AsyncClient(transport=httpx.MockTransport(lambda request: httpx.Response(403, json=body))) monkeypatch.setattr(forward, "get_client", lambda: upstream) client = TestClient(gateway.app) try: - response = client.post(PATH, headers=CLIENT_HEADERS, data={"gender": "male", "hair_style": "1"}) + response = client.post(PATH, headers=client_headers, data={"gender": "male", "hair_style": "1"}) assert response.status_code == 403 and response.json() == body finally: client.close() @@ -220,7 +160,8 @@ def test_public_interface52_page_has_no_internal_password(): page = client.get("/static/test_interface52.html") assert page.status_code == 200 assert PATH in page.text and "X-Internal-Token" not in page.text - assert 'id="apiToken"' in page.text and "X-API-Token" in page.text + assert 'id="client-auth"' in page.text and "X-API-Token" not in page.text + assert "yonghe1999" not in page.text assert "localhost:8187" not in page.text assert "test_interface52_compare.html" not in page.text assert client.get("/static/hairline_reference_box.js").status_code == 200