fix: 将52号接口范围限定为GPU worker

撤回52号网关路由、图片字段改名和mock改动,gateway目录恢复a3fddbb。worker保持base64和null响应,更新接口说明和回归测试。

验证:worker API、IP限制与网关兼容性共54项测试通过。
This commit is contained in:
xsl
2026-11-04 10:45:16 +08:00
parent 5b2b73f5d5
commit 1c51f3eaab
11 changed files with 58 additions and 92 deletions
+4 -4
View File
@@ -1804,16 +1804,16 @@ async def _generate_hairline52_data(image, gender, hair_styles, use_mask=True,
**返回说明**: **返回说明**:
- `hairline_images`:**选中发型**的列表,数量 = 所选发型数,`order` = 发型序号。每项含: - `hairline_images`:**选中发型**的列表,数量 = 所选发型数,`order` = 发型序号。每项含:
- `image_middle_url`:新高度规则的透明发际线 PNG;`image_high_url` / `image_low_url` 固定 null。 - `image_middle_base64`:新高度规则的透明发际线 PNG;`image_high_base64` / `image_low_base64` 固定 null。
- `height_placement`:旧/新中心坐标、移动距离和目标误差(像素)。 - `height_placement`:旧/新中心坐标、移动距离和目标误差(像素)。
- `grown_image_url`:该发型的**生发图**(生发失败时为 `null`)。 - `grown_image_base64`:该发型的**生发图**(生发失败时为 `null`)。
- `hairline_type`:发际线类型 key。 - `hairline_type`:发际线类型 key。
worker 返回 `*_base64`,网关落盘后改写为 `*_url`。 本接口仅部署在GPU worker,图片始终返回 `*_base64`;空图片保留字段为 null。
- `best_hairline_center_point`:**首个选中发型**的 **middle 档**发际线曲线**面部中间点**坐标, - `best_hairline_center_point`:**首个选中发型**的 **middle 档**发际线曲线**面部中间点**坐标,
以**当前底图像素**为基准(左上角为原点,x 向右,y 向下)。 以**当前底图像素**为基准(左上角为原点,x 向右,y 向下)。
- `high_hairline_center_point` / `low_hairline_center_point`:旧字段,固定 null。 - `high_hairline_center_point` / `low_hairline_center_point`:旧字段,固定 null。
- `height_rule`:中庭、下庭、目标上庭高度、目标中心与测量来源;`face_measure` 为当前底图测量。 - `height_rule`:中庭、下庭、目标上庭高度、目标中心与测量来源;`face_measure` 为当前底图测量。
- `input_image_*`:处理后底图,须用它叠加发际线;`original_image_*`:处理前图片。 - `input_image_base64`:处理后底图,须用它叠加发际线;`original_image_base64`:处理前图片。
- `coordinate_space`:默认 `original_image`,开启预处理后为 `processed_image`。 - `coordinate_space`:默认 `original_image`,开启预处理后为 `processed_image`。
- `framing`:是否标准裁切、旋转角、前后尺寸及双向坐标变换;关闭预处理时为 null。 - `framing`:是否标准裁切、旋转角、前后尺寸及双向坐标变换;关闭预处理时为 null。
""", """,
+3 -3
View File
@@ -37,7 +37,7 @@
| 2 | `results[].image_base64` / `results[].grown_image_base64`(可空) | `results[].image_url` / `results[].grown_image_url` | | 2 | `results[].image_base64` / `results[].grown_image_base64`(可空) | `results[].image_url` / `results[].grown_image_url` |
| 3 | `hair_growth_image_base64`(可空) | `hair_growth_image_url` | | 3 | `hair_growth_image_base64`(可空) | `hair_growth_image_url` |
| 5 | `hairline_images[].image_{middle,high,low}_base64` / `grown_image_base64`(可空) | `hairline_images[].image_{middle,high,low}_url` / `grown_image_url` | | 5 | `hairline_images[].image_{middle,high,low}_base64` / `grown_image_base64`(可空) | `hairline_images[].image_{middle,high,low}_url` / `grown_image_url` |
| 52 | `hairline_images[].image_{middle,high,low}_base64` / `grown_image_base64`、`input_image_base64` / `original_image_base64` | 同名 `*_url`,空图片也改为`*_url: null` | | 52 | `hairline_images[].image_{middle,high,low}_base64` / `grown_image_base64`、`input_image_base64` / `original_image_base64` | 仅GPU worker,本次不新增网关路由;直接返回`*_base64` |
| 9 | `steps_common.*_base64`、`bisenet|segformer.{hair,closed_region,final_overlay,mask}_mask_base64` 等 | 同名 `*_url` | | 9 | `steps_common.*_base64`、`bisenet|segformer.{hair,closed_region,final_overlay,mask}_mask_base64` 等 | 同名 `*_url` |
| 10 | `steps_common.*_base64`、`bisenet|segformer.{base_mask,contour,band_overlay,mask}_base64` 等 | 同名 `*_url` | | 10 | `steps_common.*_base64`、`bisenet|segformer.{base_mask,contour,band_overlay,mask}_base64` 等 | 同名 `*_url` |
| 11 | `steps.{input,baseline,hair_mask,mask_overlay,mask,swap_raw,alpha,final}_base64` | `steps.*_url` | | 11 | `steps.{input,baseline,hair_mask,mask_overlay,mask,swap_raw,alpha,final}_base64` | `steps.*_url` |
@@ -45,7 +45,7 @@
| 4 | (网关本机产出,无图片字段,`features` 为 JSON 字符串) | — | | 4 | (网关本机产出,无图片字段,`features` 为 JSON 字符串) | — |
> 实现建议:递归遍历 data,凡 key 以 `_base64` 结尾就落盘改 `_url`,自动覆盖嵌套/新增字段。 > 实现建议:递归遍历 data,凡 key 以 `_base64` 结尾就落盘改 `_url`,自动覆盖嵌套/新增字段。
> 兼容约定:接口52显式开启空图片字段改名;接口2/3/5等旧接口仍保留空图片的`*_base64: null`字段名。有图片时均落盘为`*_url`。 > 兼容约定:网关保持原样,有图片时落盘为`*_url`,空图片保留`*_base64: null`。接口52仅在worker返回base64,与网关的图片改名无关。
> **图片格式**:接口1 标注图含透明用 **PNG**;接口2/3/5 是不透明照片用 **JPG**(小很多,~9×)。 > **图片格式**:接口1 标注图含透明用 **PNG**;接口2/3/5 是不透明照片用 **JPG**(小很多,~9×)。
> 网关落盘按内容嗅探扩展名(PNG 头→`.png`,否则 `.jpg`)。 > 网关落盘按内容嗅探扩展名(PNG 头→`.png`,否则 `.jpg`)。
@@ -108,7 +108,7 @@
- **模板定位**:原有502点/贴图生成形状,`hairline/height_rule.py` 将每种 middle 曲线沿面部向上方向平移、校准中心 Y,保留曲线形状与倾斜。此规则仅接口52使用,接口2保持原逻辑。 - **模板定位**:原有502点/贴图生成形状,`hairline/height_rule.py` 将每种 middle 曲线沿面部向上方向平移、校准中心 Y,保留曲线形状与倾斜。此规则仅接口52使用,接口2保持原逻辑。
- **生发**:移动后的预览曲线直接生成黑线和重绘遮罩,再送ComfyUI,避免预览与黑模板位置不一致。 - **生发**:移动后的预览曲线直接生成黑线和重绘遮罩,再送ComfyUI,避免预览与黑模板位置不一致。
- **裁切叠加预览**:测试页新增“裁切发际线叠加”。`static/hairline_reference_box.js`共享同一个162/389/9/152参考框几何,按原图像素裁切middle透明图层,再叠加到处理后人像;四栏顺序为原图、middle、裁切发际线叠加、生发图。 - **裁切叠加预览**:测试页新增“裁切发际线叠加”。`static/hairline_reference_box.js`共享同一个162/389/9/152参考框几何,按原图像素裁切middle透明图层,再叠加到处理后人像;四栏顺序为原图、middle、裁切发际线叠加、生发图。
- **兼容**:high/low不再生成,图片与中心点字段保留为null。网关`gateway/forward.py`仅为接口52开启空图片`*_base64`→`*_url: null`,旧接口空字段名保持原样。部署52路由时同步该改写;网关不增加IP限制。`face_measure`为当前底图测量;新增`height_rule`与各项`height_placement`说明设计目标与实际误差。 - **兼容**:high/low不再生成,图片与中心点字段保留为null。接口52始终返回`*_base64`,空图片保留字段并返回null。本次不修改网关路由、落盘、字段改名或IP策略。`face_measure`为当前底图测量;新增`height_rule`与各项`height_placement`说明设计目标与实际误差。
- **测试**:规则页`static/test_interface52.html`,对比页`static/test_interface52_compare.html`;运行`scripts/compare_hairline_height.py`默认保存全部30张×男女各7型,共420组对比。每人一行,顺序为未裁切原图、单张旧规则图、全部新规则样式,支持位置对照、额头特写、同图叠线和上传新图。新图可叠加162/389两侧、底边经过9、高度为9→152距离70%的参考框。原图与叠图按需加载。 - **测试**:规则页`static/test_interface52.html`,对比页`static/test_interface52_compare.html`;运行`scripts/compare_hairline_height.py`默认保存全部30张×男女各7型,共420组对比。每人一行,顺序为未裁切原图、单张旧规则图、全部新规则样式,支持位置对照、额头特写、同图叠线和上传新图。新图可叠加162/389两侧、底边经过9、高度为9→152距离70%的参考框。原图与叠图按需加载。
### 接口9 头发遮罩 `/api/v1/head/mask`(worker) ### 接口9 头发遮罩 `/api/v1/head/mask`(worker)
+4 -3
View File
@@ -6,7 +6,7 @@
- 接口5已按11月2日前最新提交`a3fddbb`恢复:`POST /api/v1/hairline/generate`,返回middle/high/low三档,测试页`/static/test_interface5.html`。 - 接口5已按11月2日前最新提交`a3fddbb`恢复:`POST /api/v1/hairline/generate`,返回middle/high/low三档,测试页`/static/test_interface5.html`。
- 当前新规则完整迁至接口52:`POST /api/v1/hairline/generate-52`,测试页`/static/test_interface52.html`,30案例对比`/static/test_interface52_compare.html`。接口52保留迁移前所有参数和返回字段,页面显式开启`normalize_image=true`。 - 当前新规则完整迁至接口52:`POST /api/v1/hairline/generate-52`,测试页`/static/test_interface52.html`,30案例对比`/static/test_interface52_compare.html`。接口52保留迁移前所有参数和返回字段,页面显式开启`normalize_image=true`。
- 原`test_interface5_compare.html`书签跳转至52号对比页;公开5号与52号的处理路由、生成函数和网关转发均独立。 - 原`test_interface5_compare.html`书签跳转至52号对比页;5号与52号的worker处理路由和生成函数独立;52号仅部署在GPU worker,本次不新增网关转发。
## 已确定的要求 ## 已确定的要求
@@ -112,8 +112,9 @@
## 2026-11-04 提交前 Review 与验证 ## 2026-11-04 提交前 Review 与验证
- 接口5的请求处理函数、三档PNG生成函数与`a3fddbb`逐项AST比较一致,测试页逐字节一致;新高度规则、扶正裁切和矩形叠加均在接口52。 - 接口5的请求处理函数、三档PNG生成函数与`a3fddbb`逐项AST比较一致,测试页逐字节一致;新高度规则、扶正裁切和矩形叠加均在接口52。
- 修复网关空图片字段改名影响旧接口的兼容性问题:仅接口52开启`*_base64: null`→`*_url: null`,接口2/3/5等旧接口保留原字段名;已增加递归改写和真实转发函数的回归测试。 - 范围修正:接口52仅属于GPU worker,返回`*_base64`及null;网关路由、落盘、字段改名、mock worker均恢复`a3fddbb`,没有“仅52号改名”的特殊逻辑。测试确认旧网关行为保持原样且未暴露52号路由。
- 范围修正后的回归检查:`./venv/bin/pytest tests/test_ip_access.py tests/test_gateway_hairline_routes.py tests/test_gateway_nullable_images.py tests/test_api.py -q`,54项通过。
- GPU worker所有动态接口启用直连IP白名单,本机和网关`49.232.51.171`可访问;网关侧不启用IP限制。配置与部署见[接口IP白名单](接口IP白名单.md)。 - GPU worker所有动态接口启用直连IP白名单,本机和网关`49.232.51.171`可访问;网关侧不启用IP限制。配置与部署见[接口IP白名单](接口IP白名单.md)。
- `./venv/bin/pytest -q`:113项通过、1项已有失败。`tests/test_pipeline.py::test_numeric_regression`期望全脸总高24.01cm,当前实测22.47cm;从未修改的`a3fddbb`导出代码、夹具后独立运行,同样失败。此次保留历史断言,未通过修改基线掩盖差异。 - 上一轮`./venv/bin/pytest -q`:113项通过、1项已有失败(撤回网关新增功能前的测试数)。`tests/test_pipeline.py::test_numeric_regression`期望全脸总高24.01cm,当前实测22.47cm;从未修改的`a3fddbb`导出代码、夹具后独立运行,同样失败。此次保留历史断言,未通过修改基线掩盖差异。
- 浏览器实测:接口5返回middle/high/low且保持868×1166;接口52长图案例7返回1062×1416、`status=aspect_crop`,四栏图和裁切边框正常;对比页显示30个案例、378组结果,旧书签可跳转52,未出现页面脚本错误。定位验证均关闭ComfyUI生发,本轮未重复运行耗时的真实生发。 - 浏览器实测:接口5返回middle/high/low且保持868×1166;接口52长图案例7返回1062×1416、`status=aspect_crop`,四栏图和裁切边框正常;对比页显示30个案例、378组结果,旧书签可跳转52,未出现页面脚本错误。定位验证均关闭ComfyUI生发,本轮未重复运行耗时的真实生发。
- 批量结果与上传图位于被Git忽略的`static/annotations/`及`tests/output/`,不随提交分发;新环境执行`scripts/compare_hairline_height.py`重建。420是计划组合数,378为当前成功结果,失败组及原因保留在报告中。 - 批量结果与上传图位于被Git忽略的`static/annotations/`及`tests/output/`,不随提交分发;新环境执行`scripts/compare_hairline_height.py`重建。420是计划组合数,378为当前成功结果,失败组及原因保留在报告中。
+19 -19
View File
@@ -526,7 +526,7 @@ GPU worker的所有动态接口均启用IP限制:允许本机及`ip_access.jso
**说明**:入参同接口2(先选性别、再多选发型)。对每个选中发型返回一张 `middle` 设计叠图与生发图。**目标上庭 =(中庭 + 下庭)÷2**,从眉心向上定位;所有形状的中点对齐同一目标。`high` / `low` 已停用,旧字段保留为 `null`。 **说明**:入参同接口2(先选性别、再多选发型)。对每个选中发型返回一张 `middle` 设计叠图与生发图。**目标上庭 =(中庭 + 下庭)÷2**,从眉心向上定位;所有形状的中点对齐同一目标。`high` / `low` 已停用,旧字段保留为 `null`。
**请求**:`POST /api/v1/hairline/generate-52` **请求**:`POST /api/v1/hairline/generate-52`(仅GPU worker,端口8187;本次不在网关新增路由)
**IP限制**:GPU worker的所有动态接口统一仅允许本机回环地址、本机网卡地址及白名单;目前白名单含网关IP `49.232.51.171`,其他直连来源需加入`ip_access.json`的`allowed_ips`。该文件支持单个IP与CIDR,修改后立即生效;拒绝时返回HTTP403、code=1010。网关不启用此限制,已有鉴权仍保留。详见[接口IP白名单](接口IP白名单.md)。 **IP限制**:GPU worker的所有动态接口统一仅允许本机回环地址、本机网卡地址及白名单;目前白名单含网关IP `49.232.51.171`,其他直连来源需加入`ip_access.json`的`allowed_ips`。该文件支持单个IP与CIDR,修改后立即生效;拒绝时返回HTTP403、code=1010。网关不启用此限制,已有鉴权仍保留。详见[接口IP白名单](接口IP白名单.md)。
@@ -555,7 +555,7 @@ GPU worker的所有动态接口均启用IP限制:允许本机及`ip_access.jso
### 高度规则与对比 ### 高度规则与对比
- **参数与字段**:原有8个请求参数、响应外层结构和所有旧返回字段均保留。`use_mask=true`、默认prompt、`generate_grow_image=true`保持原值。新功能只新增可选参数和返回字段;旧请求未传`normalize_image`时保留原图坐标,high/low按已约定保留null,worker仍返回`*_base64`,网关仍返回对应`*_url`。 - **参数与字段**:原有8个请求参数、响应外层结构和所有旧返回字段均保留。`use_mask=true`、默认prompt、`generate_grow_image=true`保持原值。新功能只新增可选参数和返回字段;旧请求未传`normalize_image`时保留原图坐标,high/low按已约定保留null,worker始终返回`*_base64`,不负责保存图片、生成公网URL或改名;网关保持原样。
- `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_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`。浮点坐标保留精度,旧中心点字段继续返回取整像素。 - 每项 `height_placement`:`old_center`、`new_center`、`shift_x_px`、`shift_y_px`(正=下移)、`target_error_px`。浮点坐标保留精度,旧中心点字段继续返回取整像素。
- `face_measure` 为处理后底图的实际测量值,未被设计目标覆盖。 - `face_measure` 为处理后底图的实际测量值,未被设计目标覆盖。
@@ -573,8 +573,8 @@ GPU worker的所有动态接口均启用IP限制:允许本机及`ip_access.jso
| best_hairline_center_point | object \| null | **首个选中发型**的 **middle 档**发际线曲线「面部中间点」坐标,当前底图像素:`{ "x": number, "y": number }` | | best_hairline_center_point | object \| null | **首个选中发型**的 **middle 档**发际线曲线「面部中间点」坐标,当前底图像素:`{ "x": number, "y": number }` |
| high_hairline_center_point | object \| null | 已停用,固定 `null` | | high_hairline_center_point | object \| null | 已停用,固定 `null` |
| low_hairline_center_point | object \| null | 已停用,固定 `null` | | low_hairline_center_point | object \| null | 已停用,固定 `null` |
| input_image_url | string | 当前底图,发际线叠图、参考点和测量值均使用该图的坐标系 | | input_image_base64 | string | 当前底图的JPEG base64,发际线叠图、参考点和测量值均使用该图的坐标系 |
| original_image_url | string | EXIF方向修正后的上传原图 | | original_image_base64 | string | EXIF方向修正后的上传原图JPEG base64 |
| width / height | int | 当前底图的实际像素宽 / 高 | | width / height | int | 当前底图的实际像素宽 / 高 |
| coordinate_space | string | 开启预处理时为 `processed_image`,关闭时为 `original_image` | | coordinate_space | string | 开启预处理时为 `processed_image`,关闭时为 `original_image` |
| framing | object \| null | 裁切状态、旋转角、前后尺寸、轴线误差及双向仿射矩阵;关闭预处理时为 `null` | | framing | object \| null | 裁切状态、旋转角、前后尺寸、轴线误差及双向仿射矩阵;关闭预处理时为 `null` |
@@ -587,16 +587,16 @@ GPU worker的所有动态接口均启用IP限制:允许本机及`ip_access.jso
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|------|------|------| |------|------|------|
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`/`bigflower`/`clasicalflower`(female),`ellipse`/`m`/`straight`/`inverse_arc`/`heart`/`Softpetal`/`wave`(male) | | 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_middle_base64 | string | middle 档发际线曲线**透明 PNG** base64(仅曲线,透明底,**不含人物**,需叠加 `input_image_base64` 显示) |
| image_high_url | null | 已停用,保留字段为 `null` | | image_high_base64 | null | 已停用,保留字段为 `null` |
| image_low_url | null | 已停用,保留字段为 `null` | | image_low_base64 | null | 已停用,保留字段为 `null` |
| grown_image_url | string \| null | **生发后图片** URL(ComfyUI「植发」效果图,完整人像照片,生发失败或 `generate_grow_image=false` 时为 `null`) | | grown_image_base64 | string \| null | **生发后图片** JPEG base64(ComfyUI「植发」效果图,完整人像照片,生发失败或 `generate_grow_image=false` 时为 `null`) |
| order | int | 发型序号(= 传入的 hair_style 值) | | order | int | 发型序号(= 传入的 hair_style 值) |
| height_placement | object | 旧/新中心、XY移动距离、目标误差,均为未取整的像素值 | | height_placement | object | 旧/新中心、XY移动距离、目标误差,均为未取整的像素值 |
> worker 侧返回 `image_middle_base64` / `image_high_base64` / `image_low_base64` / `grown_image_base64`,网关落盘后改写为上表对应的 `*_url`。 > 上表为GPU worker的实际响应字段;所有图片使用base64,空图片保留原字段并返回null。网关的落盘与URL改写属于网关职责,本次不修改。
> >
> 💡 `image_middle_url` 为透明底 PNG,前端需用绝对定位叠加到 `input_image_url` 上显示(参考[测试页](https://hair.xiangsilian.com/static/test_interface52.html)的 `.img-stack` 叠加结构)。`grown_image_url` 是完整人像照片,直接显示即可。worker返回上述图片的 `*_base64`,网关改写为 `*_url`。 > 前端将base64转为data URI后显示。middle透明图叠加到`input_image_base64`,生发图直接显示。GPU worker本机测试页为`http://localhost:8187/static/test_interface52.html`。
`face_measure` 元素(与[接口1](#接口-1四庭七眼测量标注接口)的 `data` 同构,**不含** `annotated_image_*` 标注图字段): `face_measure` 元素(与[接口1](#接口-1四庭七眼测量标注接口)的 `data` 同构,**不含** `annotated_image_*` 标注图字段):
@@ -613,9 +613,9 @@ GPU worker的所有动态接口均启用IP限制:允许本机及`ip_access.jso
> `eye1`~`eye7` 为从左到右共 7 段宽度,eye1=左耳外段、eye7=右耳外段,某侧耳朵不可见时对应段为 `null`。详见接口1说明。 > `eye1`~`eye7` 为从左到右共 7 段宽度,eye1=左耳外段、eye7=右耳外段,某侧耳朵不可见时对应段为 `null`。详见接口1说明。
### 网关响应结构示例(normalize_image=false) ### GPU worker响应结构示例(normalize_image=false)
此例未开启裁切,坐标基于原图;`face_measure=null`时仍可按发际线关键点计算目标。网关对接口52的所有空图片字段均返回`*_url: null`。worker直连时图片字段名对应为`*_base64`,图片内容为base64。 此例未开启裁切,坐标基于原图;`face_measure=null`时仍可按发际线关键点计算目标。图片内容以缩略base64字符串示意,空图片字段返回null,字段名不变。
```json ```json
{ {
@@ -626,10 +626,10 @@ GPU worker的所有动态接口均启用IP限制:允许本机及`ip_access.jso
"hairline_images": [ "hairline_images": [
{ {
"hairline_type": "ellipse", "hairline_type": "ellipse",
"image_middle_url": "https://hair.xiangsilian.com/static/annotations/mid1.png", "image_middle_base64": "iVBORw0KGgo...",
"image_high_url": null, "image_high_base64": null,
"image_low_url": null, "image_low_base64": null,
"grown_image_url": "https://hair.xiangsilian.com/static/annotations/grown1.jpg", "grown_image_base64": "/9j/4AAQ...",
"order": 1, "order": 1,
"height_placement": { "height_placement": {
"old_center": {"x": 540.0, "y": 450.0}, "old_center": {"x": 540.0, "y": 450.0},
@@ -641,8 +641,8 @@ GPU worker的所有动态接口均启用IP限制:允许本机及`ip_access.jso
"best_hairline_center_point": {"x": 540, "y": 430}, "best_hairline_center_point": {"x": 540, "y": 430},
"high_hairline_center_point": null, "high_hairline_center_point": null,
"low_hairline_center_point": null, "low_hairline_center_point": null,
"input_image_url": "https://hair.xiangsilian.com/static/annotations/input.jpg", "input_image_base64": "/9j/4AAQ...",
"original_image_url": "https://hair.xiangsilian.com/static/annotations/original.jpg", "original_image_base64": "/9j/4AAQ...",
"width": 1080, "height": 1440, "width": 1080, "height": 1440,
"coordinate_space": "original_image", "coordinate_space": "original_image",
"framing": null, "framing": null,
@@ -666,7 +666,7 @@ GPU worker的所有动态接口均启用IP限制:允许本机及`ip_access.jso
} }
``` ```
开启`normalize_image=true`后,`coordinate_space=processed_image`,`framing`包含前述裁切状态和坐标变换,所有发际线图片须叠加到`input_image_url`。原始图片仅用于展示和坐标回溯。 开启`normalize_image=true`后,`coordinate_space=processed_image`,`framing`包含前述裁切状态和坐标变换,所有发际线图片须叠加到`input_image_base64`。原始图片仅用于展示和坐标回溯。
--- ---
-8
View File
@@ -208,8 +208,6 @@ 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",
"if52_compare": "/static/test_interface52_compare.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",
@@ -593,12 +591,6 @@ 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=["人脸分析"])
async def hairline_generate_52(request: Request):
"""接口52:新高度规则、扶正裁切与发际线生成"""
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 工作流)"""
+3 -15
View File
@@ -88,8 +88,6 @@ def rewrite_base64_to_url(
obj: Any, obj: Any,
public_base_url: str, public_base_url: str,
static_dir: str, static_dir: str,
*,
rewrite_null_images: bool = False,
) -> Any: ) -> Any:
"""递归遍历响应 JSON,将所有 *_base64 字段改写为 *_url。 """递归遍历响应 JSON,将所有 *_base64 字段改写为 *_url。
@@ -97,16 +95,10 @@ def rewrite_base64_to_url(
- 支持 data: URI 和原始 base64 两种格式 - 支持 data: URI 和原始 base64 两种格式
- 解码 base64 → 保存到 static_dir/{uuid}.png - 解码 base64 → 保存到 static_dir/{uuid}.png
- 删除 *_base64 字段,新增 *_url 字段指向公网 URL - 删除 *_base64 字段,新增 *_url 字段指向公网 URL
- 接口52显式开启 rewrite_null_images,空图片也返回 *_url: null;旧接口保持原空字段
""" """
if isinstance(obj, dict): if isinstance(obj, dict):
new_dict: Dict[str, Any] = {} new_dict: Dict[str, Any] = {}
for key, value in obj.items(): for key, value in obj.items():
if rewrite_null_images and key.endswith("_base64") and value is None:
# Deprecated/optional image fields must keep their external
# *_url names even when no image is generated.
new_dict[key[:-7] + "_url"] = None
continue
if key.endswith("_base64") and isinstance(value, str): if key.endswith("_base64") and isinstance(value, str):
img_bytes = _decode_base64_value(value) img_bytes = _decode_base64_value(value)
if img_bytes is not None: if img_bytes is not None:
@@ -126,12 +118,10 @@ def rewrite_base64_to_url(
logger.warning("字段 %s 的值无法解码为图片,保留", key) logger.warning("字段 %s 的值无法解码为图片,保留", key)
new_dict[key] = value new_dict[key] = value
else: else:
new_dict[key] = rewrite_base64_to_url( new_dict[key] = rewrite_base64_to_url(value, public_base_url, static_dir)
value, public_base_url, static_dir, rewrite_null_images=rewrite_null_images)
return new_dict return new_dict
elif isinstance(obj, list): elif isinstance(obj, list):
return [rewrite_base64_to_url(item, public_base_url, static_dir, return [rewrite_base64_to_url(item, public_base_url, static_dir) for item in obj]
rewrite_null_images=rewrite_null_images) for item in obj]
else: else:
return obj return obj
@@ -258,9 +248,7 @@ async def proxy_request(request: Request, path: str) -> JSONResponse:
) )
# 递归改写 base64 图片字段 # 递归改写 base64 图片字段
rewritten = rewrite_base64_to_url( rewritten = rewrite_base64_to_url(worker_json, public_base_url, static_dir)
worker_json, public_base_url, static_dir,
rewrite_null_images=path == "/api/v1/hairline/generate-52")
return JSONResponse( return JSONResponse(
status_code=resp.status_code, status_code=resp.status_code,
-10
View File
@@ -7,7 +7,6 @@
- POST /api/v1/hair/grow-b → mock 响应 - POST /api/v1/hair/grow-b → mock 响应
- POST /api/v1/face/features → mock 响应 - POST /api/v1/face/features → mock 响应
- POST /api/v1/hairline/generate → mock 响应 - POST /api/v1/hairline/generate → mock 响应
- POST /api/v1/hairline/generate-52 → 新规则 mock 响应
鉴权:校验 X-Internal-Token 头,不匹配返回 401。 鉴权:校验 X-Internal-Token 头,不匹配返回 401。
并发模拟:可通过 ?delay=N 让接口 sleep N 秒(默认 1)。 并发模拟:可通过 ?delay=N 让接口 sleep N 秒(默认 1)。
@@ -214,7 +213,6 @@ def create_app(port: int, password: str) -> FastAPI:
# --- 接口5 --- # --- 接口5 ---
@app.post("/api/v1/hairline/generate") @app.post("/api/v1/hairline/generate")
@app.post("/api/v1/hairline/generate-52")
async def hairline_generate( async def hairline_generate(
request: Request, request: Request,
x_internal_token: Optional[str] = Header(default=None), x_internal_token: Optional[str] = Header(default=None),
@@ -247,14 +245,6 @@ def create_app(port: int, password: str) -> FastAPI:
], ],
"best_hairline_center_point": {"x": 540, "y": 430}, "best_hairline_center_point": {"x": 540, "y": 430},
} }
if request.url.path.endswith("generate-52"):
for item in data["hairline_images"]:
item["image_high_base64"] = None
item["image_low_base64"] = None
data.update(high_hairline_center_point=None, low_hairline_center_point=None,
face_measure=None, framing=None, height_rule=None,
guide_landmarks={}, input_image_base64=_png, original_image_base64=_png,
coordinate_space="original_image", width=1, height=1)
return mock_ok(data, delay=delay) return mock_ok(data, delay=delay)
return app return app
+5 -5
View File
@@ -265,10 +265,10 @@ console.log(features.gene_style); // "自然型"</pre>
<div class="card" id="if52"> <div class="card" id="if52">
<h2>52. 发际线新规则与裁切 &nbsp;<span class="badge post">POST</span> &nbsp;<code>/api/v1/hairline/generate-52</code></h2> <h2>52. 发际线新规则与裁切 &nbsp;<span class="badge post">POST</span> &nbsp;<code>/api/v1/hairline/generate-52</code></h2>
<div class="card-body"> <div class="card-body">
<p class="desc">中下庭均值定位 middle,支持9→152扶正、人像裁切和矩形发际线预览。接口5保留原有三档实现;新功能使用此独立接口。</p> <p class="desc">仅部署在GPU worker(8187),本次不新增网关路由。中下庭均值定位 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>基础图片参数、<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>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">图片返回<code>*_base64</code>,空图片保留字段并返回null。另有<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> · <a href="/static/test_interface52_compare.html" class="link">52号30案例对比页</a></p> <p style="margin-top:12px"><a href="http://localhost:8187/static/test_interface52.html" class="link">52号规则测试页</a> · <a href="http://localhost:8187/static/test_interface52_compare.html" class="link">52号30案例对比页</a></p>
</div> </div>
</div> </div>
@@ -334,8 +334,8 @@ 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>上传照片 → 6项面部特征 + 原始JSON</td></tr> <tr><td>4. 用户特征</td><td><a href="/static/test_interface4.html" class="link">/static/test_interface4.html</a></td><td>上传照片 → 6项面部特征 + 原始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>52. 发际线新规则</td><td><a href="http://localhost:8187/static/test_interface52.html" class="link">/static/test_interface52.html</a></td><td>中下庭定位、扶正裁切、矩形发际线叠加及生发</td></tr>
<tr><td>52. 规则对比</td><td><a href="/static/test_interface52_compare.html" class="link">/static/test_interface52_compare.html</a></td><td>30个案例,原图与旧/新规则样式对照</td></tr> <tr><td>52. 规则对比</td><td><a href="http://localhost:8187/static/test_interface52_compare.html" class="link">/static/test_interface52_compare.html</a></td><td>30个案例,原图与旧/新规则样式对照</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>
</table> </table>
<p style="font-size:12px;color:#94a3b8;margin-top:12px"> <p style="font-size:12px;color:#94a3b8;margin-top:12px">
+9 -7
View File
@@ -1,16 +1,12 @@
"""The legacy interface 5 and new interface 52 forward to separate worker paths.""" """Interface 52 belongs to the GPU worker; the gateway keeps its legacy routes."""
import asyncio import asyncio
import importlib import importlib
import pytest
from starlette.requests import Request from starlette.requests import Request
@pytest.mark.parametrize("name,path", [ def test_legacy_hairline_forwarding(monkeypatch):
("hairline_generate", "/api/v1/hairline/generate"), name, path = "hairline_generate", "/api/v1/hairline/generate"
("hairline_generate_52", "/api/v1/hairline/generate-52"),
])
def test_distinct_hairline_forwarding(monkeypatch, name, path):
gateway = importlib.import_module("gateway.app") gateway = importlib.import_module("gateway.app")
request = Request({"type": "http", "method": "POST", "path": path, "headers": []}) request = Request({"type": "http", "method": "POST", "path": path, "headers": []})
calls = [] calls = []
@@ -25,3 +21,9 @@ def test_distinct_hairline_forwarding(monkeypatch, name, path):
assert next(route for route in gateway.app.routes if getattr(route, "path", None) == path).endpoint is endpoint assert next(route for route in gateway.app.routes if getattr(route, "path", None) == path).endpoint is endpoint
assert asyncio.run(endpoint(request)) == {"forwarded_to": path} assert asyncio.run(endpoint(request)) == {"forwarded_to": path}
assert calls == [path] assert calls == [path]
def test_interface52_is_not_exposed_by_gateway():
gateway = importlib.import_module("gateway.app")
assert not any(getattr(route, "path", None) == "/api/v1/hairline/generate-52"
for route in gateway.app.routes)
+9 -16
View File
@@ -1,4 +1,4 @@
"""Nullable/deprecated worker images retain their documented gateway keys.""" """Keep the gateway's historical image-field conversion unchanged."""
import asyncio import asyncio
import json import json
from types import SimpleNamespace from types import SimpleNamespace
@@ -11,14 +11,12 @@ from starlette.requests import Request
from gateway.forward import rewrite_base64_to_url from gateway.forward import rewrite_base64_to_url
def test_nullable_image_fields_keep_external_names(tmp_path): def test_nullable_image_fields_keep_original_names(tmp_path):
value = {"hairline_images": [{"image_high_base64": None, "image_low_base64": None, value = {"hairline_images": [{"image_high_base64": None, "image_low_base64": None,
"grown_image_base64": None}], "grown_image_base64": None}],
"high_hairline_center_point": None, "face_measure": None} "high_hairline_center_point": None, "face_measure": None}
actual = rewrite_base64_to_url(value, "https://example.test", str(tmp_path), rewrite_null_images=True) actual = rewrite_base64_to_url(value, "https://example.test", str(tmp_path))
assert actual == {"hairline_images": [{"image_high_url": None, "image_low_url": None, assert actual == value
"grown_image_url": None}],
"high_hairline_center_point": None, "face_measure": None}
assert not list(tmp_path.iterdir()) assert not list(tmp_path.iterdir())
@@ -30,23 +28,18 @@ def test_nonempty_images_still_rewrite_alongside_null_fields(tmp_path):
Image.new("RGBA", (2, 2), (255, 255, 255, 0)).save(buf, format="PNG") Image.new("RGBA", (2, 2), (255, 255, 255, 0)).save(buf, format="PNG")
value = {"image_middle_base64": base64.b64encode(buf.getvalue()).decode(), value = {"image_middle_base64": base64.b64encode(buf.getvalue()).decode(),
"image_high_base64": None} "image_high_base64": None}
actual = rewrite_base64_to_url(value, "https://example.test", str(tmp_path), rewrite_null_images=True) actual = rewrite_base64_to_url(value, "https://example.test", str(tmp_path))
assert actual["image_middle_url"].startswith("https://example.test/static/annotations/") assert actual["image_middle_url"].startswith("https://example.test/static/annotations/")
assert actual["image_high_url"] is None assert actual["image_high_base64"] is None
assert "image_high_url" not in actual
assert len(list(tmp_path.iterdir())) == 1 assert len(list(tmp_path.iterdir())) == 1
def test_legacy_nullable_image_field_names_remain_unchanged(tmp_path):
value = {"hairline_images": [{"grown_image_base64": None}], "face_measure": None}
assert rewrite_base64_to_url(value, "https://example.test", str(tmp_path)) == value
assert not list(tmp_path.iterdir())
@pytest.mark.parametrize("path,expected_key", [ @pytest.mark.parametrize("path,expected_key", [
("/api/v1/hairline/generate", "grown_image_base64"), ("/api/v1/hairline/generate", "grown_image_base64"),
("/api/v1/hairline/generate-52", "grown_image_url"), ("/api/v1/hair/grow", "grown_image_base64"),
]) ])
def test_proxy_rewrites_null_images_only_for_interface52(tmp_path, monkeypatch, path, expected_key): def test_proxy_keeps_original_null_image_fields(tmp_path, monkeypatch, path, expected_key):
import gateway.config as config import gateway.config as config
import gateway.forward as forward import gateway.forward as forward
+2 -2
View File
@@ -94,8 +94,8 @@ def test_gateway_does_not_apply_worker_ip_policy(policy, monkeypatch):
monkeypatch.setattr(gateway, '_proxy', proxy) monkeypatch.setattr(gateway, '_proxy', proxy)
# Deliberately skip lifespan: no workers or configuration are needed. # Deliberately skip lifespan: no workers or configuration are needed.
client = TestClient(gateway.app, client=('203.0.113.9', 50000)) client = TestClient(gateway.app, client=('203.0.113.9', 50000))
assert client.post('/api/v1/hairline/generate-52').json()['code'] == 0 assert client.post('/api/v1/hairline/generate-52').status_code == 404
assert client.post('/api/v1/hairline/generate').json()['code'] == 0 assert client.post('/api/v1/hairline/generate').json()['code'] == 0
assert client.post('/api/v1/hair/grow').json()['code'] == 0 assert client.post('/api/v1/hair/grow').json()['code'] == 0
assert client.get('/static/test_interface52.html').status_code == 200 assert client.get('/static/test_interface52.html').status_code == 200
assert calls == ['/api/v1/hairline/generate-52', '/api/v1/hairline/generate', '/api/v1/hair/grow'] assert calls == ['/api/v1/hairline/generate', '/api/v1/hair/grow']