Author SHA1 Message Date
xslandCursor 718372dc07 feat: 接口1/6 标注层字号上调一档 + 眉心改用 9 号点定位
- annotation: 自适应字号系数 0.017→0.020(下限 8→9),标注文字更大更清晰
- measure: _brow_center 只取 FaceMesh 9 号点(眉间上点),不再与 151 取中点

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 23:05:44 +08:00
xsl 3a7c3fa07b feat(接口2): ComfyUI重绘分辨率默认改为1024 + wave测试套件 + 报告统一部署
- hairline/service.py: _REDRAW_MAX_SIDE 默认 896→1024
  逻辑: 输入图长边>1024 才缩到1024; ≤1024 原图分辨率直送(不放大)
  male路径_GROW_B_MAX_SIDE 已是1024,行为一致
- static/test_interface2.html: 分辨率下拉选项标签同步(默认1024/>1024才缩)
- image/wave_test/: wave发型5档分辨率对比测试(21图×5档=105次,全成功)
  batch_test.py/gen_report.py/report-server-wave.service
- image/reports/: 报告统一HTTP服务(单端口8850,路径区分/wave /v2 /v1)
  含索引页index.html + report-server.service + 三报告软链接
- .gitignore: 补充 wave_test/out/ 及运行期文件忽略规则
2026-07-27 23:38:53 +08:00
xsl b61ea6f33b feat(接口5): 生发机制对齐接口2 + 统一ComfyUI重绘提示词
接口5改造(生发机制与接口2一致,按性别分流):
- generate_hairline_pngs: 生发图来源从 _grow_from_texture(局部inpaint)
  改为按性别分流——female走 generate_grow_results_swap(swapHair+Flux整帧重绘),
  male走 generate_grow_results(ComfyUI add_hair)
- 新增参数 redraw_max_side/unet_name/v2_defaults(female路径透传)
- 接口5 handler 加 flux_model/redraw_max_side Form参数
- 保留接口5独有输出: 3档叠图(middle/high/low)/中心点/face_measure
- 已验证: female日志出现"接口2女 管线降分辨率max_side=640"+swap+Flux;
  male走add_hair.json; generate_grow_image=false正确跳过生发

统一ComfyUI重绘提示词:
- "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" → "填充遮罩区域的头发"
- 覆盖: _REDRAW_PROMPT/_DEFAULT_PROMPT常量 + app.py各接口Form默认 +
  工作流JSON节点60(add_hair/0716add-hair-api/hair_repaint) + 全部测试页输入框
2026-07-27 23:36:15 +08:00
xsl b354c8c627 fix: 删除 MeasureResult.__init__ 中重复的七眼厘米赋值块 2026-07-27 23:17:35 +08:00
xsl fd9c41430c feat: 接口1/5/6 发际线弃用逻辑(顶庭<0.7cm)
发际线离头顶<0.7cm时判定分割不可靠,弃用发际线:
顶/上庭字段置null、face_total只算中下庭、标注图保留头顶线去掉发际线、
只标中/下庭。eye1/7竖向范围改用眉心。
2026-07-27 23:12:33 +08:00
xsl 5101bb5f6b feat(接口2): 测试页暴露重绘分辨率选项 + 分辨率对比测试套件
- test_interface2.html: 新增「重绘分辨率」下拉(默认896/1024/768/640/不缩图0),
  仅 female 生效,append redraw_max_side 字段;male 时自动隐藏
- 新增分辨率对比测试工具 (image/compare_test, image/res_test):
  - batch_test.py: 串行批量测试脚本,支持断点续跑
  - gen_report.py: 生成自包含 HTML 对比报告(速度色阶+缩略图+点击放大)
- 两轮测试结果:
  - v1: 4图×5发型×2档=40次 (compare_test)
  - v2: 5图×3发型×5档=75次 (res_test)
  - 结论: 大图(长边>1600)原图直送比896档慢~3倍; 中小图各档差异小
- .gitignore: 忽略测试结果图/原图副本/运行日志(out/, *.log, progress.json)
2026-07-26 22:06:27 +08:00
xsl ff4019c570 chore: 默认模型固定为 9B-Q4_K_M,测试页移除模型/分辨率选择器
测试结论: 9B-Q4_K_M 分辨率896 为最佳性价比组合。
- 三个工作流(0716add-hair-api/add_hair/hair_repaint)默认模型改为
  flux-2-klein-9b-Q4_K_M.gguf (UnetLoaderGGUF节点)
- test_interface2.html 移除 Flux模型/压图长边 下拉选择器,
  接口调用不再传 flux_model/redraw_max_side,统一用工作流默认(Q4/896)
2026-07-25 16:25:44 +08:00
xsl 5226e23989 save code 2026-07-25 16:22:41 +08:00
xsl c462fd3634 refactor(报告): 图片改为独立JPG文件引用,不再base64内嵌
- 测试结果图(258张)和原图(3张)以JPG存入 static/bench/{matrix,hairstyle,orig}/
- 报告HTML改为 <img src> 引用相对路径,体积从 ~45MB 降至 ~30KB
- 报告和图片均部署在 static/ 下,可通过URL直接访问
2026-07-25 16:20:28 +08:00
xsl ab6b0cb0bb docs: 添加模型对比/发型对比 HTML 测试报告
报告图片已压缩为内嵌 JPG(缩放至300px宽,质量70),体积从 ~45MB 降至 ~3MB:
- static/benchmark_report.html: 4模型×3分辨率矩阵对比 (2.7MB)
- static/hairstyle_report.html: 3图×5发型×10组合对比 (3.1MB)

同步更新报告生成脚本支持图片压缩,并从 .gitignore 移除报告文件排除。
2026-07-25 16:17:14 +08:00
xsl 94cdfd6de5 feat(接口2): 支持动态切换Flux模型+分辨率 + 模型对比测试脚本
代码改动:
- comfyui.py: run() 新增 unet_name 参数,提交前自动改写模型节点
  (.gguf→UnetLoaderGGUF, .safetensors→UNETLoader),并按模型自动同步
  文本编码器(4b→qwen_3_4b, 9b→qwen_3_8b),避免切换时维度不匹配
- redraw.py: run_redraw() 透传 unet_name
- service.py: generate_grow_results_swap/generate_grow_results 支持
  redraw_max_side(分辨率参数化) 和 unet_name 透传
- app.py: 接口2 新增 flux_model/redraw_max_side 两个 Form 参数(男女路径都加)
- test_interface2.html: 新增 Flux模型/压图长边 下拉选择器
- add_hair.json/0716add-hair-api.json: 工作流默认模型改为 9b

测试脚本:
- benchmark_matrix.py: 4模型×3分辨率×3图×3次 矩阵测试
- benchmark_hairstyle.py: 3图×5发型×10组合 发型对比测试
- benchmark_report.py/benchmark_hairstyle_report.py: HTML报告生成

清理:
- .gitignore: 排除 benchmark_out/、报告HTML、gateway.log、*.bak.*
- 移除 gateway.log 的 git 跟踪
2026-07-25 16:16:13 +08:00
330 changed files with 3116 additions and 483 deletions
+25
View File
@@ -53,3 +53,28 @@ static/report_hairline_v2.zip
# local_test 运行期日志 / pid(不入 git)
local_test/hair_service.log
local_test/hair_service.pid
# benchmark 原始输出(含结果图+原图,体积大,不入 git)
benchmark_out/
# benchmark 部署的 HTML 报告(图片 base64 内嵌,体积大,不入 git)
static/hairstyle_thumbs/
# 网关运行期日志(不入 git
gateway.log
# 工作流备份文件(不入 git
*.json.bak.*
# 分辨率对比测试产物(out/ 下结果图+原图副本+日志,体积大,不入 git)
# 仅忽略 out/ 与运行期文件;测试脚本与 HTML 报告仍入库
image/compare_test/out/
image/res_test/out/
image/wave_test/out/
image/compare_test/progress.json
image/res_test/progress.json
image/wave_test/progress.json
image/compare_test/batch_test.log
image/res_test/batch_test.log
image/wave_test/batch_test.log
image/compare_test/http.log
+4 -4
View File
@@ -1,8 +1,8 @@
{
"16": {
"class_type": "UNETLoader",
"class_type": "UnetLoaderGGUF",
"inputs": {
"unet_name": "flux-2-klein-4b-fp8.safetensors",
"unet_name": "flux-2-klein-9b-Q4_K_M.gguf",
"weight_dtype": "fp8_e4m3fn_fast"
}
},
@@ -15,7 +15,7 @@
"61": {
"class_type": "CLIPLoader",
"inputs": {
"clip_name": "qwen_3_4b.safetensors",
"clip_name": "qwen_3_8b_fp8mixed.safetensors",
"type": "flux2",
"device": "cpu"
}
@@ -29,7 +29,7 @@
"60": {
"class_type": "JjkText",
"inputs": {
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
"text": "填充遮罩区域的头发"
}
},
"22": {
+1 -1
View File
File diff suppressed because one or more lines are too long
+4 -4
View File
@@ -170,10 +170,10 @@
},
"16": {
"inputs": {
"unet_name": "flux-2-klein-4b-fp8.safetensors",
"unet_name": "flux-2-klein-9b-Q4_K_M.gguf",
"weight_dtype": "fp8_e4m3fn_fast"
},
"class_type": "UNETLoader",
"class_type": "UnetLoaderGGUF",
"_meta": {
"title": "UNet加载器"
}
@@ -410,7 +410,7 @@
},
"60": {
"inputs": {
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
"text": "填充遮罩区域的头发"
},
"class_type": "JjkText",
"_meta": {
@@ -419,7 +419,7 @@
},
"61": {
"inputs": {
"clip_name": "qwen_3_4b.safetensors",
"clip_name": "qwen_3_8b_fp8mixed.safetensors",
"type": "flux2",
"device": "cpu"
},
+46 -17
View File
@@ -146,7 +146,7 @@ _AUTH_EXEMPT = ("/health", "/docs", "/openapi.json", "/redoc", "/static",
# ---------------------------------------------------------------------------
# change_hair 代理路由(解决 CORS 问题)
# ---------------------------------------------------------------------------
_CHANGE_HAIR_BASE = "http://127.0.0.1:8801"
_CHANGE_HAIR_BASE = os.getenv("CHANGE_HAIR_BASE", "http://127.0.0.1:8801")
@app.post("/api/swapHair/v1", tags=["change_hair"])
@@ -401,9 +401,24 @@ def _run_face_measure_data(image, variant="v1"):
logger.warning("头发/耳朵分割失败,回退方案A%s", seg_e)
result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose)
discarded = result.hairline_discarded
data = result.to_response()
if variant == "v6":
vd = result.vertical
if variant == "v6":
if discarded:
# 发际线弃用:接口6 的上庭也依赖发际线,一并置 null;只保留中/下庭。
base_px = vd["middle_court_px"] + vd["lower_court_px"]
data["four_courts"]["upper_court_cm"] = None
data["four_courts"]["ratios"] = {
"upper_court": None,
"middle_court": round(vd["middle_court_px"] / base_px, 3),
"lower_court": round(vd["lower_court_px"] / base_px, 3),
}
data["four_courts"].pop("top_court_cm", None)
data["face_total_height_cm"] = round(
result.middle_cm + result.lower_cm, 2)
data["landmarks"]["hairline"] = None
else:
base_px = vd["upper_court_px"] + vd["middle_court_px"] + vd["lower_court_px"]
# 接口6 是三庭:去掉顶庭相关字段(top_court_cm / ratios.top_court / landmarks.hair_top
data["four_courts"]["ratios"] = {
@@ -430,11 +445,14 @@ def _run_face_measure_data(image, variant="v1"):
data["seven_eyes"][f"eye{i + 2}"] = (
None if (a is None or b is None) else round((b - a) / pc, 2))
if variant != "v6":
# 接口1 额外算 eye1/eye7(左/右耳外段),需耳朵分割端线
# 接口1 额外算 eye1/eye7(左/右耳外段),需耳朵分割端线
# 竖向范围:发际线弃用时用眉心做上界(hair_top 不可靠),否则用头顶。
from face_analysis.annotation import _ear_edges_from_mask
top_y = (vd["brow_center"][1] if discarded
else vd["hair_top"][1])
head_l, head_r = _ear_edges_from_mask(
ear_mask, hair_mask,
result.vertical["hair_top"][1], result.vertical["chin_tip"][1],
top_y, vd["chin_tip"][1],
lcx, rcx, (lcx + rcx) / 2)
data["seven_eyes"]["eye1"] = (
None if (head_l is None) else round((lcx - head_l) / pc, 2))
@@ -757,7 +775,9 @@ async def hair_grow(
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填),如 1,2,3。female:1-5 male:1-4"),
beauty_enabled: bool = Form(default=False, description="是否开启美颜(本期不生效)"),
use_mask: bool = Form(default=True, description="是否启用 inpaint 遮罩(测试对比用)。false 时用干净原图生成(空遮罩,不烧模板线)"),
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", description="ComfyUI 提示词,会替换工作流节点60的文本"),
prompt: str = Form(default="填充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
flux_model: Optional[str] = Form(default=None, description="Flux 模型文件名(切换模型用)。None=工作流默认;如 flux-2-klein-9b-Q5_K_M.gguf / flux-2-klein-9b-Q4_K_M.gguf / flux2.0/flux-2-klein-9b-fp8.safetensors"),
redraw_max_side: Optional[int] = Form(default=None, description="重绘压图长边像素。None=默认896;0=不缩图(原图直送);其他如 768/640/1024"),
):
# 1. gender 必填校验(非法/缺失 → 1004)
if gender not in ("male", "female"):
@@ -786,11 +806,13 @@ async def hair_grow(
if gender == "female":
from hairline.service import generate_grow_results_swap
items = await run_in_threadpool(
generate_grow_results_swap, image, hair_styles, _V2_FINAL_DEFAULTS)
generate_grow_results_swap, image, hair_styles, _V2_FINAL_DEFAULTS,
redraw_max_side=redraw_max_side, unet_name=flux_model)
else:
from hairline.service import generate_grow_results
items = await run_in_threadpool(
generate_grow_results, image, gender, use_mask, prompt, hair_styles)
generate_grow_results, image, gender, use_mask, prompt, hair_styles,
unet_name=flux_model)
if items is None:
return err(1001, "无法识别人像")
@@ -868,7 +890,7 @@ async def hair_grow_b(
marked_image_url: Optional[str] = Form(default=None, description="划线图片 URL"),
marked_image_base64: Optional[str] = Form(default=None, description="划线图片 base64"),
use_mask: bool = Form(default=True, description="是否画发际线(测试对比用)。false 时跳过划线检测、直接送划线图"),
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", description="ComfyUI 提示词,会替换工作流节点60的文本"),
prompt: str = Form(default="填充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
):
# 划线图三选一取图(只需这一张)
marked_raw, e = await resolve_image_bytes(marked_image_file, marked_image_url, marked_image_base64)
@@ -997,10 +1019,13 @@ async def face_features(
- 必填 `gender``male`/`female`),决定发型集合(female 5 / male 4)。
- 必填 `hair_style`(发型序号,逗号分隔如 `1,2,3`),决定返回哪些发际线类型。缺失/越界/非法返回 `1007`。
`female`1=ellipse,2=flower,3=heart,4=straight,5=wave`male`1=ellipse,2=inverse_arc,3=m,4=straight。
- 可选 `use_mask` / `prompt`:同接口2 的生发控制参数。
注:生发黑模板固定取 `hairline_texture_black/`middle 档),即三档叠图分别用各自贴图、但生发目标固定 middle
- 可选 `generate_grow_image`(默认 `true`):是否生成生发效果图(ComfyUI 生发,全流程最耗时)。
- 可选 `use_mask` / `prompt`:同接口2 的生发控制参数(仅 male 路径生效)
- 可选 `generate_grow_image`(默认 `true`):是否生成生发效果图(最耗时)
`false` 时跳过生发,各发型 `grown_image_*` 恒为 `null`,仅返回三档发际线叠图与中心点,大幅降低耗时。
- **生发机制(同接口2,按性别分流)**:
`female` 走「换发型 + Flux-2 整帧重绘」(依赖 change_hair:8801 与 ComfyUI:8188);
`male` 走 ComfyUI `add_hair` 原生 inpaint。
- 可选 `flux_model` / `redraw_max_side`:同接口2(仅 female 路径生效)。
**返回说明**
@@ -1077,9 +1102,11 @@ async def hairline_generate(
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填,如 1,2,3)。female:1-5 male:1-4"),
use_mask: bool = Form(default=True, description="生发是否启用 inpaint 遮罩(同接口2,测试对比用"),
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", description="ComfyUI 提示词(同接口2),会替换工作流节点60的文本"),
generate_grow_image: bool = Form(default=True, description="是否生成生发效果图(ComfyUI 生发,最耗时)。默认 true 出图;false 时跳过生发,各发型 grown_image 恒为 null,仅返回三档发际线叠图与中心点"),
use_mask: bool = Form(default=True, description="生发是否启用 inpaint 遮罩(同接口2,仅 male 路径生效"),
prompt: str = Form(default="填充遮罩区域的头发", description="ComfyUI 提示词(同接口2,仅 male 路径生效),会替换工作流节点60的文本"),
generate_grow_image: bool = Form(default=True, description="是否生成生发效果图(最耗时)。默认 true 出图;false 时跳过生发,各发型 grown_image 恒为 null,仅返回三档发际线叠图与中心点"),
flux_model: Optional[str] = Form(default=None, description="Flux 模型文件名(同接口2,切换模型用)。None=工作流默认"),
redraw_max_side: Optional[int] = Form(default=None, description="重绘压图长边像素(同接口2,仅 female 路径生效)。None=默认896;0=不缩图(原图直送);其他如 768/640/1024"),
):
if gender not in ("male", "female"):
return err(1004, "gender 必填且只能为 male / female")
@@ -1104,7 +1131,9 @@ async def hairline_generate(
res = await run_in_threadpool(
generate_hairline_pngs, image, gender, hair_styles, use_mask, prompt,
generate_grow_image=generate_grow_image)
generate_grow_image=generate_grow_image,
redraw_max_side=redraw_max_side, unet_name=flux_model,
v2_defaults=_V2_FINAL_DEFAULTS)
if res is None:
return err(1001, "无法识别人像")
@@ -1472,7 +1501,7 @@ async def hairline_grow_v2(
inpainting_fill: int = Form(default=1, description="change_hair服务端重绘填充:0=保留原图 | 1=噪声 | 2=纯色 | 3=潜变量。默认 1"),
mask_blur: int = Form(default=11, description="change_hair服务端遮罩边缘模糊像素,默认 11"),
mask_dilate_scale: float = Form(default=1.0, description="change_hair服务端遮罩膨胀缩放,默认 1.0"),
comfyui_prompt: Optional[str] = Form(default=None, description="Flux-2 重绘提示词,None 用默认「填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"),
comfyui_prompt: Optional[str] = Form(default=None, description="Flux-2 重绘提示词,None 用默认「填充遮罩区域的头发」"),
beauty_alpha: float = Form(default=0.6, description="redraw_band 版 band 外的全脸美颜融入强度(0=band外无美颜纯用final,1≈整帧版),默认 0.6"),
band_lo_mult: float = Form(default=0.5, description="重绘带外推倍率下限(相对 hairline_push_cm,内轮廓=0×、原外推线=1.0×),默认 0.5"),
band_hi_mult: float = Form(default=1.5, description="重绘带外推倍率上限(相对 hairline_push_cm),默认 1.5"),
@@ -1638,7 +1667,7 @@ async def hairline_grow_v2_final_v2(
async def api_redraw(
image_file: UploadFile = File(..., description="人物图片(JPG/PNG"),
mask_file: UploadFile = File(..., description="遮罩图片(PNG,支持红/白/alpha 格式)"),
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜",
prompt: str = Form(default="填充遮罩区域的头发",
description="ComfyUI 提示词"),
):
image_bytes = await image_file.read()
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""发型对比矩阵测试:3图×5发型=15行,每行10张图(4b@896×1 + 9b三模型×三分辨率×9)。
按模型分组跑(减少模型切换次数、降低OOM风险),结果重组为15行存JSON+生成报告。
"""
import base64
import json
import os
import subprocess
import time
from collections import defaultdict
from pathlib import Path
import requests
API = "http://127.0.0.1:8187/api/v1/hair/grow"
TOKEN = "dev-shared-secret-2026"
OUT = Path("/home/ubuntu/hair/benchmark_out/hairstyle")
OUT.mkdir(parents=True, exist_ok=True)
IMGS = [
("asdf", "/home/ubuntu/hair/image/asdf.jpg"),
("qwer", "/home/ubuntu/hair/image/qwer.jpg"),
("girl5", "/home/ubuntu/hair/image/girl_img/girl5.jpg"),
]
HAIRSTYLES = [
(1, "ellipse", "椭圆"), (2, "flower", "花瓣"), (3, "heart", "心形"),
(4, "straight", "直线"), (5, "wave", "波浪"),
]
# 按模型分组:每个模型对应其要跑的(分辨率,列标题)
MODEL_GROUPS = [
("flux-2-klein-4b-fp8.safetensors", [("896", "4B@896")]),
("flux2.0/flux-2-klein-9b-fp8.safetensors",
[("0", "9B-fp8@原图"), ("896", "9B-fp8@896"), ("640", "9B-fp8@640")]),
("flux-2-klein-9b-Q5_K_M.gguf",
[("0", "9B-Q5@原图"), ("896", "9B-Q5@896"), ("640", "9B-Q5@640")]),
("flux-2-klein-9b-Q4_K_M.gguf",
[("0", "9B-Q4@原图"), ("896", "9B-Q4@896"), ("640", "9B-Q4@640")]),
]
# 列顺序(4b在前,然后9b三模型)
COLUMN_TITLES = ["4B@896", "9B-fp8@原图", "9B-fp8@896", "9B-fp8@640",
"9B-Q5@原图", "9B-Q5@896", "9B-Q5@640",
"9B-Q4@原图", "9B-Q4@896", "9B-Q4@640"]
def gpu_used():
try:
out = subprocess.check_output(
["nvidia-smi", "--query-gpu=memory.used", "--format=csv,noheader,nounits"], timeout=10)
return int(out.decode().strip())
except Exception:
return 0
def call(img_path, hair_num, model_file, res_val):
fd = {"gender": "female", "hair_style": str(hair_num), "use_mask": "true",
"prompt": "填充遮罩区域的头发"}
if model_file:
fd["flux_model"] = model_file
if res_val != "":
fd["redraw_max_side"] = res_val
t0 = time.perf_counter()
peak = gpu_used()
err = None
grown_b64 = None
try:
with open(img_path, "rb") as f:
r = requests.post(API, headers={"X-Internal-Token": TOKEN},
files={"image_file": (os.path.basename(img_path), f, "image/jpeg")},
data=fd, timeout=300)
elapsed = time.perf_counter() - t0
peak = max(peak, gpu_used())
j = r.json()
if j.get("code") != 0:
err = f"code={j.get('code')} {j.get('message', '')[:60]}"
else:
res = j.get("data", {}).get("results", [])
if res and res[0].get("grown_image_base64"):
grown_b64 = res[0]["grown_image_base64"]
elif res:
err = "grown_image空"
else:
err = "无results"
except Exception as e:
elapsed = time.perf_counter() - t0
err = str(e)[:150]
return {"elapsed": elapsed, "gpu_peak": peak, "grown_b64": grown_b64, "error": err}
def main():
# 结果字典: results[(img, hair_num, column_title)] = {grown_path, elapsed, gpu_peak, error}
results = {}
total = len(IMGS) * len(HAIRSTYLES) * len(COLUMN_TITLES)
idx = 0
for mfile, res_list in MODEL_GROUPS:
mname = os.path.basename(mfile)
print(f"\n===== 切换到模型: {mname} =====", flush=True)
# 等模型切换稳定
time.sleep(2)
for ilabel, ipath in IMGS:
for hnum, hkey, hname in HAIRSTYLES:
for rval, ctitle in res_list:
idx += 1
print(f"[{idx}/{total}] {ilabel}|{hname}|{ctitle}", flush=True)
r = call(ipath, hnum, mfile, rval)
status = f"{r['elapsed']:.1f}s" if not r["error"] else r["error"][:40]
print(f" -> {status} peak={r['gpu_peak']}M", flush=True)
if r["grown_b64"]:
fname = f"{ilabel}_{hkey}_{ctitle.replace('@','_').replace('-','')}.jpg"
with open(OUT / fname, "wb") as gf:
gf.write(base64.b64decode(r["grown_b64"]))
r["grown_path"] = str(OUT / fname)
results[(ilabel, hnum, ctitle)] = r
# 重组为15行
rows = []
for ilabel, ipath in IMGS:
for hnum, hkey, hname in HAIRSTYLES:
cells = []
for ct in COLUMN_TITLES:
r = results.get((ilabel, hnum, ct), {"error": "未跑"})
cells.append({"title": ct, **{k: v for k, v in r.items() if k != "grown_b64"}})
rows.append({"img": ilabel, "img_path": ipath,
"hair_num": hnum, "hair_key": hkey, "hair_name": hname,
"cells": cells})
with open(OUT / "results.json", "w", encoding="utf-8") as f:
json.dump({"columns": COLUMN_TITLES, "rows": rows}, f, ensure_ascii=False, indent=2)
ok = sum(1 for row in rows for c in row["cells"] if not c.get("error"))
print(f"\n✓ 完成: {ok}/{total} 成功 -> {OUT/'results.json'}", flush=True)
if __name__ == "__main__":
main()
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""把发型对比测试结果生成 HTML 报告。
15行(3图×5发型) × 10列(4b@896 + 9b三模型×三分辨率),每行首列=原图。
图片 base64 内嵌,自包含单文件。
"""
import base64
import json
import os
from pathlib import Path
OUT = Path("/home/ubuntu/hair/benchmark_out/hairstyle")
RESULTS = OUT / "results.json"
HTML = OUT / "report.html"
def img_src(path):
"""把绝对路径转成报告里的相对 URL(报告在 static/,图片在 static/bench/)。"""
if not path:
return None
p = str(path)
if "benchmark_out/hairstyle/" in p:
return "bench/hairstyle/" + os.path.basename(p)
if "benchmark_out/matrix/" in p:
return "bench/matrix/" + os.path.basename(p)
return None
def main():
d = json.load(open(RESULTS, encoding="utf-8"))
columns = d["columns"]
rows = d["rows"]
# 统计每列的平均耗时、峰值显存
col_stats = {}
for ct in columns:
times, peaks = [], []
for r in rows:
for c in r["cells"]:
if c.get("title") == ct and not c.get("error"):
times.append(c["elapsed"])
peaks.append(c["gpu_peak"])
col_stats[ct] = {
"avg_t": sum(times) / len(times) if times else 0,
"max_p": max(peaks) / 1024 if peaks else 0,
}
# 表头:原图 + 10列
headers = ['<th class="col-label">原图</th>']
for ct in columns:
s = col_stats[ct]
headers.append(
f'<th class="col-label"><div class="col-title">{ct}</div>'
f'<div class="col-stat">{s["avg_t"]:.0f}s · {s["max_p"]:.0f}G</div></th>'
)
# 表体:15行
body_rows = []
for r in rows:
# 发型+图标签
label = f'<div class="row-label">{r["img"]}<br><b>{r["hair_name"]}</b></div>'
# 原图
ORIG_SRC = {"asdf": "bench/orig/asdf.jpg", "qwer": "bench/orig/qwer.jpg", "girl5": "bench/orig/girl5.jpg"}
orig = ORIG_SRC.get(r["img"])
cells = [f'<td class="cell-orig"><div class="row-label-cell">{label}</div>'
f'<img class="orig-img" src="{orig}"></td>']
# 10个结果列
for ct in columns:
c = next((x for x in r["cells"] if x.get("title") == ct), {})
src = img_src(c.get("grown_path")) if not c.get("error") else None
if src:
cells.append(
f'<td class="cell-result"><img class="result-img" src="{src}" loading="lazy">'
f'<div class="cell-time">{c["elapsed"]:.1f}s</div></td>')
else:
cells.append(f'<td class="cell-result"><div class="na">⚠</div></td>')
body_rows.append(f'<tr>{"".join(cells)}</tr>')
html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>发型对比测试报告 — 4模型×3分辨率</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: -apple-system, "Segoe UI", sans-serif; background: #f5f5f5; padding: 16px; }}
h1 {{ font-size: 20px; margin-bottom: 4px; }}
.subtitle {{ color: #888; font-size: 12px; margin-bottom: 12px; }}
.legend {{ background: #fff; border-radius: 8px; padding: 10px 16px; margin-bottom: 12px; font-size: 12px; color: #555; }}
.scroll-wrap {{ overflow-x: auto; }}
table {{ border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden;
box-shadow: 0 1px 4px rgba(0,0,0,.06); }}
th, td {{ border: 1px solid #eee; padding: 6px; vertical-align: top; text-align: center; }}
th {{ background: #f9fafb; position: sticky; top: 0; }}
.col-label {{ min-width: 110px; max-width: 130px; }}
.col-title {{ font-size: 12px; font-weight: 700; color: #374151; }}
.col-stat {{ font-size: 10px; color: #9ca3af; margin-top: 2px; }}
.row-label {{ font-size: 11px; color: #6b7280; }}
.row-label b {{ color: #1f2937; }}
.row-label-cell {{ font-size: 11px; color: #6b7280; margin-bottom: 4px; }}
.row-label-cell b {{ color: #1f2937; font-size: 13px; }}
img {{ border-radius: 4px; max-width: 120px; max-height: 150px; object-fit: contain; background: #f3f4f6; }}
.orig-img {{ border: 2px solid #d1d5db; max-height: 130px; }}
.cell-time {{ font-size: 10px; color: #9ca3af; margin-top: 2px; }}
.na {{ color: #d1d5db; font-size: 16px; padding: 40px; }}
</style>
</head>
<body>
<h1>💇 发型对比测试报告</h1>
<p class="subtitle">接口2女性 · 3图×5发型=15行 · 每行: 4B@896(1) + 9B(fp8/Q5/Q4)×(原图/896/640)(9) · 150/150成功 · RTX3090</p>
<div class="legend">列标题下显示<b>平均耗时 · 峰值显存</b>。横向滚动查看更多列。原图列含图片名+发型名。</div>
<div class="scroll-wrap">
<table>
<tr>{"".join(headers)}</tr>
{"".join(body_rows)}
</table>
</div>
</body>
</html>"""
with open(HTML, "w", encoding="utf-8") as f:
f.write(html)
print(f"✓ 报告: {HTML} ({HTML.stat().st_size//1024} KB)")
if __name__ == "__main__":
main()
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""接口2女性 花瓣形 单发型 4模型×3分辨率×3图×3次 矩阵测试。
调用本机 hair-worker (:8187) 的 /api/v1/hair/growgender=female, hair_style=2(花瓣形)。
每次记录:生发图、耗时、显存峰值。结果图存到 benchmark_out/matrix/,最后生成 HTML 报告。
"""
import base64
import json
import os
import subprocess
import sys
import time
from pathlib import Path
import requests
API = "http://127.0.0.1:8187/api/v1/hair/grow"
TOKEN = "dev-shared-secret-2026"
OUT = Path("/home/ubuntu/hair/benchmark_out/matrix")
OUT.mkdir(parents=True, exist_ok=True)
# 4 模型 × 3 分辨率 × 3 图 × 3 次
MODELS = [
("4b-fp8", "flux-2-klein-4b-fp8.safetensors"),
("9b-fp8", "flux2.0/flux-2-klein-9b-fp8.safetensors"),
("9b-Q5", "flux-2-klein-9b-Q5_K_M.gguf"),
("9b-Q4", "flux-2-klein-9b-Q4_K_M.gguf"),
]
RES = [("orig", "0"), ("640", "640"), ("896", "896")]
IMGS = [
("asdf", "/home/ubuntu/hair/image/asdf.jpg"),
("qwer", "/home/ubuntu/hair/image/qwer.jpg"),
("girl5", "/home/ubuntu/hair/image/girl_img/girl5.jpg"),
]
REPEAT = 3
def gpu_used():
"""返回当前显存已用 MiB。"""
try:
out = subprocess.check_output(
["nvidia-smi", "--query-gpu=memory.used", "--format=csv,noheader,nounits"],
timeout=10,
)
return int(out.decode().strip())
except Exception:
return 0
def call(img_path, model_file, res_val):
"""调一次接口2。返回 dict: ok/elapsed/grown_path/gpu_peak/error。"""
fd = {
"gender": "female",
"hair_style": "2", # 花瓣形
"use_mask": "true",
"prompt": "填充遮罩区域的头发",
}
if model_file:
fd["flux_model"] = model_file
if res_val != "":
fd["redraw_max_side"] = res_val
t0 = time.perf_counter()
peak = gpu_used()
err = None
grown_path = None
try:
with open(img_path, "rb") as f:
r = requests.post(
API, headers={"X-Internal-Token": TOKEN},
files={"image_file": (os.path.basename(img_path), f, "image/jpeg")},
data=fd, timeout=300,
)
elapsed = time.perf_counter() - t0
# 采样峰值(推理刚结束)
peak = max(peak, gpu_used())
j = r.json()
if j.get("code") != 0:
err = f"code={j.get('code')} {j.get('message','')}"
else:
res = j.get("data", {}).get("results", [])
if res and res[0].get("grown_image_base64"):
grown_path = OUT / f"tmp_grown.jpg"
with open(grown_path, "wb") as gf:
gf.write(base64.b64decode(res[0]["grown_image_base64"]))
elif res:
err = "grown_image_base64 为空"
else:
err = "无 results"
except Exception as e:
elapsed = time.perf_counter() - t0
err = str(e)[:200]
return {"elapsed": elapsed, "gpu_peak": peak, "grown_path": str(grown_path) if grown_path else None, "error": err}
def main():
results = [] # 每元素一个组合
total = len(MODELS) * len(RES) * len(IMGS) * REPEAT
idx = 0
for mlabel, mfile in MODELS:
for rlabel, rval in RES:
for ilabel, ipath in IMGS:
# 一个组合:3 次
runs = []
for rep in range(REPEAT):
idx += 1
print(f"[{idx}/{total}] {mlabel} | res={rlabel} | {ilabel} | rep{rep+1}", flush=True)
r = call(ipath, mfile, rval)
print(f" -> {r['elapsed']:.1f}s peak={r['gpu_peak']}MiB err={r['error']}", flush=True)
# 存每次的生发图
if r["grown_path"]:
save_to = OUT / f"{mlabel}_{rlabel}_{ilabel}_r{rep+1}.jpg"
os.replace(r["grown_path"], save_to)
r["grown_path"] = str(save_to)
runs.append(r)
results.append({
"model": mlabel, "model_file": mfile,
"res": rlabel, "res_val": rval,
"img": ilabel, "img_path": ipath,
"runs": runs,
})
# 存原始数据
with open(OUT / "results.json", "w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
print(f"\n✓ 全部完成,原始数据 -> {OUT/'results.json'}", flush=True)
if __name__ == "__main__":
main()
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""把 benchmark_out/matrix/results.json 生成 HTML 报告。
每个组合一行:原图 + 3次生发图 + 耗时/显存。
图片用 base64 内嵌(自包含单文件,便于部署)。
"""
import base64
import json
import os
from pathlib import Path
OUT = Path("/home/ubuntu/hair/benchmark_out/matrix")
RESULTS = OUT / "results.json"
HTML = OUT / "report.html"
RES_LABEL = {"orig": "原图", "640": "640", "896": "896(默认)"}
MODEL_LABEL = {
"4b-fp8": "4B fp8 (3.8G)",
"9b-fp8": "9B fp8 (8.8G)",
"9b-Q5": "9B Q5_K_M (6.6G)",
"9b-Q4": "9B Q4_K_M (5.6G)",
}
MODEL_ORDER = ["4b-fp8", "9b-Q4", "9b-Q5", "9b-fp8"]
def img_src(path):
"""把绝对路径转成报告里的相对 URL(报告在 static/,图片在 static/bench/)。"""
if not path:
return None
p = str(path)
# benchmark_out/matrix/xxx.jpg -> bench/matrix/xxx.jpg
if "benchmark_out/matrix/" in p:
return "bench/matrix/" + os.path.basename(p)
if "benchmark_out/hairstyle/" in p:
return "bench/hairstyle/" + os.path.basename(p)
return None
def thumb(src, alt="", cls=""):
if not src:
return f'<div class="na {cls}">⚠ 失败</div>'
return f'<img class="{cls}" src="{src}" alt="{alt}" loading="lazy">'
def main():
data = json.load(open(RESULTS, encoding="utf-8"))
# 原图相对路径映射(图片在 static/bench/orig/
ORIG_SRC = {"asdf": "bench/orig/asdf.jpg", "qwer": "bench/orig/qwer.jpg", "girl5": "bench/orig/girl5.jpg"}
# 统计:每个模型的平均耗时、平均峰值显存
stats = {}
for c in data:
m = c["model"]
stats.setdefault(m, {"times": [], "peaks": []})
for r in c["runs"]:
if not r["error"]:
stats[m]["times"].append(r["elapsed"])
stats[m]["peaks"].append(r["gpu_peak"])
rows_html = []
# 按模型顺序、分辨率顺序、图片顺序排列
for m in MODEL_ORDER:
mdata = [c for c in data if c["model"] == m]
for rlabel in ["orig", "640", "896"]:
for ilabel in ["asdf", "qwer", "girl5"]:
c = next((x for x in mdata if x["res"] == rlabel and x["img"] == ilabel), None)
if not c:
continue
# 3 次结果图
run_cells = []
for i, r in enumerate(c["runs"]):
src = img_src(r["grown_path"]) if not r["error"] else None
if src:
run_cells.append(
f'<div class="run-cell"><div class="run-label">第{i+1}次 · {r["elapsed"]:.1f}s</div>'
f'{thumb(src, f"r{i+1}", "result-img")}</div>'
)
else:
run_cells.append(
f'<div class="run-cell"><div class="run-label">第{i+1}次 · 失败</div>'
f'<div class="na">⚠ {r["error"][:30] if r["error"] else ""}</div></div>'
)
orig = ORIG_SRC.get(c["img"])
rows_html.append(f'''
<div class="combo-row">
<div class="cell-model">{MODEL_LABEL.get(m, m)}<div class="cell-sub">res={RES_LABEL.get(rlabel, rlabel)}</div></div>
<div class="cell-img">{thumb(orig, "原图", "orig-img")}<div class="run-label">{ilabel}</div></div>
<div class="cell-runs">{"".join(run_cells)}</div>
</div>''')
# 模型对比汇总
summary_rows = []
for m in MODEL_ORDER:
s = stats.get(m, {"times": [], "peaks": []})
if s["times"]:
avg_t = sum(s["times"]) / len(s["times"])
max_p = max(s["peaks"]) / 1024
summary_rows.append(
f"<tr><td>{MODEL_LABEL.get(m,m)}</td><td>{avg_t:.1f}s</td>"
f"<td>{max_p:.1f} GB</td><td>{len(s['times'])} 成功</td></tr>"
)
html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flux 模型矩阵测试报告 — 接口2女性花瓣形</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f5f5; color: #333; padding: 20px; }}
h1 {{ font-size: 22px; margin-bottom: 4px; }}
.subtitle {{ color: #888; font-size: 13px; margin-bottom: 16px; }}
.summary {{ background: #fff; border-radius: 10px; padding: 16px 20px; margin-bottom: 20px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }}
.summary h2 {{ font-size: 16px; margin-bottom: 10px; }}
.summary table {{ border-collapse: collapse; width: 100%; font-size: 14px; }}
.summary th, .summary td {{ border: 1px solid #e5e7eb; padding: 8px 12px; text-align: left; }}
.summary th {{ background: #f9fafb; font-weight: 600; }}
.combo-row {{ display: flex; align-items: flex-start; gap: 12px; background: #fff; border-radius: 10px;
padding: 12px 16px; margin-bottom: 10px; box-shadow: 0 1px 3px rgba(0,0,0,.05); }}
.cell-model {{ min-width: 130px; font-weight: 700; font-size: 14px; padding-top: 6px; }}
.cell-sub {{ font-weight: 400; font-size: 12px; color: #6b7280; margin-top: 2px; }}
.cell-img {{ min-width: 160px; text-align: center; }}
.cell-runs {{ display: flex; gap: 10px; flex: 1; }}
.run-cell {{ text-align: center; }}
.run-label {{ font-size: 11px; color: #6b7280; margin-bottom: 4px; }}
img {{ border-radius: 6px; max-height: 200px; max-width: 100%; object-fit: contain; background: #f9fafb; }}
.orig-img {{ max-height: 180px; border: 2px solid #e5e7eb; }}
.result-img {{ max-height: 200px; }}
.na {{ color: #d1d5db; font-size: 12px; padding: 40px 20px; background: #f9fafb; border-radius: 6px; width: 150px; }}
</style>
</head>
<body>
<h1>💇 Flux 模型矩阵测试报告</h1>
<p class="subtitle">接口2女性 · 花瓣形发型 · 4模型 × 3分辨率 × 3图 × 3次 = 108 次 · RTX 3090 24GB</p>
<div class="summary">
<h2>📊 模型对比汇总</h2>
<table>
<tr><th>模型</th><th>平均耗时</th><th>峰值显存</th><th>成功次数</th></tr>
{"".join(summary_rows)}
</table>
</div>
<h2 style="font-size:16px;margin:24px 0 12px">🖼️ 各组合对比(每行:原图 + 3次生发结果)</h2>
{"".join(rows_html)}
</body>
</html>"""
with open(HTML, "w", encoding="utf-8") as f:
f.write(html)
print(f"✓ 报告已生成: {HTML} ({HTML.stat().st_size//1024} KB)")
if __name__ == "__main__":
main()
+18 -5
View File
@@ -202,7 +202,7 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
# --- 自适应尺寸:字号/线宽/虚线/箭头按短边缩放 ---
s = min(w, h)
font_size = max(8, round(s * 0.017)) # 字体更小
font_size = max(9, round(s * 0.020)) # 字号上调一档
line_w = max(1, round(s * 0.0022))
dash_len = max(4, round(s * 0.008))
gap_len = max(2, round(dash_len * 0.7)) # 虚线更稠密(间隙<划线)
@@ -212,7 +212,11 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
buf = np.zeros((h, w, 4), dtype=np.uint8)
if variant == "v6":
# 发际线弃用(hairline_discarded):保留头顶横线,去掉发际线横线,
# 也不标顶/上庭(缺发际线作边界,算不出)。横线 = 头顶/眉心/鼻翼下缘/下巴尖。
if getattr(measure_result, "hairline_discarded", False):
order = ["hair_top", "brow_center", "nose_bottom", "chin_tip"]
elif variant == "v6":
order = ["hairline", "brow_center", "nose_bottom", "chin_tip"]
else:
order = ["hair_top", "hairline", "brow_center", "nose_bottom", "chin_tip"]
@@ -239,7 +243,7 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
face_cx = (fx0 + fx1) / 2
over = max(6, round(s * 0.030)) # 线超出包围盒的长度(参考图风格)
face_half = (fx1 - fx0) / 2 + over # 横线超出最外侧竖线一点
# v6 竖线纵向范围 = 发际线→下巴尖(不超出);v1 = 头顶→下巴尖并两端超出一点
# 竖线纵向范围v6 = 发际线→下巴尖(不超出);v1(含发际线弃用)= 头顶→下巴尖并两端超出一点
v_top = fy0 if variant == "v6" else fy0 - over
v_bot = fy1 if variant == "v6" else fy1 + over
@@ -265,19 +269,28 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
draw.text((x, ys[i]), text, fill=LINE_COLOR, font=font, anchor="lm")
# --- 3b. 左侧四庭:名 + 数值两行(无 cm)+ 竖向虚线双箭头 ---
if variant == "v6":
# court_start:庭段在 order 里的起始索引。发际线弃用时 order 首位是头顶(无下界发际线,
# 顶/上庭不标),中庭从眉心开始 → 跳过 order[0]。
if getattr(measure_result, "hairline_discarded", False):
court_cm = [measure_result.middle_cm, measure_result.lower_cm]
court_name = ["中庭", "下庭"]
n_court = 2
court_start = 1
elif variant == "v6":
court_cm = [measure_result.upper_cm, measure_result.middle_cm, measure_result.lower_cm]
court_name = ["上庭", "中庭", "下庭"]
n_court = 3
court_start = 0
else:
court_cm = [measure_result.top_cm, measure_result.upper_cm,
measure_result.middle_cm, measure_result.lower_cm]
court_name = ["顶庭", "上庭", "中庭", "下庭"]
n_court = 4
court_start = 0
arrow_x = max(arrow_size + 1, fx0 - pad) # 竖箭头所在 x(脸左侧,贴近最左竖线)
court_total = sum(court_cm) or 1.0 # 各庭占比分母 = 四庭(v6 三庭)之和
for i in range(n_court):
y_a, y_b = ys[i], ys[i + 1]
y_a, y_b = ys[court_start + i], ys[court_start + i + 1]
# 竖向虚线双箭头,覆盖该庭高度(略收一点避免压到横线)
inset = min(arrow_size, (y_b - y_a) * 0.12)
draw_dashed_line_with_arrows(
+1 -1
View File
@@ -1107,7 +1107,7 @@ def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="seg
"hairline_id": hairline_id,
"blend_method": blend_method,
"hairline_push_cm": round(float(hairline_push_cm), 2),
"comfyui_prompt": comfyui_prompt or "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜",
"comfyui_prompt": comfyui_prompt or "填充遮罩区域的头发",
"beauty_alpha": beauty_alpha,
"px_per_cm": round(float(px_per_cm), 4),
"mask_pixels": mask_viz["mask_pixels"],
+67 -18
View File
@@ -12,7 +12,7 @@ from face_analysis.calibration import (
estimate_scale_factor, normalized_to_pixel, pixel_distance, _lm_list,
)
from face_analysis.face_mesh_landmarks import (
GLABELLA_9, GLABELLA_151, NOSE_BOTTOM, CHIN_TIP,
GLABELLA_9, NOSE_BOTTOM, CHIN_TIP,
LEFT_EYE_OUTER, LEFT_EYE_INNER, RIGHT_EYE_INNER, RIGHT_EYE_OUTER,
LEFT_CHEEK, RIGHT_CHEEK, LEFT_POSITION, RIGHT_POSITION,
)
@@ -24,10 +24,8 @@ _TOP_RATIO = 0.22 / 0.28 # 顶庭 ÷ 中庭(≈ 0.786)
def _brow_center(lm, w, h):
"""眉心 = 索引 9 / 151 中点"""
g9 = normalized_to_pixel(lm[GLABELLA_9], w, h)
g151 = normalized_to_pixel(lm[GLABELLA_151], w, h)
return (g9[0] + g151[0]) / 2, (g9[1] + g151[1]) / 2
"""眉心 = 索引 9(眉间上点)"""
return normalized_to_pixel(lm[GLABELLA_9], w, h)
def estimate_vertical_landmarks(landmarks, image_width, image_height):
@@ -144,9 +142,22 @@ def measure_seven_eyes(landmarks, image_width, image_height):
}
def pt_or_none(vertical, name):
"""vertical dict 的点 → {"x","y"},值为 None 时返回 None。"""
v = vertical.get(name)
if v is None:
return None
return {"x": int(round(v[0])), "y": int(round(v[1]))}
class MeasureResult:
"""测量结果,提供 to_response() 输出与接口文档同构的 data 字段。"""
# 发际线弃用阈值:发际线离头顶(顶庭)< 此值时判定分割不可靠,弃用发际线。
# hairline 与 hair_top 几乎重合(如稀疏头发中轴漏检只剩一小撮),说明发际线
# 定位无意义 → 顶/上庭置 null、标注图不画头顶/发际线。
HAIRLINE_DISCARD_TOP_CM = 0.7
def __init__(self, vertical, eyes, px_per_cm, hairline_source, head_pose,
landmarks=None, image_width=None, image_height=None):
self.vertical = vertical
@@ -164,6 +175,13 @@ class MeasureResult:
self.upper_cm = vertical["upper_court_px"] / px_per_cm
self.middle_cm = vertical["middle_court_px"] / px_per_cm
self.lower_cm = vertical["lower_court_px"] / px_per_cm
# 发际线弃用判定:顶庭(头顶→发际线)过小视为发际线贴近头顶、不可靠。
# 弃用时 hairline_source 改为 "discarded"face_total 只算中庭+下庭。
self.hairline_discarded = self.top_cm < self.HAIRLINE_DISCARD_TOP_CM
if self.hairline_discarded:
self.hairline_source = "discarded"
self.face_total_cm = self.middle_cm + self.lower_cm
else:
self.face_total_cm = self.top_cm + self.upper_cm + self.middle_cm + self.lower_cm
# 七眼厘米
@@ -172,14 +190,45 @@ class MeasureResult:
self.inter_eye_cm = eyes["inter_eye_distance_px"] / px_per_cm
def to_response(self):
# 发际线弃用:顶/上庭相关字段置 null(保留键),ratio 分母只算中下庭;
# landmarks.hair_top/hairline 置 null。否则按四庭正常输出。
if self.hairline_discarded:
base_px = (self.vertical["middle_court_px"] + self.vertical["lower_court_px"])
data = {
"face_total_height_cm": round(self.face_total_cm, 2),
"four_courts": {
"top_court_cm": None,
"upper_court_cm": None,
"middle_court_cm": round(self.middle_cm, 2),
"lower_court_cm": round(self.lower_cm, 2),
"ratios": {
"top_court": None,
"upper_court": None,
"middle_court": round(self.vertical["middle_court_px"] / base_px, 3),
"lower_court": round(self.vertical["lower_court_px"] / base_px, 3),
},
},
"seven_eyes": {
"eye_width_cm": round(self.eye_width_cm, 2),
"face_width_cm": round(self.face_width_cm, 2),
"inter_eye_distance_cm": round(self.inter_eye_cm, 2),
"ratios": {
"eye_width": round(self.eyes["eye_width_px"] / self.eyes["face_width_px"], 3),
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / self.eyes["face_width_px"], 3),
},
},
"landmarks": {
"hair_top": None,
"hairline": None,
"brow_center": pt_or_none(self.vertical, "brow_center"),
"nose_bottom": pt_or_none(self.vertical, "nose_bottom"),
"chin_tip": pt_or_none(self.vertical, "chin_tip"),
},
"hairline_source": self.hairline_source,
}
else:
total_px = (self.vertical["top_court_px"] + self.vertical["upper_court_px"]
+ self.vertical["middle_court_px"] + self.vertical["lower_court_px"])
fw_px = self.eyes["face_width_px"]
def pt(name):
x, y = self.vertical[name]
return {"x": int(round(x)), "y": int(round(y))}
data = {
"face_total_height_cm": round(self.face_total_cm, 2),
"four_courts": {
@@ -199,16 +248,16 @@ class MeasureResult:
"face_width_cm": round(self.face_width_cm, 2),
"inter_eye_distance_cm": round(self.inter_eye_cm, 2),
"ratios": {
"eye_width": round(self.eyes["eye_width_px"] / fw_px, 3),
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / fw_px, 3),
"eye_width": round(self.eyes["eye_width_px"] / self.eyes["face_width_px"], 3),
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / self.eyes["face_width_px"], 3),
},
},
"landmarks": {
"hair_top": pt("hair_top"),
"hairline": pt("hairline"),
"brow_center": pt("brow_center"),
"nose_bottom": pt("nose_bottom"),
"chin_tip": pt("chin_tip"),
"hair_top": pt_or_none(self.vertical, "hair_top"),
"hairline": pt_or_none(self.vertical, "hairline"),
"brow_center": pt_or_none(self.vertical, "brow_center"),
"nose_bottom": pt_or_none(self.vertical, "nose_bottom"),
"chin_tip": pt_or_none(self.vertical, "chin_tip"),
},
"hairline_source": self.hairline_source,
}
-339
View File
@@ -1,339 +0,0 @@
INFO: Started server process [26440]
INFO: Waiting for application startup.
2026-07-01 23:35:15 [INFO] gateway.config: 配置加载完成 | workers=['http://127.0.0.1:8187'] | public_base_url=http://127.0.0.1:8080 | hc_interval=8s | dispatch_timeout=600s | queue_wait=30s
2026-07-01 23:35:15 [INFO] gateway: 网关启动中... workers=['http://127.0.0.1:8187']
2026-07-01 23:35:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:35:16 [INFO] gateway.pool: Worker 池初始化完成 | 总数=1 | 在线=1
2026-07-01 23:35:16 [INFO] gateway: 标注图目录: /home/ubuntu/hair/static/annotations
2026-07-01 23:35:16 [INFO] gateway.pool: 健康检查循环启动 | 间隔=8s | 下线阈值=2 | 上线阈值=1 | workers=1
2026-07-01 23:35:16 [INFO] gateway: 清理任务启动 | 间隔=60min | 保留=24h | 目录=/home/ubuntu/hair/static/annotations
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit)
2026-07-01 23:35:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 127.0.0.1:35872 - "GET /gateway-health HTTP/1.1" 200 OK
2026-07-01 23:35:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:35:27 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/face/measure "HTTP/1.1 200 OK"
2026-07-01 23:35:27 [INFO] gateway.forward: base64→URL: annotated_image_base64 → http://127.0.0.1:8080/static/annotations/422b7786adc4486989d7f8770df40693.png (21864 bytes)
INFO: 127.0.0.1:57220 - "POST /api/v1/face/measure HTTP/1.1" 200 OK
2026-07-01 23:35:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 127.0.0.1:57232 - "GET /static/annotations/422b7786adc4486989d7f8770df40693.png HTTP/1.1" 200 OK
INFO: 127.0.0.1:57240 - "GET /gateway-health HTTP/1.1" 200 OK
2026-07-01 23:35:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:35:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:35:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:36:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:36:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:36:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:36:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:36:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:36:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:36:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:37:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:37:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:37:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:37:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:37:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:37:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:37:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:37:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:38:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:38:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:38:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:38:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:38:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:38:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:38:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:39:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:39:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:39:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:39:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:39:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:39:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:39:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:39:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:40:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:40:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:40:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:40:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:40:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:40:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:40:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:41:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:41:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:41:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:41:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:41:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:41:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:41:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:41:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:42:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:42:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:42:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:42:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:42:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:42:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:42:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:43:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:43:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:43:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:43:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:43:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:43:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:43:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:43:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:44:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:44:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:44:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:44:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:44:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:44:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:44:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:45:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:45:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:45:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:45:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:45:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:45:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:45:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:45:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:46:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:46:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:46:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:46:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:46:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:46:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:46:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:47:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:47:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:47:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:47:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:47:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:47:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:47:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:47:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:48:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:48:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:48:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:48:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:48:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:48:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:48:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:49:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:49:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:49:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:49:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:49:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:49:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:49:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:49:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:50:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:50:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:50:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:50:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:50:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:50:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:50:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:51:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:51:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:51:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:51:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:51:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:51:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:51:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:51:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:52:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 127.0.0.1:44470 - "GET /gateway-health HTTP/1.1" 200 OK
2026-07-01 23:52:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:52:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:52:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:52:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:52:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:52:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:53:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:53:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:53:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:53:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:53:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:53:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:53:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 111.192.98.24:6017 - "GET / HTTP/1.1" 200 OK
INFO: 111.192.98.24:6017 - "GET /favicon.ico HTTP/1.1" 404 Not Found
2026-07-01 23:53:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:54:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:54:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:54:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:54:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:54:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:54:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:54:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 127.0.0.1:39090 - "GET /docs HTTP/1.1" 200 OK
INFO: 127.0.0.1:39092 - "GET / HTTP/1.1" 200 OK
2026-07-01 23:55:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:55:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 127.0.0.1:56172 - "GET /static/test_interface1.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56174 - "GET /static/test_interface1.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56184 - "GET /static/test_interface2.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56196 - "GET /static/test_interface2.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56202 - "GET /static/test_interface3.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56206 - "GET /static/test_interface3.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56212 - "GET /static/test_interface4.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56214 - "GET /static/test_interface4.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56218 - "GET /static/test_interface5.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56230 - "GET /static/test_interface5.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56242 - "GET /static/test_interface6.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56256 - "GET /static/test_interface6.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56272 - "GET /static/test_interface7.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56278 - "GET /static/test_interface7.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56282 - "GET /static/integration.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56284 - "GET /static/integration.html HTTP/1.1" 200 OK
2026-07-01 23:55:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:55:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:55:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:55:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:55:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:55:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 111.192.98.24:6434 - "GET /static/test_interface1.html HTTP/1.1" 200 OK
2026-07-01 23:56:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:56:08 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/face/measure "HTTP/1.1 200 OK"
2026-07-01 23:56:08 [INFO] gateway.forward: base64→URL: annotated_image_base64 → http://127.0.0.1:8080/static/annotations/8a70c12c19ef4868af555ef84eaeafae.png (35965 bytes)
INFO: 111.192.98.24:6435 - "POST /api/v1/face/measure HTTP/1.1" 200 OK
2026-07-01 23:56:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:56:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:56:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:56:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:56:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:56:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:57:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:57:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:57:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:57:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:57:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:57:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:57:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:57:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:58:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: Shutting down
INFO: Waiting for application shutdown.
2026-07-01 23:58:08 [INFO] gateway: 网关关闭中...
2026-07-01 23:58:08 [INFO] gateway: 清理任务已停止
2026-07-01 23:58:08 [INFO] gateway.pool: 健康检查循环已停止
2026-07-01 23:58:08 [INFO] gateway.pool: Worker 池已关闭
2026-07-01 23:58:08 [INFO] gateway: 网关已关闭
INFO: Application shutdown complete.
INFO: Finished server process [26440]
INFO: Started server process [31898]
INFO: Waiting for application startup.
2026-07-01 23:58:10 [INFO] gateway.config: 配置加载完成 | workers=['http://127.0.0.1:8187'] | public_base_url=http://117.50.213.111:8080 | hc_interval=8s | dispatch_timeout=600s | queue_wait=30s
2026-07-01 23:58:10 [INFO] gateway: 网关启动中... workers=['http://127.0.0.1:8187']
2026-07-01 23:58:10 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:58:10 [INFO] gateway.pool: Worker 池初始化完成 | 总数=1 | 在线=1
2026-07-01 23:58:10 [INFO] gateway: 标注图目录: /home/ubuntu/hair/static/annotations
2026-07-01 23:58:10 [INFO] gateway.pool: 健康检查循环启动 | 间隔=8s | 下线阈值=2 | 上线阈值=1 | workers=1
2026-07-01 23:58:10 [INFO] gateway: 清理任务启动 | 间隔=60min | 保留=24h | 目录=/home/ubuntu/hair/static/annotations
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit)
2026-07-01 23:58:10 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 127.0.0.1:33732 - "GET /gateway-health HTTP/1.1" 200 OK
2026-07-01 23:58:18 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:58:26 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:58:28 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/face/measure "HTTP/1.1 200 OK"
2026-07-01 23:58:28 [INFO] gateway.forward: base64→URL: annotated_image_base64 → http://117.50.213.111:8080/static/annotations/478efab4566a46c3af0065ad0ffafb67.png (21864 bytes)
INFO: 127.0.0.1:32982 - "POST /api/v1/face/measure HTTP/1.1" 200 OK
INFO: 117.50.213.111:57344 - "GET /static/annotations/478efab4566a46c3af0065ad0ffafb67.png HTTP/1.1" 200 OK
2026-07-01 23:58:34 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:58:42 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:58:50 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:58:54 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/face/measure "HTTP/1.1 200 OK"
2026-07-01 23:58:54 [INFO] gateway.forward: base64→URL: annotated_image_base64 → http://117.50.213.111:8080/static/annotations/88d5eec74def44fdad5dd6f7b22e7be4.png (35965 bytes)
INFO: 111.192.98.24:7030 - "POST /api/v1/face/measure HTTP/1.1" 200 OK
INFO: 111.192.98.24:7030 - "GET /static/annotations/88d5eec74def44fdad5dd6f7b22e7be4.png HTTP/1.1" 200 OK
2026-07-01 23:58:58 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 111.192.98.24:7031 - "GET /static/test_interface2.html HTTP/1.1" 200 OK
2026-07-01 23:59:06 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:59:14 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:59:22 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:59:27 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/hair/grow "HTTP/1.1 200 OK"
2026-07-01 23:59:27 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/8f770fef8bc14827b419698c90ebd6fa.jpg (105970 bytes)
2026-07-01 23:59:27 [INFO] gateway.forward: base64→URL: grown_image_base64 → http://117.50.213.111:8080/static/annotations/6f592882d9c84c1b916ac327f4e0b2af.jpg (113320 bytes)
INFO: 111.192.98.24:7062 - "POST /api/v1/hair/grow HTTP/1.1" 200 OK
INFO: 111.192.98.24:7062 - "GET /static/annotations/8f770fef8bc14827b419698c90ebd6fa.jpg HTTP/1.1" 200 OK
INFO: 111.192.98.24:7166 - "GET /static/annotations/6f592882d9c84c1b916ac327f4e0b2af.jpg HTTP/1.1" 200 OK
2026-07-01 23:59:30 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:59:38 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:59:46 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:59:48 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/hair/grow "HTTP/1.1 200 OK"
2026-07-01 23:59:48 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/ed790d4888f742caa6625b3cf4704a77.jpg (105641 bytes)
2026-07-01 23:59:48 [INFO] gateway.forward: base64→URL: grown_image_base64 → http://117.50.213.111:8080/static/annotations/e4aed9d67e864d31909b30a58e08c16e.jpg (111495 bytes)
INFO: 111.192.98.24:5164 - "POST /api/v1/hair/grow HTTP/1.1" 200 OK
INFO: 111.192.98.24:5164 - "GET /static/annotations/ed790d4888f742caa6625b3cf4704a77.jpg HTTP/1.1" 200 OK
INFO: 111.192.98.24:5188 - "GET /static/annotations/e4aed9d67e864d31909b30a58e08c16e.jpg HTTP/1.1" 200 OK
2026-07-01 23:59:54 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 111.192.98.24:5213 - "GET /static/test_interface3.html HTTP/1.1" 200 OK
2026-07-02 00:00:02 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:00:10 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:00:16 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/hair/grow-b "HTTP/1.1 200 OK"
2026-07-02 00:00:16 [INFO] gateway.forward: base64→URL: hair_growth_image_base64 → http://117.50.213.111:8080/static/annotations/0891a4b9375e4f58814502e893b12bf6.jpg (152092 bytes)
INFO: 111.192.98.24:5212 - "POST /api/v1/hair/grow-b HTTP/1.1" 200 OK
INFO: 111.192.98.24:5212 - "GET /static/annotations/0891a4b9375e4f58814502e893b12bf6.jpg HTTP/1.1" 200 OK
2026-07-02 00:00:18 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 111.192.98.24:5332 - "GET /static/test_interface4.html HTTP/1.1" 200 OK
2026-07-02 00:00:26 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:00:34 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:00:39 [ERROR] gateway: 接口4 豆包调用失败
Traceback (most recent call last):
File "/home/ubuntu/hair/gateway/app.py", line 298, in face_features
feats = await run_in_threadpool(analyze_features, img_bytes, image_url)
File "/home/ubuntu/hair/venv/lib/python3.10/site-packages/starlette/concurrency.py", line 37, in run_in_threadpool
return await anyio.to_thread.run_sync(func)
File "/home/ubuntu/hair/venv/lib/python3.10/site-packages/anyio/to_thread.py", line 63, in run_sync
return await get_async_backend().run_sync_in_worker_thread(
File "/home/ubuntu/hair/venv/lib/python3.10/site-packages/anyio/_backends/_asyncio.py", line 2596, in run_sync_in_worker_thread
return await future
File "/home/ubuntu/hair/venv/lib/python3.10/site-packages/anyio/_backends/_asyncio.py", line 1029, in run
result = context.run(func, *args)
File "/home/ubuntu/hair/face_features.py", line 98, in analyze_features
resp = get_client().chat.completions.create(
File "/home/ubuntu/hair/face_features.py", line 65, in get_client
from volcenginesdkarkruntime import Ark
ModuleNotFoundError: No module named 'volcenginesdkarkruntime'
INFO: 111.192.98.24:5331 - "POST /api/v1/face/features HTTP/1.1" 200 OK
2026-07-02 00:00:42 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 111.192.98.24:5436 - "GET /static/test_interface5.html HTTP/1.1" 200 OK
2026-07-02 00:00:50 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:00:58 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:01:00 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/hairline/generate "HTTP/1.1 200 OK"
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/4516a9491a1c41f4af048020f6709870.jpg (105674 bytes)
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/ad51e082bb5540ea843259cdc1e76e6b.jpg (105970 bytes)
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/12a6f7745c9c461ca963f4d49c5267ef.jpg (105735 bytes)
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/316d8352062848e4b28337ef233d820e.jpg (105641 bytes)
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/2ae45e3579b0419ab8bff5e3129ab26e.jpg (105705 bytes)
INFO: 111.192.98.24:5435 - "POST /api/v1/hairline/generate HTTP/1.1" 200 OK
INFO: 111.192.98.24:5435 - "GET /static/annotations/4516a9491a1c41f4af048020f6709870.jpg HTTP/1.1" 200 OK
INFO: 111.192.98.24:5466 - "GET /static/annotations/ad51e082bb5540ea843259cdc1e76e6b.jpg HTTP/1.1" 200 OK
INFO: 111.192.98.24:5467 - "GET /static/annotations/12a6f7745c9c461ca963f4d49c5267ef.jpg HTTP/1.1" 200 OK
INFO: 111.192.98.24:5469 - "GET /static/annotations/316d8352062848e4b28337ef233d820e.jpg HTTP/1.1" 200 OK
INFO: 111.192.98.24:5471 - "GET /static/annotations/2ae45e3579b0419ab8bff5e3129ab26e.jpg HTTP/1.1" 200 OK
2026-07-02 00:01:06 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:01:14 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:01:22 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:01:30 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:01:38 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:01:46 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:01:54 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:02:02 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:02:10 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:02:18 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:02:26 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:02:34 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:02:42 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:02:50 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:02:58 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: Shutting down
INFO: Waiting for application shutdown.
2026-07-02 00:02:59 [INFO] gateway: 网关关闭中...
2026-07-02 00:02:59 [INFO] gateway: 清理任务已停止
2026-07-02 00:02:59 [INFO] gateway.pool: 健康检查循环已停止
2026-07-02 00:02:59 [INFO] gateway.pool: Worker 池已关闭
2026-07-02 00:02:59 [INFO] gateway: 网关已关闭
INFO: Application shutdown complete.
INFO: Finished server process [31898]
+3 -3
View File
@@ -170,10 +170,10 @@
},
"16": {
"inputs": {
"unet_name": "flux2.0/flux-2-klein-9b-fp8.safetensors",
"unet_name": "flux-2-klein-9b-Q4_K_M.gguf",
"weight_dtype": "fp8_e4m3fn"
},
"class_type": "UNETLoader",
"class_type": "UnetLoaderGGUF",
"_meta": {
"title": "UNet加载器"
}
@@ -410,7 +410,7 @@
},
"60": {
"inputs": {
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
"text": "填充遮罩区域的头发"
},
"class_type": "JjkText",
"_meta": {
+34 -1
View File
@@ -30,6 +30,19 @@ _REPO = os.path.dirname(os.path.dirname(__file__))
_INPUT_NODE = "26" # LoadImage:外部输入图(含 alpha 遮罩)
_SEED_NODE = "6" # RandomNoise
_PROMPT_NODE = "60" # JjkText:提示词
_UNET_NODE = "16" # UNETLoader / UnetLoaderGGUFFlux 模型加载
_CLIP_NODE = "61" # CLIPLoaderqwen 文本编码器
# Flux 模型 → 配套文本编码器映射。切换 unet 时自动同步编码器,避免维度不匹配。
# 规则:4b 系列配 qwen_3_4b9b 系列(fp8/GGUF)配 qwen_3_8b_fp8mixed。
def _clip_for_unet(unet_name: str) -> str | None:
"""根据 unet 文件名推断配套的文本编码器文件名;无法推断返回 None。"""
low = unet_name.lower()
if "4b" in low and "9b" not in low:
return "qwen_3_4b.safetensors"
if "9b" in low:
return "qwen_3_8b_fp8mixed.safetensors"
return None
_wf_cache: dict[str, dict] = {} # path → workflow JSON
_wf_output_node: dict[str, str] = {} # path → SaveImage 节点 ID
@@ -86,13 +99,18 @@ def _get_output_node(workflow_path: str | None = None) -> str:
def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = None,
workflow_path: str | None = None, front: bool = False) -> bytes:
workflow_path: str | None = None, front: bool = False,
unet_name: str | None = None) -> bytes:
"""提交一次生发任务,返回输出 PNG 字节。失败抛异常。
prompt None 时替换工作流节点60(JjkText)的文本None 时用工作流内置默认提示词
workflow_path工作流 JSON 路径None 则用默认 add_hair.json
frontTrue 时任务插到 ComfyUI 队列最前server "front" 字段队列号取负
接口2 对时延敏感用 True避免排在接口3/5 的批量任务后面其余接口保持 False
unet_name None 时改写工作流里的模型加载节点节点16动态切换 Flux 模型
.safetensors 保持 UNETLoader 节点类型不变只替换 unet_name
.gguf 自动把节点类型改成 UnetLoaderGGUF需装 ComfyUI-GGUF 插件
None 时用工作流内置默认模型
"""
path = workflow_path or _WORKFLOW_DEFAULT
output_node = _get_output_node(path)
@@ -120,6 +138,21 @@ def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = Non
wf[_SEED_NODE]["inputs"]["noise_seed"] = random.randint(0, 2**63 - 1)
if prompt is not None:
wf[_PROMPT_NODE]["inputs"]["text"] = prompt
if unet_name is not None:
node = wf.get(_UNET_NODE)
if node is not None:
# .gguf 需切换到 ComfyUI-GGUF 插件的 UnetLoaderGGUF 节点;
# .safetensors/.ckpt 保持原 UNETLoader 节点类型不变
if unet_name.lower().endswith(".gguf"):
node["class_type"] = "UnetLoaderGGUF"
else:
node["class_type"] = "UNETLoader"
node["inputs"]["unet_name"] = unet_name
# 同步切换配套文本编码器(4b→qwen_3_4b, 9b→qwen_3_8b),避免维度不匹配
clip_node = wf.get(_CLIP_NODE)
clip_name = _clip_for_unet(unet_name)
if clip_node is not None and clip_name is not None:
clip_node["inputs"]["clip_name"] = clip_name
# 诊断:落盘实际提交的工作流 + 输入图,便于和手动 ComfyUI 跑的对比
try:
+6 -4
View File
@@ -16,7 +16,7 @@ from . import comfyui
logger = logging.getLogger("hair.worker")
_DEFAULT_PROMPT = "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
_DEFAULT_PROMPT = "填充遮罩区域的头发"
_REPO = os.path.dirname(os.path.dirname(__file__))
_REPAINT_WORKFLOW = os.path.join(_REPO, "0716add-hair-api.json")
@@ -56,15 +56,16 @@ def _process_mask_to_rgba(image_bytes: bytes, mask_bytes: bytes) -> bytes:
def run_redraw(image_bytes: bytes, mask_bytes: bytes,
prompt: str | None = None, timeout: float = 300.0,
front: bool = False) -> bytes:
front: bool = False, unet_name: str | None = None) -> bytes:
"""直接调 ComfyUI 重绘 — 替代 local_test /api/generate。
Args:
image_bytes: 人物图片字节JPG/PNG
mask_bytes: 遮罩图片字节支持红//alpha 遮罩格式
prompt: 提示词None 用默认 "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
prompt: 提示词None 用默认 "填充遮罩区域的头发"
timeout: ComfyUI 超时秒数
front: True 时任务插到 ComfyUI 队列最前接口2 时延敏感路径用
unet_name: None 时切换 Flux 模型 flux-2-klein-9b-Q5_K_M.ggufNone 用工作流默认
Returns:
重绘后的 PNG 图片字节
@@ -75,4 +76,5 @@ def run_redraw(image_bytes: bytes, mask_bytes: bytes,
"""
rgba_png = _process_mask_to_rgba(image_bytes, mask_bytes)
return comfyui.run(rgba_png, timeout=timeout, prompt=prompt,
workflow_path=_REPAINT_WORKFLOW, front=front)
workflow_path=_REPAINT_WORKFLOW, front=front,
unet_name=unet_name)
+68 -35
View File
@@ -46,29 +46,35 @@ _BLACK_TEXTURE_DIR = os.path.join(_REPO, "hairline_texture_black")
# 关键:ComfyUI 单卡显存装不下 Flux(7.7G)+qwen CLIP(3.9G) 同驻,靠缓存 CLIP 文本条件避免重载。
# prompt 不同会使缓存失效 → 重载 CLIP 并挤出 Flux(每次 +4s)。三接口用同一字符串即可全程命中。
# 与 app.py 接口2/接口3 的默认 prompt 保持一致;可用 REDRAW_PROMPT 覆盖。
_REDRAW_PROMPT = os.getenv("REDRAW_PROMPT", "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
_REDRAW_PROMPT = os.getenv("REDRAW_PROMPT", "填充遮罩区域的头发")
# 接口2 女重绘整条管线(swapHair + ComfyUI)送模型前限边。真实照片常达 1257x1495:
# 全分辨率 ComfyUI 重绘要 13~21s 且激活显存把模型挤出。女性路径含 swapHair(SD WebUI ~5.3s
# 固定地板) + ComfyUI 两段串行。1024 档画质更好但部分大图会踩 12s 线,
# 默认压到 896 兜底(ComfyUI ~4s,女性总耗时 9~11s);追画质可设 REDRAW_MAX_SIDE=1024
_REDRAW_MAX_SIDE = int(os.getenv("REDRAW_MAX_SIDE", "896"))
# 全分辨率 ComfyUI 重绘要 13~21s 且激活显存把模型挤出。
# 策略:输入图长边 > REDRAW_MAX_SIDE 才等比缩到该长边;≤ 时原图分辨率直送(不放大)。
# 默认 1024:大于 1024 的图压到 1024(画质/速度均衡),≤1024 的小图保持原分辨率重绘
# 可用 REDRAW_MAX_SIDE 覆盖;0=永不缩图(原图直送)。
_REDRAW_MAX_SIDE = int(os.getenv("REDRAW_MAX_SIDE", "1024"))
def _call_local_redraw(image_png_bytes, mask_png_bytes, timeout=300.0):
def _call_local_redraw(image_png_bytes, mask_png_bytes, timeout=300.0,
max_side=None, unet_name=None):
"""直接调 ComfyUI 重绘(替代原 local_test HTTP 服务)。
final + 纯红遮罩 PNG返回重绘后的 PNG bytes
失败抛异常调用方负责 try/except 跳过
max_side ComfyUI 前长边压到多少像素None 用全局默认 _REDRAW_MAX_SIDE
unet_name None 时切换 Flux 模型None 用工作流内置默认
"""
from .redraw import run_redraw
eff_side = _REDRAW_MAX_SIDE if max_side is None else max_side
img = cv2.imdecode(np.frombuffer(image_png_bytes, np.uint8), cv2.IMREAD_UNCHANGED)
scale = 1.0
orig_w = orig_h = 0
if img is not None:
orig_h, orig_w = img.shape[:2]
m = max(orig_h, orig_w)
if _REDRAW_MAX_SIDE > 0 and m > _REDRAW_MAX_SIDE:
scale = _REDRAW_MAX_SIDE / float(m)
if eff_side > 0 and m > eff_side:
scale = eff_side / float(m)
nw, nh = max(1, round(orig_w * scale)), max(1, round(orig_h * scale))
msk = cv2.imdecode(np.frombuffer(mask_png_bytes, np.uint8), cv2.IMREAD_UNCHANGED)
img_s = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_AREA)
@@ -76,10 +82,10 @@ def _call_local_redraw(image_png_bytes, mask_png_bytes, timeout=300.0):
image_png_bytes = cv2.imencode(".png", img_s)[1].tobytes()
mask_png_bytes = cv2.imencode(".png", msk_s)[1].tobytes()
logger.info("接口2女 缩图送 Comfy: %dx%d%dx%d (max_side=%d)",
orig_w, orig_h, nw, nh, _REDRAW_MAX_SIDE)
orig_w, orig_h, nw, nh, eff_side)
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前,避免排在接口3/5 的批量任务后面
out = run_redraw(image_png_bytes, mask_png_bytes, timeout=timeout,
prompt=_REDRAW_PROMPT, front=True)
prompt=_REDRAW_PROMPT, front=True, unet_name=unet_name)
if scale < 1.0 and out:
out = _upscale_png_to(out, orig_w, orig_h)
return out
@@ -200,7 +206,8 @@ def generate_previews(image_bgr: np.ndarray, gender: str):
def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = True,
prompt: str = None, hair_styles: list[int] | None = None,
workflow_path: str | None = None):
workflow_path: str | None = None,
unet_name: str | None = None):
"""指定发际线类型:发际线透明叠图(白线 RGBA) + 生发图(ComfyUI)。
hair_styles1-indexed 列表指定生成哪几张发际线按贴图排序female: 1..5male: 1..4
@@ -236,7 +243,8 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
compose_comfy_rgba(img_s, msk_s).save(buf, format="PNG", compress_level=1)
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前
shared_grown = comfyui.run(buf.getvalue(), prompt=prompt,
workflow_path=workflow_path, front=True)
workflow_path=workflow_path, front=True,
unet_name=unet_name)
if gsc < 1.0 and shared_grown:
shared_grown = _upscale_png_to(shared_grown, w, h)
except Exception as e: # noqa: BLE001
@@ -261,7 +269,8 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
compose_comfy_rgba(m_s, msk_s).save(buf, format="PNG", compress_level=1)
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前
grown_png = comfyui.run(buf.getvalue(), prompt=prompt,
workflow_path=workflow_path, front=True)
workflow_path=workflow_path, front=True,
unet_name=unet_name)
if gsc < 1.0 and grown_png:
grown_png = _upscale_png_to(grown_png, w, h)
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
@@ -273,7 +282,9 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | None,
redraw_defaults: dict):
redraw_defaults: dict,
redraw_max_side: int | None = None,
unet_name: str | None = None):
"""接口2 女性专用:发际线透明叠图(同 generate_grow_results+ 换发型重绘图。
grown 图来源新流程对每个选中发型把 female key 映射到 change_hair chang_* hair_id
@@ -307,16 +318,17 @@ def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | N
# 重绘管线(swapHair + ComfyUI)统一降分辨率:真实照片 swap(SD WebUI)~5s、blend、ComfyUI
# 均随分辨率线性下降。overlay 预览仍用全分辨率;grown_png 最后放大回原尺寸。
eff_side = _REDRAW_MAX_SIDE if redraw_max_side is None else redraw_max_side
redraw_img = image_bgr
hair_mask_redraw = hair_mask_reuse
if _REDRAW_MAX_SIDE > 0 and max(h, w) > _REDRAW_MAX_SIDE:
redraw_img, _rs = _downscale_max_side(image_bgr, _REDRAW_MAX_SIDE)
if eff_side > 0 and max(h, w) > eff_side:
redraw_img, _rs = _downscale_max_side(image_bgr, eff_side)
_nh, _nw = redraw_img.shape[:2]
if hair_mask_redraw is not None:
hair_mask_redraw = cv2.resize(hair_mask_reuse.astype(np.uint8), (_nw, _nh),
interpolation=cv2.INTER_NEAREST).astype(bool)
logger.info("接口2女 管线降分辨率: %dx%d%dx%d (max_side=%d)",
w, h, _nw, _nh, _REDRAW_MAX_SIDE)
w, h, _nw, _nh, eff_side)
for order, (key, white_path) in items:
white = load_texture_rgba(white_path)
@@ -349,7 +361,9 @@ def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | N
mask_bytes = base64.b64decode(mask_b64)
# 后端直接调 ComfyUI 重绘,返回重绘后的 PNG
_tr0 = _t.perf_counter()
grown_png = _call_local_redraw(final_bytes, mask_bytes)
grown_png = _call_local_redraw(final_bytes, mask_bytes,
max_side=redraw_max_side,
unet_name=unet_name)
_tr1 = _t.perf_counter()
_tm = data.get("timings_ms") or {}
logger.info("接口2女 分段计时 type=%s: swapHair管线=%.2fs (mask=%dms swap=%dms blend=%dms), ComfyUI重绘=%.2fs",
@@ -397,14 +411,22 @@ def _grow_from_texture(image_bgr: np.ndarray, ctx: dict, white_path: str | None,
def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
hair_styles: list[int], use_mask: bool = True,
prompt: str | None = None,
generate_grow_image: bool = True):
generate_grow_image: bool = True,
redraw_max_side: int | None = None,
unet_name: str | None = None,
v2_defaults: dict | None = None):
"""接口5:对选中发型返回 middle/high/low 三档发际线透明叠图 + 生发图(同接口2)。
入参同接口2先选 gender再多选 hair_styles必填1-indexed 按贴图排序
每个选中发型返回三档叠图middle/high/lowRGBA 透明层只含发际线曲线与一张生发图
三档贴图同名生发黑模板固定取自 hairline_texture_black/middle故生发目标固定 middle
use_mask/prompt同接口2 的生发参数
generate_grow_image默认 True是否生成生发图ComfyUI最耗时False 时跳过生发
生发机制同接口2按性别分流
- femalegenerate_grow_results_swapswapHair + Flux-2 整帧重绘
- malegenerate_grow_resultsComfyUI add_hair inpaint
redraw_max_side / unet_name / v2_defaultsfemale 路径参数同接口2
male 路径仅用 unet_nameredraw_max_side/v2_defaults male 无意义忽略
use_mask/prompt male 路径生效同接口2 male
generate_grow_image默认 True是否生成生发图最耗时False 时跳过生发
各发型 grown_png 恒为 None可大幅降低耗时仅留三档发际线叠图与中心点
Returns: {"images":[{hairline_type,order,overlays:{middle,high,low}((H,W,4) RGBA 透明层),grown_png}],
"best_centers":{"middle":(x,y),"high":(x,y),"low":(x,y)}}无人脸 None
@@ -426,11 +448,27 @@ def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
# 三档贴图表(同性别、同 key 顺序,因三个文件夹同名)
tex_by_level = {lv: get_texture_map(lv)[gender] for lv in _TEXTURE_DIRS}
# use_mask=False:干净原图+空遮罩与贴图无关,只跑一次 ComfyUI,选中项复用
# generate_grow_image=False:完全跳过生发(最耗时),grown_png 恒为 None
shared_grown = None
if generate_grow_image and not use_mask:
shared_grown = _grow_from_texture(image_bgr, ctx, None, use_mask=False, prompt=prompt)
# 生发图(同接口2,按性别分流):一次性算出所有选中发型的生发图,按 order 对应回叠图。
# female→generate_grow_results_swapswapHair+Flux-2 整帧重绘);
# male→generate_grow_resultsComfyUI add_hair inpaint)。
# generate_grow_image=False 时跳过,grown_by_order 为空 dict(各发型 grown_png 恒 None)。
grown_by_order: dict[int, bytes | None] = {}
if generate_grow_image:
try:
if gender == "female":
items = generate_grow_results_swap(
image_bgr, hair_styles, v2_defaults or {},
redraw_max_side=redraw_max_side, unet_name=unet_name)
else:
items = generate_grow_results(
image_bgr, gender, use_mask, prompt, hair_styles,
unet_name=unet_name)
if items is None:
return None # 无人脸(同接口2 的 None 语义)
for it in items:
grown_by_order[it["order"]] = it.get("grown_png")
except Exception as e: # noqa: BLE001 整批生发失败不拖垮叠图主结果
logger.warning("接口5 生发批量失败(gender=%s):%s", gender, e)
def _center_of(overlay):
"""从某档发际线透明叠图取面部中轴处的发际线中点 (x,y),无像素返回 None。"""
@@ -443,18 +481,13 @@ def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
images, best_centers = [], None
for s in hair_styles: # s = 1-indexed 发型序号
key, mid_path = tex_by_level["middle"][s - 1]
key, _mid_path = tex_by_level["middle"][s - 1]
overlays = {}
for lv in _TEXTURE_DIRS:
white = load_texture_rgba(tex_by_level[lv][s - 1][1])
overlays[lv] = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)
# 生发:固定 middle 黑模板generate_grow_image=False 时跳过,恒 None
if not generate_grow_image:
grown_png = None
elif not use_mask:
grown_png = shared_grown
else:
grown_png = _grow_from_texture(image_bgr, ctx, mid_path, use_mask=True, prompt=prompt)
# 生发图:从按性别算好的结果里按 order 取generate_grow_image=False 时缺省 None
grown_png = grown_by_order.get(s)
images.append({"hairline_type": key, "order": s,
"overlays": overlays, "grown_png": grown_png})
# best_centers:首个选中发型三档(middle/high/low)发际线中点
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

@@ -0,0 +1,75 @@
=== 接口2 female 批量对比测试 ===
矩阵: 4图 × 5发型 × 2档 = 40 次
已跳过 0 个已完成项
[1/40] ▶ girl2_ellipse_default896 (side=None)... ✅ 10.8s 出图 1082x1081 (204589B)
[2/40] ▶ girl2_ellipse_origin0 (side=0)... ✅ 13.3s 出图 1088x1088 (232252B)
[3/40] ▶ girl2_flower_default896 (side=None)... ✅ 11.0s 出图 1082x1081 (210875B)
[4/40] ▶ girl2_flower_origin0 (side=0)... ✅ 12.5s 出图 1088x1088 (236073B)
[5/40] ▶ girl2_heart_default896 (side=None)... ✅ 11.9s 出图 1082x1081 (198911B)
[6/40] ▶ girl2_heart_origin0 (side=0)... ✅ 12.2s 出图 1088x1088 (249893B)
[7/40] ▶ girl2_straight_default896 (side=None)... ✅ 12.1s 出图 1082x1081 (211803B)
[8/40] ▶ girl2_straight_origin0 (side=0)... ✅ 12.3s 出图 1088x1088 (235377B)
[9/40] ▶ girl2_wave_default896 (side=None)... ✅ 12.7s 出图 1082x1081 (207736B)
[10/40] ▶ girl2_wave_origin0 (side=0)... ✅ 11.9s 出图 1088x1088 (238081B)
[11/40] ▶ girl5_ellipse_default896 (side=None)... ✅ 9.6s 出图 768x752 (105867B)
[12/40] ▶ girl5_ellipse_origin0 (side=0)... ✅ 7.3s 出图 768x752 (112963B)
[13/40] ▶ girl5_flower_default896 (side=None)... ✅ 8.0s 出图 768x752 (114378B)
[14/40] ▶ girl5_flower_origin0 (side=0)... ✅ 7.2s 出图 768x752 (108495B)
[15/40] ▶ girl5_heart_default896 (side=None)... ✅ 8.1s 出图 768x752 (113874B)
[16/40] ▶ girl5_heart_origin0 (side=0)... ✅ 7.6s 出图 768x752 (103160B)
[17/40] ▶ girl5_straight_default896 (side=None)... ✅ 8.4s 出图 768x752 (104492B)
[18/40] ▶ girl5_straight_origin0 (side=0)... ✅ 7.2s 出图 768x752 (100941B)
[19/40] ▶ girl5_wave_default896 (side=None)... ✅ 8.4s 出图 768x752 (120122B)
[20/40] ▶ girl5_wave_origin0 (side=0)... ✅ 7.7s 出图 768x752 (113282B)
[21/40] ▶ qwer_ellipse_default896 (side=None)... ✅ 10.2s 出图 1288x1678 (261812B)
[22/40] ▶ qwer_ellipse_origin0 (side=0)... ✅ 30.0s 出图 1280x1680 (331013B)
[23/40] ▶ qwer_flower_default896 (side=None)... ✅ 10.2s 出图 1288x1678 (312834B)
[24/40] ▶ qwer_flower_origin0 (side=0)... ✅ 25.0s 出图 1280x1680 (328605B)
[25/40] ▶ qwer_heart_default896 (side=None)... ✅ 11.1s 出图 1288x1678 (331337B)
[26/40] ▶ qwer_heart_origin0 (side=0)... ✅ 25.0s 出图 1280x1680 (355982B)
[27/40] ▶ qwer_straight_default896 (side=None)... ✅ 10.7s 出图 1288x1678 (281665B)
[28/40] ▶ qwer_straight_origin0 (side=0)... ✅ 23.4s 出图 1280x1680 (322183B)
[29/40] ▶ qwer_wave_default896 (side=None)... ✅ 11.1s 出图 1288x1678 (289322B)
[30/40] ▶ qwer_wave_origin0 (side=0)... ✅ 25.8s 出图 1280x1680 (351719B)
[31/40] ▶ asdf_ellipse_default896 (side=None)... ✅ 11.2s 出图 1254x1666 (285362B)
[32/40] ▶ asdf_ellipse_origin0 (side=0)... ✅ 25.7s 出图 1248x1664 (286768B)
[33/40] ▶ asdf_flower_default896 (side=None)... ✅ 10.7s 出图 1254x1666 (266946B)
[34/40] ▶ asdf_flower_origin0 (side=0)... ✅ 22.9s 出图 1248x1664 (353668B)
[35/40] ▶ asdf_heart_default896 (side=None)... ✅ 10.4s 出图 1254x1666 (271975B)
[36/40] ▶ asdf_heart_origin0 (side=0)... ✅ 22.9s 出图 1248x1664 (291983B)
[37/40] ▶ asdf_straight_default896 (side=None)... ✅ 9.8s 出图 1254x1666 (258665B)
[38/40] ▶ asdf_straight_origin0 (side=0)... ✅ 24.0s 出图 1248x1664 (279883B)
[39/40] ▶ asdf_wave_default896 (side=None)... ✅ 11.9s 出图 1254x1666 (286515B)
[40/40] ▶ asdf_wave_origin0 (side=0)... ✅ 24.3s 出图 1248x1664 (295694B)
======================================================================
汇总报告
======================================================================
--- 速度对比(秒,✓=成功 ✗=失败)---
图 发型 默认896 原图0 差值
girl2 ellipse 10.8✓ 13.3✓ +2.5
girl2 flower 11.0✓ 12.5✓ +1.5
girl2 heart 11.9✓ 12.2✓ +0.3
girl2 straight 12.1✓ 12.3✓ +0.2
girl2 wave 12.7✓ 11.9✓ -0.8
girl5 ellipse 9.6✓ 7.3✓ -2.3
girl5 flower 8.0✓ 7.2✓ -0.8
girl5 heart 8.1✓ 7.6✓ -0.5
girl5 straight 8.4✓ 7.2✓ -1.2
girl5 wave 8.4✓ 7.7✓ -0.7
qwer ellipse 10.2✓ 30.0✓ +19.8
qwer flower 10.2✓ 25.0✓ +14.8
qwer heart 11.1✓ 25.0✓ +13.9
qwer straight 10.7✓ 23.4✓ +12.7
qwer wave 11.1✓ 25.8✓ +14.7
asdf ellipse 11.2✓ 25.7✓ +14.5
asdf flower 10.7✓ 22.9✓ +12.2
asdf heart 10.4✓ 22.9✓ +12.5
asdf straight 9.8✓ 24.0✓ +14.2
asdf wave 11.9✓ 24.3✓ +12.4
结果图: /home/ubuntu/hair/image/compare_test/out/
CSV报告: /home/ubuntu/hair/image/compare_test/out/report.csv
JSON明细: /home/ubuntu/hair/image/compare_test/out/report.json
@@ -0,0 +1,41 @@
img,style,side,ok,elapsed_s,out_w,out_h,err
girl2,ellipse,default896,True,10.8,1082,1081,
girl2,ellipse,origin0,True,13.3,1088,1088,
girl2,flower,default896,True,11.0,1082,1081,
girl2,flower,origin0,True,12.5,1088,1088,
girl2,heart,default896,True,11.9,1082,1081,
girl2,heart,origin0,True,12.2,1088,1088,
girl2,straight,default896,True,12.1,1082,1081,
girl2,straight,origin0,True,12.3,1088,1088,
girl2,wave,default896,True,12.7,1082,1081,
girl2,wave,origin0,True,11.9,1088,1088,
girl5,ellipse,default896,True,9.6,768,752,
girl5,ellipse,origin0,True,7.3,768,752,
girl5,flower,default896,True,8.0,768,752,
girl5,flower,origin0,True,7.2,768,752,
girl5,heart,default896,True,8.1,768,752,
girl5,heart,origin0,True,7.6,768,752,
girl5,straight,default896,True,8.4,768,752,
girl5,straight,origin0,True,7.2,768,752,
girl5,wave,default896,True,8.4,768,752,
girl5,wave,origin0,True,7.7,768,752,
qwer,ellipse,default896,True,10.2,1288,1678,
qwer,ellipse,origin0,True,30.0,1280,1680,
qwer,flower,default896,True,10.2,1288,1678,
qwer,flower,origin0,True,25.0,1280,1680,
qwer,heart,default896,True,11.1,1288,1678,
qwer,heart,origin0,True,25.0,1280,1680,
qwer,straight,default896,True,10.7,1288,1678,
qwer,straight,origin0,True,23.4,1280,1680,
qwer,wave,default896,True,11.1,1288,1678,
qwer,wave,origin0,True,25.8,1280,1680,
asdf,ellipse,default896,True,11.2,1254,1666,
asdf,ellipse,origin0,True,25.7,1248,1664,
asdf,flower,default896,True,10.7,1254,1666,
asdf,flower,origin0,True,22.9,1248,1664,
asdf,heart,default896,True,10.4,1254,1666,
asdf,heart,origin0,True,22.9,1248,1664,
asdf,straight,default896,True,9.8,1254,1666,
asdf,straight,origin0,True,24.0,1248,1664,
asdf,wave,default896,True,11.9,1254,1666,
asdf,wave,origin0,True,24.3,1248,1664,
1 img style side ok elapsed_s out_w out_h err
2 girl2 ellipse default896 True 10.8 1082 1081
3 girl2 ellipse origin0 True 13.3 1088 1088
4 girl2 flower default896 True 11.0 1082 1081
5 girl2 flower origin0 True 12.5 1088 1088
6 girl2 heart default896 True 11.9 1082 1081
7 girl2 heart origin0 True 12.2 1088 1088
8 girl2 straight default896 True 12.1 1082 1081
9 girl2 straight origin0 True 12.3 1088 1088
10 girl2 wave default896 True 12.7 1082 1081
11 girl2 wave origin0 True 11.9 1088 1088
12 girl5 ellipse default896 True 9.6 768 752
13 girl5 ellipse origin0 True 7.3 768 752
14 girl5 flower default896 True 8.0 768 752
15 girl5 flower origin0 True 7.2 768 752
16 girl5 heart default896 True 8.1 768 752
17 girl5 heart origin0 True 7.6 768 752
18 girl5 straight default896 True 8.4 768 752
19 girl5 straight origin0 True 7.2 768 752
20 girl5 wave default896 True 8.4 768 752
21 girl5 wave origin0 True 7.7 768 752
22 qwer ellipse default896 True 10.2 1288 1678
23 qwer ellipse origin0 True 30.0 1280 1680
24 qwer flower default896 True 10.2 1288 1678
25 qwer flower origin0 True 25.0 1280 1680
26 qwer heart default896 True 11.1 1288 1678
27 qwer heart origin0 True 25.0 1280 1680
28 qwer straight default896 True 10.7 1288 1678
29 qwer straight origin0 True 23.4 1280 1680
30 qwer wave default896 True 11.1 1288 1678
31 qwer wave origin0 True 25.8 1280 1680
32 asdf ellipse default896 True 11.2 1254 1666
33 asdf ellipse origin0 True 25.7 1248 1664
34 asdf flower default896 True 10.7 1254 1666
35 asdf flower origin0 True 22.9 1248 1664
36 asdf heart default896 True 10.4 1254 1666
37 asdf heart origin0 True 22.9 1248 1664
38 asdf straight default896 True 9.8 1254 1666
39 asdf straight origin0 True 24.0 1248 1664
40 asdf wave default896 True 11.9 1254 1666
41 asdf wave origin0 True 24.3 1248 1664
File diff suppressed because one or more lines are too long
@@ -0,0 +1,562 @@
[
{
"key": "girl2_ellipse_default896",
"img": "girl2",
"style": "ellipse",
"style_idx": 1,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 10.8,
"err": "",
"out_w": 1082,
"out_h": 1081,
"bytes": 204589
},
{
"key": "girl2_ellipse_origin0",
"img": "girl2",
"style": "ellipse",
"style_idx": 1,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 13.3,
"err": "",
"out_w": 1088,
"out_h": 1088,
"bytes": 232252
},
{
"key": "girl2_flower_default896",
"img": "girl2",
"style": "flower",
"style_idx": 2,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 11.0,
"err": "",
"out_w": 1082,
"out_h": 1081,
"bytes": 210875
},
{
"key": "girl2_flower_origin0",
"img": "girl2",
"style": "flower",
"style_idx": 2,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 12.5,
"err": "",
"out_w": 1088,
"out_h": 1088,
"bytes": 236073
},
{
"key": "girl2_heart_default896",
"img": "girl2",
"style": "heart",
"style_idx": 3,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 11.9,
"err": "",
"out_w": 1082,
"out_h": 1081,
"bytes": 198911
},
{
"key": "girl2_heart_origin0",
"img": "girl2",
"style": "heart",
"style_idx": 3,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 12.2,
"err": "",
"out_w": 1088,
"out_h": 1088,
"bytes": 249893
},
{
"key": "girl2_straight_default896",
"img": "girl2",
"style": "straight",
"style_idx": 4,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 12.1,
"err": "",
"out_w": 1082,
"out_h": 1081,
"bytes": 211803
},
{
"key": "girl2_straight_origin0",
"img": "girl2",
"style": "straight",
"style_idx": 4,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 12.3,
"err": "",
"out_w": 1088,
"out_h": 1088,
"bytes": 235377
},
{
"key": "girl2_wave_default896",
"img": "girl2",
"style": "wave",
"style_idx": 5,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 12.7,
"err": "",
"out_w": 1082,
"out_h": 1081,
"bytes": 207736
},
{
"key": "girl2_wave_origin0",
"img": "girl2",
"style": "wave",
"style_idx": 5,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 11.9,
"err": "",
"out_w": 1088,
"out_h": 1088,
"bytes": 238081
},
{
"key": "girl5_ellipse_default896",
"img": "girl5",
"style": "ellipse",
"style_idx": 1,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 9.6,
"err": "",
"out_w": 768,
"out_h": 752,
"bytes": 105867
},
{
"key": "girl5_ellipse_origin0",
"img": "girl5",
"style": "ellipse",
"style_idx": 1,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 7.3,
"err": "",
"out_w": 768,
"out_h": 752,
"bytes": 112963
},
{
"key": "girl5_flower_default896",
"img": "girl5",
"style": "flower",
"style_idx": 2,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 8.0,
"err": "",
"out_w": 768,
"out_h": 752,
"bytes": 114378
},
{
"key": "girl5_flower_origin0",
"img": "girl5",
"style": "flower",
"style_idx": 2,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 7.2,
"err": "",
"out_w": 768,
"out_h": 752,
"bytes": 108495
},
{
"key": "girl5_heart_default896",
"img": "girl5",
"style": "heart",
"style_idx": 3,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 8.1,
"err": "",
"out_w": 768,
"out_h": 752,
"bytes": 113874
},
{
"key": "girl5_heart_origin0",
"img": "girl5",
"style": "heart",
"style_idx": 3,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 7.6,
"err": "",
"out_w": 768,
"out_h": 752,
"bytes": 103160
},
{
"key": "girl5_straight_default896",
"img": "girl5",
"style": "straight",
"style_idx": 4,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 8.4,
"err": "",
"out_w": 768,
"out_h": 752,
"bytes": 104492
},
{
"key": "girl5_straight_origin0",
"img": "girl5",
"style": "straight",
"style_idx": 4,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 7.2,
"err": "",
"out_w": 768,
"out_h": 752,
"bytes": 100941
},
{
"key": "girl5_wave_default896",
"img": "girl5",
"style": "wave",
"style_idx": 5,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 8.4,
"err": "",
"out_w": 768,
"out_h": 752,
"bytes": 120122
},
{
"key": "girl5_wave_origin0",
"img": "girl5",
"style": "wave",
"style_idx": 5,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 7.7,
"err": "",
"out_w": 768,
"out_h": 752,
"bytes": 113282
},
{
"key": "qwer_ellipse_default896",
"img": "qwer",
"style": "ellipse",
"style_idx": 1,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 10.2,
"err": "",
"out_w": 1288,
"out_h": 1678,
"bytes": 261812
},
{
"key": "qwer_ellipse_origin0",
"img": "qwer",
"style": "ellipse",
"style_idx": 1,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 30.0,
"err": "",
"out_w": 1280,
"out_h": 1680,
"bytes": 331013
},
{
"key": "qwer_flower_default896",
"img": "qwer",
"style": "flower",
"style_idx": 2,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 10.2,
"err": "",
"out_w": 1288,
"out_h": 1678,
"bytes": 312834
},
{
"key": "qwer_flower_origin0",
"img": "qwer",
"style": "flower",
"style_idx": 2,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 25.0,
"err": "",
"out_w": 1280,
"out_h": 1680,
"bytes": 328605
},
{
"key": "qwer_heart_default896",
"img": "qwer",
"style": "heart",
"style_idx": 3,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 11.1,
"err": "",
"out_w": 1288,
"out_h": 1678,
"bytes": 331337
},
{
"key": "qwer_heart_origin0",
"img": "qwer",
"style": "heart",
"style_idx": 3,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 25.0,
"err": "",
"out_w": 1280,
"out_h": 1680,
"bytes": 355982
},
{
"key": "qwer_straight_default896",
"img": "qwer",
"style": "straight",
"style_idx": 4,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 10.7,
"err": "",
"out_w": 1288,
"out_h": 1678,
"bytes": 281665
},
{
"key": "qwer_straight_origin0",
"img": "qwer",
"style": "straight",
"style_idx": 4,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 23.4,
"err": "",
"out_w": 1280,
"out_h": 1680,
"bytes": 322183
},
{
"key": "qwer_wave_default896",
"img": "qwer",
"style": "wave",
"style_idx": 5,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 11.1,
"err": "",
"out_w": 1288,
"out_h": 1678,
"bytes": 289322
},
{
"key": "qwer_wave_origin0",
"img": "qwer",
"style": "wave",
"style_idx": 5,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 25.8,
"err": "",
"out_w": 1280,
"out_h": 1680,
"bytes": 351719
},
{
"key": "asdf_ellipse_default896",
"img": "asdf",
"style": "ellipse",
"style_idx": 1,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 11.2,
"err": "",
"out_w": 1254,
"out_h": 1666,
"bytes": 285362
},
{
"key": "asdf_ellipse_origin0",
"img": "asdf",
"style": "ellipse",
"style_idx": 1,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 25.7,
"err": "",
"out_w": 1248,
"out_h": 1664,
"bytes": 286768
},
{
"key": "asdf_flower_default896",
"img": "asdf",
"style": "flower",
"style_idx": 2,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 10.7,
"err": "",
"out_w": 1254,
"out_h": 1666,
"bytes": 266946
},
{
"key": "asdf_flower_origin0",
"img": "asdf",
"style": "flower",
"style_idx": 2,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 22.9,
"err": "",
"out_w": 1248,
"out_h": 1664,
"bytes": 353668
},
{
"key": "asdf_heart_default896",
"img": "asdf",
"style": "heart",
"style_idx": 3,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 10.4,
"err": "",
"out_w": 1254,
"out_h": 1666,
"bytes": 271975
},
{
"key": "asdf_heart_origin0",
"img": "asdf",
"style": "heart",
"style_idx": 3,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 22.9,
"err": "",
"out_w": 1248,
"out_h": 1664,
"bytes": 291983
},
{
"key": "asdf_straight_default896",
"img": "asdf",
"style": "straight",
"style_idx": 4,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 9.8,
"err": "",
"out_w": 1254,
"out_h": 1666,
"bytes": 258665
},
{
"key": "asdf_straight_origin0",
"img": "asdf",
"style": "straight",
"style_idx": 4,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 24.0,
"err": "",
"out_w": 1248,
"out_h": 1664,
"bytes": 279883
},
{
"key": "asdf_wave_default896",
"img": "asdf",
"style": "wave",
"style_idx": 5,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 11.9,
"err": "",
"out_w": 1254,
"out_h": 1666,
"bytes": 286515
},
{
"key": "asdf_wave_origin0",
"img": "asdf",
"style": "wave",
"style_idx": 5,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 24.3,
"err": "",
"out_w": 1248,
"out_h": 1664,
"bytes": 295694
}
]
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""接口2 female 批量对比测试:4图 × 5发型 × 3分辨率档位 = 60次请求。
串行执行记录耗时与成败结果图按 图_发型_档位 命名保存
用法: python3 batch_test.py
支持断点续跑progress.json新增档位时只会补跑未完成项
"""
import base64, csv, json, os, sys, time, traceback
import requests
API = "http://127.0.0.1:8187/api/v1/hair/grow"
TOKEN = "dev-shared-secret-2026"
TIMEOUT = 600
OUT = "/home/ubuntu/hair/image/compare_test/out"
PROGRESS = "/home/ubuntu/hair/image/compare_test/progress.json"
IMG_DIR = "/home/ubuntu/hair/image"
IMAGES = [
("girl2", f"{IMG_DIR}/girl_img/girl2.jpg"),
("girl5", f"{IMG_DIR}/girl_img/girl5.jpg"),
("qwer", f"{IMG_DIR}/qwer.jpg"),
("asdf", f"{IMG_DIR}/asdf.jpg"),
]
# female: 1=ellipse 2=flower 3=heart 4=straight 5=wave
STYLES = [
(1, "ellipse"), (2, "flower"), (3, "heart"), (4, "straight"), (5, "wave"),
]
# 三档: 默认896 / 显式1024 / 原图直送0
SIDES = [
("default896", None), # 不传 → 后端默认896
("side1024", 1024), # 长边压到 1024
("origin0", 0), # 原图直送
]
SIDE_LABEL = {
"default896": "默认896",
"side1024": "1024",
"origin0": "原图0",
}
def load_progress():
if os.path.exists(PROGRESS):
try:
return json.load(open(PROGRESS))
except Exception:
pass
return {"done": [], "results": []}
def save_progress(prog):
json.dump(prog, open(PROGRESS, "w"), ensure_ascii=False, indent=1)
def run_one(img_name, img_path, style_idx, style_key, side_name, side_val):
"""跑单次请求,返回 dict 结果。"""
key = f"{img_name}_{style_key}_{side_name}"
with open(img_path, "rb") as f:
img_b64 = base64.b64encode(f.read()).decode()
data = {
"image_base64": "data:image/jpeg;base64," + img_b64,
"gender": "female",
"hair_style": str(style_idx),
}
if side_val is not None:
data["redraw_max_side"] = str(side_val)
t0 = time.time()
rec = {"key": key, "img": img_name, "style": style_key, "style_idx": style_idx,
"side": side_name, "side_val": side_val, "ok": False,
"elapsed": 0.0, "err": "", "out_w": 0, "out_h": 0}
try:
r = requests.post(API, data=data, headers={"X-Internal-Token": TOKEN}, timeout=TIMEOUT)
rec["elapsed"] = round(time.time() - t0, 1)
d = r.json()
if d.get("code") != 0:
rec["err"] = f"code={d.get('code')} {d.get('message','')}"[:200]
return rec
results = (d.get("data") or {}).get("results") or []
if not results:
rec["err"] = "空结果"
return rec
it = results[0]
grown = it.get("grown_image_base64")
if not grown:
rec["err"] = "无生发图(grown_png=None, 重绘失败)"
return rec
raw = base64.b64decode(grown)
from PIL import Image
import io as _io
im = Image.open(_io.BytesIO(raw))
rec["out_w"], rec["out_h"] = im.size
out_path = f"{OUT}/{key}.jpg"
with open(out_path, "wb") as fo:
fo.write(raw)
rec["ok"] = True
rec["bytes"] = len(raw)
except requests.exceptions.Timeout:
rec["elapsed"] = round(time.time() - t0, 1)
rec["err"] = f"超时(>{TIMEOUT}s)"
except Exception as e:
rec["elapsed"] = round(time.time() - t0, 1)
rec["err"] = f"{type(e).__name__}: {str(e)[:180]}"
return rec
def main():
os.makedirs(OUT, exist_ok=True)
prog = load_progress()
done_keys = set(prog["done"])
total = len(IMAGES) * len(STYLES) * len(SIDES)
print(f"=== 接口2 female 批量对比测试 ===")
print(f"矩阵: {len(IMAGES)}× {len(STYLES)}发型 × {len(SIDES)}档 = {total}")
print(f"已跳过 {len(done_keys)} 个已完成项\n")
idx = 0
for img_name, img_path in IMAGES:
for style_idx, style_key in STYLES:
for side_name, side_val in SIDES:
idx += 1
key = f"{img_name}_{style_key}_{side_name}"
if key in done_keys:
print(f"[{idx}/{total}] ⏭ 跳过已完成 {key}")
continue
print(f"[{idx}/{total}] ▶ {key} (side={side_val})...", end=" ", flush=True)
rec = run_one(img_name, img_path, style_idx, style_key, side_name, side_val)
prog["results"].append(rec)
prog["done"].append(key)
save_progress(prog)
if rec["ok"]:
print(f"{rec['elapsed']}s 出图 {rec['out_w']}x{rec['out_h']} ({rec['bytes']}B)")
else:
print(f"{rec['elapsed']}s {rec['err']}")
time.sleep(2) # 串行间隔,避免队列堆积
# 汇总
print("\n" + "="*70)
print("汇总报告")
print("="*70)
write_report(prog["results"])
print(f"\n结果图: {OUT}/")
print(f"CSV报告: {OUT}/report.csv")
print(f"JSON明细: {OUT}/report.json")
def write_report(results):
# CSV
csv_path = f"{OUT}/report.csv"
with open(csv_path, "w", newline="") as f:
w = csv.writer(f)
w.writerow(["img","style","side","ok","elapsed_s","out_w","out_h","err"])
for r in results:
w.writerow([r["img"],r["style"],r["side"],r["ok"],r["elapsed"],
r["out_w"],r["out_h"],r["err"]])
# JSON
json.dump(results, open(f"{OUT}/report.json","w"), ensure_ascii=False, indent=1)
# 控制台速度对比表
print("\n--- 速度对比(秒,✓=成功 ✗=失败)---")
headers = ["", "发型"] + [SIDE_LABEL[s] for s, _ in SIDES]
print(f"{headers[0]:<8}{headers[1]:<10}" + "".join(f"{h:>12}" for h in headers[2:]))
for img_name, _ in IMAGES:
for _, style_key in STYLES:
cells = []
for side_name, _ in SIDES:
r = next((x for x in results if x["img"]==img_name and x["style"]==style_key and x["side"]==side_name), None)
cells.append(f"{r['elapsed']}{'' if r['ok'] else ''}" if r else "-")
print(f"{img_name:<8}{style_key:<10}" + "".join(f"{c:>12}" for c in cells))
if __name__ == "__main__":
main()
+244
View File
@@ -0,0 +1,244 @@
#!/usr/bin/env python3
"""把 report.json + 结果图生成为自包含 HTML 报告(支持 896 / 1024 / 原图0 三档)。"""
import json, os, html
OUT = "/home/ubuntu/hair/image/compare_test/out"
REPORT_HTML = os.path.join(OUT, "report.html")
results = json.load(open(os.path.join(OUT, "report.json")))
IMAGES = ["girl2", "girl5", "qwer", "asdf"]
STYLES = ["ellipse", "flower", "heart", "straight", "wave"]
SIDES = [
("default896", "默认 896", "#2563eb", "d"),
("side1024", "1024", "#10b981", "m"),
("origin0", "原图直送 0", "#f59e0b", "o"),
]
IMG_LONGSIDE = {"girl2": 1082, "girl5": 767, "qwer": 1678, "asdf": 1666}
STYLE_CN = {"ellipse": "椭圆", "flower": "花瓣", "heart": "心形", "straight": "直线", "wave": "波浪"}
def get(img, style, side):
for r in results:
if r["img"] == img and r["style"] == style and r["side"] == side:
return r
return None
# 总览统计(>60s 视为冷启动/异常,不进均值)
total = len(results)
ok = sum(1 for r in results if r["ok"])
COLD_S = 60.0
def avg(seq):
seq = [x for x in seq if x is not None]
return sum(seq) / len(seq) if seq else 0
def times(side, img=None):
out = []
for r in results:
if not r["ok"] or r["side"] != side:
continue
if img is not None and r["img"] != img:
continue
if r["elapsed"] >= COLD_S:
continue
out.append(r["elapsed"])
return out
cold_n = sum(1 for r in results if r["ok"] and r["elapsed"] >= COLD_S)
side_avgs = {sid: avg(times(sid)) for sid, *_ in SIDES}
all_times = [t for sid, *_ in SIDES for t in times(sid)]
max_bar = max(all_times + [1])
# 构造速度对比图数据:每图 × 三档
chart_rows = []
for img in IMAGES:
chart_rows.append((img, IMG_LONGSIDE[img], [avg(times(sid, img)) for sid, *_ in SIDES]))
def bar_svg():
bar_h = 22
gap = 12
label_w = 78
chart_w = 760
n_bars = len(SIDES)
rows = len(chart_rows)
h = rows * (bar_h * n_bars + gap) + 40
parts = [f'<svg viewBox="0 0 {label_w + chart_w + 80} {h}" class="chart">']
y = 10
for img, longside, avgs in chart_rows:
base = avgs[0] if avgs and avgs[0] else 1
for i, ((sid, label, color, _), a) in enumerate(zip(SIDES, avgs)):
yi = y + i * bar_h
w = int(a / max_bar * chart_w) if a else 0
warn = "" if (i > 0 and a > base * 1.8) else ""
parts.append(f'<rect x="{label_w}" y="{yi}" width="{w}" height="{bar_h-4}" rx="3" fill="{color}"/>')
parts.append(f'<text x="{label_w + w + 6}" y="{yi + bar_h - 10}" class="barlabel">{a:.1f}s {warn}</text>')
parts.append(f'<text x="{label_w-8}" y="{yi + bar_h - 10}" class="rowlabel" text-anchor="end">{html.escape(label)}</text>')
parts.append(f'<text x="0" y="{y + bar_h - 2}" class="imglabel">{img}<tspan class="imgside">长边{longside}</tspan></text>')
y += bar_h * n_bars + gap
parts.append("</svg>")
return "".join(parts)
def img_cell(r, cls):
if not r:
return f'<td class="{cls} fail">缺失</td>'
fname = r["key"] + ".jpg"
status = "" if r["ok"] else ""
t = f'{r["elapsed"]}s'
dim = f'{r["out_w"]}×{r["out_h"]}'
err = f'<div class="err">{html.escape(r["err"])}</div>' if r["err"] else ""
img_tag = (f'<img loading="lazy" src="{fname}" onclick="openImg(this.src)" alt="{html.escape(r["key"])}">'
if r["ok"] else '<div class="noimg">无图</div>')
return (f'<td class="{cls}"><div class="thumb">{img_tag}</div>'
f'<div class="meta">{status} {t} · {dim}</div>{err}</td>')
def compare_cards():
out = []
for img in IMAGES:
out.append(f'<div class="card"><div class="card-h">📷 {html.escape(img)} <span class="tag">原图长边 {IMG_LONGSIDE[img]}px</span></div><div class="card-b">')
heads = "".join(f'<th class="{cls}">{html.escape(label)}</th>' for _, label, _, cls in SIDES)
out.append(f'<table class="cmp"><thead><tr><th>发型</th>{heads}</tr></thead><tbody>')
for style in STYLES:
sc = STYLE_CN[style]
cells = "".join(img_cell(get(img, style, sid), cls) for sid, _, _, cls in SIDES)
out.append(f'<tr><td class="sname">{html.escape(style)}<span>{sc}</span></td>{cells}</tr>')
out.append("</tbody></table></div></div>")
return "".join(out)
# 结论
small = [img for img in IMAGES if IMG_LONGSIDE[img] <= 1100]
large = [img for img in IMAGES if IMG_LONGSIDE[img] > 1100]
def ratio_range(num_side, den_side="default896"):
ratios = []
for img in large:
den = avg(times(den_side, img))
num = avg(times(num_side, img))
if den:
ratios.append(num / den)
if not ratios:
return 0, 0
return min(ratios), max(ratios)
r1024_lo, r1024_hi = ratio_range("side1024")
r0_lo, r0_hi = ratio_range("origin0")
cold_note = f"冷启动 {cold_n} 次(≥{COLD_S:.0f}s)已从均值剔除。" if cold_n else ""
conclusion = (
f"原图长边 ≤ 1100{'/'.join(small)})时三档耗时接近;"
f"长边 > 1600{'/'.join(large)})时相对默认896"
f"<b>1024 约 {r1024_lo:.1f}~{r1024_hi:.1f}×</b>"
f"<b>原图直送约 {r0_lo:.1f}~{r0_hi:.1f}×</b>。"
f"{cold_note}画质对比见下方三列并排,点击可放大。"
)
avg896 = side_avgs.get("default896", 0)
avg1024 = side_avgs.get("side1024", 0)
avg0 = side_avgs.get("origin0", 0)
ratio1024 = avg1024 / avg896 if avg896 else 0
ratio0 = avg0 / avg896 if avg896 else 0
legend = "".join(
f'<span><i style="background:{color}"></i>{html.escape(label)}</span>'
for _, label, color, _ in SIDES
)
html_doc = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>接口2 分辨率对比测试报告</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif; background: #f3f4f6; color: #1f2937; line-height: 1.5; padding: 20px; }}
.wrap {{ max-width: 1400px; margin: 0 auto; }}
h1 {{ font-size: 24px; margin-bottom: 4px; }}
.sub {{ color: #6b7280; font-size: 13px; margin-bottom: 20px; }}
.summary {{ display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-bottom: 24px; }}
.stat {{ background: #fff; border-radius: 10px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,.06); }}
.stat .num {{ font-size: 26px; font-weight: 700; }}
.stat .lbl {{ font-size: 12px; color: #6b7280; margin-top: 2px; }}
.stat.d .num {{ color: #2563eb; }}
.stat.m .num {{ color: #10b981; }}
.stat.o .num {{ color: #f59e0b; }}
.card {{ background: #fff; border-radius: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.07); margin-bottom: 20px; overflow: hidden; }}
.card-h {{ padding: 12px 18px; background: #fafafa; border-bottom: 1px solid #f0f0f0; font-weight: 700; font-size: 15px; display: flex; align-items: center; gap: 8px; }}
.card-h .tag {{ font-size: 11px; font-weight: 500; color: #6b7280; background: #f3f4f6; padding: 2px 8px; border-radius: 10px; }}
.card-b {{ padding: 16px; }}
.chart {{ width: 100%; height: auto; max-width: 920px; }}
.barlabel {{ font-size: 12px; fill: #374151; font-weight: 600; }}
.rowlabel {{ font-size: 11px; fill: #6b7280; }}
.imglabel {{ font-size: 14px; fill: #111; font-weight: 700; }}
.imgside {{ font-size: 10px; fill: #9ca3af; font-weight: 400; }}
table.cmp {{ width: 100%; border-collapse: collapse; table-layout: fixed; }}
table.cmp th {{ font-size: 12px; color: #6b7280; font-weight: 600; padding: 8px; text-align: center; border-bottom: 2px solid #f0f0f0; }}
table.cmp td {{ padding: 8px; border-bottom: 1px solid #f6f6f6; vertical-align: top; text-align: center; }}
table.cmp th.d, table.cmp td.d {{ background: #eff6ff; }}
table.cmp th.m, table.cmp td.m {{ background: #ecfdf5; }}
table.cmp th.o, table.cmp td.o {{ background: #fffbeb; }}
td.sname {{ font-weight: 600; text-align: left; width: 90px; }}
td.sname span {{ display: block; font-size: 11px; color: #9ca3af; font-weight: 400; }}
.thumb {{ background: #222; border-radius: 6px; overflow: hidden; margin-bottom: 4px; cursor: zoom-in; }}
.thumb img {{ width: 100%; height: 200px; object-fit: contain; display: block; }}
.noimg {{ color: #d1d5db; font-size: 12px; padding: 40px 0; }}
.meta {{ font-size: 11px; color: #6b7280; }}
.err {{ font-size: 10px; color: #dc2626; margin-top: 2px; }}
.note {{ background: #fef3c7; border-left: 3px solid #f59e0b; padding: 12px 16px; border-radius: 6px; font-size: 13px; margin-bottom: 20px; }}
.legend {{ display: flex; gap: 20px; font-size: 12px; color: #6b7280; margin-bottom: 12px; flex-wrap: wrap; }}
.legend span {{ display: inline-flex; align-items: center; gap: 5px; }}
.legend i {{ width: 12px; height: 12px; border-radius: 2px; display: inline-block; }}
.overlay {{ display: none; position: fixed; inset: 0; background: rgba(0,0,0,.9); z-index: 999; justify-content: center; align-items: center; cursor: zoom-out; padding: 30px; }}
.overlay.active {{ display: flex; }}
.overlay img {{ max-width: 95%; max-height: 95%; object-fit: contain; border-radius: 4px; }}
@media (max-width: 900px) {{
.summary {{ grid-template-columns: repeat(2, 1fr); }}
.thumb img {{ height: 160px; }}
}}
</style>
</head>
<body>
<div class="wrap">
<h1>接口2 生发 · 分辨率对比测试报告</h1>
<p class="sub">POST /api/v1/hair/grow · female · 4 张图 × 5 发型 × 3 档分辨率 = {total} · 串行</p>
<div class="summary">
<div class="stat"><div class="num">{ok}/{total}</div><div class="lbl">成功 / 总数</div></div>
<div class="stat d"><div class="num">{avg896:.1f}s</div><div class="lbl">默认896 平均</div></div>
<div class="stat m"><div class="num">{avg1024:.1f}s</div><div class="lbl">1024 平均 · ×{ratio1024:.2f}</div></div>
<div class="stat o"><div class="num">{avg0:.1f}s</div><div class="lbl">原图直送 平均 · ×{ratio0:.2f}</div></div>
<div class="stat"><div class="num" style="color:{'#dc2626' if ratio0>1.3 else '#16a34a'}">×{ratio0:.2f}</div><div class="lbl">原图/默认 倍率</div></div>
</div>
<div class="note">
<b>结论</b>{conclusion}
</div>
<div class="card">
<div class="card-h">平均耗时对比按图分组单位</div>
<div class="card-b">
<div class="legend">{legend}</div>
{bar_svg()}
</div>
</div>
<h2 style="font-size:18px;margin:8px 0 14px">画质对比默认896 · 1024 · 原图直送</h2>
{compare_cards()}
</div>
<div class="overlay" id="overlay" onclick="this.classList.remove('active')">
<img id="overlayImg" src="">
</div>
<script>
function openImg(src) {{
document.getElementById('overlayImg').src = src;
document.getElementById('overlay').classList.add('active');
}}
</script>
</body>
</html>
"""
with open(REPORT_HTML, "w") as f:
f.write(html_doc)
# 同步首页
with open(os.path.join(OUT, "index.html"), "w") as f:
f.write(html_doc)
print(f"已生成: {REPORT_HTML}")
print(f"图片目录: {OUT}")
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=接口2 分辨率对比测试报告 HTTP 服务 (8848)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/home/ubuntu/hair/image/compare_test/out
ExecStart=/home/ubuntu/miniconda3/envs/my_hair/bin/python -m http.server 8848 --bind 0.0.0.0
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

+47
View File
@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>测试报告索引</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif; background: #f3f4f6; color: #1f2937; padding: 40px 20px; }
.wrap { max-width: 720px; margin: 0 auto; }
h1 { font-size: 24px; margin-bottom: 4px; }
.sub { color: #6b7280; font-size: 13px; margin-bottom: 28px; }
.card { display: block; background: #fff; border-radius: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.07); padding: 20px 24px; margin-bottom: 14px; text-decoration: none; color: inherit; transition: .15s; border-left: 4px solid #2563eb; }
.card:hover { transform: translateX(4px); box-shadow: 0 4px 12px rgba(0,0,0,.1); }
.card .title { font-size: 16px; font-weight: 700; margin-bottom: 4px; }
.card .desc { font-size: 13px; color: #6b7280; }
.card .url { font-size: 11px; color: #9ca3af; margin-top: 6px; font-family: monospace; }
.card.wave { border-left-color: #16a34a; }
.card.v2 { border-left-color: #f59e0b; }
.card.v1 { border-left-color: #2563eb; }
</style>
</head>
<body>
<div class="wrap">
<h1>📊 测试报告索引</h1>
<p class="sub">接口2 / 接口5 分辨率对比测试报告合集</p>
<a class="card wave" href="wave/">
<div class="title">💇 wave发型 · 5档分辨率对比(最新)</div>
<div class="desc">21图 × 5档(原图/1024/896/768/640) = 105次 · wave发型 · female</div>
<div class="url">/wave/</div>
</a>
<a class="card v2" href="v2/">
<div class="title">💄 5图 · 5档分辨率对比 v2</div>
<div class="desc">5图 × 3发型(花瓣/心形/波浪) × 5档 = 75次 · female</div>
<div class="url">/v2/</div>
</a>
<a class="card v1" href="v1/">
<div class="title">💄 4图 · 2档分辨率对比 v1</div>
<div class="desc">4图 × 5发型 × 2档(默认896/原图) = 40次 · female</div>
<div class="url">/v1/</div>
</a>
</div>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=测试报告统一HTTP服务 (8850, 路径区分: /wave /v2 /v1)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/home/ubuntu/hair/image/reports
ExecStart=/home/ubuntu/miniconda3/envs/my_hair/bin/python -m http.server 8850 --bind 0.0.0.0
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
+1
View File
@@ -0,0 +1 @@
/home/ubuntu/hair/image/compare_test/out
+1
View File
@@ -0,0 +1 @@
/home/ubuntu/hair/image/res_test/out
+1
View File
@@ -0,0 +1 @@
/home/ubuntu/hair/image/wave_test/out
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""接口2 female 分辨率对比测试 v2: 5图 × 3发型 × 5档 = 75次。
串行执行记录耗时与成败结果图按 图_发型_档位 命名保存
支持断点续跑progress.json
"""
import base64, csv, json, os, sys, time, io
import requests
from PIL import Image
API = "http://127.0.0.1:8187/api/v1/hair/grow"
TOKEN = "dev-shared-secret-2026"
TIMEOUT = 600
OUT = "/home/ubuntu/hair/image/res_test/out"
PROGRESS = "/home/ubuntu/hair/image/res_test/progress.json"
IMG_DIR = "/home/ubuntu/hair/image"
IMAGES = [
("girl2", f"{IMG_DIR}/girl_img/girl2.jpg"),
("girl5", f"{IMG_DIR}/girl_img/girl5.jpg"),
("qwer", f"{IMG_DIR}/qwer.jpg"),
("asdf", f"{IMG_DIR}/asdf.jpg"),
("girl7", f"{IMG_DIR}/girl_img/girl7.jpg"),
]
# female: 2=flower(花瓣) 3=heart(心形) 5=wave(波浪)
STYLES = [(2, "flower"), (3, "heart"), (5, "wave")]
# 5档: 原图(0) / 1024 / 896 / 768 / 640
SIDES = [
("origin", 0),
("s1024", 1024),
("s896", 896),
("s768", 768),
("s640", 640),
]
def load_progress():
if os.path.exists(PROGRESS):
try:
return json.load(open(PROGRESS))
except Exception:
pass
return {"done": [], "results": []}
def save_progress(prog):
json.dump(prog, open(PROGRESS, "w"), ensure_ascii=False, indent=1)
def run_one(img_name, img_path, style_idx, style_key, side_name, side_val):
key = f"{img_name}_{style_key}_{side_name}"
with open(img_path, "rb") as f:
img_b64 = base64.b64encode(f.read()).decode()
data = {
"image_base64": "data:image/jpeg;base64," + img_b64,
"gender": "female",
"hair_style": str(style_idx),
"redraw_max_side": str(side_val), # 含原图档=0
}
t0 = time.time()
rec = {"key": key, "img": img_name, "style": style_key, "style_idx": style_idx,
"side": side_name, "side_val": side_val, "ok": False,
"elapsed": 0.0, "err": "", "out_w": 0, "out_h": 0, "bytes": 0}
try:
r = requests.post(API, data=data, headers={"X-Internal-Token": TOKEN}, timeout=TIMEOUT)
rec["elapsed"] = round(time.time() - t0, 1)
d = r.json()
if d.get("code") != 0:
rec["err"] = f"code={d.get('code')} {d.get('message','')}"[:200]
return rec
results = (d.get("data") or {}).get("results") or []
if not results:
rec["err"] = "空结果"
return rec
it = results[0]
grown = it.get("grown_image_base64")
if not grown:
rec["err"] = "无生发图(重绘失败/OOM?)"
return rec
raw = base64.b64decode(grown)
im = Image.open(io.BytesIO(raw))
rec["out_w"], rec["out_h"] = im.size
out_path = f"{OUT}/{key}.jpg"
with open(out_path, "wb") as fo:
fo.write(raw)
rec["ok"] = True
rec["bytes"] = len(raw)
except requests.exceptions.Timeout:
rec["elapsed"] = round(time.time() - t0, 1)
rec["err"] = f"超时(>{TIMEOUT}s)"
except Exception as e:
rec["elapsed"] = round(time.time() - t0, 1)
rec["err"] = f"{type(e).__name__}: {str(e)[:180]}"
return rec
def main():
prog = load_progress()
done_keys = set(prog["done"])
total = len(IMAGES) * len(STYLES) * len(SIDES)
print(f"=== 接口2 female 分辨率对比测试 v2 ===")
print(f"矩阵: {len(IMAGES)}× {len(STYLES)}发型 × {len(SIDES)}档 = {total}")
print(f"已跳过 {len(done_keys)} 个已完成项\n")
idx = 0
for img_name, img_path in IMAGES:
for style_idx, style_key in STYLES:
for side_name, side_val in SIDES:
idx += 1
key = f"{img_name}_{style_key}_{side_name}"
if key in done_keys:
print(f"[{idx}/{total}] ⏭ 跳过 {key}")
continue
print(f"[{idx}/{total}] ▶ {key} (side={side_val})...", end=" ", flush=True)
rec = run_one(img_name, img_path, style_idx, style_key, side_name, side_val)
prog["results"].append(rec)
prog["done"].append(key)
save_progress(prog)
if rec["ok"]:
print(f"{rec['elapsed']}s {rec['out_w']}x{rec['out_h']} ({rec['bytes']}B)")
else:
print(f"{rec['elapsed']}s {rec['err']}")
time.sleep(2)
print("\n" + "=" * 70)
print("汇总")
print("=" * 70)
write_report(prog["results"])
print(f"\n结果图: {OUT}/")
print(f"CSV: {OUT}/report.csv JSON: {OUT}/report.json")
def write_report(results):
with open(f"{OUT}/report.csv", "w", newline="") as f:
w = csv.writer(f)
w.writerow(["img", "style", "side", "side_val", "ok", "elapsed_s", "out_w", "out_h", "bytes", "err"])
for r in results:
w.writerow([r["img"], r["style"], r["side"], r["side_val"], r["ok"],
r["elapsed"], r["out_w"], r["out_h"], r["bytes"], r["err"]])
json.dump(results, open(f"{OUT}/report.json", "w"), ensure_ascii=False, indent=1)
# 控制台速度表:按 图×发型 分行,5档列
print("\n--- 速度对比(秒)---")
print(f"{'图·发型':<18}{'原图0':>8}{'1024':>8}{'896':>8}{'768':>8}{'640':>8}")
for img_name, _ in IMAGES:
for _, style_key in STYLES:
cells = []
for side_name, _ in SIDES:
r = next((x for x in results if x["img"] == img_name and x["style"] == style_key and x["side"] == side_name), None)
cells.append(f"{r['elapsed']}" if r and r["ok"] else (f"{r['elapsed']}" if r else "-"))
print(f"{img_name+'·'+style_key:<18}{cells[0]:>8}{cells[1]:>8}{cells[2]:>8}{cells[3]:>8}{cells[4]:>8}")
if __name__ == "__main__":
main()
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""生成 v2 分辨率对比报告:每行=原图+5档,按图×发型组织15行。"""
import json, os, html
OUT = "/home/ubuntu/hair/image/res_test/out"
REPORT = os.path.join(OUT, "report.html")
results = json.load(open(os.path.join(OUT, "report.json")))
IMAGES = ["girl2", "girl5", "qwer", "asdf", "girl7"]
STYLES = [("flower", "花瓣"), ("heart", "心形"), ("wave", "波浪")]
SIDES = [("origin", "原图直送", 0), ("s1024", "1024", 1024),
("s896", "896", 896), ("s768", "768", 768), ("s640", "640", 640)]
LONGSIDE = {"girl2": 1082, "girl5": 767, "qwer": 1678, "asdf": 1666, "girl7": 925}
def get(img, style, side):
for r in results:
if r["img"] == img and r["style"] == style and r["side"] == side:
return r
return None
# 统计
total = len(results)
ok = sum(1 for r in results if r["ok"])
# 每档平均耗时(排除冷启动异常值:girl2_flower_origin=135s 明显是冷启动)
def avg(side, exclude_first_cold=False):
ts = []
for r in results:
if r["side"] == side and r["ok"]:
if exclude_first_cold and r["key"] == "girl2_flower_origin":
continue # 跳过冷启动
ts.append(r["elapsed"])
return sum(ts) / len(ts) if ts else 0
avg_origin = avg("origin", exclude_first_cold=True)
avg_1024 = avg("s1024")
avg_896 = avg("s896")
avg_768 = avg("s768")
avg_640 = avg("s640")
avgs = [("origin", "原图直送", avg_origin, 0),
("s1024", "1024", avg_1024, 1024),
("s896", "896", avg_896, 896),
("s768", "768", avg_768, 768),
("s640", "640", avg_640, 640)]
# 速度色阶:以全部耗时的 min-max 映射颜色(绿→黄→红)
all_t = sorted([r["elapsed"] for r in results if r["ok"] and r["key"] != "girl2_flower_origin"])
tmin, tmax = all_t[0], all_t[-1]
def speed_color(t):
if tmax == tmin:
return "#16a34a"
ratio = (t - tmin) / (tmax - tmin) # 0=最快(绿) 1=最慢(红)
if ratio < 0.33:
return "#16a34a" # 绿
elif ratio < 0.66:
return "#f59e0b" # 橙
else:
return "#dc2626" # 红
# 表格行
rows_html = []
for img in IMAGES:
for style_key, style_cn in STYLES:
# 原图格
orig_cell = (f'<td class="cell orig">'
f'<div class="thumb"><img loading="lazy" src="orig_{img}.jpg" '
f'onclick="openImg(this.src)" alt="原图"></div>'
f'<div class="meta">📷 原图</div></td>')
# 5档格
side_cells = []
for side_name, side_lbl, side_val in SIDES:
r = get(img, style_key, side_name)
if r and r["ok"]:
col = speed_color(r["elapsed"])
cell = (f'<td class="cell">'
f'<div class="thumb"><img loading="lazy" src="{r["key"]}.jpg" '
f'onclick="openImg(this.src)" alt="{html.escape(r["key"])}"></div>'
f'<div class="meta"><b style="color:{col}">{r["elapsed"]}s</b> · '
f'{r["out_w"]}×{r["out_h"]}</div></td>')
elif r:
cell = (f'<td class="cell fail"><div class="thumb noimg">❌</div>'
f'<div class="meta err">{html.escape(r["err"][:30])}</div></td>')
else:
cell = '<td class="cell fail"><div class="thumb noimg">—</div></td>'
side_cells.append(cell)
row_label = (f'<td class="rowlabel">'
f'<div class="rimg">{html.escape(img)}</div>'
f'<div class="rs">{html.escape(style_cn)}</div>'
f'<div class="rl">长边 {LONGSIDE[img]}</div></td>')
rows_html.append("<tr>" + row_label + orig_cell + "".join(side_cells) + "</tr>")
# 顶部平均耗时卡
def stat_card(lbl, val, col, sub=""):
return (f'<div class="stat" style="border-left:3px solid {col}">'
f'<div class="num" style="color:{col}">{val:.1f}s</div>'
f'<div class="lbl">{lbl}{sub}</div></div>')
stat_cards = "".join([
stat_card("原图直送", avg_origin, "#dc2626", "<br><span class='dim'>排除冷启动</span>"),
stat_card("1024", avg_1024, "#f59e0b"),
stat_card("896 (默认)", avg_896, "#2563eb"),
stat_card("768", avg_768, "#16a34a"),
stat_card("640", avg_640, "#0d9488"),
])
html_doc = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>接口2 分辨率对比测试 v2</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif; background: #f3f4f6; color: #1f2937; line-height: 1.5; padding: 16px; }}
.wrap {{ max-width: 100%; margin: 0 auto; }}
h1 {{ font-size: 22px; margin-bottom: 4px; }}
.sub {{ color: #6b7280; font-size: 12px; margin-bottom: 16px; }}
.stats {{ display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }}
.stat {{ background: #fff; border-radius: 8px; padding: 12px 14px; box-shadow: 0 1px 3px rgba(0,0,0,.06); flex: 1; min-width: 110px; }}
.stat .num {{ font-size: 22px; font-weight: 700; }}
.stat .lbl {{ font-size: 11px; color: #6b7280; margin-top: 2px; }}
.stat .dim {{ color: #9ca3af; font-size: 10px; }}
.summary-bar {{ background: #fff; border-radius: 8px; padding: 12px 16px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,.06); font-size: 13px; }}
.summary-bar b {{ color: #dc2626; }}
.table-wrap {{ overflow-x: auto; background: #fff; border-radius: 10px; box-shadow: 0 1px 4px rgba(0,0,0,.07); }}
table {{ border-collapse: collapse; min-width: 100%; }}
th, td {{ vertical-align: top; }}
thead th {{ position: sticky; top: 0; background: #f9fafb; z-index: 2; padding: 10px 8px; font-size: 12px; color: #374151; border-bottom: 2px solid #e5e7eb; text-align: center; }}
thead th.orig-h {{ background: #fef3c7; }}
tbody td {{ border-bottom: 1px solid #f3f4f6; padding: 8px; }}
tbody tr:hover {{ background: #f9fafb; }}
td.rowlabel {{ text-align: left; padding: 8px 12px; position: sticky; left: 0; background: #fff; z-index: 1; min-width: 90px; box-shadow: 2px 0 4px rgba(0,0,0,.04); }}
tbody tr:hover td.rowlabel {{ background: #f9fafb; }}
.rimg {{ font-weight: 700; font-size: 14px; }}
.rs {{ font-size: 12px; color: #6b7280; }}
.rl {{ font-size: 10px; color: #9ca3af; margin-top: 2px; }}
.cell {{ width: 180px; min-width: 180px; text-align: center; }}
.cell.orig {{ width: 180px; background: #fffbeb; }}
.thumb {{ background: #1f2937; border-radius: 6px; overflow: hidden; margin-bottom: 4px; cursor: zoom-in; }}
.thumb img {{ width: 100%; height: 240px; object-fit: contain; display: block; }}
.thumb.noimg {{ color: #d1d5db; font-size: 16px; padding: 100px 0; text-align: center; }}
.meta {{ font-size: 11px; color: #6b7280; }}
.meta.err {{ color: #dc2626; }}
.legend {{ display: inline-flex; gap: 12px; font-size: 11px; color: #6b7280; margin-left: 12px; }}
.legend span {{ display: inline-flex; align-items: center; gap: 4px; }}
.legend i {{ width: 10px; height: 10px; border-radius: 2px; display: inline-block; }}
.overlay {{ display: none; position: fixed; inset: 0; background: rgba(0,0,0,.92); z-index: 999; justify-content: center; align-items: center; cursor: zoom-out; padding: 24px; }}
.overlay.active {{ display: flex; }}
.overlay img {{ max-width: 96%; max-height: 96%; object-fit: contain; border-radius: 4px; }}
</style>
</head>
<body>
<div class="wrap">
<h1>💄 接口2 生发 · 5档分辨率对比报告</h1>
<p class="sub">POST /api/v1/hair/grow · female · 5 × 3 发型(花瓣/心形/波浪) × 5 档分辨率 = 75 · 串行 · 4090 (24G)</p>
<div class="stats">
<div class="stat" style="border-left:3px solid #16a34a"><div class="num" style="color:#16a34a">{ok}/{total}</div><div class="lbl">成功 / 总数</div></div>
{stat_cards}
</div>
<div class="summary-bar">
📊 <b>结论</b>耗时随送图分辨率单调下降<b>大图(qwer/asdf 长边~1670) 原图直送需 ~27-30s 896 (10s) 的近 3 </b>
中小图(girl2/girl5/girl7 长边 767-1082)各档差异较小(6-15s)
<b>4090 24G 全程无 OOM</b>75/75 成功<b>画质对比</b>见下表横向滑动点击任意图可放大
<span class="legend">
<span><i style="background:#16a34a"></i>(&lt;{tmin+ (tmax-tmin)*0.33:.0f}s)</span>
<span><i style="background:#f59e0b"></i>中等</span>
<span><i style="background:#dc2626"></i>(&gt;{tmin+ (tmax-tmin)*0.66:.0f}s)</span>
</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th> · 发型</th>
<th class="orig-h">📷 原图</th>
<th>原图直送 (0)<br><span class="dim">不缩放</span></th>
<th>1024</th>
<th>896<br><span class="dim">默认</span></th>
<th>768</th>
<th>640</th>
</tr>
</thead>
<tbody>
{"".join(rows_html)}
</tbody>
</table>
</div>
</div>
<div class="overlay" id="overlay" onclick="this.classList.remove('active')"><img id="overlayImg" src=""></div>
<script>
function openImg(src) {{
document.getElementById('overlayImg').src = src;
document.getElementById('overlay').classList.add('active');
}}
</script>
</body>
</html>
"""
with open(REPORT, "w") as f:
f.write(html_doc)
print(f"已生成: {REPORT}")
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=接口2 5档分辨率对比报告 v2 (8849)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/home/ubuntu/hair/image/res_test/out
ExecStart=/home/ubuntu/miniconda3/envs/my_hair/bin/python -m http.server 8849 --bind 0.0.0.0
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""接口2 female wave发型 分辨率对比测试 v3: 21图 × 5档 = 105次。
串行执行记录耗时与成败结果图按 图_档位 命名保存支持断点续跑
"""
import base64, csv, json, os, time, io
import requests
from PIL import Image
API = "http://127.0.0.1:8187/api/v1/hair/grow"
TOKEN = "dev-shared-secret-2026"
TIMEOUT = 600
OUT = "/home/ubuntu/hair/image/wave_test/out"
PROGRESS = "/home/ubuntu/hair/image/wave_test/progress.json"
IMG_DIR = "/home/ubuntu/hair/image"
# 21张图:19张girl_img + asdf + qwer
IMAGES = []
for f in sorted(os.listdir(os.path.join(IMG_DIR, "girl_img"))):
if f.lower().endswith((".jpg", ".jpeg", ".png")):
IMAGES.append((os.path.splitext(f)[0], os.path.join(IMG_DIR, "girl_img", f)))
IMAGES.append(("asdf", os.path.join(IMG_DIR, "asdf.jpg")))
IMAGES.append(("qwer", os.path.join(IMG_DIR, "qwer.jpg")))
# wave = female hair_style 5
STYLE_IDX = 5
# 5档: 原图(0) / 1024 / 896 / 768 / 640
SIDES = [
("origin", 0),
("s1024", 1024),
("s896", 896),
("s768", 768),
("s640", 640),
]
def load_progress():
if os.path.exists(PROGRESS):
try:
return json.load(open(PROGRESS))
except Exception:
pass
return {"done": [], "results": []}
def save_progress(prog):
json.dump(prog, open(PROGRESS, "w"), ensure_ascii=False, indent=1)
def run_one(img_name, img_path, side_name, side_val):
key = f"{img_name}_{side_name}"
with open(img_path, "rb") as f:
img_b64 = base64.b64encode(f.read()).decode()
data = {
"image_base64": "data:image/jpeg;base64," + img_b64,
"gender": "female",
"hair_style": str(STYLE_IDX),
"redraw_max_side": str(side_val),
}
t0 = time.time()
rec = {"key": key, "img": img_name, "side": side_name, "side_val": side_val,
"ok": False, "elapsed": 0.0, "err": "", "out_w": 0, "out_h": 0, "bytes": 0}
try:
r = requests.post(API, data=data, headers={"X-Internal-Token": TOKEN}, timeout=TIMEOUT)
rec["elapsed"] = round(time.time() - t0, 1)
d = r.json()
if d.get("code") != 0:
rec["err"] = f"code={d.get('code')} {d.get('message','')}"[:200]
return rec
results = (d.get("data") or {}).get("results") or []
if not results:
rec["err"] = "空结果"
return rec
it = results[0]
grown = it.get("grown_image_base64")
if not grown:
rec["err"] = "无生发图(重绘失败/OOM?)"
return rec
raw = base64.b64decode(grown)
im = Image.open(io.BytesIO(raw))
rec["out_w"], rec["out_h"] = im.size
out_path = f"{OUT}/{key}.jpg"
with open(out_path, "wb") as fo:
fo.write(raw)
rec["ok"] = True
rec["bytes"] = len(raw)
except requests.exceptions.Timeout:
rec["elapsed"] = round(time.time() - t0, 1)
rec["err"] = f"超时(>{TIMEOUT}s)"
except Exception as e:
rec["elapsed"] = round(time.time() - t0, 1)
rec["err"] = f"{type(e).__name__}: {str(e)[:180]}"
return rec
def main():
prog = load_progress()
done_keys = set(prog["done"])
total = len(IMAGES) * len(SIDES)
print(f"=== 接口2 female wave 分辨率对比测试 v3 ===")
print(f"矩阵: {len(IMAGES)}× {len(SIDES)}档 = {total} 次 (发型固定 wave)")
print(f"已跳过 {len(done_keys)} 个已完成项\n")
idx = 0
for img_name, img_path in IMAGES:
for side_name, side_val in SIDES:
idx += 1
key = f"{img_name}_{side_name}"
if key in done_keys:
print(f"[{idx}/{total}] ⏭ 跳过 {key}")
continue
print(f"[{idx}/{total}] ▶ {key} (side={side_val})...", end=" ", flush=True)
rec = run_one(img_name, img_path, side_name, side_val)
prog["results"].append(rec)
prog["done"].append(key)
save_progress(prog)
if rec["ok"]:
print(f"{rec['elapsed']}s {rec['out_w']}x{rec['out_h']} ({rec['bytes']}B)")
else:
print(f"{rec['elapsed']}s {rec['err']}")
time.sleep(2)
print("\n" + "=" * 70)
print("汇总")
print("=" * 70)
write_report(prog["results"])
print(f"\n结果图: {OUT}/")
print(f"CSV: {OUT}/report.csv JSON: {OUT}/report.json")
def write_report(results):
with open(f"{OUT}/report.csv", "w", newline="") as f:
w = csv.writer(f)
w.writerow(["img", "side", "side_val", "ok", "elapsed_s", "out_w", "out_h", "bytes", "err"])
for r in results:
w.writerow([r["img"], r["side"], r["side_val"], r["ok"],
r["elapsed"], r["out_w"], r["out_h"], r["bytes"], r["err"]])
json.dump(results, open(f"{OUT}/report.json", "w"), ensure_ascii=False, indent=1)
# 各档平均耗时
print("\n--- 各档平均耗时(秒)---")
for side_name, side_val in SIDES:
ts = [r["elapsed"] for r in results if r["side"] == side_name and r["ok"]]
if ts:
avg = sum(ts) / len(ts)
print(f" {side_name:8s} (={side_val:>4}): 平均 {avg:5.1f}s [{min(ts):.1f}~{max(ts):.1f}] 成功 {len(ts)}")
if __name__ == "__main__":
main()
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""生成 v3 wave发型 分辨率对比报告:每行=原图+5档,21行;含各档耗时统计。"""
import json, os, html
OUT = "/home/ubuntu/hair/image/wave_test/out"
REPORT = os.path.join(OUT, "report.html")
results = json.load(open(os.path.join(OUT, "report.json")))
SIDES = [("origin", "原图直送", 0), ("s1024", "1024", 1024),
("s896", "896", 896), ("s768", "768", 768), ("s640", "640", 640)]
# 图片顺序(与测试脚本一致):girl_img 排序 + asdf + qwer
IMG_DIR = "/home/ubuntu/hair/image"
IMAGES = []
for f in sorted(os.listdir(os.path.join(IMG_DIR, "girl_img"))):
if f.lower().endswith((".jpg", ".jpeg", ".png")):
IMAGES.append(os.path.splitext(f)[0])
IMAGES.append("asdf")
IMAGES.append("qwer")
def get(img, side):
for r in results:
if r["img"] == img and r["side"] == side:
return r
return None
# 统计
total = len(results)
ok = sum(1 for r in results if r["ok"])
# 各档统计
def avg(side):
ts = [r["elapsed"] for r in results if r["side"] == side and r["ok"]]
return sum(ts)/len(ts) if ts else 0
avgs = {s[0]: avg(s[0]) for s in SIDES}
# 速度色阶
all_t = sorted(r["elapsed"] for r in results if r["ok"])
tmin, tmax = all_t[0], all_t[-1]
def speed_color(t):
if tmax == tmin: return "#16a34a"
ratio = (t - tmin) / (tmax - tmin)
if ratio < 0.33: return "#16a34a"
elif ratio < 0.66: return "#f59e0b"
else: return "#dc2626"
# 表格行
rows_html = []
for img in IMAGES:
orig_cell = (f'<td class="cell orig">'
f'<div class="thumb"><img loading="lazy" src="orig_{img}.jpg" '
f'onclick="openImg(this.src)" alt="原图"></div>'
f'<div class="meta">📷 原图</div></td>')
side_cells = []
for side_name, side_lbl, side_val in SIDES:
r = get(img, side_name)
if r and r["ok"]:
col = speed_color(r["elapsed"])
cell = (f'<td class="cell">'
f'<div class="thumb"><img loading="lazy" src="{r["key"]}.jpg" '
f'onclick="openImg(this.src)" alt="{html.escape(r["key"])}"></div>'
f'<div class="meta"><b style="color:{col}">{r["elapsed"]}s</b></div></td>')
elif r:
cell = (f'<td class="cell fail"><div class="thumb noimg">❌</div>'
f'<div class="meta err">{html.escape(r["err"][:30])}</div></td>')
else:
cell = '<td class="cell fail"><div class="thumb noimg">—</div></td>'
side_cells.append(cell)
rows_html.append("<tr>" + orig_cell + "".join(side_cells) + "</tr>")
# 各档统计卡
def stat_card(lbl, val, col, rng=""):
return (f'<div class="stat" style="border-left:3px solid {col}">'
f'<div class="num" style="color:{col}">{val:.1f}s</div>'
f'<div class="lbl">{lbl}{rng}</div></div>')
stat_cards = "".join([
stat_card("原图直送(0)", avgs["origin"], "#dc2626"),
stat_card("1024", avgs["s1024"], "#f59e0b"),
stat_card("896", avgs["s896"], "#2563eb"),
stat_card("768", avgs["s768"], "#16a34a"),
stat_card("640", avgs["s640"], "#0d9488"),
])
html_doc = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>wave发型 5档分辨率对比</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif; background: #f3f4f6; color: #1f2937; line-height: 1.5; padding: 16px; }}
.wrap {{ max-width: 100%; margin: 0 auto; }}
h1 {{ font-size: 22px; margin-bottom: 4px; }}
.sub {{ color: #6b7280; font-size: 12px; margin-bottom: 16px; }}
.stats {{ display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }}
.stat {{ background: #fff; border-radius: 8px; padding: 12px 14px; box-shadow: 0 1px 3px rgba(0,0,0,.06); flex: 1; min-width: 110px; }}
.stat .num {{ font-size: 22px; font-weight: 700; }}
.stat .lbl {{ font-size: 11px; color: #6b7280; margin-top: 2px; }}
.summary-bar {{ background: #fff; border-radius: 8px; padding: 12px 16px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,.06); font-size: 13px; }}
.summary-bar b {{ color: #dc2626; }}
.legend {{ display: inline-flex; gap: 12px; font-size: 11px; color: #6b7280; margin-left: 12px; }}
.legend span {{ display: inline-flex; align-items: center; gap: 4px; }}
.legend i {{ width: 10px; height: 10px; border-radius: 2px; display: inline-block; }}
.table-wrap {{ overflow-x: auto; background: #fff; border-radius: 10px; box-shadow: 0 1px 4px rgba(0,0,0,.07); }}
table {{ border-collapse: collapse; min-width: 100%; }}
th, td {{ vertical-align: top; }}
thead th {{ position: sticky; top: 0; background: #f9fafb; z-index: 2; padding: 10px 8px; font-size: 12px; color: #374151; border-bottom: 2px solid #e5e7eb; text-align: center; }}
thead th.orig-h {{ background: #fef3c7; }}
tbody td {{ border-bottom: 1px solid #f3f4f6; padding: 8px; }}
tbody tr:hover {{ background: #f9fafb; }}
.cell {{ width: 200px; min-width: 200px; text-align: center; }}
.cell.orig {{ background: #fffbeb; }}
.thumb {{ background: #1f2937; border-radius: 6px; overflow: hidden; margin-bottom: 4px; cursor: zoom-in; }}
.thumb img {{ width: 100%; height: 260px; object-fit: contain; display: block; }}
.thumb.noimg {{ color: #d1d5db; font-size: 16px; padding: 110px 0; text-align: center; }}
.meta {{ font-size: 11px; color: #6b7280; }}
.meta.err {{ color: #dc2626; }}
.overlay {{ display: none; position: fixed; inset: 0; background: rgba(0,0,0,.92); z-index: 999; justify-content: center; align-items: center; cursor: zoom-out; padding: 24px; }}
.overlay.active {{ display: flex; }}
.overlay img {{ max-width: 96%; max-height: 96%; object-fit: contain; border-radius: 4px; }}
</style>
</head>
<body>
<div class="wrap">
<h1>💇 wave发型 · 5档分辨率对比报告</h1>
<p class="sub">POST /api/v1/hair/grow · female · wave(波浪) · 21 × 5 档分辨率 = 105 · 串行 · 4090 (24G)</p>
<div class="stats">
<div class="stat" style="border-left:3px solid #16a34a"><div class="num" style="color:#16a34a">{ok}/{total}</div><div class="lbl">成功 / 总数</div></div>
{stat_cards}
</div>
<div class="summary-bar">
📊 <b>结论</b>耗时随分辨率单调下降<b>大图(asdf 1666/qwer 1678) 原图直送需 24~26s 1024 (11s) 2.3 </b>
中小图(1024) 各档差异较小(6~13s)因小图本身不触发缩图
<b>4090 24G 全程无 OOM</b>105/105 成功<b>画质对比</b>见下表横向滑动点击任意图可放大
<span class="legend">
<span><i style="background:#16a34a"></i>(&lt;{(tmin+(tmax-tmin)*0.33):.0f}s)</span>
<span><i style="background:#f59e0b"></i>中等</span>
<span><i style="background:#dc2626"></i>(&gt;{(tmin+(tmax-tmin)*0.66):.0f}s)</span>
</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="orig-h">📷 原图</th>
<th>原图直送 (0)<br><span class="dim">不缩放</span></th>
<th>1024</th>
<th>896<br><span class="dim">默认</span></th>
<th>768</th>
<th>640</th>
</tr>
</thead>
<tbody>
{"".join(rows_html)}
</tbody>
</table>
</div>
</div>
<div class="overlay" id="overlay" onclick="this.classList.remove('active')"><img id="overlayImg" src=""></div>
<script>
function openImg(src) {{
document.getElementById('overlayImg').src = src;
document.getElementById('overlay').classList.add('active');
}}
</script>
</body>
</html>
"""
with open(REPORT, "w") as f:
f.write(html_doc)
print(f"已生成: {REPORT}")
@@ -0,0 +1,15 @@
[Unit]
Description=wave发型5档分辨率对比报告 (8850)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/home/ubuntu/hair/image/wave_test/out
ExecStart=/home/ubuntu/miniconda3/envs/my_hair/bin/python -m http.server 8850 --bind 0.0.0.0
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
+1 -1
View File
@@ -206,7 +206,7 @@ def generate():
return jsonify({"error": msg}), 400
image_file = request.files["image"]
mask_file = request.files["mask"]
prompt_text = request.form.get("prompt", "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
prompt_text = request.form.get("prompt", "填充遮罩区域的头发")
log.info(
"收到请求: image=%s mask=%s prompt=%r",
image_file.filename, mask_file.filename, prompt_text,
+1 -1
View File
@@ -27,7 +27,7 @@ def prep_and_upload():
def run_once(fname, model, dtype, steps):
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
wf = A.build_workflow(fname, "填充遮罩区域的头发")
wf["16"]["inputs"]["unet_name"] = model
wf["16"]["inputs"]["weight_dtype"] = dtype
wf["1"]["inputs"]["steps"] = steps
+1 -1
View File
@@ -33,7 +33,7 @@ def upload(scale=1.0):
def run(fname, steps):
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
wf = A.build_workflow(fname, "填充遮罩区域的头发")
wf["16"]["inputs"]["unet_name"] = MODEL
wf["16"]["inputs"]["weight_dtype"] = DTYPE
wf["1"]["inputs"]["steps"] = steps
+1 -1
View File
@@ -30,7 +30,7 @@ SEED = 123456789
imgs = []
labels = []
for steps in [2, 3, 4, 6]:
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", seed=SEED)
wf = A.build_workflow(fname, "填充遮罩区域的头发", seed=SEED)
wf["16"]["inputs"]["unet_name"] = MODEL
wf["16"]["inputs"]["weight_dtype"] = DTYPE
wf["1"]["inputs"]["steps"] = steps
+1 -1
View File
@@ -55,7 +55,7 @@ button { padding: 10px 24px; border: none; border-radius: 6px; cursor: pointer;
</div>
<div class="controls" style="margin-top:16px">
<label>提示词:</label>
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜">
<input type="text" id="promptInput" value="填充遮罩区域的头发">
</div>
<div style="text-align:center; margin-top:16px">
<button class="btn-generate" id="generateBtn" disabled>🚀 生成</button>
+1 -1
View File
@@ -25,7 +25,7 @@ resp = requests.post(
"image": ("original.jpg", img_data, "image/jpeg"),
"mask": ("mask.png", mask_data, "image/png"),
},
data={"prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"},
data={"prompt": "填充遮罩区域的头发"},
timeout=600,
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Some files were not shown because too many files have changed in this diff Show More