feat(gateway): 接入52号发际线接口与公开测试页
新增52号同路径转发,沿用现有主备派发、鉴权、队列和图片URL改写。空图片保留既有base64字段名,不带入worker算法及IP限制。 同步52号测试页、图片解析和矩形叠加依赖,公开页面不包含内部Token。补全网关契约与部署文档,Nginx示例读取超时延长至660秒。 验证:42项网关测试通过;临时网关连接真实GPU worker,5号三档与52号七型URL、长图裁切和边框均通过浏览器检查。
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
|------|------|
|
|------|------|
|
||||||
| [实现说明.md](实现说明.md) | **实现总览**:架构、五个接口怎么实现、base64→URL 映射、错误码、部署/环境要点。先读这份。 |
|
| [实现说明.md](实现说明.md) | **实现总览**:架构、五个接口怎么实现、base64→URL 映射、错误码、部署/环境要点。先读这份。 |
|
||||||
| [接口文档.md](接口文档.md) | 对外 API 契约(字段 / 错误码)。字段命名的唯一权威。 |
|
| [接口文档.md](接口文档.md) | 对外 API 契约(字段 / 错误码)。字段命名的唯一权威。 |
|
||||||
|
| [接口52网关接入.md](接口52网关接入.md) | 52号请求透传、图片URL与空字段约定、公开测试页及网关部署步骤。 |
|
||||||
| [旷视具体需求.md](旷视具体需求.md) | 原始需求。 |
|
| [旷视具体需求.md](旷视具体需求.md) | 原始需求。 |
|
||||||
| [../OFFLINE_ASSETS.md](../OFFLINE_ASSETS.md) | worker 离线模型权重/字体清单(内网部署)。 |
|
| [../OFFLINE_ASSETS.md](../OFFLINE_ASSETS.md) | worker 离线模型权重/字体清单(内网部署)。 |
|
||||||
| [最近一周调用统计_20260915-20260921.md](最近一周调用统计_20260915-20260921.md) | 2026-09-15~09-21 网关调用量、接口构成、成功率。 |
|
| [最近一周调用统计_20260915-20260921.md](最近一周调用统计_20260915-20260921.md) | 2026-09-15~09-21 网关调用量、接口构成、成功率。 |
|
||||||
|
|||||||
@@ -98,6 +98,11 @@
|
|||||||
- **做什么**:入参同接口2(`gender` + 多选 `hair_style` 必填)。对每个选中发型 → `middle`/`high`/`low` 三档发际线叠图 + 生发图 + 首个选中发型的面部中间点坐标。
|
- **做什么**:入参同接口2(`gender` + 多选 `hair_style` 必填)。对每个选中发型 → `middle`/`high`/`low` 三档发际线叠图 + 生发图 + 首个选中发型的面部中间点坐标。
|
||||||
- **怎么实现**:复用接口2 的 502 点渲染管线,三档分别用 `hairline_texture[/_high|/_low]` 同名贴图渲染叠图;生发同接口2(ComfyUI inpaint),**黑模板固定取 `hairline_texture_black/`(middle)**,每发型 1 张生发图。`best_hairline_center_point`=眉心 x × 首个选中发型 middle 档发际线 y。
|
- **怎么实现**:复用接口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`(网关透传)
|
||||||
|
- `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限制与算法不合入本次网关改动。
|
||||||
|
|
||||||
### 接口9 头发遮罩 `/api/v1/head/mask`(worker)
|
### 接口9 头发遮罩 `/api/v1/head/mask`(worker)
|
||||||
- **做什么**:人像照 → 头发遮罩 + 全流程分步可视化。
|
- **做什么**:人像照 → 头发遮罩 + 全流程分步可视化。
|
||||||
- **怎么实现**(`face_analysis/head_mask.py`):MediaPipe 底部分割线(21/68/104/69/108/151/337/299/333/298/251 连线,21/251 水平延伸)→ 线以上为上半区 → BiSeNet 与 SegFormer 双路头发分割、逐列向下填充到分割线得闭合区域(头发+额头)→ 外缘朝 151 内缩 `erode_cm`(默认1.2cm,虹膜标定) → 最终遮罩。返回 `steps_common` + `bisenet`/`segformer` 两组各四张图。
|
- **怎么实现**(`face_analysis/head_mask.py`):MediaPipe 底部分割线(21/68/104/69/108/151/337/299/333/298/251 连线,21/251 水平延伸)→ 线以上为上半区 → BiSeNet 与 SegFormer 双路头发分割、逐列向下填充到分割线得闭合区域(头发+额头)→ 外缘朝 151 内缩 `erode_cm`(默认1.2cm,虹膜标定) → 最终遮罩。返回 `steps_common` + `bisenet`/`segformer` 两组各四张图。
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# 接口52:网关接入与部署
|
||||||
|
|
||||||
|
`main`用于网关部署,本次只加入网关调用52号所需的路由、公开测试页及契约。GPU worker继续使用已部署的`ubuntu3090_debug`版本(`1c51f3e`及后续兼容版本),负责算法和本机IP限制;本次不把worker的算法、启动参数或IP中间件合并到网关。
|
||||||
|
|
||||||
|
## 调用流程
|
||||||
|
|
||||||
|
客户端 → `POST /api/v1/hairline/generate-52` → 网关现有队列和worker池 → worker同路径 → 网关落盘非空图片并返回公网URL。
|
||||||
|
|
||||||
|
- 请求体原样转发,网关附加配置中的`X-Internal-Token`;客户端和公开测试页不携带内部密码。
|
||||||
|
- 沿用现有主备派发、隔离期、重试、日志和全局并发限制。原有5号插队优先级保持原样,52号走默认队列。
|
||||||
|
- 5号`/api/v1/hairline/generate`和其他接口保持原样;网关不新增IP限制。
|
||||||
|
- worker的中下庭规则、扶正、3:4裁切和生发结果由同一次worker响应提供,网关不重新计算坐标。
|
||||||
|
|
||||||
|
## 请求
|
||||||
|
|
||||||
|
使用`multipart/form-data`;图片三选一:`image_file`、`image_url`、`image_base64`。必填`gender=male/female`和`hair_style=1..7`(支持逗号分隔多选)。
|
||||||
|
|
||||||
|
可选参数全部透传:
|
||||||
|
|
||||||
|
| 参数 | 默认值 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| normalize_image | false | true先扶正9→152并按当前规则裁切;网关测试页主动开启 |
|
||||||
|
| generate_grow_image | true | false跳过ComfyUI,快速检查曲线和坐标 |
|
||||||
|
| use_mask | true | 是否使用生发遮罩 |
|
||||||
|
| prompt | 填充遮罩区域的头发,人物增加一点磨皮 | 生发提示词 |
|
||||||
|
|
||||||
|
当前标准裁切高约`(3÷0.9)L`、宽`2.5L`,L为9→152像素距离;标准窗口不足时长图尝试保留人脸裁成3:4,其余仅扶正。具体状态以`framing`为准。
|
||||||
|
|
||||||
|
## 响应及空图片
|
||||||
|
|
||||||
|
worker始终返回`*_base64`。网关沿用原有公共改写,不添加52号专用的空字段改名规则:
|
||||||
|
|
||||||
|
| worker字段 | 网关字段 |
|
||||||
|
|---|---|
|
||||||
|
| image_middle_base64(非空PNG) | image_middle_url |
|
||||||
|
| input_image_base64、original_image_base64(非空JPEG) | input_image_url、original_image_url |
|
||||||
|
| grown_image_base64(非空JPEG) | grown_image_url |
|
||||||
|
| image_high_base64、image_low_base64(null) | 原字段保留,值为null |
|
||||||
|
| grown_image_base64(null) | 原字段保留,值为null;没有grown_image_url |
|
||||||
|
|
||||||
|
`height_rule`、每个样式的`height_placement`、`guide_landmarks`、`face_measure`、`framing`、`width/height`和中心点原样返回。开启预处理时`coordinate_space=processed_image`,middle必须叠加到`input_image_url`,原始图`original_image_url`仅供对照。
|
||||||
|
|
||||||
|
公开测试页:`/static/test_interface52.html`,按原图、middle、矩形裁切发际线叠加、生发图排列。矩形左/右边过162/389,底边过9,高为9→152的70%。worker的30案例调试接口和对比报告不在本次网关同步范围。
|
||||||
|
|
||||||
|
## 部署步骤
|
||||||
|
|
||||||
|
1. 网关拉取`main`,保留原有`gateway/config.json`。
|
||||||
|
2. 确保池中所有可能被选中的主/备worker都已提供`/api/v1/hairline/generate-52`;仅更新网关无法让旧worker获得52号功能。网关出口IP须在对应worker白名单内,当前GPU worker已允许`49.232.51.171`。无需在网关配置IP白名单。
|
||||||
|
3. 重启网关服务(本仓库示例名`hair-gateway`)。若线上使用`nginx/hair-gateway.conf`,同步其中的读取超时并检查、重载Nginx。单张高分辨率生发已观察到超过100秒,本次示例从90秒改为660秒,覆盖默认600秒上游单次请求超时;多样式或重试时按总耗时另行调整。
|
||||||
|
4. 先以`generate_grow_image=false`检查请求转发和URL,再在实际需要时验证生发。`/monitor`、worker主备配置和旧接口无需调整。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://hair.xiangsilian.com/api/v1/hairline/generate-52 \
|
||||||
|
-F 'image_file=@/path/to/photo.jpg' \
|
||||||
|
-F 'gender=male' -F 'hair_style=1' \
|
||||||
|
-F 'normalize_image=true' -F 'generate_grow_image=false'
|
||||||
|
```
|
||||||
|
|
||||||
|
成功时`code=0`,检查`input_image_url`和`hairline_images[].image_middle_url`可打开、尺寸一致;high/low仍为`*_base64: null`。访问测试页时无需输入内部Token。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
`tests/test_gateway_interface52.py`覆盖5/52路由分离、完整表单透传、由网关注入内部Token、真实公共转发函数的图片落盘与URL可访问、空字段保持原样和队列槽释放。现有pool、serialization、monitor、reqlog测试用于防止回归。
|
||||||
|
|
||||||
|
2026-11-04验证结果:上述网关测试共42项通过。另起本机临时网关连接当前真实GPU worker,浏览器验证旧5号仍返回三档,52号女性7型均返回可访问的图片URL,长图输出1062×1416,矩形叠加与空字段正常,页面请求不携带内部Token,无脚本错误。本轮关闭生发以验证网关链路,未重新运行ComfyUI生发;临时网关验证后关闭。
|
||||||
+149
-1
@@ -3,7 +3,7 @@
|
|||||||
> 本文档依据《旷视具体需求.docx》整理,描述各接口的**输入**与**输出**。
|
> 本文档依据《旷视具体需求.docx》整理,描述各接口的**输入**与**输出**。
|
||||||
> 文中标记为 **【待确认】** 的内容为需求文档中尚未明确、需与对方对齐的点。
|
> 文中标记为 **【待确认】** 的内容为需求文档中尚未明确、需与对方对齐的点。
|
||||||
|
|
||||||
> **当前为第一版 Mock 服务**:所有接口已上线,但**不做真实算法计算**,统一返回下文「响应示例」中的默认值;所有图片字段均返回示例图 `https://hair.xiangsilian.com/static/sample.jpg`。真实逻辑后续接入。
|
> 下文示例用于说明响应结构。接口52由网关转发至GPU worker执行真实计算,具体接入与部署见[接口52网关接入](接口52网关接入.md)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
| 3 B 端生发 | POST | `/api/v1/hair/grow-b` |
|
| 3 B 端生发 | POST | `/api/v1/hair/grow-b` |
|
||||||
| 4 用户特征 | POST | `/api/v1/face/features` |
|
| 4 用户特征 | POST | `/api/v1/face/features` |
|
||||||
| 5 发际线 PNG 生成 | POST | `/api/v1/hairline/generate` |
|
| 5 发际线 PNG 生成 | POST | `/api/v1/hairline/generate` |
|
||||||
|
| 52 发际线新规则 | POST | `/api/v1/hairline/generate-52` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -519,6 +520,153 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 接口 52:发际线 PNG 生成接口
|
||||||
|
|
||||||
|
**说明**:入参同接口2(先选性别、再多选发型)。对每个选中发型返回一张 `middle` 设计叠图与生发图。**目标上庭 =(中庭 + 下庭)÷2**,从眉心向上定位;所有形状的中点对齐同一目标。`high` / `low` 已停用,旧字段保留为 `null`。
|
||||||
|
|
||||||
|
**请求**:`POST /api/v1/hairline/generate-52`(网关透传至同路径GPU worker)
|
||||||
|
|
||||||
|
**职责**:GPU worker返回base64并完成算法处理,网关负责透传参数、附加内部Token、保存非空图片及返回公网URL。网关不增加IP限制;网关出口IP须已在worker白名单内。部署与完整字段约定见[接口52网关接入](接口52网关接入.md)。
|
||||||
|
|
||||||
|
### 图片扶正与标准构图
|
||||||
|
|
||||||
|
显式传`normalize_image=true`时,先扶正9→152,再按**高(3÷0.9)×L≈3.3333×L、宽2.5×L(3:4)**裁切,L为9→152的直线像素距离。相对原3L规则,裁切框宽高均扩大约11.11%,同样显示尺寸下人物缩小约10%。高度取最接近的4的倍数,宽度取高度的3/4。眉心定位沿用已扶正参考图:9号点相对位置约(49.0061%,34.4136%)。参考图仍为786×1048,两点X均为385.18784。
|
||||||
|
|
||||||
|
目标窗口完全在原图有效区域内才进行标准裁切。标准窗口不足时,偏长图片(宽/高<3/4)采用原图有效范围内最大的3:4窗口,尽量贴近眉心目标位置并保留完整人脸,`status=aspect_crop`;人脸放不进3:4窗口时回退仅扶正并返回原因。其余图片空间不足时仍仅扶正、去除旋转空角。不放大小图。**所有叠图与坐标使用响应中的处理后底图 `input_image_*`**,调用方应使用该底图叠加;`normalize_image=false` 可关闭此预处理。
|
||||||
|
|
||||||
|
`framing`包含处理前后尺寸、旋转角、是否标准裁切、跳过原因、`source_to_processed`/`processed_to_source`仿射矩阵。`status`区分`reference_crop`(标准窗口)、`aspect_crop`(长图3:4裁切)和`rotation_only`(仅扶正);`standard_crop=false`时仍可能有`aspect_crop=true`。`aspect_crop_skip_reason`说明长图无法保留人脸时的回退原因。`original_image_*`保留EXIF方向修正后的上传图,原图坐标可通过逆矩阵还原。
|
||||||
|
|
||||||
|
### 输入
|
||||||
|
|
||||||
|
图片参数见「通用约定 → 图片传参字段」。专属参数:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| gender | string | **是** | 性别:`male` / `female`。决定发型集合(female 7 / male 7)。缺失/非法返回 `1004` |
|
||||||
|
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),决定返回哪些发际线类型。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave, 6=bigflower, 7=clasicalflower;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight, 5=heart, 6=Softpetal, 7=wave。缺失/越界/非法返回 `1007` |
|
||||||
|
| use_mask | bool | 否 | 生发是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
|
||||||
|
| prompt | string | 否 | ComfyUI 提示词,默认「填充遮罩区域的头发,人物增加一点磨皮」,会替换工作流节点 60 的文本 |
|
||||||
|
| normalize_image | bool | 否 | 新增可选开关,默认false:保留原图尺寸与坐标。传true开启扶正和裁切;网关测试页显式传true |
|
||||||
|
| generate_grow_image | bool | 否 | 是否生成生发效果图(ComfyUI 生发,全流程最耗时),默认 `true`。传 `false` 时跳过生发,各发型 `grown_image_*` 恒为 `null`,仅返回设计叠图、中心点与高度规则数据,可大幅降低耗时 |
|
||||||
|
|
||||||
|
> 生发划线与遮罩直接取新位置的 middle 曲线;旧规则的头发边界与模板用于保留形状和倾斜,不决定新中心高度。计算使用未取整的像素高度,与四庭测量采用同一 Y 方向;测量主流程失败时用发际线管线的同编号关键点计算,`measurement_source` 标明来源。目标越出图片或无法对齐时返回 1007,不静默回退旧规则。
|
||||||
|
|
||||||
|
### 高度规则与对比
|
||||||
|
|
||||||
|
- **参数与字段**:原有8个请求参数、响应外层结构和所有旧返回字段均保留。`use_mask=true`、默认prompt、`generate_grow_image=true`保持原值。新功能只新增可选参数和返回字段;旧请求未传`normalize_image`时保留原图坐标,high/low按已约定保留null,worker始终返回`*_base64`;网关沿用原有规则,有图片时改为`*_url`,空图片保留`*_base64: null`。
|
||||||
|
- `height_rule`:`middle_height_px`、`lower_height_px`、`target_upper_height_px`、`target_upper_height_cm`(可空)、`target_center`、`brow_center`、`nose_bottom`、`chin_tip`、`measurement_source`。
|
||||||
|
- 每项 `height_placement`:`old_center`、`new_center`、`shift_x_px`、`shift_y_px`(正=下移)、`target_error_px`。浮点坐标保留精度,旧中心点字段继续返回取整像素。
|
||||||
|
- `face_measure` 为处理后底图的实际测量值,未被设计目标覆盖。
|
||||||
|
- `guide_landmarks`:9、150、152、162、251、389 号 MediaPipe 点的图片像素坐标,供对比页绘制参考框。参考框左/右边分别经过162/389,底边经过9,高度为9→152距离的70%;两侧均固定平行9→152。
|
||||||
|
- 网关测试页:`/static/test_interface52.html`。worker本机另有旧/新规则对比页,本次不向网关开放调试接口。
|
||||||
|
- 规则页每个发型依次展示未裁切原图、middle、裁切发际线叠加、生发图。“裁切发际线叠加”由页面按`guide_landmarks`生成:在相同处理后底图上叠加矩形内的middle PNG,矩形外保持透明;左/右边过162/389,底边过9,高度0.7×9→152,左右边平行9→152。
|
||||||
|
|
||||||
|
### 网关输出(data)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| hairline_images | object[] | **选中发型**列表,**数量 = 所选发型数**,元素见下表 |
|
||||||
|
| best_hairline_center_point | object \| null | **首个选中发型**的 **middle 档**发际线曲线「面部中间点」坐标,当前底图像素:`{ "x": number, "y": number }` |
|
||||||
|
| high_hairline_center_point | object \| null | 已停用,固定 `null` |
|
||||||
|
| low_hairline_center_point | object \| null | 已停用,固定 `null` |
|
||||||
|
| input_image_url | string | 当前底图JPEG的URL,发际线叠图、参考点和测量值均使用该图的坐标系 |
|
||||||
|
| original_image_url | string | EXIF方向修正后的上传原图JPEG的URL |
|
||||||
|
| width / height | int | 当前底图的实际像素宽 / 高 |
|
||||||
|
| coordinate_space | string | 开启预处理时为 `processed_image`,关闭时为 `original_image` |
|
||||||
|
| framing | object \| null | 裁切状态、旋转角、前后尺寸、轴线误差及双向仿射矩阵;关闭预处理时为 `null` |
|
||||||
|
| height_rule | object | 中庭/下庭、目标上庭高度、目标中心及测量来源,详见“高度规则与对比” |
|
||||||
|
| guide_landmarks | object | 9、150、152、162、251、389 号点的当前底图像素坐标;参考框实际使用9、152、162、389 |
|
||||||
|
| face_measure | object \| null | **复用接口1**的四庭七眼测量**数值**(不含标注图)。独立流程,测量失败(无人脸/非正面/分割失败)时为 `null`,不影响发际线主结果。字段结构见下表 |
|
||||||
|
|
||||||
|
`hairline_images` 元素:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`/`bigflower`/`clasicalflower`(female),`ellipse`/`m`/`straight`/`inverse_arc`/`heart`/`Softpetal`/`wave`(male) |
|
||||||
|
| image_middle_url | string | middle 档发际线曲线**透明 PNG** URL(仅曲线,透明底,**不含人物**,需叠加 `input_image_url` 显示) |
|
||||||
|
| image_high_base64 | null | 已停用,保留字段为 `null` |
|
||||||
|
| image_low_base64 | null | 已停用,保留字段为 `null` |
|
||||||
|
| grown_image_url | string | 生发成功时的完整人像JPEG URL |
|
||||||
|
| grown_image_base64 | null | 生发失败或`generate_grow_image=false`时返回;此时没有`grown_image_url` |
|
||||||
|
| order | int | 发型序号(= 传入的 hair_style 值) |
|
||||||
|
| height_placement | object | 旧/新中心、XY移动距离、目标误差,均为未取整的像素值 |
|
||||||
|
|
||||||
|
> 上表为网关响应字段。worker直连时所有图片字段均为`*_base64`;网关仅对有图片内容的字段生成`*_url`,high/low等空字段保留`*_base64: null`,沿用既有行为。
|
||||||
|
>
|
||||||
|
> middle透明PNG叠加到`input_image_url`,生发图直接显示。网关测试页为`/static/test_interface52.html`,页面不包含内部Token。
|
||||||
|
|
||||||
|
`face_measure` 元素(与[接口1](#接口-1四庭七眼测量标注接口)的 `data` 同构,**不含** `annotated_image_*` 标注图字段):
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| face_total_height_cm | number | 全脸总高度(cm)= 四庭之和 |
|
||||||
|
| four_courts | object | 四庭数据(顶/上/中/下庭 cm + 占比 ratios),结构同接口1 |
|
||||||
|
| seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距 cm + 占比 ratios + eye1~eye7 从左到右 7 段宽度),结构同接口1 |
|
||||||
|
| landmarks | object | 5 个纵向关键点像素坐标(hair_top/hairline/brow_center/nose_bottom/chin_tip),结构同接口1 |
|
||||||
|
| hairline_source | string | 发际线来源:`segmentation`(真实分割)/ `estimated`(比例估算)/ `corrected`(发际线贴近头顶、分割不可靠,已按 0.95×中庭反推修正) |
|
||||||
|
| head_pose | object | 头部姿态角度(yaw/pitch/roll,单位:度) |
|
||||||
|
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),当前底图像素:`{ "x": int, "y": int }` |
|
||||||
|
| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),当前底图像素:`{ "x": int, "y": int }` |
|
||||||
|
|
||||||
|
> `eye1`~`eye7` 为从左到右共 7 段宽度,eye1=左耳外段、eye7=右耳外段,某侧耳朵不可见时对应段为 `null`。详见接口1说明。
|
||||||
|
|
||||||
|
### 网关响应结构示例(normalize_image=false)
|
||||||
|
|
||||||
|
此例未开启裁切,坐标基于原图;`face_measure=null`时仍可按发际线关键点计算目标。有图片内容的字段已落盘为公网URL;空图片字段仍返回null,保留base64字段名。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"request_id": "mock-request-id",
|
||||||
|
"data": {
|
||||||
|
"hairline_images": [
|
||||||
|
{
|
||||||
|
"hairline_type": "ellipse",
|
||||||
|
"image_middle_url": "https://hair.xiangsilian.com/static/annotations/middle.png",
|
||||||
|
"image_high_base64": null,
|
||||||
|
"image_low_base64": null,
|
||||||
|
"grown_image_url": "https://hair.xiangsilian.com/static/annotations/grown.jpg",
|
||||||
|
"order": 1,
|
||||||
|
"height_placement": {
|
||||||
|
"old_center": {"x": 540.0, "y": 450.0},
|
||||||
|
"new_center": {"x": 540.0, "y": 430.0},
|
||||||
|
"shift_x_px": 0.0, "shift_y_px": -20.0, "target_error_px": 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"best_hairline_center_point": {"x": 540, "y": 430},
|
||||||
|
"high_hairline_center_point": null,
|
||||||
|
"low_hairline_center_point": null,
|
||||||
|
"input_image_url": "https://hair.xiangsilian.com/static/annotations/input.jpg",
|
||||||
|
"original_image_url": "https://hair.xiangsilian.com/static/annotations/original.jpg",
|
||||||
|
"width": 1080, "height": 1440,
|
||||||
|
"coordinate_space": "original_image",
|
||||||
|
"framing": null,
|
||||||
|
"height_rule": {
|
||||||
|
"name": "court_average", "formula": "上庭 = (中庭 + 下庭) / 2",
|
||||||
|
"measurement_source": "hairline_landmarks",
|
||||||
|
"brow_center": {"x": 540.0, "y": 740.0},
|
||||||
|
"nose_bottom": {"x": 540.0, "y": 1050.0},
|
||||||
|
"chin_tip": {"x": 540.0, "y": 1360.0},
|
||||||
|
"middle_height_px": 310.0, "lower_height_px": 310.0,
|
||||||
|
"target_upper_height_px": 310.0, "target_upper_height_cm": null,
|
||||||
|
"target_center": {"x": 540.0, "y": 430.0}
|
||||||
|
},
|
||||||
|
"guide_landmarks": {
|
||||||
|
"9": {"x": 540.0, "y": 740.0}, "150": {"x": 470.0, "y": 1340.0},
|
||||||
|
"152": {"x": 540.0, "y": 1360.0}, "162": {"x": 300.0, "y": 680.0},
|
||||||
|
"251": {"x": 770.0, "y": 630.0}, "389": {"x": 780.0, "y": 680.0}
|
||||||
|
},
|
||||||
|
"face_measure": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
开启`normalize_image=true`后,`coordinate_space=processed_image`,`framing`包含前述裁切状态和坐标变换,所有发际线图片须叠加到`input_image_url`。原始图片仅用于展示和坐标回溯。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 接口 9:头发遮罩生成接口
|
## 接口 9:头发遮罩生成接口
|
||||||
|
|
||||||
**说明**:输入一张含人头的照片,生成头发遮罩,并返回**每一步的可视化图**(供对比调试)。
|
**说明**:输入一张含人头的照片,生成头发遮罩,并返回**每一步的可视化图**(供对比调试)。
|
||||||
|
|||||||
@@ -5,6 +5,14 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 接口52(2026-11-04 已接入main)
|
||||||
|
|
||||||
|
新增`POST /api/v1/hairline/generate-52`,由网关转发到GPU worker同路径。5号保持原样,52号的`normalize_image`等参数原样透传;非空图片沿用公共base64→URL改写,空图片仍保留`*_base64: null`。不在网关增加IP限制,不合入worker算法。
|
||||||
|
|
||||||
|
测试页`/static/test_interface52.html`不包含内部密码。部署前确认主/备worker均支持52号,并检查生发请求的上游超时。详见[接口52网关接入](接口52网关接入.md)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 1. ✅【功能必需】base64→URL 落盘扩展名按内容嗅探(支持 JPG)
|
## 1. ✅【功能必需】base64→URL 落盘扩展名按内容嗅探(支持 JPG)
|
||||||
|
|
||||||
**背景**:接口 **2/3/5 的返回图改成了 JPG**(体积约小 9×),**接口 1 标注图仍是 PNG**(含透明)。
|
**背景**:接口 **2/3/5 的返回图改成了 JPG**(体积约小 9×),**接口 1 标注图仍是 PNG**(含透明)。
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ async def index():
|
|||||||
"if3_hair_grow_b": "/static/test_interface3.html",
|
"if3_hair_grow_b": "/static/test_interface3.html",
|
||||||
"if4_features": "/static/test_interface4.html",
|
"if4_features": "/static/test_interface4.html",
|
||||||
"if5_hairline": "/static/test_interface5.html",
|
"if5_hairline": "/static/test_interface5.html",
|
||||||
|
"if52_hairline": "/static/test_interface52.html",
|
||||||
"if6_measure_v2": "/static/test_interface6.html",
|
"if6_measure_v2": "/static/test_interface6.html",
|
||||||
"if7_hair_grow_v2": "/static/test_interface7.html",
|
"if7_hair_grow_v2": "/static/test_interface7.html",
|
||||||
"if9_head_mask": "/static/test_interface9.html",
|
"if9_head_mask": "/static/test_interface9.html",
|
||||||
@@ -671,6 +672,17 @@ async def hairline_generate(request: Request):
|
|||||||
return await _proxy(request, "/api/v1/hairline/generate")
|
return await _proxy(request, "/api/v1/hairline/generate")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/hairline/generate-52", tags=["人脸分析"],
|
||||||
|
summary="接口52:发际线新规则与人像裁切")
|
||||||
|
async def hairline_generate_52(request: Request):
|
||||||
|
"""透传图片三选一、gender、hair_style及normalize_image等参数至GPU worker。
|
||||||
|
|
||||||
|
worker负责中下庭均值定位、扶正裁切和生发;网关沿用原有派发、鉴权与图片URL改写。
|
||||||
|
空图片保持原有 *_base64: null 字段。接口5保持原样。
|
||||||
|
"""
|
||||||
|
return await _proxy(request, "/api/v1/hairline/generate-52")
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/hair/grow-v2", tags=["生发"])
|
@app.post("/api/v1/hair/grow-v2", tags=["生发"])
|
||||||
async def hair_grow_v2(request: Request):
|
async def hair_grow_v2(request: Request):
|
||||||
"""接口7:C端生发 v2(add_hair2 工作流)"""
|
"""接口7:C端生发 v2(add_hair2 工作流)"""
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
# 网关内部转发可能耗时较长(worker 处理 + 排队)
|
# 网关内部转发可能耗时较长(worker 处理 + 排队)
|
||||||
proxy_read_timeout 90s;
|
proxy_read_timeout 660s;
|
||||||
proxy_send_timeout 90s;
|
proxy_send_timeout 90s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"use strict";
|
||||||
|
// Reference geometry is in input-image pixels; no screen-vertical assumptions.
|
||||||
|
window.hairlineReferenceBox = function(points) {
|
||||||
|
const p = id => {
|
||||||
|
const value = points[String(id)];
|
||||||
|
if (!value || !Number.isFinite(value.x) || !Number.isFinite(value.y)) throw new Error(`缺少 ${id} 号关键点`);
|
||||||
|
return [value.x, value.y];
|
||||||
|
};
|
||||||
|
const add = (a,b) => [a[0]+b[0],a[1]+b[1]];
|
||||||
|
const sub = (a,b) => [a[0]-b[0],a[1]-b[1]];
|
||||||
|
const mul = (a,s) => [a[0]*s,a[1]*s];
|
||||||
|
const dot = (a,b) => a[0]*b[0]+a[1]*b[1];
|
||||||
|
const brow=p(9),chin=p(152),left=p(162),right=p(389);
|
||||||
|
const delta=sub(chin,brow),length=Math.hypot(...delta);
|
||||||
|
if(length<1)throw new Error("9→152 方向无法确定");
|
||||||
|
const up=mul(delta,-1/length),horizontal=[-up[1],up[0]],height=.7*length;
|
||||||
|
const sidePoint=(anchor,direction,level)=>{
|
||||||
|
const denominator=dot(direction,up);
|
||||||
|
if(Math.abs(denominator)<1e-6)throw new Error("参考框侧边与底边平行");
|
||||||
|
return add(anchor,mul(direction,(level-dot(sub(anchor,brow),up))/denominator));
|
||||||
|
};
|
||||||
|
const corners=[sidePoint(left,up,0),sidePoint(right,up,0),
|
||||||
|
sidePoint(right,up,height),sidePoint(left,up,height)];
|
||||||
|
if(dot(sub(corners[1],corners[0]),horizontal)<=0)throw new Error("参考框左右位置异常");
|
||||||
|
return {corners,height,faceLength:length,
|
||||||
|
landmarks:{9:brow,152:chin,162:left,389:right}};
|
||||||
|
};
|
||||||
|
|
||||||
|
window.clipHairlineToReferenceBox = function(image, points) {
|
||||||
|
const box=window.hairlineReferenceBox(points);
|
||||||
|
const width=image.naturalWidth||image.width,height=image.naturalHeight||image.height;
|
||||||
|
if(!width||!height)throw new Error("发际线图片尚未加载");
|
||||||
|
const canvas=document.createElement("canvas");canvas.width=width;canvas.height=height;
|
||||||
|
const ctx=canvas.getContext("2d");
|
||||||
|
ctx.beginPath();box.corners.forEach((p,i)=>i?ctx.lineTo(...p):ctx.moveTo(...p));ctx.closePath();
|
||||||
|
ctx.clip();ctx.drawImage(image,0,0,width,height);
|
||||||
|
return canvas;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.drawHairlineReferenceBox = function(ctx, points, crop, width, height) {
|
||||||
|
const box=window.hairlineReferenceBox(points);
|
||||||
|
const [x0,y0,x1,y1]=crop;
|
||||||
|
const screen=p=>[(p[0]-x0)/(x1-x0)*width,(p[1]-y0)/(y1-y0)*height];
|
||||||
|
const color="#e33399";
|
||||||
|
ctx.save();ctx.strokeStyle=color;ctx.lineWidth=Math.max(2.5,width/280);ctx.lineJoin="round";
|
||||||
|
ctx.beginPath();box.corners.forEach((p,i)=>{const q=screen(p);i?ctx.lineTo(...q):ctx.moveTo(...q);});ctx.closePath();ctx.stroke();
|
||||||
|
ctx.lineWidth=1.4;ctx.setLineDash([7,6]);ctx.beginPath();ctx.moveTo(...screen(box.landmarks[9]));ctx.lineTo(...screen(box.landmarks[152]));ctx.stroke();ctx.setLineDash([]);
|
||||||
|
const fontSize=Math.max(13,width/43);ctx.font=`600 ${fontSize}px sans-serif`;
|
||||||
|
for(const id of [9,152,162,389]){
|
||||||
|
const [x,y]=screen(box.landmarks[id]);
|
||||||
|
if(x<0||x>width||y<0||y>height)continue;
|
||||||
|
ctx.fillStyle=color;ctx.beginPath();ctx.arc(x,y,Math.max(3,width/160),0,Math.PI*2);ctx.fill();
|
||||||
|
const label=String(id),tw=ctx.measureText(label).width;
|
||||||
|
const tx=Math.max(3,Math.min(width-tw-8,x+7)),ty=Math.max(fontSize+4,Math.min(height-5,y-7));
|
||||||
|
ctx.fillStyle="rgba(255,255,255,.9)";ctx.fillRect(tx-3,ty-fontSize,tw+6,fontSize+4);ctx.fillStyle="#a51868";ctx.fillText(label,tx,ty);
|
||||||
|
}
|
||||||
|
const top=screen([(box.corners[2][0]+box.corners[3][0])/2,(box.corners[2][1]+box.corners[3][1])/2]);
|
||||||
|
const text="70% · 9→152",tw=ctx.measureText(text).width;
|
||||||
|
const tx=Math.max(4,Math.min(width-tw-6,top[0]-tw/2)),ty=Math.max(fontSize+4,top[1]-8);
|
||||||
|
ctx.fillStyle="rgba(255,255,255,.9)";ctx.fillRect(tx-3,ty-fontSize,tw+6,fontSize+4);ctx.fillStyle="#a51868";ctx.fillText(text,tx,ty);
|
||||||
|
ctx.restore();
|
||||||
|
};
|
||||||
@@ -52,4 +52,34 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.downscaleImageFile = downscaleImageFile;
|
window.downscaleImageFile = downscaleImageFile;
|
||||||
|
/**
|
||||||
|
* 将接口返回的图片字段规范成 <img src> 可用值。
|
||||||
|
* 同时兼容:
|
||||||
|
* - 网关改写后的 *_url(http/https/相对路径)
|
||||||
|
* - worker 直连的 *_base64(原始 base64,或已带 data:image/...;base64, 前缀)
|
||||||
|
* @param {string|null|undefined} url
|
||||||
|
* @param {string|null|undefined} b64
|
||||||
|
* @param {string} [mimeHint='image/jpeg'] 原始 base64 时的 MIME 提示(会按魔数再嗅探)
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function resolveImgSrc(url, b64, mimeHint) {
|
||||||
|
var v = url || b64 || '';
|
||||||
|
if (!v || typeof v !== 'string') return '';
|
||||||
|
v = v.trim();
|
||||||
|
if (!v) return '';
|
||||||
|
// 已是可用 URL / data URI / blob / 协议相对 URL
|
||||||
|
if (/^(https?:|blob:|data:|\/\/)/i.test(v)) return v;
|
||||||
|
// 同站相对路径(仅匹配明确的 /static/...;勿把 JPEG base64 的 /9j/ 前缀当路径)
|
||||||
|
if (/^\/static\//.test(v)) return v;
|
||||||
|
// 原始 base64:去掉可能残留的 data URI 前缀后再拼,避免双重前缀导致无法显示
|
||||||
|
var raw = v.replace(/^data:image\/[a-zA-Z0-9+.-]+;base64,/i, '');
|
||||||
|
if (!raw) return '';
|
||||||
|
var mime = mimeHint || 'image/jpeg';
|
||||||
|
if (raw.indexOf('iVBOR') === 0) mime = 'image/png';
|
||||||
|
else if (raw.indexOf('/9j/') === 0) mime = 'image/jpeg';
|
||||||
|
else if (raw.indexOf('R0lGOD') === 0) mime = 'image/gif';
|
||||||
|
else if (raw.indexOf('UklGR') === 0) mime = 'image/webp';
|
||||||
|
return 'data:' + mime + ';base64,' + raw;
|
||||||
|
}
|
||||||
|
window.resolveImgSrc = resolveImgSrc;
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
<a href="#if3">接口3</a>
|
<a href="#if3">接口3</a>
|
||||||
<a href="#if4">接口4</a>
|
<a href="#if4">接口4</a>
|
||||||
<a href="#if5">接口5</a>
|
<a href="#if5">接口5</a>
|
||||||
|
<a href="#if52">接口52</a>
|
||||||
<a href="#if6">接口6</a>
|
<a href="#if6">接口6</a>
|
||||||
<a href="#errors">错误码</a>
|
<a href="#errors">错误码</a>
|
||||||
<a href="#test">在线测试</a>
|
<a href="#test">在线测试</a>
|
||||||
@@ -263,6 +264,16 @@ console.log(features.gene_style); // "自然型"</pre>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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 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>
|
||||||
|
<p style="margin-top:12px"><a href="/static/test_interface52.html" class="link">52号规则测试页</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ======== 接口6 ======== -->
|
<!-- ======== 接口6 ======== -->
|
||||||
<div class="card" id="if6">
|
<div class="card" id="if6">
|
||||||
<h2>6. 四庭七眼测量 v2 <span class="badge post">POST</span> <code>/api/v1/face/measure-v2</code> <span class="badge warn">v2</span></h2>
|
<h2>6. 四庭七眼测量 v2 <span class="badge post">POST</span> <code>/api/v1/face/measure-v2</code> <span class="badge warn">v2</span></h2>
|
||||||
@@ -326,6 +337,7 @@ console.log(features.gene_style); // "自然型"</pre>
|
|||||||
<tr><td>3. B端生发</td><td><a href="/static/test_interface3.html" class="link">/static/test_interface3.html</a></td><td>划线图上传 → 生发效果图</td></tr>
|
<tr><td>3. B端生发</td><td><a href="/static/test_interface3.html" class="link">/static/test_interface3.html</a></td><td>划线图上传 → 生发效果图</td></tr>
|
||||||
<tr><td>4. 用户特征</td><td><a href="/static/test_interface4.html" class="link">/static/test_interface4.html</a></td><td>上传照片 → 7项面部特征 + 原始JSON</td></tr>
|
<tr><td>4. 用户特征</td><td><a href="/static/test_interface4.html" class="link">/static/test_interface4.html</a></td><td>上传照片 → 7项面部特征 + 原始JSON</td></tr>
|
||||||
<tr><td>5. 发际线PNG</td><td><a href="/static/test_interface5.html" class="link">/static/test_interface5.html</a></td><td>上传+性别 → 发际线方案+中心点坐标</td></tr>
|
<tr><td>5. 发际线PNG</td><td><a href="/static/test_interface5.html" class="link">/static/test_interface5.html</a></td><td>上传+性别 → 发际线方案+中心点坐标</td></tr>
|
||||||
|
<tr><td>52. 发际线新规则</td><td><a href="/static/test_interface52.html" class="link">/static/test_interface52.html</a></td><td>中下庭定位、扶正裁切、矩形叠加与生发</td></tr>
|
||||||
<tr><td>6. 四庭七眼 v2</td><td><a href="/static/test_interface6.html" class="link">/static/test_interface6.html</a></td><td>同接口1,去顶庭 · 竖线发际线→下巴 · 无头部端线</td></tr>
|
<tr><td>6. 四庭七眼 v2</td><td><a href="/static/test_interface6.html" class="link">/static/test_interface6.html</a></td><td>同接口1,去顶庭 · 竖线发际线→下巴 · 无头部端线</td></tr>
|
||||||
<tr><td>9. 头发遮罩</td><td><a href="/static/test_interface9.html" class="link">/static/test_interface9.html</a></td><td>上传照片 → 头发遮罩 + 分步可视化</td></tr>
|
<tr><td>9. 头发遮罩</td><td><a href="/static/test_interface9.html" class="link">/static/test_interface9.html</a></td><td>上传照片 → 头发遮罩 + 分步可视化</td></tr>
|
||||||
<tr><td>10. 外缘膨胀带</td><td><a href="/static/test_interface10.html" class="link">/static/test_interface10.html</a></td><td>上传照片 → 头部外缘带状遮罩 + 分步可视化</td></tr>
|
<tr><td>10. 外缘膨胀带</td><td><a href="/static/test_interface10.html" class="link">/static/test_interface10.html</a></td><td>上传照片 → 头部外缘带状遮罩 + 分步可视化</td></tr>
|
||||||
|
|||||||
@@ -0,0 +1,418 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>接口52 — 发际线PNG生成 测试页</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; }
|
||||||
|
.form-group { display: flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||||||
|
.form-group label { font-size: 13px; font-weight: 600; color: #374151; }
|
||||||
|
.form-group select { padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px; }
|
||||||
|
.hair-checkboxes { display: flex; flex-wrap: wrap; gap: 4px 14px; align-items: center; }
|
||||||
|
.hair-checkboxes label { font-size: 13px; font-weight: 500; color: #374151; cursor: pointer; display: inline-flex; align-items: center; gap: 3px; white-space: nowrap; }
|
||||||
|
.hair-checkboxes label input[type=checkbox] { cursor: pointer; }
|
||||||
|
.checkbox-actions { display: flex; gap: 6px; margin-left: 4px; }
|
||||||
|
.checkbox-actions button { font-size: 11px; padding: 2px 8px; border-radius: 4px; border: 1px solid #d1d5db; background: #f9fafb; cursor: pointer; }
|
||||||
|
.checkbox-actions button:hover { background: #e5e7eb; }
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
/* 每个发型区块 */
|
||||||
|
.hair-block { padding: 16px 0; border-bottom: 1px solid #f0f0f0; }
|
||||||
|
.hair-block:first-child { padding-top: 4px; }
|
||||||
|
.hair-block:last-child { border-bottom: none; padding-bottom: 4px; }
|
||||||
|
.hair-block-title { font-size: 15px; font-weight: 700; color: #111827; margin-bottom: 10px; }
|
||||||
|
|
||||||
|
/* 未裁切原图 + middle + 矩形裁切发际线叠加 + 生发图 */
|
||||||
|
.level-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; align-items: start; }
|
||||||
|
.level-cell { background: #222; border-radius: 8px; overflow: hidden; display: flex; flex-direction: column; }
|
||||||
|
.level-cell .level-label { font-size: 12px; font-weight: 600; color: #fff; background: rgba(0,0,0,.55); padding: 4px 8px; min-height: 38px; display: flex; align-items: center; }
|
||||||
|
.level-cell img { width: 100%; max-height: 380px; object-fit: contain; display: block; }
|
||||||
|
.level-cell .level-none { color: #9ca3af; font-size: 13px; padding: 28px 0; text-align: center; }
|
||||||
|
/* 处理后底图 + 同尺寸透明设计曲线叠加 */
|
||||||
|
.level-cell .img-stack { position: relative; line-height: 0; }
|
||||||
|
.level-cell .img-stack .layer-base { width: 100%; max-height: 380px; object-fit: contain; display: block; }
|
||||||
|
.level-cell .img-stack .layer-anno { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: contain; }
|
||||||
|
.hairline-crop-border { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; overflow: visible; }
|
||||||
|
.hairline-crop-border polygon { fill: none; stroke: #e33399; stroke-width: 2; stroke-dasharray: 6 4; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
|
||||||
|
@media (max-width: 700px) { .level-row { grid-template-columns: repeat(2, 1fr); } }
|
||||||
|
|
||||||
|
/* 坐标高亮 */
|
||||||
|
.coord-box { background: #f9fafb; border-radius: 8px; padding: 12px 16px; margin-top: 10px; font-size: 14px; }
|
||||||
|
.coord-box .label { font-size: 11px; color: #9ca3af; margin-bottom: 4px; }
|
||||||
|
.coord-box .value { font-weight: 700; font-size: 18px; color: #111827; }
|
||||||
|
|
||||||
|
/* 四庭七眼测量(face_measure) */
|
||||||
|
.fm-section { margin-bottom: 18px; }
|
||||||
|
.fm-section:last-child { margin-bottom: 0; }
|
||||||
|
.fm-title { font-size: 13px; font-weight: 700; color: #374151; margin-bottom: 8px; }
|
||||||
|
.fm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; }
|
||||||
|
.fm-cell { background: #f9fafb; border-radius: 6px; padding: 8px 10px; border: 1px solid #f0f0f0; }
|
||||||
|
.fm-cell .k { font-size: 11px; color: #9ca3af; margin-bottom: 2px; }
|
||||||
|
.fm-cell .v { font-size: 15px; font-weight: 700; color: #111827; }
|
||||||
|
.fm-cell .v .sub { font-size: 11px; font-weight: 500; color: #6b7280; margin-left: 4px; }
|
||||||
|
.fm-cell.muted .v { color: #9ca3af; font-weight: 600; }
|
||||||
|
.fm-tag { display: inline-block; font-size: 11px; padding: 2px 8px; border-radius: 10px; margin-left: 6px; font-weight: 600; }
|
||||||
|
.fm-tag.ok { background: #d1fae5; color: #065f46; }
|
||||||
|
.fm-tag.warn { background: #fef3c7; color: #92400e; }
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
.rule-banner { background:#ecfdf5; border:1px solid #a7f3d0; border-radius:12px; padding:18px; margin-bottom:20px; line-height:1.8; }
|
||||||
|
.rule-banner strong { color:#065f46; font-size:19px; }
|
||||||
|
.rule-banner a { color:#065f46; margin-left:18px; font-weight:600; }
|
||||||
|
.placement { font-size:12px; color:#64748b; font-weight:400; margin:6px 0 10px; }
|
||||||
|
@media (max-width: 800px) { .results-layout { flex-direction: column; } }
|
||||||
|
</style>
|
||||||
|
<script src="/static/img_downscale.js?v=52"></script>
|
||||||
|
<script src="/static/hairline_reference_box.js?v=2"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>💈 接口52 — 发际线 PNG 生成 测试</h1>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>性别</label>
|
||||||
|
<select id="gender" onchange="onGenderChange()"><option value="female" selected>👩 Female(7种)</option><option value="male">👨 Male(7种)</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="align-items:flex-start">
|
||||||
|
<label style="padding-top:3px">发型 *</label>
|
||||||
|
<div>
|
||||||
|
<div class="hair-checkboxes" id="hairStyleGroup"></div>
|
||||||
|
<div class="checkbox-actions" style="margin-top:4px">
|
||||||
|
<button type="button" onclick="selectAllHair(true)">全选</button>
|
||||||
|
<button type="button" onclick="selectAllHair(false)">全不选</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>生发效果图</label>
|
||||||
|
<label class="checkbox-inline" style="font-weight:normal;display:flex;align-items:center;gap:6px">
|
||||||
|
<input type="checkbox" id="genGrowImg" checked> 同时生成生发效果(关闭后只查看设计曲线,速度更快)
|
||||||
|
</label>
|
||||||
|
</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 正面照 | Female: 椭圆/花瓣/心形/直线/波浪/大花瓣/古典花瓣 | Male: 椭圆/倒弧/M形/直线/桃心/柔和花瓣/波浪 | 每个发型返回一张设计曲线,生发遮罩同步到新位置</div>
|
||||||
|
<div id="statusBar" class="status hidden"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="results-layout hidden" id="resultsArea">
|
||||||
|
<div class="col-main">
|
||||||
|
<!-- 四庭七眼测量(face_measure,复用接口1数值) -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><span>📐 处理后图像的四庭七眼测量</span><span style="font-weight:400;font-size:12px;color:#9ca3af">与当前显示底图使用同一坐标系</span></div>
|
||||||
|
<div class="card-body"><div id="faceMeasureArea"><span style="color:#9ca3af">—</span></div></div>
|
||||||
|
</div>
|
||||||
|
<!-- 所有发型平铺 -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><span>🎯 未裁切原图 + middle + 裁切发际线叠加 + 生发图</span><span style="font-weight:400;font-size:12px;color:#9ca3af" id="resultCount">共 0 个发型</span></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="coord-box" style="margin-top:0;margin-bottom:16px">
|
||||||
|
<div class="label">📍 设计中心点 — 图片像素坐标</div>
|
||||||
|
<div class="value">
|
||||||
|
<span id="centerPoint">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="ruleDetails" style="font-size:13px;color:#475569;margin-bottom:16px;line-height:1.8"></div>
|
||||||
|
<div id="resultsGrid"></div>
|
||||||
|
</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;
|
||||||
|
let _images = [], _uploadUrl = '', _originalSrc = '', _processedSrc = '', _center = null, _guideLandmarks = null, _renderVersion = 0;
|
||||||
|
|
||||||
|
function $(id) { return document.getElementById(id); }
|
||||||
|
function setStatus(t, type) { const b=$('statusBar'); b.textContent=t; b.className='status '+type; }
|
||||||
|
|
||||||
|
// 发型列表(同接口2)
|
||||||
|
const HAIR_STYLES = {
|
||||||
|
female: [
|
||||||
|
{ value:'1', label:'1. ellipse 椭圆' },
|
||||||
|
{ value:'2', label:'2. flower 花瓣' },
|
||||||
|
{ value:'3', label:'3. heart 心形' },
|
||||||
|
{ value:'4', label:'4. straight 直线' },
|
||||||
|
{ value:'5', label:'5. wave 波浪' },
|
||||||
|
{ value:'6', label:'6. bigflower 大花瓣型' },
|
||||||
|
{ value:'7', label:'7. clasicalflower 古典花瓣型' },
|
||||||
|
],
|
||||||
|
male: [
|
||||||
|
{ value:'1', label:'1. ellipse 椭圆' },
|
||||||
|
{ value:'2', label:'2. inverse_arc 倒弧' },
|
||||||
|
{ value:'3', label:'3. m M形' },
|
||||||
|
{ value:'4', label:'4. straight 直线' },
|
||||||
|
{ value:'5', label:'5. heart 桃心形' },
|
||||||
|
{ value:'6', label:'6. Softpetal 柔和花瓣形' },
|
||||||
|
{ value:'7', label:'7. wave 波浪形' },
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
function onGenderChange() {
|
||||||
|
const g = $('gender').value;
|
||||||
|
const div = $('hairStyleGroup');
|
||||||
|
div.innerHTML = '';
|
||||||
|
HAIR_STYLES[g].forEach(s => {
|
||||||
|
const cb = document.createElement('input');
|
||||||
|
cb.type = 'checkbox'; cb.value = s.value; cb.id = 'hs_' + s.value;
|
||||||
|
const lb = document.createElement('label');
|
||||||
|
lb.htmlFor = cb.id; lb.appendChild(cb);
|
||||||
|
lb.appendChild(document.createTextNode(' ' + s.label));
|
||||||
|
div.appendChild(lb);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllHair(select) {
|
||||||
|
document.querySelectorAll('#hairStyleGroup input[type=checkbox]').forEach(cb => { cb.checked = select; });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitTest() {
|
||||||
|
let f = $('imageFile').files[0];
|
||||||
|
if (!f) { setStatus('请选择图片', 'error'); return; }
|
||||||
|
const checked = [...document.querySelectorAll('#hairStyleGroup input:checked')].map(cb => cb.value);
|
||||||
|
if (!checked.length) { setStatus('请至少选择一个发型', 'error'); return; }
|
||||||
|
// Preserve native pixels: the worker frames and straightens the image first.
|
||||||
|
|
||||||
|
if (_uploadUrl) URL.revokeObjectURL(_uploadUrl);
|
||||||
|
_uploadUrl = URL.createObjectURL(f);
|
||||||
|
_originalSrc = _uploadUrl; _processedSrc = ''; _images = []; _guideLandmarks = null; _renderVersion++;
|
||||||
|
$('resultsGrid').innerHTML = '';
|
||||||
|
$('submitBtn').disabled = true; $('submitBtn').textContent = '⏳ ...';
|
||||||
|
setStatus('请求中...', 'info'); $('resultsArea').classList.add('hidden');
|
||||||
|
|
||||||
|
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', 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) {
|
||||||
|
_images = json.data.hairline_images || [];
|
||||||
|
_originalSrc = resolveImgSrc(json.data.original_image_url, json.data.original_image_base64, 'image/jpeg') || _uploadUrl;
|
||||||
|
_processedSrc = resolveImgSrc(json.data.input_image_url, json.data.input_image_base64, 'image/jpeg');
|
||||||
|
_guideLandmarks = json.data.guide_landmarks;
|
||||||
|
if (_originalSrc !== _uploadUrl) { URL.revokeObjectURL(_uploadUrl); _uploadUrl = ''; }
|
||||||
|
_center = json.data.best_hairline_center_point;
|
||||||
|
const _fmtPt = p => p ? '(' + p.x + ', ' + p.y + ')' : '—';
|
||||||
|
$('centerPoint').textContent = _fmtPt(json.data.best_hairline_center_point);
|
||||||
|
const rule = json.data.height_rule;
|
||||||
|
$('ruleDetails').textContent = rule
|
||||||
|
? '中庭 ' + rule.middle_height_px.toFixed(2) + ' px + 下庭 ' + rule.lower_height_px.toFixed(2) + ' px → 目标上庭 ' + rule.target_upper_height_px.toFixed(2) + ' px' + (rule.target_upper_height_cm == null ? '' : '(约 ' + rule.target_upper_height_cm.toFixed(2) + ' cm)') + ';目标 y = ' + rule.target_center.y.toFixed(2) + '。测量来源:' + (rule.measurement_source === 'face_measure' ? '当前底图测量' : '发际线关键点')
|
||||||
|
: '—';
|
||||||
|
if (json.data.framing) { const f = json.data.framing; $('ruleDetails').textContent += ';' + f.message + ',旋转 ' + f.rotation_degrees.toFixed(3) + '°,底图 ' + f.output_size.width + '×' + f.output_size.height + '。'; }
|
||||||
|
$('resultCount').textContent = '共 ' + _images.length + ' 个发型';
|
||||||
|
setStatus('✅ ' + _images.length + ' 个发型已按新规则定位 (' + _elapsed + 's)', 'success');
|
||||||
|
await renderGrid();
|
||||||
|
renderFaceMeasure(json.data.face_measure);
|
||||||
|
} 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 = '🚀 提交'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildClippedOverlay(src, points) {
|
||||||
|
const image = await new Promise((resolve, reject) => {
|
||||||
|
const img = new Image(); img.crossOrigin = 'anonymous';
|
||||||
|
img.onload = () => resolve(img); img.onerror = () => reject(new Error('无法读取发际线图片'));
|
||||||
|
img.src = src;
|
||||||
|
});
|
||||||
|
const canvas = window.clipHairlineToReferenceBox(image, points);
|
||||||
|
const corners = window.hairlineReferenceBox(points).corners.map(p => p.join(',')).join(' ');
|
||||||
|
const result = {
|
||||||
|
src: canvas.toDataURL('image/png'),
|
||||||
|
border: '<svg class="hairline-crop-border" viewBox="0 0 '+canvas.width+' '+canvas.height+'" role="img" aria-label="粉色虚线:发际线裁切边界"><polygon points="'+corners+'"></polygon></svg>'
|
||||||
|
};
|
||||||
|
canvas.width = 0; canvas.height = 0;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderGrid() {
|
||||||
|
const version = ++_renderVersion;
|
||||||
|
if (!_images.length) { $('resultsGrid').innerHTML = '<span style="color:#9ca3af">无数据</span>'; return; }
|
||||||
|
const images = _images, originalSrc = _originalSrc, processedSrc = _processedSrc, guideLandmarks = _guideLandmarks;
|
||||||
|
const escapeText = text => String(text).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
// The overlay uses the server's processed image, with matching coordinates.
|
||||||
|
const overlayCell = (label, src, className, emptyText = '暂无处理后图片', border = '') => (src && processedSrc)
|
||||||
|
? '<div class="level-cell '+className+'"><div class="level-label">'+label+'</div><div class="img-stack">'+
|
||||||
|
'<img class="layer-base" src="'+processedSrc+'" alt="处理后底图">'+
|
||||||
|
'<img class="layer-anno" src="'+src+'" alt="'+label+'">'+border+'</div></div>'
|
||||||
|
: '<div class="level-cell '+className+'"><div class="level-label">'+label+'</div><div class="level-none">'+escapeText(emptyText)+'</div></div>';
|
||||||
|
const photoCell = (label, src, className) => src
|
||||||
|
? '<div class="level-cell '+className+'"><div class="level-label">'+label+'</div><img src="'+src+'" alt="'+label+'"></div>'
|
||||||
|
: '<div class="level-cell '+className+'"><div class="level-label">'+label+'</div><div class="level-none">无</div></div>';
|
||||||
|
const blocks = await Promise.all(images.map(async function(it) {
|
||||||
|
// 兼容网关 *_url 与 worker *_base64
|
||||||
|
const mid = resolveImgSrc(it.image_middle_url, it.image_middle_base64, 'image/png');
|
||||||
|
const grown = resolveImgSrc(it.grown_image_url, it.grown_image_base64, 'image/jpeg');
|
||||||
|
let clipped = '', clipBorder = '', clipError = '缺少发际线图片或参考点';
|
||||||
|
if (mid && processedSrc && guideLandmarks) {
|
||||||
|
try { const preview = await buildClippedOverlay(mid, guideLandmarks); clipped = preview.src; clipBorder = preview.border; }
|
||||||
|
catch (error) { clipError = error.message; }
|
||||||
|
}
|
||||||
|
return '<div class="hair-block">' +
|
||||||
|
'<div class="hair-block-title">#' + (it.order||'—') + ' ' + (it.hairline_type||'') +
|
||||||
|
(grown ? ' <span style="font-size:11px;color:#7c3aed">含生发图</span>' : '') + '</div>' +
|
||||||
|
(it.height_placement ? '<div class="placement">中心 y:' + it.height_placement.new_center.y.toFixed(2) + ' · 相对旧规则 ' + (it.height_placement.shift_y_px >= 0 ? '下移 ' : '上移 ') + Math.abs(it.height_placement.shift_y_px).toFixed(2) + ' px · 目标误差 ' + it.height_placement.target_error_px.toFixed(2) + ' px</div>' : '') +
|
||||||
|
'<div class="level-row">' +
|
||||||
|
photoCell('未裁切原图', originalSrc, 'original-cell') +
|
||||||
|
overlayCell('middle · 发际线叠加', mid, 'middle-cell') +
|
||||||
|
overlayCell('裁切发际线叠加', clipped, 'clipped-cell', clipError, clipBorder) +
|
||||||
|
photoCell('生发图', grown, 'grown-cell') +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
}));
|
||||||
|
if (version === _renderVersion) $('resultsGrid').innerHTML = blocks.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearResults() {
|
||||||
|
if (_uploadUrl) URL.revokeObjectURL(_uploadUrl);
|
||||||
|
_images=[]; _uploadUrl=''; _originalSrc=''; _processedSrc=''; _center=null; _guideLandmarks=null; _renderVersion++;
|
||||||
|
$('ruleDetails').textContent = '';
|
||||||
|
$('resultsArea').classList.add('hidden'); $('statusBar').className='status hidden';
|
||||||
|
$('imageFile').value=''; $('jsonContent').textContent=''; $('resultsGrid').innerHTML='';
|
||||||
|
$('centerPoint').textContent='—';
|
||||||
|
$('resultCount').textContent = '共 0 个发型';
|
||||||
|
$('faceMeasureArea').innerHTML = '<span style="color:#9ca3af">—</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 四庭七眼测量渲染(face_measure,接口1数值)
|
||||||
|
const _PCT = r => (r == null ? '' : '(' + (r * 100).toFixed(1) + '%)');
|
||||||
|
const _FMT = v => (v == null ? '—' : Number(v).toFixed(2));
|
||||||
|
function renderFaceMeasure(fm) {
|
||||||
|
const box = $('faceMeasureArea');
|
||||||
|
if (!fm) {
|
||||||
|
box.innerHTML = '<div style="padding:24px;text-align:center;color:#9ca3af;font-size:14px">⚠️ face_measure = null(测量失败或未启用)</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 发际线来源 + 头部姿态
|
||||||
|
const src = fm.hairline_source;
|
||||||
|
const srcTag = src === 'segmentation'
|
||||||
|
? '<span class="fm-tag ok">segmentation 真实分割</span>'
|
||||||
|
: (src === 'estimated' ? '<span class="fm-tag warn">estimated 比例估算</span>' : '');
|
||||||
|
let poseHtml = '—';
|
||||||
|
if (fm.head_pose) {
|
||||||
|
const p = fm.head_pose;
|
||||||
|
poseHtml = 'yaw ' + _FMT(p.yaw) + '° pitch ' + _FMT(p.pitch) + '° roll ' + _FMT(p.roll) + '°';
|
||||||
|
}
|
||||||
|
let h = '<div class="coord-box" style="margin-top:0;margin-bottom:16px;display:flex;gap:32px;flex-wrap:wrap;align-items:center">'
|
||||||
|
+ '<div><div class="label">脸总高</div><div class="value">' + _FMT(fm.face_total_height_cm) + ' cm</div></div>'
|
||||||
|
+ '<div><div class="label">发际线来源</div><div class="value" style="font-size:15px">' + (src || '—') + srcTag + '</div></div>'
|
||||||
|
+ '<div><div class="label">头部姿态</div><div class="value" style="font-size:14px">' + poseHtml + '</div></div>'
|
||||||
|
+ '</div>';
|
||||||
|
|
||||||
|
// 四庭
|
||||||
|
const fc = fm.four_courts || {};
|
||||||
|
const fr = (fc.ratios || {});
|
||||||
|
const courts = [
|
||||||
|
['顶庭', fc.top_court_cm, fr.top_court],
|
||||||
|
['上庭', fc.upper_court_cm, fr.upper_court],
|
||||||
|
['中庭', fc.middle_court_cm, fr.middle_court],
|
||||||
|
['下庭', fc.lower_court_cm, fr.lower_court],
|
||||||
|
];
|
||||||
|
h += '<div class="fm-section"><div class="fm-title">📏 四庭(纵向,自上而下)</div><div class="fm-grid">';
|
||||||
|
courts.forEach(c => {
|
||||||
|
h += '<div class="fm-cell"><div class="k">' + c[0] + '</div><div class="v">' + _FMT(c[1]) + ' cm<span class="sub">' + _PCT(c[2]).replace(/[()]/g, '') + '</span></div></div>';
|
||||||
|
});
|
||||||
|
h += '</div></div>';
|
||||||
|
|
||||||
|
// 七眼
|
||||||
|
const se = fm.seven_eyes || {};
|
||||||
|
const ser = (se.ratios || {});
|
||||||
|
h += '<div class="fm-section"><div class="fm-title">👁 七眼(横向汇总)</div><div class="fm-grid">';
|
||||||
|
[['单眼宽', se.eye_width_cm, ser.eye_width], ['脸宽', se.face_width_cm, null], ['两眼间距', se.inter_eye_distance_cm, ser.inter_eye_distance]].forEach(e => {
|
||||||
|
h += '<div class="fm-cell"><div class="k">' + e[0] + '</div><div class="v">' + _FMT(e[1]) + ' cm' + (e[2] != null ? '<span class="sub">' + (e[2] * 100).toFixed(1) + '%</span>' : '') + '</div></div>';
|
||||||
|
});
|
||||||
|
h += '</div></div>';
|
||||||
|
|
||||||
|
// 七眼 7 段(从左到右)
|
||||||
|
const segLabels = ['eye1 左耳外段', 'eye2 左脸颊段', 'eye3 左眼', 'eye4 两眼间距', 'eye5 右眼', 'eye6 右脸颊段', 'eye7 右耳外段'];
|
||||||
|
h += '<div class="fm-section"><div class="fm-title">↔️ 七眼 7 段宽度(从左到右,cm)</div><div class="fm-grid" style="grid-template-columns:repeat(7,1fr)">';
|
||||||
|
segLabels.forEach((lab, i) => {
|
||||||
|
const v = se['eye' + (i + 1)];
|
||||||
|
const muted = v == null ? ' muted' : '';
|
||||||
|
h += '<div class="fm-cell' + muted + '"><div class="k">' + lab + '</div><div class="v">' + (v == null ? '—' : _FMT(v)) + '</div></div>';
|
||||||
|
});
|
||||||
|
h += '</div></div>';
|
||||||
|
|
||||||
|
// 关键点坐标
|
||||||
|
const lm = fm.landmarks || {};
|
||||||
|
const lmLabels = { hair_top: '头顶', hairline: '发际线', brow_center: '眉心', nose_bottom: '鼻翼下缘', chin_tip: '下巴尖' };
|
||||||
|
h += '<div class="fm-section"><div class="fm-title">📍 关键分界点(当前底图像素坐标)</div><div class="fm-grid">';
|
||||||
|
Object.keys(lmLabels).forEach(k => {
|
||||||
|
const p = lm[k];
|
||||||
|
h += '<div class="fm-cell"><div class="k">' + lmLabels[k] + '</div><div class="v" style="font-size:14px">' + (p ? '(' + p.x + ', ' + p.y + ')' : '—') + '</div></div>';
|
||||||
|
});
|
||||||
|
h += '</div></div>';
|
||||||
|
|
||||||
|
box.innerHTML = h;
|
||||||
|
}
|
||||||
|
|
||||||
|
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', () => {
|
||||||
|
onGenderChange(); // 初始填充发型列表
|
||||||
|
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>
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""Gateway-only interface 52 integration: raw forwarding, token and image URLs."""
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import importlib
|
||||||
|
import io
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from PIL import Image
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from gateway import config, forward
|
||||||
|
|
||||||
|
gateway = importlib.import_module("gateway.app")
|
||||||
|
PATH = "/api/v1/hairline/generate-52"
|
||||||
|
|
||||||
|
|
||||||
|
def image_bytes(fmt):
|
||||||
|
buf = io.BytesIO()
|
||||||
|
Image.new("RGB", (6, 8), (127, 127, 127)).save(buf, format=fmt)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def proxy_setup(tmp_path, monkeypatch):
|
||||||
|
output = tmp_path / "annotations"
|
||||||
|
output.mkdir()
|
||||||
|
cfg = {"shared_password": "private-gateway-token", "public_base_url": "http://testserver",
|
||||||
|
"static_dir": str(output), "dispatch": {"request_timeout_seconds": 600, "max_retries": 0}}
|
||||||
|
monkeypatch.setattr(config, "get_config", lambda: cfg)
|
||||||
|
acquire = AsyncMock(return_value=True)
|
||||||
|
release = Mock()
|
||||||
|
monkeypatch.setattr(forward, "acquire_global_slot", acquire)
|
||||||
|
monkeypatch.setattr(forward, "release_global_slot", release)
|
||||||
|
monkeypatch.setattr(forward, "acquire_worker", AsyncMock(return_value=SimpleNamespace(url="http://worker")))
|
||||||
|
release_worker = AsyncMock()
|
||||||
|
monkeypatch.setattr(forward, "release_worker", release_worker)
|
||||||
|
unhealthy = AsyncMock()
|
||||||
|
monkeypatch.setattr(forward, "mark_worker_unhealthy", unhealthy)
|
||||||
|
static = next(route.app for route in gateway.app.routes if getattr(route, "name", None) == "static")
|
||||||
|
monkeypatch.setattr(static, "all_directories", [str(tmp_path), *static.all_directories])
|
||||||
|
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):
|
||||||
|
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).json() == {"code": 0, "data": None}
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
proxy.assert_awaited_once()
|
||||||
|
assert proxy.await_args.args[1] == path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("generate_grow_image", [False, True])
|
||||||
|
def test_interface52_raw_forwarding_and_served_image_urls(proxy_setup, monkeypatch, generate_grow_image):
|
||||||
|
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,
|
||||||
|
"framing": {"status": "reference_crop", "axis_x_error_px": 0},
|
||||||
|
"height_rule": {"target_center": {"x": 3.0, "y": 2.0}},
|
||||||
|
"guide_landmarks": {"9": {"x": 3.0, "y": 3.0}},
|
||||||
|
"best_hairline_center_point": {"x": 3, "y": 2},
|
||||||
|
"high_hairline_center_point": None, "low_hairline_center_point": None,
|
||||||
|
"face_measure": None}
|
||||||
|
worker_json = {"code": 0, "data": {**metadata,
|
||||||
|
"input_image_base64": base64.b64encode(jpg).decode(),
|
||||||
|
"original_image_base64": base64.b64encode(jpg).decode(),
|
||||||
|
"hairline_images": [{"hairline_type": "ellipse", "order": 1,
|
||||||
|
"height_placement": {"target_error_px": 0.01},
|
||||||
|
"image_middle_base64": base64.b64encode(png).decode(),
|
||||||
|
"image_high_base64": None, "image_low_base64": None,
|
||||||
|
"grown_image_base64": base64.b64encode(jpg).decode() if generate_grow_image else None}]}}
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def worker(request):
|
||||||
|
calls.append(request)
|
||||||
|
return httpx.Response(200, json=worker_json)
|
||||||
|
|
||||||
|
upstream = httpx.AsyncClient(transport=httpx.MockTransport(worker))
|
||||||
|
monkeypatch.setattr(forward, "get_client", lambda: upstream)
|
||||||
|
fields = {"gender": "male", "hair_style": "1,3", "normalize_image": "true",
|
||||||
|
"generate_grow_image": str(generate_grow_image).lower(), "use_mask": "false",
|
||||||
|
"prompt": "test prompt"}
|
||||||
|
source = httpx.Request("POST", "http://testserver" + PATH,
|
||||||
|
files={"image_file": ("face.png", png, "image/png")}, data=fields)
|
||||||
|
raw = source.read()
|
||||||
|
client = TestClient(gateway.app, client=("203.0.113.9", 50000))
|
||||||
|
try:
|
||||||
|
result = client.post(PATH, content=raw, headers={
|
||||||
|
"Content-Type": source.headers["content-type"], "X-Internal-Token": "untrusted-client-token"})
|
||||||
|
assert result.status_code == 200
|
||||||
|
data = result.json()["data"]
|
||||||
|
assert {key: data[key] for key in metadata} == metadata
|
||||||
|
item = data["hairline_images"][0]
|
||||||
|
assert item["image_high_base64"] is None and item["image_low_base64"] is None
|
||||||
|
assert "image_high_url" not in item and "image_low_url" not in item
|
||||||
|
assert item["height_placement"] == {"target_error_px": 0.01}
|
||||||
|
for key, expected in [("input_image_url", jpg), ("original_image_url", jpg)]:
|
||||||
|
assert client.get(data[key]).content == expected
|
||||||
|
assert data[key].endswith(".jpg")
|
||||||
|
assert client.get(item["image_middle_url"]).content == png
|
||||||
|
assert item["image_middle_url"].endswith(".png")
|
||||||
|
if generate_grow_image:
|
||||||
|
assert client.get(item["grown_image_url"]).content == jpg
|
||||||
|
else:
|
||||||
|
assert item["grown_image_base64"] is None and "grown_image_url" not in item
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
asyncio.run(upstream.aclose())
|
||||||
|
assert len(calls) == 1
|
||||||
|
assert str(calls[0].url) == "http://worker" + PATH
|
||||||
|
assert calls[0].content == raw
|
||||||
|
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
|
||||||
|
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):
|
||||||
|
_, _, 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, data={"gender": "male", "hair_style": "1"})
|
||||||
|
assert response.status_code == 403 and response.json() == body
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
asyncio.run(upstream.aclose())
|
||||||
|
release.assert_called_once()
|
||||||
|
release_worker.assert_awaited_once()
|
||||||
|
unhealthy.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_interface52_page_has_no_internal_password():
|
||||||
|
client = TestClient(gateway.app)
|
||||||
|
try:
|
||||||
|
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 "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
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
Reference in New Issue
Block a user