diff --git a/app.py b/app.py index b2b6fa5..a02e303 100644 --- a/app.py +++ b/app.py @@ -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}") + + # --------------------------------------------------------------------------- # 健康检查 # --------------------------------------------------------------------------- diff --git a/docs/发际线增强算法.md b/docs/发际线增强算法.md new file mode 100644 index 0000000..8b764a0 --- /dev/null +++ b/docs/发际线增强算法.md @@ -0,0 +1,7 @@ +第一步 使用接口9 头发遮罩生成 的算法获取 mask +第二部 改造 /home/xsl/change_hair 换发型的工作流, 换发型的参考文档在这里 /home/xsl/change_hair/docs/换发型集成文档.md +1、原始换发型工作的遮罩用第一步算出来的遮罩 +2、然后换发型得到遮罩区域发际线的图片。 +3、严格按照遮罩区域把图片贴回到原图上面。 +4、贴图的时候融合贴图边缘和原图的接缝,可以采用羽化算法或者渐变alpha混合的算法,目的就是边缘要和原图过渡自然。 这里通过传入各种参数可以控制选哪种算法和控制过渡细节。 +最后一步返回生成特定样式的图片。 \ No newline at end of file diff --git a/face_analysis/hairline_grow.py b/face_analysis/hairline_grow.py new file mode 100644 index 0000000..450e844 --- /dev/null +++ b/face_analysis/hairline_grow.py @@ -0,0 +1,337 @@ +"""接口11:发际线生发。 + +输入一张发际线较高 / 头发稀少的正脸图 + 发际线类型 ID(= change_hair 的 hair_id, +如 chang_tuoyuan/chang_bolang/...),输出同一个人、同一发型、按该发际线类型压低发际线 +后的图片。管线(见 docs/发际线增强算法.md): + +1. 用接口9 的算法算出头发遮罩(含额头闭合区域,外缘内缩 erode_cm)。 + —— seg_model 选 bisenet/segformer,mask_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_hair,swapHair 用它自己的内部遮罩,贴回时再裁到接口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 | segformer;mask_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_strength:webui 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"换发型服务返回非 JSON(HTTP {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"区域生发返回非 JSON(HTTP {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 diff --git a/image/hair_test.jpg b/image/hair_test.jpg new file mode 100644 index 0000000..93fd74c Binary files /dev/null and b/image/hair_test.jpg differ diff --git a/scripts/restart_if11_backends.sh b/scripts/restart_if11_backends.sh new file mode 100755 index 0000000..8977105 --- /dev/null +++ b/scripts/restart_if11_backends.sh @@ -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" diff --git a/static/test_interface11.html b/static/test_interface11.html new file mode 100644 index 0000000..a576216 --- /dev/null +++ b/static/test_interface11.html @@ -0,0 +1,376 @@ + + +
+ + +