From 544c2a83d6f0020b2f420d113160d34fc0b0a115 Mon Sep 17 00:00:00 2001 From: xsl Date: Wed, 4 Nov 2026 11:15:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(gateway):=20=E6=8E=A5=E5=85=A552=E5=8F=B7?= =?UTF-8?q?=E5=8F=91=E9=99=85=E7=BA=BF=E6=8E=A5=E5=8F=A3=E4=B8=8E=E5=85=AC?= =?UTF-8?q?=E5=BC=80=E6=B5=8B=E8=AF=95=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增52号同路径转发,沿用现有主备派发、鉴权、队列和图片URL改写。空图片保留既有base64字段名,不带入worker算法及IP限制。 同步52号测试页、图片解析和矩形叠加依赖,公开页面不包含内部Token。补全网关契约与部署文档,Nginx示例读取超时延长至660秒。 验证:42项网关测试通过;临时网关连接真实GPU worker,5号三档与52号七型URL、长图裁切和边框均通过浏览器检查。 --- docs/README.md | 1 + docs/实现说明.md | 5 + docs/接口52网关接入.md | 65 +++++ docs/接口文档.md | 150 ++++++++++- docs/网关待改动.md | 8 + gateway/app.py | 12 + nginx/hair-gateway.conf | 2 +- static/hairline_reference_box.js | 62 +++++ static/img_downscale.js | 30 +++ static/integration.html | 12 + static/test_interface52.html | 418 ++++++++++++++++++++++++++++++ tests/test_gateway_interface52.py | 155 +++++++++++ 12 files changed, 918 insertions(+), 2 deletions(-) create mode 100644 docs/接口52网关接入.md create mode 100644 static/hairline_reference_box.js create mode 100644 static/test_interface52.html create mode 100644 tests/test_gateway_interface52.py diff --git a/docs/README.md b/docs/README.md index a01a53a..201f07c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,6 +7,7 @@ |------|------| | [实现说明.md](实现说明.md) | **实现总览**:架构、五个接口怎么实现、base64→URL 映射、错误码、部署/环境要点。先读这份。 | | [接口文档.md](接口文档.md) | 对外 API 契约(字段 / 错误码)。字段命名的唯一权威。 | +| [接口52网关接入.md](接口52网关接入.md) | 52号请求透传、图片URL与空字段约定、公开测试页及网关部署步骤。 | | [旷视具体需求.md](旷视具体需求.md) | 原始需求。 | | [../OFFLINE_ASSETS.md](../OFFLINE_ASSETS.md) | worker 离线模型权重/字体清单(内网部署)。 | | [最近一周调用统计_20260915-20260921.md](最近一周调用统计_20260915-20260921.md) | 2026-09-15~09-21 网关调用量、接口构成、成功率。 | diff --git a/docs/实现说明.md b/docs/实现说明.md index 522aa89..752313a 100644 --- a/docs/实现说明.md +++ b/docs/实现说明.md @@ -98,6 +98,11 @@ - **做什么**:入参同接口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。 +### 接口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) - **做什么**:人像照 → 头发遮罩 + 全流程分步可视化。 - **怎么实现**(`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` 两组各四张图。 diff --git a/docs/接口52网关接入.md b/docs/接口52网关接入.md new file mode 100644 index 0000000..f83b78d --- /dev/null +++ b/docs/接口52网关接入.md @@ -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生发;临时网关验证后关闭。 diff --git a/docs/接口文档.md b/docs/接口文档.md index 6d57521..4605ccb 100644 --- a/docs/接口文档.md +++ b/docs/接口文档.md @@ -3,7 +3,7 @@ > 本文档依据《旷视具体需求.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` | | 4 用户特征 | POST | `/api/v1/face/features` | | 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:头发遮罩生成接口 **说明**:输入一张含人头的照片,生成头发遮罩,并返回**每一步的可视化图**(供对比调试)。 diff --git a/docs/网关待改动.md b/docs/网关待改动.md index eee02c5..12f6341 100644 --- a/docs/网关待改动.md +++ b/docs/网关待改动.md @@ -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) **背景**:接口 **2/3/5 的返回图改成了 JPG**(体积约小 9×),**接口 1 标注图仍是 PNG**(含透明)。 diff --git a/gateway/app.py b/gateway/app.py index e187b89..450a3e9 100644 --- a/gateway/app.py +++ b/gateway/app.py @@ -250,6 +250,7 @@ async def index(): "if3_hair_grow_b": "/static/test_interface3.html", "if4_features": "/static/test_interface4.html", "if5_hairline": "/static/test_interface5.html", + "if52_hairline": "/static/test_interface52.html", "if6_measure_v2": "/static/test_interface6.html", "if7_hair_grow_v2": "/static/test_interface7.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") +@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=["生发"]) async def hair_grow_v2(request: Request): """接口7:C端生发 v2(add_hair2 工作流)""" diff --git a/nginx/hair-gateway.conf b/nginx/hair-gateway.conf index 575764c..90e66b4 100644 --- a/nginx/hair-gateway.conf +++ b/nginx/hair-gateway.conf @@ -21,7 +21,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; # 网关内部转发可能耗时较长(worker 处理 + 排队) - proxy_read_timeout 90s; + proxy_read_timeout 660s; proxy_send_timeout 90s; } diff --git a/static/hairline_reference_box.js b/static/hairline_reference_box.js new file mode 100644 index 0000000..8d38b6a --- /dev/null +++ b/static/hairline_reference_box.js @@ -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(); +}; diff --git a/static/img_downscale.js b/static/img_downscale.js index f01502f..e175a00 100644 --- a/static/img_downscale.js +++ b/static/img_downscale.js @@ -52,4 +52,34 @@ } window.downscaleImageFile = downscaleImageFile; + /** + * 将接口返回的图片字段规范成 可用值。 + * 同时兼容: + * - 网关改写后的 *_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; })(); diff --git a/static/integration.html b/static/integration.html index e83a35e..49fd429 100644 --- a/static/integration.html +++ b/static/integration.html @@ -56,6 +56,7 @@ 接口3 接口4 接口5 + 接口52 接口6 错误码 在线测试 @@ -263,6 +264,16 @@ console.log(features.gene_style); // "自然型" +
+

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

+
+

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

+

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

+

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

+

52号规则测试页

+
+
+

6. 四庭七眼测量 v2  POST  /api/v1/face/measure-v2  v2

@@ -326,6 +337,7 @@ console.log(features.gene_style); // "自然型" 3. B端生发/static/test_interface3.html划线图上传 → 生发效果图 4. 用户特征/static/test_interface4.html上传照片 → 7项面部特征 + 原始JSON 5. 发际线PNG/static/test_interface5.html上传+性别 → 发际线方案+中心点坐标 + 52. 发际线新规则/static/test_interface52.html中下庭定位、扶正裁切、矩形叠加与生发 6. 四庭七眼 v2/static/test_interface6.html同接口1,去顶庭 · 竖线发际线→下巴 · 无头部端线 9. 头发遮罩/static/test_interface9.html上传照片 → 头发遮罩 + 分步可视化 10. 外缘膨胀带/static/test_interface10.html上传照片 → 头部外缘带状遮罩 + 分步可视化 diff --git a/static/test_interface52.html b/static/test_interface52.html new file mode 100644 index 0000000..d1843ba --- /dev/null +++ b/static/test_interface52.html @@ -0,0 +1,418 @@ + + + + + +接口52 — 发际线PNG生成 测试页 + + + + + +
+

💈 接口52 — 发际线 PNG 生成 测试

+

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

+
目标上庭高度 =(中庭高度 + 下庭高度)÷ 2
上传图先扶正9→152,再裁切为高约 3.3333×L、宽 2.5×L,人物比原3L规则缩小约10%;L 是9号点到152号点的像素距离,眉心位置沿用参考图比例。长图会在保留人脸的前提下裁为3:4,其余空间不足时仅扶正。从眉心向上定位,各发型中点对齐同一目标。high、low 已停用,旧字段返回 null。
+ +
+
+
+
+
+ + +
+
+ +
+
+
+ + +
+
+
+
+ + +
+ + +
+
JPG/PNG 正面照  |  Female: 椭圆/花瓣/心形/直线/波浪/大花瓣/古典花瓣  |  Male: 椭圆/倒弧/M形/直线/桃心/柔和花瓣/波浪  |  每个发型返回一张设计曲线,生发遮罩同步到新位置
+ +
+
+ + +
+ + + + diff --git a/tests/test_gateway_interface52.py b/tests/test_gateway_interface52.py new file mode 100644 index 0000000..64b314f --- /dev/null +++ b/tests/test_gateway_interface52.py @@ -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()