|
|
|
|
|
|
|
|
""" |
|
|
Hunyuan3D Full Pipeline (aptol/genshin 메인) — Weaponless + T-Pose + 6-View |
|
|
STEP1: 전처리 (rembg, DINO+SAM+LaMa weapon remove, T-pose enforce via ControlNet, img2img redraw) |
|
|
STEP2: Space 호출 (model / optional texture) |
|
|
STEP3: 6-view 렌더 ZIP + Blender 오토리깅(FBX) + VRM(옵션) + 기본 BlendShapes + Unity Animator 스크립트 |
|
|
|
|
|
권장 패키지: |
|
|
pip install gradio gradio_client pillow numpy opencv-python |
|
|
pip install rembg |
|
|
pip install diffusers transformers accelerate torch --extra-index-url https://download.pytorch.org/whl/cu121 |
|
|
pip install controlnet-aux |
|
|
pip install groundingdino segment-anything |
|
|
pip install lama-cleaner |
|
|
pip install trimesh pyrender PyOpenGL |
|
|
|
|
|
환경변수(옵션): |
|
|
HF_TOKEN=hf_xxx |
|
|
BLENDER_PATH=/path/to/blender |
|
|
DINO_CFG=./GroundingDINO_SwinT_OGC.py |
|
|
DINO_WEIGHTS=./groundingdino_swint_ogc.pth |
|
|
SAM_WEIGHTS=./sam_vit_h_4b8939.pth |
|
|
""" |
|
|
|
|
|
import os, io, json, time, math, random, shutil, zipfile, hashlib, tempfile, subprocess, textwrap, traceback |
|
|
from pathlib import Path |
|
|
from typing import Any, Dict, List, Optional, Tuple, Union |
|
|
from PIL import Image as PILImage, ImageDraw, ImageFilter |
|
|
|
|
|
import gradio as gr |
|
|
try: |
|
|
import spaces |
|
|
except Exception: |
|
|
class _Dummy: |
|
|
def GPU(self, *a, **k): |
|
|
def deco(fn): return fn |
|
|
return deco |
|
|
spaces = _Dummy() |
|
|
from PIL import Image, ImageDraw |
|
|
|
|
|
|
|
|
try: |
|
|
import spaces |
|
|
except Exception: |
|
|
class _Dummy: |
|
|
def GPU(self, *a, **k): |
|
|
def deco(fn): return fn |
|
|
return deco |
|
|
spaces = _Dummy() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parent |
|
|
OUT = ROOT / "outputs" |
|
|
(OUT / "step1").mkdir(parents=True, exist_ok=True) |
|
|
(OUT / "step2").mkdir(parents=True, exist_ok=True) |
|
|
(OUT / "step2b").mkdir(parents=True, exist_ok=True) |
|
|
(OUT / "step3").mkdir(parents=True, exist_ok=True) |
|
|
(OUT / "exports").mkdir(parents=True, exist_ok=True) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _lazy(): |
|
|
P = {} |
|
|
try: |
|
|
import numpy as np |
|
|
P["np"] = np |
|
|
except Exception: |
|
|
P["np"] = None |
|
|
|
|
|
try: |
|
|
import cv2 |
|
|
P["cv2"] = cv2 |
|
|
except Exception: |
|
|
P["cv2"] = None |
|
|
|
|
|
try: |
|
|
from gradio_client import Client |
|
|
P["Client"] = Client |
|
|
except Exception: |
|
|
P["Client"] = None |
|
|
|
|
|
try: |
|
|
import trimesh, pyrender |
|
|
P["trimesh"] = __import__("trimesh") |
|
|
P["pyrender"] = __import__("pyrender") |
|
|
except Exception: |
|
|
P["trimesh"] = None |
|
|
P["pyrender"] = None |
|
|
|
|
|
|
|
|
try: |
|
|
from rembg import remove as rembg_remove |
|
|
P["rembg_remove"] = rembg_remove |
|
|
except Exception: |
|
|
P["rembg_remove"] = None |
|
|
|
|
|
|
|
|
try: |
|
|
from diffusers import StableDiffusionImg2ImgPipeline |
|
|
import torch |
|
|
P["sd_img2img"] = StableDiffusionImg2ImgPipeline |
|
|
P["torch"] = torch |
|
|
except Exception: |
|
|
P["sd_img2img"] = None |
|
|
P["torch"] = None |
|
|
|
|
|
|
|
|
try: |
|
|
from diffusers import ControlNetModel, StableDiffusionControlNetImg2ImgPipeline |
|
|
P["ControlNetModel"] = ControlNetModel |
|
|
P["SD_CN_Img2Img"] = StableDiffusionControlNetImg2ImgPipeline |
|
|
except Exception: |
|
|
P["ControlNetModel"] = None |
|
|
P["SD_CN_Img2Img"] = None |
|
|
|
|
|
|
|
|
try: |
|
|
from controlnet_aux import OpenposeDetector |
|
|
P["OpenposeDetector"] = OpenposeDetector |
|
|
except Exception: |
|
|
P["OpenposeDetector"] = None |
|
|
|
|
|
|
|
|
try: |
|
|
from groundingdino.util.inference import load_model, predict |
|
|
P["g_load_model"] = load_model |
|
|
P["g_predict"] = predict |
|
|
except Exception: |
|
|
P["g_load_model"] = None |
|
|
P["g_predict"] = None |
|
|
|
|
|
|
|
|
try: |
|
|
from segment_anything import sam_model_registry, SamPredictor |
|
|
P["sam_model_registry"] = sam_model_registry |
|
|
P["SamPredictor"] = SamPredictor |
|
|
except Exception: |
|
|
P["sam_model_registry"] = None |
|
|
P["SamPredictor"] = None |
|
|
|
|
|
|
|
|
try: |
|
|
from lama_cleaner.model_manager import ModelManager |
|
|
P["LaMaManager"] = ModelManager |
|
|
except Exception: |
|
|
P["LaMaManager"] = None |
|
|
|
|
|
return P |
|
|
|
|
|
PKG = _lazy() |
|
|
|
|
|
def _device(): |
|
|
try: |
|
|
t = PKG["torch"] |
|
|
if t is not None and t.cuda.is_available(): |
|
|
return "cuda" |
|
|
except Exception: |
|
|
pass |
|
|
return "cpu" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
SPACES: Dict[str, Dict[str, Any]] = { |
|
|
"genshin (main)": { |
|
|
"repo": "aptol/genshin", |
|
|
"api_model": "/run", |
|
|
"api_texture": "/texture", |
|
|
"variants_model": [ |
|
|
("prompt","image","steps","guidance","seed"), |
|
|
("prompt","image"), |
|
|
("image","steps","guidance","seed"), |
|
|
("image",), |
|
|
], |
|
|
"variants_texture": [ |
|
|
("glb","steps","guidance"), |
|
|
("glb",), |
|
|
], |
|
|
}, |
|
|
"fork (backend)": { |
|
|
"repo": "aptol/Hunyuan3D-2.1", |
|
|
"api_model": "/run", |
|
|
"api_texture": "/texture", |
|
|
"variants_model": [ |
|
|
("prompt","image","steps","guidance","seed"), |
|
|
("prompt","image"), |
|
|
("image","steps","guidance","seed"), |
|
|
("image",), |
|
|
], |
|
|
"variants_texture": [ |
|
|
("glb","steps","guidance"), |
|
|
("glb",), |
|
|
], |
|
|
}, |
|
|
"tencent (upstream)": { |
|
|
"repo": "tencent/Hunyuan3D-2.1", |
|
|
"api_model": "/run", |
|
|
"api_texture": "/texture", |
|
|
"variants_model": [ |
|
|
("prompt","image","steps","guidance","seed"), |
|
|
("prompt","image"), |
|
|
("image","steps","guidance","seed"), |
|
|
("image",), |
|
|
], |
|
|
"variants_texture": [ |
|
|
("glb","steps","guidance"), |
|
|
("glb",), |
|
|
], |
|
|
}, |
|
|
"<custom>": { |
|
|
"repo": "", |
|
|
"api_model": "/run", |
|
|
"api_texture": "/texture", |
|
|
"variants_model": [("prompt","image","steps","guidance","seed"),("prompt","image"),("image","steps","guidance","seed"),("image",)], |
|
|
"variants_texture": [("glb","steps","guidance"),("glb",)], |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _disable_safety(pipe): |
|
|
""" |
|
|
Diffusers SafetyChecker 완전 비활성화 (0.29.x 호환). |
|
|
run_safety_checker가 항상 (images, [False]*BATCH) 형태를 반환하게 만든다. |
|
|
""" |
|
|
try: |
|
|
def _dummy_run_safety_checker(images, device=None, dtype=None): |
|
|
|
|
|
try: |
|
|
if hasattr(images, "shape"): |
|
|
batch = int(images.shape[0]) |
|
|
else: |
|
|
batch = len(images) |
|
|
except Exception: |
|
|
batch = 1 |
|
|
return images, [False] * max(1, batch) |
|
|
|
|
|
pipe.run_safety_checker = _dummy_run_safety_checker |
|
|
pipe.safety_checker = None |
|
|
|
|
|
|
|
|
pipe.feature_extractor = (lambda *args, **kwargs: None) |
|
|
except Exception: |
|
|
pass |
|
|
return pipe |
|
|
|
|
|
|
|
|
|
|
|
def _openpose_canvas_from_image(img_rgb: Image.Image) -> Image.Image: |
|
|
try: |
|
|
if PKG.get("OpenposeDetector") is None: |
|
|
return Image.new("RGB", img_rgb.size, "black") |
|
|
det = PKG["OpenposeDetector"]() |
|
|
pose = det(img_rgb) |
|
|
return pose.convert("RGB").resize(img_rgb.size) |
|
|
except Exception: |
|
|
return Image.new("RGB", img_rgb.size, "black") |
|
|
|
|
|
def _blend_pose_canvases(orig_pose: Image.Image, tpose: Image.Image, alpha: float = 0.4) -> Image.Image: |
|
|
alpha = max(0.0, min(1.0, float(alpha))) |
|
|
if orig_pose.size != tpose.size: |
|
|
tpose = tpose.resize(orig_pose.size) |
|
|
return Image.blend(orig_pose, tpose, alpha).convert("RGB") |
|
|
|
|
|
def _mean_brightness(img: Image.Image) -> float: |
|
|
import numpy as np |
|
|
return float(np.asarray(img.convert("L"), dtype=np.uint8).mean()) |
|
|
|
|
|
def _save_png(img: Image.Image, path: Union[str,Path]) -> str: |
|
|
p = Path(path); p.parent.mkdir(parents=True, exist_ok=True); img.save(p); return str(p) |
|
|
|
|
|
def _cache_key(img_path: str, opt: Dict[str,Any]) -> str: |
|
|
b = Path(img_path).read_bytes() |
|
|
digest = hashlib.sha256(b + json.dumps(opt, sort_keys=True).encode()).hexdigest()[:24] |
|
|
return digest |
|
|
|
|
|
def _hf_client(repo: str): |
|
|
if PKG["Client"] is None: |
|
|
raise RuntimeError("gradio_client 설치 필요: pip install gradio_client") |
|
|
tok = os.getenv("HF_TOKEN", None) |
|
|
return PKG["Client"](repo, hf_token=tok) |
|
|
|
|
|
def _call_space(repo: str, api_name: str, variant: Tuple[str,...], payload: Dict[str,Any]): |
|
|
client = _hf_client(repo) |
|
|
args = [] |
|
|
for k in variant: |
|
|
args.append(payload[k]) |
|
|
try: |
|
|
return client.predict(*args, api_name=api_name) |
|
|
except Exception: |
|
|
job = client.submit(*args, api_name=api_name) |
|
|
return job.result() |
|
|
|
|
|
def _pick_glb(res: Any) -> Optional[str]: |
|
|
if isinstance(res, str) and res.lower().endswith(".glb"): |
|
|
return res |
|
|
if isinstance(res, (list,tuple)): |
|
|
for it in res: |
|
|
p = _pick_glb(it) |
|
|
if p: return p |
|
|
if isinstance(res, dict): |
|
|
for k in ("glb","path","file","result","data"): |
|
|
if k in res: |
|
|
p = _pick_glb(res[k]) |
|
|
if p: return p |
|
|
if hasattr(res, "name") and str(res.name).lower().endswith(".glb"): |
|
|
return str(res.name) |
|
|
return None |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _remove_bg(img: Image.Image) -> Image.Image: |
|
|
""" |
|
|
rembg를 안전하게 호출 (PNG 바이트 왕복). |
|
|
결과가 완전 투명(알파 0%)이면 실패로 간주하고 원본을 반환. |
|
|
""" |
|
|
try: |
|
|
from rembg import remove |
|
|
buf = io.BytesIO() |
|
|
img.convert("RGBA").save(buf, format="PNG") |
|
|
out_bytes = remove(buf.getvalue()) |
|
|
out = Image.open(io.BytesIO(out_bytes)).convert("RGBA") |
|
|
|
|
|
|
|
|
alpha = out.getchannel("A") |
|
|
if alpha.getbbox() is None: |
|
|
|
|
|
return img.convert("RGBA") |
|
|
return out |
|
|
except Exception: |
|
|
return img.convert("RGBA") |
|
|
def _to_preview(img: Image.Image, bg=(40, 40, 40)) -> Image.Image: |
|
|
""" |
|
|
갤러리 미리보기용으로 RGBA 이미지를 불투명 배경에 합성. |
|
|
저장물은 RGBA 그대로 두고, UI 표시만 보기 좋게. |
|
|
""" |
|
|
if img.mode != "RGBA": |
|
|
return img.convert("RGB") |
|
|
bg_img = Image.new("RGB", img.size, bg) |
|
|
bg_img.paste(img, mask=img.split()[-1]) |
|
|
return bg_img |
|
|
|
|
|
|
|
|
_DINO_MODEL = None |
|
|
def _dino_boxes(img: Image.Image, text_prompt: str) -> List[Tuple[int,int,int,int]]: |
|
|
global _DINO_MODEL |
|
|
if PKG["g_load_model"] is None or PKG["g_predict"] is None or PKG["np"] is None: |
|
|
return [] |
|
|
cfg = os.getenv("DINO_CFG", str(ROOT/"GroundingDINO_SwinT_OGC.py")) |
|
|
wts = os.getenv("DINO_WEIGHTS", str(ROOT/"groundingdino_swint_ogc.pth")) |
|
|
if not Path(cfg).exists() or not Path(wts).exists(): |
|
|
return [] |
|
|
try: |
|
|
if _DINO_MODEL is None: |
|
|
_DINO_MODEL = PKG["g_load_model"](cfg, wts) |
|
|
np = PKG["np"]; im = np.array(img.convert("RGB")) |
|
|
boxes, logits, phrases = PKG["g_predict"]( |
|
|
model=_DINO_MODEL, image=im, caption=text_prompt, |
|
|
box_threshold=0.25, text_threshold=0.25 |
|
|
) |
|
|
out=[] |
|
|
for b in boxes: |
|
|
x1,y1,x2,y2 = [int(x) for x in b.tolist()] |
|
|
out.append((x1,y1,x2,y2)) |
|
|
return out |
|
|
except Exception: |
|
|
return [] |
|
|
|
|
|
_SAM_PRED = None |
|
|
def _sam_mask(img: Image.Image, boxes: List[Tuple[int,int,int,int]]) -> Optional[Image.Image]: |
|
|
if not boxes: return None |
|
|
if PKG["sam_model_registry"] is None or PKG["SamPredictor"] is None or PKG["np"] is None: |
|
|
return None |
|
|
ckpt = os.getenv("SAM_WEIGHTS", str(ROOT/"sam_vit_h_4b8939.pth")) |
|
|
if not Path(ckpt).exists(): |
|
|
return None |
|
|
try: |
|
|
global _SAM_PRED |
|
|
if _SAM_PRED is None: |
|
|
sam = PKG["sam_model_registry"]["vit_h"](checkpoint=ckpt) |
|
|
_SAM_PRED = PKG["SamPredictor"](sam) |
|
|
np = PKG["np"] |
|
|
arr = np.array(img.convert("RGB")) |
|
|
_SAM_PRED.set_image(arr) |
|
|
H,W = arr.shape[:2] |
|
|
m_all = np.zeros((H,W), dtype=np.uint8) |
|
|
for (x1,y1,x2,y2) in boxes: |
|
|
box = PKG["np"].array([x1,y1,x2,y2]) |
|
|
masks, scores, _ = _SAM_PRED.predict(box=box, multimask_output=True) |
|
|
if masks is not None and len(masks)>0: |
|
|
m = (masks[scores.argmax()].astype("uint8")*255) |
|
|
m_all = PKG["np"].maximum(m_all, m) |
|
|
return Image.fromarray(m_all, mode="L") |
|
|
except Exception: |
|
|
return None |
|
|
|
|
|
def _lama_inpaint(img: Image.Image, mask: Image.Image) -> Image.Image: |
|
|
""" |
|
|
우선 simple-lama-inpainting 사용, 실패 시 OpenCV로 폴백. |
|
|
ZeroGPU 안전: CPU만 사용. |
|
|
""" |
|
|
if mask is None: |
|
|
return img |
|
|
|
|
|
|
|
|
try: |
|
|
from simple_lama_inpainting import SimpleLama |
|
|
import numpy as np, cv2 |
|
|
|
|
|
model = SimpleLama() |
|
|
src = np.array(img.convert("RGB")) |
|
|
m = np.array(mask.convert("L")) |
|
|
|
|
|
if m.max() <= 1: |
|
|
m = (m * 255).astype("uint8") |
|
|
res = model(src, m) |
|
|
return Image.fromarray(res) |
|
|
except Exception: |
|
|
pass |
|
|
|
|
|
|
|
|
try: |
|
|
import numpy as np, cv2 |
|
|
src = cv2.cvtColor(np.array(img.convert("RGB")), cv2.COLOR_RGB2BGR) |
|
|
m = np.array(mask.convert("L")) |
|
|
if m.max() <= 1: |
|
|
m = (m * 255).astype("uint8") |
|
|
|
|
|
m = cv2.GaussianBlur(m, (0,0), 1.2) |
|
|
dst = cv2.inpaint(src, (m>127).astype("uint8")*255, 3, cv2.INPAINT_TELEA) |
|
|
return Image.fromarray(cv2.cvtColor(dst, cv2.COLOR_BGR2RGB)) |
|
|
except Exception: |
|
|
return img |
|
|
|
|
|
|
|
|
def _refine_mask_with_morph(mask_pil: Image.Image, ksize: int = 5, dilate_iter: int = 1, erode_iter: int = 1) -> Image.Image: |
|
|
if PKG["cv2"] is None or PKG["np"] is None: |
|
|
return mask_pil |
|
|
cv2, np = PKG["cv2"], PKG["np"] |
|
|
m = np.array(mask_pil.convert("L")) |
|
|
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (ksize, ksize)) |
|
|
m = cv2.dilate(m, k, iterations=dilate_iter) |
|
|
m = cv2.erode(m, k, iterations=erode_iter) |
|
|
m = cv2.GaussianBlur(m, (0, 0), 1.5) |
|
|
return Image.fromarray(m.clip(0, 255).astype("uint8"), "L") |
|
|
|
|
|
def _filter_weapon_boxes(boxes: List[Tuple[int,int,int,int]], img_size: Tuple[int,int]) -> List[Tuple[int,int,int,int]]: |
|
|
W, H = img_size |
|
|
out = [] |
|
|
for (x1, y1, x2, y2) in boxes: |
|
|
w, h = max(1, x2 - x1), max(1, y2 - y1) |
|
|
area = w * h |
|
|
aspect = max(w, h) / max(1, min(w, h)) |
|
|
if area < (W * H * 0.005): |
|
|
continue |
|
|
if aspect < 2.2: |
|
|
continue |
|
|
out.append((x1, y1, x2, y2)) |
|
|
return out or boxes |
|
|
|
|
|
def _weaponless_pipeline(img: Image.Image, terms: str, logs: List[str]) -> Image.Image: |
|
|
boxes = _dino_boxes(img, terms or "weapon, sword, spear, lance, polearm, bow, gun, axe, dagger") |
|
|
if not boxes: |
|
|
logs.append("무기 탐지 실패 또는 DINO/SAM 미설치 → 무기 제거 스킵(프롬프트 가중만)") |
|
|
return img |
|
|
boxes = _filter_weapon_boxes(boxes, img.size) |
|
|
mask = _sam_mask(img, boxes) |
|
|
if mask is None: |
|
|
logs.append("SAM 마스크 생성 실패 → 무기 제거 스킵") |
|
|
return img |
|
|
mask = _refine_mask_with_morph(mask, ksize=5, dilate_iter=1, erode_iter=1) |
|
|
out = _lama_inpaint(img, mask) |
|
|
logs.append(f"무기 제거 완료 (boxes={len(boxes)})") |
|
|
return out |
|
|
|
|
|
|
|
|
|
|
|
def _draw_tpose_openpose_canvas(size=768) -> Image.Image: |
|
|
"""간단 T포즈 스켈레톤 가이드 이미지(흑배경 흰선). ControlNet 힌트로 사용.""" |
|
|
img = Image.new("RGB", (size,size), "black") |
|
|
d = ImageDraw.Draw(img) |
|
|
cx, cy = size//2, int(size*0.6) |
|
|
arm = int(size*0.35); leg = int(size*0.35); head = int(size*0.06) |
|
|
|
|
|
d.line([ (cx, cy-int(size*0.25)), (cx, cy+int(size*0.05)) ], fill="white", width=6) |
|
|
|
|
|
d.line([ (cx-arm, cy-int(size*0.20)), (cx+arm, cy-int(size*0.20)) ], fill="white", width=6) |
|
|
|
|
|
d.line([ (cx, cy+int(size*0.05)), (cx-int(leg*0.6), cy+leg) ], fill="white", width=6) |
|
|
d.line([ (cx, cy+int(size*0.05)), (cx+int(leg*0.6), cy+leg) ], fill="white", width=6) |
|
|
|
|
|
d.ellipse([ (cx-head, cy-int(size*0.35)-head), (cx+head, cy-int(size*0.35)+head) ], outline="white", width=6) |
|
|
return img |
|
|
|
|
|
def _tpose_controlnet(img: Image.Image, logs: List[str], |
|
|
strength=0.6, steps=25, guidance=7.5) -> Image.Image: |
|
|
"""ControlNet(OpenPose)으로 T포즈 강제 (설치 시). 실패 시 프롬프트만.""" |
|
|
if PKG["ControlNetModel"] is None or PKG["SD_CN_Img2Img"] is None or PKG["torch"] is None: |
|
|
logs.append("ControlNet 미설치 → T-포즈는 프롬프트로만 유도") |
|
|
return img |
|
|
try: |
|
|
dev = _device() |
|
|
cn = PKG["ControlNetModel"].from_pretrained( |
|
|
"lllyasviel/control_v11p_sd15_openpose", torch_dtype=PKG["torch"].float16 if dev=="cuda" else PKG["torch"].float32 |
|
|
) |
|
|
pipe = PKG["SD_CN_Img2Img"].from_pretrained( |
|
|
"runwayml/stable-diffusion-v1-5", |
|
|
controlnet=cn, |
|
|
torch_dtype=PKG["torch"].float16 if dev=="cuda" else PKG["torch"].float32 |
|
|
) |
|
|
if dev=="cuda": |
|
|
pipe.to("cuda") |
|
|
pose_canvas = _draw_tpose_openpose_canvas(size=max(img.size)) |
|
|
out = pipe( |
|
|
prompt="T-pose, full body, clean anime lines", |
|
|
image=img.convert("RGB"), |
|
|
control_image=pose_canvas, |
|
|
strength=float(strength), |
|
|
guidance_scale=float(guidance), |
|
|
num_inference_steps=int(steps) |
|
|
).images[0] |
|
|
logs.append("ControlNet(OpenPose) T-포즈 적용") |
|
|
return out |
|
|
except Exception as e: |
|
|
logs.append(f"T-포즈 ControlNet 실패: {e}") |
|
|
return img |
|
|
|
|
|
|
|
|
def _redraw(img: Image.Image, strength=0.5, steps=25, guidance=7.0) -> Image.Image: |
|
|
if PKG["sd_img2img"] is None or PKG["torch"] is None: |
|
|
return img |
|
|
try: |
|
|
dev = _device() |
|
|
pipe = PKG["sd_img2img"].from_pretrained( |
|
|
"runwayml/stable-diffusion-v1-5", |
|
|
torch_dtype=PKG["torch"].float16 if dev=="cuda" else PKG["torch"].float32 |
|
|
) |
|
|
if dev=="cuda": pipe.to("cuda") |
|
|
out = pipe( |
|
|
prompt="clean anime illustration, sharp lines, simple solid background", |
|
|
image=img.convert("RGB"), |
|
|
strength=float(strength), |
|
|
guidance_scale=float(guidance), |
|
|
num_inference_steps=int(steps) |
|
|
).images[0] |
|
|
return out |
|
|
except Exception: |
|
|
return img |
|
|
|
|
|
def step1_preprocess(img: Image.Image, |
|
|
keep_rembg: bool, |
|
|
do_weaponless: bool, |
|
|
weapon_terms: str, |
|
|
enforce_tpose: bool, |
|
|
tpose_strength: float, |
|
|
tpose_steps: int, |
|
|
tpose_guidance: float, |
|
|
do_redraw_flag: bool, |
|
|
redraw_strength: float, |
|
|
redraw_steps: int, |
|
|
redraw_guidance: float) -> Tuple[List[Image.Image], str, str]: |
|
|
logs=[] |
|
|
if img is None: raise gr.Error("이미지를 업로드하세요.") |
|
|
base = img.convert("RGBA") |
|
|
if keep_rembg: |
|
|
base = _remove_bg(base); logs.append("rembg 배경 제거") |
|
|
if do_weaponless: |
|
|
base = _weaponless_pipeline(base, weapon_terms, logs) |
|
|
if enforce_tpose: |
|
|
base = _tpose_controlnet(base, logs, strength=tpose_strength, steps=tpose_steps, guidance=tpose_guidance) |
|
|
if do_redraw_flag: |
|
|
base = _redraw(base, redraw_strength, redraw_steps, redraw_guidance) |
|
|
p = _save_png(base, OUT/"step1"/"input_preprocessed.png") |
|
|
return [base], p, "\n".join(logs) |
|
|
def step1_cpu(img, keep_rembg, do_weaponless, weapon_terms): |
|
|
"""CPU 단계: rembg + (있으면) DINO/SAM/LaMa로 무기 제거. CUDA 사용 금지""" |
|
|
logs = [] |
|
|
if img is None: |
|
|
raise gr.Error("이미지를 업로드하세요.") |
|
|
|
|
|
base = img.convert("RGBA") |
|
|
|
|
|
|
|
|
try: |
|
|
base2 = _remove_bg(base) if keep_rembg else base |
|
|
if keep_rembg: |
|
|
logs.append("rembg 배경 제거") |
|
|
base = base2 |
|
|
except Exception as e: |
|
|
logs.append(f"rembg 실패: {e}") |
|
|
|
|
|
|
|
|
try: |
|
|
if do_weaponless: |
|
|
base = _weaponless_pipeline(base, weapon_terms, logs) |
|
|
except Exception as e: |
|
|
logs.append(f"무기 제거 실패: {e}") |
|
|
|
|
|
|
|
|
out_path = _save_png(base, OUT / "step1" / "input_preprocessed.png") |
|
|
|
|
|
|
|
|
preview = _to_preview(base) |
|
|
return [preview], str(out_path), "\n".join(logs), str(out_path) |
|
|
|
|
|
|
|
|
def _resize_to_multiple(img: Image.Image, multiple: int = 8, max_side: int = 768) -> Image.Image: |
|
|
"""Aspect 유지 + 8의 배수 리사이즈 (최대 변은 max_side)""" |
|
|
w, h = img.size |
|
|
|
|
|
scale = min(1.0, float(max_side) / float(max(w, h))) |
|
|
w = int(w * scale); h = int(h * scale) |
|
|
|
|
|
w = max(multiple, (w // multiple) * multiple) |
|
|
h = max(multiple, (h // multiple) * multiple) |
|
|
if (w, h) != img.size: |
|
|
img = img.resize((w, h), Image.BICUBIC) |
|
|
return img |
|
|
|
|
|
def _make_tpose_canvas_like(img: Image.Image) -> Image.Image: |
|
|
"""입력과 동일 해상도의 T-포즈 가이드 캔버스 생성""" |
|
|
from PIL import ImageDraw |
|
|
w, h = img.size |
|
|
size = min(w, h) |
|
|
base = Image.new("RGB", (w, h), "black") |
|
|
square = Image.new("RGB", (size, size), "black") |
|
|
d = ImageDraw.Draw(square) |
|
|
cx, cy = size//2, int(size*0.58) |
|
|
arm = int(size*0.36); leg = int(size*0.36); head = int(size*0.06) |
|
|
|
|
|
d.line([(cx, cy-int(size*0.28)), (cx, cy+int(size*0.04))], fill="white", width=10) |
|
|
|
|
|
yA = cy-int(size*0.22) |
|
|
d.line([(cx-arm, yA), (cx+arm, yA)], fill="white", width=10) |
|
|
|
|
|
d.line([(cx, cy+int(size*0.04)), (cx-int(leg*0.65), cy+leg)], fill="white", width=10) |
|
|
d.line([(cx, cy+int(size*0.04)), (cx+int(leg*0.65), cy+leg)], fill="white", width=10) |
|
|
|
|
|
d.ellipse([(cx-head, yA-int(size*0.18)-head), (cx+head, yA-int(size*0.18)+head)], outline="white", width=10) |
|
|
|
|
|
for pt in [(cx,yA), (cx-arm,yA), (cx+arm,yA), (cx,cy), (cx,cy+int(size*0.04))]: |
|
|
d.ellipse([(pt[0]-8,pt[1]-8), (pt[0]+8,pt[1]+8)], fill="white") |
|
|
offx = (w - size)//2; offy = (h - size)//2 |
|
|
base.paste(square, (offx, offy)) |
|
|
return base |
|
|
|
|
|
|
|
|
@spaces.GPU(duration=120) |
|
|
def step1_gpu_refine( |
|
|
s1_path: str, |
|
|
enforce_tpose: bool, tpose_strength: float, tpose_steps: int, tpose_guidance: float, |
|
|
do_redraw_flag: bool, redraw_strength: float, redraw_steps: int, redraw_guidance: float |
|
|
): |
|
|
""" |
|
|
GPU 단계: ControlNet(OpenPose)로 T-포즈 강제 → (선택) img2img 리드로우. |
|
|
- ZeroGPU 규칙: torch/diffusers 로드는 이 함수 내부에서만! |
|
|
- image/control_image 해상도 동일 + 8의 배수로 강제. |
|
|
""" |
|
|
logs = [] |
|
|
if not s1_path or not Path(s1_path).exists(): |
|
|
raise gr.Error("STEP1 이미지가 없습니다. 먼저 STEP1(CPU)을 실행하세요.") |
|
|
|
|
|
|
|
|
img: PILImage.Image = PILImage.open(s1_path).convert("RGBA") |
|
|
(OUT/"step1").mkdir(parents=True, exist_ok=True) |
|
|
try: |
|
|
img.save(OUT/"step1"/"dbg_00_loaded.png") |
|
|
except Exception: |
|
|
pass |
|
|
|
|
|
|
|
|
tpose_strength = max(0.35, min(0.65, float(tpose_strength))) |
|
|
tpose_steps = int(max(12, min(28, int(tpose_steps)))) |
|
|
tpose_guidance = max(5.5, min(9.0, float(tpose_guidance))) |
|
|
redraw_strength = max(0.25, min(0.5, float(redraw_strength))) |
|
|
redraw_steps = int(max(12, min(28, int(redraw_steps)))) |
|
|
redraw_guidance = max(5.0, min(9.0, float(redraw_guidance))) |
|
|
|
|
|
|
|
|
try: |
|
|
import torch |
|
|
dev = "cuda" if torch.cuda.is_available() else "cpu" |
|
|
dtype = torch.float16 if dev == "cuda" else torch.float32 |
|
|
except Exception: |
|
|
dev = "cpu"; dtype = None |
|
|
|
|
|
if enforce_tpose: |
|
|
try: |
|
|
import math, torch |
|
|
from PIL import Image |
|
|
from diffusers import ( |
|
|
ControlNetModel, |
|
|
StableDiffusionControlNetImg2ImgPipeline, |
|
|
DPMSolverMultistepScheduler |
|
|
) |
|
|
from diffusers.utils import load_image |
|
|
from diffusers.pipelines.controlnet.multicontrolnet import MultiControlNetModel |
|
|
|
|
|
dev = "cuda" if torch.cuda.is_available() else "cpu" |
|
|
dtype = torch.float16 if dev == "cuda" else torch.float32 |
|
|
|
|
|
|
|
|
base_rgb = _resize_to_multiple(img.convert("RGB"), multiple=8, max_side=512) |
|
|
|
|
|
|
|
|
pose_orig = _openpose_canvas_from_image(base_rgb) |
|
|
pose_t = _make_tpose_canvas_like(base_rgb) |
|
|
pose_canvas= _blend_pose_canvases(pose_orig, pose_t, alpha=0.20).resize(base_rgb.size) |
|
|
|
|
|
|
|
|
cn_pose = ControlNetModel.from_pretrained( |
|
|
"lllyasviel/control_v11p_sd15_openpose", torch_dtype=dtype |
|
|
) |
|
|
cn_ref = ControlNetModel.from_pretrained( |
|
|
"lllyasviel/control_v11f1e_sd15_tile", torch_dtype=dtype |
|
|
) |
|
|
|
|
|
controlnet = MultiControlNetModel([cn_pose, cn_ref]) |
|
|
|
|
|
pipe = StableDiffusionControlNetImg2ImgPipeline.from_pretrained( |
|
|
"runwayml/stable-diffusion-v1-5", |
|
|
controlnet=controlnet, |
|
|
torch_dtype=dtype, |
|
|
safety_checker=None, feature_extractor=None, |
|
|
) |
|
|
pipe = _disable_safety(pipe) |
|
|
try: |
|
|
pipe.scheduler = DPMSolverMultistepScheduler.from_config( |
|
|
pipe.scheduler.config, use_karras_sigmas=True |
|
|
) |
|
|
except Exception: |
|
|
pass |
|
|
if dev == "cuda": |
|
|
pipe.to("cuda") |
|
|
try: |
|
|
pipe.enable_vae_slicing() |
|
|
except Exception: |
|
|
pass |
|
|
|
|
|
|
|
|
control_images = [ |
|
|
pose_canvas, |
|
|
base_rgb |
|
|
] |
|
|
|
|
|
|
|
|
POS = ( |
|
|
"clean anime illustration, full body, sharp lines, same outfit and colors as reference, " |
|
|
"T-pose tendency, white studio background, bright, high-key lighting" |
|
|
) |
|
|
NEG = ( |
|
|
"glitch, collage, cutout, fragments, abstract shapes, mosaic, compression artifacts, " |
|
|
"extra limbs, extra fingers, deformed, melted, noisy, text, watermark, black background" |
|
|
) |
|
|
|
|
|
|
|
|
steps = int(max(16, min(28, int(tpose_steps)))) |
|
|
strength = float(max(0.50, min(0.65, float(tpose_strength)))) |
|
|
guidance = float(max(7.0, min(9.5, float(tpose_guidance)))) |
|
|
|
|
|
cond_scales = [0.22, 0.70] |
|
|
start_list = [0.05, 0.00] |
|
|
end_list = [0.35, 0.80] |
|
|
|
|
|
|
|
|
out = pipe( |
|
|
prompt=POS, negative_prompt=NEG, |
|
|
image=base_rgb, |
|
|
control_image=control_images, |
|
|
num_inference_steps=steps, |
|
|
strength=strength, |
|
|
guidance_scale=guidance, |
|
|
controlnet_conditioning_scale=cond_scales, |
|
|
control_guidance_start=start_list, |
|
|
control_guidance_end=end_list, |
|
|
guess_mode=True, |
|
|
|
|
|
|
|
|
).images[0].convert("RGBA") |
|
|
|
|
|
|
|
|
if _mean_brightness(out) < 16: |
|
|
out = _lift_brightness(out, gain=1.18, gamma=0.90) |
|
|
if _mean_brightness(out) < 12: |
|
|
logs.append("T-포즈(DualCN) 결과가 어두워 원본 유지") |
|
|
else: |
|
|
img = out |
|
|
try: |
|
|
pose_canvas.save(OUT/"step1"/"dbg_pose_blend.png") |
|
|
img.save(OUT/"step1"/"dbg_03_after_dualcn.png") |
|
|
except Exception: |
|
|
pass |
|
|
logs.append("T-포즈(Dual-ControlNet) 적용: Pose 0.22 + Reference 0.70") |
|
|
|
|
|
except Exception as e: |
|
|
logs.append(f"T-포즈 Dual-ControlNet 실패: {e}") |
|
|
|
|
|
|
|
|
|
|
|
POS = ( |
|
|
"T-pose tendency, full body, same outfit and colors, clean anime lines, " |
|
|
"consistent scale, white studio background, bright, high-key lighting" |
|
|
) |
|
|
NEG = ( |
|
|
"black background, low-key lighting, extra limbs, extra fingers, deformed hands, " |
|
|
"melted face, distorted body, nsfw, cleavage, underwear, bikini, watermark, text, noisy" |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
def cond_scale_by_step(step, total): |
|
|
start, end = 0.22, 0.18 |
|
|
t = step / max(1,total-1) |
|
|
return end + (start - end) * (1.0 - t) |
|
|
|
|
|
steps = int(max(14, min(28, int(tpose_steps)))) |
|
|
strength = float(max(0.45, min(0.65, float(tpose_strength)))) |
|
|
guidance = float(max(7.0, min(9.5, float(tpose_guidance)))) |
|
|
|
|
|
|
|
|
|
|
|
def run_pose(_img_rgb, scale, start, end, n_steps): |
|
|
return pipe_pose( |
|
|
prompt=POS, |
|
|
negative_prompt=NEG, |
|
|
image=_img_rgb, |
|
|
control_image=pose_canvas, |
|
|
strength=strength, |
|
|
guidance_scale=guidance, |
|
|
num_inference_steps=n_steps, |
|
|
controlnet_conditioning_scale=scale, |
|
|
control_guidance_start=[start], |
|
|
control_guidance_end=[end], |
|
|
guess_mode=True |
|
|
).images[0].convert("RGBA") |
|
|
|
|
|
|
|
|
out_a = run_pose(base_rgb, scale=0.22, start=0.05, end=0.20, n_steps=math.ceil(steps*0.55)) |
|
|
|
|
|
inter_rgb = _resize_to_multiple(out_a.convert("RGB"), multiple=8, max_side=512) |
|
|
out_b = run_pose(inter_rgb, scale=0.18, start=0.20, end=0.35, n_steps=steps - math.ceil(steps*0.55)) |
|
|
|
|
|
out = out_b |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if _mean_brightness(out) < 16: |
|
|
out = _lift_brightness(out, gain=1.20, gamma=0.88) |
|
|
if _mean_brightness(out) < 12: |
|
|
logs.append("T-포즈(SafeMode) 결과가 어두워 원본 유지") |
|
|
else: |
|
|
img = out |
|
|
img.save(OUT/"step1"/"dbg_03_after_tpose.png") |
|
|
pose_canvas.save(OUT/"step1"/"dbg_pose_safemode.png") |
|
|
logs.append("T-포즈(SafeMode) 적용: 원본80%+T포즈20%, cond_scale↓, Karras DPM, 얼굴 보호") |
|
|
|
|
|
except Exception as e: |
|
|
logs.append(f"T-포즈 ControlNet 실패(SafeMode): {e}") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if do_redraw_flag: |
|
|
try: |
|
|
from diffusers import StableDiffusionImg2ImgPipeline |
|
|
pr = StableDiffusionImg2ImgPipeline.from_pretrained( |
|
|
"runwayml/stable-diffusion-v1-5", |
|
|
torch_dtype=dtype, |
|
|
safety_checker=None, |
|
|
feature_extractor=None |
|
|
) |
|
|
|
|
|
pr = _disable_safety(pr) |
|
|
if dev == "cuda": |
|
|
pr.to("cuda") |
|
|
|
|
|
img_for = _resize_to_multiple(img.convert("RGB"), 8, 640) |
|
|
|
|
|
img = pr( |
|
|
prompt="clean anime illustration, sharp lines, flat colors, plain white background", |
|
|
negative_prompt="glitch, mosaic, text, watermark, noisy", |
|
|
image=img_for, |
|
|
strength=float(max(0.30, min(0.45, float(redraw_strength)))), |
|
|
guidance_scale=float(max(6.5, min(9.0, float(redraw_guidance)))), |
|
|
num_inference_steps=int(max(14, min(28, int(redraw_steps)))) |
|
|
).images[0].convert("RGBA") |
|
|
|
|
|
img.save(OUT/"step1"/"dbg_05_after_redraw.png") |
|
|
logs.append("img2img 리드로우 적용") |
|
|
|
|
|
except Exception as e: |
|
|
logs.append(f"img2img 리드로우 실패: {e}") |
|
|
|
|
|
|
|
|
|
|
|
out_path = _save_png(img, OUT / "step1" / "input_preprocessed.png") |
|
|
try: |
|
|
preview = _to_preview(img) |
|
|
except Exception: |
|
|
preview = img.convert("RGB") |
|
|
|
|
|
|
|
|
return [preview], str(out_path), "\n".join(logs), str(out_path) |
|
|
|
|
|
|
|
|
|
|
|
if do_redraw_flag: |
|
|
try: |
|
|
from diffusers import StableDiffusionImg2ImgPipeline |
|
|
pipe_redraw = StableDiffusionImg2ImgPipeline.from_pretrained( |
|
|
"runwayml/stable-diffusion-v1-5", |
|
|
torch_dtype=(torch.float16 if dev == "cuda" else torch.float32) |
|
|
) |
|
|
if dev == "cuda": |
|
|
pipe_redraw.to("cuda") |
|
|
|
|
|
img_for_redraw = _resize_to_multiple(img.convert("RGB"), multiple=8, max_side=768) |
|
|
out = pipe_redraw( |
|
|
prompt="clean anime illustration, sharp lines, simple solid background", |
|
|
image=img_for_redraw, |
|
|
strength=float(redraw_strength), |
|
|
guidance_scale=float(redraw_guidance), |
|
|
num_inference_steps=int(redraw_steps), |
|
|
).images[0] |
|
|
img = out.convert("RGBA") |
|
|
logs.append("img2img 리드로우 적용") |
|
|
except Exception as e: |
|
|
logs.append(f"img2img 리드로우 실패: {e}") |
|
|
|
|
|
|
|
|
out_path = _save_png(img, OUT / "step1" / "input_preprocessed.png") |
|
|
try: |
|
|
preview = _to_preview(img) |
|
|
except Exception: |
|
|
preview = img.convert("RGB") |
|
|
|
|
|
return [preview], str(out_path), "\n".join(logs) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _compose_prompt(gender: str, weaponless: bool, extra: str, enforce_tpose: bool) -> str: |
|
|
seg = [] |
|
|
if gender and gender!="auto": seg.append(gender) |
|
|
if weaponless: seg += ["weaponless","no weapons"] |
|
|
if enforce_tpose: seg.append("T-pose") |
|
|
seg += ["anime character","clean lines","consistent style"] |
|
|
if extra: seg.append(extra) |
|
|
return ", ".join(seg) |
|
|
|
|
|
@spaces.GPU(duration=120) |
|
|
def step2_generate(space_key: str, custom_repo: str, steps: int, guidance: float, seed: int, |
|
|
s1_path: str, gender: str, weaponless: bool, enforce_tpose: bool, |
|
|
do_texture: bool, prompt_extra: str) -> Tuple[str, str, str]: |
|
|
if not s1_path or not Path(s1_path).exists(): raise gr.Error("STEP1 결과가 없습니다.") |
|
|
profile = SPACES[space_key] |
|
|
repo = profile["repo"] or custom_repo.strip() |
|
|
if not repo: raise gr.Error("커스텀 Space를 선택한 경우 레포를 입력하세요. 예) username/space") |
|
|
prompt = _compose_prompt(gender, weaponless, prompt_extra, enforce_tpose) |
|
|
|
|
|
|
|
|
ck = _cache_key(s1_path, dict(steps=steps,guidance=guidance,seed=seed,space=repo,gender=gender,weaponless=weaponless,enforce_tpose=enforce_tpose,extra=prompt_extra)) |
|
|
glb_cache = OUT/"step2"/f"{ck}.glb" |
|
|
glb_tex_cache = OUT/"step2b"/f"{ck}.glb" |
|
|
if glb_cache.exists() and (not do_texture or glb_tex_cache.exists()): |
|
|
return str(glb_cache), (str(glb_tex_cache) if glb_tex_cache.exists() else ""), "cache hit" |
|
|
|
|
|
payload = {"prompt":prompt,"image":s1_path,"steps":int(steps),"guidance":float(guidance),"seed":int(seed),"glb":str(glb_cache)} |
|
|
last_err=None; glb=None |
|
|
for variant in profile["variants_model"]: |
|
|
try: |
|
|
res = _call_space(repo, profile["api_model"], variant, payload) |
|
|
glb = _pick_glb(res) |
|
|
if glb: break |
|
|
except Exception as e: |
|
|
last_err=e; continue |
|
|
if not glb: raise gr.Error(f"모델 생성 실패: {last_err}") |
|
|
|
|
|
try: |
|
|
if Path(glb).exists(): shutil.copy2(glb, glb_cache) |
|
|
except Exception: pass |
|
|
|
|
|
tex_log="" |
|
|
if do_texture and profile.get("api_texture"): |
|
|
payload2={"glb":str(glb_cache),"steps":int(steps),"guidance":float(guidance)} |
|
|
last2=None; glb2=None |
|
|
for var in profile["variants_texture"]: |
|
|
try: |
|
|
res = _call_space(repo, profile["api_texture"], var, payload2) |
|
|
glb2=_pick_glb(res) |
|
|
if glb2: break |
|
|
except Exception as e: |
|
|
last2=e; continue |
|
|
if glb2 and Path(glb2).exists(): |
|
|
shutil.copy2(glb2, glb_tex_cache); tex_log="texture ok" |
|
|
else: |
|
|
tex_log=f"texture skip/failed: {last2}" |
|
|
return str(glb_cache), (str(glb_tex_cache) if glb_tex_cache.exists() else ""), tex_log |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_mesh(mesh: "trimesh.Trimesh") -> "trimesh.Trimesh": |
|
|
m=mesh.copy(); b=m.bounds; size=(b[1]-b[0]).max() |
|
|
if size>0: m.apply_scale(1.0/float(size)); m.apply_translation(-m.centroid) |
|
|
return m |
|
|
|
|
|
def six_view_render(glb_path: str) -> Tuple[List[str], str, str]: |
|
|
if not Path(glb_path).exists(): raise gr.Error("GLB가 없습니다.") |
|
|
if PKG["trimesh"] is None or PKG["pyrender"] is None: |
|
|
info = OUT/"step3"/"RENDER_INFO.txt"; info.write_text("pip install trimesh pyrender PyOpenGL\n") |
|
|
return [], "", "trimesh/pyrender 미설치" |
|
|
import trimesh, pyrender, numpy as np |
|
|
scene = pyrender.Scene(bg_color=[0,0,0,0]); outs=[] |
|
|
try: |
|
|
mesh = trimesh.load(glb_path) |
|
|
if isinstance(mesh, trimesh.Scene): mesh = trimesh.util.concatenate(mesh.dump()) |
|
|
mesh = _normalize_mesh(mesh) |
|
|
node = scene.add(pyrender.Mesh.from_trimesh(mesh, smooth=True)) |
|
|
scene.add(pyrender.DirectionalLight(intensity=3.0)) |
|
|
rnd = pyrender.OffscreenRenderer(768,768) |
|
|
views=[(0,0),(0,180),(0,90),(0,-90),(45,0),(-45,0)] |
|
|
for i,(elev,az) in enumerate(views): |
|
|
cam = pyrender.PerspectiveCamera(yfov=60*math.pi/180); cn = scene.add(cam) |
|
|
r=2.2; phi=math.radians(90-elev); th=math.radians(az) |
|
|
x=r*math.sin(phi)*math.cos(th); y=r*math.cos(phi); z=r*math.sin(phi)*math.sin(th) |
|
|
pose = trimesh.transformations.look_at(eye=[x,y,z], target=[0,0,0], up=[0,1,0]) |
|
|
scene.set_pose(cn, pose); color,_ = rnd.render(scene) |
|
|
p = OUT/"step3"/f"view_{i+1}.png"; Image.fromarray(color).save(p); outs.append(str(p)); scene.remove_node(cn) |
|
|
rnd.delete(); scene.remove_node(node) |
|
|
z = OUT/"step3"/"six_views.zip" |
|
|
with zipfile.ZipFile(z,"w") as zf: |
|
|
for p in outs: zf.write(p, Path(p).name) |
|
|
return outs, str(z), "ok" |
|
|
except Exception as e: |
|
|
err = OUT/"step3"/"RENDER_ERROR.txt"; err.write_text(f"{e}\n{traceback.format_exc()}"); return [], "", f"render err: {e}" |
|
|
|
|
|
def _write_blender_script(tmp_py: Path, src_glb: str, out_fbx: str, out_vrm: Optional[str], add_bs: bool): |
|
|
tmp_py.write_text(textwrap.dedent(f""" |
|
|
import bpy, os |
|
|
src=r'''{src_glb}'''; outfbx=r'''{out_fbx}'''; outvrm=r'''{out_vrm or ""}'''; add={str(bool(add_bs))} |
|
|
bpy.ops.wm.read_homefile(use_empty=True) |
|
|
bpy.ops.import_scene.gltf(filepath=src) |
|
|
meshes=[o for o in bpy.context.scene.objects if o.type=='MESH'] |
|
|
if not meshes: raise RuntimeError("no mesh") |
|
|
obj=meshes[0] |
|
|
bpy.ops.object.armature_add(enter_editmode=False, location=(0,0,0)) |
|
|
arm=bpy.context.active_object |
|
|
bpy.ops.object.select_all(action='DESELECT'); obj.select_set(True); arm.select_set(True); bpy.context.view_layer.objects.active=arm |
|
|
bpy.ops.object.parent_set(type='ARMATURE_AUTO') |
|
|
if add and obj.type=='MESH': |
|
|
if not obj.data.shape_keys: obj.shape_key_add(name='Basis') |
|
|
s=obj.shape_key_add(name='Smile'); a=obj.shape_key_add(name='Angry'); u=obj.shape_key_add(name='Surprised') |
|
|
for v in obj.data.vertices: |
|
|
s.data[v.index].co.y+=0.01; a.data[v.index].co.x-=0.01; u.data[v.index].co.z+=0.01 |
|
|
bpy.ops.export_scene.fbx(filepath=outfbx, use_active_collection=False, apply_unit_scale=True, bake_space_transform=True, object_types={{'ARMATURE','MESH'}}, add_leaf_bones=False, path_mode='AUTO') |
|
|
if outvrm: |
|
|
try: bpy.ops.export_scene.vrm(filepath=outvrm) |
|
|
except Exception as e: print("VRM export failed:", e) |
|
|
""")) |
|
|
|
|
|
def _run_blender(glb: str, add_bs: bool, do_vrm: bool) -> Tuple[str,str,str]: |
|
|
blender = os.getenv("BLENDER_PATH","blender") |
|
|
outfbx = OUT/"exports"/"character_unity.fbx" |
|
|
outvrm = OUT/"exports"/"character.vrm" if do_vrm else None |
|
|
script = Path(tempfile.gettempdir())/f"rig_{int(time.time())}.py" |
|
|
_write_blender_script(script, glb, str(outfbx), (str(outvrm) if outvrm else ""), add_bs) |
|
|
try: |
|
|
subprocess.run([blender,"--background","--python",str(script)], check=True) |
|
|
return str(outfbx), (str(outvrm) if outvrm and Path(outvrm).exists() else ""), "ok" |
|
|
except Exception as e: |
|
|
return "", "", f"blender err: {e}" |
|
|
finally: |
|
|
try: script.unlink(missing_ok=True) |
|
|
except Exception: pass |
|
|
|
|
|
def _write_unity_animator_cs() -> str: |
|
|
cs = OUT/"exports"/"CreateCharacterAnimator.cs" |
|
|
cs.write_text(textwrap.dedent(r''' |
|
|
using UnityEditor; using UnityEngine; using UnityEditor.Animations; |
|
|
public class CreateCharacterAnimator : MonoBehaviour { |
|
|
[MenuItem("Tools/Generate Character Animator")] |
|
|
static void Generate() { |
|
|
var ctrl = AnimatorController.CreateAnimatorControllerAtPath("Assets/character_controller.controller"); |
|
|
var root = ctrl.layers[0].stateMachine; |
|
|
var idle = ctrl.AddMotion(new AnimationClip()); idle.name="Idle"; var s=root.AddState("Idle"); s.motion=idle; |
|
|
var sm = ctrl.AddMotion(new AnimationClip()); sm.name="Smile"; var stS=root.AddState("Smile"); stS.motion=sm; |
|
|
var ag = ctrl.AddMotion(new AnimationClip()); ag.name="Angry"; var stA=root.AddState("Angry"); stA.motion=ag; |
|
|
var su = ctrl.AddMotion(new AnimationClip()); su.name="Surprised"; var stU=root.AddState("Surprised"); stU.motion=su; |
|
|
ctrl.AddParameter("Smile", AnimatorControllerParameterType.Trigger); |
|
|
ctrl.AddParameter("Angry", AnimatorControllerParameterType.Trigger); |
|
|
ctrl.AddParameter("Surprised", AnimatorControllerParameterType.Trigger); |
|
|
var t1 = s.AddTransition(stS); t1.AddCondition(AnimatorConditionMode.If,0,"Smile"); t1.hasExitTime=false; |
|
|
var t2 = s.AddTransition(stA); t2.AddCondition(AnimatorConditionMode.If,0,"Angry"); t2.hasExitTime=false; |
|
|
var t3 = s.AddTransition(stU); t3.AddCondition(AnimatorConditionMode.If,0,"Surprised"); t3.hasExitTime=false; |
|
|
Debug.Log("Animator created at Assets/character_controller.controller"); |
|
|
} |
|
|
}'''), encoding="utf-8") |
|
|
return str(cs) |
|
|
|
|
|
def step3_all(glb_model: str, glb_tex: str, add_bs: bool, do_vrm: bool) -> Tuple[List[Tuple[str,str]], str, str, str, str]: |
|
|
glb = glb_tex if glb_tex and Path(glb_tex).exists() else glb_model |
|
|
if not glb or not Path(glb).exists(): raise gr.Error("GLB가 없습니다. STEP2를 먼저 실행하세요.") |
|
|
views, zip_path, rlog = six_view_render(glb) |
|
|
fbx, vrm, blog = _run_blender(glb, add_bs, do_vrm) |
|
|
cs = _write_unity_animator_cs() |
|
|
gallery=[(p, Path(p).name) for p in views] |
|
|
log = "\n".join(filter(None, [rlog, blog])) |
|
|
return gallery, (zip_path if zip_path else ""), (fbx if fbx else ""), (vrm if vrm else ""), cs |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
with gr.Blocks() as demo: |
|
|
gr.Markdown("## Pixel→(rembg / **Weaponless** / **T-Pose** / Redraw)→Hunyuan3D(genshin)→**6-View**→AutoRig(FBX/VRM)+BlendShapes→Unity Animator") |
|
|
|
|
|
with gr.Row(): |
|
|
space_sel = gr.Dropdown(choices=list(SPACES.keys()), value="genshin (main)", label="Space 선택") |
|
|
custom_repo = gr.Textbox(label="커스텀 Space (선택, <custom>일 때)", placeholder="username/space-name") |
|
|
steps = gr.Slider(5, 100, value=28, step=1, label="steps") |
|
|
guidance = gr.Slider(1.0, 20.0, value=7.5, step=0.5, label="guidance") |
|
|
seed = gr.Number(value=0, precision=0, label="seed (0=랜덤)") |
|
|
|
|
|
with gr.Tab("STEP 1 — 전처리"): |
|
|
with gr.Row(): |
|
|
s1_img = gr.Image(type="pil", label="입력 이미지") |
|
|
s1_gallery = gr.Gallery(label="전처리 미리보기", columns=3, height=220) |
|
|
|
|
|
with gr.Row(): |
|
|
keep_rembg = gr.Checkbox(value=True, label="배경 제거(rembg)") |
|
|
do_weaponless = gr.Checkbox(value=True, label="무기 제거 (DINO+SAM+LaMa)") |
|
|
weapon_terms = gr.Textbox(value="weapon, sword, spear, lance, polearm, bow, gun, axe, dagger", label="무기 키워드") |
|
|
enforce_tpose = gr.Checkbox(value=True, label="T-포즈 강제 (ControlNet/OpenPose)") |
|
|
|
|
|
with gr.Row(): |
|
|
tpose_strength = gr.Slider(0.1, 0.9, value=0.6, step=0.05, label="T-포즈 강도") |
|
|
tpose_steps = gr.Slider(5, 50, value=25, step=1, label="T-포즈 steps") |
|
|
tpose_guidance = gr.Slider(1.0, 15.0, value=7.5, step=0.5, label="T-포즈 guidance") |
|
|
|
|
|
with gr.Row(): |
|
|
do_redraw_flag = gr.Checkbox(value=False, label="리드로우(img2img)") |
|
|
redraw_strength = gr.Slider(0.1, 0.9, value=0.5, step=0.05, label="리드로우 강도") |
|
|
redraw_steps = gr.Slider(5, 50, value=25, step=1, label="리드로우 steps") |
|
|
redraw_guidance = gr.Slider(1.0, 15.0, value=7.0, step=0.5, label="리드로우 guidance") |
|
|
|
|
|
with gr.Row(): |
|
|
gender = gr.Dropdown(choices=["auto","female","male","androgynous"], value="auto", label="성별") |
|
|
extra = gr.Textbox(value="", label="프롬프트 추가(선택)") |
|
|
s1_btn = gr.Button("STEP1 실행", variant="primary") |
|
|
|
|
|
s1_path = gr.Textbox(label="STEP1 결과 경로", interactive=False) |
|
|
s1_log = gr.Textbox(label="STEP1 로그", interactive=False) |
|
|
dbg_pre = gr.Image(label="전처리 이미지 확인(파일 그대로)", type="filepath") |
|
|
|
|
|
|
|
|
s1_btn.click( |
|
|
step1_cpu, |
|
|
inputs=[s1_img, keep_rembg, do_weaponless, weapon_terms], |
|
|
outputs=[s1_gallery, s1_path, s1_log, dbg_pre] |
|
|
).then( |
|
|
step1_gpu_refine, |
|
|
inputs=[s1_path, enforce_tpose, tpose_strength, tpose_steps, tpose_guidance, |
|
|
do_redraw_flag, redraw_strength, redraw_steps, redraw_guidance], |
|
|
outputs=[s1_gallery, s1_path, s1_log, dbg_pre] |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
with gr.Tab("STEP 2 — 3D 생성 (모델/텍스처)"): |
|
|
do_texture = gr.Checkbox(value=True, label="텍스처 단계 실행(지원 시)") |
|
|
s2_btn = gr.Button("STEP2 실행") |
|
|
glb_out = gr.Textbox(label="GLB(모델)", interactive=False) |
|
|
glb_tex_out = gr.Textbox(label="GLB(텍스처)", interactive=False) |
|
|
s2_log = gr.Textbox(label="STEP2 로그", interactive=False) |
|
|
|
|
|
s2_btn.click(step2_generate, |
|
|
inputs=[space_sel, custom_repo, steps, guidance, seed, s1_path, gender, do_weaponless, enforce_tpose, do_texture, extra], |
|
|
outputs=[glb_out, glb_tex_out, s2_log]) |
|
|
|
|
|
with gr.Tab("STEP 3 — 6뷰/오토리깅/Unity"): |
|
|
add_bs = gr.Checkbox(value=True, label="기본 BlendShapes(Smile/Angry/Surprised)") |
|
|
do_vrm = gr.Checkbox(value=False, label="VRM 내보내기(애드온 필요)") |
|
|
s3_btn = gr.Button("STEP3 실행") |
|
|
s3_gallery = gr.Gallery(label="6-View Render", columns=3, height=220) |
|
|
s3_zip = gr.File(label="6뷰 ZIP") |
|
|
s3_fbx = gr.File(label="Unity FBX") |
|
|
s3_vrm = gr.File(label="VRM(옵션)") |
|
|
s3_cs = gr.File(label="Unity Animator C#") |
|
|
s3_btn.click(step3_all, |
|
|
inputs=[glb_out, glb_tex_out, add_bs, do_vrm], |
|
|
outputs=[s3_gallery, s3_zip, s3_fbx, s3_vrm, s3_cs]) |
|
|
|
|
|
|
|
|
if __name__ == "__main__": |
|
|
|
|
|
os.environ.setdefault("HF_HOME", str((ROOT/".hf").resolve())) |
|
|
os.environ.setdefault("TRANSFORMERS_CACHE", str((ROOT/".hf"/"transformers").resolve())) |
|
|
demo.queue() |
|
|
demo.launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT","7860")), ssr_mode=False, share=False) |
|
|
|