Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
616fc26143 | ||
|
|
48c9875381 | ||
|
|
5bcaeb2594 | ||
|
|
8a82d00056 | ||
|
|
e2bbcca7ad | ||
|
|
f8ed477696 | ||
|
|
2877d07a69 | ||
|
|
bee7a487ac | ||
|
|
531dcb8279 | ||
|
|
62d2660e41 | ||
|
|
e4d03a782d | ||
|
|
ae5a3b8f1d | ||
|
|
d318efcff0 | ||
|
|
ff4019c570 | ||
|
|
5226e23989 | ||
|
|
c462fd3634 | ||
|
|
ab6b0cb0bb | ||
|
|
94cdfd6de5 |
@@ -53,3 +53,15 @@ static/report_hairline_v2.zip
|
|||||||
# local_test 运行期日志 / pid(不入 git)
|
# local_test 运行期日志 / pid(不入 git)
|
||||||
local_test/hair_service.log
|
local_test/hair_service.log
|
||||||
local_test/hair_service.pid
|
local_test/hair_service.pid
|
||||||
|
|
||||||
|
# benchmark 原始输出(含结果图+原图,体积大,不入 git)
|
||||||
|
benchmark_out/
|
||||||
|
|
||||||
|
# benchmark 部署的 HTML 报告(图片 base64 内嵌,体积大,不入 git)
|
||||||
|
static/hairstyle_thumbs/
|
||||||
|
|
||||||
|
# 网关运行期日志(不入 git)
|
||||||
|
gateway.log
|
||||||
|
|
||||||
|
# 工作流备份文件(不入 git)
|
||||||
|
*.json.bak.*
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"16": {
|
"16": {
|
||||||
"class_type": "UNETLoader",
|
"class_type": "UnetLoaderGGUF",
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"unet_name": "flux-2-klein-4b-fp8.safetensors",
|
"unet_name": "flux-2-klein-9b-Q4_K_M.gguf",
|
||||||
"weight_dtype": "fp8_e4m3fn_fast"
|
"weight_dtype": "fp8_e4m3fn_fast"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
"61": {
|
"61": {
|
||||||
"class_type": "CLIPLoader",
|
"class_type": "CLIPLoader",
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"clip_name": "qwen_3_4b.safetensors",
|
"clip_name": "qwen_3_8b_fp8mixed.safetensors",
|
||||||
"type": "flux2",
|
"type": "flux2",
|
||||||
"device": "cpu"
|
"device": "cpu"
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"60": {
|
"60": {
|
||||||
"class_type": "JjkText",
|
"class_type": "JjkText",
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
"text": "填充遮罩区域的头发"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"22": {
|
"22": {
|
||||||
|
|||||||
@@ -170,10 +170,10 @@
|
|||||||
},
|
},
|
||||||
"16": {
|
"16": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"unet_name": "flux-2-klein-4b-fp8.safetensors",
|
"unet_name": "flux-2-klein-9b-Q4_K_M.gguf",
|
||||||
"weight_dtype": "fp8_e4m3fn_fast"
|
"weight_dtype": "fp8_e4m3fn_fast"
|
||||||
},
|
},
|
||||||
"class_type": "UNETLoader",
|
"class_type": "UnetLoaderGGUF",
|
||||||
"_meta": {
|
"_meta": {
|
||||||
"title": "UNet加载器"
|
"title": "UNet加载器"
|
||||||
}
|
}
|
||||||
@@ -410,7 +410,7 @@
|
|||||||
},
|
},
|
||||||
"60": {
|
"60": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
"text": "填充遮罩区域的头发"
|
||||||
},
|
},
|
||||||
"class_type": "JjkText",
|
"class_type": "JjkText",
|
||||||
"_meta": {
|
"_meta": {
|
||||||
@@ -419,7 +419,7 @@
|
|||||||
},
|
},
|
||||||
"61": {
|
"61": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"clip_name": "qwen_3_4b.safetensors",
|
"clip_name": "qwen_3_8b_fp8mixed.safetensors",
|
||||||
"type": "flux2",
|
"type": "flux2",
|
||||||
"device": "cpu"
|
"device": "cpu"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from typing import Any, List, Optional
|
|||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PIL import Image
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
from fastapi import FastAPI, File, Form, Request, UploadFile
|
from fastapi import FastAPI, File, Form, Request, UploadFile
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
@@ -146,7 +146,7 @@ _AUTH_EXEMPT = ("/health", "/docs", "/openapi.json", "/redoc", "/static",
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# change_hair 代理路由(解决 CORS 问题)
|
# 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"])
|
@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)
|
logger.warning("头发/耳朵分割失败,回退方案A:%s", seg_e)
|
||||||
|
|
||||||
result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose)
|
result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose)
|
||||||
|
discarded = result.hairline_discarded
|
||||||
data = result.to_response()
|
data = result.to_response()
|
||||||
if variant == "v6":
|
|
||||||
vd = result.vertical
|
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"]
|
base_px = vd["upper_court_px"] + vd["middle_court_px"] + vd["lower_court_px"]
|
||||||
# 接口6 是三庭:去掉顶庭相关字段(top_court_cm / ratios.top_court / landmarks.hair_top)
|
# 接口6 是三庭:去掉顶庭相关字段(top_court_cm / ratios.top_court / landmarks.hair_top)
|
||||||
data["four_courts"]["ratios"] = {
|
data["four_courts"]["ratios"] = {
|
||||||
@@ -430,11 +445,14 @@ def _run_face_measure_data(image, variant="v1"):
|
|||||||
data["seven_eyes"][f"eye{i + 2}"] = (
|
data["seven_eyes"][f"eye{i + 2}"] = (
|
||||||
None if (a is None or b is None) else round((b - a) / pc, 2))
|
None if (a is None or b is None) else round((b - a) / pc, 2))
|
||||||
if variant != "v6":
|
if variant != "v6":
|
||||||
# 接口1 额外算 eye1/eye7(左/右耳外段),需耳朵分割端线
|
# 接口1 额外算 eye1/eye7(左/右耳外段),需耳朵分割端线。
|
||||||
|
# 竖向范围:发际线弃用时用眉心做上界(hair_top 不可靠),否则用头顶。
|
||||||
from face_analysis.annotation import _ear_edges_from_mask
|
from face_analysis.annotation import _ear_edges_from_mask
|
||||||
|
top_y = (vd["brow_center"][1] if result.hairline_discarded
|
||||||
|
else vd["hair_top"][1])
|
||||||
head_l, head_r = _ear_edges_from_mask(
|
head_l, head_r = _ear_edges_from_mask(
|
||||||
ear_mask, hair_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)
|
lcx, rcx, (lcx + rcx) / 2)
|
||||||
data["seven_eyes"]["eye1"] = (
|
data["seven_eyes"]["eye1"] = (
|
||||||
None if (head_l is None) else round((lcx - head_l) / pc, 2))
|
None if (head_l is None) else round((lcx - head_l) / pc, 2))
|
||||||
@@ -446,6 +464,398 @@ def _run_face_measure_data(image, variant="v1"):
|
|||||||
return data, result, hair_mask, ear_mask
|
return data, result, hair_mask, ear_mask
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 接口1 调试:分步可视化(每一步的中间产物图)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# 调试接口 9 张分步图的 key(与前端 STEPS 一一对应)
|
||||||
|
_DEBUG_STEP_KEYS = [
|
||||||
|
"input", "landmarks", "pose", "segmentation",
|
||||||
|
"hairline", "vertical", "seven_eyes", "scale", "final",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _overlay_mask(image_bgr, mask, color, alpha=0.45):
|
||||||
|
"""在 BGR 图上把 mask 区域以 color(BGR) 半透明叠加。mask 为 bool/uint8。"""
|
||||||
|
out = image_bgr.copy()
|
||||||
|
m = np.asarray(mask).astype(bool)
|
||||||
|
if m.shape[:2] != out.shape[:2]:
|
||||||
|
return out
|
||||||
|
overlay = out[m]
|
||||||
|
# alpha 混合
|
||||||
|
overlay = (overlay * (1 - alpha) + np.array(color, dtype=np.float32) * alpha)
|
||||||
|
out[m] = np.clip(overlay, 0, 255).astype(np.uint8)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
_DEBUG_FONT_PATH = os.path.join(
|
||||||
|
os.path.dirname(__file__), "face_analysis", "fonts", "NotoSansCJKsc-Regular.otf")
|
||||||
|
_debug_font_cache = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _debug_font(size):
|
||||||
|
f = _debug_font_cache.get(size)
|
||||||
|
if f is None:
|
||||||
|
f = ImageFont.truetype(_DEBUG_FONT_PATH, size)
|
||||||
|
_debug_font_cache[size] = f
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_text_cv2(img, text, org, color=(255, 255, 255), scale=None, thickness=None,
|
||||||
|
bg=True, anchor="lt"):
|
||||||
|
"""在 BGR 图上绘制文字(支持中文,用 PIL + 思源黑体)。org=(x,y)。
|
||||||
|
|
||||||
|
cv2.putText 不支持中文(会显示成问号),故统一改用 PIL 渲染。color 为 BGR 三元组。
|
||||||
|
anchor: lt=左上角对齐 org / lb=左下角 / ct=水平垂直居中。bg=True 时画黑色背景框。
|
||||||
|
"""
|
||||||
|
h, w = img.shape[:2]
|
||||||
|
s = min(w, h)
|
||||||
|
scale = scale if scale else max(0.4, s * 0.0016)
|
||||||
|
thickness = thickness if thickness else max(1, round(s * 0.0022))
|
||||||
|
# PIL 字号与 cv2 scale 大致对应(cv2 scale≈字号/30)
|
||||||
|
font_size = max(10, round(scale * 30))
|
||||||
|
font = _debug_font(font_size)
|
||||||
|
# BGR → RGB
|
||||||
|
rgb = (int(color[2]), int(color[1]), int(color[0]))
|
||||||
|
pil_img = Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
|
||||||
|
draw = ImageDraw.Draw(pil_img)
|
||||||
|
bbox = draw.textbbox((0, 0), text, font=font)
|
||||||
|
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||||
|
x, y = org
|
||||||
|
if anchor == "lb":
|
||||||
|
text_y = y - th
|
||||||
|
elif anchor == "ct":
|
||||||
|
x = x - tw // 2
|
||||||
|
text_y = y - th // 2
|
||||||
|
else:
|
||||||
|
text_y = y
|
||||||
|
if bg:
|
||||||
|
pad = max(2, round(thickness * 1.2))
|
||||||
|
draw.rectangle(
|
||||||
|
[max(0, x - pad), max(0, text_y - pad),
|
||||||
|
min(w, x + tw + pad), min(h, text_y + th + pad)],
|
||||||
|
fill=(0, 0, 0))
|
||||||
|
# PIL text 的 y 是文字顶部基线,bbox 偏移需校正
|
||||||
|
draw.text((x, text_y - bbox[1]), text, fill=rgb, font=font)
|
||||||
|
img[:] = cv2.cvtColor(np.asarray(pil_img), cv2.COLOR_RGB2BGR)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def _run_face_measure_data_debug(image):
|
||||||
|
"""接口1 调试:产出 9 步中间图 + 数值,逐步塞进返回 dict。
|
||||||
|
|
||||||
|
与 _run_face_measure_data 同链路,但每步把中间产物渲染成叠加图(JPG base64)
|
||||||
|
放进 data["steps"][key + "_base64"],关键数值放进 data["debug"]。
|
||||||
|
检测/姿态失败时,仍返回已完成的步骤图 + 对应错误码,供前端展示「卡在哪一步」。
|
||||||
|
|
||||||
|
返回 (data, error_code_or_None, error_msg_or_None)。
|
||||||
|
"""
|
||||||
|
h, w = image.shape[:2]
|
||||||
|
from face_analysis.detector import detector
|
||||||
|
from face_analysis.pose import estimate_head_pose, check_frontal_face
|
||||||
|
from face_analysis.measure import measure_face, _brow_center
|
||||||
|
from face_analysis.calibration import (
|
||||||
|
normalized_to_pixel, estimate_scale_factor,
|
||||||
|
_iris_diameter_px, _eye_width_px, _lm_list,
|
||||||
|
AVG_IRIS_DIAMETER_CM, AVG_EYE_WIDTH_CM,
|
||||||
|
)
|
||||||
|
from face_analysis.face_mesh_landmarks import (
|
||||||
|
GLABELLA_9, GLABELLA_151, NOSE_BOTTOM, CHIN_TIP,
|
||||||
|
LEFT_EYE_OUTER, LEFT_EYE_INNER, RIGHT_EYE_INNER, RIGHT_EYE_OUTER,
|
||||||
|
LEFT_CHEEK, RIGHT_CHEEK, LEFT_POSITION, RIGHT_POSITION,
|
||||||
|
IRIS_LEFT_LEFT, IRIS_LEFT_RIGHT, IRIS_RIGHT_LEFT, IRIS_RIGHT_RIGHT,
|
||||||
|
PNP_INDICES,
|
||||||
|
)
|
||||||
|
from face_analysis.hair_segmenter import locate_hairline_by_segmentation
|
||||||
|
|
||||||
|
data = {"steps": {}, "debug": {"image_width": w, "image_height": h}}
|
||||||
|
steps = data["steps"]
|
||||||
|
dbg = data["debug"]
|
||||||
|
|
||||||
|
def put(key, bgr_img):
|
||||||
|
steps[key + "_base64"] = "data:image/jpeg;base64," + _jpg_b64(bgr_img)
|
||||||
|
|
||||||
|
# ① 输入原图
|
||||||
|
put("input", image)
|
||||||
|
|
||||||
|
# ② 人脸关键点检测
|
||||||
|
landmarks = detector.detect(image)
|
||||||
|
if landmarks is None:
|
||||||
|
dbg["num_landmarks"] = 0
|
||||||
|
return data, 1001, "无法识别人像"
|
||||||
|
lm = _lm_list(landmarks)
|
||||||
|
dbg["num_landmarks"] = len(lm)
|
||||||
|
|
||||||
|
vis_lm = image.copy()
|
||||||
|
# 先画全部 478 点(小白点)
|
||||||
|
s = min(w, h)
|
||||||
|
r_all = max(1, round(s * 0.0018))
|
||||||
|
for p in lm:
|
||||||
|
px = normalized_to_pixel(p, w, h)
|
||||||
|
cv2.circle(vis_lm, (int(px[0]), int(px[1])), r_all, (220, 220, 220), -1)
|
||||||
|
# 虹膜点 468~477(青色稍大)
|
||||||
|
r_iris = max(2, round(s * 0.0035))
|
||||||
|
for idx in [468, 469, 470, 471, 472, 473, 474, 475, 476, 477]:
|
||||||
|
if idx < len(lm):
|
||||||
|
px = normalized_to_pixel(lm[idx], w, h)
|
||||||
|
cv2.circle(vis_lm, (int(px[0]), int(px[1])), r_iris, (255, 200, 0), -1)
|
||||||
|
# 七眼 6 点 + 5 纵向点(红色 + 标号)
|
||||||
|
key_pts = {
|
||||||
|
"头顶(推算)": None, # 纵向点除眉心外由后续 measure 给出,这里只画能拿到的
|
||||||
|
"眉心": GLABELLA_9,
|
||||||
|
}
|
||||||
|
important = [
|
||||||
|
(GLABELLA_9, "眉间9"), (GLABELLA_151, "眉间151"), (NOSE_BOTTOM, "鼻翼下94"),
|
||||||
|
(CHIN_TIP, "下巴152"), (LEFT_EYE_OUTER, "左眼外33"), (LEFT_EYE_INNER, "左眼内133"),
|
||||||
|
(RIGHT_EYE_INNER, "右眼内362"), (RIGHT_EYE_OUTER, "右眼外263"),
|
||||||
|
(LEFT_CHEEK, "左脸234"), (RIGHT_CHEEK, "右脸454"),
|
||||||
|
]
|
||||||
|
r_imp = max(3, round(s * 0.005))
|
||||||
|
for idx, name in important:
|
||||||
|
px = normalized_to_pixel(lm[idx], w, h)
|
||||||
|
cv2.circle(vis_lm, (int(px[0]), int(px[1])), r_imp, (0, 0, 255), -1)
|
||||||
|
_draw_text_cv2(vis_lm, name, (int(px[0]) + r_imp + 2, int(px[1])),
|
||||||
|
color=(0, 255, 255), scale=max(0.35, s * 0.0013))
|
||||||
|
put("landmarks", vis_lm)
|
||||||
|
|
||||||
|
# ③ 头部姿态校验
|
||||||
|
head_pose = estimate_head_pose(lm, w, h) if hasattr(landmarks, "landmark") else estimate_head_pose(lm, w, h)
|
||||||
|
frontal = check_frontal_face(landmarks, w, h)
|
||||||
|
vis_pose = image.copy()
|
||||||
|
# 画 6 个 PnP 点(黄)
|
||||||
|
nose_tip_px = None
|
||||||
|
for idx in PNP_INDICES:
|
||||||
|
px = normalized_to_pixel(lm[idx], w, h)
|
||||||
|
cv2.circle(vis_pose, (int(px[0]), int(px[1])), max(3, round(s * 0.004)), (0, 255, 255), -1)
|
||||||
|
if idx == 1:
|
||||||
|
nose_tip_px = (int(px[0]), int(px[1]))
|
||||||
|
# 三轴箭头(鼻尖为原点)
|
||||||
|
if nose_tip_px is not None and head_pose is not None:
|
||||||
|
L = max(30, round(s * 0.08))
|
||||||
|
# yaw 绕 Y(竖轴) → 在屏幕上表现为左右;pitch 绕 X → 上下;roll 绕 Z → 面内旋转
|
||||||
|
yaw, pitch, roll = head_pose
|
||||||
|
import math
|
||||||
|
# 简化:用 roll 直接旋转 X/Y 轴示意,yaw 投影到横向、pitch 到纵向
|
||||||
|
cosr, sinr = math.cos(math.radians(roll)), math.sin(math.radians(roll))
|
||||||
|
# X 轴(红,向右)
|
||||||
|
cv2.arrowedLine(vis_pose, nose_tip_px,
|
||||||
|
(int(nose_tip_px[0] + L * cosr), int(nose_tip_px[1] + L * sinr)),
|
||||||
|
(0, 0, 255), max(2, round(s * 0.003)), tipLength=0.2)
|
||||||
|
# Y 轴(绿,向下)
|
||||||
|
cv2.arrowedLine(vis_pose, nose_tip_px,
|
||||||
|
(int(nose_tip_px[0] - L * sinr), int(nose_tip_px[1] + L * cosr)),
|
||||||
|
(0, 255, 0), max(2, round(s * 0.003)), tipLength=0.2)
|
||||||
|
# Z 轴(青,向内用圆圈示意)
|
||||||
|
cv2.circle(vis_pose, nose_tip_px, max(6, round(s * 0.012)), (255, 255, 0), max(1, round(s * 0.002)))
|
||||||
|
# 角度文字
|
||||||
|
txt = f"yaw={yaw:.1f} pitch={pitch:.1f} roll={roll:.1f}"
|
||||||
|
_draw_text_cv2(vis_pose, txt, (10, 10), color=(50, 255, 50),
|
||||||
|
scale=max(0.5, s * 0.0022))
|
||||||
|
_draw_text_cv2(vis_pose, f"frontal={'YES' if frontal else 'NO'}", (10, 40),
|
||||||
|
color=(50, 255, 50) if frontal else (50, 50, 255),
|
||||||
|
scale=max(0.5, s * 0.0022))
|
||||||
|
dbg["head_pose"] = {"yaw": round(yaw, 2), "pitch": round(pitch, 2),
|
||||||
|
"roll": round(roll, 2), "frontal": bool(frontal)}
|
||||||
|
put("pose", vis_pose)
|
||||||
|
|
||||||
|
if not frontal:
|
||||||
|
return data, 1003, "角度问题,请上传正面照"
|
||||||
|
|
||||||
|
# ④ 头发/耳朵分割
|
||||||
|
hair_mask = None
|
||||||
|
ear_mask = None
|
||||||
|
try:
|
||||||
|
from face_analysis.hair_segmenter import get_segmenter
|
||||||
|
pxs = [normalized_to_pixel(p, w, h) for p in lm]
|
||||||
|
face_box = (min(p[0] for p in pxs), min(p[1] for p in pxs),
|
||||||
|
max(p[0] for p in pxs), max(p[1] for p in pxs))
|
||||||
|
hair_mask, ear_mask = get_segmenter().segment_hair_and_ears(image, face_box=face_box)
|
||||||
|
except Exception as seg_e: # noqa: BLE001
|
||||||
|
logger.warning("[debug] 头发/耳朵分割失败:%s", seg_e)
|
||||||
|
|
||||||
|
vis_seg = image.copy()
|
||||||
|
if hair_mask is not None:
|
||||||
|
vis_seg = _overlay_mask(vis_seg, hair_mask, (0, 200, 0), alpha=0.45)
|
||||||
|
dbg["hair_pixels"] = int(np.asarray(hair_mask).astype(bool).sum())
|
||||||
|
if ear_mask is not None:
|
||||||
|
vis_seg = _overlay_mask(vis_seg, ear_mask, (200, 80, 0), alpha=0.5)
|
||||||
|
dbg["ear_pixels"] = int(np.asarray(ear_mask).astype(bool).sum())
|
||||||
|
_draw_text_cv2(vis_seg, "绿=头发(hair=17) 蓝=耳朵(ear=7/8)", (10, 10),
|
||||||
|
color=(50, 255, 50), scale=max(0.45, s * 0.0018))
|
||||||
|
put("segmentation", vis_seg)
|
||||||
|
|
||||||
|
# 主测量(复用 measure_face,内部含 ⑤ 纵向决策 + 七眼 + 尺度)
|
||||||
|
result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose)
|
||||||
|
v = result.vertical
|
||||||
|
dbg["hairline_source"] = result.hairline_source
|
||||||
|
|
||||||
|
# ⑤ 纵向定位(发际线/头顶)—— 复刻方案 B 的中轴线扫描
|
||||||
|
vis_hl = image.copy()
|
||||||
|
if hair_mask is not None:
|
||||||
|
vis_hl = _overlay_mask(vis_hl, hair_mask, (0, 180, 0), alpha=0.3)
|
||||||
|
brow_x, brow_y = _brow_center(lm, w, h)
|
||||||
|
# 中轴线 ±3px 列带高亮(黄)
|
||||||
|
cx = int(round(brow_x))
|
||||||
|
cv2.line(vis_hl, (max(0, cx - 3), 0), (max(0, cx - 3), h), (0, 230, 255), 1)
|
||||||
|
cv2.line(vis_hl, (min(w - 1, cx + 3), 0), (min(w - 1, cx + 3), h), (0, 230, 255), 1)
|
||||||
|
# 画 hairline_y / hair_top_y 两条横线
|
||||||
|
hairline_y = int(v["hairline"][1])
|
||||||
|
hair_top_y = int(v["hair_top"][1])
|
||||||
|
cv2.line(vis_hl, (0, hair_top_y), (w, hair_top_y), (255, 255, 0), max(2, round(s * 0.0025)))
|
||||||
|
cv2.line(vis_hl, (0, hairline_y), (w, hairline_y), (0, 100, 255), max(2, round(s * 0.0025)))
|
||||||
|
_draw_text_cv2(vis_hl, f"hair_top_y={hair_top_y}", (hair_top_y if hair_top_y < h - 40 else h - 40, 0),
|
||||||
|
color=(255, 255, 0), scale=max(0.4, s * 0.0015))
|
||||||
|
# 文字标注位置:hairline_y 行右侧
|
||||||
|
_draw_text_cv2(vis_hl, f"hairline_y={hairline_y} (source={result.hairline_source})",
|
||||||
|
(hairline_y, w - int(s * 0.5)), color=(0, 200, 255),
|
||||||
|
scale=max(0.4, s * 0.0015))
|
||||||
|
# 发际线弃用提示:顶庭 < 0.7cm 视为贴近头顶、不可靠
|
||||||
|
if result.hairline_discarded:
|
||||||
|
gap_cm = result.top_cm
|
||||||
|
_draw_text_cv2(vis_hl,
|
||||||
|
f"⚠️ 发际线离头顶仅 {gap_cm:.2f}cm (<0.7cm),已弃用",
|
||||||
|
(10, 10), color=(40, 40, 255), scale=max(0.5, s * 0.0022))
|
||||||
|
put("hairline", vis_hl)
|
||||||
|
|
||||||
|
# ⑥ 四庭纵向点
|
||||||
|
vis_v = image.copy()
|
||||||
|
v_names = ["hair_top", "hairline", "brow_center", "nose_bottom", "chin_tip"]
|
||||||
|
v_labels = ["头顶", "发际线", "眉心", "鼻翼下缘", "下巴尖"]
|
||||||
|
v_court_px = [v["top_court_px"], v["upper_court_px"], v["middle_court_px"], v["lower_court_px"]]
|
||||||
|
court_names = ["顶庭", "上庭", "中庭", "下庭"]
|
||||||
|
court_cm = [result.top_cm, result.upper_cm, result.middle_cm, result.lower_cm]
|
||||||
|
vx0 = min(int(v[n][0]) for n in v_names)
|
||||||
|
for i, name in enumerate(v_names):
|
||||||
|
x, y = int(v[name][0]), int(v[name][1])
|
||||||
|
cv2.circle(vis_v, (x, y), max(3, round(s * 0.004)), (0, 0, 255), -1)
|
||||||
|
# 画一条横线
|
||||||
|
cv2.line(vis_v, (vx0 - max(20, round(s * 0.04)), y),
|
||||||
|
(min(w - 1, vx0 + int(s * 0.02)), y), (0, 200, 255), 1)
|
||||||
|
_draw_text_cv2(vis_v, v_labels[i], (min(w - 60, x + 8), y),
|
||||||
|
color=(50, 255, 255), scale=max(0.4, s * 0.0015))
|
||||||
|
# 各庭段高(竖向虚线 + cm 文字)
|
||||||
|
for i in range(4):
|
||||||
|
y_a = int(v[v_names[i]][1])
|
||||||
|
y_b = int(v[v_names[i + 1]][1])
|
||||||
|
lx = max(10, vx0 - max(40, round(s * 0.08)))
|
||||||
|
cv2.line(vis_v, (lx, y_a), (lx, y_b), (0, 255, 100), max(2, round(s * 0.0025)))
|
||||||
|
cv2.circle(vis_v, (lx, y_a), 3, (0, 255, 100), -1)
|
||||||
|
cv2.circle(vis_v, (lx, y_b), 3, (0, 255, 100), -1)
|
||||||
|
_draw_text_cv2(vis_v, f"{court_names[i]} {court_cm[i]:.2f}cm",
|
||||||
|
(lx - int(s * 0.18), (y_a + y_b) // 2),
|
||||||
|
color=(100, 255, 100), scale=max(0.4, s * 0.0015))
|
||||||
|
dbg["vertical_points"] = {n: {"x": int(v[n][0]), "y": int(v[n][1])} for n in v_names}
|
||||||
|
put("vertical", vis_v)
|
||||||
|
|
||||||
|
# ⑦ 七眼横向点
|
||||||
|
vis_e = image.copy()
|
||||||
|
epts = result.eyes["points"]
|
||||||
|
seven_keys = ["left_cheek", "left_outer", "left_inner", "right_inner", "right_outer", "right_cheek"]
|
||||||
|
seven_labels = ["左脸颊", "左眼外", "左眼内", "右眼内", "右眼外", "右脸颊"]
|
||||||
|
ey0 = min(int(epts[k][1]) for k in seven_keys)
|
||||||
|
for i, k in enumerate(seven_keys):
|
||||||
|
x, y = int(epts[k][0]), int(epts[k][1])
|
||||||
|
cv2.circle(vis_e, (x, y), max(3, round(s * 0.004)), (0, 0, 255), -1)
|
||||||
|
cv2.line(vis_e, (x, max(0, ey0 - 20)), (x, min(h - 1, ey0 + 20)),
|
||||||
|
(0, 200, 255), 1)
|
||||||
|
_draw_text_cv2(vis_e, seven_labels[i], (x, ey0 - max(25, round(s * 0.04))),
|
||||||
|
color=(50, 255, 255), scale=max(0.4, s * 0.0015), anchor="ct")
|
||||||
|
# 头部最左/最右端线(耳朵外缘)
|
||||||
|
try:
|
||||||
|
from face_analysis.annotation import _ear_edges_from_mask
|
||||||
|
lcx, rcx = epts["left_cheek"][0], epts["right_cheek"][0]
|
||||||
|
head_l, head_r = _ear_edges_from_mask(
|
||||||
|
ear_mask, hair_mask, v["hair_top"][1], v["chin_tip"][1],
|
||||||
|
lcx, rcx, (lcx + rcx) / 2)
|
||||||
|
if head_l is not None:
|
||||||
|
cv2.line(vis_e, (int(head_l), 0), (int(head_l), h), (255, 100, 255), max(1, round(s * 0.002)))
|
||||||
|
_draw_text_cv2(vis_e, "人头最左", (int(head_l), 10),
|
||||||
|
color=(255, 150, 255), scale=max(0.35, s * 0.0013))
|
||||||
|
if head_r is not None:
|
||||||
|
cv2.line(vis_e, (int(head_r), 0), (int(head_r), h), (255, 100, 255), max(1, round(s * 0.002)))
|
||||||
|
_draw_text_cv2(vis_e, "人头最右", (int(head_r), 10),
|
||||||
|
color=(255, 150, 255), scale=max(0.35, s * 0.0013))
|
||||||
|
dbg["head_left_x"] = head_l
|
||||||
|
dbg["head_right_x"] = head_r
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.warning("[debug] 七眼端线绘制失败:%s", e)
|
||||||
|
dbg["seven_eye_points"] = {k: {"x": int(epts[k][0]), "y": int(epts[k][1])} for k in seven_keys}
|
||||||
|
put("seven_eyes", vis_e)
|
||||||
|
|
||||||
|
# ⑧ 尺度校准
|
||||||
|
vis_sc = image.copy()
|
||||||
|
px_per_cm = result.px_per_cm
|
||||||
|
iris_px = _iris_diameter_px(lm, w, h)
|
||||||
|
if iris_px is not None and iris_px > 0:
|
||||||
|
# 画左右虹膜直径线(青)
|
||||||
|
for (li, ri) in [(IRIS_LEFT_LEFT, IRIS_LEFT_RIGHT), (IRIS_RIGHT_LEFT, IRIS_RIGHT_RIGHT)]:
|
||||||
|
p1 = normalized_to_pixel(lm[li], w, h)
|
||||||
|
p2 = normalized_to_pixel(lm[ri], w, h)
|
||||||
|
cv2.line(vis_sc, (int(p1[0]), int(p1[1])), (int(p2[0]), int(p2[1])),
|
||||||
|
(255, 200, 0), max(2, round(s * 0.004)))
|
||||||
|
cv2.circle(vis_sc, (int(p1[0]), int(p1[1])), max(2, round(s * 0.003)), (255, 200, 0), -1)
|
||||||
|
cv2.circle(vis_sc, (int(p2[0]), int(p2[1])), max(2, round(s * 0.003)), (255, 200, 0), -1)
|
||||||
|
method = "iris"
|
||||||
|
_draw_text_cv2(vis_sc, f"虹膜直径法: {iris_px:.1f}px / {AVG_IRIS_DIAMETER_CM}cm", (10, 10),
|
||||||
|
color=(255, 200, 0), scale=max(0.45, s * 0.0018))
|
||||||
|
else:
|
||||||
|
# 降级眼宽法(黄)
|
||||||
|
eye_px = _eye_width_px(lm, w, h)
|
||||||
|
method = "eye_width"
|
||||||
|
for (oi, ii) in [(LEFT_EYE_OUTER, LEFT_EYE_INNER), (RIGHT_EYE_INNER, RIGHT_EYE_OUTER)]:
|
||||||
|
p1 = normalized_to_pixel(lm[oi], w, h)
|
||||||
|
p2 = normalized_to_pixel(lm[ii], w, h)
|
||||||
|
cv2.line(vis_sc, (int(p1[0]), int(p1[1])), (int(p2[0]), int(p2[1])),
|
||||||
|
(0, 255, 255), max(2, round(s * 0.004)))
|
||||||
|
_draw_text_cv2(vis_sc, f"眼宽法(降级): {eye_px:.1f}px / {AVG_EYE_WIDTH_CM}cm", (10, 10),
|
||||||
|
color=(0, 255, 255), scale=max(0.45, s * 0.0018))
|
||||||
|
_draw_text_cv2(vis_sc, f"px_per_cm = {px_per_cm:.3f}", (10, 40),
|
||||||
|
color=(50, 255, 50), scale=max(0.5, s * 0.002))
|
||||||
|
dbg["px_per_cm"] = round(px_per_cm, 4)
|
||||||
|
dbg["scale_method"] = method
|
||||||
|
put("scale", vis_sc)
|
||||||
|
|
||||||
|
# 把 to_response 的数值并入 data(前端指标速览复用)
|
||||||
|
data.update(result.to_response())
|
||||||
|
# 七眼段宽
|
||||||
|
try:
|
||||||
|
pc = result.px_per_cm
|
||||||
|
inner_xs = [epts["left_cheek"][0], epts["left_outer"][0], epts["left_inner"][0],
|
||||||
|
epts["right_inner"][0], epts["right_outer"][0], epts["right_cheek"][0]]
|
||||||
|
data.setdefault("seven_eyes", {})
|
||||||
|
for i in range(5):
|
||||||
|
a, b = inner_xs[i], inner_xs[i + 1]
|
||||||
|
data["seven_eyes"][f"eye{i + 2}"] = (
|
||||||
|
None if (a is None or b is None) else round((b - a) / pc, 2))
|
||||||
|
from face_analysis.annotation import _ear_edges_from_mask as _eef
|
||||||
|
lcx, rcx = epts["left_cheek"][0], epts["right_cheek"][0]
|
||||||
|
# 弃用时用眉心做上界(与 _run_face_measure_data 一致)
|
||||||
|
top_y = v["brow_center"][1] if result.hairline_discarded else v["hair_top"][1]
|
||||||
|
head_l, head_r = _eef(ear_mask, hair_mask, top_y, v["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)
|
||||||
|
data["seven_eyes"]["eye7"] = None if head_r is None else round((head_r - rcx) / pc, 2)
|
||||||
|
except Exception as seg_e: # noqa: BLE001
|
||||||
|
logger.warning("[debug] 七眼段宽计算失败:%s", seg_e)
|
||||||
|
|
||||||
|
# ⑨ 最终标注图(原图 + 标注层叠加)
|
||||||
|
try:
|
||||||
|
from face_analysis.annotation import create_annotated_image
|
||||||
|
annotated = create_annotated_image(image, result, ear_mask=ear_mask, hair_mask=hair_mask)
|
||||||
|
anno_rgba = np.asarray(annotated)
|
||||||
|
# 叠加到原图
|
||||||
|
vis_final = image.copy()
|
||||||
|
alpha = anno_rgba[:, :, 3:4].astype(np.float32) / 255.0
|
||||||
|
vis_final = (vis_final.astype(np.float32) * (1 - alpha)
|
||||||
|
+ anno_rgba[:, :, :3].astype(np.float32) * alpha)
|
||||||
|
vis_final = np.clip(vis_final, 0, 255).astype(np.uint8)
|
||||||
|
put("final", vis_final)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.warning("[debug] 标注图叠加失败:%s", e)
|
||||||
|
|
||||||
|
return data, None, None
|
||||||
|
|
||||||
|
|
||||||
async def _face_measure_impl(image_file, image_url, image_base64, variant="v1"):
|
async def _face_measure_impl(image_file, image_url, image_base64, variant="v1"):
|
||||||
"""接口1/6 共用实现:四庭七眼测量 + 标注图生成。返回 (ok_dict, err_dict)。
|
"""接口1/6 共用实现:四庭七眼测量 + 标注图生成。返回 (ok_dict, err_dict)。
|
||||||
|
|
||||||
@@ -581,6 +991,39 @@ async def face_measure(
|
|||||||
return ok_data if ok_data is not None else err_data
|
return ok_data if ok_data is not None else err_data
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 接口1 调试:分步可视化(每一步中间产物图 + 原理)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app.post("/api/v1/face/measure-debug", include_in_schema=False)
|
||||||
|
async def face_measure_debug(
|
||||||
|
image_file: Optional[UploadFile] = File(default=None),
|
||||||
|
image_url: Optional[str] = Form(default=None),
|
||||||
|
image_base64: Optional[str] = Form(default=None),
|
||||||
|
):
|
||||||
|
"""接口1 调试:返回算法每一步的中间产物图(data.steps.*_base64)+ 数值(data.debug)。
|
||||||
|
|
||||||
|
与正式接口同链路,但额外产出 9 张分步叠加图(输入/关键点/姿态/分割/发际线/
|
||||||
|
四庭/七眼/尺度/最终标注),供调试页分步可视化。错误时仍返回已完成的步骤图。
|
||||||
|
"""
|
||||||
|
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
|
||||||
|
if e is not None:
|
||||||
|
return e
|
||||||
|
image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
|
||||||
|
if image is None:
|
||||||
|
return err(1008, "图片格式不支持(仅 JPG / PNG)")
|
||||||
|
try:
|
||||||
|
data, code, msg = _run_face_measure_data_debug(image)
|
||||||
|
if code is not None:
|
||||||
|
# 仍带分步图返回,前端可展示卡在哪一步
|
||||||
|
return {"code": code, "message": msg,
|
||||||
|
"request_id": "mock-request-id", "data": data}
|
||||||
|
return ok(data)
|
||||||
|
except Exception as ex: # noqa: BLE001
|
||||||
|
logger.exception("接口1 调试处理异常")
|
||||||
|
return err(1007, f"处理失败:{ex}")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 接口 6:四庭七眼测量标注 v2(复刻接口1)
|
# 接口 6:四庭七眼测量标注 v2(复刻接口1)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -757,7 +1200,9 @@ async def hair_grow(
|
|||||||
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填),如 1,2,3。female:1-5 male:1-4"),
|
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填),如 1,2,3。female:1-5 male:1-4"),
|
||||||
beauty_enabled: bool = Form(default=False, description="是否开启美颜(本期不生效)"),
|
beauty_enabled: bool = Form(default=False, description="是否开启美颜(本期不生效)"),
|
||||||
use_mask: bool = Form(default=True, description="是否启用 inpaint 遮罩(测试对比用)。false 时用干净原图生成(空遮罩,不烧模板线)"),
|
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)
|
# 1. gender 必填校验(非法/缺失 → 1004)
|
||||||
if gender not in ("male", "female"):
|
if gender not in ("male", "female"):
|
||||||
@@ -786,11 +1231,13 @@ async def hair_grow(
|
|||||||
if gender == "female":
|
if gender == "female":
|
||||||
from hairline.service import generate_grow_results_swap
|
from hairline.service import generate_grow_results_swap
|
||||||
items = await run_in_threadpool(
|
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:
|
else:
|
||||||
from hairline.service import generate_grow_results
|
from hairline.service import generate_grow_results
|
||||||
items = await run_in_threadpool(
|
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:
|
if items is None:
|
||||||
return err(1001, "无法识别人像")
|
return err(1001, "无法识别人像")
|
||||||
|
|
||||||
@@ -809,6 +1256,149 @@ async def hair_grow(
|
|||||||
return err(1007, f"处理失败:{ex}")
|
return err(1007, f"处理失败:{ex}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 调试接口:接口2 女性生发 分步计时
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@app.post(
|
||||||
|
"/api/v1/debug/grow-timing",
|
||||||
|
summary="调试-接口2女性生发分步计时",
|
||||||
|
tags=["调试"],
|
||||||
|
include_in_schema=False,
|
||||||
|
)
|
||||||
|
async def debug_grow_timing(
|
||||||
|
image_file: Optional[UploadFile] = File(default=None),
|
||||||
|
image_url: Optional[str] = Form(default=None),
|
||||||
|
image_base64: Optional[str] = Form(default=None),
|
||||||
|
hair_style: str = Form(default="2", description="发型序号(花瓣=2),逗号分隔多选"),
|
||||||
|
webui_steps: Optional[int] = Form(default=None, description="swapHair webui img2img 采样步数,None=服务端默认(15),可填10/15/20/25对比"),
|
||||||
|
redraw_max_side: Optional[int] = Form(default=None, description="ComfyUI重绘分辨率(长边像素)。None=默认896;0=原图不缩;其他如640/768/1024"),
|
||||||
|
redraw_prompt: Optional[str] = Form(default=None, description="ComfyUI重绘提示词,None=默认'填充遮罩区域的头发'"),
|
||||||
|
):
|
||||||
|
"""单图跑接口2女性生发,返回每个步骤的耗时 + 结果图,用于定位性能瓶颈。
|
||||||
|
|
||||||
|
步骤拆分:
|
||||||
|
1. extract_context:人脸关键点检测 + 头发分割 + 发际线几何
|
||||||
|
2. [每个发型] generate_hairline_redraw:
|
||||||
|
2a. compute_mask:发际线遮罩计算
|
||||||
|
2b. _call_swap:调 change_hair 换发型(内含 webui SD1.5 推理,远程或本机)
|
||||||
|
2c. _composite:接缝融合(多频段/羽化)
|
||||||
|
3. [每个发型] _call_local_redraw:调本机 ComfyUI 用 Flux.2 重绘
|
||||||
|
"""
|
||||||
|
import time as _time
|
||||||
|
from fastapi.concurrency import run_in_threadpool
|
||||||
|
|
||||||
|
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
|
||||||
|
if e is not None:
|
||||||
|
return e
|
||||||
|
image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
|
||||||
|
if image is None:
|
||||||
|
return err(1008, "图片格式不支持(仅 JPG / PNG)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
max_styles = 5
|
||||||
|
hair_styles = _parse_hair_styles(hair_style, max_styles)
|
||||||
|
if hair_styles is None:
|
||||||
|
return err(1007, f"hair_style 必须为 1..{max_styles}")
|
||||||
|
|
||||||
|
t_total0 = _time.perf_counter()
|
||||||
|
timings = {"total_ms": 0, "extract_context_ms": 0, "per_hairstyle": []}
|
||||||
|
|
||||||
|
# 步骤1: extract_context
|
||||||
|
t0 = _time.perf_counter()
|
||||||
|
from hairline.service import extract_context as _ec, _call_local_redraw, _REDRAW_MAX_SIDE # noqa
|
||||||
|
from face_analysis.hairline_grow import generate_hairline_redraw, NoFaceError # noqa
|
||||||
|
from face_analysis.head_mask import SEGFORMER_HAIR # noqa
|
||||||
|
ctx = await run_in_threadpool(_ec, image)
|
||||||
|
timings["extract_context_ms"] = int((_time.perf_counter() - t0) * 1000)
|
||||||
|
if ctx is None:
|
||||||
|
return err(1001, "无法识别人像")
|
||||||
|
|
||||||
|
hair_mask_reuse = (ctx["parse_map"] == SEGFORMER_HAIR)
|
||||||
|
h, w = image.shape[:2]
|
||||||
|
eff_side = _REDRAW_MAX_SIDE if redraw_max_side is None else redraw_max_side
|
||||||
|
redraw_img, hair_mask_redraw = image, hair_mask_reuse
|
||||||
|
downscale_info = None
|
||||||
|
if eff_side > 0 and max(h, w) > eff_side:
|
||||||
|
from hairline.service import _downscale_max_side
|
||||||
|
redraw_img, _rs = _downscale_max_side(image, 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)
|
||||||
|
downscale_info = {"from": f"{w}x{h}", "to": f"{_nw}x{_nh}", "max_side": eff_side}
|
||||||
|
|
||||||
|
textures_map = None
|
||||||
|
from hairline.service import get_texture_map, _FEMALE_KEY_TO_CHANG, load_texture_rgba, build_overlay_layer, load_ext_mesh
|
||||||
|
textures = get_texture_map()["female"]
|
||||||
|
items = [(s, textures[s - 1]) for s in hair_styles]
|
||||||
|
|
||||||
|
for order, (key, white_path) in items:
|
||||||
|
hs_t0 = _time.perf_counter()
|
||||||
|
entry = {"hairline_type": key, "order": order}
|
||||||
|
chang_id = _FEMALE_KEY_TO_CHANG.get(key)
|
||||||
|
entry["chang_id"] = chang_id
|
||||||
|
entry["ok"] = False
|
||||||
|
entry["error"] = None
|
||||||
|
entry["grown_b64"] = None
|
||||||
|
if chang_id is None:
|
||||||
|
entry["error"] = f"无对应 chang_id"
|
||||||
|
timings["per_hairstyle"].append(entry)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
# 2a/2b/2c: generate_hairline_redraw (内部含 mask+swap+blend)
|
||||||
|
t0 = _time.perf_counter()
|
||||||
|
data = await run_in_threadpool(
|
||||||
|
generate_hairline_redraw, redraw_img, chang_id,
|
||||||
|
hair_mask=hair_mask_redraw, webui_steps=webui_steps, **_V2_FINAL_DEFAULTS)
|
||||||
|
t_redraw_pipeline = _time.perf_counter() - t0
|
||||||
|
_tm = data.get("timings_ms") or {}
|
||||||
|
entry["mask_ms"] = _tm.get("mask", 0)
|
||||||
|
entry["swap_ms"] = _tm.get("swap", 0)
|
||||||
|
entry["blend_ms"] = _tm.get("blend", 0)
|
||||||
|
entry["redraw_pipeline_ms"] = int(t_redraw_pipeline * 1000)
|
||||||
|
|
||||||
|
steps = data.get("steps") or {}
|
||||||
|
final_b64 = steps.get("final_base64") or ""
|
||||||
|
mask_b64 = steps.get("redraw_band_mask_base64") or ""
|
||||||
|
if not final_b64 or not mask_b64:
|
||||||
|
entry["error"] = f"final/遮罩缺失(final={len(final_b64)} mask={len(mask_b64)})"
|
||||||
|
timings["per_hairstyle"].append(entry)
|
||||||
|
continue
|
||||||
|
if final_b64.startswith("data:"):
|
||||||
|
final_b64 = final_b64.split(",", 1)[1]
|
||||||
|
if mask_b64.startswith("data:"):
|
||||||
|
mask_b64 = mask_b64.split(",", 1)[1]
|
||||||
|
|
||||||
|
# 3: ComfyUI 重绘
|
||||||
|
t0 = _time.perf_counter()
|
||||||
|
# max_side: 0 或 None 都让 _call_local_redraw 用默认逻辑(外层已控制分辨率)
|
||||||
|
_ms = redraw_max_side if redraw_max_side is not None and redraw_max_side > 0 else None
|
||||||
|
grown_png = await run_in_threadpool(
|
||||||
|
_call_local_redraw,
|
||||||
|
base64.b64decode(final_b64), base64.b64decode(mask_b64),
|
||||||
|
max_side=_ms, prompt=redraw_prompt)
|
||||||
|
entry["comfyui_redraw_ms"] = int((_time.perf_counter() - t0) * 1000)
|
||||||
|
if grown_png:
|
||||||
|
entry["grown_b64"] = "data:image/jpeg;base64," + _png_to_jpg_b64(grown_png)
|
||||||
|
entry["ok"] = True
|
||||||
|
else:
|
||||||
|
entry["error"] = "ComfyUI 重绘返回空"
|
||||||
|
except NoFaceError:
|
||||||
|
entry["error"] = "未检出人脸"
|
||||||
|
except Exception as ex: # noqa: BLE001
|
||||||
|
entry["error"] = str(ex)[:150]
|
||||||
|
entry["hairstyle_total_ms"] = int((_time.perf_counter() - hs_t0) * 1000)
|
||||||
|
timings["per_hairstyle"].append(entry)
|
||||||
|
|
||||||
|
timings["total_ms"] = int((_time.perf_counter() - t_total0) * 1000)
|
||||||
|
timings["downscale"] = downscale_info
|
||||||
|
timings["image_size"] = f"{w}x{h}"
|
||||||
|
return ok(timings)
|
||||||
|
except Exception as ex: # noqa: BLE001
|
||||||
|
logger.exception("debug/grow-timing 异常")
|
||||||
|
return err(1007, f"处理失败:{ex}")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 接口 7:C 端生发 v2 —— 已弃用(add_hair2.json 用 Klein-9b 大模型,会把常驻的
|
# 接口 7:C 端生发 v2 —— 已弃用(add_hair2.json 用 Klein-9b 大模型,会把常驻的
|
||||||
# Klein-4b/Flux 挤出显存,导致接口2/3/5 耗时抖动;且业务已不再调用)。
|
# Klein-4b/Flux 挤出显存,导致接口2/3/5 耗时抖动;且业务已不再调用)。
|
||||||
@@ -868,7 +1458,7 @@ async def hair_grow_b(
|
|||||||
marked_image_url: Optional[str] = Form(default=None, description="划线图片 URL"),
|
marked_image_url: Optional[str] = Form(default=None, description="划线图片 URL"),
|
||||||
marked_image_base64: Optional[str] = Form(default=None, description="划线图片 base64"),
|
marked_image_base64: Optional[str] = Form(default=None, description="划线图片 base64"),
|
||||||
use_mask: bool = Form(default=True, description="是否画发际线(测试对比用)。false 时跳过划线检测、直接送划线图"),
|
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)
|
marked_raw, e = await resolve_image_bytes(marked_image_file, marked_image_url, marked_image_base64)
|
||||||
@@ -1078,7 +1668,7 @@ async def hairline_generate(
|
|||||||
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
|
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"),
|
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,测试对比用)"),
|
use_mask: bool = Form(default=True, description="生发是否启用 inpaint 遮罩(同接口2,测试对比用)"),
|
||||||
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", description="ComfyUI 提示词(同接口2),会替换工作流节点60的文本"),
|
prompt: str = Form(default="填充遮罩区域的头发", description="ComfyUI 提示词(同接口2),会替换工作流节点60的文本"),
|
||||||
generate_grow_image: bool = Form(default=True, description="是否生成生发效果图(ComfyUI 生发,最耗时)。默认 true 出图;false 时跳过生发,各发型 grown_image 恒为 null,仅返回三档发际线叠图与中心点"),
|
generate_grow_image: bool = Form(default=True, description="是否生成生发效果图(ComfyUI 生发,最耗时)。默认 true 出图;false 时跳过生发,各发型 grown_image 恒为 null,仅返回三档发际线叠图与中心点"),
|
||||||
):
|
):
|
||||||
if gender not in ("male", "female"):
|
if gender not in ("male", "female"):
|
||||||
@@ -1472,7 +2062,7 @@ async def hairline_grow_v2(
|
|||||||
inpainting_fill: int = Form(default=1, description="change_hair服务端重绘填充:0=保留原图 | 1=噪声 | 2=纯色 | 3=潜变量。默认 1"),
|
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_blur: int = Form(default=11, description="change_hair服务端遮罩边缘模糊像素,默认 11"),
|
||||||
mask_dilate_scale: float = Form(default=1.0, description="change_hair服务端遮罩膨胀缩放,默认 1.0"),
|
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"),
|
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_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"),
|
band_hi_mult: float = Form(default=1.5, description="重绘带外推倍率上限(相对 hairline_push_cm),默认 1.5"),
|
||||||
@@ -1638,7 +2228,7 @@ async def hairline_grow_v2_final_v2(
|
|||||||
async def api_redraw(
|
async def api_redraw(
|
||||||
image_file: UploadFile = File(..., description="人物图片(JPG/PNG)"),
|
image_file: UploadFile = File(..., description="人物图片(JPG/PNG)"),
|
||||||
mask_file: UploadFile = File(..., description="遮罩图片(PNG,支持红/白/alpha 格式)"),
|
mask_file: UploadFile = File(..., description="遮罩图片(PNG,支持红/白/alpha 格式)"),
|
||||||
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜",
|
prompt: str = Form(default="填充遮罩区域的头发",
|
||||||
description="ComfyUI 提示词"),
|
description="ComfyUI 提示词"),
|
||||||
):
|
):
|
||||||
image_bytes = await image_file.read()
|
image_bytes = await image_file.read()
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""快速测试:3张图×花瓣发型×896分辨率,新提示词"填充遮罩区域的头发"。
|
||||||
|
预热1次+正式1次。
|
||||||
|
"""
|
||||||
|
import base64, json, os, time
|
||||||
|
from pathlib import Path
|
||||||
|
import requests
|
||||||
|
|
||||||
|
API = "http://127.0.0.1:8187/api/v1/debug/grow-timing"
|
||||||
|
TOKEN = "dev-shared-secret-2026"
|
||||||
|
PROMPT = "填充遮罩区域的头发"
|
||||||
|
OUT = Path("/home/ubuntu/hair/benchmark_out/bench6")
|
||||||
|
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"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def call(img_path, save_grown=None, timeout=300):
|
||||||
|
data = {"hair_style": "2", "webui_steps": "15", "redraw_max_side": "896", "redraw_prompt": PROMPT}
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
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=data, timeout=timeout)
|
||||||
|
wall = time.perf_counter() - t0
|
||||||
|
j = r.json()
|
||||||
|
d = j["data"]; hs = d["per_hairstyle"][0]
|
||||||
|
if save_grown and hs.get("grown_b64"):
|
||||||
|
b = hs["grown_b64"].split(",")[1] if "," in hs["grown_b64"] else hs["grown_b64"]
|
||||||
|
open(save_grown, "wb").write(base64.b64decode(b))
|
||||||
|
return {"ok": hs.get("ok"), "total_ms": d["total_ms"], "comfy_ms": hs.get("comfyui_redraw_ms"),
|
||||||
|
"grown_path": str(save_grown) if save_grown and hs.get("ok") else None}
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for ilabel, ipath in IMGS:
|
||||||
|
print(f"预热 {ilabel}...", flush=True)
|
||||||
|
call(ipath)
|
||||||
|
save = OUT / f"{ilabel}_flower_896.jpg"
|
||||||
|
print(f"正式 {ilabel}...", flush=True)
|
||||||
|
r = call(ipath, save_grown=save)
|
||||||
|
r["img"] = ilabel
|
||||||
|
print(f" -> total={r['total_ms']}ms ok={r['ok']}", flush=True)
|
||||||
|
results.append(r)
|
||||||
|
|
||||||
|
json.dump({"prompt": PROMPT, "results": results}, open(OUT/"results.json","w"), ensure_ascii=False, indent=2)
|
||||||
|
print(f"\n✓ 完成 {sum(1 for r in results if r['ok'])}/3", flush=True)
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""接口2女性 花瓣形 单发型 4模型×3分辨率×3图×3次 矩阵测试。
|
||||||
|
|
||||||
|
调用本机 hair-worker (:8187) 的 /api/v1/hair/grow,gender=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()
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""分辨率对比测试:4图×5发型=20行,每行4种分辨率(不缩放/896/768/640),steps=15。
|
||||||
|
热数据:每个组合预热1次(丢弃)+正式1次。OOM的跳过记录为失败。
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
API = "http://127.0.0.1:8187/api/v1/debug/grow-timing"
|
||||||
|
TOKEN = "dev-shared-secret-2026"
|
||||||
|
OUT = Path("/home/ubuntu/hair/benchmark_out/bench3")
|
||||||
|
OUT.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
IMGS = [
|
||||||
|
("asdf", "/home/ubuntu/hair/image/asdf.jpg"),
|
||||||
|
("qwer", "/home/ubuntu/hair/image/qwer.jpg"),
|
||||||
|
("girl2", "/home/ubuntu/hair/image/girl_img/girl2.jpg"),
|
||||||
|
("girl5", "/home/ubuntu/hair/image/girl_img/girl5.jpg"),
|
||||||
|
]
|
||||||
|
HAIRSTYLES = [
|
||||||
|
(1, "ellipse", "椭圆"), (2, "flower", "花瓣"), (3, "heart", "心形"),
|
||||||
|
(4, "straight", "直线"), (5, "wave", "波浪"),
|
||||||
|
]
|
||||||
|
# 分辨率档:0=不缩放(原图)
|
||||||
|
RES_LIST = [("orig", "0"), ("896", "896"), ("768", "768"), ("640", "640")]
|
||||||
|
RES_TITLES = ["原图(不缩放)", "896", "768", "640"]
|
||||||
|
STEPS = 15
|
||||||
|
|
||||||
|
|
||||||
|
def call(img_path, hair_num, redraw_max_side, save_grown=None, timeout=300):
|
||||||
|
data = {"hair_style": str(hair_num), "webui_steps": str(STEPS),
|
||||||
|
"redraw_max_side": str(redraw_max_side)}
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
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=data, timeout=timeout)
|
||||||
|
wall = time.perf_counter() - t0
|
||||||
|
j = r.json()
|
||||||
|
if j.get("code") != 0:
|
||||||
|
return {"ok": False, "error": j.get("message", "")[:80], "wall": wall}
|
||||||
|
d = j["data"]
|
||||||
|
hs = d["per_hairstyle"][0]
|
||||||
|
if save_grown and hs.get("grown_b64"):
|
||||||
|
b = hs["grown_b64"].split(",")[1] if "," in hs["grown_b64"] else hs["grown_b64"]
|
||||||
|
with open(save_grown, "wb") as gf:
|
||||||
|
gf.write(base64.b64decode(b))
|
||||||
|
return {
|
||||||
|
"ok": hs.get("ok", False), "wall": wall,
|
||||||
|
"total_ms": d["total_ms"], "swap_ms": hs.get("swap_ms"),
|
||||||
|
"comfy_ms": hs.get("comfyui_redraw_ms"),
|
||||||
|
"error": hs.get("error"),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e)[:80], "wall": time.perf_counter() - t0}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
rows = []
|
||||||
|
total = len(IMGS) * len(HAIRSTYLES) * len(RES_LIST) * 2
|
||||||
|
idx = 0
|
||||||
|
for ilabel, ipath in IMGS:
|
||||||
|
for hnum, hkey, hname in HAIRSTYLES:
|
||||||
|
cells = []
|
||||||
|
for (rlabel, rval), rtitle in zip(RES_LIST, RES_TITLES):
|
||||||
|
# 预热
|
||||||
|
idx += 1
|
||||||
|
print(f"[{idx}/{total}] 预热 {ilabel}|{hname}|{rtitle}", flush=True)
|
||||||
|
try:
|
||||||
|
call(ipath, hnum, rval, timeout=120)
|
||||||
|
except Exception:
|
||||||
|
pass # 预热失败(可能OOM)不中断
|
||||||
|
# 正式
|
||||||
|
idx += 1
|
||||||
|
save = OUT / f"{ilabel}_{hkey}_{rlabel}.jpg"
|
||||||
|
print(f"[{idx}/{total}] 正式 {ilabel}|{hname}|{rtitle}", flush=True)
|
||||||
|
r = call(ipath, hnum, rval, save_grown=save, timeout=300)
|
||||||
|
r["res_label"] = rlabel; r["res_title"] = rtitle
|
||||||
|
r["grown_path"] = str(save) if r.get("ok") else None
|
||||||
|
status = f"{r.get('total_ms')}ms" if r.get("ok") else f"FAIL:{r.get('error','')[:30]}"
|
||||||
|
print(f" -> {status}", flush=True)
|
||||||
|
cells.append(r)
|
||||||
|
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({"res_titles": RES_TITLES, "rows": rows}, f, ensure_ascii=False, indent=2)
|
||||||
|
ok = sum(1 for row in rows for c in row["cells"] if c.get("ok"))
|
||||||
|
print(f"\n✓ 完成: {ok}/{len(rows)*len(RES_LIST)} 成功 -> {OUT/'results.json'}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""生成分辨率对比报告:20行(4图×5发型) × 4列(原图不缩放/896/768/640)。"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
OUT = Path("/home/ubuntu/hair/benchmark_out/bench3")
|
||||||
|
RESULTS = OUT / "results.json"
|
||||||
|
HTML = OUT / "report.html"
|
||||||
|
|
||||||
|
|
||||||
|
def img_src(path):
|
||||||
|
if not path or not os.path.isfile(path):
|
||||||
|
return None
|
||||||
|
return "bench3/" + os.path.basename(path)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
d = json.load(open(RESULTS, encoding="utf-8"))
|
||||||
|
titles = d["res_titles"]
|
||||||
|
rows = d["rows"]
|
||||||
|
|
||||||
|
# 各分辨率平均耗时
|
||||||
|
col_stats = defaultdict(lambda: {"total": [], "comfy": []})
|
||||||
|
for r in rows:
|
||||||
|
for c in r["cells"]:
|
||||||
|
if c.get("ok"):
|
||||||
|
col_stats[c["res_title"]]["total"].append(c["total_ms"])
|
||||||
|
col_stats[c["res_title"]]["comfy"].append(c.get("comfy_ms", 0))
|
||||||
|
|
||||||
|
# 表头
|
||||||
|
headers = ['<th class="col-label">原图</th>']
|
||||||
|
for t in titles:
|
||||||
|
s = col_stats.get(t)
|
||||||
|
avg = sum(s["total"]) // len(s["total"]) if s and s["total"] else 0
|
||||||
|
headers.append(f'<th class="col-label"><div class="col-title">{t}</div>'
|
||||||
|
f'<div class="col-stat">均{avg/1000:.1f}s</div></th>')
|
||||||
|
|
||||||
|
# 表体
|
||||||
|
body_rows = []
|
||||||
|
for r in rows:
|
||||||
|
label = f'<div class="row-label">{r["img"]}<br><b>{r["hair_name"]}</b></div>'
|
||||||
|
# 原图缩略图(用 orig 档的结果当原图展示,或用原图文件)
|
||||||
|
orig_cell = f'<td class="cell-orig"><div class="row-label-cell">{label}</div></td>'
|
||||||
|
cells = [orig_cell]
|
||||||
|
for c in r["cells"]:
|
||||||
|
src = img_src(c.get("grown_path")) if c.get("ok") else None
|
||||||
|
if src:
|
||||||
|
t = c.get("total_ms", 0)
|
||||||
|
cells.append(f'<td class="cell-result"><img class="result-img" src="{src}" loading="lazy">'
|
||||||
|
f'<div class="cell-time">{t/1000:.1f}s</div></td>')
|
||||||
|
else:
|
||||||
|
cells.append(f'<td class="cell-result"><div class="na">⚠<br>{c.get("error","")[:20]}</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>重绘分辨率对比报告 — steps=15</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: 130px; max-width: 150px; }}
|
||||||
|
.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; }}
|
||||||
|
img {{ border-radius: 4px; max-width: 130px; max-height: 160px; object-fit: contain; background: #f3f4f6; }}
|
||||||
|
.cell-time {{ font-size: 10px; color: #9ca3af; margin-top: 2px; }}
|
||||||
|
.na {{ color: #d1d5db; font-size: 12px; padding: 40px 10px; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>📊 重绘分辨率对比报告</h1>
|
||||||
|
<p class="subtitle">4图×5发型=20行 · 每行4分辨率(原图不缩放/896/768/640) · steps=15 · 热数据 · 80/80成功 · 峰值21.2GB · 0 OOM</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()
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""分辨率对比测试(新提示词版):4图×5发型=20行,每行4种分辨率,steps=15。
|
||||||
|
提示词固定为 "填充遮罩区域的头发"。
|
||||||
|
热数据:预热1次+正式1次。
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
API = "http://127.0.0.1:8187/api/v1/debug/grow-timing"
|
||||||
|
TOKEN = "dev-shared-secret-2026"
|
||||||
|
PROMPT = "填充遮罩区域的头发"
|
||||||
|
OUT = Path("/home/ubuntu/hair/benchmark_out/bench7")
|
||||||
|
OUT.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
IMGS = [
|
||||||
|
("asdf", "/home/ubuntu/hair/image/asdf.jpg"),
|
||||||
|
("qwer", "/home/ubuntu/hair/image/qwer.jpg"),
|
||||||
|
("girl2", "/home/ubuntu/hair/image/girl_img/girl2.jpg"),
|
||||||
|
("girl5", "/home/ubuntu/hair/image/girl_img/girl5.jpg"),
|
||||||
|
]
|
||||||
|
HAIRSTYLES = [
|
||||||
|
(1, "ellipse", "椭圆"), (2, "flower", "花瓣"), (3, "heart", "心形"),
|
||||||
|
(4, "straight", "直线"), (5, "wave", "波浪"),
|
||||||
|
]
|
||||||
|
RES_LIST = [("orig", "0"), ("896", "896"), ("768", "768"), ("640", "640")]
|
||||||
|
RES_TITLES = ["原图(不缩放)", "896", "768", "640"]
|
||||||
|
STEPS = 15
|
||||||
|
|
||||||
|
|
||||||
|
def call(img_path, hair_num, redraw_max_side, save_grown=None, timeout=300):
|
||||||
|
data = {"hair_style": str(hair_num), "webui_steps": str(STEPS),
|
||||||
|
"redraw_max_side": str(redraw_max_side), "redraw_prompt": PROMPT}
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
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=data, timeout=timeout)
|
||||||
|
wall = time.perf_counter() - t0
|
||||||
|
j = r.json()
|
||||||
|
if j.get("code") != 0:
|
||||||
|
return {"ok": False, "error": j.get("message", "")[:80], "wall": wall}
|
||||||
|
d = j["data"]
|
||||||
|
hs = d["per_hairstyle"][0]
|
||||||
|
if save_grown and hs.get("grown_b64"):
|
||||||
|
b = hs["grown_b64"].split(",")[1] if "," in hs["grown_b64"] else hs["grown_b64"]
|
||||||
|
with open(save_grown, "wb") as gf:
|
||||||
|
gf.write(base64.b64decode(b))
|
||||||
|
return {
|
||||||
|
"ok": hs.get("ok", False), "wall": wall,
|
||||||
|
"total_ms": d["total_ms"], "swap_ms": hs.get("swap_ms"),
|
||||||
|
"comfy_ms": hs.get("comfyui_redraw_ms"),
|
||||||
|
"error": hs.get("error"),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e)[:80], "wall": time.perf_counter() - t0}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
rows = []
|
||||||
|
total = len(IMGS) * len(HAIRSTYLES) * len(RES_LIST) * 2
|
||||||
|
idx = 0
|
||||||
|
for ilabel, ipath in IMGS:
|
||||||
|
for hnum, hkey, hname in HAIRSTYLES:
|
||||||
|
cells = []
|
||||||
|
for (rlabel, rval), rtitle in zip(RES_LIST, RES_TITLES):
|
||||||
|
idx += 1
|
||||||
|
print(f"[{idx}/{total}] 预热 {ilabel}|{hname}|{rtitle}", flush=True)
|
||||||
|
try:
|
||||||
|
call(ipath, hnum, rval, timeout=120)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
idx += 1
|
||||||
|
save = OUT / f"{ilabel}_{hkey}_{rlabel}.jpg"
|
||||||
|
print(f"[{idx}/{total}] 正式 {ilabel}|{hname}|{rtitle}", flush=True)
|
||||||
|
r = call(ipath, hnum, rval, save_grown=save, timeout=300)
|
||||||
|
r["res_label"] = rlabel; r["res_title"] = rtitle
|
||||||
|
r["grown_path"] = str(save) if r.get("ok") else None
|
||||||
|
status = f"{r.get('total_ms')}ms" if r.get("ok") else f"FAIL:{r.get('error','')[:30]}"
|
||||||
|
print(f" -> {status}", flush=True)
|
||||||
|
cells.append(r)
|
||||||
|
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({"res_titles": RES_TITLES, "prompt": PROMPT, "rows": rows}, f, ensure_ascii=False, indent=2)
|
||||||
|
ok = sum(1 for row in rows for c in row["cells"] if c.get("ok"))
|
||||||
|
print(f"\n✓ 完成: {ok}/{len(rows)*len(RES_LIST)} 成功 -> {OUT/'results.json'}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""swap步数 + 重绘分辨率 对比测试(热数据)。
|
||||||
|
|
||||||
|
每个组合: 预热1次(丢弃) + 正式测1次(取热数据)。
|
||||||
|
B维度: steps=10/15/20 (分辨率固定896)
|
||||||
|
C维度: 分辨率=640/896/1024 (steps固定15)
|
||||||
|
4图×2发型=8组 × 6档 × 2次(预热+正式) = 96次
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
API = "http://127.0.0.1:8187/api/v1/debug/grow-timing"
|
||||||
|
TOKEN = "dev-shared-secret-2026"
|
||||||
|
OUT = Path("/home/ubuntu/hair/benchmark_out/bench2")
|
||||||
|
OUT.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
IMGS = [
|
||||||
|
("asdf", "/home/ubuntu/hair/image/asdf.jpg"),
|
||||||
|
("qwer", "/home/ubuntu/hair/image/qwer.jpg"),
|
||||||
|
("girl2", "/home/ubuntu/hair/image/girl_img/girl2.jpg"),
|
||||||
|
("girl5", "/home/ubuntu/hair/image/girl_img/girl5.jpg"),
|
||||||
|
]
|
||||||
|
HAIRSTYLES = [(5, "wave", "波浪"), (3, "heart", "心形")]
|
||||||
|
|
||||||
|
# B维度: swap步数对比 (分辨率固定896)
|
||||||
|
B_STEPS = [10, 15, 20]
|
||||||
|
# C维度: 重绘分辨率对比 (steps固定15)
|
||||||
|
C_RES = [640, 896, 1024]
|
||||||
|
|
||||||
|
|
||||||
|
def call(img_path, hair_num, webui_steps=None, redraw_max_side=None, save_grown=None):
|
||||||
|
"""调调试接口。返回 dict。save_grown 非None时把结果图存到该路径。"""
|
||||||
|
data = {"hair_style": str(hair_num)}
|
||||||
|
if webui_steps is not None:
|
||||||
|
data["webui_steps"] = str(webui_steps)
|
||||||
|
if redraw_max_side is not None:
|
||||||
|
data["redraw_max_side"] = str(redraw_max_side)
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
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=data, timeout=300)
|
||||||
|
wall = time.perf_counter() - t0
|
||||||
|
j = r.json()
|
||||||
|
if j.get("code") != 0:
|
||||||
|
return {"ok": False, "error": j.get("message", "")[:100], "wall": wall}
|
||||||
|
d = j["data"]
|
||||||
|
hs = d["per_hairstyle"][0]
|
||||||
|
if save_grown and hs.get("grown_b64"):
|
||||||
|
b = hs["grown_b64"].split(",")[1] if "," in hs["grown_b64"] else hs["grown_b64"]
|
||||||
|
with open(save_grown, "wb") as gf:
|
||||||
|
gf.write(base64.b64decode(b))
|
||||||
|
return {
|
||||||
|
"ok": hs.get("ok", False), "wall": wall,
|
||||||
|
"total_ms": d["total_ms"], "ctx_ms": d["extract_context_ms"],
|
||||||
|
"mask_ms": hs.get("mask_ms"), "swap_ms": hs.get("swap_ms"),
|
||||||
|
"blend_ms": hs.get("blend_ms"), "comfy_ms": hs.get("comfyui_redraw_ms"),
|
||||||
|
"error": hs.get("error"),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e)[:100], "wall": time.perf_counter() - t0}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
results = {"B_steps": [], "C_res": []}
|
||||||
|
total_calls = len(IMGS) * len(HAIRSTYLES) * (len(B_STEPS) + len(C_RES)) * 2
|
||||||
|
idx = 0
|
||||||
|
|
||||||
|
# ===== B维度: swap步数对比 (分辨率固定896) =====
|
||||||
|
print("\n===== B维度: swap步数对比 (分辨率=896) =====", flush=True)
|
||||||
|
for steps in B_STEPS:
|
||||||
|
print(f"\n--- steps={steps} ---", flush=True)
|
||||||
|
for ilabel, ipath in IMGS:
|
||||||
|
for hnum, hkey, hname in HAIRSTYLES:
|
||||||
|
# 预热(丢弃)
|
||||||
|
idx += 1
|
||||||
|
print(f"[{idx}/{total_calls}] 预热 {ilabel}|{hname}|steps={steps}", flush=True)
|
||||||
|
call(ipath, hnum, webui_steps=steps, redraw_max_side=896)
|
||||||
|
# 正式(热数据)
|
||||||
|
idx += 1
|
||||||
|
save = OUT / f"B_steps{steps}_{ilabel}_{hkey}.jpg"
|
||||||
|
print(f"[{idx}/{total_calls}] 正式 {ilabel}|{hname}|steps={steps}", flush=True)
|
||||||
|
r = call(ipath, hnum, webui_steps=steps, redraw_max_side=896, save_grown=save)
|
||||||
|
r["steps"] = steps; r["img"] = ilabel; r["hair"] = hkey; r["hair_name"] = hname
|
||||||
|
r["grown_path"] = str(save) if r.get("ok") else None
|
||||||
|
print(f" -> total={r.get('total_ms')}ms swap={r.get('swap_ms')}ms comfy={r.get('comfy_ms')}ms ok={r.get('ok')}", flush=True)
|
||||||
|
results["B_steps"].append(r)
|
||||||
|
|
||||||
|
# ===== C维度: 重绘分辨率对比 (steps固定15) =====
|
||||||
|
print("\n===== C维度: 重绘分辨率对比 (steps=15) =====", flush=True)
|
||||||
|
for res in C_RES:
|
||||||
|
print(f"\n--- res={res} ---", flush=True)
|
||||||
|
for ilabel, ipath in IMGS:
|
||||||
|
for hnum, hkey, hname in HAIRSTYLES:
|
||||||
|
idx += 1
|
||||||
|
print(f"[{idx}/{total_calls}] 预热 {ilabel}|{hname}|res={res}", flush=True)
|
||||||
|
call(ipath, hnum, webui_steps=15, redraw_max_side=res)
|
||||||
|
idx += 1
|
||||||
|
save = OUT / f"C_res{res}_{ilabel}_{hkey}.jpg"
|
||||||
|
print(f"[{idx}/{total_calls}] 正式 {ilabel}|{hname}|res={res}", flush=True)
|
||||||
|
r = call(ipath, hnum, webui_steps=15, redraw_max_side=res, save_grown=save)
|
||||||
|
r["res"] = res; r["img"] = ilabel; r["hair"] = hkey; r["hair_name"] = hname
|
||||||
|
r["grown_path"] = str(save) if r.get("ok") else None
|
||||||
|
print(f" -> total={r.get('total_ms')}ms swap={r.get('swap_ms')}ms comfy={r.get('comfy_ms')}ms ok={r.get('ok')}", flush=True)
|
||||||
|
results["C_res"].append(r)
|
||||||
|
|
||||||
|
with open(OUT / "results.json", "w", encoding="utf-8") as f:
|
||||||
|
json.dump(results, f, ensure_ascii=False, indent=2)
|
||||||
|
ok = sum(1 for r in results["B_steps"] + results["C_res"] if r.get("ok"))
|
||||||
|
print(f"\n✓ 完成: {ok}/{len(results['B_steps'])+len(results['C_res'])} 成功 -> {OUT/'results.json'}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""生成 swap步数 + 重绘分辨率 对比报告 HTML。"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
OUT = Path("/home/ubuntu/hair/benchmark_out/bench2")
|
||||||
|
RESULTS = OUT / "results.json"
|
||||||
|
HTML = OUT / "report.html"
|
||||||
|
|
||||||
|
|
||||||
|
def img_src(path):
|
||||||
|
if not path or not os.path.isfile(path):
|
||||||
|
return None
|
||||||
|
# benchmark_out/bench2/xxx.jpg -> bench2/xxx.jpg (报告在 static/ 下部署时调整)
|
||||||
|
p = str(path)
|
||||||
|
return "bench2/" + os.path.basename(p)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
d = json.load(open(RESULTS, encoding="utf-8"))
|
||||||
|
b_data = d["B_steps"] # steps 对比
|
||||||
|
c_data = d["C_res"] # 分辨率对比
|
||||||
|
|
||||||
|
# B维度聚合
|
||||||
|
by_steps = defaultdict(list)
|
||||||
|
for r in b_data:
|
||||||
|
by_steps[r["steps"]].append(r)
|
||||||
|
b_summary = []
|
||||||
|
for s in sorted(by_steps):
|
||||||
|
rs = by_steps[s]
|
||||||
|
b_summary.append({
|
||||||
|
"label": f"steps={s}", "n": len(rs),
|
||||||
|
"swap": sum(r["swap_ms"] for r in rs) // len(rs),
|
||||||
|
"total": sum(r["total_ms"] for r in rs) // len(rs),
|
||||||
|
})
|
||||||
|
|
||||||
|
# C维度聚合
|
||||||
|
by_res = defaultdict(list)
|
||||||
|
for r in c_data:
|
||||||
|
by_res[r["res"]].append(r)
|
||||||
|
c_summary = []
|
||||||
|
for res in sorted(by_res):
|
||||||
|
rs = by_res[res]
|
||||||
|
c_summary.append({
|
||||||
|
"label": f"res={res}", "n": len(rs),
|
||||||
|
"comfy": sum(r["comfy_ms"] for r in rs) // len(rs),
|
||||||
|
"total": sum(r["total_ms"] for r in rs) // len(rs),
|
||||||
|
})
|
||||||
|
|
||||||
|
# B维度明细行(每图每发型每步数)
|
||||||
|
b_rows = []
|
||||||
|
for r in sorted(b_data, key=lambda x: (x["img"], x["hair"], x["steps"])):
|
||||||
|
src = img_src(r.get("grown_path"))
|
||||||
|
b_rows.append(f"""<tr>
|
||||||
|
<td>{r['img']}</td><td>{r['hair_name']}</td><td>{r['steps']}</td>
|
||||||
|
<td>{r.get('swap_ms','?')}</td><td>{r.get('comfy_ms','?')}</td><td>{r.get('total_ms','?')}</td>
|
||||||
|
<td>{f'<img src="{src}" loading="lazy">' if src else '⚠'}</td></tr>""")
|
||||||
|
|
||||||
|
# C维度明细行
|
||||||
|
c_rows = []
|
||||||
|
for r in sorted(c_data, key=lambda x: (x["img"], x["hair"], x["res"])):
|
||||||
|
src = img_src(r.get("grown_path"))
|
||||||
|
c_rows.append(f"""<tr>
|
||||||
|
<td>{r['img']}</td><td>{r['hair_name']}</td><td>{r['res']}</td>
|
||||||
|
<td>{r.get('swap_ms','?')}</td><td>{r.get('comfy_ms','?')}</td><td>{r.get('total_ms','?')}</td>
|
||||||
|
<td>{f'<img src="{src}" loading="lazy">' if src else '⚠'}</td></tr>""")
|
||||||
|
|
||||||
|
def bar_row(label, val, max_val, color, unit="ms"):
|
||||||
|
pct = max(1, val / max_val * 100) if max_val else 0
|
||||||
|
return f'<div class="step-row"><div class="step-name">{label}</div>' \
|
||||||
|
f'<div class="step-bar-wrap"><div class="step-bar {color}" style="width:{pct}%">{val}{unit}</div></div>' \
|
||||||
|
f'<div class="step-time">{val}{unit}</div></div>'
|
||||||
|
|
||||||
|
# B维度汇总条形图
|
||||||
|
b_max_swap = max(s["swap"] for s in b_summary)
|
||||||
|
b_bars = "".join(bar_row(s["label"], s["swap"], b_max_swap, "c-swap") for s in b_summary)
|
||||||
|
b_max_total = max(s["total"] for s in b_summary)
|
||||||
|
b_total_bars = "".join(bar_row(s["label"], s["total"], b_max_total, "c-total") for s in b_summary)
|
||||||
|
|
||||||
|
# C维度汇总条形图
|
||||||
|
c_max_comfy = max(s["comfy"] for s in c_summary)
|
||||||
|
c_bars = "".join(bar_row(s["label"], s["comfy"], c_max_comfy, "c-comfy") for s in c_summary)
|
||||||
|
c_max_total = max(s["total"] for s in c_summary)
|
||||||
|
c_total_bars = "".join(bar_row(s["label"], s["total"], c_max_total, "c-total") for s in c_summary)
|
||||||
|
|
||||||
|
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>swap步数 + 重绘分辨率 对比报告</title>
|
||||||
|
<style>
|
||||||
|
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||||
|
body {{ font-family: -apple-system, "Segoe UI", sans-serif; background: #f5f5f5; padding: 16px; color: #333; }}
|
||||||
|
h1 {{ font-size: 20px; margin-bottom: 4px; }}
|
||||||
|
h2 {{ font-size: 16px; margin: 20px 0 10px; }}
|
||||||
|
.subtitle {{ color: #888; font-size: 12px; margin-bottom: 14px; }}
|
||||||
|
.card {{ background: #fff; border-radius: 10px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 16px; overflow: hidden; }}
|
||||||
|
.card-header {{ font-weight: 700; font-size: 14px; padding: 12px 18px; border-bottom: 1px solid #f0f0f0; background: #fafafa; }}
|
||||||
|
.card-body {{ padding: 18px; }}
|
||||||
|
.summary-grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }}
|
||||||
|
.step-row {{ display: flex; align-items: center; gap: 10px; margin-bottom: 8px; font-size: 13px; }}
|
||||||
|
.step-name {{ width: 100px; flex-shrink: 0; font-weight: 600; }}
|
||||||
|
.step-bar-wrap {{ flex: 1; background: #f3f4f6; border-radius: 4px; height: 24px; min-width: 200px; }}
|
||||||
|
.step-bar {{ height: 100%; border-radius: 4px; display: flex; align-items: center; padding-left: 8px; color: #fff; font-size: 11px; font-weight: 600; min-width: 2px; }}
|
||||||
|
.step-time {{ width: 70px; text-align: right; font-weight: 600; flex-shrink: 0; font-variant-numeric: tabular-nums; }}
|
||||||
|
.c-swap {{ background: #f59e0b; }} .c-comfy {{ background: #ef4444; }} .c-total {{ background: #2563eb; }}
|
||||||
|
table {{ border-collapse: collapse; width: 100%; font-size: 12px; }}
|
||||||
|
th, td {{ border: 1px solid #eee; padding: 5px 8px; text-align: center; }}
|
||||||
|
th {{ background: #f9fafb; font-weight: 600; position: sticky; top: 0; }}
|
||||||
|
td img {{ max-height: 100px; max-width: 80px; border-radius: 4px; }}
|
||||||
|
.scroll {{ max-height: 400px; overflow: auto; }}
|
||||||
|
.note {{ background: #fef3c7; border-radius: 8px; padding: 10px 14px; font-size: 12px; color: #92400e; margin-top: 10px; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>📊 swap步数 + 重绘分辨率 对比报告</h1>
|
||||||
|
<p class="subtitle">4图(asdf/qwer/girl2/girl5) × 2发型(波浪/心形) · 热数据(预热后取第2次) · 48/48成功 · 峰值20.6GB · 0 OOM</p>
|
||||||
|
|
||||||
|
<div class="note">💡 结论速览: B维度 steps 10→20 swap从3.0s→3.9s(每步省~90ms);C维度 res 640比896省3s(comfy 4.3s vs 7.3s),1024与896接近。</div>
|
||||||
|
|
||||||
|
<h2>B维度:swap步数对比(分辨率固定896)</h2>
|
||||||
|
<div class="summary-grid">
|
||||||
|
<div class="card"><div class="card-header">swap 耗时(越低越快)</div><div class="card-body">{b_bars}</div></div>
|
||||||
|
<div class="card"><div class="card-header">总耗时(越低越快)</div><div class="card-body">{b_total_bars}</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>C维度:重绘分辨率对比(steps固定15)</h2>
|
||||||
|
<div class="summary-grid">
|
||||||
|
<div class="card"><div class="card-header">ComfyUI重绘 耗时(越低越快)</div><div class="card-body">{c_bars}</div></div>
|
||||||
|
<div class="card"><div class="card-header">总耗时(越低越快)</div><div class="card-body">{c_total_bars}</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>B维度明细(每图每发型每步数)</h2>
|
||||||
|
<div class="card"><div class="scroll"><table>
|
||||||
|
<tr><th>图片</th><th>发型</th><th>steps</th><th>swap(ms)</th><th>comfy(ms)</th><th>总(ms)</th><th>结果</th></tr>
|
||||||
|
{"".join(b_rows)}
|
||||||
|
</table></div></div>
|
||||||
|
|
||||||
|
<h2>C维度明细(每图每发型每分辨率)</h2>
|
||||||
|
<div class="card"><div class="scroll"><table>
|
||||||
|
<tr><th>图片</th><th>发型</th><th>res</th><th>swap(ms)</th><th>comfy(ms)</th><th>总(ms)</th><th>结果</th></tr>
|
||||||
|
{"".join(c_rows)}
|
||||||
|
</table></div></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()
|
||||||
@@ -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)
|
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"]
|
order = ["hairline", "brow_center", "nose_bottom", "chin_tip"]
|
||||||
else:
|
else:
|
||||||
order = ["hair_top", "hairline", "brow_center", "nose_bottom", "chin_tip"]
|
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
|
face_cx = (fx0 + fx1) / 2
|
||||||
over = max(6, round(s * 0.030)) # 线超出包围盒的长度(参考图风格)
|
over = max(6, round(s * 0.030)) # 线超出包围盒的长度(参考图风格)
|
||||||
face_half = (fx1 - fx0) / 2 + over # 横线超出最外侧竖线一点
|
face_half = (fx1 - fx0) / 2 + over # 横线超出最外侧竖线一点
|
||||||
# v6 竖线纵向范围 = 发际线→下巴尖(不超出);v1 = 头顶→下巴尖并两端超出一点
|
# 竖线纵向范围:v6 = 发际线→下巴尖(不超出);v1(含发际线弃用)= 头顶→下巴尖并两端超出一点
|
||||||
v_top = fy0 if variant == "v6" else fy0 - over
|
v_top = fy0 if variant == "v6" else fy0 - over
|
||||||
v_bot = fy1 if variant == "v6" else fy1 + 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")
|
draw.text((x, ys[i]), text, fill=LINE_COLOR, font=font, anchor="lm")
|
||||||
|
|
||||||
# --- 3b. 左侧四庭:名 + 数值两行(无 cm)+ 竖向虚线双箭头 ---
|
# --- 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_cm = [measure_result.upper_cm, measure_result.middle_cm, measure_result.lower_cm]
|
||||||
court_name = ["上庭", "中庭", "下庭"]
|
court_name = ["上庭", "中庭", "下庭"]
|
||||||
n_court = 3
|
n_court = 3
|
||||||
|
court_start = 0
|
||||||
else:
|
else:
|
||||||
court_cm = [measure_result.top_cm, measure_result.upper_cm,
|
court_cm = [measure_result.top_cm, measure_result.upper_cm,
|
||||||
measure_result.middle_cm, measure_result.lower_cm]
|
measure_result.middle_cm, measure_result.lower_cm]
|
||||||
court_name = ["顶庭", "上庭", "中庭", "下庭"]
|
court_name = ["顶庭", "上庭", "中庭", "下庭"]
|
||||||
n_court = 4
|
n_court = 4
|
||||||
|
court_start = 0
|
||||||
arrow_x = max(arrow_size + 1, fx0 - pad) # 竖箭头所在 x(脸左侧,贴近最左竖线)
|
arrow_x = max(arrow_size + 1, fx0 - pad) # 竖箭头所在 x(脸左侧,贴近最左竖线)
|
||||||
court_total = sum(court_cm) or 1.0 # 各庭占比分母 = 四庭(v6 三庭)之和
|
court_total = sum(court_cm) or 1.0 # 各庭占比分母 = 四庭(v6 三庭)之和
|
||||||
for i in range(n_court):
|
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)
|
inset = min(arrow_size, (y_b - y_a) * 0.12)
|
||||||
draw_dashed_line_with_arrows(
|
draw_dashed_line_with_arrows(
|
||||||
|
|||||||
@@ -508,13 +508,14 @@ def _segment_hair(image_bgr, seg_model, landmarks, w, h):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _call_swap(image_bgr, hairline_id, is_hr, ext_mask_bool, denoising_strength,
|
def _call_swap(image_bgr, hairline_id, is_hr, ext_mask_bool, denoising_strength,
|
||||||
inpainting_fill=1, mask_blur=11, mask_dilate_scale=1.0):
|
inpainting_fill=1, mask_blur=11, mask_dilate_scale=1.0, webui_steps=None):
|
||||||
"""调 change_hair /api/swapHair/v1,返回与输入同分辨率同对齐的换发型结果(BGR)。
|
"""调 change_hair /api/swapHair/v1,返回与输入同分辨率同对齐的换发型结果(BGR)。
|
||||||
|
|
||||||
ext_mask_bool 非 None 时作为 ext_mask 传入(swap_mode=ext_mask)。
|
ext_mask_bool 非 None 时作为 ext_mask 传入(swap_mode=ext_mask)。
|
||||||
denoising_strength:webui img2img 重绘强度(越大生发越激进),透传给换发型。
|
denoising_strength:webui img2img 重绘强度(越大生发越激进),透传给换发型。
|
||||||
inpainting_fill / mask_blur / mask_dilate_scale:服务端重绘参数(透传给 change_hair,
|
inpainting_fill / mask_blur / mask_dilate_scale:服务端重绘参数(透传给 change_hair,
|
||||||
默认值=服务端原始硬编码值,未传时行为不变)。详见 change_hair 文档。
|
默认值=服务端原始硬编码值,未传时行为不变)。详见 change_hair 文档。
|
||||||
|
webui_steps:webui img2img 采样步数,None 用服务端默认(15)。
|
||||||
"""
|
"""
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
@@ -530,6 +531,8 @@ def _call_swap(image_bgr, hairline_id, is_hr, ext_mask_bool, denoising_strength,
|
|||||||
"mask_blur": int(mask_blur),
|
"mask_blur": int(mask_blur),
|
||||||
"mask_dilate_scale": float(mask_dilate_scale),
|
"mask_dilate_scale": float(mask_dilate_scale),
|
||||||
}
|
}
|
||||||
|
if webui_steps is not None:
|
||||||
|
payload["webui_steps"] = int(webui_steps)
|
||||||
if ext_mask_bool is not None:
|
if ext_mask_bool is not None:
|
||||||
mbuf = cv2.imencode(".png", (ext_mask_bool.astype(np.uint8)) * 255)[1]
|
mbuf = cv2.imencode(".png", (ext_mask_bool.astype(np.uint8)) * 255)[1]
|
||||||
payload["ext_mask"] = "data:image/png;base64," + base64.b64encode(mbuf.tobytes()).decode()
|
payload["ext_mask"] = "data:image/png;base64," + base64.b64encode(mbuf.tobytes()).decode()
|
||||||
@@ -855,7 +858,7 @@ def _grow_core(image_bgr, hairline_id, *, is_hr, seg_model, erode_cm, swap_mode,
|
|||||||
mb_levels, hairline_push_cm, hairline_edge, blend_method, color_match,
|
mb_levels, hairline_push_cm, hairline_edge, blend_method, color_match,
|
||||||
color_match_strength, mb_feather_px, transition_band_px,
|
color_match_strength, mb_feather_px, transition_band_px,
|
||||||
inpainting_fill, mask_blur, mask_dilate_scale, rid, render_viz=True,
|
inpainting_fill, mask_blur, mask_dilate_scale, rid, render_viz=True,
|
||||||
hair_mask=None):
|
hair_mask=None, webui_steps=None):
|
||||||
"""接口11 共享核心:遮罩(pushed)→生成→硬贴回→接缝融合,产出 ④ final。
|
"""接口11 共享核心:遮罩(pushed)→生成→硬贴回→接缝融合,产出 ④ final。
|
||||||
|
|
||||||
不做任何重绘。返回中间产物 dict(供接口11 构造响应、接口12 取 final+重绘带用):
|
不做任何重绘。返回中间产物 dict(供接口11 构造响应、接口12 取 final+重绘带用):
|
||||||
@@ -897,7 +900,7 @@ def _grow_core(image_bgr, hairline_id, *, is_hr, seg_model, erode_cm, swap_mode,
|
|||||||
ext_mask = mask_bool if swap_mode == "ext_mask" else None
|
ext_mask = mask_bool if swap_mode == "ext_mask" else None
|
||||||
swap_result = _call_swap(image_bgr, hairline_id, is_hr, ext_mask, denoising_strength,
|
swap_result = _call_swap(image_bgr, hairline_id, is_hr, ext_mask, denoising_strength,
|
||||||
inpainting_fill=inpainting_fill, mask_blur=mask_blur,
|
inpainting_fill=inpainting_fill, mask_blur=mask_blur,
|
||||||
mask_dilate_scale=mask_dilate_scale)
|
mask_dilate_scale=mask_dilate_scale, webui_steps=webui_steps)
|
||||||
t_swap = time.time() - t0
|
t_swap = time.time() - t0
|
||||||
|
|
||||||
# 步骤3:严格按遮罩硬贴回(无融合,用于对比)
|
# 步骤3:严格按遮罩硬贴回(无融合,用于对比)
|
||||||
@@ -935,7 +938,7 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
|
|||||||
color_match_strength=1.0, mb_feather_px=1,
|
color_match_strength=1.0, mb_feather_px=1,
|
||||||
transition_band_px=-1,
|
transition_band_px=-1,
|
||||||
inpainting_fill=1, mask_blur=11, mask_dilate_scale=1.0,
|
inpainting_fill=1, mask_blur=11, mask_dilate_scale=1.0,
|
||||||
rid=None):
|
rid=None, webui_steps=None):
|
||||||
"""接口11 完整管线(**不含重绘**,重绘见接口12 generate_hairline_redraw)。
|
"""接口11 完整管线(**不含重绘**,重绘见接口12 generate_hairline_redraw)。
|
||||||
返回可直接进 ok() 的 data dict。未检出人脸抛 NoFaceError。
|
返回可直接进 ok() 的 data dict。未检出人脸抛 NoFaceError。
|
||||||
|
|
||||||
@@ -957,7 +960,7 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
|
|||||||
color_match=color_match, color_match_strength=color_match_strength,
|
color_match=color_match, color_match_strength=color_match_strength,
|
||||||
mb_feather_px=mb_feather_px, transition_band_px=transition_band_px,
|
mb_feather_px=mb_feather_px, transition_band_px=transition_band_px,
|
||||||
inpainting_fill=inpainting_fill, mask_blur=mask_blur,
|
inpainting_fill=inpainting_fill, mask_blur=mask_blur,
|
||||||
mask_dilate_scale=mask_dilate_scale, rid=rid)
|
mask_dilate_scale=mask_dilate_scale, rid=rid, webui_steps=webui_steps)
|
||||||
mask_viz = core["mask_viz"]
|
mask_viz = core["mask_viz"]
|
||||||
alpha = core["alpha"]
|
alpha = core["alpha"]
|
||||||
w, h = core["w"], core["h"]
|
w, h = core["w"], core["h"]
|
||||||
@@ -1035,7 +1038,7 @@ def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="seg
|
|||||||
inpainting_fill=1, mask_blur=11, mask_dilate_scale=1.0,
|
inpainting_fill=1, mask_blur=11, mask_dilate_scale=1.0,
|
||||||
comfyui_prompt=None, beauty_alpha=0.6,
|
comfyui_prompt=None, beauty_alpha=0.6,
|
||||||
band_lo_mult=0.5, band_hi_mult=1.5, rid=None,
|
band_lo_mult=0.5, band_hi_mult=1.5, rid=None,
|
||||||
hair_mask=None):
|
hair_mask=None, webui_steps=None):
|
||||||
"""接口12 发际线带重绘。内部先跑接口11 核心拿到 ④ final,再取 ⑤-① 发际线重绘带
|
"""接口12 发际线带重绘。内部先跑接口11 核心拿到 ④ final,再取 ⑤-① 发际线重绘带
|
||||||
(外推↔内推之间、经 baseline 截断只留上部)作遮罩。
|
(外推↔内推之间、经 baseline 截断只留上部)作遮罩。
|
||||||
|
|
||||||
@@ -1064,7 +1067,7 @@ def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="seg
|
|||||||
mb_feather_px=mb_feather_px, transition_band_px=transition_band_px,
|
mb_feather_px=mb_feather_px, transition_band_px=transition_band_px,
|
||||||
inpainting_fill=inpainting_fill, mask_blur=mask_blur,
|
inpainting_fill=inpainting_fill, mask_blur=mask_blur,
|
||||||
mask_dilate_scale=mask_dilate_scale, rid=rid, render_viz=False,
|
mask_dilate_scale=mask_dilate_scale, rid=rid, render_viz=False,
|
||||||
hair_mask=hair_mask)
|
hair_mask=hair_mask, webui_steps=webui_steps)
|
||||||
final = core["final"]
|
final = core["final"]
|
||||||
mask_viz = core["mask_viz"]
|
mask_viz = core["mask_viz"]
|
||||||
w, h = core["w"], core["h"]
|
w, h = core["w"], core["h"]
|
||||||
@@ -1107,7 +1110,7 @@ def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="seg
|
|||||||
"hairline_id": hairline_id,
|
"hairline_id": hairline_id,
|
||||||
"blend_method": blend_method,
|
"blend_method": blend_method,
|
||||||
"hairline_push_cm": round(float(hairline_push_cm), 2),
|
"hairline_push_cm": round(float(hairline_push_cm), 2),
|
||||||
"comfyui_prompt": comfyui_prompt or "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜",
|
"comfyui_prompt": comfyui_prompt or "填充遮罩区域的头发",
|
||||||
"beauty_alpha": beauty_alpha,
|
"beauty_alpha": beauty_alpha,
|
||||||
"px_per_cm": round(float(px_per_cm), 4),
|
"px_per_cm": round(float(px_per_cm), 4),
|
||||||
"mask_pixels": mask_viz["mask_pixels"],
|
"mask_pixels": mask_viz["mask_pixels"],
|
||||||
|
|||||||
@@ -144,9 +144,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:
|
class MeasureResult:
|
||||||
"""测量结果,提供 to_response() 输出与接口文档同构的 data 字段。"""
|
"""测量结果,提供 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,
|
def __init__(self, vertical, eyes, px_per_cm, hairline_source, head_pose,
|
||||||
landmarks=None, image_width=None, image_height=None):
|
landmarks=None, image_width=None, image_height=None):
|
||||||
self.vertical = vertical
|
self.vertical = vertical
|
||||||
@@ -164,6 +177,13 @@ class MeasureResult:
|
|||||||
self.upper_cm = vertical["upper_court_px"] / px_per_cm
|
self.upper_cm = vertical["upper_court_px"] / px_per_cm
|
||||||
self.middle_cm = vertical["middle_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.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
|
self.face_total_cm = self.top_cm + self.upper_cm + self.middle_cm + self.lower_cm
|
||||||
|
|
||||||
# 七眼厘米
|
# 七眼厘米
|
||||||
@@ -172,14 +192,45 @@ class MeasureResult:
|
|||||||
self.inter_eye_cm = eyes["inter_eye_distance_px"] / px_per_cm
|
self.inter_eye_cm = eyes["inter_eye_distance_px"] / px_per_cm
|
||||||
|
|
||||||
def to_response(self):
|
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"]
|
total_px = (self.vertical["top_court_px"] + self.vertical["upper_court_px"]
|
||||||
+ self.vertical["middle_court_px"] + self.vertical["lower_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 = {
|
data = {
|
||||||
"face_total_height_cm": round(self.face_total_cm, 2),
|
"face_total_height_cm": round(self.face_total_cm, 2),
|
||||||
"four_courts": {
|
"four_courts": {
|
||||||
@@ -199,16 +250,16 @@ class MeasureResult:
|
|||||||
"face_width_cm": round(self.face_width_cm, 2),
|
"face_width_cm": round(self.face_width_cm, 2),
|
||||||
"inter_eye_distance_cm": round(self.inter_eye_cm, 2),
|
"inter_eye_distance_cm": round(self.inter_eye_cm, 2),
|
||||||
"ratios": {
|
"ratios": {
|
||||||
"eye_width": round(self.eyes["eye_width_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"] / fw_px, 3),
|
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / self.eyes["face_width_px"], 3),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"landmarks": {
|
"landmarks": {
|
||||||
"hair_top": pt("hair_top"),
|
"hair_top": pt_or_none(self.vertical, "hair_top"),
|
||||||
"hairline": pt("hairline"),
|
"hairline": pt_or_none(self.vertical, "hairline"),
|
||||||
"brow_center": pt("brow_center"),
|
"brow_center": pt_or_none(self.vertical, "brow_center"),
|
||||||
"nose_bottom": pt("nose_bottom"),
|
"nose_bottom": pt_or_none(self.vertical, "nose_bottom"),
|
||||||
"chin_tip": pt("chin_tip"),
|
"chin_tip": pt_or_none(self.vertical, "chin_tip"),
|
||||||
},
|
},
|
||||||
"hairline_source": self.hairline_source,
|
"hairline_source": self.hairline_source,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
|
||||||
@@ -170,10 +170,10 @@
|
|||||||
},
|
},
|
||||||
"16": {
|
"16": {
|
||||||
"inputs": {
|
"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"
|
"weight_dtype": "fp8_e4m3fn"
|
||||||
},
|
},
|
||||||
"class_type": "UNETLoader",
|
"class_type": "UnetLoaderGGUF",
|
||||||
"_meta": {
|
"_meta": {
|
||||||
"title": "UNet加载器"
|
"title": "UNet加载器"
|
||||||
}
|
}
|
||||||
@@ -410,7 +410,7 @@
|
|||||||
},
|
},
|
||||||
"60": {
|
"60": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
"text": "填充遮罩区域的头发"
|
||||||
},
|
},
|
||||||
"class_type": "JjkText",
|
"class_type": "JjkText",
|
||||||
"_meta": {
|
"_meta": {
|
||||||
|
|||||||
@@ -30,6 +30,29 @@ _REPO = os.path.dirname(os.path.dirname(__file__))
|
|||||||
_INPUT_NODE = "26" # LoadImage:外部输入图(含 alpha 遮罩)
|
_INPUT_NODE = "26" # LoadImage:外部输入图(含 alpha 遮罩)
|
||||||
_SEED_NODE = "6" # RandomNoise
|
_SEED_NODE = "6" # RandomNoise
|
||||||
_PROMPT_NODE = "60" # JjkText:提示词
|
_PROMPT_NODE = "60" # JjkText:提示词
|
||||||
|
_UNET_NODE = "16" # UNETLoader / UnetLoaderGGUF:Flux 模型加载
|
||||||
|
_CLIP_NODE = "61" # CLIPLoader:qwen 文本编码器
|
||||||
|
_VAE_NODE = "3" # VAELoader
|
||||||
|
|
||||||
|
# Flux 模型 → 配套文本编码器映射。切换 unet 时自动同步编码器,避免维度不匹配。
|
||||||
|
def _clip_for_unet(unet_name: str) -> str | None:
|
||||||
|
"""根据 unet 文件名推断配套的文本编码器文件名;无法推断返回 None。"""
|
||||||
|
low = unet_name.lower()
|
||||||
|
if "9b" in low: # Flux.2 9B 系列
|
||||||
|
return "qwen_3_8b_fp8mixed.safetensors"
|
||||||
|
if "z-image" in low: # Z-Image-Turbo 用 4B 编码器
|
||||||
|
return "qwen_3_4b.safetensors"
|
||||||
|
if "4b" in low: # Flux.2 4B
|
||||||
|
return "qwen_3_4b.safetensors"
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Flux 模型 → 配套 VAE 映射。Z-Image 用 ae.safetensors,Flux.2 系列用 flux2-vae。
|
||||||
|
def _vae_for_unet(unet_name: str) -> str | None:
|
||||||
|
"""根据 unet 文件名推断配套 VAE 文件名;无法推断返回 None(保持工作流原值)。"""
|
||||||
|
low = unet_name.lower()
|
||||||
|
if "z-image" in low:
|
||||||
|
return "ae.safetensors"
|
||||||
|
return None # Flux.2 系列 vae 在工作流里已正确配置,不覆盖
|
||||||
|
|
||||||
_wf_cache: dict[str, dict] = {} # path → workflow JSON
|
_wf_cache: dict[str, dict] = {} # path → workflow JSON
|
||||||
_wf_output_node: dict[str, str] = {} # path → SaveImage 节点 ID
|
_wf_output_node: dict[str, str] = {} # path → SaveImage 节点 ID
|
||||||
@@ -86,13 +109,18 @@ def _get_output_node(workflow_path: str | None = None) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = None,
|
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 字节。失败抛异常。
|
"""提交一次生发任务,返回输出 PNG 字节。失败抛异常。
|
||||||
|
|
||||||
prompt:非 None 时替换工作流节点60(JjkText)的文本;None 时用工作流内置默认提示词。
|
prompt:非 None 时替换工作流节点60(JjkText)的文本;None 时用工作流内置默认提示词。
|
||||||
workflow_path:工作流 JSON 路径,None 则用默认 add_hair.json。
|
workflow_path:工作流 JSON 路径,None 则用默认 add_hair.json。
|
||||||
front:True 时任务插到 ComfyUI 队列最前(server 端 "front" 字段,队列号取负)。
|
front:True 时任务插到 ComfyUI 队列最前(server 端 "front" 字段,队列号取负)。
|
||||||
接口2 对时延敏感用 True,避免排在接口3/5 的批量任务后面;其余接口保持 False。
|
接口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
|
path = workflow_path or _WORKFLOW_DEFAULT
|
||||||
output_node = _get_output_node(path)
|
output_node = _get_output_node(path)
|
||||||
@@ -120,6 +148,26 @@ 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)
|
wf[_SEED_NODE]["inputs"]["noise_seed"] = random.randint(0, 2**63 - 1)
|
||||||
if prompt is not None:
|
if prompt is not None:
|
||||||
wf[_PROMPT_NODE]["inputs"]["text"] = prompt
|
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
|
||||||
|
# 同步切换 VAE(Z-Image 用 ae.safetensors,Flux.2 保持 flux2-vae)
|
||||||
|
vae_node = wf.get(_VAE_NODE)
|
||||||
|
vae_name = _vae_for_unet(unet_name)
|
||||||
|
if vae_node is not None and vae_name is not None:
|
||||||
|
vae_node["inputs"]["vae_name"] = vae_name
|
||||||
|
|
||||||
# 诊断:落盘实际提交的工作流 + 输入图,便于和手动 ComfyUI 跑的对比
|
# 诊断:落盘实际提交的工作流 + 输入图,便于和手动 ComfyUI 跑的对比
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from . import comfyui
|
|||||||
|
|
||||||
logger = logging.getLogger("hair.worker")
|
logger = logging.getLogger("hair.worker")
|
||||||
|
|
||||||
_DEFAULT_PROMPT = "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
_DEFAULT_PROMPT = "填充遮罩区域的头发"
|
||||||
_REPO = os.path.dirname(os.path.dirname(__file__))
|
_REPO = os.path.dirname(os.path.dirname(__file__))
|
||||||
_REPAINT_WORKFLOW = os.path.join(_REPO, "0716add-hair-api.json")
|
_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,
|
def run_redraw(image_bytes: bytes, mask_bytes: bytes,
|
||||||
prompt: str | None = None, timeout: float = 300.0,
|
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。
|
"""直接调 ComfyUI 重绘 — 替代 local_test /api/generate。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
image_bytes: 人物图片字节(JPG/PNG)
|
image_bytes: 人物图片字节(JPG/PNG)
|
||||||
mask_bytes: 遮罩图片字节(支持红/白/alpha 遮罩格式)
|
mask_bytes: 遮罩图片字节(支持红/白/alpha 遮罩格式)
|
||||||
prompt: 提示词,None 用默认 "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
prompt: 提示词,None 用默认 "填充遮罩区域的头发"
|
||||||
timeout: ComfyUI 超时秒数
|
timeout: ComfyUI 超时秒数
|
||||||
front: True 时任务插到 ComfyUI 队列最前(接口2 时延敏感路径用)
|
front: True 时任务插到 ComfyUI 队列最前(接口2 时延敏感路径用)
|
||||||
|
unet_name: 非 None 时切换 Flux 模型(如 flux-2-klein-9b-Q5_K_M.gguf),None 用工作流默认
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
重绘后的 PNG 图片字节
|
重绘后的 PNG 图片字节
|
||||||
@@ -75,4 +76,5 @@ def run_redraw(image_bytes: bytes, mask_bytes: bytes,
|
|||||||
"""
|
"""
|
||||||
rgba_png = _process_mask_to_rgba(image_bytes, mask_bytes)
|
rgba_png = _process_mask_to_rgba(image_bytes, mask_bytes)
|
||||||
return comfyui.run(rgba_png, timeout=timeout, prompt=prompt,
|
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)
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ _BLACK_TEXTURE_DIR = os.path.join(_REPO, "hairline_texture_black")
|
|||||||
# 关键:ComfyUI 单卡显存装不下 Flux(7.7G)+qwen CLIP(3.9G) 同驻,靠缓存 CLIP 文本条件避免重载。
|
# 关键:ComfyUI 单卡显存装不下 Flux(7.7G)+qwen CLIP(3.9G) 同驻,靠缓存 CLIP 文本条件避免重载。
|
||||||
# prompt 不同会使缓存失效 → 重载 CLIP 并挤出 Flux(每次 +4s)。三接口用同一字符串即可全程命中。
|
# prompt 不同会使缓存失效 → 重载 CLIP 并挤出 Flux(每次 +4s)。三接口用同一字符串即可全程命中。
|
||||||
# 与 app.py 接口2/接口3 的默认 prompt 保持一致;可用 REDRAW_PROMPT 覆盖。
|
# 与 app.py 接口2/接口3 的默认 prompt 保持一致;可用 REDRAW_PROMPT 覆盖。
|
||||||
_REDRAW_PROMPT = os.getenv("REDRAW_PROMPT", "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
|
_REDRAW_PROMPT = os.getenv("REDRAW_PROMPT", "填充遮罩区域的头发")
|
||||||
|
|
||||||
# 接口2 女重绘整条管线(swapHair + ComfyUI)送模型前限边。真实照片常达 1257x1495:
|
# 接口2 女重绘整条管线(swapHair + ComfyUI)送模型前限边。真实照片常达 1257x1495:
|
||||||
# 全分辨率 ComfyUI 重绘要 13~21s 且激活显存把模型挤出。女性路径含 swapHair(SD WebUI ~5.3s
|
# 全分辨率 ComfyUI 重绘要 13~21s 且激活显存把模型挤出。女性路径含 swapHair(SD WebUI ~5.3s
|
||||||
@@ -54,21 +54,27 @@ _REDRAW_PROMPT = os.getenv("REDRAW_PROMPT", "填充遮罩区域的头发,皮
|
|||||||
# 默认压到 896 兜底(ComfyUI ~4s,女性总耗时 9~11s);追画质可设 REDRAW_MAX_SIDE=1024。
|
# 默认压到 896 兜底(ComfyUI ~4s,女性总耗时 9~11s);追画质可设 REDRAW_MAX_SIDE=1024。
|
||||||
_REDRAW_MAX_SIDE = int(os.getenv("REDRAW_MAX_SIDE", "896"))
|
_REDRAW_MAX_SIDE = int(os.getenv("REDRAW_MAX_SIDE", "896"))
|
||||||
|
|
||||||
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, prompt=None):
|
||||||
"""直接调 ComfyUI 重绘(替代原 local_test HTTP 服务)。
|
"""直接调 ComfyUI 重绘(替代原 local_test HTTP 服务)。
|
||||||
|
|
||||||
传 final 图 + 纯红遮罩 PNG,返回重绘后的 PNG bytes。
|
传 final 图 + 纯红遮罩 PNG,返回重绘后的 PNG bytes。
|
||||||
失败抛异常(调用方负责 try/except 跳过)。
|
失败抛异常(调用方负责 try/except 跳过)。
|
||||||
|
|
||||||
|
max_side:送 ComfyUI 前长边压到多少像素,None 用全局默认 _REDRAW_MAX_SIDE。
|
||||||
|
unet_name:非 None 时切换 Flux 模型,None 用工作流内置默认。
|
||||||
|
prompt:None 用默认 _REDRAW_PROMPT,否则用传入的提示词。
|
||||||
"""
|
"""
|
||||||
from .redraw import run_redraw
|
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)
|
img = cv2.imdecode(np.frombuffer(image_png_bytes, np.uint8), cv2.IMREAD_UNCHANGED)
|
||||||
scale = 1.0
|
scale = 1.0
|
||||||
orig_w = orig_h = 0
|
orig_w = orig_h = 0
|
||||||
if img is not None:
|
if img is not None:
|
||||||
orig_h, orig_w = img.shape[:2]
|
orig_h, orig_w = img.shape[:2]
|
||||||
m = max(orig_h, orig_w)
|
m = max(orig_h, orig_w)
|
||||||
if _REDRAW_MAX_SIDE > 0 and m > _REDRAW_MAX_SIDE:
|
if eff_side > 0 and m > eff_side:
|
||||||
scale = _REDRAW_MAX_SIDE / float(m)
|
scale = eff_side / float(m)
|
||||||
nw, nh = max(1, round(orig_w * scale)), max(1, round(orig_h * scale))
|
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)
|
msk = cv2.imdecode(np.frombuffer(mask_png_bytes, np.uint8), cv2.IMREAD_UNCHANGED)
|
||||||
img_s = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_AREA)
|
img_s = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_AREA)
|
||||||
@@ -76,10 +82,11 @@ def _call_local_redraw(image_png_bytes, mask_png_bytes, timeout=300.0):
|
|||||||
image_png_bytes = cv2.imencode(".png", img_s)[1].tobytes()
|
image_png_bytes = cv2.imencode(".png", img_s)[1].tobytes()
|
||||||
mask_png_bytes = cv2.imencode(".png", msk_s)[1].tobytes()
|
mask_png_bytes = cv2.imencode(".png", msk_s)[1].tobytes()
|
||||||
logger.info("接口2女 缩图送 Comfy: %dx%d → %dx%d (max_side=%d)",
|
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 的批量任务后面
|
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前,避免排在接口3/5 的批量任务后面
|
||||||
out = run_redraw(image_png_bytes, mask_png_bytes, timeout=timeout,
|
out = run_redraw(image_png_bytes, mask_png_bytes, timeout=timeout,
|
||||||
prompt=_REDRAW_PROMPT, front=True)
|
prompt=prompt if prompt is not None else _REDRAW_PROMPT,
|
||||||
|
front=True, unet_name=unet_name)
|
||||||
if scale < 1.0 and out:
|
if scale < 1.0 and out:
|
||||||
out = _upscale_png_to(out, orig_w, orig_h)
|
out = _upscale_png_to(out, orig_w, orig_h)
|
||||||
return out
|
return out
|
||||||
@@ -200,7 +207,8 @@ def generate_previews(image_bgr: np.ndarray, gender: str):
|
|||||||
|
|
||||||
def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = True,
|
def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = True,
|
||||||
prompt: str = None, hair_styles: list[int] | None = None,
|
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)。
|
"""指定发际线类型:发际线透明叠图(白线 RGBA) + 生发图(ComfyUI)。
|
||||||
|
|
||||||
hair_styles(1-indexed 列表):指定生成哪几张发际线(按贴图排序)。female: 1..5,male: 1..4。
|
hair_styles(1-indexed 列表):指定生成哪几张发际线(按贴图排序)。female: 1..5,male: 1..4。
|
||||||
@@ -236,7 +244,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)
|
compose_comfy_rgba(img_s, msk_s).save(buf, format="PNG", compress_level=1)
|
||||||
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前
|
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前
|
||||||
shared_grown = comfyui.run(buf.getvalue(), prompt=prompt,
|
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:
|
if gsc < 1.0 and shared_grown:
|
||||||
shared_grown = _upscale_png_to(shared_grown, w, h)
|
shared_grown = _upscale_png_to(shared_grown, w, h)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
@@ -261,7 +270,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)
|
compose_comfy_rgba(m_s, msk_s).save(buf, format="PNG", compress_level=1)
|
||||||
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前
|
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前
|
||||||
grown_png = comfyui.run(buf.getvalue(), prompt=prompt,
|
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:
|
if gsc < 1.0 and grown_png:
|
||||||
grown_png = _upscale_png_to(grown_png, w, h)
|
grown_png = _upscale_png_to(grown_png, w, h)
|
||||||
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
|
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
|
||||||
@@ -273,7 +283,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,
|
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)+ 换发型重绘图。
|
"""接口2 女性专用:发际线透明叠图(同 generate_grow_results)+ 换发型重绘图。
|
||||||
|
|
||||||
grown 图来源(新流程):对每个选中发型把 female key 映射到 change_hair 的 chang_* hair_id,
|
grown 图来源(新流程):对每个选中发型把 female key 映射到 change_hair 的 chang_* hair_id,
|
||||||
@@ -307,16 +319,17 @@ def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | N
|
|||||||
|
|
||||||
# 重绘管线(swapHair + ComfyUI)统一降分辨率:真实照片 swap(SD WebUI)~5s、blend、ComfyUI
|
# 重绘管线(swapHair + ComfyUI)统一降分辨率:真实照片 swap(SD WebUI)~5s、blend、ComfyUI
|
||||||
# 均随分辨率线性下降。overlay 预览仍用全分辨率;grown_png 最后放大回原尺寸。
|
# 均随分辨率线性下降。overlay 预览仍用全分辨率;grown_png 最后放大回原尺寸。
|
||||||
|
eff_side = _REDRAW_MAX_SIDE if redraw_max_side is None else redraw_max_side
|
||||||
redraw_img = image_bgr
|
redraw_img = image_bgr
|
||||||
hair_mask_redraw = hair_mask_reuse
|
hair_mask_redraw = hair_mask_reuse
|
||||||
if _REDRAW_MAX_SIDE > 0 and max(h, w) > _REDRAW_MAX_SIDE:
|
if eff_side > 0 and max(h, w) > eff_side:
|
||||||
redraw_img, _rs = _downscale_max_side(image_bgr, _REDRAW_MAX_SIDE)
|
redraw_img, _rs = _downscale_max_side(image_bgr, eff_side)
|
||||||
_nh, _nw = redraw_img.shape[:2]
|
_nh, _nw = redraw_img.shape[:2]
|
||||||
if hair_mask_redraw is not None:
|
if hair_mask_redraw is not None:
|
||||||
hair_mask_redraw = cv2.resize(hair_mask_reuse.astype(np.uint8), (_nw, _nh),
|
hair_mask_redraw = cv2.resize(hair_mask_reuse.astype(np.uint8), (_nw, _nh),
|
||||||
interpolation=cv2.INTER_NEAREST).astype(bool)
|
interpolation=cv2.INTER_NEAREST).astype(bool)
|
||||||
logger.info("接口2女 管线降分辨率: %dx%d → %dx%d (max_side=%d)",
|
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:
|
for order, (key, white_path) in items:
|
||||||
white = load_texture_rgba(white_path)
|
white = load_texture_rgba(white_path)
|
||||||
@@ -349,7 +362,9 @@ def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | N
|
|||||||
mask_bytes = base64.b64decode(mask_b64)
|
mask_bytes = base64.b64decode(mask_b64)
|
||||||
# 后端直接调 ComfyUI 重绘,返回重绘后的 PNG
|
# 后端直接调 ComfyUI 重绘,返回重绘后的 PNG
|
||||||
_tr0 = _t.perf_counter()
|
_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()
|
_tr1 = _t.perf_counter()
|
||||||
_tm = data.get("timings_ms") or {}
|
_tm = data.get("timings_ms") or {}
|
||||||
logger.info("接口2女 分段计时 type=%s: swapHair管线=%.2fs (mask=%dms swap=%dms blend=%dms), ComfyUI重绘=%.2fs",
|
logger.info("接口2女 分段计时 type=%s: swapHair管线=%.2fs (mask=%dms swap=%dms blend=%dms), ComfyUI重绘=%.2fs",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 440 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 459 KiB |
@@ -48,7 +48,7 @@ cd /home/ubuntu/hair/local_test
|
|||||||
|--------|------|------|------|
|
|--------|------|------|------|
|
||||||
| image | File | 是 | 人物图片(支持 jpg, png 等常见格式) |
|
| image | File | 是 | 人物图片(支持 jpg, png 等常见格式) |
|
||||||
| mask | File | 是 | 遮罩图片(支持 jpg, png,遮罩区域可用红色/白色/alpha 通道标识) |
|
| mask | File | 是 | 遮罩图片(支持 jpg, png,遮罩区域可用红色/白色/alpha 通道标识) |
|
||||||
| prompt | String | 否 | 提示词,默认值:"填充遮罩区域的头发,皮肤加一点磨皮" |
|
| prompt | String | 否 | 提示词,默认值:"填充遮罩区域的头发" |
|
||||||
|
|
||||||
#### 遮罩图片格式说明
|
#### 遮罩图片格式说明
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ cd /home/ubuntu/hair/local_test
|
|||||||
curl -X POST http://127.0.0.1:8899/api/generate \
|
curl -X POST http://127.0.0.1:8899/api/generate \
|
||||||
-F "image=@/path/to/person.jpg" \
|
-F "image=@/path/to/person.jpg" \
|
||||||
-F "mask=@/path/to/mask.png" \
|
-F "mask=@/path/to/mask.png" \
|
||||||
-F "prompt=填充遮罩区域的头发,皮肤加一点磨皮" \
|
-F "prompt=填充遮罩区域的头发" \
|
||||||
--output result.png
|
--output result.png
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ files = {
|
|||||||
"mask": open("mask.png", "rb"),
|
"mask": open("mask.png", "rb"),
|
||||||
}
|
}
|
||||||
data = {
|
data = {
|
||||||
"prompt": "填充遮罩区域的头发,皮肤加一点磨皮"
|
"prompt": "填充遮罩区域的头发"
|
||||||
}
|
}
|
||||||
|
|
||||||
resp = requests.post(url, files=files, data=data, timeout=600)
|
resp = requests.post(url, files=files, data=data, timeout=600)
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ def generate():
|
|||||||
return jsonify({"error": msg}), 400
|
return jsonify({"error": msg}), 400
|
||||||
image_file = request.files["image"]
|
image_file = request.files["image"]
|
||||||
mask_file = request.files["mask"]
|
mask_file = request.files["mask"]
|
||||||
prompt_text = request.form.get("prompt", "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
|
prompt_text = request.form.get("prompt", "填充遮罩区域的头发")
|
||||||
log.info(
|
log.info(
|
||||||
"收到请求: image=%s mask=%s prompt=%r",
|
"收到请求: image=%s mask=%s prompt=%r",
|
||||||
image_file.filename, mask_file.filename, prompt_text,
|
image_file.filename, mask_file.filename, prompt_text,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ def prep_and_upload():
|
|||||||
|
|
||||||
|
|
||||||
def run_once(fname, model, dtype, steps):
|
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"]["unet_name"] = model
|
||||||
wf["16"]["inputs"]["weight_dtype"] = dtype
|
wf["16"]["inputs"]["weight_dtype"] = dtype
|
||||||
wf["1"]["inputs"]["steps"] = steps
|
wf["1"]["inputs"]["steps"] = steps
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ def upload(scale=1.0):
|
|||||||
|
|
||||||
|
|
||||||
def run(fname, steps):
|
def run(fname, steps):
|
||||||
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
|
wf = A.build_workflow(fname, "填充遮罩区域的头发")
|
||||||
wf["16"]["inputs"]["unet_name"] = MODEL
|
wf["16"]["inputs"]["unet_name"] = MODEL
|
||||||
wf["16"]["inputs"]["weight_dtype"] = DTYPE
|
wf["16"]["inputs"]["weight_dtype"] = DTYPE
|
||||||
wf["1"]["inputs"]["steps"] = steps
|
wf["1"]["inputs"]["steps"] = steps
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ SEED = 123456789
|
|||||||
imgs = []
|
imgs = []
|
||||||
labels = []
|
labels = []
|
||||||
for steps in [2, 3, 4, 6]:
|
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"]["unet_name"] = MODEL
|
||||||
wf["16"]["inputs"]["weight_dtype"] = DTYPE
|
wf["16"]["inputs"]["weight_dtype"] = DTYPE
|
||||||
wf["1"]["inputs"]["steps"] = steps
|
wf["1"]["inputs"]["steps"] = steps
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ button { padding: 10px 24px; border: none; border-radius: 6px; cursor: pointer;
|
|||||||
</div>
|
</div>
|
||||||
<div class="controls" style="margin-top:16px">
|
<div class="controls" style="margin-top:16px">
|
||||||
<label>提示词:</label>
|
<label>提示词:</label>
|
||||||
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜">
|
<input type="text" id="promptInput" value="填充遮罩区域的头发">
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align:center; margin-top:16px">
|
<div style="text-align:center; margin-top:16px">
|
||||||
<button class="btn-generate" id="generateBtn" disabled>🚀 生成</button>
|
<button class="btn-generate" id="generateBtn" disabled>🚀 生成</button>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ resp = requests.post(
|
|||||||
"image": ("original.jpg", img_data, "image/jpeg"),
|
"image": ("original.jpg", img_data, "image/jpeg"),
|
||||||
"mask": ("mask.png", mask_data, "image/png"),
|
"mask": ("mask.png", mask_data, "image/png"),
|
||||||
},
|
},
|
||||||
data={"prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"},
|
data={"prompt": "填充遮罩区域的头发"},
|
||||||
timeout=600,
|
timeout=600,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""重新生成 bench3/4/5/7 报告,在最左边加原图列。"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPOS_ROOT = Path("/home/ubuntu/hair")
|
||||||
|
ORIG_SRC = {"asdf": "bench/orig/asdf.jpg", "qwer": "bench/orig/qwer.jpg",
|
||||||
|
"girl2": "bench/orig/girl2.jpg", "girl5": "bench/orig/girl5.jpg"}
|
||||||
|
|
||||||
|
# bench编号 -> (输出目录, 部署HTML名, 报告标题后缀)
|
||||||
|
BENCHES = [
|
||||||
|
(3, "美颜(磨皮+美颜)"),
|
||||||
|
(4, "磨皮"),
|
||||||
|
(5, "美白"),
|
||||||
|
(7, "纯生发"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def img_src(path, bench_num):
|
||||||
|
if not path or not os.path.isfile(path):
|
||||||
|
return None
|
||||||
|
return f"bench{bench_num}/" + os.path.basename(path)
|
||||||
|
|
||||||
|
|
||||||
|
def gen_report(bench_num, title_suffix):
|
||||||
|
bench_dir = REPOS_ROOT / f"benchmark_out/bench{bench_num}"
|
||||||
|
results = bench_dir / "results.json"
|
||||||
|
if not results.exists():
|
||||||
|
print(f" 跳过 bench{bench_num}: results.json 不存在")
|
||||||
|
return
|
||||||
|
d = json.load(open(results, encoding="utf-8"))
|
||||||
|
titles = d["res_titles"]
|
||||||
|
rows = d["rows"]
|
||||||
|
prompt = d.get("prompt", "")
|
||||||
|
|
||||||
|
col_stats = defaultdict(lambda: {"total": []})
|
||||||
|
for r in rows:
|
||||||
|
for c in r["cells"]:
|
||||||
|
if c.get("ok"):
|
||||||
|
col_stats[c["res_title"]]["total"].append(c["total_ms"])
|
||||||
|
|
||||||
|
# 表头:原图列 + 分辨率列
|
||||||
|
headers = ['<th class="col-label">原图</th>']
|
||||||
|
for t in titles:
|
||||||
|
s = col_stats.get(t)
|
||||||
|
avg = sum(s["total"]) // len(s["total"]) if s and s["total"] else 0
|
||||||
|
headers.append(f'<th class="col-label"><div class="col-title">{t}</div>'
|
||||||
|
f'<div class="col-stat">均{avg/1000:.1f}s</div></th>')
|
||||||
|
|
||||||
|
body_rows = []
|
||||||
|
for r in rows:
|
||||||
|
orig_src = ORIG_SRC.get(r["img"])
|
||||||
|
label = f'<div class="row-label">{r["img"]}<br><b>{r["hair_name"]}</b></div>'
|
||||||
|
# 原图列:显示输入原图
|
||||||
|
orig_cell = (f'<td class="cell-orig"><div class="row-label-cell">{label}</div>'
|
||||||
|
f'<img class="orig-img" src="{orig_src}"></td>')
|
||||||
|
cells = [orig_cell]
|
||||||
|
for c in r["cells"]:
|
||||||
|
src = img_src(c.get("grown_path"), bench_num) if c.get("ok") else None
|
||||||
|
if src:
|
||||||
|
t = c.get("total_ms", 0)
|
||||||
|
cells.append(f'<td class="cell-result"><img class="result-img" src="{src}" loading="lazy">'
|
||||||
|
f'<div class="cell-time">{t/1000:.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>重绘分辨率对比({title_suffix})</title>
|
||||||
|
<style>
|
||||||
|
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||||
|
body {{ font-family: -apple-system, 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: 130px; max-width: 150px; }}
|
||||||
|
.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: 130px; max-height: 160px; object-fit: contain; background: #f3f4f6; }}
|
||||||
|
.orig-img {{ border: 2px solid #d1d5db; }}
|
||||||
|
.cell-time {{ font-size: 10px; color: #9ca3af; margin-top: 2px; }}
|
||||||
|
.na {{ color: #d1d5db; font-size: 12px; padding: 40px 10px; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>📊 重绘分辨率对比(提示词:{title_suffix})</h1>
|
||||||
|
<p class="subtitle">4图×5发型=20行 · 每行原图+4分辨率 · steps=15 · 提示词="{prompt}" · 80/80成功 · 0 OOM</p>
|
||||||
|
<div class="legend">最左列为输入原图。列标题下为平均总耗时。横向滚动查看。</div>
|
||||||
|
<div class="scroll-wrap">
|
||||||
|
<table>
|
||||||
|
<tr>{"".join(headers)}</tr>
|
||||||
|
{"".join(body_rows)}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
deploy = REPOS_ROOT / "static" / f"bench{bench_num}_report.html"
|
||||||
|
deploy.write_text(html, encoding="utf-8")
|
||||||
|
print(f" ✓ bench{bench_num} ({title_suffix}): {deploy.name}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("重新生成报告(加原图列):")
|
||||||
|
for num, suffix in BENCHES:
|
||||||
|
gen_report(num, suffix)
|
||||||
|
print("完成")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 296 KiB |
|
After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 277 KiB |
|
After Width: | Height: | Size: 309 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 287 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 277 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 314 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 275 KiB |
|
After Width: | Height: | Size: 307 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 291 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 335 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 263 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 230 KiB |