初始化:换发型/换发色/训练发型服务
包含: - hair_service_sd: 主服务(换发型/换发色/生发,端口8801) - photo_service: LoRA调度+训练(端口32678) - hair_grow_service: 调试测试页(端口8888,含4个测试页) - 批量训练脚本(batch_train_hairstyles.py) - 发际线mask自动识别(hairline_mask.py,4种方案) - 手绘mask换发型(hair_swap_manual.py) - 文档:README.md + LARGE_FILES.md + docs/ 大文件(模型权重200G、训练数据123G)已排除,见 LARGE_FILES.md OSS/COS密钥已脱敏为环境变量,原文件备份在本地
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
# 区域生发测试服务
|
||||
|
||||
提供带「画笔涂抹遮罩」的 Web 测试页面,在用户涂抹的区域内生发。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 确认 webui 已启动(生发依赖 SD inpainting)
|
||||
```bash
|
||||
ss -tlnp | grep 57860 # 确认 webui 在跑
|
||||
bash /home/xsl/change_hair/start_all.sh status # 看 webui 是否就绪
|
||||
```
|
||||
|
||||
### 2. 启动生发服务(端口 8899)
|
||||
```bash
|
||||
nohup /home/xsl/change_hair/start_hairgrow.sh > /home/xsl/change_hair/project/logs/hair_grow_service.log 2>&1 &
|
||||
```
|
||||
|
||||
### 3. 打开测试页面
|
||||
浏览器访问:
|
||||
```
|
||||
http://172.21.246.70:8899
|
||||
```
|
||||
(如从 Windows 宿主机访问 WSL,用上面地址;或用 localhost:8899)
|
||||
|
||||
### 4. 使用流程
|
||||
1. **上传图片**:点击或拖拽一张人头像照片(正面、发际线/稀疏区清晰)
|
||||
2. **涂抹遮罩**:用画笔在需要生发的区域涂抹(白色 = 生发区)
|
||||
- 左侧可调画笔粗细、切换橡皮、清空
|
||||
3. **调强度**:生发强度滑块(0.1~1.0,默认 0.5)
|
||||
- 低强度:轻微生发,严格保持遮罩边界
|
||||
- 高强度:浓密生发,边缘自然过渡
|
||||
4. **点「生成」**:等待约 5 秒
|
||||
5. **看结果**:原图与生发结果并排对比,可下载
|
||||
|
||||
## 文件说明
|
||||
```
|
||||
hair_grow_service/
|
||||
├── app.py # Flask 服务(端口 8899)
|
||||
├── static/index.html # 测试页面(画笔遮罩 + 结果对比)
|
||||
└── README.md
|
||||
|
||||
start_hairgrow.sh # 启动脚本
|
||||
```
|
||||
|
||||
## API 接口(可单独调用)
|
||||
```
|
||||
POST http://<host>:8899/api/grow
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"img": "data:image/jpeg;base64,...", # 原图 base64
|
||||
"mask": "data:image/png;base64,...", # 遮罩 base64,白色(255)=生发区,与img同分辨率
|
||||
"strength": 0.5 # 生发强度 0.1~1.0
|
||||
}
|
||||
|
||||
→ {"state": 0, "result": "<base64结果图>"}
|
||||
{"state": -1, "msg": "错误信息"}
|
||||
```
|
||||
|
||||
## 停止服务
|
||||
```bash
|
||||
pkill -f "hair_grow_service/app.py"
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
- 生发服务**依赖 webui(57860)**,webui 未启动会报连接失败
|
||||
- 图片过大(>1024px)会自动缩放并对齐到 8 的倍数
|
||||
- 生发结果是确定性的(固定 seed=123456789),同输入同输出
|
||||
@@ -0,0 +1,515 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""区域生发服务(走换发型工作流,端口 8888)
|
||||
|
||||
工作流与换发型一致:infer_hairstyle_diy_jy → warpAffine → photo_service+LoRA → webui → 贴回。
|
||||
mask = origin_matting ∪ new_matting ∪ 手绘mask(不减刘海)。
|
||||
|
||||
启动: python app.py
|
||||
页面: http://<host>:8888
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import base64
|
||||
import traceback
|
||||
|
||||
# 把 hair_service_sd 加入 path,使其模块可被导入
|
||||
HAIR_SERVICE_DIR = "/home/xsl/change_hair/project/hair_service_sd"
|
||||
sys.path.insert(0, HAIR_SERVICE_DIR)
|
||||
# 切换到 hair_service_sd 目录,使 common.logger 能读到 config/configure.ini(相对路径)
|
||||
os.chdir(HAIR_SERVICE_DIR)
|
||||
os.environ.setdefault("HF_HUB_OFFLINE", "1")
|
||||
os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
|
||||
os.environ.setdefault("CRYPTOGRAPHY_OPENSSL_NO_LEGACY", "1")
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from flask import Flask, request, jsonify, send_from_directory
|
||||
from gevent import pywsgi
|
||||
|
||||
PORT = 8888
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
app = Flask(__name__, static_folder="static", static_url_path="/static")
|
||||
|
||||
# ===== 模型懒加载(首次请求时初始化,避免启动卡死)=====
|
||||
_hairstyle_process = None
|
||||
_landmark_processor = None
|
||||
|
||||
|
||||
def _get_models():
|
||||
"""懒加载换发型全套模型"""
|
||||
global _hairstyle_process, _landmark_processor
|
||||
if _hairstyle_process is None:
|
||||
print("[init] 加载换发型模型(首次,约30-90秒)...")
|
||||
t0 = time.time()
|
||||
import torch
|
||||
from core.hairstyle_model import HairStyle_Model
|
||||
from utils import landmark_processor
|
||||
_hairstyle_process = HairStyle_Model(gpu=True, use_enhance=True)
|
||||
_landmark_processor = landmark_processor
|
||||
print(f"[init] 模型加载完成,耗时 {time.time()-t0:.1f}s")
|
||||
return _hairstyle_process, _landmark_processor
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return send_from_directory(os.path.join(BASE_DIR, "static"), "index.html")
|
||||
|
||||
|
||||
@app.route("/swap")
|
||||
def swap_page():
|
||||
"""换发型测试页(独立,无遮罩)"""
|
||||
return send_from_directory(os.path.join(BASE_DIR, "static"), "swap.html")
|
||||
|
||||
|
||||
@app.route("/test_new")
|
||||
def test_new_page():
|
||||
"""新发型效果测试页(聚焦本次训练的30款发型)"""
|
||||
return send_from_directory(os.path.join(BASE_DIR, "static"), "test_new.html")
|
||||
|
||||
|
||||
@app.route("/debug")
|
||||
def debug_page():
|
||||
"""换发型调试页(全参数可视化)"""
|
||||
return send_from_directory(os.path.join(BASE_DIR, "static"), "debug.html")
|
||||
|
||||
|
||||
@app.route("/hairline")
|
||||
def hairline_page():
|
||||
"""发际线带重绘实验页"""
|
||||
return send_from_directory(os.path.join(BASE_DIR, "static"), "hairline.html")
|
||||
|
||||
|
||||
@app.route("/manual")
|
||||
def manual_page():
|
||||
"""手绘mask重绘测试页"""
|
||||
return send_from_directory(os.path.join(BASE_DIR, "static"), "manual.html")
|
||||
|
||||
|
||||
@app.route("/api/swap", methods=["POST"])
|
||||
def api_swap_proxy():
|
||||
"""代理换发型请求到 8801(避免前端跨域问题)"""
|
||||
import requests as req
|
||||
try:
|
||||
data = request.json
|
||||
resp = req.post("http://127.0.0.1:8801/api/swapHair/v1",
|
||||
json=data, timeout=600)
|
||||
return jsonify(resp.json())
|
||||
except Exception as e:
|
||||
return jsonify({"state": -1, "msg": f"换发型代理失败: {e}"}), 500
|
||||
|
||||
|
||||
@app.route("/api/swap_viz", methods=["POST"])
|
||||
def api_swap_viz():
|
||||
"""换发型(带可视化中间产物)
|
||||
|
||||
入参 JSON:
|
||||
img: 原图 base64
|
||||
hair_id: 发型ID
|
||||
is_hr: "true"/"false"
|
||||
返回:
|
||||
{state, result: 最终图b64, steps: [{title, desc, images:[{label, b64}]}]}
|
||||
"""
|
||||
t0 = time.time()
|
||||
try:
|
||||
data = request.json
|
||||
img_b64 = data.get("img", "")
|
||||
hair_id = data.get("hair_id", "")
|
||||
is_hr = str(data.get("is_hr", "false")).lower() == "true"
|
||||
strict_mask = data.get("strict_mask", False)
|
||||
|
||||
if not img_b64 or not hair_id:
|
||||
return jsonify({"state": -1, "msg": "img 和 hair_id 不能为空"}), 400
|
||||
|
||||
img = _b64_to_ndarray(img_b64, color=True)
|
||||
if img is None:
|
||||
return jsonify({"state": -1, "msg": "img 解析失败"}), 400
|
||||
|
||||
print(f"[swap_viz] img={img.shape}, hair_id={hair_id}, is_hr={is_hr}, strict_mask={strict_mask}")
|
||||
hairstyle_process, landmark_processor = _get_models()
|
||||
|
||||
from hair_swap_viz import hair_swap_viz
|
||||
task_id = f"swapviz_{int(time.time()*1000)}"
|
||||
result, steps = hair_swap_viz(
|
||||
origin_img=img, hair_id=hair_id,
|
||||
hairstyle_process=hairstyle_process,
|
||||
landmark_processor=landmark_processor,
|
||||
task_id=task_id, is_hr=is_hr, strict_mask=strict_mask)
|
||||
|
||||
_, buf = cv2.imencode(".jpg", result, [cv2.IMWRITE_JPEG_QUALITY, 95])
|
||||
result_b64 = base64.b64encode(buf).decode("utf-8")
|
||||
print(f"[swap_viz] 完成,总耗时 {time.time()-t0:.1f}s,{len(steps)}个步骤")
|
||||
return jsonify({"state": 0, "result": result_b64, "steps": steps})
|
||||
|
||||
except Exception as e:
|
||||
print(f"[swap_viz] 失败: {e}")
|
||||
traceback.print_exc()
|
||||
return jsonify({"state": -1, "msg": f"换发型失败: {e}"}), 500
|
||||
|
||||
|
||||
@app.route("/api/swap_debug", methods=["POST"])
|
||||
def api_swap_debug():
|
||||
"""换发型调试接口(全参数可调 + 每步可视化)。
|
||||
|
||||
入参 JSON:
|
||||
img: 原图 base64
|
||||
hair_id: 发型ID
|
||||
# 流程开关
|
||||
cut_bang: bool, 是否减刘海圆(默认 true)
|
||||
strict_mask: bool, 严格按mask贴回(默认 false)
|
||||
seamless_blend: bool, 泊松融合消除接缝(默认 true,仅 strict_mask 时生效)
|
||||
# 尺寸/对齐
|
||||
is_hr: bool, 高清模式(默认 true)
|
||||
dilate_kernel: [x,y], mask膨胀核(默认 [6,18])
|
||||
# SD 推理(仅 denoising 可调)
|
||||
denoising_strength: float 0~1, 重绘强度(默认 0.6)
|
||||
# 贴回/融合
|
||||
blend_dilate: [x,y], strict贴回mask膨胀(默认 [5,5])
|
||||
seamless_dilate: [x,y], 泊松融合mask膨胀(默认 [9,9])
|
||||
返回:
|
||||
{state, result, steps, params}
|
||||
"""
|
||||
t0 = time.time()
|
||||
try:
|
||||
d = request.json
|
||||
img_b64 = d.get("img", "")
|
||||
hair_id = d.get("hair_id", "")
|
||||
if not img_b64 or not hair_id:
|
||||
return jsonify({"state": -1, "msg": "img 和 hair_id 不能为空"}), 400
|
||||
|
||||
img = _b64_to_ndarray(img_b64, color=True)
|
||||
if img is None:
|
||||
return jsonify({"state": -1, "msg": "img 解析失败"}), 400
|
||||
|
||||
# 解析参数(带默认值 + 类型转换)
|
||||
def g(k, default): return d.get(k, default)
|
||||
dk = g("dilate_kernel", [6, 18])
|
||||
bd = g("blend_dilate", [5, 5])
|
||||
sd = g("seamless_dilate", [9, 9])
|
||||
|
||||
print(f"[swap_debug] hair_id={hair_id}, cut_bang={g('cut_bang',True)}, "
|
||||
f"strict_mask={g('strict_mask',False)}, seamless_blend={g('seamless_blend',True)}, "
|
||||
f"is_hr={g('is_hr',True)}, denoising={g('denoising_strength',0.6)}")
|
||||
|
||||
hairstyle_process, landmark_processor = _get_models()
|
||||
from hair_swap_debug import hair_swap_debug
|
||||
task_id = f"swapdbg_{int(time.time()*1000)}"
|
||||
result, steps, params = hair_swap_debug(
|
||||
origin_img=img, hair_id=hair_id,
|
||||
hairstyle_process=hairstyle_process,
|
||||
landmark_processor=landmark_processor,
|
||||
task_id=task_id,
|
||||
cut_bang=bool(g("cut_bang", True)),
|
||||
strict_mask=bool(g("strict_mask", False)),
|
||||
seamless_blend=bool(g("seamless_blend", True)),
|
||||
is_hr=bool(g("is_hr", True)),
|
||||
dilate_kernel=(int(dk[0]), int(dk[1])),
|
||||
denoising_strength=float(g("denoising_strength", 0.6)),
|
||||
blend_dilate=(int(bd[0]), int(bd[1])),
|
||||
seamless_dilate=(int(sd[0]), int(sd[1])),
|
||||
)
|
||||
|
||||
_, buf = cv2.imencode(".jpg", result, [cv2.IMWRITE_JPEG_QUALITY, 95])
|
||||
result_b64 = base64.b64encode(buf).decode("utf-8")
|
||||
print(f"[swap_debug] 完成,总耗时 {time.time()-t0:.1f}s")
|
||||
return jsonify({"state": 0, "result": result_b64, "steps": steps, "params": params})
|
||||
|
||||
except Exception as e:
|
||||
print(f"[swap_debug] 失败: {e}")
|
||||
traceback.print_exc()
|
||||
return jsonify({"state": -1, "msg": f"换发型失败: {e}"}), 500
|
||||
|
||||
|
||||
@app.route("/api/swap_hairline", methods=["POST"])
|
||||
def api_swap_hairline():
|
||||
"""发际线带重绘接口(实验性,流程同 swap_debug,仅步骤③改为边界带)。
|
||||
|
||||
比 swap_debug 多一个参数:
|
||||
method: str, 发际线mask方案,可选 boundary_band/mediapipe/landmark_1k/deeplab(默认 mediapipe)
|
||||
band_width: int, 边界带宽度(形态学核大小,默认15,带宽≈2*band_width)
|
||||
其余参数同 swap_debug。
|
||||
"""
|
||||
t0 = time.time()
|
||||
try:
|
||||
d = request.json
|
||||
img_b64 = d.get("img", "")
|
||||
hair_id = d.get("hair_id", "")
|
||||
if not img_b64 or not hair_id:
|
||||
return jsonify({"state": -1, "msg": "img 和 hair_id 不能为空"}), 400
|
||||
|
||||
img = _b64_to_ndarray(img_b64, color=True)
|
||||
if img is None:
|
||||
return jsonify({"state": -1, "msg": "img 解析失败"}), 400
|
||||
|
||||
def g(k, default): return d.get(k, default)
|
||||
dk = g("dilate_kernel", [6, 18])
|
||||
bd = g("blend_dilate", [5, 5])
|
||||
sd = g("seamless_dilate", [9, 9])
|
||||
|
||||
print(f"[swap_hairline] hair_id={hair_id}, method={g('method','mediapipe')}, "
|
||||
f"band_width={g('band_width',15)}, denoising={g('denoising_strength',0.6)}")
|
||||
|
||||
hairstyle_process, landmark_processor = _get_models()
|
||||
from hair_swap_hairline import hair_swap_hairline
|
||||
task_id = f"hairline_{int(time.time()*1000)}"
|
||||
result, steps, params = hair_swap_hairline(
|
||||
origin_img=img, hair_id=hair_id,
|
||||
hairstyle_process=hairstyle_process,
|
||||
landmark_processor=landmark_processor,
|
||||
task_id=task_id,
|
||||
method=str(g("method", "mediapipe")),
|
||||
strict_mask=bool(g("strict_mask", False)),
|
||||
seamless_blend=bool(g("seamless_blend", True)),
|
||||
is_hr=bool(g("is_hr", True)),
|
||||
dilate_kernel=(int(dk[0]), int(dk[1])),
|
||||
denoising_strength=float(g("denoising_strength", 0.6)),
|
||||
blend_dilate=(int(bd[0]), int(bd[1])),
|
||||
seamless_dilate=(int(sd[0]), int(sd[1])),
|
||||
band_width=int(g("band_width", 15)),
|
||||
preview_only=bool(g("preview_only", False)),
|
||||
height_ratio=float(g("height_ratio", 0.432)),
|
||||
width_ratio=float(g("width_ratio", 0.144)),
|
||||
corner_ratio=float(g("corner_ratio", 0.25)),
|
||||
vertical_offset=float(g("vertical_offset", 0.0)),
|
||||
refiner_switch_at=float(g("refiner_switch_at", 0.5)),
|
||||
)
|
||||
|
||||
_, buf = cv2.imencode(".jpg", result, [cv2.IMWRITE_JPEG_QUALITY, 95])
|
||||
result_b64 = base64.b64encode(buf).decode("utf-8")
|
||||
print(f"[swap_hairline] 完成,总耗时 {time.time()-t0:.1f}s")
|
||||
return jsonify({"state": 0, "result": result_b64, "steps": steps, "params": params})
|
||||
|
||||
except Exception as e:
|
||||
print(f"[swap_hairline] 失败: {e}")
|
||||
traceback.print_exc()
|
||||
return jsonify({"state": -1, "msg": f"发际线带重绘失败: {e}"}), 500
|
||||
|
||||
|
||||
@app.route("/api/swap_manual", methods=["POST"])
|
||||
def api_swap_manual():
|
||||
"""手绘mask版换发型接口(重绘区域完全由用户手绘mask决定)。
|
||||
|
||||
入参 JSON:
|
||||
img: 原图 base64
|
||||
mask: 用户手绘mask base64(白色=重绘区,与img同分辨率)
|
||||
hair_id: 发型ID
|
||||
其余参数同 swap_debug(strict_mask/seamless_blend/is_hr/dilate_kernel/
|
||||
denoising_strength/blend_dilate/seamless_dilate)
|
||||
返回: {state, result, steps, params}
|
||||
"""
|
||||
t0 = time.time()
|
||||
try:
|
||||
d = request.json
|
||||
img_b64 = d.get("img", "")
|
||||
mask_b64 = d.get("mask", "")
|
||||
hair_id = d.get("hair_id", "")
|
||||
if not img_b64 or not mask_b64:
|
||||
return jsonify({"state": -1, "msg": "img 和 mask 不能为空(请先手绘重绘区域)"}), 400
|
||||
if not hair_id:
|
||||
return jsonify({"state": -1, "msg": "hair_id 不能为空"}), 400
|
||||
|
||||
img = _b64_to_ndarray(img_b64, color=True)
|
||||
hand_mask = _b64_to_ndarray(mask_b64, color=False)
|
||||
if img is None:
|
||||
return jsonify({"state": -1, "msg": "img 解析失败"}), 400
|
||||
if hand_mask is None:
|
||||
return jsonify({"state": -1, "msg": "mask 解析失败"}), 400
|
||||
|
||||
def g(k, default): return d.get(k, default)
|
||||
dk = g("dilate_kernel", [6, 18])
|
||||
bd = g("blend_dilate", [5, 5])
|
||||
sd = g("seamless_dilate", [9, 9])
|
||||
|
||||
print(f"[swap_manual] hair_id={hair_id}, img={img.shape}, mask={hand_mask.shape}, "
|
||||
f"strict_mask={g('strict_mask',False)}, denoising={g('denoising_strength',0.6)}")
|
||||
|
||||
hairstyle_process, landmark_processor = _get_models()
|
||||
from hair_swap_manual import hair_swap_manual
|
||||
task_id = f"manual_{int(time.time()*1000)}"
|
||||
result, steps, params = hair_swap_manual(
|
||||
origin_img=img, hand_mask=hand_mask, hair_id=hair_id,
|
||||
hairstyle_process=hairstyle_process,
|
||||
landmark_processor=landmark_processor,
|
||||
task_id=task_id,
|
||||
strict_mask=bool(g("strict_mask", False)),
|
||||
seamless_blend=bool(g("seamless_blend", True)),
|
||||
is_hr=bool(g("is_hr", True)),
|
||||
dilate_kernel=(int(dk[0]), int(dk[1])),
|
||||
denoising_strength=float(g("denoising_strength", 0.6)),
|
||||
blend_dilate=(int(bd[0]), int(bd[1])),
|
||||
seamless_dilate=(int(sd[0]), int(sd[1])),
|
||||
feather_px=int(g("feather_px", 0)),
|
||||
enhance=bool(g("enhance", False)),
|
||||
enhance_denoising=float(g("enhance_denoising", 0.35)),
|
||||
)
|
||||
|
||||
_, buf = cv2.imencode(".jpg", result, [cv2.IMWRITE_JPEG_QUALITY, 95])
|
||||
result_b64 = base64.b64encode(buf).decode("utf-8")
|
||||
print(f"[swap_manual] 完成,总耗时 {time.time()-t0:.1f}s")
|
||||
return jsonify({"state": 0, "result": result_b64, "steps": steps, "params": params})
|
||||
|
||||
except Exception as e:
|
||||
print(f"[swap_manual] 失败: {e}")
|
||||
traceback.print_exc()
|
||||
return jsonify({"state": -1, "msg": f"手绘mask换发型失败: {e}"}), 500
|
||||
|
||||
|
||||
def _b64_to_ndarray(b64_str, color=True):
|
||||
if "," in b64_str and b64_str.startswith("data:"):
|
||||
b64_str = b64_str.split(",", 1)[1]
|
||||
data = base64.b64decode(b64_str)
|
||||
flag = cv2.IMREAD_COLOR if color else cv2.IMREAD_GRAYSCALE
|
||||
return cv2.imdecode(np.frombuffer(data, np.uint8), flag)
|
||||
|
||||
|
||||
@app.route("/api/hairstyles")
|
||||
def api_hairstyles():
|
||||
"""返回可用发型列表(含性别,用于前端下拉框分组)"""
|
||||
try:
|
||||
from common.logger import config
|
||||
hairstyle_dir = config.get('default', 'hairstyleDir')
|
||||
train_dir = config.get('default', 'train_dir')
|
||||
upload_dir = config.get('default', 'upload_train_dir')
|
||||
from hair_grow_swap import list_hairstyles
|
||||
styles = list_hairstyles(hairstyle_dir, train_dir, upload_dir)
|
||||
boy = [s for s in styles if s["gender"] == "boy"]
|
||||
girl = [s for s in styles if s["gender"] == "girl"]
|
||||
print(f"[hairstyles] 共 {len(styles)} 个可用发型 (boy={len(boy)}, girl={len(girl)})")
|
||||
return jsonify({"state": 0, "data": styles, "count": len(styles)})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"state": -1, "msg": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/preview/<hair_id>")
|
||||
def preview_img(hair_id):
|
||||
"""直接返回预览图文件(比 base64 API 快,浏览器可缓存)"""
|
||||
effect_path = os.path.join(BASE_DIR, "static", "previews", f"{hair_id}.jpg")
|
||||
if os.path.exists(effect_path):
|
||||
return send_from_directory(os.path.join(BASE_DIR, "static", "previews"), f"{hair_id}.jpg")
|
||||
# 回退到 ref_rgb
|
||||
try:
|
||||
from common.logger import config
|
||||
hairstyle_dir = config.get('default', 'hairstyleDir')
|
||||
fallback = os.path.join(hairstyle_dir, hair_id, "ref_rgb_8uc3_768.png")
|
||||
if os.path.exists(fallback):
|
||||
return send_file_or_404(fallback)
|
||||
except Exception:
|
||||
pass
|
||||
return ("", 404)
|
||||
|
||||
|
||||
@app.route("/train_src/<hair_id>")
|
||||
def train_src_img(hair_id):
|
||||
"""返回发型的训练原图(hair_type_images/<hair_id>.jpg/.png)。
|
||||
用于测试页展示发型真实样子,而非套在标准脸上的效果图。
|
||||
"""
|
||||
src_dir = "/home/xsl/change_hair/hair_type_images"
|
||||
for ext in (".jpg", ".jpeg", ".png"):
|
||||
path = os.path.join(src_dir, hair_id + ext)
|
||||
if os.path.exists(path):
|
||||
return send_from_directory(src_dir, hair_id + ext)
|
||||
return ("", 404)
|
||||
|
||||
|
||||
@app.route("/api/hairstyle_preview/<hair_id>")
|
||||
def api_hairstyle_preview(hair_id):
|
||||
"""返回某发型的预览图。
|
||||
优先用生成的效果图 static/previews/<hair_id>.jpg(发型套在标准脸上的样子),
|
||||
没有则回退到 first## 原始上传图。
|
||||
"""
|
||||
try:
|
||||
# 1. 优先:生成的效果图(发型套在 boy/girl 标准脸上)
|
||||
preview = None
|
||||
effect_path = os.path.join(BASE_DIR, "static", "previews", f"{hair_id}.jpg")
|
||||
if os.path.exists(effect_path):
|
||||
preview = effect_path
|
||||
else:
|
||||
# 2. 回退:first## 原始上传图
|
||||
from common.logger import config
|
||||
upload_dir = config.get('default', 'upload_train_dir')
|
||||
save_dir = os.path.join(upload_dir, hair_id)
|
||||
if os.path.isdir(save_dir):
|
||||
for name in os.listdir(save_dir):
|
||||
if "first##" in name:
|
||||
preview = os.path.join(save_dir, name)
|
||||
break
|
||||
# 3. 再回退:ref_rgb
|
||||
if not preview or not os.path.exists(preview):
|
||||
hairstyle_dir = config.get('default', 'hairstyleDir')
|
||||
fallback = os.path.join(hairstyle_dir, hair_id, "ref_rgb_8uc3_768.png")
|
||||
if os.path.exists(fallback):
|
||||
preview = fallback
|
||||
if not preview:
|
||||
return jsonify({"state": -1, "msg": "无预览图"}), 404
|
||||
img = cv2.imread(preview)
|
||||
img = cv2.resize(img, (256, 256), interpolation=cv2.INTER_AREA)
|
||||
_, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
||||
b64 = base64.b64encode(buf).decode()
|
||||
return jsonify({"state": 0, "preview": b64})
|
||||
except Exception as e:
|
||||
return jsonify({"state": -1, "msg": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/grow", methods=["POST"])
|
||||
def api_grow():
|
||||
"""生发接口(走换发型工作流)
|
||||
|
||||
入参 JSON:
|
||||
img: 原图 base64
|
||||
mask: 手绘遮罩 base64(白=生发区,与img同分辨率)
|
||||
hair_id: 选择的发型ID(必填)
|
||||
is_hr: 是否高清 "true"/"false",默认 "true"
|
||||
"""
|
||||
t0 = time.time()
|
||||
try:
|
||||
data = request.json
|
||||
img_b64 = data.get("img", "")
|
||||
mask_b64 = data.get("mask", "")
|
||||
hair_id = data.get("hair_id", "")
|
||||
is_hr = str(data.get("is_hr", "true")).lower() == "true"
|
||||
|
||||
if not img_b64 or not mask_b64:
|
||||
return jsonify({"state": -1, "msg": "img 和 mask 不能为空"}), 400
|
||||
if not hair_id:
|
||||
return jsonify({"state": -1, "msg": "hair_id 不能为空(请先选择发型)"}), 400
|
||||
|
||||
img = _b64_to_ndarray(img_b64, color=True)
|
||||
mask = _b64_to_ndarray(mask_b64, color=False)
|
||||
if img is None:
|
||||
return jsonify({"state": -1, "msg": "img 解析失败"}), 400
|
||||
if mask is None:
|
||||
return jsonify({"state": -1, "msg": "mask 解析失败"}), 400
|
||||
|
||||
print(f"[grow] img={img.shape}, mask={mask.shape}, hair_id={hair_id}, is_hr={is_hr}")
|
||||
|
||||
# 加载模型(首次慢)
|
||||
hairstyle_process, landmark_processor = _get_models()
|
||||
|
||||
# 调用生发(走换发型工作流)
|
||||
from hair_grow_swap import hair_grow_swap
|
||||
task_id = f"grow_{int(time.time()*1000)}"
|
||||
result = hair_grow_swap(
|
||||
origin_img=img, hand_mask=mask, hair_id=hair_id,
|
||||
hairstyle_process=hairstyle_process,
|
||||
landmark_processor=landmark_processor,
|
||||
task_id=task_id, is_hr=is_hr)
|
||||
|
||||
_, buf = cv2.imencode(".jpg", result, [cv2.IMWRITE_JPEG_QUALITY, 95])
|
||||
result_b64 = base64.b64encode(buf).decode("utf-8")
|
||||
print(f"[grow] 完成,总耗时 {time.time()-t0:.1f}s")
|
||||
return jsonify({"state": 0, "result": result_b64})
|
||||
|
||||
except Exception as e:
|
||||
print(f"[grow] 失败: {e}")
|
||||
traceback.print_exc()
|
||||
return jsonify({"state": -1, "msg": f"生发失败: {e}"}), 500
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"生发服务启动,端口 {PORT}")
|
||||
print(f"测试页面: http://0.0.0.0:{PORT}")
|
||||
print(f"注意:首次请求会加载换发型模型(约30-60秒)")
|
||||
server = pywsgi.WSGIServer(("0.0.0.0", PORT), app)
|
||||
server.serve_forever()
|
||||
@@ -0,0 +1,379 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>生发调试台</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; background: #1a1a2e; color: #eee; min-height: 100vh; font-size: 14px; }
|
||||
.header { background: #16213e; padding: 16px 28px; border-bottom: 1px solid #0f3460; display: flex; justify-content: space-between; align-items: center; }
|
||||
.header h1 { font-size: 19px; font-weight: 600; }
|
||||
.header p { font-size: 12px; color: #888; margin-top: 3px; }
|
||||
.header .links a { color: #4ecca3; font-size: 12px; text-decoration: none; margin-left: 12px; }
|
||||
.container { display: flex; gap: 18px; padding: 18px; max-width: 1800px; margin: 0 auto; }
|
||||
|
||||
/* 左侧参数面板 */
|
||||
.panel { width: 340px; flex-shrink: 0; background: #16213e; border-radius: 10px; padding: 16px; height: fit-content; max-height: 92vh; overflow-y: auto; }
|
||||
.panel::-webkit-scrollbar { width: 6px; }
|
||||
.panel::-webkit-scrollbar-thumb { background: #0f3460; border-radius: 3px; }
|
||||
.panel h3 { font-size: 13px; color: #4ecca3; margin: 14px 0 8px; text-transform: uppercase; letter-spacing: 1px; padding-bottom: 5px; border-bottom: 1px solid #0f3460; }
|
||||
.panel h3:first-child { margin-top: 0; }
|
||||
.field { margin-bottom: 12px; }
|
||||
.field-label { display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: #bbb; margin-bottom: 5px; }
|
||||
.field-label .val { color: #4ecca3; font-family: monospace; font-size: 11px; }
|
||||
.field-label .tip { color: #666; font-size: 10px; margin-left: 4px; cursor: help; }
|
||||
.field input[type="range"] { width: 100%; height: 4px; -webkit-appearance: none; background: #0f3460; border-radius: 2px; outline: none; }
|
||||
.field input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: #4ecca3; cursor: pointer; }
|
||||
.field input[type="number"] { width: 100%; padding: 5px 8px; background: #0d1b3e; border: 1px solid #0f3460; border-radius: 4px; color: #eee; font-size: 12px; }
|
||||
.field select { width: 100%; padding: 5px 8px; background: #0d1b3e; border: 1px solid #0f3460; border-radius: 4px; color: #eee; font-size: 12px; }
|
||||
.switch { display: flex; align-items: center; justify-content: space-between; padding: 7px 0; font-size: 13px; color: #ccc; cursor: pointer; }
|
||||
.switch:hover { color: #eee; }
|
||||
.toggle { position: relative; width: 36px; height: 20px; background: #0f3460; border-radius: 10px; transition: .2s; flex-shrink: 0; }
|
||||
.toggle::after { content: ''; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; background: #888; border-radius: 50%; transition: .2s; }
|
||||
.switch.on .toggle { background: #4ecca3; }
|
||||
.switch.on .toggle::after { left: 18px; background: #16213e; }
|
||||
.pair { display: flex; gap: 8px; }
|
||||
.pair .field { flex: 1; }
|
||||
.hint { font-size: 11px; color: #666; line-height: 1.5; margin-top: 8px; padding: 8px 10px; background: #0d1b3e; border-radius: 5px; }
|
||||
.hint b { color: #aaa; }
|
||||
.readonly-info { font-size: 11px; color: #777; padding: 8px 10px; background: #0d1b3e; border-radius: 5px; line-height: 1.6; }
|
||||
.readonly-info code { color: #aaa; background: #16213e; padding: 1px 5px; border-radius: 3px; }
|
||||
.btn { display: block; width: 100%; padding: 10px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; transition: .15s; }
|
||||
.btn-primary { background: #4ecca3; color: #16213e; font-weight: 600; font-size: 15px; padding: 12px; margin-top: 8px; }
|
||||
.btn-primary:hover { background: #6ee0bd; }
|
||||
.btn-primary:disabled { background: #555; color: #999; cursor: not-allowed; }
|
||||
.btn-ghost { background: #0f3460; color: #eee; margin-top: 6px; }
|
||||
.btn-ghost:hover { background: #1a4a80; }
|
||||
|
||||
/* 中间发型选择 */
|
||||
.mid-panel { width: 200px; flex-shrink: 0; background: #16213e; border-radius: 10px; padding: 14px; height: fit-content; max-height: 92vh; overflow-y: auto; }
|
||||
.mid-panel h3 { font-size: 12px; color: #4ecca3; margin-bottom: 10px; }
|
||||
.hairstyle-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
|
||||
.hairstyle-item { position: relative; cursor: pointer; border-radius: 5px; overflow: hidden; border: 2px solid transparent; aspect-ratio: 1; background: #0d1b3e; }
|
||||
.hairstyle-item img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.hairstyle-item.ready { border-color: #2a5a4a; }
|
||||
.hairstyle-item.selected { border-color: #4ecca3; }
|
||||
.hairstyle-item.selected::after { content: '✓'; position: absolute; top: 1px; right: 3px; color: #4ecca3; font-weight: bold; text-shadow: 0 0 3px #000; font-size: 11px; }
|
||||
.hairstyle-item .name { position: absolute; bottom: 0; left: 0; right: 0; background: linear-gradient(transparent, rgba(0,0,0,.85)); color: #fff; font-size: 10px; padding: 8px 2px 2px; text-align: center; }
|
||||
.hairstyle-item.pending { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
/* 右侧工作区 */
|
||||
.workspace { flex: 1; min-width: 0; }
|
||||
.canvas-wrap { background: #0d0d1a; border-radius: 10px; padding: 18px; text-align: center; min-height: 280px; display: flex; align-items: center; justify-content: center; }
|
||||
.upload-zone { width: 100%; max-width: 460px; border: 2px dashed #0f3460; border-radius: 10px; padding: 40px 20px; text-align: center; cursor: pointer; transition: .2s; }
|
||||
.upload-zone:hover { border-color: #4ecca3; background: rgba(78,204,163,.05); }
|
||||
.upload-zone svg { width: 42px; height: 42px; fill: #4ecca3; margin-bottom: 10px; }
|
||||
.upload-zone p { color: #888; font-size: 13px; }
|
||||
.upload-zone p.highlight { color: #4ecca3; }
|
||||
#preview-img { display: none; max-width: 100%; max-height: 460px; border-radius: 6px; }
|
||||
#status { text-align: center; padding: 24px; color: #4ecca3; display: none; }
|
||||
.spinner { display: inline-block; width: 18px; height: 18px; border: 3px solid #0f3460; border-top-color: #4ecca3; border-radius: 50%; animation: spin .8s linear infinite; margin-right: 8px; vertical-align: middle; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
#error-msg { color: #e74c3c; padding: 16px; display: none; font-size: 13px; background: rgba(231,76,60,.1); border-radius: 6px; margin-top: 12px; }
|
||||
|
||||
/* 步骤画廊 */
|
||||
.result-top { margin-top: 16px; background: #16213e; border-radius: 10px; padding: 16px; display: none; }
|
||||
.result-top.show { display: block; }
|
||||
.result-top h3 { font-size: 14px; color: #4ecca3; margin-bottom: 12px; }
|
||||
.result-pair { display: flex; gap: 16px; flex-wrap: wrap; }
|
||||
.result-card { flex: 1; min-width: 240px; }
|
||||
.result-card h4 { font-size: 12px; color: #aaa; margin-bottom: 6px; text-align: center; }
|
||||
.result-card img { width: 100%; border-radius: 6px; display: block; }
|
||||
|
||||
.params-used { margin-top: 12px; padding: 10px 12px; background: #0d1b3e; border-radius: 6px; font-size: 11px; color: #888; line-height: 1.7; font-family: monospace; }
|
||||
|
||||
.steps-section { margin-top: 16px; }
|
||||
.step-item { background: #16213e; border-radius: 10px; padding: 16px; margin-bottom: 14px; }
|
||||
.step-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 4px; }
|
||||
.step-title { font-size: 15px; font-weight: 600; color: #4ecca3; }
|
||||
.step-time { font-size: 11px; color: #666; font-family: monospace; }
|
||||
.step-desc { font-size: 12px; color: #999; margin-bottom: 12px; line-height: 1.6; }
|
||||
.step-images { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.step-img-card { flex: 1; min-width: 180px; max-width: 280px; }
|
||||
.step-img-card .lbl { font-size: 11px; color: #888; margin-bottom: 4px; text-align: center; }
|
||||
.step-img-card img { width: 100%; border-radius: 6px; display: block; border: 1px solid #0f3460; cursor: zoom-in; transition: .15s; }
|
||||
.step-img-card img:hover { border-color: #4ecca3; }
|
||||
#lightbox { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.92); z-index: 999; justify-content: center; align-items: center; cursor: zoom-out; }
|
||||
#lightbox img { max-width: 92%; max-height: 92%; border-radius: 8px; }
|
||||
#lightbox.show { display: flex; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>🔬 生发调试台(逐步可视化 + 全参数)</h1>
|
||||
<p>调参数 → 看每一步中间产物如何变化。绿色框=可编辑参数,灰色=webui端固定</p>
|
||||
</div>
|
||||
<div class="links">
|
||||
<a href="/test_new">→ 简易测试页</a>
|
||||
<a href="/swap">→ 全部发型页</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<!-- 左侧:参数面板 -->
|
||||
<div class="panel">
|
||||
<h3>① 流程开关</h3>
|
||||
<div class="switch on" id="sw-cut_bang" onclick="toggleSwitch('cut_bang', this)">
|
||||
<span>减刘海圆 <span class="tip" title="从原头发mask中扣除眼睛周围的圆形区域,避免重绘眼睛">ⓘ</span></span>
|
||||
<div class="toggle"></div>
|
||||
</div>
|
||||
<div class="switch" id="sw-strict_mask" onclick="toggleSwitch('strict_mask', this)">
|
||||
<span>严格mask贴回 <span class="tip" title="只在mask区域覆盖,mask外保留原图(否则整个裁剪框覆盖)">ⓘ</span></span>
|
||||
<div class="toggle"></div>
|
||||
</div>
|
||||
<div class="switch on" id="sw-seamless_blend" onclick="toggleSwitch('seamless_blend', this)">
|
||||
<span>泊松融合消接缝 <span class="tip" title="严格mask模式下用seamlessClone消除边缘色差(仅strict_mask开启时生效)">ⓘ</span></span>
|
||||
<div class="toggle"></div>
|
||||
</div>
|
||||
|
||||
<h3>② 尺寸 / 对齐</h3>
|
||||
<div class="switch on" id="sw-is_hr" onclick="toggleSwitch('is_hr', this)">
|
||||
<span>高清模式 <span class="tip" title="1152x1536 vs 576x768,影响显存和速度">ⓘ</span></span>
|
||||
<div class="toggle"></div>
|
||||
</div>
|
||||
<div class="pair">
|
||||
<div class="field">
|
||||
<div class="field-label">dilate_kernel <span class="val" id="v-dk0">6</span></div>
|
||||
<input type="number" id="dk0" value="6" min="1" max="40" oninput="syncNum('dk0')">
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field-label">mask膨胀核(x,y) <span class="val" id="v-dk1">18</span></div>
|
||||
<input type="number" id="dk1" value="18" min="1" max="40" oninput="syncNum('dk1')">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>③ SD 推理参数</h3>
|
||||
<div class="field">
|
||||
<div class="field-label">denoising_strength <span class="val" id="v-den">0.60</span><span class="tip" title="重绘强度。越高越偏离原图,发丝细节越多但可能失真">ⓘ</span></div>
|
||||
<input type="range" id="den" min="0.1" max="1.0" step="0.05" value="0.6" oninput="syncRange('den',2)">
|
||||
</div>
|
||||
<div class="readonly-info">
|
||||
<b style="color:#888">webui端固定(不可调):</b><br>
|
||||
<code>cfg_scale=7</code> <code>steps=20</code><br>
|
||||
<code>sampler=DPM++ 2M Karras</code><br>
|
||||
<code>mask_blur=11</code> <code>seed=123456789</code>
|
||||
</div>
|
||||
|
||||
<h3>④ 贴回 / 融合</h3>
|
||||
<div class="pair">
|
||||
<div class="field">
|
||||
<div class="field-label">blend_dilate <span class="val" id="v-bd0">5</span></div>
|
||||
<input type="number" id="bd0" value="5" min="1" max="40" oninput="syncNum('bd0')">
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field-label">(strict贴回) <span class="val" id="v-bd1">5</span></div>
|
||||
<input type="number" id="bd1" value="5" min="1" max="40" oninput="syncNum('bd1')">
|
||||
</div>
|
||||
</div>
|
||||
<div class="pair">
|
||||
<div class="field">
|
||||
<div class="field-label">seamless_dilate <span class="val" id="v-sd0">9</span></div>
|
||||
<input type="number" id="sd0" value="9" min="1" max="40" oninput="syncNum('sd0')">
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field-label">(泊松融合) <span class="val" id="v-sd1">9</span></div>
|
||||
<input type="number" id="sd1" value="9" min="1" max="40" oninput="syncNum('sd1')">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" id="btn-run" disabled>▶ 执行生发</button>
|
||||
<button class="btn btn-ghost" id="btn-reset" onclick="resetParams()">↺ 恢复默认参数</button>
|
||||
<div class="hint">
|
||||
<b>使用说明</b><br>
|
||||
1. 选发型(中间)→ 上传人像(右侧)<br>
|
||||
2. 调整左侧参数<br>
|
||||
3. 点"执行",看7步中间产物<br>
|
||||
4. 改参数重跑,对比效果差异
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间:发型选择 -->
|
||||
<div class="mid-panel">
|
||||
<h3>选择发型</h3>
|
||||
<div class="hairstyle-grid" id="hairstyle-grid"><div style="color:#666;font-size:11px">加载中...</div></div>
|
||||
<div style="font-size:11px;color:#4ecca3;margin-top:8px;text-align:center" id="sel-info">未选择</div>
|
||||
<button class="btn btn-ghost" style="margin-top:8px;font-size:12px" onclick="loadStatus()">↻ 刷新</button>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:工作区 -->
|
||||
<div class="workspace">
|
||||
<div class="canvas-wrap">
|
||||
<div class="upload-zone" id="upload-zone">
|
||||
<svg viewBox="0 0 24 24"><path d="M19 13v6H5v-6H3v8h18v-8zM6 9l1.41 1.41L11 6.83V18h2V6.83l3.59 3.58L18 9l-6-6z"/></svg>
|
||||
<p class="highlight">点击上传人像</p>
|
||||
<p>正面清晰照效果最佳</p>
|
||||
</div>
|
||||
<input type="file" id="file-input" accept="image/*" style="display:none">
|
||||
<img id="preview-img">
|
||||
</div>
|
||||
<div id="status"><span class="spinner"></span><span id="status-text">执行中,约40-90秒...</span></div>
|
||||
<div id="error-msg"></div>
|
||||
|
||||
<div class="result-top" id="result-top">
|
||||
<h3>最终对比</h3>
|
||||
<div class="result-pair">
|
||||
<div class="result-card"><h4>原图</h4><img id="r-orig"></div>
|
||||
<div class="result-card"><h4>生发结果</h4><img id="r-new"></div>
|
||||
</div>
|
||||
<div class="params-used" id="params-used"></div>
|
||||
</div>
|
||||
|
||||
<div class="steps-section" id="steps-section"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="lightbox" onclick="this.classList.remove('show')"><img id="lightbox-img"></div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
const $=id=>document.getElementById(id);
|
||||
const HAIRSTYLES = [
|
||||
{face:"圆",name:"圆-心形"},{face:"圆",name:"圆-椭圆"},{face:"圆",name:"圆-波浪"},{face:"圆",name:"圆-直线"},{face:"圆",name:"圆-花瓣"},
|
||||
{face:"心形",name:"心形-心形"},{face:"心形",name:"心形-椭圆"},{face:"心形",name:"心形-波浪"},{face:"心形",name:"心形-直线"},{face:"心形",name:"心形-花瓣"},
|
||||
{face:"方脸",name:"方脸-心形"},{face:"方脸",name:"方脸-椭圆"},{face:"方脸",name:"方脸-波浪"},{face:"方脸",name:"方脸-直线"},{face:"方脸",name:"方脸-花瓣"},
|
||||
{face:"椭圆",name:"椭圆-心形"},{face:"椭圆",name:"椭圆-椭圆"},{face:"椭圆",name:"椭圆-波浪"},{face:"椭圆",name:"椭圆-直线"},{face:"椭圆",name:"椭圆-花瓣"},
|
||||
{face:"菱形",name:"菱形-心"},{face:"菱形",name:"菱形-椭圆"},{face:"菱形",name:"菱形-波浪"},{face:"菱形",name:"菱形-直线"},{face:"菱形",name:"菱形-花瓣"},
|
||||
{face:"长",name:"长-心形"},{face:"长",name:"长 -椭圆"},{face:"长",name:"长 -波浪"},{face:"长",name:"长 -直线"},{face:"长",name:"长 -花瓣"}
|
||||
];
|
||||
const defaults = {cut_bang:true,strict_mask:false,seamless_blend:true,is_hr:true,
|
||||
dk0:6,dk1:18,den:0.6,bd0:5,bd1:5,sd0:9,sd1:9};
|
||||
|
||||
let readySet=new Set(), selectedHair=null, imgB64=null;
|
||||
|
||||
async function loadStatus(){
|
||||
try{
|
||||
const r=await fetch("/api/hairstyles");const d=await r.json();
|
||||
if(d.state!==0)throw new Error(d.msg);
|
||||
readySet=new Set(d.data.map(x=>x.hair_id));
|
||||
}catch(e){readySet=new Set();}
|
||||
renderGrid();
|
||||
}
|
||||
function renderGrid(){
|
||||
const g=$("hairstyle-grid");g.innerHTML="";
|
||||
HAIRSTYLES.forEach(h=>{
|
||||
const ready=readySet.has(h.name);
|
||||
const it=document.createElement("div");
|
||||
it.className="hairstyle-item"+(ready?" ready":" pending")+(h.name===selectedHair?" selected":"");
|
||||
it.innerHTML=`<img loading="lazy" src="/train_src/${encodeURIComponent(h.name)}" onerror="this.style.objectFit='contain';this.src='data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22100%22 height=%22100%22><text x=%2250%22 y=%2255%22 text-anchor=%22middle%22 fill=%22%23666%22 font-size=%2210%22>训练中</text></svg>'"><div class="name">${h.name}</div>`;
|
||||
if(ready) it.onclick=()=>{
|
||||
selectedHair=h.name;
|
||||
$("sel-info").textContent="已选: "+h.name;
|
||||
renderGrid(); updateBtn();
|
||||
};
|
||||
g.appendChild(it);
|
||||
});
|
||||
}
|
||||
|
||||
// 参数控件
|
||||
window.toggleSwitch=(key,el)=>{
|
||||
el.classList.toggle("on");
|
||||
updateBtn();
|
||||
};
|
||||
// range 滑块绑定显示
|
||||
["den"].forEach(id=>{
|
||||
$(id).addEventListener("input",e=>$("v-"+id).textContent=parseFloat(e.target.value).toFixed(2));
|
||||
});
|
||||
window.syncNum=(id)=>{ $("v-"+id).textContent=$(id).value; };
|
||||
window.resetParams=()=>{
|
||||
Object.keys(defaults).forEach(k=>{
|
||||
if(typeof defaults[k]==="boolean"){
|
||||
const el=$("sw-"+k); el.classList.toggle("on",defaults[k]);
|
||||
}else if(k==="den"){ $("den").value=defaults[k]; $("v-den").textContent=defaults[k].toFixed(2);
|
||||
}else{ $(k).value=defaults[k]; $("v-"+k).textContent=defaults[k]; }
|
||||
});
|
||||
};
|
||||
|
||||
function collectParams(){
|
||||
return {
|
||||
cut_bang:$("sw-cut_bang").classList.contains("on"),
|
||||
strict_mask:$("sw-strict_mask").classList.contains("on"),
|
||||
seamless_blend:$("sw-seamless_blend").classList.contains("on"),
|
||||
is_hr:$("sw-is_hr").classList.contains("on"),
|
||||
dilate_kernel:[parseInt($("dk0").value),parseInt($("dk1").value)],
|
||||
denoising_strength:parseFloat($("den").value),
|
||||
blend_dilate:[parseInt($("bd0").value),parseInt($("bd1").value)],
|
||||
seamless_dilate:[parseInt($("sd0").value),parseInt($("sd1").value)],
|
||||
};
|
||||
}
|
||||
|
||||
// 上传
|
||||
$("upload-zone").onclick=()=>$("file-input").click();
|
||||
$("file-input").onchange=e=>{ if(e.target.files[0]) loadImg(e.target.files[0]); };
|
||||
$("upload-zone").ondragover=e=>{e.preventDefault();$("upload-zone").style.borderColor="#4ecca3";};
|
||||
$("upload-zone").ondragleave=()=>$("upload-zone").style.borderColor="#0f3460";
|
||||
$("upload-zone").ondrop=e=>{e.preventDefault();if(e.dataTransfer.files[0])loadImg(e.dataTransfer.files[0]);};
|
||||
function loadImg(file){
|
||||
if(!file.type.startsWith("image/")){alert("请上传图片");return;}
|
||||
const rd=new FileReader();
|
||||
rd.onload=e=>{
|
||||
imgB64=e.target.result;
|
||||
$("preview-img").src=imgB64;
|
||||
$("preview-img").style.display="block";
|
||||
$("upload-zone").style.display="none";
|
||||
updateBtn();
|
||||
};
|
||||
rd.readAsDataURL(file);
|
||||
}
|
||||
function updateBtn(){ $("btn-run").disabled=!(selectedHair&&imgB64); }
|
||||
|
||||
// 执行
|
||||
$("btn-run").onclick=async()=>{
|
||||
if(!selectedHair||!imgB64) return;
|
||||
const p=collectParams();
|
||||
$("btn-run").disabled=true;
|
||||
$("status").style.display="block";
|
||||
$("status-text").textContent="执行中(粗推理→mask→warpAffine→SD→贴回),约40-90秒...";
|
||||
$("error-msg").style.display="none";
|
||||
$("result-top").classList.remove("show");
|
||||
$("steps-section").innerHTML="";
|
||||
try{
|
||||
const resp=await fetch("/api/swap_debug",{
|
||||
method:"POST",headers:{"Content-Type":"application/json"},
|
||||
body:JSON.stringify(Object.assign({img:imgB64,hair_id:selectedHair},p))
|
||||
});
|
||||
const data=await resp.json();
|
||||
$("status").style.display="none";
|
||||
if(data.state===0){
|
||||
const url="data:image/jpeg;base64,"+data.result;
|
||||
$("r-orig").src=imgB64;$("r-new").src=url;
|
||||
// 参数回显
|
||||
let ph=""; Object.keys(data.params).forEach(k=>{
|
||||
let v=data.params[k]; if(Array.isArray(v))v="["+v.join(",")+"]";
|
||||
if(typeof v==="boolean")v=v?"开":"关";
|
||||
ph+=k+" = "+v+" ";
|
||||
});
|
||||
$("params-used").textContent="本次参数: "+ph;
|
||||
$("result-top").classList.add("show");
|
||||
// 步骤画廊
|
||||
const sc=$("steps-section");
|
||||
data.steps.forEach(step=>{
|
||||
const div=document.createElement("div");div.className="step-item";
|
||||
let imgs='<div class="step-images">';
|
||||
step.images.forEach(im=>{imgs+='<div class="step-img-card"><div class="lbl">'+im.label+'</div><img src="data:image/jpeg;base64,'+im.b64+'" data-full="data:image/jpeg;base64,'+im.b64+'"></div>';});
|
||||
imgs+='</div>';
|
||||
div.innerHTML='<div class="step-head"><div class="step-title">'+step.title+'</div></div><div class="step-desc">'+step.desc+'</div>'+imgs;
|
||||
sc.appendChild(div);
|
||||
});
|
||||
sc.querySelectorAll("img").forEach(img=>img.onclick=()=>showLightbox(img.dataset.full));
|
||||
$("result-top").scrollIntoView({behavior:"smooth"});
|
||||
}else{
|
||||
$("error-msg").textContent="❌ "+(data.msg||"失败");
|
||||
$("error-msg").style.display="block";
|
||||
}
|
||||
}catch(err){
|
||||
$("status").style.display="none";
|
||||
$("error-msg").textContent="❌ 请求失败: "+err.message;
|
||||
$("error-msg").style.display="block";
|
||||
}
|
||||
updateBtn();
|
||||
};
|
||||
|
||||
function showLightbox(src){$("lightbox-img").src=src;$("lightbox").classList.add("show");}
|
||||
|
||||
loadStatus();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,434 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>发际线带重绘实验</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; background: #1a1a2e; color: #eee; min-height: 100vh; font-size: 14px; }
|
||||
.header { background: #16213e; padding: 16px 28px; border-bottom: 1px solid #0f3460; display: flex; justify-content: space-between; align-items: center; }
|
||||
.header h1 { font-size: 19px; font-weight: 600; }
|
||||
.header p { font-size: 12px; color: #888; margin-top: 3px; }
|
||||
.header .links a { color: #4ecca3; font-size: 12px; text-decoration: none; margin-left: 12px; }
|
||||
.container { display: flex; gap: 18px; padding: 18px; max-width: 1800px; margin: 0 auto; }
|
||||
|
||||
/* 左侧参数面板 */
|
||||
.panel { width: 340px; flex-shrink: 0; background: #16213e; border-radius: 10px; padding: 16px; height: fit-content; max-height: 92vh; overflow-y: auto; }
|
||||
.panel::-webkit-scrollbar { width: 6px; }
|
||||
.panel::-webkit-scrollbar-thumb { background: #0f3460; border-radius: 3px; }
|
||||
.panel h3 { font-size: 13px; color: #4ecca3; margin: 14px 0 8px; text-transform: uppercase; letter-spacing: 1px; padding-bottom: 5px; border-bottom: 1px solid #0f3460; }
|
||||
.panel h3:first-child { margin-top: 0; }
|
||||
.field { margin-bottom: 12px; }
|
||||
.field-label { display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: #bbb; margin-bottom: 5px; }
|
||||
.field-label .val { color: #4ecca3; font-family: monospace; font-size: 11px; }
|
||||
.field-label .tip { color: #666; font-size: 10px; margin-left: 4px; cursor: help; }
|
||||
.field input[type="range"] { width: 100%; height: 4px; -webkit-appearance: none; background: #0f3460; border-radius: 2px; outline: none; }
|
||||
.field input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: #4ecca3; cursor: pointer; }
|
||||
.field input[type="number"] { width: 100%; padding: 5px 8px; background: #0d1b3e; border: 1px solid #0f3460; border-radius: 4px; color: #eee; font-size: 12px; }
|
||||
.field select { width: 100%; padding: 5px 8px; background: #0d1b3e; border: 1px solid #0f3460; border-radius: 4px; color: #eee; font-size: 12px; }
|
||||
.switch { display: flex; align-items: center; justify-content: space-between; padding: 7px 0; font-size: 13px; color: #ccc; cursor: pointer; }
|
||||
.switch:hover { color: #eee; }
|
||||
.toggle { position: relative; width: 36px; height: 20px; background: #0f3460; border-radius: 10px; transition: .2s; flex-shrink: 0; }
|
||||
.toggle::after { content: ''; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; background: #888; border-radius: 50%; transition: .2s; }
|
||||
.switch.on .toggle { background: #4ecca3; }
|
||||
.switch.on .toggle::after { left: 18px; background: #16213e; }
|
||||
.pair { display: flex; gap: 8px; }
|
||||
.pair .field { flex: 1; }
|
||||
.hint { font-size: 11px; color: #666; line-height: 1.5; margin-top: 8px; padding: 8px 10px; background: #0d1b3e; border-radius: 5px; }
|
||||
.hint b { color: #aaa; }
|
||||
.readonly-info { font-size: 11px; color: #777; padding: 8px 10px; background: #0d1b3e; border-radius: 5px; line-height: 1.6; }
|
||||
.readonly-info code { color: #aaa; background: #16213e; padding: 1px 5px; border-radius: 3px; }
|
||||
.btn { display: block; width: 100%; padding: 10px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; transition: .15s; }
|
||||
.btn-primary { background: #4ecca3; color: #16213e; font-weight: 600; font-size: 15px; padding: 12px; margin-top: 8px; }
|
||||
.btn-primary:hover { background: #6ee0bd; }
|
||||
.btn-primary:disabled { background: #555; color: #999; cursor: not-allowed; }
|
||||
.btn-ghost { background: #0f3460; color: #eee; margin-top: 6px; }
|
||||
.btn-ghost:hover { background: #1a4a80; }
|
||||
|
||||
/* 中间发型选择 */
|
||||
.mid-panel { width: 200px; flex-shrink: 0; background: #16213e; border-radius: 10px; padding: 14px; height: fit-content; max-height: 92vh; overflow-y: auto; }
|
||||
.mid-panel h3 { font-size: 12px; color: #4ecca3; margin-bottom: 10px; }
|
||||
.hairstyle-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
|
||||
.hairstyle-item { position: relative; cursor: pointer; border-radius: 5px; overflow: hidden; border: 2px solid transparent; aspect-ratio: 1; background: #0d1b3e; }
|
||||
.hairstyle-item img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.hairstyle-item.ready { border-color: #2a5a4a; }
|
||||
.hairstyle-item.selected { border-color: #4ecca3; }
|
||||
.hairstyle-item.selected::after { content: '✓'; position: absolute; top: 1px; right: 3px; color: #4ecca3; font-weight: bold; text-shadow: 0 0 3px #000; font-size: 11px; }
|
||||
.hairstyle-item .name { position: absolute; bottom: 0; left: 0; right: 0; background: linear-gradient(transparent, rgba(0,0,0,.85)); color: #fff; font-size: 10px; padding: 8px 2px 2px; text-align: center; }
|
||||
.hairstyle-item.pending { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
/* 右侧工作区 */
|
||||
.workspace { flex: 1; min-width: 0; }
|
||||
.canvas-wrap { background: #0d0d1a; border-radius: 10px; padding: 18px; text-align: center; min-height: 280px; display: flex; align-items: center; justify-content: center; }
|
||||
.upload-zone { width: 100%; max-width: 460px; border: 2px dashed #0f3460; border-radius: 10px; padding: 40px 20px; text-align: center; cursor: pointer; transition: .2s; }
|
||||
.upload-zone:hover { border-color: #4ecca3; background: rgba(78,204,163,.05); }
|
||||
.upload-zone svg { width: 42px; height: 42px; fill: #4ecca3; margin-bottom: 10px; }
|
||||
.upload-zone p { color: #888; font-size: 13px; }
|
||||
.upload-zone p.highlight { color: #4ecca3; }
|
||||
#preview-img { display: none; max-width: 100%; max-height: 460px; border-radius: 6px; }
|
||||
#status { text-align: center; padding: 24px; color: #4ecca3; display: none; }
|
||||
.spinner { display: inline-block; width: 18px; height: 18px; border: 3px solid #0f3460; border-top-color: #4ecca3; border-radius: 50%; animation: spin .8s linear infinite; margin-right: 8px; vertical-align: middle; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
#error-msg { color: #e74c3c; padding: 16px; display: none; font-size: 13px; background: rgba(231,76,60,.1); border-radius: 6px; margin-top: 12px; }
|
||||
|
||||
/* 步骤画廊 */
|
||||
.result-top { margin-top: 16px; background: #16213e; border-radius: 10px; padding: 16px; display: none; }
|
||||
.result-top.show { display: block; }
|
||||
.result-top h3 { font-size: 14px; color: #4ecca3; margin-bottom: 12px; }
|
||||
.result-pair { display: flex; gap: 16px; flex-wrap: wrap; }
|
||||
.result-card { flex: 1; min-width: 240px; }
|
||||
.result-card h4 { font-size: 12px; color: #aaa; margin-bottom: 6px; text-align: center; }
|
||||
.result-card img { width: 100%; border-radius: 6px; display: block; }
|
||||
|
||||
.params-used { margin-top: 12px; padding: 10px 12px; background: #0d1b3e; border-radius: 6px; font-size: 11px; color: #888; line-height: 1.7; font-family: monospace; }
|
||||
|
||||
.steps-section { margin-top: 16px; }
|
||||
.step-item { background: #16213e; border-radius: 10px; padding: 16px; margin-bottom: 14px; }
|
||||
.step-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 4px; }
|
||||
.step-title { font-size: 15px; font-weight: 600; color: #4ecca3; }
|
||||
.step-time { font-size: 11px; color: #666; font-family: monospace; }
|
||||
.step-desc { font-size: 12px; color: #999; margin-bottom: 12px; line-height: 1.6; }
|
||||
.step-images { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.step-img-card { flex: 1; min-width: 180px; max-width: 280px; }
|
||||
.step-img-card .lbl { font-size: 11px; color: #888; margin-bottom: 4px; text-align: center; }
|
||||
.step-img-card img { width: 100%; border-radius: 6px; display: block; border: 1px solid #0f3460; cursor: zoom-in; transition: .15s; }
|
||||
.step-img-card img:hover { border-color: #4ecca3; }
|
||||
#lightbox { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.92); z-index: 999; justify-content: center; align-items: center; cursor: zoom-out; }
|
||||
#lightbox img { max-width: 92%; max-height: 92%; border-radius: 8px; }
|
||||
#lightbox.show { display: flex; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>🔬 发际线带重绘实验(只重绘发际线,主体保留原图)</h1>
|
||||
<p>实验性:重绘区域只取发际线边界带,观察"只改发际线、保留原发型主体"的效果</p>
|
||||
</div>
|
||||
<div class="links">
|
||||
<a href="/test_new">→ 简易测试页</a>
|
||||
<a href="/swap">→ 全部发型页</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<!-- 左侧:参数面板 -->
|
||||
<div class="panel">
|
||||
<h3>① 流程开关</h3>
|
||||
<div class="switch" id="sw-strict_mask" onclick="toggleSwitch('strict_mask', this)">
|
||||
<span>严格mask贴回 <span class="tip" title="只在mask区域覆盖,mask外保留原图(否则整个裁剪框覆盖)">ⓘ</span></span>
|
||||
<div class="toggle"></div>
|
||||
</div>
|
||||
<div class="switch on" id="sw-seamless_blend" onclick="toggleSwitch('seamless_blend', this)">
|
||||
<span>泊松融合消接缝 <span class="tip" title="严格mask模式下用seamlessClone消除边缘色差(仅strict_mask开启时生效)">ⓘ</span></span>
|
||||
<div class="toggle"></div>
|
||||
</div>
|
||||
|
||||
<h3>② 尺寸 / 对齐</h3>
|
||||
<div class="switch on" id="sw-is_hr" onclick="toggleSwitch('is_hr', this)">
|
||||
<span>高清模式 <span class="tip" title="1152x1536 vs 576x768,影响显存和速度">ⓘ</span></span>
|
||||
<div class="toggle"></div>
|
||||
</div>
|
||||
<div class="pair">
|
||||
<div class="field">
|
||||
<div class="field-label">dilate_kernel <span class="val" id="v-dk0">6</span></div>
|
||||
<input type="number" id="dk0" value="6" min="1" max="40" oninput="syncNum('dk0')">
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field-label">mask膨胀核(x,y) <span class="val" id="v-dk1">18</span></div>
|
||||
<input type="number" id="dk1" value="18" min="1" max="40" oninput="syncNum('dk1')">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>③ 贴回 / 融合</h3>
|
||||
<div class="pair">
|
||||
<div class="field">
|
||||
<div class="field-label">blend_dilate <span class="val" id="v-bd0">5</span></div>
|
||||
<input type="number" id="bd0" value="5" min="1" max="40" oninput="syncNum('bd0')">
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field-label">(strict贴回) <span class="val" id="v-bd1">5</span></div>
|
||||
<input type="number" id="bd1" value="5" min="1" max="40" oninput="syncNum('bd1')">
|
||||
</div>
|
||||
</div>
|
||||
<div class="pair">
|
||||
<div class="field">
|
||||
<div class="field-label">seamless_dilate <span class="val" id="v-sd0">9</span></div>
|
||||
<input type="number" id="sd0" value="9" min="1" max="40" oninput="syncNum('sd0')">
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field-label">(泊松融合) <span class="val" id="v-sd1">9</span></div>
|
||||
<input type="number" id="sd1" value="9" min="1" max="40" oninput="syncNum('sd1')">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style="color:#e9b949">★ 发际线mask参数(landmark_1k 圆角矩形)</h3>
|
||||
<div class="field">
|
||||
<div class="field-label">band_width <span class="val" id="v-bw">15</span><span class="tip" title="膨胀核宽度(像素)。越大重绘区域越宽">ⓘ</span></div>
|
||||
<input type="range" id="bw" min="3" max="50" step="1" value="15" oninput="syncRangeInt('bw')">
|
||||
</div>
|
||||
<div class="hint" style="font-size:10px;color:#e9b949;margin:6px 0 4px">▼ 圆角矩形形状</div>
|
||||
<div class="field">
|
||||
<div class="field-label">height_ratio <span class="val" id="v-hr">0.43</span><span class="tip" title="高度缩放比(相对原额头高度)。越小上下越窄">ⓘ</span></div>
|
||||
<input type="range" id="hr" min="0.1" max="1.0" step="0.02" value="0.43" oninput="syncRangeFloat('hr',2)">
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field-label">width_ratio <span class="val" id="v-wr">0.14</span><span class="tip" title="左右各扩展比(相对眉宽)。越大左右越宽">ⓘ</span></div>
|
||||
<input type="range" id="wr" min="0" max="0.6" step="0.02" value="0.14" oninput="syncRangeFloat('wr',2)">
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field-label">corner_ratio <span class="val" id="v-cr">0.25</span><span class="tip" title="圆角半径比(相对短边)。越大圆角越大">ⓘ</span></div>
|
||||
<input type="range" id="cr" min="0" max="0.5" step="0.05" value="0.25" oninput="syncRangeFloat('cr',2)">
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field-label">vertical_offset <span class="val" id="v-vo">0.00</span><span class="tip" title="上下平移比(相对额头高度)。正值下移,负值上移">ⓘ</span></div>
|
||||
<input type="range" id="vo" min="-0.5" max="0.5" step="0.02" value="0" oninput="syncRangeFloat('vo',2)">
|
||||
</div>
|
||||
<div class="hint" style="font-size:10px;color:#4ecca3;margin:6px 0 4px">▼ SD 推理(仅执行换发型时生效)</div>
|
||||
<div class="field">
|
||||
<div class="field-label">denoising <span class="val" id="v-den">0.60</span><span class="tip" title="重绘强度。越高越偏离原图">ⓘ</span></div>
|
||||
<input type="range" id="den" min="0.1" max="1.0" step="0.05" value="0.6" oninput="syncRangeFloat('den',2)">
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field-label">refiner_switch_at <span class="val" id="v-rsa">0.50</span><span class="tip" title="refiner切换点(仅高清模式)。0=不用refiner,0.5=中途切换,1=全程refiner">ⓘ</span></div>
|
||||
<input type="range" id="rsa" min="0" max="1.0" step="0.05" value="0.5" oninput="syncRangeFloat('rsa',2)">
|
||||
</div>
|
||||
<div class="switch on" id="sw-preview_only" onclick="toggleSwitch('preview_only', this)">
|
||||
<span>仅验证mask <span class="tip" title="开=只生成发际线mask(几秒),不做换发型重绘。关=执行完整换发型(40-90秒)">ⓘ</span></span>
|
||||
<div class="toggle"></div>
|
||||
</div>
|
||||
<div class="hint" style="border-left:3px solid #e9b949">
|
||||
<b style="color:#e9b949">说明:</b><br>
|
||||
步骤③用 <b>landmark_1k 关键点</b>定位额头,生成<b>圆角矩形</b>重绘区。头发主体保留原图,LoRA 仅在发际线区生效。<br>
|
||||
看「★ 叠加用户原图」直观判断重绘区位置。
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" id="btn-run" disabled>▶ 执行换发型</button>
|
||||
<button class="btn btn-ghost" id="btn-reset" onclick="resetParams()">↺ 恢复默认参数</button>
|
||||
<div class="hint">
|
||||
<b>使用说明</b><br>
|
||||
1. 选发型(中间)→ 上传人像(右侧)<br>
|
||||
2. 调整左侧参数<br>
|
||||
3. 点"执行",看7步中间产物<br>
|
||||
4. 改参数重跑,对比效果差异
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间:发型选择 -->
|
||||
<div class="mid-panel">
|
||||
<h3>选择发型</h3>
|
||||
<div class="hairstyle-grid" id="hairstyle-grid"><div style="color:#666;font-size:11px">加载中...</div></div>
|
||||
<div style="font-size:11px;color:#4ecca3;margin-top:8px;text-align:center" id="sel-info">未选择</div>
|
||||
<button class="btn btn-ghost" style="margin-top:8px;font-size:12px" onclick="loadStatus()">↻ 刷新</button>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:工作区 -->
|
||||
<div class="workspace">
|
||||
<div class="canvas-wrap">
|
||||
<div class="upload-zone" id="upload-zone">
|
||||
<svg viewBox="0 0 24 24"><path d="M19 13v6H5v-6H3v8h18v-8zM6 9l1.41 1.41L11 6.83V18h2V6.83l3.59 3.58L18 9l-6-6z"/></svg>
|
||||
<p class="highlight">点击上传人像</p>
|
||||
<p>正面清晰照效果最佳</p>
|
||||
</div>
|
||||
<input type="file" id="file-input" accept="image/*" style="display:none">
|
||||
<img id="preview-img">
|
||||
</div>
|
||||
<div id="status"><span class="spinner"></span><span id="status-text">执行中,约40-90秒...</span></div>
|
||||
<div id="error-msg"></div>
|
||||
|
||||
<div class="result-top" id="result-top">
|
||||
<h3>最终对比</h3>
|
||||
<div class="result-pair">
|
||||
<div class="result-card"><h4>原图</h4><img id="r-orig"></div>
|
||||
<div class="result-card"><h4>换发型结果</h4><img id="r-new"></div>
|
||||
</div>
|
||||
<div class="params-used" id="params-used"></div>
|
||||
</div>
|
||||
|
||||
<div class="steps-section" id="steps-section"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="lightbox" onclick="this.classList.remove('show')"><img id="lightbox-img"></div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
const $=id=>document.getElementById(id);
|
||||
const HAIRSTYLES = [
|
||||
{face:"圆",name:"圆-心形"},{face:"圆",name:"圆-椭圆"},{face:"圆",name:"圆-波浪"},{face:"圆",name:"圆-直线"},{face:"圆",name:"圆-花瓣"},
|
||||
{face:"心形",name:"心形-心形"},{face:"心形",name:"心形-椭圆"},{face:"心形",name:"心形-波浪"},{face:"心形",name:"心形-直线"},{face:"心形",name:"心形-花瓣"},
|
||||
{face:"方脸",name:"方脸-心形"},{face:"方脸",name:"方脸-椭圆"},{face:"方脸",name:"方脸-波浪"},{face:"方脸",name:"方脸-直线"},{face:"方脸",name:"方脸-花瓣"},
|
||||
{face:"椭圆",name:"椭圆-心形"},{face:"椭圆",name:"椭圆-椭圆"},{face:"椭圆",name:"椭圆-波浪"},{face:"椭圆",name:"椭圆-直线"},{face:"椭圆",name:"椭圆-花瓣"},
|
||||
{face:"菱形",name:"菱形-心"},{face:"菱形",name:"菱形-椭圆"},{face:"菱形",name:"菱形-波浪"},{face:"菱形",name:"菱形-直线"},{face:"菱形",name:"菱形-花瓣"},
|
||||
{face:"长",name:"长-心形"},{face:"长",name:"长 -椭圆"},{face:"长",name:"长 -波浪"},{face:"长",name:"长 -直线"},{face:"长",name:"长 -花瓣"}
|
||||
];
|
||||
const defaults = {method:"landmark_1k",strict_mask:false,seamless_blend:true,is_hr:true,
|
||||
dk0:6,dk1:18,bd0:5,bd1:5,sd0:9,sd1:9,bw:15,preview_only:true,
|
||||
hr:0.43,wr:0.14,cr:0.25,vo:0,den:0.6,rsa:0.5};
|
||||
|
||||
let readySet=new Set(), selectedHair=null, imgB64=null;
|
||||
|
||||
async function loadStatus(){
|
||||
try{
|
||||
const r=await fetch("/api/hairstyles");const d=await r.json();
|
||||
if(d.state!==0)throw new Error(d.msg);
|
||||
readySet=new Set(d.data.map(x=>x.hair_id));
|
||||
}catch(e){readySet=new Set();}
|
||||
renderGrid();
|
||||
}
|
||||
function renderGrid(){
|
||||
const g=$("hairstyle-grid");g.innerHTML="";
|
||||
HAIRSTYLES.forEach(h=>{
|
||||
const ready=readySet.has(h.name);
|
||||
const it=document.createElement("div");
|
||||
it.className="hairstyle-item"+(ready?" ready":" pending")+(h.name===selectedHair?" selected":"");
|
||||
it.innerHTML=`<img loading="lazy" src="/train_src/${encodeURIComponent(h.name)}" onerror="this.style.objectFit='contain';this.src='data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22100%22 height=%22100%22><text x=%2250%22 y=%2255%22 text-anchor=%22middle%22 fill=%22%23666%22 font-size=%2210%22>训练中</text></svg>'"><div class="name">${h.name}</div>`;
|
||||
if(ready) it.onclick=()=>{
|
||||
selectedHair=h.name;
|
||||
$("sel-info").textContent="已选: "+h.name;
|
||||
renderGrid(); updateBtn();
|
||||
};
|
||||
g.appendChild(it);
|
||||
});
|
||||
}
|
||||
|
||||
// 参数控件
|
||||
window.toggleSwitch=(key,el)=>{
|
||||
el.classList.toggle("on");
|
||||
if(key==="preview_only") updateRunBtn();
|
||||
updateBtn();
|
||||
};
|
||||
function updateRunBtn(){
|
||||
const po=$("sw-preview_only").classList.contains("on");
|
||||
$("btn-run").innerHTML = po ? "▶ 仅生成mask(快速预览)" : "▶ 执行完整换发型(40-90秒)";
|
||||
}
|
||||
// range 滑块绑定显示
|
||||
["den"].forEach(id=>{
|
||||
$(id).addEventListener("input",e=>$("v-"+id).textContent=parseFloat(e.target.value).toFixed(2));
|
||||
});
|
||||
// band_width 滑块(整数,HTML 里 oninput 直接调用全局函数)
|
||||
window.syncRangeInt=(id)=>{ $("v-"+id).textContent=$(id).value; };
|
||||
// 浮点滑块(HTML oninput 调用,联动显示)
|
||||
window.syncRangeFloat=(id,fix)=>{ $("v-"+id).textContent=parseFloat($(id).value).toFixed(fix); };
|
||||
window.syncNum=(id)=>{ $("v-"+id).textContent=$(id).value; };
|
||||
window.resetParams=()=>{
|
||||
Object.keys(defaults).forEach(k=>{
|
||||
if(typeof defaults[k]==="boolean"){
|
||||
const el=$("sw-"+k); el.classList.toggle("on",defaults[k]);
|
||||
}else if(k==="method"){
|
||||
// method 固定 landmark_1k,无 UI
|
||||
}else{
|
||||
const v=defaults[k];
|
||||
$(k).value=v;
|
||||
const lbl=$("v-"+k);
|
||||
if(lbl){
|
||||
// 浮点显示2位小数,整数原样
|
||||
lbl.textContent = (typeof v==="number" && !Number.isInteger(v)) ? v.toFixed(2) : v;
|
||||
}
|
||||
}
|
||||
});
|
||||
updateRunBtn();
|
||||
};
|
||||
|
||||
function collectParams(){
|
||||
return {
|
||||
method:"landmark_1k",
|
||||
strict_mask:$("sw-strict_mask").classList.contains("on"),
|
||||
seamless_blend:$("sw-seamless_blend").classList.contains("on"),
|
||||
is_hr:$("sw-is_hr").classList.contains("on"),
|
||||
dilate_kernel:[parseInt($("dk0").value),parseInt($("dk1").value)],
|
||||
denoising_strength:parseFloat($("den").value),
|
||||
blend_dilate:[parseInt($("bd0").value),parseInt($("bd1").value)],
|
||||
seamless_dilate:[parseInt($("sd0").value),parseInt($("sd1").value)],
|
||||
band_width:parseInt($("bw").value),
|
||||
preview_only:$("sw-preview_only").classList.contains("on"),
|
||||
height_ratio:parseFloat($("hr").value),
|
||||
width_ratio:parseFloat($("wr").value),
|
||||
corner_ratio:parseFloat($("cr").value),
|
||||
vertical_offset:parseFloat($("vo").value),
|
||||
refiner_switch_at:parseFloat($("rsa").value),
|
||||
};
|
||||
}
|
||||
|
||||
// 上传
|
||||
$("upload-zone").onclick=()=>$("file-input").click();
|
||||
$("file-input").onchange=e=>{ if(e.target.files[0]) loadImg(e.target.files[0]); };
|
||||
$("upload-zone").ondragover=e=>{e.preventDefault();$("upload-zone").style.borderColor="#4ecca3";};
|
||||
$("upload-zone").ondragleave=()=>$("upload-zone").style.borderColor="#0f3460";
|
||||
$("upload-zone").ondrop=e=>{e.preventDefault();if(e.dataTransfer.files[0])loadImg(e.dataTransfer.files[0]);};
|
||||
function loadImg(file){
|
||||
if(!file.type.startsWith("image/")){alert("请上传图片");return;}
|
||||
const rd=new FileReader();
|
||||
rd.onload=e=>{
|
||||
imgB64=e.target.result;
|
||||
$("preview-img").src=imgB64;
|
||||
$("preview-img").style.display="block";
|
||||
$("upload-zone").style.display="none";
|
||||
updateBtn();
|
||||
};
|
||||
rd.readAsDataURL(file);
|
||||
}
|
||||
function updateBtn(){ $("btn-run").disabled=!(selectedHair&&imgB64); }
|
||||
|
||||
// 执行
|
||||
$("btn-run").onclick=async()=>{
|
||||
if(!selectedHair||!imgB64) return;
|
||||
const p=collectParams();
|
||||
$("btn-run").disabled=true;
|
||||
$("status").style.display="block";
|
||||
$("status-text").textContent = p.preview_only
|
||||
? "生成发际线mask中(粗推理+方案),约5-15秒..."
|
||||
: "执行完整换发型中(粗推理→mask→warpAffine→SD→贴回),约40-90秒...";
|
||||
$("error-msg").style.display="none";
|
||||
$("result-top").classList.remove("show");
|
||||
$("steps-section").innerHTML="";
|
||||
try{
|
||||
const resp=await fetch("/api/swap_hairline",{
|
||||
method:"POST",headers:{"Content-Type":"application/json"},
|
||||
body:JSON.stringify(Object.assign({img:imgB64,hair_id:selectedHair},p))
|
||||
});
|
||||
const data=await resp.json();
|
||||
$("status").style.display="none";
|
||||
if(data.state===0){
|
||||
const url="data:image/jpeg;base64,"+data.result;
|
||||
$("r-orig").src=imgB64;$("r-new").src=url;
|
||||
// 参数回显
|
||||
let ph=""; Object.keys(data.params).forEach(k=>{
|
||||
let v=data.params[k]; if(Array.isArray(v))v="["+v.join(",")+"]";
|
||||
if(typeof v==="boolean")v=v?"开":"关";
|
||||
ph+=k+" = "+v+" ";
|
||||
});
|
||||
$("params-used").textContent="本次参数: "+ph;
|
||||
$("result-top").classList.add("show");
|
||||
// 步骤画廊
|
||||
const sc=$("steps-section");
|
||||
data.steps.forEach(step=>{
|
||||
const div=document.createElement("div");div.className="step-item";
|
||||
let imgs='<div class="step-images">';
|
||||
step.images.forEach(im=>{imgs+='<div class="step-img-card"><div class="lbl">'+im.label+'</div><img src="data:image/jpeg;base64,'+im.b64+'" data-full="data:image/jpeg;base64,'+im.b64+'"></div>';});
|
||||
imgs+='</div>';
|
||||
div.innerHTML='<div class="step-head"><div class="step-title">'+step.title+'</div></div><div class="step-desc">'+step.desc+'</div>'+imgs;
|
||||
sc.appendChild(div);
|
||||
});
|
||||
sc.querySelectorAll("img").forEach(img=>img.onclick=()=>showLightbox(img.dataset.full));
|
||||
$("result-top").scrollIntoView({behavior:"smooth"});
|
||||
}else{
|
||||
$("error-msg").textContent="❌ "+(data.msg||"失败");
|
||||
$("error-msg").style.display="block";
|
||||
}
|
||||
}catch(err){
|
||||
$("status").style.display="none";
|
||||
$("error-msg").textContent="❌ 请求失败: "+err.message;
|
||||
$("error-msg").style.display="block";
|
||||
}
|
||||
updateBtn();
|
||||
};
|
||||
|
||||
function showLightbox(src){$("lightbox-img").src=src;$("lightbox").classList.add("show");}
|
||||
|
||||
updateRunBtn();
|
||||
loadStatus();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,242 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>区域生发测试</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; background: #1a1a2e; color: #eee; min-height: 100vh; }
|
||||
.header { background: #16213e; padding: 18px 28px; border-bottom: 1px solid #0f3460; }
|
||||
.header h1 { font-size: 20px; font-weight: 600; }
|
||||
.header p { font-size: 13px; color: #888; margin-top: 4px; }
|
||||
.container { display: flex; gap: 20px; padding: 20px; max-width: 1700px; margin: 0 auto; }
|
||||
.panel { width: 280px; flex-shrink: 0; background: #16213e; border-radius: 10px; padding: 18px; height: fit-content; max-height: 90vh; overflow-y: auto; }
|
||||
.panel h3 { font-size: 14px; color: #4ecca3; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.panel::-webkit-scrollbar { width: 6px; }
|
||||
.panel::-webkit-scrollbar-thumb { background: #0f3460; border-radius: 3px; }
|
||||
.hairstyle-tabs { display: flex; gap: 6px; margin-bottom: 10px; }
|
||||
.hairstyle-tab { flex: 1; padding: 6px; text-align: center; background: #0f3460; border: none; border-radius: 4px; color: #aaa; cursor: pointer; font-size: 12px; }
|
||||
.hairstyle-tab.active { background: #4ecca3; color: #16213e; font-weight: 600; }
|
||||
.search-box { width: 100%; padding: 6px 10px; background: #0d1b3e; border: 1px solid #0f3460; border-radius: 4px; color: #eee; font-size: 12px; margin-bottom: 10px; }
|
||||
.hairstyle-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; max-height: 280px; overflow-y: auto; }
|
||||
.hairstyle-item { position: relative; cursor: pointer; border-radius: 4px; overflow: hidden; border: 2px solid transparent; aspect-ratio: 1; background: #0d1b3e; }
|
||||
.hairstyle-item img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.hairstyle-item.selected { border-color: #4ecca3; }
|
||||
.hairstyle-item.selected::after { content: '✓'; position: absolute; top: 2px; right: 4px; color: #4ecca3; font-weight: bold; text-shadow: 0 0 3px #000; }
|
||||
.selected-hairstyle-info { font-size: 11px; color: #888; margin-top: 8px; text-align: center; word-break: break-all; }
|
||||
.tool-group { margin-bottom: 16px; }
|
||||
.tool-group label { display: block; font-size: 13px; color: #aaa; margin-bottom: 6px; }
|
||||
.btn { display: block; width: 100%; padding: 9px; border: none; border-radius: 6px; font-size: 13px; cursor: pointer; margin-bottom: 8px; transition: .15s; }
|
||||
.btn-tool { background: #0f3460; color: #eee; }
|
||||
.btn-tool:hover { background: #1a4a7a; }
|
||||
.btn-tool.active { background: #4ecca3; color: #16213e; font-weight: 600; }
|
||||
.btn-primary { background: #4ecca3; color: #16213e; font-weight: 600; font-size: 15px; padding: 12px; }
|
||||
.btn-primary:hover { background: #6ee0bd; }
|
||||
.btn-primary:disabled { background: #555; color: #999; cursor: not-allowed; }
|
||||
.btn-danger { background: #c0392b; color: #fff; font-size: 12px; }
|
||||
.btn-danger:hover { background: #e74c3c; }
|
||||
.hint { font-size: 12px; color: #666; line-height: 1.6; margin-top: 14px; padding: 10px; background: #0d1b3e; border-radius: 6px; }
|
||||
.workspace { flex: 1; min-width: 0; }
|
||||
.canvas-wrap { position: relative; background: #0d0d1a; border-radius: 10px; padding: 20px; text-align: center; min-height: 400px; display: flex; align-items: center; justify-content: center; }
|
||||
.upload-zone { width: 100%; max-width: 500px; border: 2px dashed #0f3460; border-radius: 10px; padding: 50px 20px; text-align: center; cursor: pointer; transition: .2s; }
|
||||
.upload-zone:hover, .upload-zone.dragover { border-color: #4ecca3; background: rgba(78,204,163,.05); }
|
||||
.upload-zone svg { width: 48px; height: 48px; fill: #4ecca3; margin-bottom: 12px; }
|
||||
.upload-zone p { color: #888; font-size: 14px; }
|
||||
.upload-zone p.highlight { color: #4ecca3; margin-bottom: 4px; }
|
||||
#canvas-container { position: relative; display: inline-block; max-width: 100%; }
|
||||
#img-canvas, #mask-canvas { display: block; max-width: 100%; border-radius: 6px; }
|
||||
#mask-canvas { position: absolute; top: 0; left: 0; cursor: crosshair; touch-action: none; }
|
||||
#mask-canvas.eraser { cursor: cell; }
|
||||
.result-section { margin-top: 20px; background: #16213e; border-radius: 10px; padding: 18px; display: none; }
|
||||
.result-section.show { display: block; }
|
||||
.result-section h3 { font-size: 14px; color: #4ecca3; margin-bottom: 14px; }
|
||||
.result-grid { display: flex; gap: 16px; flex-wrap: wrap; }
|
||||
.result-card { flex: 1; min-width: 250px; }
|
||||
.result-card h4 { font-size: 13px; color: #aaa; margin-bottom: 8px; text-align: center; }
|
||||
.result-card img { width: 100%; border-radius: 6px; display: block; }
|
||||
.download-btn { display: inline-block; margin-top: 8px; padding: 6px 14px; background: #0f3460; color: #eee; border-radius: 4px; font-size: 12px; text-decoration: none; }
|
||||
#status { text-align: center; padding: 30px; color: #4ecca3; font-size: 15px; display: none; }
|
||||
.spinner { display: inline-block; width: 20px; height: 20px; border: 3px solid #0f3460; border-top-color: #4ecca3; border-radius: 50%; animation: spin .8s linear infinite; margin-right: 8px; vertical-align: middle; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
#error-msg { color: #e74c3c; text-align: center; padding: 20px; display: none; font-size: 14px; }
|
||||
.loading-hairstyles { text-align: center; padding: 20px; color: #666; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>🟢 区域生发(换发型工作流)</h1>
|
||||
<p>选发型 → 上传人像 → 在缺发区涂抹遮罩 → 生成(走 LoRA + 换发型链路)</p>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="panel">
|
||||
<h3>① 选择发型</h3>
|
||||
<div class="hairstyle-tabs">
|
||||
<button class="hairstyle-tab active" data-gender="all">全部</button>
|
||||
<button class="hairstyle-tab" data-gender="girl">女款</button>
|
||||
<button class="hairstyle-tab" data-gender="boy">男款</button>
|
||||
</div>
|
||||
<input type="text" class="search-box" id="search" placeholder="搜索发型ID...">
|
||||
<div class="hairstyle-grid" id="hairstyle-grid"><div class="loading-hairstyles">加载发型中...</div></div>
|
||||
<div class="selected-hairstyle-info" id="selected-info">未选择发型</div>
|
||||
<h3 style="margin-top:20px">② 涂抹遮罩</h3>
|
||||
<div class="tool-group">
|
||||
<button class="btn btn-tool active" id="btn-brush">🖌️ 画笔</button>
|
||||
<button class="btn btn-tool" id="btn-eraser">🧽 橡皮</button>
|
||||
</div>
|
||||
<div class="tool-group">
|
||||
<label>画笔粗细 <span style="float:right;color:#4ecca3" id="size-val">25px</span></label>
|
||||
<input type="range" id="brush-size" min="5" max="60" value="25" style="width:100%">
|
||||
</div>
|
||||
<button class="btn btn-danger" id="btn-clear">🗑️ 清空遮罩</button>
|
||||
<h3 style="margin-top:20px">③ 生成</h3>
|
||||
<div class="tool-group">
|
||||
<label><input type="checkbox" id="is-hr" checked> 高清模式 (较慢)</label>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="btn-generate" disabled>✨ 生成生发</button>
|
||||
<div class="hint">
|
||||
<b>使用说明</b><br>
|
||||
1. 左侧选择一个发型<br>
|
||||
2. 上传一张人头像照片<br>
|
||||
3. 用画笔在头发稀少/发际线区域涂抹(红色=生发区)<br>
|
||||
4. 点"生成",等待30-90秒<br>
|
||||
工作流:换发型粗推理 + LoRA + mask并集(原头发∪新发型∪手绘)
|
||||
</div>
|
||||
</div>
|
||||
<div class="workspace">
|
||||
<div class="canvas-wrap" id="canvas-wrap">
|
||||
<div class="upload-zone" id="upload-zone">
|
||||
<svg viewBox="0 0 24 24"><path d="M19 13v6H5v-6H3v8h18v-8zM6 9l1.41 1.41L11 6.83V18h2V6.83l3.59 3.58L18 9l-6-6z"/></svg>
|
||||
<p class="highlight">点击或拖拽上传图片</p>
|
||||
<p>建议人脸清晰正面照</p>
|
||||
</div>
|
||||
<input type="file" id="file-input" accept="image/*" style="display:none">
|
||||
<div id="canvas-container" style="display:none">
|
||||
<canvas id="img-canvas"></canvas>
|
||||
<canvas id="mask-canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div id="status"><span class="spinner"></span><span id="status-text">正在生成,请稍候...</span></div>
|
||||
<div id="error-msg"></div>
|
||||
<div class="result-section" id="result-section">
|
||||
<h3>对比结果</h3>
|
||||
<div class="result-grid">
|
||||
<div class="result-card"><h4>原图</h4><img id="result-orig" alt="原图"></div>
|
||||
<div class="result-card"><h4>生发结果</h4><img id="result-new" alt="结果"><a class="download-btn" id="download-link" download="hairgrow_result.jpg">⬇️ 下载结果</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
const $=id=>document.getElementById(id);
|
||||
const uploadZone=$("upload-zone"),fileInput=$("file-input"),canvasContainer=$("canvas-container");
|
||||
const imgCanvas=$("img-canvas"),maskCanvas=$("mask-canvas");
|
||||
const imgCtx=imgCanvas.getContext("2d"),maskCtx=maskCanvas.getContext("2d",{willReadFrequently:true});
|
||||
let mode="brush",brushSize=25,drawing=false,lastX=0,lastY=0;
|
||||
let selectedHairId=null,allHairstyles=[],imgB64Orig=null;
|
||||
|
||||
async function loadHairstyles(){
|
||||
try{
|
||||
const r=await fetch("/api/hairstyles");const d=await r.json();
|
||||
if(d.state!==0)throw new Error(d.msg);
|
||||
allHairstyles=d.data;renderHairstyles("all");
|
||||
}catch(e){$("hairstyle-grid").innerHTML='<div class="loading-hairstyles">加载失败: '+e.message+'</div>';}
|
||||
}
|
||||
function renderHairstyles(gf){
|
||||
const s=$("search").value.trim().toLowerCase();
|
||||
const f=allHairstyles.filter(x=>{if(gf!=="all"&&x.gender!==gf)return false;if(s&&!x.hair_id.toLowerCase().includes(s))return false;return true;});
|
||||
const g=$("hairstyle-grid");
|
||||
if(f.length===0){g.innerHTML='<div class="loading-hairstyles">无匹配发型</div>';return;}
|
||||
g.innerHTML="";
|
||||
f.slice(0,60).forEach(x=>{
|
||||
const it=document.createElement("div");
|
||||
it.className="hairstyle-item"+(x.hair_id===selectedHairId?" selected":"");
|
||||
it.dataset.hairId=x.hair_id;
|
||||
const im=document.createElement("img");im.loading="lazy";im.src="/preview/"+x.hair_id;
|
||||
im.onerror=()=>{im.style.visibility="hidden";};
|
||||
it.appendChild(im);it.onclick=()=>selectHairstyle(x.hair_id);g.appendChild(it);
|
||||
});
|
||||
if(f.length>60){const m=document.createElement("div");m.className="loading-hairstyles";m.style.gridColumn="1/-1";m.textContent="还有 "+(f.length-60)+" 个,请搜索";g.appendChild(m);}
|
||||
}
|
||||
function selectHairstyle(id){
|
||||
selectedHairId=id;
|
||||
document.querySelectorAll(".hairstyle-item").forEach(el=>el.classList.toggle("selected",el.dataset.hairId===id));
|
||||
$("selected-info").textContent="已选: "+id;updateGenerateBtn();
|
||||
}
|
||||
document.querySelectorAll(".hairstyle-tab").forEach(t=>{t.onclick=()=>{document.querySelectorAll(".hairstyle-tab").forEach(x=>x.classList.remove("active"));t.classList.add("active");renderHairstyles(t.dataset.gender);};});
|
||||
$("search").addEventListener("input",()=>{const a=document.querySelector(".hairstyle-tab.active");renderHairstyles(a?a.dataset.gender:"all");});
|
||||
loadHairstyles();
|
||||
|
||||
$("brush-size").addEventListener("input",e=>{brushSize=+e.target.value;$("size-val").textContent=brushSize+"px";});
|
||||
$("btn-brush").addEventListener("click",()=>{mode="brush";$("btn-brush").classList.add("active");$("btn-eraser").classList.remove("active");maskCanvas.classList.remove("eraser");});
|
||||
$("btn-eraser").addEventListener("click",()=>{mode="eraser";$("btn-eraser").classList.add("active");$("btn-brush").classList.remove("active");maskCanvas.classList.add("eraser");});
|
||||
$("btn-clear").addEventListener("click",()=>{maskCtx.globalCompositeOperation="source-over";maskCtx.clearRect(0,0,maskCanvas.width,maskCanvas.height);});
|
||||
|
||||
uploadZone.addEventListener("click",()=>fileInput.click());
|
||||
uploadZone.addEventListener("dragover",e=>{e.preventDefault();uploadZone.classList.add("dragover");});
|
||||
uploadZone.addEventListener("dragleave",()=>uploadZone.classList.remove("dragover"));
|
||||
uploadZone.addEventListener("drop",e=>{e.preventDefault();uploadZone.classList.remove("dragover");if(e.dataTransfer.files[0])loadImage(e.dataTransfer.files[0]);});
|
||||
fileInput.addEventListener("change",e=>{if(e.target.files[0])loadImage(e.target.files[0]);});
|
||||
|
||||
function loadImage(file){
|
||||
if(!file.type.startsWith("image/")){alert("请上传图片文件");return;}
|
||||
const rd=new FileReader();
|
||||
rd.onload=e=>{
|
||||
const im=new Image();
|
||||
im.onload=()=>{
|
||||
let w=im.width,h=im.height;const MAX=1024;
|
||||
if(w>MAX||h>MAX){const r=Math.min(MAX/w,MAX/h);w=Math.round(w*r);h=Math.round(h*r);}
|
||||
w=w-(w%8);h=h-(h%8);
|
||||
imgCanvas.width=maskCanvas.width=w;imgCanvas.height=maskCanvas.height=h;
|
||||
imgCtx.drawImage(im,0,0,w,h);imgB64Orig=imgCanvas.toDataURL("image/jpeg",0.95);
|
||||
maskCtx.clearRect(0,0,w,h);
|
||||
uploadZone.style.display="none";canvasContainer.style.display="inline-block";
|
||||
updateGenerateBtn();$("result-section").classList.remove("show");$("error-msg").style.display="none";
|
||||
};im.src=e.target.result;
|
||||
};rd.readAsDataURL(file);
|
||||
}
|
||||
function updateGenerateBtn(){$("btn-generate").disabled=!(selectedHairId&&imgB64Orig);}
|
||||
|
||||
function getPos(e){const r=maskCanvas.getBoundingClientRect();const sx=maskCanvas.width/r.width,sy=maskCanvas.height/r.height;const p=e.touches?e.touches[0]:e;return{x:(p.clientX-r.left)*sx,y:(p.clientY-r.top)*sy};}
|
||||
function startDraw(e){e.preventDefault();drawing=true;const p=getPos(e);lastX=p.x;lastY=p.y;drawDot(p.x,p.y);}
|
||||
function draw(e){if(!drawing)return;e.preventDefault();const p=getPos(e);drawLine(lastX,lastY,p.x,p.y);lastX=p.x;lastY=p.y;}
|
||||
function endDraw(){drawing=false;}
|
||||
function drawDot(x,y){if(mode==="brush"){maskCtx.globalCompositeOperation="source-over";maskCtx.fillStyle="rgba(255,50,50,0.45)";}else{maskCtx.globalCompositeOperation="destination-out";maskCtx.fillStyle="rgba(0,0,0,1)";}maskCtx.beginPath();maskCtx.arc(x,y,brushSize/2,0,Math.PI*2);maskCtx.fill();}
|
||||
function drawLine(x1,y1,x2,y2){if(mode==="brush"){maskCtx.globalCompositeOperation="source-over";maskCtx.strokeStyle="rgba(255,50,50,0.45)";}else{maskCtx.globalCompositeOperation="destination-out";maskCtx.strokeStyle="rgba(0,0,0,1)";}maskCtx.beginPath();maskCtx.moveTo(x1,y1);maskCtx.lineTo(x2,y2);maskCtx.lineWidth=brushSize;maskCtx.lineCap="round";maskCtx.lineJoin="round";maskCtx.stroke();}
|
||||
maskCanvas.addEventListener("mousedown",startDraw);maskCanvas.addEventListener("mousemove",draw);
|
||||
maskCanvas.addEventListener("mouseup",endDraw);maskCanvas.addEventListener("mouseleave",endDraw);
|
||||
maskCanvas.addEventListener("touchstart",startDraw,{passive:false});maskCanvas.addEventListener("touchmove",draw,{passive:false});
|
||||
maskCanvas.addEventListener("touchend",endDraw);
|
||||
|
||||
$("btn-generate").addEventListener("click",async()=>{
|
||||
const md=maskCtx.getImageData(0,0,maskCanvas.width,maskCanvas.height);
|
||||
let hasMask=false;for(let i=3;i<md.data.length;i+=4){if(md.data[i]>30){hasMask=true;break;}}
|
||||
if(!hasMask){alert("请先用画笔涂抹需要生发的区域");return;}
|
||||
const isHr=$("is-hr").checked;
|
||||
const tmp=document.createElement("canvas");tmp.width=maskCanvas.width;tmp.height=maskCanvas.height;
|
||||
const tmpCtx=tmp.getContext("2d");
|
||||
const md2=maskCtx.getImageData(0,0,maskCanvas.width,maskCanvas.height);
|
||||
const out=tmpCtx.createImageData(tmp.width,tmp.height);
|
||||
for(let i=0;i<md2.data.length;i+=4){const v=md2.data[i+3]>30?255:0;out.data[i]=v;out.data[i+1]=v;out.data[i+2]=v;out.data[i+3]=255;}
|
||||
tmpCtx.putImageData(out,0,0);
|
||||
const maskB64=tmp.toDataURL("image/png");
|
||||
$("btn-generate").disabled=true;$("status").style.display="block";
|
||||
$("status-text").textContent="正在生成(换发型工作流+LoRA),约30-90秒...";
|
||||
$("error-msg").style.display="none";$("result-section").classList.remove("show");
|
||||
try{
|
||||
const resp=await fetch("/api/grow",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({img:imgB64Orig,mask:maskB64,hair_id:selectedHairId,is_hr:String(isHr)})});
|
||||
const data=await resp.json();$("status").style.display="none";
|
||||
if(data.state===0){
|
||||
const url="data:image/jpeg;base64,"+data.result;
|
||||
$("result-orig").src=imgB64Orig;$("result-new").src=url;$("download-link").href=url;
|
||||
$("result-section").classList.add("show");$("result-section").scrollIntoView({behavior:"smooth"});
|
||||
maskCtx.clearRect(0,0,maskCanvas.width,maskCanvas.height);
|
||||
}else{$("error-msg").textContent="❌ "+(data.msg||"生成失败");$("error-msg").style.display="block";}
|
||||
}catch(err){$("status").style.display="none";$("error-msg").textContent="❌ 请求失败: "+err.message;$("error-msg").style.display="block";}
|
||||
updateGenerateBtn();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,427 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>手绘mask换发型</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; background: #1a1a2e; color: #eee; min-height: 100vh; font-size: 14px; }
|
||||
.header { background: #16213e; padding: 16px 28px; border-bottom: 1px solid #0f3460; display: flex; justify-content: space-between; align-items: center; }
|
||||
.header h1 { font-size: 19px; font-weight: 600; }
|
||||
.header p { font-size: 12px; color: #888; margin-top: 3px; }
|
||||
.header .links a { color: #4ecca3; font-size: 12px; text-decoration: none; margin-left: 12px; }
|
||||
.container { display: flex; gap: 18px; padding: 18px; max-width: 1800px; margin: 0 auto; }
|
||||
|
||||
/* 左侧:工具+参数 */
|
||||
.panel { width: 290px; flex-shrink: 0; background: #16213e; border-radius: 10px; padding: 16px; height: fit-content; max-height: 92vh; overflow-y: auto; }
|
||||
.panel::-webkit-scrollbar { width: 6px; }
|
||||
.panel::-webkit-scrollbar-thumb { background: #0f3460; border-radius: 3px; }
|
||||
.panel h3 { font-size: 13px; color: #4ecca3; margin: 14px 0 8px; text-transform: uppercase; letter-spacing: 1px; padding-bottom: 5px; border-bottom: 1px solid #0f3460; }
|
||||
.panel h3:first-child { margin-top: 0; }
|
||||
.hint { font-size: 11px; color: #888; line-height: 1.6; margin: 6px 0 10px; }
|
||||
|
||||
/* 画笔工具 */
|
||||
.tool-row { display: flex; gap: 6px; margin-bottom: 10px; }
|
||||
.btn-tool { flex: 1; padding: 8px; background: #0f3460; color: #ccc; border: none; border-radius: 5px; cursor: pointer; font-size: 12px; }
|
||||
.btn-tool.active { background: #4ecca3; color: #16213e; font-weight: 600; }
|
||||
.btn-tool:hover { background: #1a4a7a; }
|
||||
.btn-tool.active:hover { background: #6ee0bd; }
|
||||
.btn-danger { width: 100%; padding: 7px; background: #c0392b; color: #fff; border: none; border-radius: 5px; cursor: pointer; font-size: 12px; margin-bottom: 10px; }
|
||||
.btn-danger:hover { background: #e74c3c; }
|
||||
.field { margin-bottom: 12px; }
|
||||
.field-label { display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: #bbb; margin-bottom: 5px; }
|
||||
.field-label .val { color: #4ecca3; font-family: monospace; font-size: 11px; }
|
||||
.field input[type="range"] { width: 100%; height: 4px; -webkit-appearance: none; background: #0f3460; border-radius: 2px; outline: none; }
|
||||
.field input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: #4ecca3; cursor: pointer; }
|
||||
.switch { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; font-size: 13px; color: #ccc; cursor: pointer; }
|
||||
.switch:hover { color: #eee; }
|
||||
.toggle { position: relative; width: 36px; height: 20px; background: #0f3460; border-radius: 10px; transition: .2s; flex-shrink: 0; }
|
||||
.toggle::after { content: ''; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; background: #888; border-radius: 50%; transition: .2s; }
|
||||
.switch.on .toggle { background: #4ecca3; }
|
||||
.switch.on .toggle::after { left: 18px; background: #16213e; }
|
||||
.pair { display: flex; gap: 8px; }
|
||||
.pair .field { flex: 1; }
|
||||
.readonly-info { font-size: 11px; color: #777; padding: 8px 10px; background: #0d1b3e; border-radius: 5px; line-height: 1.6; }
|
||||
.readonly-info code { color: #aaa; background: #16213e; padding: 1px 5px; border-radius: 3px; }
|
||||
|
||||
.btn { display: block; width: 100%; padding: 10px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; transition: .15s; }
|
||||
.btn-primary { background: #4ecca3; color: #16213e; font-weight: 600; font-size: 15px; padding: 12px; margin-top: 8px; }
|
||||
.btn-primary:hover { background: #6ee0bd; }
|
||||
.btn-primary:disabled { background: #555; color: #999; cursor: not-allowed; }
|
||||
|
||||
/* 中间发型选择 */
|
||||
.mid-panel { width: 200px; flex-shrink: 0; background: #16213e; border-radius: 10px; padding: 14px; height: fit-content; max-height: 92vh; overflow-y: auto; }
|
||||
.mid-panel h3 { font-size: 12px; color: #4ecca3; margin-bottom: 10px; }
|
||||
.hairstyle-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
|
||||
.hairstyle-item { position: relative; cursor: pointer; border-radius: 5px; overflow: hidden; border: 2px solid transparent; aspect-ratio: 1; background: #0d1b3e; }
|
||||
.hairstyle-item img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.hairstyle-item.ready { border-color: #2a5a4a; }
|
||||
.hairstyle-item.selected { border-color: #4ecca3; }
|
||||
.hairstyle-item.selected::after { content: '✓'; position: absolute; top: 1px; right: 3px; color: #4ecca3; font-weight: bold; text-shadow: 0 0 3px #000; font-size: 11px; }
|
||||
.hairstyle-item .name { position: absolute; bottom: 0; left: 0; right: 0; background: linear-gradient(transparent, rgba(0,0,0,.85)); color: #fff; font-size: 10px; padding: 8px 2px 2px; text-align: center; }
|
||||
.hairstyle-item.pending { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
/* 右侧画板+结果 */
|
||||
.workspace { flex: 1; min-width: 0; }
|
||||
.canvas-wrap { background: #0d0d1a; border-radius: 10px; padding: 18px; text-align: center; min-height: 280px; display: flex; align-items: center; justify-content: center; }
|
||||
.upload-zone { width: 100%; max-width: 600px; border: 2px dashed #0f3460; border-radius: 10px; padding: 40px 20px; text-align: center; cursor: pointer; transition: .2s; }
|
||||
.upload-zone:hover { border-color: #4ecca3; background: rgba(78,204,163,.05); }
|
||||
.upload-zone svg { width: 42px; height: 42px; fill: #4ecca3; margin-bottom: 10px; }
|
||||
.upload-zone p { color: #888; font-size: 13px; }
|
||||
.upload-zone p.highlight { color: #4ecca3; }
|
||||
#canvas-container { position: relative; display: inline-block; max-width: 100%; }
|
||||
#img-canvas, #mask-canvas { display: block; max-width: 100%; border-radius: 6px; }
|
||||
#mask-canvas { position: absolute; top: 0; left: 0; cursor: crosshair; touch-action: none; }
|
||||
#mask-canvas.eraser { cursor: cell; }
|
||||
.canvas-tip { font-size: 11px; color: #666; margin-top: 8px; }
|
||||
#status { text-align: center; padding: 24px; color: #4ecca3; display: none; }
|
||||
.spinner { display: inline-block; width: 18px; height: 18px; border: 3px solid #0f3460; border-top-color: #4ecca3; border-radius: 50%; animation: spin .8s linear infinite; margin-right: 8px; vertical-align: middle; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
#error-msg { color: #e74c3c; padding: 16px; display: none; font-size: 13px; background: rgba(231,76,60,.1); border-radius: 6px; margin-top: 12px; }
|
||||
|
||||
.result-top { margin-top: 16px; background: #16213e; border-radius: 10px; padding: 16px; display: none; }
|
||||
.result-top.show { display: block; }
|
||||
.result-top h3 { font-size: 14px; color: #4ecca3; margin-bottom: 12px; }
|
||||
.result-pair { display: flex; gap: 16px; flex-wrap: wrap; }
|
||||
.result-card { flex: 1; min-width: 240px; }
|
||||
.result-card h4 { font-size: 12px; color: #aaa; margin-bottom: 6px; text-align: center; }
|
||||
.result-card img { width: 100%; border-radius: 6px; display: block; }
|
||||
.params-used { margin-top: 12px; padding: 10px 12px; background: #0d1b3e; border-radius: 6px; font-size: 11px; color: #888; line-height: 1.7; font-family: monospace; }
|
||||
|
||||
.steps-section { margin-top: 16px; }
|
||||
.step-item { background: #16213e; border-radius: 10px; padding: 16px; margin-bottom: 14px; }
|
||||
.step-title { font-size: 15px; font-weight: 600; color: #4ecca3; margin-bottom: 4px; }
|
||||
.step-desc { font-size: 12px; color: #999; margin-bottom: 12px; line-height: 1.6; }
|
||||
.step-images { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.step-img-card { flex: 1; min-width: 180px; max-width: 280px; }
|
||||
.step-img-card .lbl { font-size: 11px; color: #888; margin-bottom: 4px; text-align: center; }
|
||||
.step-img-card img { width: 100%; border-radius: 6px; display: block; border: 1px solid #0f3460; cursor: zoom-in; transition: .15s; }
|
||||
.step-img-card img:hover { border-color: #4ecca3; }
|
||||
#lightbox { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.92); z-index: 999; justify-content: center; align-items: center; cursor: zoom-out; }
|
||||
#lightbox img { max-width: 92%; max-height: 92%; border-radius: 8px; }
|
||||
#lightbox.show { display: flex; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>🖌️ 手绘mask换发型(重绘区由你画)</h1>
|
||||
<p>上传人像 → 用画笔涂出要重绘的区域 → 选发型 → 生成。SD 只在你涂的区域重绘</p>
|
||||
</div>
|
||||
<div class="links">
|
||||
<a href="/debug">→ 全自动调试台</a>
|
||||
<a href="/hairline">→ 发际线带实验</a>
|
||||
<a href="/test_new">→ 简易测试</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<!-- 左侧:画笔工具 + 参数 -->
|
||||
<div class="panel">
|
||||
<h3>① 画笔工具</h3>
|
||||
<div class="hint">在右侧图片上涂抹需要重绘的区域(白色=重绘,黑色=保留原图)</div>
|
||||
<div class="tool-row">
|
||||
<button class="btn-tool active" id="tool-brush">🖌️ 画笔</button>
|
||||
<button class="btn-tool" id="tool-eraser">🧹 橡皮</button>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field-label">画笔大小 <span class="val" id="size-val">25px</span></div>
|
||||
<input type="range" id="brush-size" min="5" max="80" value="25">
|
||||
</div>
|
||||
<button class="btn-danger" id="btn-clear">✕ 清除全部涂抹</button>
|
||||
|
||||
<h3>② 参数</h3>
|
||||
<div class="switch on" id="sw-is_hr" onclick="toggleSwitch('is_hr', this)">
|
||||
<span>高清模式 (1152x1536)</span>
|
||||
<div class="toggle"></div>
|
||||
</div>
|
||||
<div class="switch" id="sw-strict_mask" onclick="toggleSwitch('strict_mask', this)">
|
||||
<span>严格mask贴回 <span style="color:#666;font-size:10px">(mask外保留原图)</span></span>
|
||||
<div class="toggle"></div>
|
||||
</div>
|
||||
<div class="switch on" id="sw-seamless_blend" onclick="toggleSwitch('seamless_blend', this)">
|
||||
<span>泊松融合消接缝 <span style="color:#e9b949;font-size:10px">⚠需同时开「严格mask」</span></span>
|
||||
<div class="toggle"></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field-label">denoising_strength <span class="val" id="v-den">0.60</span></div>
|
||||
<input type="range" id="den" min="0.1" max="1.0" step="0.05" value="0.6" oninput="syncFloat('den')">
|
||||
</div>
|
||||
<div class="pair">
|
||||
<div class="field">
|
||||
<div class="field-label">dilate_kernel <span class="val" id="v-dk0">6</span></div>
|
||||
<input type="number" id="dk0" value="6" min="1" max="40" oninput="$('v-dk0').textContent=this.value">
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field-label">(膨胀) <span class="val" id="v-dk1">18</span></div>
|
||||
<input type="number" id="dk1" value="18" min="1" max="40" oninput="$('v-dk1').textContent=this.value">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field-label">feather_px <span class="val" id="v-fp">0</span><span class="tip" title="严格mask贴回的边缘羽化像素。0=硬边缘,>0=高斯模糊边缘平滑过渡(消除贴回接缝)">ⓘ</span></div>
|
||||
<input type="range" id="fp" min="0" max="60" step="1" value="0" oninput="syncInt('fp')">
|
||||
</div>
|
||||
<div class="readonly-info">
|
||||
<b style="color:#888">webui端固定:</b><br>
|
||||
<code>cfg=7</code> <code>steps=20</code> <code>sampler=DPM++ 2M Karras</code>
|
||||
</div>
|
||||
|
||||
<h3 style="color:#4ecca3;margin-top:14px">③ enhance 二次增强(可选)</h3>
|
||||
<div class="switch" id="sw-enhance" onclick="toggleSwitch('enhance', this)">
|
||||
<span>开启enhance <span class="tip" title="换发型后对结果再做一次低强度SD重绘,让发丝更清晰(+15-20秒)">ⓘ</span></span>
|
||||
<div class="toggle"></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field-label">enhance_denoising <span class="val" id="v-ed">0.35</span><span class="tip" title="enhance重绘强度。越低越保留原图,越高发丝变化越大">ⓘ</span></div>
|
||||
<input type="range" id="ed" min="0.1" max="0.8" step="0.05" value="0.35" oninput="syncFloat('ed')">
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" id="btn-run" disabled>▶ 生成(手绘区换发型)</button>
|
||||
</div>
|
||||
|
||||
<!-- 中间发型选择 -->
|
||||
<div class="mid-panel">
|
||||
<h3>③ 选发型</h3>
|
||||
<div class="hairstyle-grid" id="hairstyle-grid"><div style="color:#666;font-size:11px">加载中...</div></div>
|
||||
<div style="font-size:11px;color:#4ecca3;margin-top:8px;text-align:center" id="sel-info">未选择</div>
|
||||
<button class="btn-tool" style="margin-top:8px;width:100%" onclick="loadStatus()">↻ 刷新</button>
|
||||
</div>
|
||||
|
||||
<!-- 右侧画板+结果 -->
|
||||
<div class="workspace">
|
||||
<div class="canvas-wrap">
|
||||
<div class="upload-zone" id="upload-zone">
|
||||
<svg viewBox="0 0 24 24"><path d="M19 13v6H5v-6H3v8h18v-8zM6 9l1.41 1.41L11 6.83V18h2V6.83l3.59 3.58L18 9l-6-6z"/></svg>
|
||||
<p class="highlight">点击上传人像</p>
|
||||
<p>正面清晰照效果最佳</p>
|
||||
</div>
|
||||
<div id="canvas-container" style="display:none">
|
||||
<canvas id="img-canvas"></canvas>
|
||||
<canvas id="mask-canvas"></canvas>
|
||||
</div>
|
||||
<input type="file" id="file-input" accept="image/*" style="display:none">
|
||||
</div>
|
||||
<div class="canvas-tip" id="canvas-tip" style="display:none;text-align:center">用左侧画笔在图片上涂抹要重绘的区域</div>
|
||||
|
||||
<div id="status"><span class="spinner"></span><span id="status-text">生成中,约40-90秒...</span></div>
|
||||
<div id="error-msg"></div>
|
||||
|
||||
<div class="result-top" id="result-top">
|
||||
<h3>最终对比</h3>
|
||||
<div class="result-pair">
|
||||
<div class="result-card"><h4>原图</h4><img id="r-orig"></div>
|
||||
<div class="result-card"><h4>换发型结果(仅手绘区)</h4><img id="r-new"></div>
|
||||
</div>
|
||||
<div class="params-used" id="params-used"></div>
|
||||
</div>
|
||||
|
||||
<div class="steps-section" id="steps-section"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="lightbox" onclick="this.classList.remove('show')"><img id="lightbox-img"></div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
const $=id=>document.getElementById(id);
|
||||
const HAIRSTYLES = [
|
||||
{face:"圆",name:"圆-心形"},{face:"圆",name:"圆-椭圆"},{face:"圆",name:"圆-波浪"},{face:"圆",name:"圆-直线"},{face:"圆",name:"圆-花瓣"},
|
||||
{face:"心形",name:"心形-心形"},{face:"心形",name:"心形-椭圆"},{face:"心形",name:"心形-波浪"},{face:"心形",name:"心形-直线"},{face:"心形",name:"心形-花瓣"},
|
||||
{face:"方脸",name:"方脸-心形"},{face:"方脸",name:"方脸-椭圆"},{face:"方脸",name:"方脸-波浪"},{face:"方脸",name:"方脸-直线"},{face:"方脸",name:"方脸-花瓣"},
|
||||
{face:"椭圆",name:"椭圆-心形"},{face:"椭圆",name:"椭圆-椭圆"},{face:"椭圆",name:"椭圆-波浪"},{face:"椭圆",name:"椭圆-直线"},{face:"椭圆",name:"椭圆-花瓣"},
|
||||
{face:"菱形",name:"菱形-心"},{face:"菱形",name:"菱形-椭圆"},{face:"菱形",name:"菱形-波浪"},{face:"菱形",name:"菱形-直线"},{face:"菱形",name:"菱形-花瓣"},
|
||||
{face:"长",name:"长-心形"},{face:"长",name:"长 -椭圆"},{face:"长",name:"长 -波浪"},{face:"长",name:"长 -直线"},{face:"长",name:"长 -花瓣"}
|
||||
];
|
||||
|
||||
let readySet=new Set(), selectedHair=null, imgB64Orig=null;
|
||||
// 画板状态
|
||||
const imgCanvas=$("img-canvas"),maskCanvas=$("mask-canvas");
|
||||
const imgCtx=imgCanvas.getContext("2d"),maskCtx=maskCanvas.getContext("2d",{willReadFrequently:true});
|
||||
let mode="brush",brushSize=25,drawing=false,lastX=0,lastY=0;
|
||||
|
||||
async function loadStatus(){
|
||||
try{
|
||||
const r=await fetch("/api/hairstyles");const d=await r.json();
|
||||
if(d.state!==0)throw new Error(d.msg);
|
||||
readySet=new Set(d.data.map(x=>x.hair_id));
|
||||
}catch(e){readySet=new Set();}
|
||||
renderGrid();
|
||||
}
|
||||
function renderGrid(){
|
||||
const g=$("hairstyle-grid");g.innerHTML="";
|
||||
HAIRSTYLES.forEach(h=>{
|
||||
const ready=readySet.has(h.name);
|
||||
const it=document.createElement("div");
|
||||
it.className="hairstyle-item"+(ready?" ready":" pending")+(h.name===selectedHair?" selected":"");
|
||||
it.innerHTML=`<img loading="lazy" src="/train_src/${encodeURIComponent(h.name)}" onerror="this.style.objectFit='contain';this.src='data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22100%22 height=%22100%22><text x=%2250%22 y=%2255%22 text-anchor=%22middle%22 fill=%22%23666%22 font-size=%2210%22>训练中</text></svg>'"><div class="name">${h.name}</div>`;
|
||||
if(ready) it.onclick=()=>{
|
||||
selectedHair=h.name;
|
||||
$("sel-info").textContent="已选: "+h.name;
|
||||
renderGrid(); updateBtn();
|
||||
};
|
||||
g.appendChild(it);
|
||||
});
|
||||
}
|
||||
|
||||
window.toggleSwitch=(key,el)=>{ el.classList.toggle("on"); };
|
||||
// 浮点滑块联动显示
|
||||
window.syncFloat=(id)=>{ $("v-"+id).textContent=parseFloat($(id).value).toFixed(2); };
|
||||
// 整数滑块联动显示
|
||||
window.syncInt=(id)=>{ $("v-"+id).textContent=$(id).value; };
|
||||
|
||||
// 上传图片
|
||||
$("upload-zone").onclick=()=>$("file-input").click();
|
||||
$("file-input").onchange=e=>{ if(e.target.files[0]) loadImage(e.target.files[0]); };
|
||||
function loadImage(file){
|
||||
if(!file.type.startsWith("image/")){alert("请上传图片");return;}
|
||||
const rd=new FileReader();
|
||||
rd.onload=e=>{
|
||||
const im=new Image();
|
||||
im.onload=()=>{
|
||||
// 限制最大尺寸,避免太大
|
||||
let w=im.width,h=im.height;
|
||||
const MAX=1024;
|
||||
if(Math.max(w,h)>MAX){const sc=MAX/Math.max(w,h);w=Math.round(w*sc);h=Math.round(h*sc);}
|
||||
imgCanvas.width=w;imgCanvas.height=h;
|
||||
maskCanvas.width=w;maskCanvas.height=h;
|
||||
imgCtx.drawImage(im,0,0,w,h);
|
||||
maskCtx.clearRect(0,0,w,h);
|
||||
imgB64Orig=imgCanvas.toDataURL("image/jpeg",0.95);
|
||||
$("canvas-container").style.display="inline-block";
|
||||
$("upload-zone").style.display="none";
|
||||
$("canvas-tip").style.display="block";
|
||||
updateBtn();
|
||||
};
|
||||
im.src=e.target.result;
|
||||
};
|
||||
rd.readAsDataURL(file);
|
||||
}
|
||||
|
||||
// 画笔工具切换
|
||||
$("tool-brush").onclick=()=>{mode="brush";$("tool-brush").classList.add("active");$("tool-eraser").classList.remove("active");maskCanvas.classList.remove("eraser");};
|
||||
$("tool-eraser").onclick=()=>{mode="eraser";$("tool-eraser").classList.add("active");$("tool-brush").classList.remove("active");maskCanvas.classList.add("eraser");};
|
||||
$("brush-size").oninput=e=>{brushSize=+e.target.value;$("size-val").textContent=brushSize+"px";};
|
||||
$("btn-clear").onclick=()=>{maskCtx.globalCompositeOperation="source-over";maskCtx.clearRect(0,0,maskCanvas.width,maskCanvas.height);};
|
||||
|
||||
// 绘制
|
||||
function getPos(e){
|
||||
const r=maskCanvas.getBoundingClientRect();
|
||||
const sc=maskCanvas.width/r.width;
|
||||
const cx=(e.touches?e.touches[0].clientX:e.clientX)-r.left;
|
||||
const cy=(e.touches?e.touches[0].clientY:e.clientY)-r.top;
|
||||
return [cx*sc,cy*sc];
|
||||
}
|
||||
function startDraw(e){e.preventDefault();drawing=true;const[x,y]=getPos(e);lastX=x;lastY=y;drawDot(x,y);}
|
||||
function moveDraw(e){if(!drawing)return;e.preventDefault();const[x,y]=getPos(e);drawLine(lastX,lastY,x,y);lastX=x;lastY=y;}
|
||||
function endDraw(){drawing=false;}
|
||||
function drawDot(x,y){
|
||||
if(mode==="brush"){maskCtx.globalCompositeOperation="source-over";maskCtx.fillStyle="rgba(255,60,80,0.5)";}
|
||||
else{maskCtx.globalCompositeOperation="destination-out";maskCtx.fillStyle="rgba(0,0,0,1)";}
|
||||
maskCtx.beginPath();maskCtx.arc(x,y,brushSize/2,0,Math.PI*2);maskCtx.fill();
|
||||
}
|
||||
function drawLine(x1,y1,x2,y2){
|
||||
if(mode==="brush"){maskCtx.globalCompositeOperation="source-over";maskCtx.strokeStyle="rgba(255,60,80,0.5)";}
|
||||
else{maskCtx.globalCompositeOperation="destination-out";maskCtx.strokeStyle="rgba(0,0,0,1)";}
|
||||
maskCtx.beginPath();maskCtx.moveTo(x1,y1);maskCtx.lineTo(x2,y2);
|
||||
maskCtx.lineWidth=brushSize;maskCtx.lineCap="round";maskCtx.lineJoin="round";maskCtx.stroke();
|
||||
}
|
||||
maskCanvas.addEventListener("mousedown",startDraw);
|
||||
maskCanvas.addEventListener("mousemove",moveDraw);
|
||||
window.addEventListener("mouseup",endDraw);
|
||||
maskCanvas.addEventListener("touchstart",startDraw,{passive:false});
|
||||
maskCanvas.addEventListener("touchmove",moveDraw,{passive:false});
|
||||
maskCanvas.addEventListener("touchend",endDraw);
|
||||
|
||||
function updateBtn(){ $("btn-run").disabled=!(selectedHair&&imgB64Orig); }
|
||||
|
||||
// 提取mask为黑白二值PNG base64
|
||||
function extractMaskB64(){
|
||||
const tmp=document.createElement("canvas");
|
||||
tmp.width=maskCanvas.width;tmp.height=maskCanvas.height;
|
||||
const tc=tmp.getContext("2d");
|
||||
const md=maskCtx.getImageData(0,0,maskCanvas.width,maskCanvas.height);
|
||||
const out=tc.createImageData(tmp.width,tmp.height);
|
||||
for(let i=0;i<md.data.length;i+=4){
|
||||
const v=md.data[i+3]>30?255:0; // 用alpha判断是否涂抹过
|
||||
out.data[i]=v;out.data[i+1]=v;out.data[i+2]=v;out.data[i+3]=255;
|
||||
}
|
||||
tc.putImageData(out,0,0);
|
||||
return tmp.toDataURL("image/png");
|
||||
}
|
||||
|
||||
// 执行
|
||||
$("btn-run").onclick=async()=>{
|
||||
if(!selectedHair||!imgB64Orig) return;
|
||||
// 检查是否涂抹了mask
|
||||
const md=maskCtx.getImageData(0,0,maskCanvas.width,maskCanvas.height);
|
||||
let hasMask=false;
|
||||
for(let i=3;i<md.data.length;i+=4){if(md.data[i]>30){hasMask=true;break;}}
|
||||
if(!hasMask){alert("请先用画笔涂抹需要重绘的区域");return;}
|
||||
|
||||
const maskB64=extractMaskB64();
|
||||
const params={
|
||||
strict_mask:$("sw-strict_mask").classList.contains("on"),
|
||||
seamless_blend:$("sw-seamless_blend").classList.contains("on"),
|
||||
is_hr:$("sw-is_hr").classList.contains("on"),
|
||||
dilate_kernel:[parseInt($("dk0").value),parseInt($("dk1").value)],
|
||||
feather_px:parseInt($("fp").value),
|
||||
denoising_strength:parseFloat($("den").value),
|
||||
enhance:$("sw-enhance").classList.contains("on"),
|
||||
enhance_denoising:parseFloat($("ed").value),
|
||||
};
|
||||
$("btn-run").disabled=true;
|
||||
$("status").style.display="block";
|
||||
$("status-text").textContent = params.enhance
|
||||
? "生成中(换发型 + enhance增强),约60-110秒..."
|
||||
: "生成中(粗推理→手绘mask→warpAffine→SD→贴回),约40-90秒...";
|
||||
$("error-msg").style.display="none";
|
||||
$("result-top").classList.remove("show");
|
||||
$("steps-section").innerHTML="";
|
||||
try{
|
||||
const resp=await fetch("/api/swap_manual",{
|
||||
method:"POST",headers:{"Content-Type":"application/json"},
|
||||
body:JSON.stringify(Object.assign({img:imgB64Orig,mask:maskB64,hair_id:selectedHair},params))
|
||||
});
|
||||
const data=await resp.json();
|
||||
$("status").style.display="none";
|
||||
if(data.state===0){
|
||||
const url="data:image/jpeg;base64,"+data.result;
|
||||
$("r-orig").src=imgB64Orig;$("r-new").src=url;
|
||||
let ph="";Object.keys(data.params).forEach(k=>{
|
||||
let v=data.params[k];if(Array.isArray(v))v="["+v.join(",")+"]";
|
||||
if(typeof v==="boolean")v=v?"开":"关";
|
||||
ph+=k+" = "+v+" ";
|
||||
});
|
||||
$("params-used").textContent="本次参数: "+ph;
|
||||
$("result-top").classList.add("show");
|
||||
// 步骤画廊
|
||||
const sc=$("steps-section");
|
||||
data.steps.forEach(step=>{
|
||||
const div=document.createElement("div");div.className="step-item";
|
||||
let imgs='<div class="step-images">';
|
||||
step.images.forEach(im=>{imgs+='<div class="step-img-card"><div class="lbl">'+im.label+'</div><img src="data:image/jpeg;base64,'+im.b64+'" data-full="data:image/jpeg;base64,'+im.b64+'"></div>';});
|
||||
imgs+='</div>';
|
||||
div.innerHTML='<div class="step-title">'+step.title+'</div><div class="step-desc">'+step.desc+'</div>'+imgs;
|
||||
sc.appendChild(div);
|
||||
});
|
||||
sc.querySelectorAll("img").forEach(img=>img.onclick=()=>showLightbox(img.dataset.full));
|
||||
$("result-top").scrollIntoView({behavior:"smooth"});
|
||||
}else{
|
||||
$("error-msg").textContent="❌ "+(data.msg||"失败");
|
||||
$("error-msg").style.display="block";
|
||||
}
|
||||
}catch(err){
|
||||
$("status").style.display="none";
|
||||
$("error-msg").textContent="❌ 请求失败: "+err.message;
|
||||
$("error-msg").style.display="block";
|
||||
}
|
||||
updateBtn();
|
||||
};
|
||||
|
||||
function showLightbox(src){$("lightbox-img").src=src;$("lightbox").classList.add("show");}
|
||||
loadStatus();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,210 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>换发型测试</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; background: #1a1a2e; color: #eee; min-height: 100vh; }
|
||||
.header { background: #16213e; padding: 18px 28px; border-bottom: 1px solid #0f3460; }
|
||||
.header h1 { font-size: 20px; font-weight: 600; }
|
||||
.header p { font-size: 13px; color: #888; margin-top: 4px; }
|
||||
.container { display: flex; gap: 20px; padding: 20px; max-width: 1700px; margin: 0 auto; }
|
||||
.panel { width: 280px; flex-shrink: 0; background: #16213e; border-radius: 10px; padding: 18px; height: fit-content; max-height: 90vh; overflow-y: auto; }
|
||||
.panel h3 { font-size: 14px; color: #4ecca3; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.panel::-webkit-scrollbar { width: 6px; }
|
||||
.panel::-webkit-scrollbar-thumb { background: #0f3460; border-radius: 3px; }
|
||||
.hairstyle-tabs { display: flex; gap: 6px; margin-bottom: 10px; }
|
||||
.hairstyle-tab { flex: 1; padding: 6px; text-align: center; background: #0f3460; border: none; border-radius: 4px; color: #aaa; cursor: pointer; font-size: 12px; }
|
||||
.hairstyle-tab.active { background: #4ecca3; color: #16213e; font-weight: 600; }
|
||||
.search-box { width: 100%; padding: 6px 10px; background: #0d1b3e; border: 1px solid #0f3460; border-radius: 4px; color: #eee; font-size: 12px; margin-bottom: 10px; }
|
||||
.hairstyle-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; max-height: 420px; overflow-y: auto; }
|
||||
.hairstyle-item { position: relative; cursor: pointer; border-radius: 4px; overflow: hidden; border: 2px solid transparent; aspect-ratio: 1; background: #0d1b3e; }
|
||||
.hairstyle-item img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.hairstyle-item.selected { border-color: #4ecca3; }
|
||||
.hairstyle-item.selected::after { content: '✓'; position: absolute; top: 2px; right: 4px; color: #4ecca3; font-weight: bold; text-shadow: 0 0 3px #000; }
|
||||
.selected-hairstyle-info { font-size: 11px; color: #888; margin-top: 8px; text-align: center; word-break: break-all; }
|
||||
.tool-group { margin-bottom: 16px; }
|
||||
.tool-group label { display: block; font-size: 13px; color: #aaa; margin-bottom: 6px; }
|
||||
.btn { display: block; width: 100%; padding: 9px; border: none; border-radius: 6px; font-size: 13px; cursor: pointer; margin-bottom: 8px; transition: .15s; }
|
||||
.btn-primary { background: #4ecca3; color: #16213e; font-weight: 600; font-size: 15px; padding: 12px; }
|
||||
.btn-primary:hover { background: #6ee0bd; }
|
||||
.btn-primary:disabled { background: #555; color: #999; cursor: not-allowed; }
|
||||
.hint { font-size: 12px; color: #666; line-height: 1.6; margin-top: 14px; padding: 10px; background: #0d1b3e; border-radius: 6px; }
|
||||
.workspace { flex: 1; min-width: 0; }
|
||||
.canvas-wrap { position: relative; background: #0d0d1a; border-radius: 10px; padding: 20px; text-align: center; min-height: 400px; display: flex; align-items: center; justify-content: center; }
|
||||
.upload-zone { width: 100%; max-width: 500px; border: 2px dashed #0f3460; border-radius: 10px; padding: 50px 20px; text-align: center; cursor: pointer; transition: .2s; }
|
||||
.upload-zone:hover, .upload-zone.dragover { border-color: #4ecca3; background: rgba(78,204,163,.05); }
|
||||
.upload-zone svg { width: 48px; height: 48px; fill: #4ecca3; margin-bottom: 12px; }
|
||||
.upload-zone p { color: #888; font-size: 14px; }
|
||||
.upload-zone p.highlight { color: #4ecca3; margin-bottom: 4px; }
|
||||
#preview-img { display: none; max-width: 100%; max-height: 500px; border-radius: 6px; }
|
||||
.result-section { margin-top: 20px; background: #16213e; border-radius: 10px; padding: 18px; display: none; }
|
||||
.result-section.show { display: block; }
|
||||
.result-section h3 { font-size: 14px; color: #4ecca3; margin-bottom: 14px; }
|
||||
.result-grid { display: flex; gap: 16px; flex-wrap: wrap; }
|
||||
.result-card { flex: 1; min-width: 250px; }
|
||||
.result-card h4 { font-size: 13px; color: #aaa; margin-bottom: 8px; text-align: center; }
|
||||
.result-card img { width: 100%; border-radius: 6px; display: block; }
|
||||
.download-btn { display: inline-block; margin-top: 8px; padding: 6px 14px; background: #0f3460; color: #eee; border-radius: 4px; font-size: 12px; text-decoration: none; }
|
||||
/* 步骤画廊 */
|
||||
.steps-section { margin-top: 20px; background: #16213e; border-radius: 10px; padding: 18px; display: none; }
|
||||
.steps-section.show { display: block; }
|
||||
.steps-section h3 { font-size: 14px; color: #4ecca3; margin-bottom: 6px; }
|
||||
.steps-section .sub { font-size: 12px; color: #666; margin-bottom: 16px; }
|
||||
.step-item { margin-bottom: 24px; padding-bottom: 20px; border-bottom: 1px solid #0f3460; }
|
||||
.step-item:last-child { border-bottom: none; }
|
||||
.step-title { font-size: 15px; font-weight: 600; color: #4ecca3; margin-bottom: 4px; }
|
||||
.step-desc { font-size: 12px; color: #999; margin-bottom: 12px; line-height: 1.5; }
|
||||
.step-images { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.step-img-card { flex: 1; min-width: 180px; max-width: 280px; }
|
||||
.step-img-card .lbl { font-size: 11px; color: #888; margin-bottom: 4px; text-align: center; }
|
||||
.step-img-card img { width: 100%; border-radius: 6px; display: block; border: 1px solid #0f3460; cursor: zoom-in; }
|
||||
.step-img-card img:hover { border-color: #4ecca3; }
|
||||
#status { text-align: center; padding: 30px; color: #4ecca3; font-size: 15px; display: none; }
|
||||
.spinner { display: inline-block; width: 20px; height: 20px; border: 3px solid #0f3460; border-top-color: #4ecca3; border-radius: 50%; animation: spin .8s linear infinite; margin-right: 8px; vertical-align: middle; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
#error-msg { color: #e74c3c; text-align: center; padding: 20px; display: none; font-size: 14px; }
|
||||
.loading-hairstyles { text-align: center; padding: 20px; color: #666; font-size: 12px; }
|
||||
.link-row { margin-top: 12px; text-align: center; }
|
||||
.link-row a { color: #4ecca3; font-size: 12px; text-decoration: none; }
|
||||
/* 图片放大查看 */
|
||||
#lightbox { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,.9); z-index: 999; justify-content: center; align-items: center; cursor: zoom-out; }
|
||||
#lightbox img { max-width: 90%; max-height: 90%; border-radius: 8px; }
|
||||
#lightbox.show { display: flex; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>💇 换发型测试(过程可视化)</h1>
|
||||
<p>选发型 → 上传人像 → 生成(展示换发型每个步骤的中间产物)</p>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="panel">
|
||||
<h3>① 选择发型</h3>
|
||||
<div class="hairstyle-tabs">
|
||||
<button class="hairstyle-tab active" data-gender="all">全部</button>
|
||||
<button class="hairstyle-tab" data-gender="girl">女款</button>
|
||||
<button class="hairstyle-tab" data-gender="boy">男款</button>
|
||||
</div>
|
||||
<input type="text" class="search-box" id="search" placeholder="搜索发型ID...">
|
||||
<div class="hairstyle-grid" id="hairstyle-grid"><div class="loading-hairstyles">加载发型中...</div></div>
|
||||
<div class="selected-hairstyle-info" id="selected-info">未选择发型</div>
|
||||
<h3 style="margin-top:20px">② 上传人像</h3>
|
||||
<div class="hint" style="margin-top:0">点击右侧上传区选择人头像照片</div>
|
||||
<h3 style="margin-top:20px">③ 生成</h3>
|
||||
<div class="tool-group">
|
||||
<label><input type="checkbox" id="is-hr"> 高清模式 (较慢)</label>
|
||||
<label><input type="checkbox" id="strict-mask"> 严格按mask贴回 (mask外不变)</label>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="btn-generate" disabled>✨ 换发型</button>
|
||||
<div class="hint">
|
||||
<b>说明</b><br>
|
||||
换发型后会展示6个步骤:粗推理→生成mask→warpAffine→SD推理→贴回原图
|
||||
</div>
|
||||
<div class="link-row"><a href="/test_new">→ 去新发型测试页(30款)</a></div>
|
||||
<div class="link-row"><a href="/">→ 去生发测试页</a></div>
|
||||
</div>
|
||||
<div class="workspace">
|
||||
<div class="canvas-wrap" id="canvas-wrap">
|
||||
<div class="upload-zone" id="upload-zone">
|
||||
<svg viewBox="0 0 24 24"><path d="M19 13v6H5v-6H3v8h18v-8zM6 9l1.41 1.41L11 6.83V18h2V6.83l3.59 3.58L18 9l-6-6z"/></svg>
|
||||
<p class="highlight">点击或拖拽上传人像</p>
|
||||
<p>建议人脸清晰正面照</p>
|
||||
</div>
|
||||
<input type="file" id="file-input" accept="image/*" style="display:none">
|
||||
<img id="preview-img">
|
||||
</div>
|
||||
<div id="status"><span class="spinner"></span><span id="status-text">正在换发型(含可视化),约60秒...</span></div>
|
||||
<div id="error-msg"></div>
|
||||
<div class="result-section" id="result-section">
|
||||
<h3>对比结果</h3>
|
||||
<div class="result-grid">
|
||||
<div class="result-card"><h4>原图</h4><img id="result-orig"></div>
|
||||
<div class="result-card"><h4>换发型结果</h4><img id="result-new"><a class="download-btn" id="download-link" download="swaphair_result.jpg">⬇️ 下载结果</a></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="steps-section" id="steps-section">
|
||||
<h3>🔍 换发型过程(逐步可视化)</h3>
|
||||
<div class="sub">每个步骤用到的图片和说明,点击图片可放大查看</div>
|
||||
<div id="steps-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="lightbox" onclick="this.classList.remove('show')"><img id="lightbox-img"></div>
|
||||
<script>
|
||||
(function(){
|
||||
const $=id=>document.getElementById(id);
|
||||
const uploadZone=$("upload-zone"),fileInput=$("file-input"),previewImg=$("preview-img");
|
||||
let selectedHairId=null,allHairstyles=[],imgB64Orig=null;
|
||||
|
||||
async function loadHairstyles(){
|
||||
try{const r=await fetch("/api/hairstyles");const d=await r.json();
|
||||
if(d.state!==0)throw new Error(d.msg);allHairstyles=d.data;renderHairstyles("all");
|
||||
}catch(e){$("hairstyle-grid").innerHTML='<div class="loading-hairstyles">加载失败: '+e.message+'</div>';}
|
||||
}
|
||||
function renderHairstyles(gf){
|
||||
const s=$("search").value.trim().toLowerCase();
|
||||
const f=allHairstyles.filter(x=>{if(gf!=="all"&&x.gender!==gf)return false;if(s&&!x.hair_id.toLowerCase().includes(s))return false;return true;});
|
||||
const g=$("hairstyle-grid");if(f.length===0){g.innerHTML='<div class="loading-hairstyles">无匹配发型</div>';return;}
|
||||
g.innerHTML="";f.slice(0,90).forEach(x=>{
|
||||
const it=document.createElement("div");it.className="hairstyle-item"+(x.hair_id===selectedHairId?" selected":"");it.dataset.hairId=x.hair_id;
|
||||
const im=document.createElement("img");im.loading="lazy";im.src="/preview/"+x.hair_id;im.onerror=()=>{im.style.visibility="hidden";};
|
||||
it.appendChild(im);it.onclick=()=>selectHairstyle(x.hair_id);g.appendChild(it);
|
||||
});
|
||||
if(f.length>90){const m=document.createElement("div");m.className="loading-hairstyles";m.style.gridColumn="1/-1";m.textContent="还有 "+(f.length-90)+" 个,请搜索";g.appendChild(m);}
|
||||
}
|
||||
function selectHairstyle(id){selectedHairId=id;document.querySelectorAll(".hairstyle-item").forEach(el=>el.classList.toggle("selected",el.dataset.hairId===id));$("selected-info").textContent="已选: "+id;updateBtn();}
|
||||
document.querySelectorAll(".hairstyle-tab").forEach(t=>{t.onclick=()=>{document.querySelectorAll(".hairstyle-tab").forEach(x=>x.classList.remove("active"));t.classList.add("active");renderHairstyles(t.dataset.gender);};});
|
||||
$("search").addEventListener("input",()=>{const a=document.querySelector(".hairstyle-tab.active");renderHairstyles(a?a.dataset.gender:"all");});
|
||||
loadHairstyles();
|
||||
|
||||
uploadZone.addEventListener("click",()=>fileInput.click());
|
||||
uploadZone.addEventListener("dragover",e=>{e.preventDefault();uploadZone.classList.add("dragover");});
|
||||
uploadZone.addEventListener("dragleave",()=>uploadZone.classList.remove("dragover"));
|
||||
uploadZone.addEventListener("drop",e=>{e.preventDefault();uploadZone.classList.remove("dragover");if(e.dataTransfer.files[0])loadImage(e.dataTransfer.files[0]);});
|
||||
fileInput.addEventListener("change",e=>{if(e.target.files[0])loadImage(e.target.files[0]);});
|
||||
function loadImage(file){if(!file.type.startsWith("image/")){alert("请上传图片文件");return;}
|
||||
const rd=new FileReader();rd.onload=e=>{imgB64Orig=e.target.result;previewImg.src=imgB64Orig;previewImg.style.display="block";uploadZone.style.display="none";updateBtn();$("result-section").classList.remove("show");$("steps-section").classList.remove("show");$("error-msg").style.display="none";};rd.readAsDataURL(file);}
|
||||
function updateBtn(){$("btn-generate").disabled=!(selectedHairId&&imgB64Orig);}
|
||||
|
||||
function showLightbox(src){$("lightbox-img").src=src;$("lightbox").classList.add("show");}
|
||||
|
||||
$("btn-generate").addEventListener("click",async()=>{
|
||||
const isHr=$("is-hr").checked;
|
||||
$("btn-generate").disabled=true;$("status").style.display="block";
|
||||
$("status-text").textContent="正在换发型(含过程可视化),约60秒...";
|
||||
$("error-msg").style.display="none";$("result-section").classList.remove("show");$("steps-section").classList.remove("show");
|
||||
try{
|
||||
const resp=await fetch("/api/swap_viz",{method:"POST",headers:{"Content-Type":"application/json"},
|
||||
body:JSON.stringify({img:imgB64Orig,hair_id:selectedHairId,is_hr:String(isHr),strict_mask:$("strict-mask").checked})});
|
||||
const data=await resp.json();$("status").style.display="none";
|
||||
if(data.state===0){
|
||||
const url="data:image/jpeg;base64,"+data.result;
|
||||
$("result-orig").src=imgB64Orig;$("result-new").src=url;$("download-link").href=url;
|
||||
$("result-section").classList.add("show");
|
||||
// 渲染步骤画廊
|
||||
if(data.steps&&data.steps.length){
|
||||
const c=$("steps-container");c.innerHTML="";
|
||||
data.steps.forEach(step=>{
|
||||
const div=document.createElement("div");div.className="step-item";
|
||||
let imgsHtml='<div class="step-images">';
|
||||
step.images.forEach(im=>{imgsHtml+='<div class="step-img-card"><div class="lbl">'+im.label+'</div><img src="data:image/jpeg;base64,'+im.b64+'" data-full="data:image/jpeg;base64,'+im.b64+'"></div>';});
|
||||
imgsHtml+='</div>';
|
||||
div.innerHTML='<div class="step-title">'+step.title+'</div><div class="step-desc">'+step.desc+'</div>'+imgsHtml;
|
||||
c.appendChild(div);
|
||||
});
|
||||
// 点击放大
|
||||
c.querySelectorAll("img").forEach(img=>img.onclick=()=>showLightbox(img.dataset.full));
|
||||
$("steps-section").classList.add("show");
|
||||
}
|
||||
$("result-section").scrollIntoView({behavior:"smooth"});
|
||||
}else{$("error-msg").textContent="❌ "+(data.msg||"换发型失败");$("error-msg").style.display="block";}
|
||||
}catch(err){$("status").style.display="none";$("error-msg").textContent="❌ 请求失败: "+err.message;$("error-msg").style.display="block";}
|
||||
updateBtn();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,298 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>新发型效果测试</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; background: #1a1a2e; color: #eee; min-height: 100vh; }
|
||||
.header { background: #16213e; padding: 18px 28px; border-bottom: 1px solid #0f3460; }
|
||||
.header h1 { font-size: 20px; font-weight: 600; }
|
||||
.header p { font-size: 13px; color: #888; margin-top: 4px; }
|
||||
.container { display: flex; gap: 20px; padding: 20px; max-width: 1700px; margin: 0 auto; }
|
||||
.panel { width: 320px; flex-shrink: 0; background: #16213e; border-radius: 10px; padding: 18px; height: fit-content; max-height: 90vh; overflow-y: auto; }
|
||||
.panel h3 { font-size: 14px; color: #4ecca3; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.panel::-webkit-scrollbar { width: 6px; }
|
||||
.panel::-webkit-scrollbar-thumb { background: #0f3460; border-radius: 3px; }
|
||||
.face-tabs { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
|
||||
.face-tab { padding: 5px 12px; background: #0f3460; border: none; border-radius: 4px; color: #aaa; cursor: pointer; font-size: 12px; }
|
||||
.face-tab.active { background: #4ecca3; color: #16213e; font-weight: 600; }
|
||||
.status-row { font-size: 12px; color: #888; margin-bottom: 10px; padding: 6px 10px; background: #0d1b3e; border-radius: 4px; }
|
||||
.status-row b { color: #4ecca3; }
|
||||
.hairstyle-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; max-height: 500px; overflow-y: auto; }
|
||||
.hairstyle-item { position: relative; cursor: pointer; border-radius: 6px; overflow: hidden; border: 2px solid transparent; aspect-ratio: 1; background: #0d1b3e; }
|
||||
.hairstyle-item img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.hairstyle-item.ready { border-color: #2a5a4a; }
|
||||
.hairstyle-item.selected { border-color: #4ecca3; }
|
||||
.hairstyle-item.selected::after { content: '✓'; position: absolute; top: 2px; right: 4px; color: #4ecca3; font-weight: bold; text-shadow: 0 0 3px #000; }
|
||||
.hairstyle-item .name { position: absolute; bottom: 0; left: 0; right: 0; background: linear-gradient(transparent, rgba(0,0,0,.85)); color: #fff; font-size: 11px; padding: 12px 4px 3px; text-align: center; }
|
||||
.hairstyle-item .badge { position: absolute; top: 3px; left: 3px; background: #4ecca3; color: #16213e; font-size: 9px; padding: 1px 5px; border-radius: 3px; font-weight: 600; }
|
||||
.hairstyle-item .badge.training { background: #e9b949; }
|
||||
.hairstyle-item .badge.pending { background: #555; color: #aaa; }
|
||||
.hairstyle-item.pending { opacity: 0.45; cursor: not-allowed; }
|
||||
.selected-hairstyle-info { font-size: 12px; color: #4ecca3; margin-top: 10px; text-align: center; padding: 6px; background: #0d1b3e; border-radius: 4px; }
|
||||
.tool-group { margin-bottom: 16px; }
|
||||
.tool-group label { display: block; font-size: 13px; color: #aaa; margin-bottom: 6px; cursor: pointer; }
|
||||
.tool-group label input { margin-right: 6px; }
|
||||
.btn { display: block; width: 100%; padding: 9px; border: none; border-radius: 6px; font-size: 13px; cursor: pointer; margin-bottom: 8px; transition: .15s; }
|
||||
.btn-primary { background: #4ecca3; color: #16213e; font-weight: 600; font-size: 15px; padding: 12px; }
|
||||
.btn-primary:hover { background: #6ee0bd; }
|
||||
.btn-primary:disabled { background: #555; color: #999; cursor: not-allowed; }
|
||||
.btn-ghost { background: #0f3460; color: #eee; }
|
||||
.btn-ghost:hover { background: #1a4a80; }
|
||||
.hint { font-size: 12px; color: #666; line-height: 1.6; margin-top: 14px; padding: 10px; background: #0d1b3e; border-radius: 6px; }
|
||||
.workspace { flex: 1; min-width: 0; }
|
||||
.canvas-wrap { position: relative; background: #0d0d1a; border-radius: 10px; padding: 20px; text-align: center; min-height: 320px; display: flex; align-items: center; justify-content: center; }
|
||||
.upload-zone { width: 100%; max-width: 500px; border: 2px dashed #0f3460; border-radius: 10px; padding: 50px 20px; text-align: center; cursor: pointer; transition: .2s; }
|
||||
.upload-zone:hover, .upload-zone.dragover { border-color: #4ecca3; background: rgba(78,204,163,.05); }
|
||||
.upload-zone svg { width: 48px; height: 48px; fill: #4ecca3; margin-bottom: 12px; }
|
||||
.upload-zone p { color: #888; font-size: 14px; }
|
||||
.upload-zone p.highlight { color: #4ecca3; margin-bottom: 4px; }
|
||||
#preview-img { display: none; max-width: 100%; max-height: 500px; border-radius: 6px; }
|
||||
.result-section { margin-top: 20px; background: #16213e; border-radius: 10px; padding: 18px; display: none; }
|
||||
.result-section.show { display: block; }
|
||||
.result-section h3 { font-size: 14px; color: #4ecca3; margin-bottom: 14px; }
|
||||
.result-grid { display: flex; gap: 16px; flex-wrap: wrap; }
|
||||
.result-card { flex: 1; min-width: 250px; }
|
||||
.result-card h4 { font-size: 13px; color: #aaa; margin-bottom: 8px; text-align: center; }
|
||||
.result-card img { width: 100%; border-radius: 6px; display: block; }
|
||||
.download-btn { display: inline-block; margin-top: 8px; padding: 6px 14px; background: #0f3460; color: #eee; border-radius: 4px; font-size: 12px; text-decoration: none; }
|
||||
.steps-section { margin-top: 20px; background: #16213e; border-radius: 10px; padding: 18px; display: none; }
|
||||
.steps-section.show { display: block; }
|
||||
.steps-section h3 { font-size: 14px; color: #4ecca3; margin-bottom: 6px; }
|
||||
.steps-section .sub { font-size: 12px; color: #666; margin-bottom: 16px; }
|
||||
.step-item { margin-bottom: 24px; padding-bottom: 20px; border-bottom: 1px solid #0f3460; }
|
||||
.step-item:last-child { border-bottom: none; }
|
||||
.step-title { font-size: 15px; font-weight: 600; color: #4ecca3; margin-bottom: 4px; }
|
||||
.step-desc { font-size: 12px; color: #999; margin-bottom: 12px; line-height: 1.5; }
|
||||
.step-images { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.step-img-card { flex: 1; min-width: 180px; max-width: 280px; }
|
||||
.step-img-card .lbl { font-size: 11px; color: #888; margin-bottom: 4px; text-align: center; }
|
||||
.step-img-card img { width: 100%; border-radius: 6px; display: block; border: 1px solid #0f3460; cursor: zoom-in; }
|
||||
.step-img-card img:hover { border-color: #4ecca3; }
|
||||
#status { text-align: center; padding: 30px; color: #4ecca3; font-size: 15px; display: none; }
|
||||
.spinner { display: inline-block; width: 20px; height: 20px; border: 3px solid #0f3460; border-top-color: #4ecca3; border-radius: 50%; animation: spin .8s linear infinite; margin-right: 8px; vertical-align: middle; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
#error-msg { color: #e74c3c; text-align: center; padding: 20px; display: none; font-size: 14px; }
|
||||
.loading-hairstyles { text-align: center; padding: 20px; color: #666; font-size: 12px; }
|
||||
.link-row { margin-top: 12px; text-align: center; }
|
||||
.link-row a { color: #4ecca3; font-size: 12px; text-decoration: none; }
|
||||
#lightbox { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,.9); z-index: 999; justify-content: center; align-items: center; cursor: zoom-out; }
|
||||
#lightbox img { max-width: 90%; max-height: 90%; border-radius: 8px; }
|
||||
#lightbox.show { display: flex; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>💇 新发型效果测试(30款 · 按脸型适配)</h1>
|
||||
<p>选择发型 → 上传人像 → 生成对比。绿色徽章=可测试,黄色=训练中,灰色=待训练</p>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="panel">
|
||||
<h3>① 选择发型</h3>
|
||||
<div class="status-row" id="status-row">加载中...</div>
|
||||
<div class="face-tabs" id="face-tabs"></div>
|
||||
<div class="hairstyle-grid" id="hairstyle-grid"><div class="loading-hairstyles">加载中...</div></div>
|
||||
<div class="selected-hairstyle-info" id="selected-info">未选择发型</div>
|
||||
|
||||
<h3 style="margin-top:20px">② 上传人像</h3>
|
||||
<div class="hint" style="margin-top:0">点击右侧上传区选择一张人头像照片(正面、清晰)</div>
|
||||
|
||||
<h3 style="margin-top:20px">③ 生成</h3>
|
||||
<div class="tool-group">
|
||||
<label><input type="checkbox" id="is-hr"> 高清模式(更清晰但更慢)</label>
|
||||
<label><input type="checkbox" id="strict-mask"> 严格按mask贴回(mask外不变)</label>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="btn-generate" disabled>✨ 换发型</button>
|
||||
<button class="btn btn-ghost" id="btn-refresh">↻ 刷新训练状态</button>
|
||||
<div class="hint">
|
||||
<b>说明</b><br>
|
||||
• 只能选择「可测试」状态的发型<br>
|
||||
• 换发型约需 30-60 秒<br>
|
||||
• 会展示换发型的中间步骤便于评估效果
|
||||
</div>
|
||||
<div class="link-row"><a href="/swap">→ 去全部发型测试页</a></div>
|
||||
</div>
|
||||
<div class="workspace">
|
||||
<div class="canvas-wrap" id="canvas-wrap">
|
||||
<div class="upload-zone" id="upload-zone">
|
||||
<svg viewBox="0 0 24 24"><path d="M19 13v6H5v-6H3v8h18v-8zM6 9l1.41 1.41L11 6.83V18h2V6.83l3.59 3.58L18 9l-6-6z"/></svg>
|
||||
<p class="highlight">点击或拖拽上传人像</p>
|
||||
<p>建议人脸清晰正面照</p>
|
||||
</div>
|
||||
<input type="file" id="file-input" accept="image/*" style="display:none">
|
||||
<img id="preview-img">
|
||||
</div>
|
||||
<div id="status"><span class="spinner"></span><span id="status-text">正在换发型(含可视化),约60秒...</span></div>
|
||||
<div id="error-msg"></div>
|
||||
<div class="result-section" id="result-section">
|
||||
<h3>对比结果</h3>
|
||||
<div class="result-grid">
|
||||
<div class="result-card"><h4>原图</h4><img id="result-orig"></div>
|
||||
<div class="result-card"><h4>换发型结果</h4><img id="result-new"><a class="download-btn" id="download-link" download="swaphair_result.jpg">⬇️ 下载结果</a></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="steps-section" id="steps-section">
|
||||
<h3>🔍 换发型过程(逐步可视化)</h3>
|
||||
<div class="sub">每个步骤用到的图片和说明,点击图片可放大查看</div>
|
||||
<div id="steps-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="lightbox" onclick="this.classList.remove('show')"><img id="lightbox-img"></div>
|
||||
<script>
|
||||
(function(){
|
||||
const $=id=>document.getElementById(id);
|
||||
|
||||
// 30 个新发型 ID(按脸型分组)
|
||||
const HAIRSTYLES = [
|
||||
// 圆脸
|
||||
{face:"圆",name:"圆-心形"},{face:"圆",name:"圆-椭圆"},{face:"圆",name:"圆-波浪"},{face:"圆",name:"圆-直线"},{face:"圆",name:"圆-花瓣"},
|
||||
// 心形脸
|
||||
{face:"心形",name:"心形-心形"},{face:"心形",name:"心形-椭圆"},{face:"心形",name:"心形-波浪"},{face:"心形",name:"心形-直线"},{face:"心形",name:"心形-花瓣"},
|
||||
// 方脸
|
||||
{face:"方脸",name:"方脸-心形"},{face:"方脸",name:"方脸-椭圆"},{face:"方脸",name:"方脸-波浪"},{face:"方脸",name:"方脸-直线"},{face:"方脸",name:"方脸-花瓣"},
|
||||
// 椭圆脸
|
||||
{face:"椭圆",name:"椭圆-心形"},{face:"椭圆",name:"椭圆-椭圆"},{face:"椭圆",name:"椭圆-波浪"},{face:"椭圆",name:"椭圆-直线"},{face:"椭圆",name:"椭圆-花瓣"},
|
||||
// 菱形脸
|
||||
{face:"菱形",name:"菱形-心"},{face:"菱形",name:"菱形-椭圆"},{face:"菱形",name:"菱形-波浪"},{face:"菱形",name:"菱形-直线"},{face:"菱形",name:"菱形-花瓣"},
|
||||
// 长脸
|
||||
{face:"长",name:"长-心形"},{face:"长",name:"长 -椭圆"},{face:"长",name:"长 -波浪"},{face:"长",name:"长 -直线"},{face:"长",name:"长 -花瓣"}
|
||||
];
|
||||
|
||||
const uploadZone=$("upload-zone"),fileInput=$("file-input"),previewImg=$("preview-img");
|
||||
let selectedHairId=null, readySet=new Set(), imgB64Orig=null;
|
||||
|
||||
async function loadStatus(){
|
||||
try{
|
||||
const r=await fetch("/api/hairstyles");
|
||||
const d=await r.json();
|
||||
if(d.state!==0) throw new Error(d.msg||"加载失败");
|
||||
readySet=new Set(d.data.map(x=>x.hair_id));
|
||||
}catch(e){
|
||||
$("status-row").innerHTML='<span style="color:#e74c3c">加载可用发型失败: '+e.message+'</span>';
|
||||
}
|
||||
renderStatus();
|
||||
renderTabs();
|
||||
renderGrid("全部");
|
||||
}
|
||||
function renderStatus(){
|
||||
const ready=HAIRSTYLES.filter(h=>readySet.has(h.name)).length;
|
||||
$("status-row").innerHTML='共 30 款发型|<b style="color:#4ecca3">可测试 '+ready+'</b>|训练中/待训练 '+(30-ready);
|
||||
}
|
||||
function renderTabs(){
|
||||
const faces=["全部",...new Set(HAIRSTYLES.map(h=>h.face))];
|
||||
$("face-tabs").innerHTML=faces.map((f,i)=>
|
||||
`<button class="face-tab${i===0?' active':''}" data-face="${f}">${f}</button>`).join('');
|
||||
$("face-tabs").querySelectorAll(".face-tab").forEach(t=>{
|
||||
t.onclick=()=>{
|
||||
$("face-tabs").querySelectorAll(".face-tab").forEach(x=>x.classList.remove("active"));
|
||||
t.classList.add("active");
|
||||
renderGrid(t.dataset.face);
|
||||
};
|
||||
});
|
||||
}
|
||||
function renderGrid(faceFilter){
|
||||
let list=HAIRSTYLES;
|
||||
if(faceFilter!=="全部") list=list.filter(h=>h.face===faceFilter);
|
||||
const g=$("hairstyle-grid");
|
||||
if(list.length===0){ g.innerHTML='<div class="loading-hairstyles">无发型</div>'; return; }
|
||||
g.innerHTML="";
|
||||
list.forEach(h=>{
|
||||
const ready=readySet.has(h.name);
|
||||
const it=document.createElement("div");
|
||||
it.className="hairstyle-item"+(ready?" ready":" pending");
|
||||
if(h.name===selectedHairId) it.classList.add("selected");
|
||||
const badge=ready
|
||||
? '<span class="badge">可测试</span>'
|
||||
: '<span class="badge training">训练中</span>';
|
||||
it.innerHTML=badge+
|
||||
`<img loading="lazy" src="/train_src/${encodeURIComponent(h.name)}" onerror="this.style.background='#0d1b3e';this.style.objectFit='contain';this.src='data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22120%22 height=%22120%22><text x=%2260%22 y=%2265%22 text-anchor=%22middle%22 fill=%22%23666%22 font-size=%2212%22>训练中</text></svg>'">`+
|
||||
`<div class="name">${h.name}</div>`;
|
||||
if(ready){
|
||||
it.onclick=()=>{
|
||||
selectedHairId=h.name;
|
||||
$("hairstyle-grid").querySelectorAll(".hairstyle-item").forEach(el=>el.classList.remove("selected"));
|
||||
it.classList.add("selected");
|
||||
$("selected-info").textContent="已选: "+h.name;
|
||||
updateBtn();
|
||||
};
|
||||
}
|
||||
g.appendChild(it);
|
||||
});
|
||||
}
|
||||
|
||||
uploadZone.addEventListener("click",()=>fileInput.click());
|
||||
uploadZone.addEventListener("dragover",e=>{e.preventDefault();uploadZone.classList.add("dragover");});
|
||||
uploadZone.addEventListener("dragleave",()=>uploadZone.classList.remove("dragover"));
|
||||
uploadZone.addEventListener("drop",e=>{e.preventDefault();uploadZone.classList.remove("dragover");if(e.dataTransfer.files[0])loadImage(e.dataTransfer.files[0]);});
|
||||
fileInput.addEventListener("change",e=>{if(e.target.files[0])loadImage(e.target.files[0]);});
|
||||
function loadImage(file){
|
||||
if(!file.type.startsWith("image/")){alert("请上传图片文件");return;}
|
||||
const rd=new FileReader();
|
||||
rd.onload=e=>{
|
||||
imgB64Orig=e.target.result;
|
||||
previewImg.src=imgB64Orig;previewImg.style.display="block";
|
||||
uploadZone.style.display="none";updateBtn();
|
||||
$("result-section").classList.remove("show");
|
||||
$("steps-section").classList.remove("show");
|
||||
$("error-msg").style.display="none";
|
||||
};
|
||||
rd.readAsDataURL(file);
|
||||
}
|
||||
function updateBtn(){ $("btn-generate").disabled=!(selectedHairId&&imgB64Orig&&readySet.has(selectedHairId)); }
|
||||
|
||||
function showLightbox(src){ $("lightbox-img").src=src; $("lightbox").classList.add("show"); }
|
||||
|
||||
$("btn-refresh").onclick=()=>{ loadStatus(); };
|
||||
|
||||
$("btn-generate").addEventListener("click",async()=>{
|
||||
if(!selectedHairId||!imgB64Orig) return;
|
||||
const isHr=$("is-hr").checked;
|
||||
$("btn-generate").disabled=true;$("status").style.display="block";
|
||||
$("status-text").textContent="正在换发型(含过程可视化),约30-60秒...";
|
||||
$("error-msg").style.display="none";
|
||||
$("result-section").classList.remove("show");$("steps-section").classList.remove("show");
|
||||
try{
|
||||
const resp=await fetch("/api/swap_viz",{
|
||||
method:"POST",headers:{"Content-Type":"application/json"},
|
||||
body:JSON.stringify({img:imgB64Orig,hair_id:selectedHairId,is_hr:String(isHr),strict_mask:$("strict-mask").checked})
|
||||
});
|
||||
const data=await resp.json();
|
||||
$("status").style.display="none";
|
||||
if(data.state===0){
|
||||
const url="data:image/jpeg;base64,"+data.result;
|
||||
$("result-orig").src=imgB64Orig;$("result-new").src=url;$("download-link").href=url;
|
||||
$("result-section").classList.add("show");
|
||||
if(data.steps&&data.steps.length){
|
||||
const c=$("steps-container");c.innerHTML="";
|
||||
data.steps.forEach(step=>{
|
||||
const div=document.createElement("div");div.className="step-item";
|
||||
let imgsHtml='<div class="step-images">';
|
||||
step.images.forEach(im=>{imgsHtml+='<div class="step-img-card"><div class="lbl">'+im.label+'</div><img src="data:image/jpeg;base64,'+im.b64+'" data-full="data:image/jpeg;base64,'+im.b64+'"></div>';});
|
||||
imgsHtml+='</div>';
|
||||
div.innerHTML='<div class="step-title">'+step.title+'</div><div class="step-desc">'+step.desc+'</div>'+imgsHtml;
|
||||
c.appendChild(div);
|
||||
});
|
||||
c.querySelectorAll("img").forEach(img=>img.onclick=()=>showLightbox(img.dataset.full));
|
||||
$("steps-section").classList.add("show");
|
||||
}
|
||||
$("result-section").scrollIntoView({behavior:"smooth"});
|
||||
}else{
|
||||
$("error-msg").textContent="❌ "+(data.msg||"换发型失败");
|
||||
$("error-msg").style.display="block";
|
||||
}
|
||||
}catch(err){
|
||||
$("status").style.display="none";
|
||||
$("error-msg").textContent="❌ 请求失败: "+err.message;
|
||||
$("error-msg").style.display="block";
|
||||
}
|
||||
updateBtn();
|
||||
});
|
||||
|
||||
loadStatus();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user