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
32 changed files with 632 additions and 123 deletions
+3
View File
@@ -70,8 +70,11 @@ gateway.log
# 仅忽略 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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -410,7 +410,7 @@
},
"60": {
"inputs": {
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
"text": "填充遮罩区域的头发"
},
"class_type": "JjkText",
"_meta": {
+51 -26
View File
@@ -401,21 +401,36 @@ 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()
vd = result.vertical
if variant == "v6":
vd = result.vertical
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"] = {
"upper_court": round(vd["upper_court_px"] / base_px, 3),
"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.upper_cm + result.middle_cm + result.lower_cm, 2)
# 注:landmarks.hair_top 保留返回(供前端/下游定位头顶),但顶庭数值、
# 占比、标注图仍按三庭处理,显示效果不变。
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"] = {
"upper_court": round(vd["upper_court_px"] / base_px, 3),
"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.upper_cm + result.middle_cm + result.lower_cm, 2)
# 注:landmarks.hair_top 保留返回(供前端/下游定位头顶),但顶庭数值、
# 占比、标注图仍按三庭处理,显示效果不变。
# 七眼段宽度(cm)。eye1=左耳外段 eye2=左脸颊 eye3=左眼 eye4=两眼间距 eye5=右眼 eye6=右脸颊 eye7=右耳外段。
# eye2~eye6(5段)只用内部分点,接口1/6 共用;eye1/eye7 需耳朵分割端线,仅接口1 有。
@@ -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,7 @@ 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"),
):
@@ -872,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)
@@ -1001,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 路径生效)。
**返回说明**
@@ -1081,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")
@@ -1108,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, "无法识别人像")
@@ -1476,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"),
@@ -1642,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()
+1 -1
View File
@@ -55,7 +55,7 @@ def gpu_used():
def call(img_path, hair_num, model_file, res_val):
fd = {"gender": "female", "hair_style": str(hair_num), "use_mask": "true",
"prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"}
"prompt": "填充遮罩区域的头发"}
if model_file:
fd["flux_model"] = model_file
if res_val != "":
+1 -1
View File
@@ -54,7 +54,7 @@ def call(img_path, model_file, res_val):
"gender": "female",
"hair_style": "2", # 花瓣形
"use_mask": "true",
"prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜",
"prompt": "填充遮罩区域的头发",
}
if model_file:
fd["flux_model"] = model_file
+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"],
+93 -44
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,7 +175,14 @@ 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
self.face_total_cm = self.top_cm + self.upper_cm + self.middle_cm + self.lower_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
# 七眼厘米
self.eye_width_cm = eyes["eye_width_px"] / px_per_cm
@@ -172,46 +190,77 @@ class MeasureResult:
self.inter_eye_cm = eyes["inter_eye_distance_px"] / px_per_cm
def to_response(self):
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": {
"top_court_cm": round(self.top_cm, 2),
"upper_court_cm": round(self.upper_cm, 2),
"middle_court_cm": round(self.middle_cm, 2),
"lower_court_cm": round(self.lower_cm, 2),
"ratios": {
"top_court": round(self.vertical["top_court_px"] / total_px, 3),
"upper_court": round(self.vertical["upper_court_px"] / total_px, 3),
"middle_court": round(self.vertical["middle_court_px"] / total_px, 3),
"lower_court": round(self.vertical["lower_court_px"] / total_px, 3),
# 发际线弃用:顶/上庭相关字段置 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"] / fw_px, 3),
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / fw_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": pt("hair_top"),
"hairline": pt("hairline"),
"brow_center": pt("brow_center"),
"nose_bottom": pt("nose_bottom"),
"chin_tip": pt("chin_tip"),
},
"hairline_source": self.hairline_source,
}
"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"])
data = {
"face_total_height_cm": round(self.face_total_cm, 2),
"four_courts": {
"top_court_cm": round(self.top_cm, 2),
"upper_court_cm": round(self.upper_cm, 2),
"middle_court_cm": round(self.middle_cm, 2),
"lower_court_cm": round(self.lower_cm, 2),
"ratios": {
"top_court": round(self.vertical["top_court_px"] / total_px, 3),
"upper_court": round(self.vertical["upper_court_px"] / total_px, 3),
"middle_court": round(self.vertical["middle_court_px"] / total_px, 3),
"lower_court": round(self.vertical["lower_court_px"] / total_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": 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,
}
# left/right_positionmediapipe 21/251 号定位点(原图像素,与 landmarks 同坐标系)。
# landmarks 缺省(如测试直构 MeasureResult)时不输出,保持向后兼容。
if self.landmarks is not None and self.w and self.h:
+1 -1
View File
@@ -410,7 +410,7 @@
},
"60": {
"inputs": {
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
"text": "填充遮罩区域的头发"
},
"class_type": "JjkText",
"_meta": {
+2 -2
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")
@@ -62,7 +62,7 @@ def run_redraw(image_bytes: bytes, mask_bytes: bytes,
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 用工作流默认
+42 -22
View File
@@ -46,13 +46,14 @@ _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,
max_side=None, unet_name=None):
@@ -410,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
@@ -439,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。"""
@@ -456,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)发际线中点
+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
+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,
)
+2 -2
View File
@@ -69,7 +69,7 @@
</div>
<div class="upload-row" style="margin-top:10px">
<label style="font-size:13px;font-weight:600;white-space:nowrap">重绘提示词</label>
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
</div>
<div class="upload-row" style="margin-top:10px">
<label style="font-size:13px;font-weight:600;white-space:nowrap">X-Internal-Token</label>
@@ -354,7 +354,7 @@ function dataUriToBlob(dataUri) {
async function runLocalRedraw() {
if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; }
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜';
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发';
const btn = $('redrawBtn');
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
flog('===== 调后端重绘 =====', 'info');
+2 -2
View File
@@ -56,7 +56,7 @@
</div>
<div class="upload-row" style="margin-top:10px">
<label style="font-size:13px;font-weight:600;white-space:nowrap">重绘提示词</label>
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
</div>
<div class="params">
<div class="pf">
@@ -169,7 +169,7 @@ async function submitTest() {
async function runLocalRedraw() {
if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; }
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜';
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发';
const btn = $('redrawBtn');
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
+5 -5
View File
@@ -103,12 +103,12 @@
</div>
<div class="form-group" id="redrawSideGroup">
<label>重绘分辨率</label>
<select id="redrawMaxSide" title="仅 Female 生效:ComfyUI 重绘前把送图长边压到该像素。值越小越快越省显存,细节越少;0=原图直送">
<option value="" selected>默认 896</option>
<option value="1024">1024(高清,慢</option>
<select id="redrawMaxSide" title="仅 Female 生效:图长边超过该值才缩到该值,不超过则原图直送。默认1024=大于1024压到1024,小图保持原分辨率">
<option value="" selected>默认 1024&gt;1024 才缩)</option>
<option value="0">0 — 永不缩图(原图直送</option>
<option value="1280">1280(更高清,慢)</option>
<option value="768">768(快)</option>
<option value="640">640(最快,糊)</option>
<option value="0">0 — 不缩图(原图直送)</option>
</select>
</div>
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交</button>
@@ -117,7 +117,7 @@
<div class="hint">JPG/PNG &nbsp;|&nbsp; 生发图生成较慢(数十秒~数分钟),请耐心等待</div>
<div style="margin-top:10px;display:flex;align-items:center;gap:8px">
<label style="font-size:13px;font-weight:600;color:#374151;white-space:nowrap">💬 提示词</label>
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
<input type="text" id="promptInput" value="填充遮罩区域的头发" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
</div>
<div id="statusBar" class="status hidden"></div>
</div>
+1 -1
View File
@@ -94,7 +94,7 @@
</div>
<div class="upload-group" style="margin-top:14px">
<div class="label">💬 提示词(prompt</div>
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="width:100%;padding:8px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:14px;max-width:500px">
<input type="text" id="promptInput" value="填充遮罩区域的头发" style="width:100%;padding:8px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:14px;max-width:500px">
</div>
<div style="margin-top:14px;display:flex;gap:12px;align-items:center">
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交</button>
+1 -1
View File
@@ -104,7 +104,7 @@
<div class="hint">JPG/PNG &nbsp;|&nbsp; 生发图生成较慢(数十秒~数分钟),请耐心等待 &nbsp;|&nbsp; 工作流: add_hair2.json</div>
<div style="margin-top:10px;display:flex;align-items:center;gap:8px">
<label style="font-size:13px;font-weight:600;color:#374151;white-space:nowrap">💬 提示词</label>
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
<input type="text" id="promptInput" value="填充遮罩区域的头发" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
</div>
<div id="statusBar" class="status hidden"></div>
</div>