Files
2026-07-17 22:13:56 +08:00

525 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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/ubuntu/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, send_file
from gevent import pywsgi
# PyTorch 2.6+ 默认 weights_only=True,无法加载含 numpy 对象的旧权重(yolov5l.pt 等)。
# 统一改回 False(权重均为本机自有可信文件)。
import torch
_orig_torch_load = torch.load
def _torch_load(*args, **kwargs):
kwargs.setdefault("weights_only", False)
return _orig_torch_load(*args, **kwargs)
torch.load = _torch_load
PORT = 8899
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_debugstrict_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(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/ubuntu/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()