Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f68049f001 | ||
|
|
7725e461eb | ||
|
|
7d8dfd93b8 | ||
|
|
9379fbb8f8 | ||
|
|
c20606c003 | ||
|
|
1ca033f25a | ||
|
|
87ff2c15d0 | ||
|
|
c536e4ccb1 |
@@ -29,7 +29,7 @@
|
||||
"60": {
|
||||
"class_type": "JjkText",
|
||||
"inputs": {
|
||||
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
||||
"text": "填充遮罩区域的头发"
|
||||
}
|
||||
},
|
||||
"22": {
|
||||
|
||||
@@ -410,7 +410,7 @@
|
||||
},
|
||||
"60": {
|
||||
"inputs": {
|
||||
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
||||
"text": "填充遮罩区域的头发"
|
||||
},
|
||||
"class_type": "JjkText",
|
||||
"_meta": {
|
||||
|
||||
@@ -381,7 +381,7 @@
|
||||
},
|
||||
"60": {
|
||||
"inputs": {
|
||||
"text": "补充遮罩内区域的头发,区域内填充满头发,不要保留皮肤,发际线下移填充头发。自然的头发生长方向,逼真的头发质感,自然发质。"
|
||||
"text": "填充遮罩区域的头发"
|
||||
},
|
||||
"class_type": "JjkText",
|
||||
"_meta": {
|
||||
@@ -684,7 +684,7 @@
|
||||
},
|
||||
"87": {
|
||||
"inputs": {
|
||||
"text": "去掉头发接缝的黄色痕迹,头发完美融合,保持发型不变,发色不变。其他不变。",
|
||||
"text": "填充遮罩区域的头发",
|
||||
"clip": [
|
||||
"78",
|
||||
0
|
||||
|
||||
@@ -675,10 +675,11 @@ async def face_measure_v2(
|
||||
- 发际线类型 `hairline_type`(英文 key)
|
||||
- 顺序 `order`(本期固定 `1..N`,不排序)
|
||||
|
||||
> **female 走「换发型」模式**:生发图 `grown_image_base64` 由换发型(change_hair)
|
||||
> **female 走「换发型」模式**:1..5 的生发图 `grown_image_base64` 由换发型(change_hair)
|
||||
> + Flux-2 整帧重绘(= 接口12 final 管线,整帧美颜+整帧重绘)生成,其余参数用固化默认值。
|
||||
> **male 仍走原生发(ComfyUI add_hair)管线**。入参与返回结构不变。
|
||||
> female 依赖 change_hair 与 ComfyUI(:8188) 均在跑。
|
||||
> **6/7(bigflower/clasicalflower)与 male 一样走原生发(ComfyUI add_hair)管线**。
|
||||
> **male 全部走原生发(ComfyUI add_hair)管线**。入参与返回结构不变。
|
||||
> female 1..5 依赖 change_hair 与 ComfyUI(:8188) 均在跑。
|
||||
|
||||
{_image_fields_desc}
|
||||
|
||||
@@ -686,15 +687,15 @@ async def face_measure_v2(
|
||||
|
||||
---
|
||||
|
||||
- **gender**(必填):`male` / `female`。决定返回的贴图集合(female 5 张 / male 4 张)。
|
||||
- **gender**(必填):`male` / `female`。决定返回的贴图集合(female 7 张 / male 6 张)。
|
||||
非法或缺失返回 `1004`。
|
||||
- **hair_style**(必填):发型序号,**逗号分隔多选**(如 `1,2,3`),最多不超过该性别的预设数量。
|
||||
`female`:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave;
|
||||
`male`:1=ellipse, 2=inverse_arc, 3=m, 4=straight。越界/非法返回 `1007`。
|
||||
`female`:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave, 6=bigflower, 7=clasicalflower;
|
||||
`male`:1=ellipse, 2=inverse_arc, 3=m, 4=straight, 5=heart, 6=Softpetal。越界/非法返回 `1007`。
|
||||
- **beauty_enabled**:本期保留但不生效。
|
||||
|
||||
`hairline_type` 取值:`ellipse` / `flower` / `heart` / `straight` / `wave`(female),
|
||||
`ellipse` / `m` / `straight` / `inverse_arc`(male)。
|
||||
`hairline_type` 取值:`ellipse` / `flower` / `heart` / `straight` / `wave` / `bigflower` / `clasicalflower`(female),
|
||||
`ellipse` / `m` / `straight` / `inverse_arc` / `heart` / `Softpetal`(male)。
|
||||
""",
|
||||
responses={
|
||||
200: {
|
||||
@@ -733,17 +734,17 @@ async def hair_grow(
|
||||
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
||||
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
||||
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
|
||||
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填),如 1,2,3。female:1-5 male:1-4"),
|
||||
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填),如 1,2,3。female:1-7 male:1-6"),
|
||||
beauty_enabled: bool = Form(default=False, description="是否开启美颜(本期不生效)"),
|
||||
use_mask: bool = Form(default=True, description="是否启用 inpaint 遮罩(测试对比用)。false 时用干净原图生成(空遮罩,不烧模板线)"),
|
||||
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||
prompt: str = Form(default="填充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||
):
|
||||
# 1. gender 必填校验(非法/缺失 → 1004)
|
||||
if gender not in ("male", "female"):
|
||||
return err(1004, "gender 必填且只能为 male / female")
|
||||
|
||||
# 2. hair_style 必填校验(解析逗号分隔,越界 → 1007)
|
||||
max_styles = {"female": 5, "male": 4}[gender]
|
||||
max_styles = {"female": 7, "male": 6}[gender]
|
||||
hair_styles = _parse_hair_styles(hair_style, max_styles)
|
||||
if hair_styles is None:
|
||||
return err(1007, f"hair_style 必填且为 1..{max_styles} 的整数(逗号分隔),收到 {hair_style!r}")
|
||||
@@ -765,7 +766,8 @@ async def hair_grow(
|
||||
if gender == "female":
|
||||
from hairline.service import generate_grow_results_swap
|
||||
items = await run_in_threadpool(
|
||||
generate_grow_results_swap, image, hair_styles, _V2_FINAL_DEFAULTS)
|
||||
generate_grow_results_swap, image, hair_styles, _V2_FINAL_DEFAULTS,
|
||||
prompt=prompt)
|
||||
else:
|
||||
from hairline.service import generate_grow_results
|
||||
items = await run_in_threadpool(
|
||||
@@ -788,6 +790,149 @@ async def hair_grow(
|
||||
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 = 7
|
||||
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 大模型,会把常驻的
|
||||
# Klein-4b/Flux 挤出显存,导致接口2/3/5 耗时抖动;且业务已不再调用)。
|
||||
@@ -808,6 +953,7 @@ async def hair_grow_v2():
|
||||
"/api/v1/hair/grow-b",
|
||||
summary="接口3 B端生发(医生/操作端)",
|
||||
tags=["生发"],
|
||||
deprecated=True,
|
||||
description="""
|
||||
医生/操作端在用户照片上**手动用马克笔划线标注**目标发际线后,**只需上传这一张划线图**,返回:
|
||||
- 生发后效果图(系统检测划线 → 据此生成「植发 3 个月」效果)
|
||||
@@ -847,36 +993,11 @@ async def hair_grow_b(
|
||||
marked_image_url: Optional[str] = Form(default=None, description="划线图片 URL"),
|
||||
marked_image_base64: Optional[str] = Form(default=None, description="划线图片 base64"),
|
||||
use_mask: bool = Form(default=True, description="是否画发际线(测试对比用)。false 时跳过划线检测、直接送划线图"),
|
||||
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||
prompt: str = Form(default="填充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||
):
|
||||
# 划线图三选一取图(只需这一张)
|
||||
marked_raw, e = await resolve_image_bytes(marked_image_file, marked_image_url, marked_image_base64)
|
||||
if e is not None:
|
||||
return e
|
||||
|
||||
marked = cv2.imdecode(np.frombuffer(marked_raw, np.uint8), cv2.IMREAD_COLOR)
|
||||
if marked is None:
|
||||
return err(1008, "图片格式不支持(仅 JPG / PNG)")
|
||||
|
||||
try:
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from hairline.service import generate_grow_b
|
||||
|
||||
res = await run_in_threadpool(generate_grow_b, marked, use_mask, prompt)
|
||||
if res["status"] == "no_face":
|
||||
return err(1001, "无法识别人像")
|
||||
if res["status"] == "no_line":
|
||||
return err(1001, "未检测到发际线划线,请确认划线图额头有清晰的手绘发际线")
|
||||
|
||||
grown_b64 = _png_to_jpg_b64(res["grown_png"]) if res["grown_png"] else None # 生发图 JPG
|
||||
data = {
|
||||
"hair_growth_image_base64": grown_b64,
|
||||
"hairline_type": "custom",
|
||||
}
|
||||
return ok(data)
|
||||
except Exception as ex: # noqa: BLE001
|
||||
logger.exception("接口3 处理异常")
|
||||
return err(1007, f"处理失败:{ex}")
|
||||
"""接口3 已屏蔽:调用直接返回错误,不再执行生发/ComfyUI 逻辑。
|
||||
保留路由(含原参数签名)避免老客户端裸 404,multipart 入参仍被接受但不处理。"""
|
||||
return err(1007, "接口3(/api/v1/hair/grow-b)已屏蔽,暂不提供服务")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -941,9 +1062,10 @@ async def face_features(
|
||||
---
|
||||
|
||||
**入参**(同接口2:先选性别,再多选发型):
|
||||
- 必填 `gender`(`male`/`female`),决定发型集合(female 5 / male 4)。
|
||||
- 必填 `gender`(`male`/`female`),决定发型集合(female 7 / male 6)。
|
||||
- 必填 `hair_style`(发型序号,逗号分隔如 `1,2,3`),决定返回哪些发际线类型。缺失/越界/非法返回 `1007`。
|
||||
`female`:1=ellipse,2=flower,3=heart,4=straight,5=wave;`male`:1=ellipse,2=inverse_arc,3=m,4=straight。
|
||||
`female`:1=ellipse,2=flower,3=heart,4=straight,5=wave,6=bigflower,7=clasicalflower;
|
||||
`male`:1=ellipse,2=inverse_arc,3=m,4=straight,5=heart,6=Softpetal。
|
||||
- 可选 `use_mask` / `prompt`:同接口2 的生发控制参数。
|
||||
注:生发黑模板固定取 `hairline_texture_black/`(middle 档),即三档叠图分别用各自贴图、但生发目标固定 middle。
|
||||
- 可选 `generate_grow_image`(默认 `true`):是否生成生发效果图(ComfyUI 生发,全流程最耗时)。
|
||||
@@ -1023,16 +1145,16 @@ async def hairline_generate(
|
||||
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
||||
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
||||
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
|
||||
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填,如 1,2,3)。female:1-5 male:1-4"),
|
||||
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填,如 1,2,3)。female:1-7 male:1-6"),
|
||||
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,仅返回三档发际线叠图与中心点"),
|
||||
):
|
||||
if gender not in ("male", "female"):
|
||||
return err(1004, "gender 必填且只能为 male / female")
|
||||
|
||||
# hair_style 必填(同接口2):解析逗号分隔,缺失/越界/非法 → 1007
|
||||
max_styles = {"female": 5, "male": 4}[gender]
|
||||
max_styles = {"female": 7, "male": 6}[gender]
|
||||
hair_styles = _parse_hair_styles(hair_style, max_styles)
|
||||
if hair_styles is None:
|
||||
return err(1007, f"hair_style 必填且为 1..{max_styles} 的整数(逗号分隔),收到 {hair_style!r}")
|
||||
@@ -1419,7 +1541,7 @@ async def hairline_grow_v2(
|
||||
inpainting_fill: int = Form(default=1, description="change_hair服务端重绘填充:0=保留原图 | 1=噪声 | 2=纯色 | 3=潜变量。默认 1"),
|
||||
mask_blur: int = Form(default=11, description="change_hair服务端遮罩边缘模糊像素,默认 11"),
|
||||
mask_dilate_scale: float = Form(default=1.0, description="change_hair服务端遮罩膨胀缩放,默认 1.0"),
|
||||
comfyui_prompt: Optional[str] = Form(default=None, description="Flux-2 重绘提示词,None 用默认「填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜」"),
|
||||
comfyui_prompt: Optional[str] = Form(default=None, description="Flux-2 重绘提示词,None 用默认「填充遮罩区域的头发」"),
|
||||
beauty_alpha: float = Form(default=0.6, description="redraw_band 版 band 外的全脸美颜融入强度(0=band外无美颜纯用final,1≈整帧版),默认 0.6"),
|
||||
band_lo_mult: float = Form(default=0.5, description="重绘带外推倍率下限(相对 hairline_push_cm,内轮廓=0×、原外推线=1.0×),默认 0.5"),
|
||||
band_hi_mult: float = Form(default=1.5, description="重绘带外推倍率上限(相对 hairline_push_cm),默认 1.5"),
|
||||
@@ -1585,7 +1707,7 @@ async def hairline_grow_v2_final_v2(
|
||||
async def api_redraw(
|
||||
image_file: UploadFile = File(..., description="人物图片(JPG/PNG)"),
|
||||
mask_file: UploadFile = File(..., description="遮罩图片(PNG,支持红/白/alpha 格式)"),
|
||||
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜",
|
||||
prompt: str = Form(default="填充遮罩区域的头发",
|
||||
description="ComfyUI 提示词"),
|
||||
):
|
||||
image_bytes = await image_file.read()
|
||||
|
||||
@@ -58,7 +58,7 @@ def call_iface2(image_path, hair_style):
|
||||
with open(image_path, "rb") as f:
|
||||
img_data = f.read()
|
||||
fields = {"gender": "female", "hair_style": str(hair_style),
|
||||
"prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"}
|
||||
"prompt": "填充遮罩区域的头发"}
|
||||
body, boundary = _multipart(
|
||||
fields, {"image_file": (os.path.basename(image_path), img_data, "image/jpeg")})
|
||||
req = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow", data=body, method="POST")
|
||||
|
||||
@@ -56,7 +56,7 @@ def call_iface2_female(image_path, hair_style):
|
||||
with open(image_path, "rb") as f:
|
||||
img_data = f.read()
|
||||
fields = {"gender": "female", "hair_style": str(hair_style),
|
||||
"prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"}
|
||||
"prompt": "填充遮罩区域的头发"}
|
||||
body, boundary = _multipart(
|
||||
fields, {"image_file": (os.path.basename(image_path), img_data, "image/jpeg")})
|
||||
req = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow", data=body, method="POST")
|
||||
|
||||
@@ -89,7 +89,7 @@ def call_api2(image_path, gender, hair_style="1"):
|
||||
with open(image_path, "rb") as f:
|
||||
img_data = f.read()
|
||||
fields = {"gender": gender, "hair_style": hair_style, "use_mask": "0",
|
||||
"prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"}
|
||||
"prompt": "填充遮罩区域的头发"}
|
||||
body, boundary = _multipart(fields, {"image_file": (os.path.basename(image_path), img_data, "image/jpeg")})
|
||||
req = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow", data=body, method="POST")
|
||||
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
|
||||
@@ -107,7 +107,7 @@ def call_api3(image_path):
|
||||
"""接口3:B端生发(use_mask=False,直接送图)"""
|
||||
with open(image_path, "rb") as f:
|
||||
img_data = f.read()
|
||||
fields = {"use_mask": "true", "prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"}
|
||||
fields = {"use_mask": "true", "prompt": "填充遮罩区域的头发"}
|
||||
body, boundary = _multipart(fields, {"marked_image_file": (os.path.basename(image_path), img_data, "image/jpeg")})
|
||||
req = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow-b", data=body, method="POST")
|
||||
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
按 `face_ext.obj` 的 UV 把发际线贴图渲染到额头(预览)。生发:黑贴图渲染遮罩 → 调本机 **ComfyUI 8182** 的
|
||||
`add_hair.json`(Flux-2) 出图。**关键坑**:obj 是重排序,需 `INDEX_MAP_468` 把 MP 序→OBJ 序。
|
||||
返回 `results[].image_base64` + `grown_image_base64`。
|
||||
- `hair_style` 映射:female 1=ellipse 2=flower 3=heart 4=straight 5=wave;male 1=ellipse 2=inverse_arc 3=m 4=straight。
|
||||
- `hair_style` 映射:female 1=ellipse 2=flower 3=heart 4=straight 5=wave 6=bigflower 7=clasicalflower;male 1=ellipse 2=inverse_arc 3=m 4=straight 5=heart 6=Softpetal。female 1..5 走「换发型(change_hair)」+Flux-2 重绘管线;female 6/7 与 male 全部走原生发(ComfyUI add_hair)管线。
|
||||
|
||||
### 接口7 C端生发 v2 `/api/v1/hair/grow-v2`(worker)—— 接口2同款,add_hair2 工作流
|
||||
- **做什么**:与接口 2 完全一致(正面照 + `gender` + `hair_style` 逗号分隔多选 → N 组预览+生发图)。
|
||||
@@ -85,7 +85,7 @@
|
||||
### 接口4 用户特征 `/api/v1/face/features`(**网关本机**)
|
||||
- **做什么**:照片 → 几十项面部特征(脸型/眉形/肤色/三庭五眼/四季色彩季型/量感/基因风格/性别…)。`data.features` 是 JSON 字符串。
|
||||
- **怎么实现**(`gateway/`,逻辑参考 worker `face_features.py` / `/home/xsl/fuyan`):调**火山方舟 豆包视觉模型**
|
||||
`doubao-seed-1-6-vision`(OpenAI 兼容,base64 data URI 喂图),解析眉形/年龄/动静/性别/基因风格 5 项。
|
||||
`doubao-seed-2-0-lite-260428`(OpenAI 兼容,base64 data URI 喂图),解析眉形/年龄/动静/性别/基因风格 5 项。
|
||||
**`face_shape`(脸型)改为本机 `face/face_shape_classifier.py`(MediaPipe)计算并覆盖豆包结果**。
|
||||
无人脸→1001。网关需可达 `ark.cn-beijing.volces.com`,API Key 走网关配置(不入 git)。
|
||||
⚠️ 因此**网关机不再是纯轻量代理**,需额外安装 `mediapipe`/`opencv-python`/`numpy<2`(见 `requirements.txt`)。
|
||||
@@ -115,7 +115,7 @@
|
||||
(不再是"网关不装 torch/mediapipe/opencv",只是仍不需要 torch/transformers/scikit-image 等重依赖)。
|
||||
- 配置 `gateway/config.json`(不入 git):`workers` 列表、`shared_password`、
|
||||
`ark` 的 api_key/base_url/model、`public_base_url`、超时(**生发接口慢,`request_timeout_seconds` 调大 ≥120s**)。
|
||||
- 托管 `/static/annotations/`(落盘的图),定期清理。
|
||||
- 托管 `/static/annotations/`(落盘的图),永久保留,不自动清理。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -265,7 +265,7 @@
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| gender | string | **是** | 性别:`male` / `female`。决定使用的发际线贴图集合 |
|
||||
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),最多不超过该性别的预设数。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight。越界/非法返回 `1007` |
|
||||
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),最多不超过该性别的预设数。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave, 6=bigflower, 7=clasicalflower;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight, 5=heart, 6=Softpetal。越界/非法返回 `1007` |
|
||||
| beauty_enabled | bool | 否 | 生发图是否带美颜效果,默认 false(当前阶段不生效) |
|
||||
| use_mask | bool | 否 | 是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
|
||||
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
|
||||
@@ -278,7 +278,7 @@
|
||||
|------|------|------|
|
||||
| image_url | string | 发际线曲线**透明 PNG** URL(仅白色发际线曲线,透明底,**不含人物**,需前端叠加原图显示) |
|
||||
| grown_image_url | string | **生发后图片** URL(ComfyUI/Flux「植发 3 个月」效果图,完整人像照片) |
|
||||
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`(female),`ellipse`/`m`/`straight`/`inverse_arc`(male) |
|
||||
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`/`bigflower`/`clasicalflower`(female),`ellipse`/`m`/`straight`/`inverse_arc`/`heart`/`Softpetal`(male) |
|
||||
| order | int | 排序序号(当前阶段固定 `1..N`,按贴图顺序,暂不计算合适度) |
|
||||
|
||||
> ⚠️ 生发图由本机 ComfyUI(Flux-2,端口 8182)生成,**一次请求生成指定发型的 1 张、同步返回**。
|
||||
@@ -352,7 +352,7 @@
|
||||
|
||||
## 接口 4:用户特征接口
|
||||
|
||||
**说明**:输入用户照片,由**火山方舟 豆包视觉模型**(`doubao-seed-1-6-vision`)分析,输出一大批面部特征。
|
||||
**说明**:输入用户照片,由**火山方舟 豆包视觉模型**(`doubao-seed-2-0-lite-260428`)分析,输出一大批面部特征。
|
||||
|
||||
**请求**:`POST /api/v1/face/features`
|
||||
|
||||
@@ -405,8 +405,8 @@
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| gender | string | **是** | 性别:`male` / `female`。决定发型集合(female 5 / male 4)。缺失/非法返回 `1004` |
|
||||
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),决定返回哪些发际线类型。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight。缺失/越界/非法返回 `1007` |
|
||||
| gender | string | **是** | 性别:`male` / `female`。决定发型集合(female 7 / male 6)。缺失/非法返回 `1004` |
|
||||
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),决定返回哪些发际线类型。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave, 6=bigflower, 7=clasicalflower;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight, 5=heart, 6=Softpetal。缺失/越界/非法返回 `1007` |
|
||||
| use_mask | bool | 否 | 生发是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
|
||||
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
|
||||
| generate_grow_image | bool | 否 | 是否生成生发效果图(ComfyUI 生发,全流程最耗时),默认 `true`。传 `false` 时跳过生发,各发型 `grown_image_*` 恒为 `null`,仅返回三档发际线叠图与中心点,可大幅降低耗时 |
|
||||
@@ -427,7 +427,7 @@
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`(female),`ellipse`/`m`/`straight`/`inverse_arc`(male) |
|
||||
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`/`bigflower`/`clasicalflower`(female),`ellipse`/`m`/`straight`/`inverse_arc`/`heart`/`Softpetal`(male) |
|
||||
| image_middle_url | string | middle 档发际线曲线**透明 PNG** URL(仅曲线,透明底,**不含人物**,需叠加原图显示) |
|
||||
| image_high_url | string | high 档发际线曲线**透明 PNG** URL(同上,high 档曲线) |
|
||||
| image_low_url | string | low 档发际线曲线**透明 PNG** URL(同上,low 档曲线) |
|
||||
|
||||
@@ -203,7 +203,7 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
|
||||
|
||||
# --- 自适应尺寸:字号/线宽/虚线/箭头按短边缩放 ---
|
||||
s = min(w, h)
|
||||
font_size = max(11, round(s * 0.026)) # 字体更小
|
||||
font_size = max(12, round(s * 0.030)) # 字号上调一档
|
||||
line_w = max(1, round(s * 0.0022))
|
||||
dash_len = max(4, round(s * 0.008))
|
||||
gap_len = max(2, round(dash_len * 0.7)) # 虚线更稠密(间隙<划线)
|
||||
@@ -260,6 +260,18 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
font = _load_font(font_size)
|
||||
|
||||
# --- 2b. 每条横线在「中间线段」(两内眼角之间)中点画原点突出 ---
|
||||
# 注意:原点不在整条线的中点 face_cx,而在被竖线切出的中间段(左内眼角↔右内眼角)
|
||||
# 的正中,即脸的竖直中轴附近、两内眼角连线中点。
|
||||
li_x = pts["left_inner"][0]
|
||||
ri_x = pts["right_inner"][0]
|
||||
dot_cx = (li_x + ri_x) / 2
|
||||
dot_r = max(2, round(s * 0.0045)) # 原点半径,与 arrow_size 同档自适应
|
||||
for cy in ys:
|
||||
x0, y0 = dot_cx - dot_r, cy - dot_r
|
||||
x1, y1 = dot_cx + dot_r, cy + dot_r
|
||||
draw.ellipse((x0, y0, x1, y1), fill=LINE_COLOR)
|
||||
|
||||
# --- 3a. 横线右侧:线名(头顶/发际线/眉心/鼻翼下缘/下巴尖),文字在线上方 ---
|
||||
name_x = fx1 + pad
|
||||
name_gap = max(2, round(pad * 1.6)) # 文字底部到线的间距(再上移)
|
||||
|
||||
@@ -1107,7 +1107,7 @@ def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="seg
|
||||
"hairline_id": hairline_id,
|
||||
"blend_method": blend_method,
|
||||
"hairline_push_cm": round(float(hairline_push_cm), 2),
|
||||
"comfyui_prompt": comfyui_prompt or "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜",
|
||||
"comfyui_prompt": comfyui_prompt or "填充遮罩区域的头发",
|
||||
"beauty_alpha": beauty_alpha,
|
||||
"px_per_cm": round(float(px_per_cm), 4),
|
||||
"mask_pixels": mask_viz["mask_pixels"],
|
||||
|
||||
@@ -12,7 +12,7 @@ from face_analysis.calibration import (
|
||||
estimate_scale_factor, normalized_to_pixel, pixel_distance, _lm_list,
|
||||
)
|
||||
from face_analysis.face_mesh_landmarks import (
|
||||
GLABELLA_9, GLABELLA_151, NOSE_BOTTOM, CHIN_TIP,
|
||||
GLABELLA_9, NOSE_BOTTOM, CHIN_TIP,
|
||||
LEFT_EYE_OUTER, LEFT_EYE_INNER, RIGHT_EYE_INNER, RIGHT_EYE_OUTER,
|
||||
LEFT_CHEEK, RIGHT_CHEEK, LEFT_POSITION, RIGHT_POSITION,
|
||||
)
|
||||
@@ -24,10 +24,8 @@ _TOP_RATIO = 0.22 / 0.28 # 顶庭 ÷ 中庭(≈ 0.786)
|
||||
|
||||
|
||||
def _brow_center(lm, w, h):
|
||||
"""眉心 = 索引 9 / 151 中点。"""
|
||||
g9 = normalized_to_pixel(lm[GLABELLA_9], w, h)
|
||||
g151 = normalized_to_pixel(lm[GLABELLA_151], w, h)
|
||||
return (g9[0] + g151[0]) / 2, (g9[1] + g151[1]) / 2
|
||||
"""眉心 = 索引 9(眉间上点)。"""
|
||||
return normalized_to_pixel(lm[GLABELLA_9], w, h)
|
||||
|
||||
|
||||
def estimate_vertical_landmarks(landmarks, image_width, image_height):
|
||||
|
||||
@@ -17,7 +17,7 @@ import os
|
||||
logger = logging.getLogger("hair.worker")
|
||||
|
||||
ARK_BASE_URL = os.getenv("ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3")
|
||||
ARK_MODEL = os.getenv("ARK_MODEL", "doubao-seed-1-6-vision-250815")
|
||||
ARK_MODEL = os.getenv("ARK_MODEL", "doubao-seed-2-0-lite-260428")
|
||||
|
||||
# doubao 中文键 → 接口4 英文字段(脸型不走豆包,见 _local_face_shape)
|
||||
_KEY_MAP = {
|
||||
|
||||
@@ -4,13 +4,10 @@
|
||||
不跑任何算法(无 torch/mediapipe/opencv 依赖)。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -67,32 +64,15 @@ async def lifespan(app: FastAPI):
|
||||
else:
|
||||
app.state._pool_shutdown = None
|
||||
|
||||
# 确保标注图目录存在
|
||||
# 确保标注图目录存在(永久保留,不做定期清理)
|
||||
static_dir = Path(cfg["static_dir"])
|
||||
static_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.info("标注图目录: %s", static_dir)
|
||||
|
||||
# 启动定期清理任务(阶段四)
|
||||
cleanup_shutdown = asyncio.Event()
|
||||
cleanup_task = asyncio.create_task(
|
||||
_cleanup_loop(static_dir, cfg, cleanup_shutdown)
|
||||
)
|
||||
app.state._cleanup_shutdown = cleanup_shutdown
|
||||
app.state._cleanup_task = cleanup_task
|
||||
logger.info("标注图目录: %s(永久保留,不自动清理)", static_dir)
|
||||
|
||||
yield
|
||||
|
||||
# 关闭
|
||||
logger.info("网关关闭中...")
|
||||
# 停止清理任务
|
||||
if app.state._cleanup_shutdown:
|
||||
app.state._cleanup_shutdown.set()
|
||||
if app.state._cleanup_task:
|
||||
app.state._cleanup_task.cancel()
|
||||
try:
|
||||
await app.state._cleanup_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if app.state._pool_shutdown:
|
||||
await app.state._pool_shutdown()
|
||||
logger.info("网关已关闭")
|
||||
@@ -132,51 +112,6 @@ def _get_pool_status_safe():
|
||||
return {"total": 0, "healthy": 0, "busy": 0}
|
||||
|
||||
|
||||
async def _cleanup_loop(annotations_dir: Path, cfg: dict, shutdown: asyncio.Event):
|
||||
"""定期清理 static/annotations/ 中过期的标注图文件。
|
||||
|
||||
配置项(可选,在 config.json 中设定):
|
||||
- cleanup.interval_minutes: 清理间隔,默认 60
|
||||
- cleanup.max_age_hours: 文件保留时长(小时),默认 24
|
||||
"""
|
||||
cleanup_cfg = cfg.get("cleanup", {})
|
||||
interval_s = cleanup_cfg.get("interval_minutes", 60) * 60
|
||||
max_age_s = cleanup_cfg.get("max_age_hours", 24) * 3600
|
||||
|
||||
logger.info(
|
||||
"清理任务启动 | 间隔=%dmin | 保留=%dh | 目录=%s",
|
||||
interval_s // 60, max_age_s // 3600, annotations_dir,
|
||||
)
|
||||
|
||||
while not shutdown.is_set():
|
||||
try:
|
||||
await asyncio.wait_for(shutdown.wait(), timeout=interval_s)
|
||||
break # shutdown
|
||||
except asyncio.TimeoutError:
|
||||
pass # 正常到时,执行清理
|
||||
|
||||
now = time.time()
|
||||
deleted = 0
|
||||
for f in annotations_dir.iterdir():
|
||||
if f.name == ".gitkeep":
|
||||
continue
|
||||
if not f.is_file():
|
||||
continue
|
||||
try:
|
||||
age_s = now - f.stat().st_mtime
|
||||
if age_s > max_age_s:
|
||||
f.unlink()
|
||||
deleted += 1
|
||||
logger.debug("清理过期文件: %s (age=%.1fh)", f.name, age_s / 3600)
|
||||
except Exception:
|
||||
logger.warning("清理文件失败: %s", f.name, exc_info=True)
|
||||
|
||||
if deleted:
|
||||
logger.info("清理完成: 删除 %d 个过期文件", deleted)
|
||||
|
||||
logger.info("清理任务已停止")
|
||||
|
||||
|
||||
@app.get("/gateway-health", include_in_schema=False)
|
||||
async def gateway_health():
|
||||
"""网关自身健康检查(区别于 worker 的 /health)。"""
|
||||
@@ -539,10 +474,15 @@ async def hair_grow(request: Request):
|
||||
return await _proxy(request, "/api/v1/hair/grow")
|
||||
|
||||
|
||||
@app.post("/api/v1/hair/grow-b", tags=["生发"])
|
||||
@app.post("/api/v1/hair/grow-b", tags=["生发"], deprecated=True)
|
||||
async def hair_grow_b(request: Request):
|
||||
"""接口3:B端生发"""
|
||||
return await _proxy(request, "/api/v1/hair/grow-b")
|
||||
"""接口3:B端生发(已屏蔽)"""
|
||||
# 在网关层直接拦截,不转发到 worker 池——对所有上游 worker 立即生效。
|
||||
import uuid as _uuid
|
||||
return JSONResponse(status_code=200, content={
|
||||
"code": 1007, "message": "接口3(/api/v1/hair/grow-b)已屏蔽,暂不提供服务",
|
||||
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
|
||||
})
|
||||
|
||||
|
||||
@app.post("/api/v1/face/features", tags=["人脸分析"])
|
||||
|
||||
@@ -22,9 +22,5 @@
|
||||
"request_timeout_seconds": 600,
|
||||
"retry_on_failure": true,
|
||||
"max_retries": 1
|
||||
},
|
||||
"cleanup": {
|
||||
"interval_minutes": 60,
|
||||
"max_age_hours": 24
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,10 +31,6 @@ DEFAULTS = {
|
||||
"retry_on_failure": True,
|
||||
"max_retries": 1,
|
||||
},
|
||||
"cleanup": {
|
||||
"interval_minutes": 60,
|
||||
"max_age_hours": 24,
|
||||
},
|
||||
"request_log": {
|
||||
"enabled": True,
|
||||
"log_file": "gateway/request_log.jsonl",
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
设计原则(与用户约定):
|
||||
- 入参/出参日志里**绝不内嵌图片 base64**。图片统一存盘后用 URL 引用,保持日志短小。
|
||||
- 入参图片(image_file / *_base64)当前网关不存盘——这里补存到 static_dir(in_ 前缀),
|
||||
复用现有 24h 清理循环自动回收;url 输入图本身就在远端,不重新下载,直接记 URL。
|
||||
- 入参图片(image_file / *_base64)补存到 static_dir(in_ 前缀),永久保留;
|
||||
url 输入图本身就在远端,不重新下载,直接记 URL。
|
||||
- 出参响应在 forward.py 已把 base64 改写成 *_url(无大图),记录完整 data,
|
||||
但用递归摘要器截断超长结构(landmarks、长字符串),单条上限 ~8KB。
|
||||
|
||||
@@ -39,7 +39,7 @@ def save_image_bytes(
|
||||
"""把图片字节存盘并返回公网 URL。失败返回 None(不抛异常)。
|
||||
|
||||
存到 static_dir/{prefix}{uuid}.{ext},URL = {public_base_url}/static/annotations/{file}。
|
||||
与 forward.py 的 rewrite_base64_to_url 落盘路径/URL 规则一致,可被同一清理循环回收。
|
||||
与 forward.py 的 rewrite_base64_to_url 落盘路径/URL 规则一致;文件永久保留,不自动清理。
|
||||
"""
|
||||
if not data:
|
||||
return None
|
||||
|
||||
@@ -410,7 +410,7 @@
|
||||
},
|
||||
"60": {
|
||||
"inputs": {
|
||||
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
||||
"text": "填充遮罩区域的头发"
|
||||
},
|
||||
"class_type": "JjkText",
|
||||
"_meta": {
|
||||
|
||||
@@ -16,7 +16,7 @@ from . import comfyui
|
||||
|
||||
logger = logging.getLogger("hair.worker")
|
||||
|
||||
_DEFAULT_PROMPT = "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
||||
_DEFAULT_PROMPT = "填充遮罩区域的头发"
|
||||
_REPO = os.path.dirname(os.path.dirname(__file__))
|
||||
_REPAINT_WORKFLOW = os.path.join(_REPO, "0716add-hair-api.json")
|
||||
|
||||
@@ -62,7 +62,7 @@ def run_redraw(image_bytes: bytes, mask_bytes: bytes,
|
||||
Args:
|
||||
image_bytes: 人物图片字节(JPG/PNG)
|
||||
mask_bytes: 遮罩图片字节(支持红/白/alpha 遮罩格式)
|
||||
prompt: 提示词,None 用默认 "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
||||
prompt: 提示词,None 用默认 "填充遮罩区域的头发"
|
||||
timeout: ComfyUI 超时秒数
|
||||
front: True 时任务插到 ComfyUI 队列最前(接口2 时延敏感路径用)
|
||||
|
||||
|
||||
@@ -27,7 +27,8 @@ import logging
|
||||
logger = logging.getLogger("hair.worker")
|
||||
|
||||
# 接口2 女性发型 key → change_hair hair_id(chang_*)映射:换发型+Flux-2 整帧重绘用。
|
||||
# 与接口12 final 的 5 型一一对应。
|
||||
# 与接口12 final 的 5 型一一对应。female 6/7(bigflower/clasicalflower)无对应 LoRA,
|
||||
# 走与男性一致的原生生发(ComfyUI add_hair)管线,故不在本表。
|
||||
_FEMALE_KEY_TO_CHANG = {
|
||||
"ellipse": "chang_tuoyuan", # 椭圆
|
||||
"flower": "chang_huaban", # 花瓣
|
||||
@@ -36,6 +37,16 @@ _FEMALE_KEY_TO_CHANG = {
|
||||
"wave": "chang_bolang", # 波浪
|
||||
}
|
||||
|
||||
# 发际线贴图显式顺序表:决定 hair_style 序号(1-indexed)。
|
||||
# 不再依赖文件名字母序——字母序会因新增/重命名文件而错位,破坏现有前端/客户端取值。
|
||||
# key 须与 _gender_key 派生结果一致(已去空格):如 "inverse_arc"(源 man_ inverse_arc.png)、
|
||||
# "Softpetal"(源 man_Soft petal.png,大写 S 保留)。表外未知 key 兜底排到末尾。
|
||||
_HAIRSTYLE_ORDER = {
|
||||
"female": ["ellipse", "flower", "heart", "straight", "wave",
|
||||
"bigflower", "clasicalflower"], # 1..7
|
||||
"male": ["ellipse", "inverse_arc", "m", "straight", "heart", "Softpetal"], # 1..6
|
||||
}
|
||||
|
||||
_REPO = os.path.dirname(os.path.dirname(__file__))
|
||||
_TEXTURE_DIR = os.path.join(_REPO, "hairline_texture")
|
||||
_BLACK_TEXTURE_DIR = os.path.join(_REPO, "hairline_texture_black")
|
||||
@@ -89,10 +100,13 @@ def _gender_key(stem: str):
|
||||
|
||||
|
||||
def get_texture_map(level: str = "middle") -> dict:
|
||||
"""扫描指定档位贴图目录建 {gender: [(key, path)]},按 key 排序、按档位缓存。
|
||||
"""扫描指定档位贴图目录建 {gender: [(key, path)]},按显式顺序表排序、按档位缓存。
|
||||
|
||||
level:middle(默认) / high / low,分别对应 hairline_texture[/_high|/_low]。
|
||||
文件名规范化去空格(如 `man_ inverse_arc.png` → key `inverse_arc`)。
|
||||
|
||||
排序依据 _HAIRSTYLE_ORDER:表内 key 按表序、表外未知 key 兜底排到末尾(再按字母序),
|
||||
保证新增/重命名文件不会打乱现有 hair_style 序号。
|
||||
"""
|
||||
if level not in _TEXTURE_DIRS:
|
||||
raise ValueError(f"hairline_level 必须是 middle/high/low,收到 {level!r}")
|
||||
@@ -106,7 +120,9 @@ def get_texture_map(level: str = "middle") -> dict:
|
||||
if gender:
|
||||
mapping[gender].append((key, path))
|
||||
for g in mapping:
|
||||
mapping[g].sort(key=lambda kp: kp[0])
|
||||
order = _HAIRSTYLE_ORDER.get(g, [])
|
||||
idx = {k: i for i, k in enumerate(order)}
|
||||
mapping[g].sort(key=lambda kp: (idx.get(kp[0], len(idx)), kp[0]))
|
||||
_texture_maps[level] = mapping
|
||||
return mapping
|
||||
|
||||
@@ -163,7 +179,7 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
|
||||
workflow_path: str | None = None):
|
||||
"""指定发际线类型:发际线透明叠图(白线 RGBA) + 生发图(ComfyUI)。
|
||||
|
||||
hair_styles(1-indexed 列表):指定生成哪几张发际线(按贴图排序)。female: 1..5,male: 1..4。
|
||||
hair_styles(1-indexed 列表):指定生成哪几张发际线(按贴图排序)。female: 1..7,male: 1..6。
|
||||
为 None 时生成全部(兼容旧调用)。
|
||||
use_mask(默认 True):是否启用 inpaint 遮罩,用于测试对比(同接口3)。
|
||||
False 时用**干净原图 + 空遮罩**送 ComfyUI(不烧黑色模板线)。
|
||||
@@ -227,15 +243,22 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
|
||||
|
||||
|
||||
def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | None,
|
||||
redraw_defaults: dict):
|
||||
"""接口2 女性专用:发际线透明叠图(同 generate_grow_results)+ 换发型重绘图。
|
||||
redraw_defaults: dict,
|
||||
redraw_max_side: int | None = None,
|
||||
unet_name: str | None = None,
|
||||
prompt: str | None = None):
|
||||
"""接口2 女性专用:发际线透明叠图(同 generate_grow_results)+ 生发图。
|
||||
|
||||
grown 图来源(新流程):对每个选中发型把 female key 映射到 change_hair 的 chang_* hair_id,
|
||||
调 face_analysis.hairline_grow.generate_hairline_redraw(= 接口12 final 管线,参数用
|
||||
redraw_defaults)拿到 ④ final(接缝融合基底)+ ⑤-② 纯红遮罩 PNG,再**后端直接调
|
||||
ComfyUI**(0716add-hair-api.json 工作流)完成发际线带重绘,重绘结果作为生发图。
|
||||
生发图来源按发型分两路:
|
||||
- 换发型(chang_* 表内,1..5):female key → change_hair 的 chang_* hair_id,调
|
||||
face_analysis.hairline_grow.generate_hairline_redraw(= 接口12 final 管线,参数用
|
||||
redraw_defaults)拿到 ④ final(接缝融合基底)+ ⑤-② 纯红遮罩 PNG,再**后端直接调
|
||||
ComfyUI**(0716add-hair-api.json 工作流)完成发际线带重绘。
|
||||
- 原生生发(表外,6/7 bigflower/clasicalflower 等):无对应 change_hair LoRA,改走与男性
|
||||
一致的原生生发(ComfyUI add_hair),由 _grow_native_one 完成(黑模板 + inpaint 遮罩)。
|
||||
|
||||
overlay 仍是发际线曲线透明层(与 generate_grow_results 完全一致)。
|
||||
prompt 仅用于原生生发分支(换发型分支的提示词由 redraw 流程内部固定)。
|
||||
Returns: list[dict] {"hairline_type","order","overlay","grown_png"(jpg bytes 或 None)};
|
||||
无人脸返回 None。单个发型换发型/重绘失败时 grown_png=None,不抛异常。
|
||||
"""
|
||||
@@ -265,7 +288,10 @@ def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | N
|
||||
grown_png = None
|
||||
chang_id = _FEMALE_KEY_TO_CHANG.get(key)
|
||||
if chang_id is None:
|
||||
logger.warning("接口2 换发型:female key=%s 无对应 chang_id,跳过生发图", key)
|
||||
# 无对应 change_hair LoRA(如 bigflower/clasicalflower)→ 走与男性一致的原生生发
|
||||
logger.info("接口2 女 key=%s 无 chang_id,走原生生发(ComfyUI add_hair)", key)
|
||||
grown_png = _grow_native_one(image_bgr, ctx, white_path,
|
||||
prompt=prompt, unet_name=unet_name)
|
||||
else:
|
||||
try:
|
||||
data = generate_hairline_redraw(image_bgr, chang_id, hair_mask=hair_mask_reuse, **redraw_defaults)
|
||||
@@ -321,6 +347,32 @@ def _grow_from_texture(image_bgr: np.ndarray, ctx: dict, white_path: str | None,
|
||||
return None
|
||||
|
||||
|
||||
def _grow_native_one(image_bgr: np.ndarray, ctx: dict, white_path: str,
|
||||
prompt: str | None = None, unet_name: str | None = None):
|
||||
"""对单个发际线做原生生发(ComfyUI add_hair),与男性 generate_grow_results 一致。
|
||||
|
||||
接口2 女性新发型(bigflower/clasicalflower 等)无对应 change_hair LoRA,改走此路径:
|
||||
黑模板 → build_inpaint_mask → 限边(_prep_comfy_input)→ comfyui.run(front=True)。
|
||||
失败返回 None,不抛异常。结果按限边前原图尺寸放大回原尺寸(仅展示对齐)。
|
||||
"""
|
||||
try:
|
||||
h, w = image_bgr.shape[:2]
|
||||
black = load_texture_rgba(_black_texture_path(white_path))
|
||||
marked, mask = build_inpaint_mask(
|
||||
image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black)
|
||||
m_s, msk_s, gsc = _prep_comfy_input(marked, mask)
|
||||
buf = io.BytesIO()
|
||||
compose_comfy_rgba(m_s, msk_s).save(buf, format="PNG", compress_level=1)
|
||||
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前
|
||||
grown_png = comfyui.run(buf.getvalue(), prompt=prompt, front=True, unet_name=unet_name)
|
||||
if gsc < 1.0 and grown_png:
|
||||
grown_png = _upscale_png_to(grown_png, w, h)
|
||||
return grown_png
|
||||
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
|
||||
logger.warning("接口2 女原生生发图失败:%s", e)
|
||||
return None
|
||||
|
||||
|
||||
def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
|
||||
hair_styles: list[int], use_mask: bool = True,
|
||||
prompt: str | None = None,
|
||||
|
||||
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.8 KiB |
@@ -206,7 +206,7 @@ def generate():
|
||||
return jsonify({"error": msg}), 400
|
||||
image_file = request.files["image"]
|
||||
mask_file = request.files["mask"]
|
||||
prompt_text = request.form.get("prompt", "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
|
||||
prompt_text = request.form.get("prompt", "填充遮罩区域的头发")
|
||||
log.info(
|
||||
"收到请求: image=%s mask=%s prompt=%r",
|
||||
image_file.filename, mask_file.filename, prompt_text,
|
||||
|
||||
@@ -27,7 +27,7 @@ def prep_and_upload():
|
||||
|
||||
|
||||
def run_once(fname, model, dtype, steps):
|
||||
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
|
||||
wf = A.build_workflow(fname, "填充遮罩区域的头发")
|
||||
wf["16"]["inputs"]["unet_name"] = model
|
||||
wf["16"]["inputs"]["weight_dtype"] = dtype
|
||||
wf["1"]["inputs"]["steps"] = steps
|
||||
|
||||
@@ -33,7 +33,7 @@ def upload(scale=1.0):
|
||||
|
||||
|
||||
def run(fname, steps):
|
||||
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
|
||||
wf = A.build_workflow(fname, "填充遮罩区域的头发")
|
||||
wf["16"]["inputs"]["unet_name"] = MODEL
|
||||
wf["16"]["inputs"]["weight_dtype"] = DTYPE
|
||||
wf["1"]["inputs"]["steps"] = steps
|
||||
|
||||
@@ -30,7 +30,7 @@ SEED = 123456789
|
||||
imgs = []
|
||||
labels = []
|
||||
for steps in [2, 3, 4, 6]:
|
||||
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", seed=SEED)
|
||||
wf = A.build_workflow(fname, "填充遮罩区域的头发", seed=SEED)
|
||||
wf["16"]["inputs"]["unet_name"] = MODEL
|
||||
wf["16"]["inputs"]["weight_dtype"] = DTYPE
|
||||
wf["1"]["inputs"]["steps"] = steps
|
||||
|
||||
@@ -55,7 +55,7 @@ button { padding: 10px 24px; border: none; border-radius: 6px; cursor: pointer;
|
||||
</div>
|
||||
<div class="controls" style="margin-top:16px">
|
||||
<label>提示词:</label>
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜">
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发">
|
||||
</div>
|
||||
<div style="text-align:center; margin-top:16px">
|
||||
<button class="btn-generate" id="generateBtn" disabled>🚀 生成</button>
|
||||
|
||||
@@ -25,7 +25,7 @@ resp = requests.post(
|
||||
"image": ("original.jpg", img_data, "image/jpeg"),
|
||||
"mask": ("mask.png", mask_data, "image/png"),
|
||||
},
|
||||
data={"prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"},
|
||||
data={"prompt": "填充遮罩区域的头发"},
|
||||
timeout=600,
|
||||
)
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
</div>
|
||||
<div class="upload-row" style="margin-top:10px">
|
||||
<label style="font-size:13px;font-weight:600;white-space:nowrap">重绘提示词</label>
|
||||
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||
</div>
|
||||
<div class="upload-row" style="margin-top:10px">
|
||||
<label style="font-size:13px;font-weight:600;white-space:nowrap">X-Internal-Token</label>
|
||||
@@ -354,7 +354,7 @@ function dataUriToBlob(dataUri) {
|
||||
|
||||
async function runLocalRedraw() {
|
||||
if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; }
|
||||
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜';
|
||||
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发';
|
||||
const btn = $('redrawBtn');
|
||||
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
|
||||
flog('===== 调后端重绘 =====', 'info');
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
</div>
|
||||
<div class="upload-row" style="margin-top:10px">
|
||||
<label style="font-size:13px;font-weight:600;white-space:nowrap">重绘提示词</label>
|
||||
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||
</div>
|
||||
<div class="params">
|
||||
<div class="pf">
|
||||
@@ -169,7 +169,7 @@ async function submitTest() {
|
||||
|
||||
async function runLocalRedraw() {
|
||||
if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; }
|
||||
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜';
|
||||
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发';
|
||||
const btn = $('redrawBtn');
|
||||
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
<div class="hint">JPG/PNG | 生发图生成较慢(数十秒~数分钟),请耐心等待</div>
|
||||
<div style="margin-top:10px;display:flex;align-items:center;gap:8px">
|
||||
<label style="font-size:13px;font-weight:600;color:#374151;white-space:nowrap">💬 提示词</label>
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
|
||||
</div>
|
||||
<div id="statusBar" class="status hidden"></div>
|
||||
</div>
|
||||
@@ -237,12 +237,16 @@ const HAIR_STYLES = {
|
||||
{ value:'3', label:'heart 心形' },
|
||||
{ value:'4', label:'straight 直线形' },
|
||||
{ value:'5', label:'wave 波浪形' },
|
||||
{ value:'6', label:'bigflower 大花瓣型' },
|
||||
{ value:'7', label:'clasicalflower 古典花瓣型' },
|
||||
],
|
||||
male: [
|
||||
{ value:'1', label:'ellipse 椭圆形' },
|
||||
{ value:'2', label:'inverse_arc 倒弧形' },
|
||||
{ value:'3', label:'m M形' },
|
||||
{ value:'4', label:'straight 直线形' },
|
||||
{ value:'5', label:'heart 桃心形' },
|
||||
{ value:'6', label:'Softpetal 柔和花瓣形' },
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
</div>
|
||||
<div class="upload-group" style="margin-top:14px">
|
||||
<div class="label">💬 提示词(prompt)</div>
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="width:100%;padding:8px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:14px;max-width:500px">
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发" style="width:100%;padding:8px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:14px;max-width:500px">
|
||||
</div>
|
||||
<div style="margin-top:14px;display:flex;gap:12px;align-items:center">
|
||||
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交</button>
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
<div class="file-input"><input type="file" id="imageFile" accept="image/jpeg,image/png,.jpg,.jpeg,.png"></div>
|
||||
<div class="form-group">
|
||||
<label>性别</label>
|
||||
<select id="gender" onchange="onGenderChange()"><option value="female" selected>👩 Female(5种)</option><option value="male">👨 Male(4种)</option></select>
|
||||
<select id="gender" onchange="onGenderChange()"><option value="female" selected>👩 Female(7种)</option><option value="male">👨 Male(6种)</option></select>
|
||||
</div>
|
||||
<div class="form-group" style="align-items:flex-start">
|
||||
<label style="padding-top:3px">发型 *</label>
|
||||
@@ -174,12 +174,16 @@ const HAIR_STYLES = {
|
||||
{ value:'3', label:'3. heart 心形' },
|
||||
{ value:'4', label:'4. straight 直线' },
|
||||
{ value:'5', label:'5. wave 波浪' },
|
||||
{ value:'6', label:'6. bigflower 大花瓣型' },
|
||||
{ value:'7', label:'7. clasicalflower 古典花瓣型' },
|
||||
],
|
||||
male: [
|
||||
{ value:'1', label:'1. ellipse 椭圆' },
|
||||
{ value:'2', label:'2. inverse_arc 倒弧' },
|
||||
{ value:'3', label:'3. m M形' },
|
||||
{ value:'4', label:'4. straight 直线' },
|
||||
{ value:'5', label:'5. heart 桃心形' },
|
||||
{ value:'6', label:'6. Softpetal 柔和花瓣形' },
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
<div class="hint">JPG/PNG | 生发图生成较慢(数十秒~数分钟),请耐心等待 | 工作流: add_hair2.json</div>
|
||||
<div style="margin-top:10px;display:flex;align-items:center;gap:8px">
|
||||
<label style="font-size:13px;font-weight:600;color:#374151;white-space:nowrap">💬 提示词</label>
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
|
||||
</div>
|
||||
<div id="statusBar" class="status hidden"></div>
|
||||
</div>
|
||||
|
||||