接口11:发际线生发(接口9遮罩 + change_hair换发型/区域生发 + 按遮罩羽化贴回)+ 分步可视化测试页

- 端点 POST /api/v1/hairline/grow(app.py,纯新增,不影响接口1-5)
- 编排模块 face_analysis/hairline_grow.py:复用接口9 遮罩 → HTTP 调 change_hair(8801) → 按遮罩贴回
- 双生成后端 gen_backend:swaphair(换发型LoRA) / hairgrow(区域生发inpaint)
- swap_mode:ext_mask(接口9遮罩作换发型遮罩) / as_is;融合 feather/alpha_gradient/seamless
- 参数全在测试页可调;可视化按算法文档4步:最终遮罩→生成全帧→严格贴回→接缝融合
- 附启停脚本 scripts/restart_if11_backends.sh、算法文档、测试图

注:change_hair 侧 swapHair 的 ext_mask/denoising_strength 改造在 change_hair 仓库,向后兼容。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
xsl
2026-07-09 22:06:17 +08:00
co-authored by Claude Opus 4.8
parent 3eb60bddc5
commit 0ddfa83743
6 changed files with 883 additions and 0 deletions
+73
View File
@@ -1232,6 +1232,79 @@ async def head_band(
return err(1007, f"处理失败:{ex}")
# ---------------------------------------------------------------------------
# 接口 11:发际线生发(接口9 遮罩 + change_hair 换发型 + 按遮罩羽化贴回)
# ---------------------------------------------------------------------------
@app.post(
"/api/v1/hairline/grow",
summary="接口11 发际线生发",
tags=["生发"],
description=f"""
输入一张**发际线较高 / 头发稀少**的正脸图 + **发际线类型 ID**= change_hair 的 `hair_id`
如 `chang_tuoyuan`/`chang_bolang`/`chang_zhixian`/`chang_xinxing`/`chang_huaban`),
输出同一个人、同一发型、按该发际线类型压低发际线后的图片,并返回**每一步可视化**。
管线(见 `docs/发际线增强算法.md`):
1. 用接口9 算法算头发遮罩(含额头闭合区域,外缘内缩 `erode_cm`)。`seg_model` 选
BiSeNet/SegFormer`mask_type` 选 eroded(内缩)/closed(闭合区域)。
2. 调 change_hair 换发型服务生成该发际线类型图(返回图已与原图同分辨率同对齐)。
`swap_mode=ext_mask`:把接口9 遮罩作为 `ext_mask` 传给换发型,webui 精确重绘该区域
(忠于算法文档);`swap_mode=as_is`:不改换发型,贴回时再裁到接口9 遮罩。
3. 严格按接口9 遮罩把生成图贴回原图(遮罩外=原图不动)。
4. 接缝融合:`blend_method` 选 feather(高斯羽化)/alpha_gradient(距离变换内渐变)/
seamless(泊松无缝克隆)`feather_px`、`edge_erode_px` 控制过渡细节。
{_image_fields_desc}
返回 `data.steps` 含 `input`/`baseline`/`hair_mask`/`mask_overlay`/`mask`/`swap_raw`/
`alpha`/`final` 各步图(`*_base64`,经网关改写为 `*_url`)。
""",
)
async def hairline_grow(
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG"),
image_url: Optional[str] = Form(default=None, description="图片 URL"),
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
hairline_id: str = Form(..., description="发际线类型 ID= change_hair hair_id,如 chang_tuoyuan"),
gen_backend: str = Form(default="swaphair", description="生成后端:swaphair(换发型LoRA) | hairgrow(区域生发inpaint,压低发际线)(默认 swaphair)"),
hairgrow_strength: float = Form(default=0.75, description="区域生发强度(仅 hairgrow 后端),默认 0.75"),
is_hr: bool = Form(default=False, description="高清模式(换发型输出 1152×1536,否则 576×768"),
seg_model: str = Form(default="segformer", description="头发分割模型:bisenet | segformer(默认 segformer"),
mask_type: str = Form(default="eroded", description="遮罩类型:eroded(内缩) | closed(闭合区域)(默认 eroded"),
erode_cm: float = Form(default=1.2, description="遮罩外缘朝中心151内缩距离(厘米,同接口9),默认 1.2"),
swap_mode: str = Form(default="ext_mask", description="换发型取图模式:ext_mask(改造换发型用接口9遮罩) | as_is(不改换发型,贴回再裁)(默认 ext_mask)"),
blend_method: str = Form(default="feather", description="接缝融合:feather(高斯羽化) | alpha_gradient(距离渐变) | seamless(泊松无缝)(默认 feather"),
feather_px: int = Form(default=15, description="羽化/渐变过渡宽度(像素),默认 15"),
edge_erode_px: int = Form(default=3, description="贴图前遮罩内缩像素(防边缘露皮/光晕),默认 3"),
denoising_strength: float = Form(default=0.6, description="换发型 webui 重绘强度(越大生发越激进),默认 0.6"),
):
"""接口11:发际线生发 + 分步可视化"""
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:
from fastapi.concurrency import run_in_threadpool
from face_analysis.hairline_grow import generate_hairline_grow, NoFaceError, SwapError
try:
data = await run_in_threadpool(
generate_hairline_grow, image, hairline_id, is_hr, seg_model,
mask_type, erode_cm, swap_mode, blend_method, feather_px, edge_erode_px,
denoising_strength, gen_backend, hairgrow_strength)
except NoFaceError:
return err(1001, "无法识别人像")
except SwapError as se:
return err(1007, f"换发型失败:{se}")
return ok(data)
except Exception as ex: # noqa: BLE001
logger.exception("接口11 处理异常")
return err(1007, f"处理失败:{ex}")
# ---------------------------------------------------------------------------
# 健康检查
# ---------------------------------------------------------------------------
+7
View File
@@ -0,0 +1,7 @@
第一步 使用接口9 头发遮罩生成 的算法获取 mask
第二部 改造 /home/xsl/change_hair 换发型的工作流, 换发型的参考文档在这里 /home/xsl/change_hair/docs/换发型集成文档.md
1、原始换发型工作的遮罩用第一步算出来的遮罩
2、然后换发型得到遮罩区域发际线的图片。
3、严格按照遮罩区域把图片贴回到原图上面。
4、贴图的时候融合贴图边缘和原图的接缝,可以采用羽化算法或者渐变alpha混合的算法,目的就是边缘要和原图过渡自然。 这里通过传入各种参数可以控制选哪种算法和控制过渡细节。
最后一步返回生成特定样式的图片。
+337
View File
@@ -0,0 +1,337 @@
"""接口11:发际线生发。
输入一张发际线较高 / 头发稀少的正脸图 + 发际线类型 ID= change_hair 的 hair_id
如 chang_tuoyuan/chang_bolang/...),输出同一个人、同一发型、按该发际线类型压低发际线
后的图片。管线(见 docs/发际线增强算法.md):
1. 用接口9 的算法算出头发遮罩(含额头闭合区域,外缘内缩 erode_cm)。
—— seg_model 选 bisenet/segformermask_type 选 eroded(内缩)/closed(未内缩闭合区域)。
2. 调 change_hair 换发型服务(/api/swapHair/v1)生成该发际线类型的图。返回的结果图已被
change_hair 用 M_inv 贴回、与输入原图**同分辨率同对齐**,可直接按遮罩合成。
两种取图模式(swap_mode):
- ext_mask:把步骤1 的遮罩作为 ext_mask 传给 swapHair,让 webui 精确重绘该区域
(忠于算法文档「换发型的遮罩用接口9 遮罩」)。
- as_is:不改 change_hairswapHair 用它自己的内部遮罩,贴回时再裁到接口9 遮罩。
3. 严格按接口9 遮罩把生成图贴回原图(遮罩外=原图,纹丝不动)。
4. 融合接缝:blend_method 选 feather(高斯羽化) / alpha_gradient(距离变换内渐变) /
seamless(泊松无缝克隆)feather_px、edge_erode_px 控制过渡细节。
对外返回每一步可视化(base64,data URI),供测试页逐步展示。经网关时 *_base64 字段会被
落盘改写为 *_url。
"""
import base64
import os
import time
from uuid import uuid4
import cv2
import numpy as np
from face_analysis.detector import detector
from face_analysis.calibration import estimate_scale_factor
from face_analysis.head_mask import (
NoFaceError,
_baseline_points,
_upper_region_mask,
_bisenet_hair_mask,
_segformer_hair_mask,
_fill_to_baseline,
_erode,
_largest_cc,
_overlay,
)
# change_hair 服务地址(可用环境变量覆盖)
SWAP_URL = os.getenv("SWAP_HAIR_URL", "http://127.0.0.1:8801/api/swapHair/v1")
HAIRGROW_URL = os.getenv("HAIR_GROW_URL", "http://127.0.0.1:8801/api/hairGrow/v1")
SWAP_TIMEOUT = float(os.getenv("SWAP_HAIR_TIMEOUT", "300"))
DEFAULTS = {
"gen_backend": "swaphair", # swaphair(换发型LoRA) | hairgrow(区域生发inpaint)
"is_hr": False,
"seg_model": "segformer", # bisenet | segformer
"mask_type": "eroded", # eroded | closed
"erode_cm": 1.2,
"swap_mode": "ext_mask", # ext_mask | as_is(仅 swaphair
"denoising_strength": 0.6, # 仅 swaphair
"hairgrow_strength": 0.75, # 仅 hairgrow
"blend_method": "feather", # feather | alpha_gradient | seamless
"feather_px": 15,
"edge_erode_px": 3,
}
class SwapError(Exception):
"""调用 change_hair 换发型服务失败。"""
# ---------------------------------------------------------------------------
# 编码
# ---------------------------------------------------------------------------
def _jpg_b64(bgr):
ok, buf = cv2.imencode(".jpg", bgr, [cv2.IMWRITE_JPEG_QUALITY, 92])
return "data:image/jpeg;base64," + base64.b64encode(buf.tobytes()).decode()
def _png_b64(bgr_or_gray):
ok, buf = cv2.imencode(".png", bgr_or_gray)
return "data:image/png;base64," + base64.b64encode(buf.tobytes()).decode()
def _gray_b64(gray_float):
"""0~1 的浮点图 → 灰度 PNG data URI。"""
g = np.clip(gray_float * 255.0, 0, 255).astype(np.uint8)
return _png_b64(g)
# ---------------------------------------------------------------------------
# 步骤1:接口9 头发遮罩(复用 head_mask 构件)
# ---------------------------------------------------------------------------
def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm):
"""算出布尔遮罩 + 可视化。
seg_model: bisenet | segformermask_type: eroded(内缩) | closed(闭合区域未内缩)。
返回 (mask_bool, viz_dict)。
"""
h, w = image_bgr.shape[:2]
r = int(round(max(0.0, erode_cm) * px_per_cm))
baseline_pts = _baseline_points(landmarks, w, h)
upper = _upper_region_mask(baseline_pts, w, h)
if seg_model == "bisenet":
hair_mask = _bisenet_hair_mask(image_bgr, landmarks, w, h)
elif seg_model == "segformer":
hair_mask = _segformer_hair_mask(image_bgr)
else:
raise ValueError(f"未知 seg_model: {seg_model}")
top_fill = _fill_to_baseline(hair_mask, upper) # 含额头,延伸到图底
closed = _largest_cc(top_fill & upper) # 闭合区域:头发+额头,底=基线
eroded = _largest_cc(_erode(top_fill, r) & upper) # 外缘内缩 r、底线不动
mask_bool = eroded if mask_type == "eroded" else closed
# 只输出最终遮罩(叠加图 + 纯遮罩),不展开接口9 内部子步骤
viz = {
"erode_px": r,
"hair_pixels": int(hair_mask.sum()),
"mask_pixels": int(mask_bool.sum()),
"mask_overlay_base64": _jpg_b64(_overlay(image_bgr, mask_bool, (0, 0, 255))),
"mask_base64": _png_b64((mask_bool.astype(np.uint8)) * 255),
}
return mask_bool, viz
# ---------------------------------------------------------------------------
# 步骤2:调 change_hair 换发型
# ---------------------------------------------------------------------------
def _call_swap(image_bgr, hairline_id, is_hr, ext_mask_bool, denoising_strength):
"""调 change_hair /api/swapHair/v1,返回与输入同分辨率同对齐的换发型结果(BGR)。
ext_mask_bool 非 None 时作为 ext_mask 传入(swap_mode=ext_mask)。
denoising_strengthwebui img2img 重绘强度(越大生发越激进),透传给换发型。
"""
import requests
ok, ibuf = cv2.imencode(".jpg", image_bgr, [cv2.IMWRITE_JPEG_QUALITY, 95])
payload = {
"hair_id": hairline_id,
"task_id": "if11-" + uuid4().hex[:12],
"is_hr": "true" if is_hr else "false",
"user_img_path": "data:image/jpeg;base64," + base64.b64encode(ibuf.tobytes()).decode(),
"output_format": "base64",
"denoising_strength": float(denoising_strength),
}
if ext_mask_bool is not None:
mbuf = cv2.imencode(".png", (ext_mask_bool.astype(np.uint8)) * 255)[1]
payload["ext_mask"] = "data:image/png;base64," + base64.b64encode(mbuf.tobytes()).decode()
try:
resp = requests.post(SWAP_URL, json=payload, timeout=SWAP_TIMEOUT)
except Exception as ex: # noqa: BLE001
raise SwapError(f"换发型服务不可达({SWAP_URL}):{ex}")
try:
j = resp.json()
except Exception: # noqa: BLE001
raise SwapError(f"换发型服务返回非 JSONHTTP {resp.status_code}):{resp.text[:200]}")
if j.get("state") != 0 or not j.get("data"):
raise SwapError(f"换发型失败:{j.get('msg', j)}")
b64 = j["data"]
if "," in b64 and b64.startswith("data:"):
b64 = b64.split(",", 1)[1]
result = cv2.imdecode(np.frombuffer(base64.b64decode(b64), np.uint8), cv2.IMREAD_COLOR)
if result is None:
raise SwapError("换发型结果解码失败")
# 保险:与原图对齐(change_hair 已贴回原尺寸,若极端情况尺寸不符则拉回)
if result.shape[:2] != image_bgr.shape[:2]:
result = cv2.resize(result, (image_bgr.shape[1], image_bgr.shape[0]),
interpolation=cv2.INTER_LANCZOS4)
return result
def _call_hairgrow(image_bgr, mask_bool, strength):
"""调 change_hair /api/hairGrow/v1(区域生发 inpaint),在遮罩区域长出头发。
返回与输入同分辨率的结果(BGR)。hairGrow 内部已做贴回与颜色迁移,
这里再套接口11 的遮罩羽化贴回以保证遮罩外严格不动。
"""
import requests
ok, ibuf = cv2.imencode(".jpg", image_bgr, [cv2.IMWRITE_JPEG_QUALITY, 95])
mbuf = cv2.imencode(".png", (mask_bool.astype(np.uint8)) * 255)[1]
payload = {
"img": "data:image/jpeg;base64," + base64.b64encode(ibuf.tobytes()).decode(),
"mask": "data:image/png;base64," + base64.b64encode(mbuf.tobytes()).decode(),
"strength": float(strength),
"output_format": "base64",
}
try:
resp = requests.post(HAIRGROW_URL, json=payload, timeout=SWAP_TIMEOUT)
except Exception as ex: # noqa: BLE001
raise SwapError(f"区域生发服务不可达({HAIRGROW_URL}):{ex}")
try:
j = resp.json()
except Exception: # noqa: BLE001
raise SwapError(f"区域生发返回非 JSONHTTP {resp.status_code}):{resp.text[:200]}")
if j.get("state") != 0 or not j.get("result"):
raise SwapError(f"区域生发失败:{j.get('msg', j)}")
b64 = j["result"]
if "," in b64 and b64.startswith("data:"):
b64 = b64.split(",", 1)[1]
result = cv2.imdecode(np.frombuffer(base64.b64decode(b64), np.uint8), cv2.IMREAD_COLOR)
if result is None:
raise SwapError("区域生发结果解码失败")
if result.shape[:2] != image_bgr.shape[:2]:
result = cv2.resize(result, (image_bgr.shape[1], image_bgr.shape[0]),
interpolation=cv2.INTER_LANCZOS4)
return result
# ---------------------------------------------------------------------------
# 步骤3+4:按遮罩贴回 + 接缝融合
# ---------------------------------------------------------------------------
def _feather_alpha(mask_bool, blend_method, feather_px, edge_erode_px):
"""由布尔遮罩生成 0~1 的 alpha(贴图权重)。遮罩外恒为 0(原图纹丝不动)。"""
m = mask_bool.astype(np.uint8)
if edge_erode_px > 0:
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * edge_erode_px + 1,) * 2)
m = cv2.erode(m, k)
fp = max(1, int(feather_px))
if blend_method == "alpha_gradient":
# 距离变换:过渡只发生在遮罩内侧(边界 0 → 内部 feather_px 处 1),遮罩外严格为 0
dist = cv2.distanceTransform(m, cv2.DIST_L2, 3)
alpha = np.clip(dist / fp, 0.0, 1.0)
else: # feather(高斯羽化,默认)
ksz = fp * 2 + 1
alpha = cv2.GaussianBlur(m.astype(np.float32), (ksz, ksz), sigmaX=fp / 2.0)
alpha = np.clip(alpha, 0.0, 1.0)
return alpha
def _composite(orig, swap_result, mask_bool, blend_method, feather_px, edge_erode_px):
"""把 swap_result 按遮罩贴回 orig,返回 (final_bgr, alpha_float or None)。"""
if blend_method == "seamless":
m = mask_bool.astype(np.uint8)
if edge_erode_px > 0:
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * edge_erode_px + 1,) * 2)
m = cv2.erode(m, k)
if m.sum() < 10:
return orig.copy(), None
ys, xs = np.where(m > 0)
center = (int((xs.min() + xs.max()) / 2), int((ys.min() + ys.max()) / 2))
final = cv2.seamlessClone(swap_result, orig, m * 255, center, cv2.NORMAL_CLONE)
return final, None
alpha = _feather_alpha(mask_bool, blend_method, feather_px, edge_erode_px)
a3 = alpha[:, :, None]
final = (orig.astype(np.float32) * (1 - a3) + swap_result.astype(np.float32) * a3)
return np.clip(final, 0, 255).astype(np.uint8), alpha
# ---------------------------------------------------------------------------
# 主入口
# ---------------------------------------------------------------------------
def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segformer",
mask_type="eroded", erode_cm=1.2, swap_mode="ext_mask",
blend_method="feather", feather_px=15, edge_erode_px=3,
denoising_strength=0.6, gen_backend="swaphair",
hairgrow_strength=0.75):
"""接口11 完整管线。返回可直接进 ok() 的 data dict。未检出人脸抛 NoFaceError。
gen_backend:生成后端。swaphair=换发型LoRA(应用发际线类型);
hairgrow=区域生发inpaint(在遮罩内长出头发、压低发际线)。
"""
h, w = image_bgr.shape[:2]
landmarks = detector.detect(image_bgr)
if landmarks is None:
raise NoFaceError()
px_per_cm = estimate_scale_factor(landmarks, w, h)
# 步骤1:接口9 遮罩
t0 = time.time()
mask_bool, mask_viz = compute_mask(
image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm)
t_mask = time.time() - t0
# 步骤2:生成(按后端)
t0 = time.time()
if gen_backend == "hairgrow":
swap_result = _call_hairgrow(image_bgr, mask_bool, hairgrow_strength)
else:
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)
t_swap = time.time() - t0
# 步骤3:严格按遮罩硬贴回(无融合,用于对比)
hard_paste = image_bgr.copy()
hard_paste[mask_bool] = swap_result[mask_bool]
# 步骤4:接缝融合
t0 = time.time()
final, alpha = _composite(
image_bgr, swap_result, mask_bool, blend_method, feather_px, edge_erode_px)
t_blend = time.time() - t0
data = {
"hairline_id": hairline_id,
"gen_backend": gen_backend,
"hairgrow_strength": round(float(hairgrow_strength), 3),
"is_hr": is_hr,
"seg_model": seg_model,
"mask_type": mask_type,
"erode_cm": round(float(erode_cm), 2),
"swap_mode": swap_mode,
"blend_method": blend_method,
"feather_px": int(feather_px),
"edge_erode_px": int(edge_erode_px),
"denoising_strength": round(float(denoising_strength), 3),
"px_per_cm": round(float(px_per_cm), 4),
"erode_px": mask_viz["erode_px"],
"hair_pixels": mask_viz["hair_pixels"],
"mask_pixels": mask_viz["mask_pixels"],
"image_size": {"width": w, "height": h},
"timings_ms": {
"mask": int(t_mask * 1000),
"swap": int(t_swap * 1000),
"blend": int(t_blend * 1000),
},
"steps": {
"input_base64": _jpg_b64(image_bgr),
"mask_overlay_base64": mask_viz["mask_overlay_base64"],
"mask_base64": mask_viz["mask_base64"],
"swap_raw_base64": _jpg_b64(swap_result),
"hard_paste_base64": _jpg_b64(hard_paste),
"alpha_base64": _gray_b64(alpha) if alpha is not None else mask_viz["mask_base64"],
"final_base64": _jpg_b64(final),
},
}
return data
Binary file not shown.

After

Width:  |  Height:  |  Size: 417 KiB

+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# 接口11(发际线生发)所需的 change_hair 后端服务启停脚本。
#
# webui(57860) + photo_service(32678) + hair_service_sd(8801)
# —— 不需要 ComfyUI。
#
# hair_service_sd 会被【强制重启】以加载接口11 的 ext_mask 改动;
# webui / photo_service 仅在未运行时才拉起(已在跑则不动,避免 webui 重新加载模型)。
#
# 用法:
# bash scripts/restart_if11_backends.sh # 确保三个服务就绪(强制重启 8801)
# bash scripts/restart_if11_backends.sh status # 只看状态
set -u
PROJ="/home/xsl/change_hair/project"
LOGDIR="$PROJ/logs"
PIDD="$LOGDIR/pids"
PY_SD="/home/xsl/miniconda3/envs/sdwebui/bin/python"
PY_PHOTO="/home/xsl/miniconda3/envs/py310/bin/python"
PY_HAIR="/home/xsl/miniconda3/envs/my_hair/bin/python"
mkdir -p "$LOGDIR" "$PIDD"
export CRYPTOGRAPHY_OPENSSL_NO_LEGACY=1
export CUDA_VISIBLE_DEVICES=0
export APP_WORKER_ID=1
port_up() { ss -ltn 2>/dev/null | grep -q ":$1 "; }
wait_port() { # $1=port $2=label $3=timeout_s
local p="$1" label="$2" to="${3:-90}" i=0
echo -n " 等待 $label (端口 $p) "
while [ "$i" -lt "$to" ]; do
if port_up "$p"; then echo " ✓ 就绪"; return 0; fi
echo -n "."; sleep 2; i=$((i+2))
done
echo " ✗ 超时(看日志:$LOGDIR/*.log"; return 1
}
status() {
echo "=== 服务状态 $(date '+%H:%M:%S') ==="
for pair in "webui:57860" "photo_service:32678" "hair_service_sd:8801"; do
local name="${pair%%:*}" port="${pair##*:}"
if port_up "$port"; then echo "$name (端口 $port 就绪)"; else echo "$name (端口 $port 未监听)"; fi
done
echo " worker 8187: $(curl -s -m3 http://127.0.0.1:8187/health 2>/dev/null || echo '未响应')"
}
if [ "${1:-}" = "status" ]; then status; exit 0; fi
# 1) webui —— 仅未运行时启动(模型加载慢,别乱重启)
if pgrep -f "webui.py.*57860" >/dev/null; then
echo "webui 已在运行,跳过"
else
echo "启动 webui (57860)..."
( cd "$PROJ/onediff/stable-diffusion-webui" && \
HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 \
nohup "$PY_SD" webui.py --api --listen --xformers --port 57860 > "$LOGDIR/webui.log" 2>&1 & \
echo $! > "$PIDD/webui.pid" )
echo " webui PID=$(cat "$PIDD/webui.pid" 2>/dev/null)"
fi
# 2) photo_service —— 仅未运行时启动
if pgrep -f "lora_train_service_1.py" >/dev/null; then
echo "photo_service 已在运行,跳过"
else
echo "启动 photo_service (32678)..."
( cd "$PROJ/photo_service" && \
nohup "$PY_PHOTO" -u lora_train_service_1.py > "$LOGDIR/photo_service.log" 2>&1 & \
echo $! > "$PIDD/photo_service.pid" )
echo " photo_service PID=$(cat "$PIDD/photo_service.pid" 2>/dev/null)"
fi
# 3) hair_service_sd —— 强制重启(加载接口11 的 ext_mask 改动)
echo "强制重启 hair_service_sd (8801)..."
pkill -f "run_copy_cost_colorb64" 2>/dev/null
sleep 2
( cd "$PROJ/hair_service_sd" && \
nohup "$PY_HAIR" run_copy_cost_colorb64.py > "$LOGDIR/hair_service_if11.log" 2>&1 & \
echo $! > "$PIDD/hair_service.pid" )
echo " hair_service_sd PID=$(cat "$PIDD/hair_service.pid" 2>/dev/null)"
echo "--- 等待就绪 ---"
wait_port 57860 webui 180
wait_port 32678 photo_service 60
wait_port 8801 hair_service_sd 120
echo
status
echo
echo "提示:hair_service_sd 端口就绪后,模型可能还需几秒;日志出现 '[HAIR_INIT] All init done' 即完全可用:"
echo " tail -f $LOGDIR/hair_service_if11.log"
+376
View File
@@ -0,0 +1,376 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>接口11 — 发际线生发 测试页</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
.container { max-width: 1240px; margin: 0 auto; padding: 24px; }
h1 { font-size: 22px; margin-bottom: 8px; }
.subtitle { color: #888; font-size: 13px; margin-bottom: 20px; line-height: 1.6; }
h2 { font-size: 16px; margin: 24px 0 12px; }
.card { background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 20px; }
.upload-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
input[type=file] { flex: 1; min-width: 200px; padding: 8px; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; }
.btn { padding: 10px 24px; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; font-weight: 600; }
.btn-primary { background: #2563eb; color: #fff; }
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
.btn-outline { background: #fff; border: 1px solid #d1d5db; color: #374151; }
.hint { font-size: 12px; color: #9ca3af; margin-top: 8px; line-height: 1.6; }
.params { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 14px; margin-top: 16px; }
.pf { display: flex; flex-direction: column; gap: 4px; }
.pf label { font-size: 13px; font-weight: 600; }
.pf .desc { font-size: 11px; color: #9ca3af; font-weight: 400; }
.pf select, .pf input[type=number], .pf input[type=text] { padding: 8px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; }
.pf .row { display: flex; gap: 8px; align-items: center; }
.pf .row input[type=number] { width: 80px; }
.pf .row input[type=range] { flex: 1; }
.chk { display: flex; align-items: center; gap: 8px; }
.status { padding: 10px 16px; border-radius: 8px; font-size: 14px; margin-bottom: 16px; display: none; }
.status.info { background: #dbeafe; color: #1e40af; display: block; }
.status.error { background: #fee2e2; color: #991b1b; display: block; }
.status.success { background: #d1fae5; color: #065f46; display: block; }
.metrics { display: flex; gap: 16px; flex-wrap: wrap; }
.metric { background: #f9fafb; border: 1px solid #eee; border-radius: 8px; padding: 10px 16px; min-width: 110px; }
.metric .label { font-size: 12px; color: #888; }
.metric .value { font-size: 17px; font-weight: 700; color: #111; }
.steps { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
.step { background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
.step .cap { font-size: 13px; font-weight: 600; padding: 8px 12px; background: #fafafa; border-bottom: 1px solid #f0f0f0; }
.step .cap small { color: #999; font-weight: 400; display:block; margin-top:2px; }
.step img { width: 100%; display: block; background: #eee; cursor: zoom-in; }
.big img { max-height: 520px; object-fit: contain; }
.json-panel { max-height: 320px; overflow: auto; background: #1e1e1e; color: #d4d4d4; padding: 14px; border-radius: 8px; font: 12px/1.5 Consolas, Monaco, monospace; white-space: pre-wrap; word-break: break-all; }
.hidden { display: none; }
.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.85); display: none; align-items: center; justify-content: center; z-index: 50; cursor: zoom-out; }
.lightbox img { max-width: 95%; max-height: 95%; }
</style>
</head>
<body>
<div class="container">
<h1>接口11 — 发际线生发</h1>
<div class="subtitle">
接口9 头发遮罩 → change_hair 换发型(发际线类型 LoRA)→ 严格按遮罩贴回原图 → 接缝羽化融合。<br>
输入:发际线较高 / 头发稀少的正脸图 + 发际线类型 ID。输出:同一个人同一发型、压低发际线后的图。每一步可视化,所有可调参数都在下面。
</div>
<div class="card">
<div class="upload-row">
<input type="file" id="imageFile" accept="image/*">
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交测试</button>
<button class="btn btn-outline" onclick="clearResults()">清空</button>
</div>
<div class="upload-row" style="margin-top:10px">
<label style="font-size:13px; font-weight:600; white-space:nowrap">X-Internal-Token</label>
<input type="text" id="token" placeholder="直连 worker 需填;走网关可留空" value="dev-shared-secret-2026"
style="flex:1; min-width:200px; padding:8px; border:1px solid #ddd; border-radius:8px">
</div>
<div class="params">
<div class="pf">
<label>gen_backend <span class="desc">生成后端</span></label>
<select id="genBackend">
<option value="swaphair">swaphair(换发型LoRA,应用发际线类型)</option>
<option value="hairgrow">hairgrow(区域生发inpaint,压低发际线)</option>
</select>
</div>
<div class="pf">
<label>发际线类型 ID <span class="desc">= change_hair hair_id(仅swaphair</span></label>
<select id="hairlineId">
<option value="chang_zhixian">chang_zhixian(直线)</option>
<option value="chang_tuoyuan">chang_tuoyuan(椭圆)</option>
<option value="chang_bolang">chang_bolang(波浪)</option>
<option value="chang_xinxing">chang_xinxing(心形)</option>
<option value="chang_huaban">chang_huaban(花瓣)</option>
</select>
</div>
<div class="pf">
<label>swap_mode <span class="desc">换发型取图模式</span></label>
<select id="swapMode">
<option value="ext_mask">ext_mask(改造换发型,用接口9遮罩重绘)</option>
<option value="as_is">as_is(不改换发型,贴回再裁)</option>
</select>
</div>
<div class="pf">
<label>seg_model <span class="desc">头发分割模型</span></label>
<select id="segModel">
<option value="segformer">segformer(默认)</option>
<option value="bisenet">bisenet</option>
</select>
</div>
<div class="pf">
<label>mask_type <span class="desc">遮罩类型</span></label>
<select id="maskType">
<option value="eroded">eroded(外缘内缩,默认)</option>
<option value="closed">closed(闭合区域,未内缩)</option>
</select>
</div>
<div class="pf">
<label>erode_cm <span class="desc">外缘内缩距离(cm)</span></label>
<div class="row">
<input type="number" id="erodeCm" min="0" max="5" step="0.1" value="1.2">
<input type="range" id="erodeRange" min="0" max="3" step="0.1" value="1.2">
</div>
</div>
<div class="pf">
<label>blend_method <span class="desc">接缝融合算法</span></label>
<select id="blendMethod">
<option value="feather">feather(高斯羽化,默认)</option>
<option value="alpha_gradient">alpha_gradient(距离变换内渐变)</option>
<option value="seamless">seamless(泊松无缝克隆)</option>
</select>
</div>
<div class="pf">
<label>feather_px <span class="desc">羽化/渐变过渡宽度(px)</span></label>
<div class="row">
<input type="number" id="featherPx" min="1" max="80" step="1" value="15">
<input type="range" id="featherRange" min="1" max="60" step="1" value="15">
</div>
</div>
<div class="pf">
<label>edge_erode_px <span class="desc">贴图前遮罩内缩(px)</span></label>
<div class="row">
<input type="number" id="edgeErodePx" min="0" max="40" step="1" value="3">
<input type="range" id="edgeErodeRange" min="0" max="30" step="1" value="3">
</div>
</div>
<div class="pf">
<label>denoising_strength <span class="desc">换发型重绘强度(仅swaphair)</span></label>
<div class="row">
<input type="number" id="denoise" min="0.2" max="1.0" step="0.05" value="0.6">
<input type="range" id="denoiseRange" min="0.2" max="1.0" step="0.05" value="0.6">
</div>
</div>
<div class="pf">
<label>hairgrow_strength <span class="desc">区域生发强度(仅hairgrow)</span></label>
<div class="row">
<input type="number" id="hgStrength" min="0.2" max="1.0" step="0.05" value="0.75">
<input type="range" id="hgStrengthRange" min="0.2" max="1.0" step="0.05" value="0.75">
</div>
</div>
<div class="pf">
<label>is_hr <span class="desc">高清模式</span></label>
<div class="chk"><input type="checkbox" id="isHr"><span style="font-size:13px">1152×1536(否则 576×768</span></div>
</div>
</div>
<div class="hint">
发际线类型 ID 必须是 change_hair 已训练的 hair_id(下拉为已有的 5 个)。参数改动后点「提交测试」重新计算,设置自动记住。<br>
换发型走 GPU + SD img2img,单次约 10~15sis_hr 更慢。
</div>
</div>
<div class="status hidden" id="statusBar"></div>
<div id="resultsArea" class="hidden">
<div class="card">
<h2 style="margin-top:0">🎯 最终结果对比</h2>
<div class="steps" style="grid-template-columns: repeat(auto-fill, minmax(320px, 1fr))">
<div class="step big"><div class="cap">输入原图</div><img id="finalInput"></div>
<div class="step big"><div class="cap">最终结果 <small>按遮罩贴回 + 接缝融合</small></div><img id="finalOut"></div>
</div>
</div>
<div class="card">
<h2 style="margin-top:0">📏 参数与指标</h2>
<div class="metrics" id="metricsBar"></div>
</div>
<div class="card">
<h2 style="margin-top:0">🪜 分步可视化</h2>
<div class="steps" id="stepsGrid"></div>
</div>
<div class="card">
<h2 style="margin-top:0; display:flex; justify-content:space-between; align-items:center">
<span>原始 JSON</span>
<button class="btn btn-outline" style="padding:6px 14px; font-size:13px" onclick="copyJson()">复制</button>
</h2>
<div class="json-panel" id="jsonContent"></div>
</div>
</div>
</div>
<div class="lightbox" id="lightbox" onclick="this.style.display='none'"><img id="lightboxImg" alt=""></div>
<script>
const API_BASE = window.location.origin;
const ENDPOINT = '/api/v1/hairline/grow';
// 分步展示的图(按算法文档 4 步;key 前缀、标题、副标题)
const STEPS = [
{ key: 'mask_overlay', title: '① 接口9 最终遮罩(叠加)', sub: '红=遮罩区(含额头,外缘内缩)' },
{ key: 'mask', title: '① 纯遮罩', sub: '白=生成/贴回区(传给换发型作遮罩 & 贴回)' },
{ key: 'swap_raw', title: '② 生成全帧', sub: 'change_hair 生成,已对齐原图' },
{ key: 'hard_paste', title: '③ 严格按遮罩贴回', sub: '遮罩内=生成,遮罩外=原图,无融合' },
{ key: 'alpha', title: '④ 融合权重 alpha', sub: '羽化/渐变,白=用生成图' },
{ key: 'final', title: '④ 接缝融合(最终)', sub: '遮罩边缘自然过渡' },
];
function $(id) { return document.getElementById(id); }
function setStatus(text, type) { const b = $('statusBar'); b.textContent = text; b.className = 'status ' + type; }
// 网关把 *_base64 改写为 *_url;直连 worker 保留 *_base64。两者都兼容。
function pick(obj, name) { if (!obj) return null; return obj[name + '_url'] || obj[name + '_base64'] || null; }
function stepCard(title, sub, src) {
const div = document.createElement('div');
div.className = 'step';
const img = src ? '<img src="' + src + '" alt="' + title + '" onclick="zoom(this.src)">'
: '<div style="padding:30px;text-align:center;color:#bbb;font-size:13px">无图</div>';
div.innerHTML = '<div class="cap">' + title + '<small>' + (sub || '') + '</small></div>' + img;
return div;
}
function zoom(src) { $('lightboxImg').src = src; $('lightbox').style.display = 'flex'; }
function renderMetrics(d) {
const sz = d.image_size || {}, t = d.timings_ms || {};
const items = [
{ label: 'gen_backend', value: d.gen_backend },
{ label: 'hairline_id', value: d.hairline_id },
{ label: 'swap_mode', value: d.swap_mode },
{ label: 'seg_model', value: d.seg_model },
{ label: 'mask_type', value: d.mask_type },
{ label: 'blend', value: d.blend_method },
{ label: 'denoise', value: d.denoising_strength },
{ label: 'px_per_cm', value: d.px_per_cm },
{ label: '内缩', value: d.erode_cm + 'cm/' + d.erode_px + 'px' },
{ label: '遮罩像素', value: d.mask_pixels },
{ label: '尺寸', value: (sz.width||'?') + '×' + (sz.height||'?') },
{ label: '耗时(遮罩/换发/融合)', value: (t.mask||0) + '/' + (t.swap||0) + '/' + (t.blend||0) + 'ms' },
];
$('metricsBar').innerHTML = items.map(m =>
'<div class="metric"><div class="label">' + m.label + '</div><div class="value">' + (m.value ?? '—') + '</div></div>'
).join('');
}
function renderResult(d) {
const s = d.steps || {};
$('finalInput').src = pick(s, 'input') || '';
$('finalInput').onclick = function(){ zoom(this.src); };
$('finalOut').src = pick(s, 'final') || '';
$('finalOut').onclick = function(){ zoom(this.src); };
renderMetrics(d);
const grid = $('stepsGrid'); grid.innerHTML = '';
STEPS.forEach(st => grid.appendChild(stepCard(st.title, st.sub, pick(s, st.key))));
}
async function submitTest() {
const file = $('imageFile').files[0];
if (!file) { setStatus('请先选择一张图片', 'error'); return; }
const t0 = performance.now();
const btn = $('submitBtn');
btn.disabled = true; btn.textContent = '⏳ 请求中...';
setStatus('正在请求(换发型走 GPU,约 10~15s...', 'info');
$('resultsArea').classList.add('hidden');
const form = new FormData();
form.append('image_file', file);
form.append('hairline_id', $('hairlineId').value.trim());
form.append('gen_backend', $('genBackend').value);
form.append('hairgrow_strength', $('hgStrength').value || '0.75');
form.append('is_hr', $('isHr').checked ? 'true' : 'false');
form.append('seg_model', $('segModel').value);
form.append('mask_type', $('maskType').value);
form.append('erode_cm', $('erodeCm').value || '1.2');
form.append('swap_mode', $('swapMode').value);
form.append('blend_method', $('blendMethod').value);
form.append('feather_px', $('featherPx').value || '15');
form.append('edge_erode_px', $('edgeErodePx').value || '3');
form.append('denoising_strength', $('denoise').value || '0.6');
try {
const headers = {};
const tok = $('token').value.trim();
if (tok) headers['X-Internal-Token'] = tok;
const resp = await fetch(API_BASE + ENDPOINT, { method: 'POST', headers, body: form });
const json = await resp.json();
const dt = ((performance.now() - t0) / 1000).toFixed(2);
$('jsonContent').textContent = JSON.stringify(json, (k, v) =>
(typeof v === 'string' && v.length > 120) ? v.slice(0, 60) + '…(' + v.length + ')' : v, 2);
$('resultsArea').classList.remove('hidden');
if (json.code === 0) {
setStatus('✅ 成功 (' + dt + 's)', 'success');
renderResult(json.data);
} else {
setStatus('❌ 业务错误 (' + dt + 's) — code ' + json.code + '' + json.message, 'error');
}
} catch (err) {
setStatus('❌ 网络错误: ' + err.message, 'error');
$('jsonContent').textContent = 'Error: ' + err.message;
$('resultsArea').classList.remove('hidden');
} finally {
btn.disabled = false; btn.textContent = '🚀 提交测试';
}
}
function clearResults() {
$('resultsArea').classList.add('hidden');
$('statusBar').className = 'status hidden';
$('imageFile').value = '';
$('jsonContent').textContent = '';
}
function copyJson() {
navigator.clipboard.writeText($('jsonContent').textContent).then(() => {
const b = event.target, o = b.textContent; b.textContent = '✅ 已复制'; setTimeout(() => b.textContent = o, 1500);
});
}
// ---- 参数持久化 + 联动 ----
const FIELDS = ['token','genBackend','hairlineId','swapMode','segModel','maskType','erodeCm','blendMethod','featherPx','edgeErodePx','denoise','hgStrength'];
function saveField(id) { try { localStorage.setItem('if11_' + id, $(id).type === 'checkbox' ? ($(id).checked?'1':'0') : $(id).value); } catch(e){} }
function linkNumRange(numId, rngId) {
const num = $(numId), rng = $(rngId);
function sync(from) {
let v = parseFloat(from.value); if (isNaN(v)) v = 0;
num.value = v; rng.value = Math.min(parseFloat(rng.max), Math.max(parseFloat(rng.min), v));
saveField(numId);
}
num.addEventListener('input', () => sync(num));
rng.addEventListener('input', () => sync(rng));
}
document.addEventListener('DOMContentLoaded', () => {
const dz = $('imageFile');
dz.addEventListener('change', () => { if (dz.files.length) setStatus('已选择: ' + dz.files[0].name, 'info'); });
FIELDS.forEach(id => {
try { const s = localStorage.getItem('if11_' + id); if (s !== null) $(id).value = s; } catch(e){}
$(id).addEventListener('change', () => saveField(id));
});
try { $('isHr').checked = localStorage.getItem('if11_isHr') === '1'; } catch(e){}
$('isHr').addEventListener('change', () => saveField('isHr'));
linkNumRange('erodeCm','erodeRange');
linkNumRange('featherPx','featherRange');
linkNumRange('edgeErodePx','edgeErodeRange');
linkNumRange('denoise','denoiseRange');
linkNumRange('hgStrength','hgStrengthRange');
// 初始化滑块值
$('erodeRange').value = Math.min(3, parseFloat($('erodeCm').value)||1.2);
$('featherRange').value = Math.min(60, parseFloat($('featherPx').value)||15);
$('edgeErodeRange').value = Math.min(30, parseFloat($('edgeErodePx').value)||3);
$('denoiseRange').value = Math.min(1.0, parseFloat($('denoise').value)||0.6);
$('hgStrengthRange').value = Math.min(1.0, parseFloat($('hgStrength').value)||0.75);
});
</script>
</body>
</html>