feat(gateway): 接入42和72并为42/52/72启用可刷新令牌
This commit is contained in:
@@ -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 离线模型权重/字体清单(内网部署)。 |
|
||||
|
||||
+1
-1
@@ -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 <access_token>`。校验失败返回 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限制与算法不合入本次网关改动。
|
||||
|
||||
@@ -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 <access_token>`。缺少、过期、签名无效或误用刷新令牌时,返回 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`,页面先登录再提交,访问令牌到期时自动刷新。
|
||||
+5
-5
@@ -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 <access_token>` 发送。网关鉴权后进入队列。
|
||||
- 沿用现有主备派发、隔离期、重试、日志和全局并发限制。原有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时应同步填写该值。
|
||||
测试页先登录再提交图片,访问令牌过期时使用当前刷新令牌自动换取新的访问令牌。
|
||||
|
||||
## 验证
|
||||
|
||||
|
||||
+89
-2
@@ -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 <access_token>`。登录、刷新和错误码见[客户端鉴权](接口42-52-72网关鉴权.md)。
|
||||
|
||||
**职责**:GPU worker返回base64并完成算法处理,网关负责透传参数、附加内部Token、保存非空图片及返回公网URL。网关不增加IP限制;网关出口IP须已在worker白名单内。部署与完整字段约定见[接口52网关接入](接口52网关接入.md)。
|
||||
|
||||
|
||||
+1
-1
@@ -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算法。
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
+88
-24
@@ -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 工作流)"""
|
||||
|
||||
@@ -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
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"interface52_token": "yonghe1999",
|
||||
"api_token_secret": "",
|
||||
"workers": [
|
||||
"http://127.0.0.1:28187",
|
||||
"http://127.0.0.1:28188"
|
||||
|
||||
+1
-5
@@ -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 地址")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
})();
|
||||
@@ -270,7 +270,8 @@ console.log(features.gene_style); // "自然型"</pre>
|
||||
<div class="card" id="if52">
|
||||
<h2>52. 发际线新规则与裁切 <span class="badge post">POST</span> <code>/api/v1/hairline/generate-52</code></h2>
|
||||
<div class="card-body">
|
||||
<p>客户端请求头必填 <code>X-API-Token: yonghe1999</code>。缺少或不匹配返回HTTP401、code=1009。可在网关配置<code>interface52_token</code>覆盖默认值;内部Token由网关添加。</p>
|
||||
<p>42、52、72 统一使用 <code>Authorization: Bearer <access_token></code>。通过 <code>POST /api/v1/auth/login</code> 登录,访问令牌有效 15 分钟,可通过 <code>POST /api/v1/auth/refresh</code> 刷新。缺少或无效时返回 HTTP 401、code 1009。</p>
|
||||
<p><a href="/static/test_interface42.html" class="link">42 号特征测试</a> · <a href="/static/test_interface72.html" class="link">72 号关键点测试</a></p>
|
||||
<p class="desc">网关透传至GPU worker执行。中下庭均值定位 middle,支持9→152扶正、人像裁切和矩形发际线预览。接口5保留原有三档实现;新功能使用此独立接口。</p>
|
||||
<p>基础图片参数、<code>gender</code>、<code>hair_style</code>、<code>use_mask</code>、<code>prompt</code>、<code>generate_grow_image</code>沿用接口5。新增可选<code>normalize_image</code>,默认false,传true开启扶正与裁切;测试页显式开启。</p>
|
||||
<p style="margin-top:12px">非空图片返回<code>*_url</code>,空图片沿用<code>*_base64: null</code>。另有<code>height_rule</code>、<code>framing</code>、<code>guide_landmarks</code>、原图及处理后底图。middle和坐标使用当前底图,high/low字段为null。标准裁切高约3.3333L、宽2.5L;长图在保留人脸的前提下裁为3:4。</p>
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>接口42 — 用户特征分析与两项判定 测试页</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
|
||||
.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
||||
h1 { font-size: 22px; margin-bottom: 6px; }
|
||||
.subtitle { color: #888; font-size: 13px; margin-bottom: 24px; }
|
||||
|
||||
.card { background: #fff; border-radius: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 20px; }
|
||||
.card-header { font-weight: 700; font-size: 14px; padding: 14px 18px; border-bottom: 1px solid #f0f0f0; background: #fafafa; display: flex; justify-content: space-between; align-items: center; }
|
||||
.card-body { padding: 18px; }
|
||||
|
||||
.upload-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||
.file-input { flex: 1; min-width: 200px; }
|
||||
.file-input input[type=file] { width: 100%; padding: 8px; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; }
|
||||
.btn { padding: 10px 28px; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; font-weight: 600; transition: .2s; }
|
||||
.btn-primary { background: #2563eb; color: #fff; }
|
||||
.btn-primary:hover { background: #1d4ed8; }
|
||||
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
|
||||
.btn-sm { padding: 6px 14px; font-size: 13px; }
|
||||
.btn-outline { background: #fff; border: 1px solid #d1d5db; color: #374151; }
|
||||
.btn-outline:hover { background: #f9fafb; }
|
||||
.hint { font-size: 12px; color: #9ca3af; margin-top: 8px; }
|
||||
|
||||
.status { padding: 10px 16px; border-radius: 8px; font-size: 14px; margin-bottom: 16px; display: none; }
|
||||
.status.info { background: #dbeafe; color: #1e40af; display: block; }
|
||||
.status.error { background: #fee2e2; color: #991b1b; display: block; }
|
||||
.status.success { background: #d1fae5; color: #065f46; display: block; }
|
||||
|
||||
.results-layout { display: flex; gap: 24px; }
|
||||
.col-main { flex: 1.5; min-width: 0; }
|
||||
.col-side { flex: 1; min-width: 0; }
|
||||
|
||||
/* 特征表格 */
|
||||
.feat-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.feat-table th, .feat-table td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #f1f5f9; }
|
||||
.feat-table th { background: #f8fafc; font-weight: 700; color: #475569; font-size: 11px; text-transform: uppercase; letter-spacing: .3px; }
|
||||
.feat-table td:first-child { font-weight: 600; color: #1e293b; width: 200px; }
|
||||
.feat-table tr:hover td { background: #f8fafc; }
|
||||
.feat-table .highlight td { background: #eff6ff; }
|
||||
|
||||
/* 图片预览 */
|
||||
.img-preview { text-align: center; }
|
||||
.img-preview .thumb { background: #222; border-radius: 8px; overflow: hidden; display: inline-block; line-height: 0; }
|
||||
.img-preview .thumb img { max-width: 100%; max-height: 280px; object-fit: contain; }
|
||||
|
||||
.json-panel { max-height: 550px; overflow: auto; }
|
||||
.json-content { padding: 14px 16px; font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
|
||||
|
||||
.hidden { display: none !important; }
|
||||
.count-badge { background: #dbeafe; color: #1e40af; padding: 2px 10px; border-radius: 10px; font-size: 12px; font-weight: 700; }
|
||||
|
||||
@media (max-width: 800px) { .results-layout { flex-direction: column; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🔬 接口42 — 用户特征分析与两项判定 测试</h1>
|
||||
<p class="subtitle">POST /api/v1/face/features-42 | 质检 → 扶正裁切 → 面部特征与两项判定 | data.features 为 JSON 字符串</p>
|
||||
|
||||
<form id="client-auth" style="display:flex;align-items:center;flex-wrap:wrap;gap:8px;padding:12px 0">
|
||||
<label>用户名 <input name="username" autocomplete="username" required></label>
|
||||
<label>密码 <input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button type="submit">登录</button>
|
||||
<span id="client-auth-status" role="status">登录后可调用接口。</span>
|
||||
</form>
|
||||
<script src="/static/client_auth.js" defer></script>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="upload-row">
|
||||
<div class="file-input"><input type="file" id="imageFile" accept="image/jpeg,image/png,.jpg,.jpeg,.png"></div>
|
||||
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 分析特征</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="clearResults()">清除</button>
|
||||
</div>
|
||||
<div class="hint">JPG/PNG 正面照 | 检测人脸完整性和俯拍角度,通过后扶正裁切</div>
|
||||
<div id="statusBar" class="status hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results-layout hidden" id="resultsArea">
|
||||
<div class="col-main">
|
||||
<!-- 图片预览 -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span>📸 上传的照片</span></div>
|
||||
<div class="card-body img-preview">
|
||||
<div class="thumb" id="imgPreview"><span style="color:#9ca3af;padding:40px">—</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 英文优先字段 -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span>⭐ 英文优先字段</span><span id="countBadge" class="count-badge hidden"></span></div>
|
||||
<div class="card-body" style="padding:0">
|
||||
<table class="feat-table" id="enTable"></table>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 全部字段 -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span>📋 全部特征字段</span></div>
|
||||
<div class="card-body" style="padding:0;max-height:500px;overflow:auto">
|
||||
<table class="feat-table" id="allTable"></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-side">
|
||||
<div class="card">
|
||||
<div class="card-header"><span>📄 原始 JSON 响应</span><button class="btn btn-outline btn-sm" onclick="copyJson()">📋 复制</button></div>
|
||||
<div class="json-panel"><pre class="json-content" id="jsonContent"></pre></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_BASE = window.location.origin;
|
||||
|
||||
const EN_KEYS = ['face_shape','eyebrow_shape','facial_age','dynamic_static_type','gender','gene_style','zygoma_expansion','temple_hollowing'];
|
||||
const EN_LABELS = {
|
||||
face_shape:'脸型', eyebrow_shape:'眉形', facial_age:'面部年龄',
|
||||
dynamic_static_type:'动静类型', gender:'性别', gene_style:'基因风格',
|
||||
zygoma_expansion:'颧弓外扩', temple_hollowing:'太阳穴凹陷'
|
||||
};
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
function setStatus(t, type) { const b=$('statusBar'); b.textContent=t; b.className='status '+type; }
|
||||
|
||||
async function submitTest() {
|
||||
const f = $('imageFile').files[0];
|
||||
if (!f) { setStatus('请选择图片', 'error'); return; }
|
||||
|
||||
$('imgPreview').innerHTML = '<img src="'+URL.createObjectURL(f)+'" alt="preview">';
|
||||
$('submitBtn').disabled = true; $('submitBtn').textContent = '⏳ 分析中...';
|
||||
setStatus('正在质检、扶正裁切并分析特征…', 'info'); $('resultsArea').classList.add('hidden');
|
||||
|
||||
$('enTable').textContent = '';
|
||||
$('allTable').textContent = '';
|
||||
$('countBadge').classList.add('hidden');
|
||||
const fd = new FormData(); fd.append('image_file', f);
|
||||
const _reqStart = performance.now();
|
||||
try {
|
||||
const r = await window.hairClientFetch(API_BASE + '/api/v1/face/features-42', { method:'POST', body:fd });
|
||||
const json = await r.json();
|
||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||
$('jsonContent').textContent = JSON.stringify(json, null, 2);
|
||||
$('resultsArea').classList.remove('hidden');
|
||||
|
||||
if (json.code === 0) {
|
||||
const feats = JSON.parse(json.data.features);
|
||||
setStatus('✅ 分析完成 (' + _elapsed + 's) — ' + Object.keys(feats).length + ' 项特征', 'success');
|
||||
renderFeatures(feats);
|
||||
} else {
|
||||
setStatus('❌ (' + _elapsed + 's) code=' + json.code + ' ' + json.message, 'error');
|
||||
}
|
||||
} catch(e) {
|
||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||
setStatus('❌ (' + _elapsed + 's) ' + e.message, 'error'); $('resultsArea').classList.remove('hidden'); }
|
||||
finally { $('submitBtn').disabled = false; $('submitBtn').textContent = '🚀 分析特征'; }
|
||||
}
|
||||
|
||||
function displayValue(value, key) {
|
||||
const label = value === true ? (EN_LABELS[key] ?? String(value)) : value === false ? ''
|
||||
: value === null ? '无法测量' : String(value);
|
||||
const element = document.createElement('span');
|
||||
element.textContent = label;
|
||||
return element.innerHTML;
|
||||
}
|
||||
|
||||
function renderFeatures(feats) {
|
||||
// 英文优先字段
|
||||
let enHtml = '';
|
||||
EN_KEYS.forEach(k => {
|
||||
if (feats[k] !== undefined) {
|
||||
enHtml += '<tr class="highlight"><td>'+displayValue(k)+'</td><td>'+EN_LABELS[k]+'</td><td>'+displayValue(feats[k], k)+'</td></tr>';
|
||||
}
|
||||
});
|
||||
$('enTable').innerHTML = enHtml;
|
||||
|
||||
// 全部字段
|
||||
const allKeys = Object.keys(feats);
|
||||
let allHtml = '';
|
||||
allKeys.forEach(k => {
|
||||
const isEn = EN_KEYS.includes(k);
|
||||
allHtml += '<tr'+(isEn?' class="highlight"':'')+'><td>'+displayValue(k)+'</td><td>'+(isEn?EN_LABELS[k]||'':'')+'</td><td>'+displayValue(feats[k], k)+'</td></tr>';
|
||||
});
|
||||
$('allTable').innerHTML = allHtml;
|
||||
|
||||
$('countBadge').textContent = allKeys.length + ' 项';
|
||||
$('countBadge').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
$('resultsArea').classList.add('hidden'); $('statusBar').className='status hidden';
|
||||
$('imageFile').value=''; $('jsonContent').textContent='';
|
||||
$('imgPreview').innerHTML='<span style="color:#9ca3af;padding:40px">—</span>';
|
||||
$('enTable').innerHTML=''; $('allTable').innerHTML='';
|
||||
$('countBadge').classList.add('hidden');
|
||||
}
|
||||
|
||||
function copyJson() {
|
||||
navigator.clipboard.writeText($('jsonContent').textContent).then(() => {
|
||||
const b = event.target, o = b.textContent; b.textContent = '✅ 已复制'; setTimeout(() => b.textContent = o, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const dz = $('imageFile');
|
||||
dz.addEventListener('dragover', e => { e.preventDefault(); dz.style.borderColor='#2563eb'; });
|
||||
dz.addEventListener('dragleave', () => dz.style.borderColor='#ddd');
|
||||
dz.addEventListener('drop', e => { e.preventDefault(); dz.style.borderColor='#ddd'; if(e.dataTransfer.files.length){ dz.files=e.dataTransfer.files; setStatus('已选择: '+e.dataTransfer.files[0].name, 'info'); } });
|
||||
dz.addEventListener('change', () => { if(dz.files.length) setStatus('已选择: '+dz.files[0].name, 'info'); });
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -103,10 +103,16 @@
|
||||
<p class="subtitle">上传正面照,选择性别与发型,查看按中下庭均值定位的设计曲线和生发效果。</p>
|
||||
<div class="rule-banner"><strong>目标上庭高度 =(中庭高度 + 下庭高度)÷ 2</strong><br>上传图先扶正9→152,再裁切为高约 3.3333×L、宽 2.5×L,人物比原3L规则缩小约10%;L 是9号点到152号点的像素距离,眉心位置沿用参考图比例。长图会在保留人脸的前提下裁为3:4,其余空间不足时仅扶正。从眉心向上定位,各发型中点对齐同一目标。high、low 已停用,旧字段返回 null。</div>
|
||||
|
||||
<form id="client-auth" style="display:flex;align-items:center;flex-wrap:wrap;gap:8px;padding:12px 0">
|
||||
<label>用户名 <input name="username" autocomplete="username" required></label>
|
||||
<label>密码 <input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button type="submit">登录</button>
|
||||
<span id="client-auth-status" role="status">登录后可调用接口。</span>
|
||||
</form>
|
||||
<script src="/static/client_auth.js" defer></script>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="upload-row">
|
||||
<div class="form-group"><label for="apiToken">客户端 Token</label><input id="apiToken" type="password" value="yonghe1999" autocomplete="off"></div>
|
||||
<div class="file-input"><input type="file" id="imageFile" accept="image/jpeg,image/png,.jpg,.jpeg,.png"></div>
|
||||
<div class="form-group">
|
||||
<label>性别</label>
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>72 号接口 · B 端五个关键点</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: #eef2f5; color: #192d3d; font: 16px/1.55 system-ui, sans-serif; }
|
||||
main { width: min(1040px, 100%); margin: auto; padding: 26px 18px; }
|
||||
h1 { margin: 0; font-size: clamp(24px, 4vw, 35px); }
|
||||
h2 { margin: 0 0 10px; font-size: 19px; }
|
||||
p { margin: 8px 0; }
|
||||
.note { color: #4f6577; }
|
||||
.controls, .panel { background: white; border-radius: 12px; padding: 18px; margin-top: 16px; }
|
||||
form { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
|
||||
button { background: #156a72; color: white; border: 0; border-radius: 7px; padding: 9px 15px; font: inherit; cursor: pointer; }
|
||||
button:disabled { opacity: .55; cursor: wait; }
|
||||
input { max-width: 100%; }
|
||||
#status[data-error="true"] { color: #a22629; }
|
||||
.results { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); gap: 16px; }
|
||||
[hidden] { display: none; }
|
||||
.photo { display: grid; border-radius: 8px; overflow: hidden; background: #1b2c37; }
|
||||
.photo img { grid-area: 1 / 1; width: 100%; display: block; }
|
||||
.panel a { color: #156a72; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 8px 0 20px; font-variant-numeric: tabular-nums; }
|
||||
th, td { text-align: left; border-bottom: 1px solid #e6edf2; padding: 5px 7px; }
|
||||
@media(max-width: 680px) { .results { grid-template-columns: 1fr; gap: 0; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>72 号接口 · B 端五个关键点</h1>
|
||||
<p class="note">上传正面照,查看扶正裁切后的六个白点、四条虚线和厘米标注。</p>
|
||||
<form id="client-auth" style="display:flex;align-items:center;flex-wrap:wrap;gap:8px;padding:12px 0">
|
||||
<label>用户名 <input name="username" autocomplete="username" required></label>
|
||||
<label>密码 <input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button type="submit">登录</button>
|
||||
<span id="client-auth-status" role="status">登录后可调用接口。</span>
|
||||
</form>
|
||||
<script src="/static/client_auth.js" defer></script>
|
||||
<section class="controls">
|
||||
<form id="form">
|
||||
<label for="file">人脸照片</label>
|
||||
<input id="file" type="file" accept="image/jpeg,image/png" required>
|
||||
<button id="submit" type="submit">生成标注</button>
|
||||
<button id="sample" type="button">测试样例</button>
|
||||
</form>
|
||||
<p id="status" role="status">请选择 JPG 或 PNG 图片。</p>
|
||||
</section>
|
||||
<div id="results" class="results" hidden>
|
||||
<section class="panel">
|
||||
<h2>标注预览</h2>
|
||||
<div class="photo">
|
||||
<img id="base" alt="扶正裁切后的底图">
|
||||
<img id="overlay" alt="白点、虚线及距离标注">
|
||||
</div>
|
||||
<p><a id="download" download="interface72-overlay.png">下载透明标注层</a></p>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>测量结果</h2>
|
||||
<table><thead><tr><th>点位</th><th>距离</th></tr></thead><tbody id="distances"></tbody></table>
|
||||
<h2>坐标(像素)</h2>
|
||||
<table><thead><tr><th>点号</th><th>X</th><th>Y</th></tr></thead><tbody id="points"></tbody></table>
|
||||
<p class="note">点 6(MediaPipe 446)是射线锚点。六个白点的直径相同。</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
const byId = (id) => document.getElementById(id);
|
||||
const status = byId("status");
|
||||
const results = byId("results");
|
||||
const submit = byId("submit");
|
||||
const sample = byId("sample");
|
||||
|
||||
function setRows(id, rows) {
|
||||
const body = byId(id);
|
||||
body.replaceChildren();
|
||||
for (const values of rows) {
|
||||
const row = body.insertRow();
|
||||
for (const value of values) row.insertCell().textContent = value;
|
||||
}
|
||||
}
|
||||
|
||||
function imageSource(data, field, mime) {
|
||||
return data[field + "_url"] || ("data:" + mime + ";base64," + data[field + "_base64"]);
|
||||
}
|
||||
|
||||
async function runAnnotation(loadFile) {
|
||||
submit.disabled = sample.disabled = true;
|
||||
results.hidden = true;
|
||||
status.dataset.error = "false";
|
||||
status.textContent = "正在扶正裁切并生成标注…";
|
||||
try {
|
||||
const file = await loadFile();
|
||||
const form = new FormData();
|
||||
form.append("image_file", file, file.name || "sample.jpg");
|
||||
const response = await window.hairClientFetch("/api/v1/face/b-end-points", { method: "POST", body: form });
|
||||
const { code, message, data } = await response.json();
|
||||
if (!response.ok || code !== 0) throw new Error(message || "请求失败");
|
||||
|
||||
byId("base").src = imageSource(data, "input_image", "image/jpeg");
|
||||
const overlay = imageSource(data, "annotated_image", "image/png");
|
||||
byId("overlay").src = overlay;
|
||||
byId("download").href = overlay;
|
||||
setRows("distances", Object.entries(data.measurements_cm).map(([key, value]) => [
|
||||
key, value.toFixed(1) + " cm"
|
||||
]));
|
||||
setRows("points", Object.entries(data.points).map(([key, point]) => [
|
||||
key, point.x.toFixed(1), point.y.toFixed(1)
|
||||
]));
|
||||
results.hidden = false;
|
||||
status.textContent = "标注完成。";
|
||||
} catch (error) {
|
||||
status.dataset.error = "true";
|
||||
status.textContent = error.message;
|
||||
} finally {
|
||||
submit.disabled = sample.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
byId("form").addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const file = byId("file").files[0];
|
||||
if (file) runAnnotation(() => file);
|
||||
});
|
||||
|
||||
sample.addEventListener("click", () => runAnnotation(async () => {
|
||||
const response = await fetch("/static/sample.jpg");
|
||||
if (!response.ok) throw new Error("无法读取样例图片");
|
||||
return response.blob();
|
||||
}));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user