Upload app.py
Browse files
app.py
CHANGED
|
@@ -1,1012 +1,901 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
try:
|
| 14 |
-
|
| 15 |
except Exception:
|
| 16 |
-
#
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
#
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
#
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
from PIL import Image as _PILImage, ImageOps as _ImageOps
|
| 38 |
-
|
| 39 |
-
def _grad_mask(w, h, top=True, band=0.22):
|
| 40 |
-
"""상/하 블렌딩용 그라데이션 알파마스크 생성 (0~1)"""
|
| 41 |
-
y = _np.linspace(0, 1, h, dtype=_np.float32)
|
| 42 |
-
if top:
|
| 43 |
-
a = _np.clip((band - y) / max(band, 1e-6), 0, 1)
|
| 44 |
-
else:
|
| 45 |
-
a = _np.clip((y - (1.0 - band)) / max(band, 1e-6), 0, 1)
|
| 46 |
-
mask = _np.tile(a[:, None], (1, w))
|
| 47 |
-
return (mask * 255).astype(_np.uint8)
|
| 48 |
-
|
| 49 |
-
def _perspective_paste(dst_rgba: _PILImage, src_rgba: _PILImage, quad_dst, alpha_mask=None):
|
| 50 |
-
"""
|
| 51 |
-
src_rgba를 dst_rgba 위에 '사다리꼴/사각형(quad_dst)'로 퍼스펙티브 워핑해서 합성.
|
| 52 |
-
quad_dst: [(x0,y0),(x1,y1),(x2,y2),(x3,y3)] (좌상,우상,우하,좌하)
|
| 53 |
-
"""
|
| 54 |
-
w, h = src_rgba.size
|
| 55 |
-
# PIL은 직접적인 perspective quad paste가 없어 transform 이용
|
| 56 |
-
# src의 사각형을 quad_dst로 보낼 변환행렬을 계산
|
| 57 |
-
src_quad = [(0,0),(w,0),(w,h),(0,h)]
|
| 58 |
-
# 호모그래피 계산 (numpy)
|
| 59 |
-
def _to_mat(points):
|
| 60 |
-
return _np.array([[x,y,1] for (x,y) in points], dtype=_np.float32)
|
| 61 |
-
A = []
|
| 62 |
-
B = []
|
| 63 |
-
for (xs,ys, _), (xd,yd) in zip(_to_mat(src_quad), quad_dst):
|
| 64 |
-
A.append([xs, ys, 1, 0, 0, 0, -xs*xd, -ys*xd])
|
| 65 |
-
B.append(xd)
|
| 66 |
-
A.append([0, 0, 0, xs, ys, 1, -xs*yd, -ys*yd])
|
| 67 |
-
B.append(yd)
|
| 68 |
-
A = _np.array(A, dtype=_np.float32)
|
| 69 |
-
B = _np.array(B, dtype=_np.float32)
|
| 70 |
-
H = _np.linalg.lstsq(A, B, rcond=None)[0]
|
| 71 |
-
H = _np.append(H, 1).reshape(3,3)
|
| 72 |
-
|
| 73 |
-
# PIL transform 은 역행렬 필요
|
| 74 |
-
Hinv = _np.linalg.inv(H)
|
| 75 |
-
coeffs = (Hinv[0,0], Hinv[0,1], Hinv[0,2],
|
| 76 |
-
Hinv[1,0], Hinv[1,1], Hinv[1,2],
|
| 77 |
-
Hinv[2,0], Hinv[2,1])
|
| 78 |
-
|
| 79 |
-
canvas = _PILImage.new("RGBA", dst_rgba.size, (0,0,0,0))
|
| 80 |
-
warped = src_rgba.transform(dst_rgba.size, _PILImage.PERSPECTIVE, coeffs, resample=_PILImage.BICUBIC)
|
| 81 |
-
if alpha_mask is not None:
|
| 82 |
-
if alpha_mask.size != dst_rgba.size:
|
| 83 |
-
alpha_mask = alpha_mask.resize(dst_rgba.size, _PILImage.BILINEAR)
|
| 84 |
-
warped.putalpha(alpha_mask)
|
| 85 |
-
return _PILImage.alpha_composite(dst_rgba, warped)
|
| 86 |
-
|
| 87 |
-
def _enhance_4views_with_top_bottom(front_img, back_img, left_img, right_img, top_img=None, bottom_img=None):
|
| 88 |
-
"""
|
| 89 |
-
4뷰에 top/bottom 정보를 얕게 투사하여 경계부(머리/어깨/발/밑면) 디테일 증강.
|
| 90 |
-
- 해상도는 4뷰 기준으로 맞춰서 합성
|
| 91 |
-
- 투사 위치:
|
| 92 |
-
* top → 각 뷰의 상단 띠(헤어/어깨 라인)로 부드럽게 블렌딩
|
| 93 |
-
* bottom → 각 뷰의 하단 띠(발/밑면)로 부드럽게 블렌딩
|
| 94 |
-
"""
|
| 95 |
-
def _to_rgba(x):
|
| 96 |
-
if isinstance(x, _PILImage.Image): im = x
|
| 97 |
-
else: im = _PILImage.open(x)
|
| 98 |
-
return im.convert("RGBA")
|
| 99 |
-
|
| 100 |
-
F = _to_rgba(front_img); Bk = _to_rgba(back_img); L = _to_rgba(left_img); R = _to_rgba(right_img)
|
| 101 |
-
views = {"front":F, "back":Bk, "left":L, "right":R}
|
| 102 |
-
W,H = F.size
|
| 103 |
-
|
| 104 |
-
if top_img is not None:
|
| 105 |
-
T = _to_rgba(top_img).resize((W,H), _PILImage.LANCZOS)
|
| 106 |
-
# 상단 22% 띠에 그라데이션 마스크
|
| 107 |
-
mask_top = _PILImage.fromarray(_grad_mask(W,H, top=True, band=0.22), mode="L")
|
| 108 |
-
# 각 뷰 상단 strip 사다리꼴로 얕게 투사 (여기선 간단히 상단 가장자리로 덮기)
|
| 109 |
-
for k in views:
|
| 110 |
-
dst = views[k]
|
| 111 |
-
# 살짝 전방 경사 느낌의 사다리꼴
|
| 112 |
-
pad = int(W*0.08)
|
| 113 |
-
h_strip = int(H*0.35)
|
| 114 |
-
quad = [(0+pad,0), (W-pad,0), (W, h_strip), (0, h_strip)]
|
| 115 |
-
views[k] = _perspective_paste(dst, T, quad_dst=quad, alpha_mask=mask_top)
|
| 116 |
-
|
| 117 |
-
if bottom_img is not None:
|
| 118 |
-
Bt = _to_rgba(bottom_img).resize((W,H), _PILImage.LANCZOS)
|
| 119 |
-
mask_bot = _PILImage.fromarray(_grad_mask(W,H, top=False, band=0.20), mode="L")
|
| 120 |
-
for k in views:
|
| 121 |
-
dst = views[k]
|
| 122 |
-
pad = int(W*0.08)
|
| 123 |
-
h_strip = int(H*0.34)
|
| 124 |
-
quad = [(0, H-h_strip), (W, H-h_strip), (W-pad, H), (0+pad, H)]
|
| 125 |
-
views[k] = _perspective_paste(dst, Bt, quad_dst=quad, alpha_mask=mask_bot)
|
| 126 |
-
|
| 127 |
-
return views["front"], views["back"], views["left"], views["right"]
|
| 128 |
-
|
| 129 |
-
def to_rgba(img: Image.Image) -> Image.Image:
|
| 130 |
-
return img.convert("RGBA")
|
| 131 |
-
|
| 132 |
-
def save_tmp(img: Image.Image, suffix=".png"):
|
| 133 |
-
f = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
|
| 134 |
-
img.save(f.name)
|
| 135 |
-
return f.name
|
| 136 |
-
|
| 137 |
-
# -------------------------------------------------------
|
| 138 |
-
# 1) 배경 제거 (알파 유지)
|
| 139 |
-
# -------------------------------------------------------
|
| 140 |
-
from rembg import remove as rembg_remove
|
| 141 |
-
def remove_bg_keep_alpha(pil_img: Image.Image) -> Image.Image:
|
| 142 |
-
arr = np.array(pil_img.convert("RGBA"))
|
| 143 |
-
out = rembg_remove(arr)
|
| 144 |
-
return Image.fromarray(out).convert("RGBA")
|
| 145 |
-
|
| 146 |
-
# -------------------------------------------------------
|
| 147 |
-
# 2) 무기 자동 제거: GroundingDINO + SAM + LaMa (FULL)
|
| 148 |
-
# -------------------------------------------------------
|
| 149 |
-
import torch, numpy as np, tempfile, os
|
| 150 |
-
from PIL import Image
|
| 151 |
-
from transformers import (
|
| 152 |
-
AutoProcessor,
|
| 153 |
-
AutoModelForZeroShotObjectDetection, # ← 중요: ObjectDetection이 아니라 ZeroShotObjectDetection
|
| 154 |
-
SamProcessor,
|
| 155 |
-
SamModel,
|
| 156 |
-
)
|
| 157 |
-
from simple_lama_inpainting import SimpleLama
|
| 158 |
-
|
| 159 |
-
# 모델 식별자 및 장치
|
| 160 |
-
GDINO_ID = os.getenv("GDINO_ID", "IDEA-Research/grounding-dino-base")
|
| 161 |
-
SAM_ID_CANDIDATES = [
|
| 162 |
-
os.getenv("SAM_ID", "facebook/sam-vit-huge"),
|
| 163 |
-
"facebook/sam-vit-base",
|
| 164 |
-
"facebook/sam-vit-large",
|
| 165 |
-
]
|
| 166 |
-
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
| 167 |
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
|
|
|
|
|
|
| 171 |
|
| 172 |
-
#
|
| 173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
| 178 |
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
|
|
|
|
|
|
| 182 |
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
]
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
try:
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
except Exception as e:
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
if
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
def canny_map(pil):
|
| 340 |
-
rgb = np.array(pil.convert("RGB"))
|
| 341 |
-
edges = cv2.Canny(rgb, 100, 200)
|
| 342 |
-
return Image.fromarray(edges).convert("RGB")
|
| 343 |
-
|
| 344 |
-
def redraw_to_clean_anime(pil_img: Image.Image, strength=0.55, scale=6.5, steps=24) -> Image.Image:
|
| 345 |
-
_lazy_redraw()
|
| 346 |
-
cn = canny_map(pil_img)
|
| 347 |
-
res = _img2img(
|
| 348 |
-
prompt=PROMPT, negative_prompt=NEG,
|
| 349 |
-
image=pil_img.convert("RGB"), control_image=cn,
|
| 350 |
-
num_inference_steps=steps, strength=strength, guidance_scale=scale,
|
| 351 |
-
)
|
| 352 |
-
out = res.images[0].convert("RGBA")
|
| 353 |
-
out.putalpha(pil_img.split()[-1])
|
| 354 |
-
return out
|
| 355 |
-
|
| 356 |
-
# -------------------------------------------------------
|
| 357 |
-
# 4) T-포즈 정렬(옵션) : OpenPose ControlNet + MediaPipe Hands(정확한 21점 합성)
|
| 358 |
-
# -------------------------------------------------------
|
| 359 |
-
from diffusers import StableDiffusionControlNetPipeline, ControlNetModel as CModel2
|
| 360 |
-
import mediapipe as mp
|
| 361 |
-
|
| 362 |
-
CONTROLNET_POSE = "lllyasviel/control_v11p_sd15_openpose"
|
| 363 |
-
_sd_pose=_cn_pose=None
|
| 364 |
-
|
| 365 |
-
_mp_hands = mp.solutions.hands.Hands(
|
| 366 |
-
static_image_mode=True,
|
| 367 |
-
max_num_hands=2,
|
| 368 |
-
model_complexity=1,
|
| 369 |
-
min_detection_confidence=0.3,
|
| 370 |
-
min_tracking_confidence=0.3
|
| 371 |
-
)
|
| 372 |
-
|
| 373 |
-
HAND_COL = {
|
| 374 |
-
"thumb": (255,105,180),
|
| 375 |
-
"index": ( 0,191,255),
|
| 376 |
-
"middle": ( 50,205, 50),
|
| 377 |
-
"ring": (255,140, 0),
|
| 378 |
-
"little": (186, 85,211),
|
| 379 |
-
}
|
| 380 |
-
FINGERS = {
|
| 381 |
-
"thumb": [0,1,2,3,4],
|
| 382 |
-
"index": [0,5,6,7,8],
|
| 383 |
-
"middle": [0,9,10,11,12],
|
| 384 |
-
"ring": [0,13,14,15,16],
|
| 385 |
-
"little": [0,17,18,19,20],
|
| 386 |
-
}
|
| 387 |
-
|
| 388 |
-
def _lazy_pose():
|
| 389 |
-
global _sd_pose,_cn_pose
|
| 390 |
-
if _cn_pose is None:
|
| 391 |
-
_cn_pose = CModel2.from_pretrained(CONTROLNET_POSE, torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32)
|
| 392 |
-
if _sd_pose is None:
|
| 393 |
-
_sd_pose = StableDiffusionControlNetPipeline.from_pretrained(
|
| 394 |
-
SD15_ID, controlnet=_cn_pose, torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32
|
| 395 |
)
|
| 396 |
-
if
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
for p in lm.landmark:
|
| 409 |
-
pts.append([p.x*w, p.y*h])
|
| 410 |
-
pts = np.array(pts, dtype=np.float32) # (21,2)
|
| 411 |
-
handedness = handed.classification[0].label.lower() # 'left' or 'right'
|
| 412 |
-
hands.append((pts, handedness))
|
| 413 |
-
return hands
|
| 414 |
-
|
| 415 |
-
def _angle(p0, p1):
|
| 416 |
-
return math.atan2(p1[1]-p0[1], p1[0]-p0[0] + 1e-8)
|
| 417 |
-
|
| 418 |
-
def place_hand_on_pose(canvas: Image.Image, src_pts: np.ndarray, target_wrist_xy, facing_hint: str=None, scale_px=110):
|
| 419 |
-
draw = ImageDraw.Draw(canvas)
|
| 420 |
-
wrist = src_pts[0]
|
| 421 |
-
up_vec = src_pts[9] - wrist
|
| 422 |
-
theta = _angle([0,0], up_vec)
|
| 423 |
-
R = np.array([[math.cos(-theta-math.pi/2), -math.sin(-theta-math.pi/2)],
|
| 424 |
-
[math.sin(-theta-math.pi/2), math.cos(-theta-math.pi/2)]], dtype=np.float32)
|
| 425 |
-
norm = (src_pts - wrist) @ R.T
|
| 426 |
-
thumb_dir = norm[2][0]
|
| 427 |
-
sx = 1.0
|
| 428 |
-
if facing_hint == "left":
|
| 429 |
-
sx = -1.0
|
| 430 |
-
elif facing_hint == "right":
|
| 431 |
-
sx = 1.0
|
| 432 |
-
else:
|
| 433 |
-
sx = 1.0 if thumb_dir > 0 else -1.0
|
| 434 |
-
norm[:,0] *= sx
|
| 435 |
-
size_ref = np.linalg.norm(norm[12] - np.array([0,0],dtype=np.float32)) + 1e-6
|
| 436 |
-
s = scale_px / size_ref
|
| 437 |
-
norm *= s
|
| 438 |
-
placed = norm + np.array(target_wrist_xy, dtype=np.float32)
|
| 439 |
-
|
| 440 |
-
r = 3
|
| 441 |
-
for name, idxs in FINGERS.items():
|
| 442 |
-
col = HAND_COL[name]
|
| 443 |
-
last = None
|
| 444 |
-
for k in idxs:
|
| 445 |
-
x,y = placed[k]
|
| 446 |
-
draw.ellipse((x-r,y-r,x+r,y+r), fill=col)
|
| 447 |
-
if last is not None:
|
| 448 |
-
draw.line((last[0],last[1],x,y), fill=col, width=3)
|
| 449 |
-
last = (x,y)
|
| 450 |
-
|
| 451 |
-
def estimate_wrist_xy_from_map(posemap_rgb: Image.Image):
|
| 452 |
-
W,H = posemap_rgb.size
|
| 453 |
-
y = int(H*0.33)
|
| 454 |
-
left = (int(W*0.20), y)
|
| 455 |
-
right = (int(W*0.80), y)
|
| 456 |
-
return left, right
|
| 457 |
-
|
| 458 |
-
def build_pose_control_image(base_pose_png: Image.Image,
|
| 459 |
-
input_image_for_hands: Image.Image,
|
| 460 |
-
add_hands_with_mediapipe=True):
|
| 461 |
-
pose = base_pose_png.convert("RGB").copy()
|
| 462 |
-
if not add_hands_with_mediapipe:
|
| 463 |
-
return pose
|
| 464 |
-
lw, rw = estimate_wrist_xy_from_map(pose)
|
| 465 |
-
hands = detect_hands_xy(input_image_for_hands)
|
| 466 |
-
if not hands:
|
| 467 |
-
return pose
|
| 468 |
-
W,_ = input_image_for_hands.size
|
| 469 |
-
for pts, handed in hands:
|
| 470 |
-
cx = pts[:,0].mean()
|
| 471 |
-
if cx < W/2:
|
| 472 |
-
place_hand_on_pose(pose, pts, lw, facing_hint="left")
|
| 473 |
-
else:
|
| 474 |
-
place_hand_on_pose(pose, pts, rw, facing_hint="right")
|
| 475 |
-
return pose
|
| 476 |
-
|
| 477 |
-
def force_tpose(pil_img: Image.Image, pose_control_img: Image.Image, strength=0.25, scale=5.0) -> Image.Image:
|
| 478 |
-
_lazy_pose()
|
| 479 |
-
r = _sd_pose(
|
| 480 |
-
prompt="", image=pil_img.convert("RGB"), control_image=pose_control_img,
|
| 481 |
-
num_inference_steps=20, strength=strength, guidance_scale=scale
|
| 482 |
-
)
|
| 483 |
-
out = r.images[0].convert("RGBA"); out.putalpha(pil_img.split()[-1])
|
| 484 |
-
return out
|
| 485 |
-
|
| 486 |
-
# -------------------------------------------------------
|
| 487 |
-
# 5) Hunyuan3D 2.1 호출 (원격 Space)
|
| 488 |
-
# -------------------------------------------------------
|
| 489 |
-
from gradio_client import Client, handle_file
|
| 490 |
-
|
| 491 |
-
def run_hunyuan3d(pil_rgba: Image.Image) -> str:
|
| 492 |
-
client = Client(HY3D_SPACE, hf_token=os.environ.get("HF_TOKEN"))
|
| 493 |
-
apis = client.view_api(all_endpoints=True)
|
| 494 |
-
api = apis[0]["api_name"] if apis else "/predict"
|
| 495 |
-
path = save_tmp(pil_rgba, ".png")
|
| 496 |
-
r = client.predict(image=handle_file(path), api_name=api)
|
| 497 |
-
def pick(x):
|
| 498 |
-
if isinstance(x,str) and x.lower().endswith(".glb"): return x
|
| 499 |
-
if hasattr(x,"name") and str(x.name).lower().endswith(".glb"): return x.name
|
| 500 |
-
if isinstance(r,(list,tuple)):
|
| 501 |
-
for it in r:
|
| 502 |
-
g = pick(it)
|
| 503 |
-
if g: return g
|
| 504 |
-
g = pick(r)
|
| 505 |
-
if g: return g
|
| 506 |
-
raise gr.Error("Hunyuan3D GLB 결과를 찾지 못했습니다.")
|
| 507 |
-
|
| 508 |
-
# -------------------------------------------------------
|
| 509 |
-
# 6) 6뷰 렌더 (가능 시: pyrender/OSMesa 필요)
|
| 510 |
-
# -------------------------------------------------------
|
| 511 |
-
def sixview_render(glb_path: str, size=1024):
|
| 512 |
try:
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
def pose(yaw,pitch,dist):
|
| 528 |
-
y=math.radians(yaw); p=math.radians(pitch)
|
| 529 |
-
x=dist*math.cos(p)*math.cos(y); yy=dist*math.sin(p); z=dist*math.cos(p)*math.sin(y)
|
| 530 |
-
eye=np.array([x,yy,z]); at=np.array([0,0,0]); up=np.array([0,1,0])
|
| 531 |
-
f=(at-eye); f=f/np.linalg.norm(f); s=np.cross(f,up); s=s/np.linalg.norm(s); u=np.cross(s,f)
|
| 532 |
-
M=np.eye(4); M[:3,0]=s; M[:3,1]=u; M[:3,2]=-f; M[:3,3]=eye; return M
|
| 533 |
-
r=pyrender.OffscreenRenderer(size,size); imgs=[]
|
| 534 |
-
dist=scale*2.0+1e-6
|
| 535 |
-
for _,(yaw,pitch) in views:
|
| 536 |
-
scene.set_pose(cnode, pose=pose(yaw,pitch,dist))
|
| 537 |
-
scene.set_pose(lnode, pose=pose(yaw,pitch,dist))
|
| 538 |
-
color,_=r.render(scene, flags=pyrender.RenderFlags.RGBA)
|
| 539 |
-
imgs.append(Image.fromarray(color).convert("RGBA"))
|
| 540 |
-
r.delete()
|
| 541 |
-
return imgs
|
| 542 |
except Exception as e:
|
| 543 |
-
|
| 544 |
-
return
|
|
|
|
| 545 |
|
| 546 |
-
#
|
| 547 |
-
# 7)
|
| 548 |
-
#
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
%BLENDER% -b --python blender\autorig_pipeline.py -- ^
|
| 568 |
-
--in "%IN%" --out "%OUT%" --export fbx ^
|
| 569 |
-
--outline --toon --surface_deform --limit_cloth_bones --try_faceit
|
| 570 |
-
'''
|
| 571 |
-
README_AUTORIG = r'''자동 리깅 번들 사용법
|
| 572 |
-
1) Blender 설치(4.x 권장)
|
| 573 |
-
2) 터미널(또는 더블클릭)로 다음 실행
|
| 574 |
-
- Linux/Mac: ./run_blender_local.sh model.glb rigged_toon.fbx
|
| 575 |
-
- Windows: run_blender_local.bat model.glb rigged_toon.fbx
|
| 576 |
-
3) 완료되면 rigged_toon.fbx(또는 glb)이 생성됩니다.
|
| 577 |
-
* 의복은 Surface Deform 기반으로 바디를 추종(본 가중치 최소화), Toon/Outline 적용, 표정키(플레이스홀더) 생성.
|
| 578 |
-
'''
|
| 579 |
-
|
| 580 |
-
def make_autorig_bundle(glb_path: str) -> str:
|
| 581 |
-
out_dir = tempfile.mkdtemp()
|
| 582 |
-
# 디렉토리 구성
|
| 583 |
-
os.makedirs(os.path.join(out_dir,"blender"), exist_ok=True)
|
| 584 |
-
# 파일 쓰기
|
| 585 |
-
with open(os.path.join(out_dir,"blender","autorig_pipeline.py"),"w",encoding="utf-8") as f:
|
| 586 |
-
f.write(AUTORIG_SCRIPT)
|
| 587 |
-
with open(os.path.join(out_dir,"run_blender_local.sh"),"w",encoding="utf-8") as f:
|
| 588 |
-
f.write(RUN_SH)
|
| 589 |
-
os.chmod(os.path.join(out_dir,"run_blender_local.sh"), 0o755)
|
| 590 |
-
with open(os.path.join(out_dir,"run_blender_local.bat"),"w",encoding="utf-8") as f:
|
| 591 |
-
f.write(RUN_BAT)
|
| 592 |
-
with open(os.path.join(out_dir,"README_AUTORIG.txt"),"w",encoding="utf-8") as f:
|
| 593 |
-
f.write(README_AUTORIG)
|
| 594 |
-
# GLB 포함
|
| 595 |
-
shutil.copy2(glb_path, os.path.join(out_dir,"model.glb"))
|
| 596 |
-
# ZIP으로 묶기
|
| 597 |
-
zipf=os.path.join(out_dir,"autorig_bundle.zip")
|
| 598 |
-
with zipfile.ZipFile(zipf,"w",zipfile.ZIP_DEFLATED) as z:
|
| 599 |
-
for root,_,files in os.walk(out_dir):
|
| 600 |
-
for name in files:
|
| 601 |
-
if name.endswith(".zip"): continue
|
| 602 |
-
full=os.path.join(root,name)
|
| 603 |
-
arc=os.path.relpath(full, out_dir)
|
| 604 |
-
z.write(full, arcname=arc)
|
| 605 |
-
return zipf
|
| 606 |
-
|
| 607 |
-
# -------------------------------------------------------
|
| 608 |
-
# 8) 파이프라인
|
| 609 |
-
# -------------------------------------------------------
|
| 610 |
-
def pipeline(image, has_weapon: bool, do_redraw: bool,
|
| 611 |
-
force_pose: bool, add_hand_details: bool,
|
| 612 |
-
user_pose_map):
|
| 613 |
-
if image is None:
|
| 614 |
-
return None, None, [], None, None
|
| 615 |
-
|
| 616 |
-
img=image.convert("RGBA")
|
| 617 |
-
|
| 618 |
-
# a) 배경제거
|
| 619 |
-
clean=remove_bg_keep_alpha(img)
|
| 620 |
-
|
| 621 |
-
# b) 무기 자동 제거
|
| 622 |
-
if has_weapon:
|
| 623 |
-
try: clean=auto_remove_weapons(clean)
|
| 624 |
-
except Exception as e: print("[WARN] weapon removal:", e)
|
| 625 |
-
|
| 626 |
-
# c) 원신풍 리드로우
|
| 627 |
-
if do_redraw:
|
| 628 |
-
try: clean=redraw_to_clean_anime(clean, strength=0.55, scale=6.5, steps=24)
|
| 629 |
-
except Exception as e: print("[WARN] redraw:", e)
|
| 630 |
-
|
| 631 |
-
# d) T-포즈 컨트롤맵 생성(+손 디테일)
|
| 632 |
-
pose_src = user_pose_map or (Image.open(DEFAULT_POSE_PATH).convert("RGB") if os.path.exists(DEFAULT_POSE_PATH) else None)
|
| 633 |
-
if force_pose and pose_src is not None:
|
| 634 |
try:
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
except Exception as e:
|
| 642 |
-
|
|
|
|
|
|
|
| 643 |
|
| 644 |
-
# e) Hunyuan3D → GLB
|
| 645 |
-
glb_path=run_hunyuan3d(clean)
|
| 646 |
|
| 647 |
-
|
| 648 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 649 |
|
| 650 |
-
# g) 오토리깅 번들 ZIP (Blender는 포함 안 함)
|
| 651 |
-
zip_path = make_autorig_bundle(glb_path)
|
| 652 |
|
| 653 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 654 |
|
| 655 |
-
# ---------- SAFE WRAPPERS & UTILS (drop-in) ----------
|
| 656 |
-
from pathlib import Path as _PPath
|
| 657 |
-
import time as _ptime
|
| 658 |
-
import uuid as _uuid
|
| 659 |
-
from PIL import Image
|
| 660 |
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
if max(w, h) > max_side:
|
| 667 |
-
scale = max_side / float(max(w, h))
|
| 668 |
-
pil = pil.resize((int(w*scale), int(h*scale)), Image.LANCZOS)
|
| 669 |
-
out_dir = _PPath("/tmp/pixel2hy3d")
|
| 670 |
out_dir.mkdir(parents=True, exist_ok=True)
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 675 |
|
| 676 |
-
|
| 677 |
try:
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 687 |
|
| 688 |
-
|
|
|
|
| 689 |
try:
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 695 |
except Exception as e:
|
| 696 |
-
|
| 697 |
-
return
|
| 698 |
-
# ------------------------------------------------------
|
| 699 |
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
# ==========================================
|
| 724 |
-
# -------------------------------------------------------
|
| 725 |
-
# 9) UI
|
| 726 |
-
# -------------------------------------------------------
|
| 727 |
with gr.Blocks() as demo:
|
| 728 |
-
gr.Markdown(
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
s1_has_weapon = gr.Checkbox(value=True, label="무기 자동 제거")
|
| 741 |
-
s1_redraw = gr.Checkbox(value=True, label="원신 느낌 리드로우")
|
| 742 |
-
s1_force_t = gr.Checkbox(value=True, label="T-포즈 강제")
|
| 743 |
-
s1_hand = gr.Checkbox(value=True, label="손가락 21점 보정(가능 시)")
|
| 744 |
-
gender = gr.Radio(["남","여"], value="남", label="성별 선택", interactive=True)
|
| 745 |
-
s1_pose = gr.Image(type="pil", label="사용자 지정 포즈(OpenPose PNG, 선택)", interactive=True)
|
| 746 |
-
s1_space = gr.Textbox(value=HY3D_SPACE, label="Hunyuan3D Space ID/URL")
|
| 747 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 748 |
with gr.Row():
|
| 749 |
-
|
| 750 |
-
|
|
|
|
|
|
|
| 751 |
with gr.Row():
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
|
|
|
| 756 |
with gr.Row():
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
#
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 788 |
if img is None:
|
| 789 |
-
raise gr.Error("이미지를
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
try:
|
| 794 |
-
clean = auto_remove_weapons(clean)
|
| 795 |
-
except Exception as e:
|
| 796 |
-
print("[WARN] weapon:", e)
|
| 797 |
-
if do_redraw:
|
| 798 |
-
try:
|
| 799 |
-
g = {"남":"male","여":"female"}.get((gender_choice or "").strip(), "neutral")
|
| 800 |
-
clean = _gpu_redraw(clean, gender=g, strength=0.55, scale=6.5, steps=22)
|
| 801 |
-
except Exception as e:
|
| 802 |
-
print("[WARN] redraw:", e)
|
| 803 |
-
pose_src = pose_img or (Image.open(DEFAULT_POSE_PATH).convert("RGB") if os.path.exists(DEFAULT_POSE_PATH) else None)
|
| 804 |
-
if force_t and pose_src is not None:
|
| 805 |
-
try:
|
| 806 |
-
pose_control = build_pose_control_image(base_pose_png=pose_src, input_image_for_hands=clean, add_hands_with_mediapipe=hand)
|
| 807 |
-
clean = _gpu_force_tpose(clean, pose_control, strength=0.25, scale=5.0)
|
| 808 |
-
except Exception as e:
|
| 809 |
-
print("[WARN] tpose:", e)
|
| 810 |
-
|
| 811 |
-
prep_path = _save_tmp_pil(clean, ".png", max_side=768)
|
| 812 |
-
cfg = _hy3d_cfg_generic("HY3D_S1")
|
| 813 |
try:
|
| 814 |
-
|
| 815 |
-
|
|
|
|
|
|
|
| 816 |
except Exception as e:
|
| 817 |
-
raise gr.Error(f"STEP1
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
out_dir = Path("/tmp/six_views") / str(int(time.time()))
|
| 822 |
-
views = _render_six_views(white_glb, out_dir, img_size=768)
|
| 823 |
-
zpath = _zip_files(views, out_dir / "six_views.zip")
|
| 824 |
-
def _pilp(p): return Image.open(p).convert("RGBA")
|
| 825 |
-
log = f"[STEP1] prep={prep_path}\nwhite={white_glb}\nzip={zpath}\nviews={views}"
|
| 826 |
-
return (_pilp(views["front"]), _pilp(views["right"]), _pilp(views["back"]), _pilp(views["left"]),
|
| 827 |
-
_pilp(views["top"]), _pilp(views["bottom"]), zpath, log)
|
| 828 |
-
|
| 829 |
-
def _step2_model(front, back, left, right, top, bottom, space_id):
|
| 830 |
-
if not (front and back and left and right):
|
| 831 |
-
raise gr.Error("front/back/left/right 이미지를 모두 업로드하세요.")
|
| 832 |
-
if top or bottom:
|
| 833 |
-
print("[INFO] HY3D: top/bottom 이미지는 현재 API에서 사용되지 않습니다 (4-view만 소비).")
|
| 834 |
-
cfg = _hy3d_cfg_generic("HY3D_S2")
|
| 835 |
try:
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 839 |
except Exception as e:
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
def _step3_texture(front, back, left, right, top, bottom, space_id):
|
| 847 |
-
if not (front and back and left and right):
|
| 848 |
-
raise gr.Error("front/back/left/right 이미지를 모두 업로드하세요.")
|
| 849 |
-
if top or bottom:
|
| 850 |
-
f2, b2, l2, r2 = _enhance_4views_with_top_bottom(front, back, left, right, top, bottom)
|
| 851 |
-
from pathlib import Path as _Path
|
| 852 |
-
import time as _time
|
| 853 |
-
stamp = str(int(_time.time()*1000))
|
| 854 |
-
tmp_dir = _Path("/tmp/enh4_"+stamp); tmp_dir.mkdir(parents=True, exist_ok=True)
|
| 855 |
-
f_path = str(tmp_dir/"front.png"); f2.save(f_path)
|
| 856 |
-
b_path = str(tmp_dir/"back.png"); b2.save(b_path)
|
| 857 |
-
l_path = str(tmp_dir/"left.png"); l2.save(l_path)
|
| 858 |
-
r_path = str(tmp_dir/"right.png"); r2.save(r_path)
|
| 859 |
-
front, back, left, right = f_path, b_path, l_path, r_path
|
| 860 |
-
|
| 861 |
-
cfg = _hy3d_cfg_generic("HY3D_T")
|
| 862 |
try:
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 866 |
except Exception as e:
|
| 867 |
-
raise gr.Error(f"
|
| 868 |
-
|
| 869 |
-
|
| 870 |
-
log
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 888 |
)
|
| 889 |
|
| 890 |
-
|
| 891 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 892 |
|
| 893 |
-
|
| 894 |
-
|
| 895 |
-
|
| 896 |
-
|
| 897 |
-
|
| 898 |
-
|
| 899 |
-
|
| 900 |
-
|
| 901 |
-
|
| 902 |
-
def _hy3d_cfg_generic(prefix="HY3D"):
|
| 903 |
-
return dict(
|
| 904 |
-
steps=int(os.getenv(f"{prefix}_STEPS", "25")),
|
| 905 |
-
guidance_scale=float(os.getenv(f"{prefix}_GUIDE", "6.5")),
|
| 906 |
-
seed=int(os.getenv(f"{prefix}_SEED", "1234")),
|
| 907 |
-
octree_resolution=int(os.getenv(f"{prefix}_OCT", "128")),
|
| 908 |
-
check_box_rembg=False,
|
| 909 |
-
num_chunks=int(os.getenv(f"{prefix}_CHUNKS", "7500")),
|
| 910 |
-
randomize_seed=False
|
| 911 |
-
)
|
| 912 |
|
| 913 |
-
|
| 914 |
-
|
| 915 |
-
|
| 916 |
-
|
| 917 |
-
|
| 918 |
-
# center and scale to fit into unit cube for consistent rendering
|
| 919 |
-
scene = mesh.scene() if hasattr(mesh, "scene") else _trimesh.Scene(mesh)
|
| 920 |
-
bounds = scene.bounds
|
| 921 |
-
minv, maxv = bounds
|
| 922 |
-
size = (maxv - minv).max()
|
| 923 |
-
center = (minv + maxv) / 2.0
|
| 924 |
-
T = _np.eye(4)
|
| 925 |
-
T[:3, 3] = -center
|
| 926 |
-
S = _np.eye(4)
|
| 927 |
-
if size > 0:
|
| 928 |
-
S[0,0] = S[1,1] = S[2,2] = 2.0/size # fit roughly into [-1,1]
|
| 929 |
-
tf = S @ T
|
| 930 |
-
return mesh.apply_transform(tf)
|
| 931 |
|
| 932 |
-
def _render_six_views(glb_path, out_dir, img_size=768):
|
| 933 |
-
out_dir = Path(out_dir)
|
| 934 |
-
out_dir.mkdir(parents=True, exist_ok=True)
|
| 935 |
-
mesh = _trimesh.load(glb_path, force="mesh")
|
| 936 |
-
if not isinstance(mesh, _trimesh.Trimesh) and hasattr(mesh, "dump"):
|
| 937 |
-
# Some GLBs load as Scene: merge
|
| 938 |
-
mesh = _trimesh.util.concatenate(mesh.dump())
|
| 939 |
-
_normalize_mesh_to_unit(mesh)
|
| 940 |
-
|
| 941 |
-
scene = _pyrender.Scene(ambient_light=_np.array([0.35,0.35,0.35,1.0]), bg_color=[0,0,0,0])
|
| 942 |
-
mesh_node = _pyrender.Mesh.from_trimesh(mesh, smooth=True)
|
| 943 |
-
scene.add(mesh_node)
|
| 944 |
-
|
| 945 |
-
light = _pyrender.DirectionalLight(color=_np.ones(3), intensity=3.0)
|
| 946 |
-
scene.add(light, pose=_np.eye(4))
|
| 947 |
-
|
| 948 |
-
r = _pyrender.OffscreenRenderer(viewport_width=img_size, viewport_height=img_size)
|
| 949 |
-
|
| 950 |
-
def cam_pose(dir_vec):
|
| 951 |
-
# Look from dir_vec to origin with up inferred
|
| 952 |
-
dir_vec = _np.array(dir_vec, dtype=float)
|
| 953 |
-
eye = dir_vec * 2.2 # distance
|
| 954 |
-
at = _np.array([0.0,0.0,0.0])
|
| 955 |
-
up = _np.array([0.0,1.0,0.0])
|
| 956 |
-
if _np.allclose(dir_vec, [0,1,0]) or _np.allclose(dir_vec, [0,-1,0]):
|
| 957 |
-
up = _np.array([0.0,0.0,1.0])
|
| 958 |
-
z = (at - eye)
|
| 959 |
-
z = z / _np.linalg.norm(z)
|
| 960 |
-
x = _np.cross(up, z); x = x / _np.linalg.norm(x)
|
| 961 |
-
y = _np.cross(z, x)
|
| 962 |
-
M = _np.eye(4)
|
| 963 |
-
M[:3,0] = x; M[:3,1] = y; M[:3,2] = z; M[:3,3] = eye
|
| 964 |
-
return M
|
| 965 |
-
|
| 966 |
-
cam = _pyrender.PerspectiveCamera(yfov=_np.deg2rad(40.0))
|
| 967 |
-
# Directions: +Z(front), -Z(back), +X(right), -X(left), +Y(top), -Y(bottom)
|
| 968 |
-
views = {
|
| 969 |
-
"front": [0,0,1],
|
| 970 |
-
"back": [0,0,-1],
|
| 971 |
-
"right": [1,0,0],
|
| 972 |
-
"left": [-1,0,0],
|
| 973 |
-
"top": [0,1,0],
|
| 974 |
-
"bottom":[0,-1,0],
|
| 975 |
-
}
|
| 976 |
-
out_files = {}
|
| 977 |
-
for name, dv in views.items():
|
| 978 |
-
pose = cam_pose(dv)
|
| 979 |
-
node = scene.add(cam, pose=pose)
|
| 980 |
-
color, _ = r.render(scene, flags=_pyrender.RenderFlags.RGBA)
|
| 981 |
-
scene.remove_node(node)
|
| 982 |
-
img = _PILImage.fromarray(color, mode="RGBA")
|
| 983 |
-
p = out_dir / f"{name}.png"
|
| 984 |
-
_save_png_rgba(img, str(p))
|
| 985 |
-
out_files[name] = str(p)
|
| 986 |
-
|
| 987 |
-
r.delete()
|
| 988 |
-
return out_files
|
| 989 |
-
|
| 990 |
-
def _call_positional_multi(space_id, api_name, img_front=None, img_back=None, img_left=None, img_right=None, cfg=None):
|
| 991 |
-
cfg = cfg or _hy3d_cfg_generic()
|
| 992 |
-
client = _Client(space_id, hf_token=os.getenv("HF_TOKEN"))
|
| 993 |
-
kw = []
|
| 994 |
-
def hf(x):
|
| 995 |
-
return _handle_file(x) if isinstance(x, (str, Path)) else x
|
| 996 |
-
# positional order as discovered: image, mv_front, mv_back, mv_left, mv_right, steps, guidance, seed, octree, rembg, chunks, rand
|
| 997 |
-
return client.predict(
|
| 998 |
-
hf(img_front) if img_front else None,
|
| 999 |
-
hf(img_front), hf(img_back), hf(img_left), hf(img_right),
|
| 1000 |
-
cfg["steps"], cfg["guidance_scale"], cfg["seed"], cfg["octree_resolution"],
|
| 1001 |
-
cfg["check_box_rembg"], cfg["num_chunks"], cfg["randomize_seed"],
|
| 1002 |
-
api_name=api_name,
|
| 1003 |
-
)
|
| 1004 |
|
| 1005 |
-
|
| 1006 |
-
|
| 1007 |
-
out_zip = Path(out_zip)
|
| 1008 |
-
out_zip.parent.mkdir(parents=True, exist_ok=True)
|
| 1009 |
-
with zipfile.ZipFile(out_zip, "w", compression=zipfile.ZIP_DEFLATED) as z:
|
| 1010 |
-
for k, v in file_dict.items():
|
| 1011 |
-
z.write(v, arcname=f"{k}.png")
|
| 1012 |
-
return str(out_zip)
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
app.py — Full Restored & Fixed (Hunyuan3D 3‑Step Builder, advanced pipeline)
|
| 4 |
+
|
| 5 |
+
This version restores the advanced features that were trimmed in the minimal build, and
|
| 6 |
+
fixes the NameError / symbol‑order issues by defining everything BEFORE the UI.
|
| 7 |
+
|
| 8 |
+
What’s included compared to the minimal build:
|
| 9 |
+
- GPU/ZeroGPU toggles (soft — no CUDA hard‑require; falls back to CPU when unavailable).
|
| 10 |
+
- Weapon auto‑removal pipeline (GroundingDINO → SAM → LaMa inpaint), with graceful fallbacks to rembg.
|
| 11 |
+
- Redraw/Cleanup pipeline (Canny map + Diffusers) and optional OpenPose/Hands ControlNet hint for T‑pose.
|
| 12 |
+
- Model/texturing staged steps: (2A) model creation, (2B) texture pass (if your Space/pipeline supports it).
|
| 13 |
+
- AutoRig packaging hook (graceful fallback if rigging tool isn’t available).
|
| 14 |
+
- Advanced config knobs unified in a single config dataclass; surfaced in the UI.
|
| 15 |
+
- Six‑view rendering with trimesh+pyrender (or placeholder if not installed).
|
| 16 |
+
- Clear logs, retries, and non‑crashing UX throughout.
|
| 17 |
+
|
| 18 |
+
NOTE: This file is designed to RUN even if heavy packages aren’t installed. It will skip or
|
| 19 |
+
soft‑degrade to placeholders and tell you what to install to enable the full path.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
# =========================
|
| 23 |
+
# 1) Imports & Optional Dependencies
|
| 24 |
+
# =========================
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import io
|
| 28 |
+
import os
|
| 29 |
+
import re
|
| 30 |
+
import sys
|
| 31 |
+
import json
|
| 32 |
+
import time
|
| 33 |
+
import math
|
| 34 |
+
import shutil
|
| 35 |
+
import random
|
| 36 |
+
import zipfile
|
| 37 |
+
import traceback
|
| 38 |
+
from dataclasses import dataclass, asdict
|
| 39 |
+
from pathlib import Path
|
| 40 |
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
| 41 |
+
|
| 42 |
+
# Core
|
| 43 |
+
from PIL import Image
|
| 44 |
+
|
| 45 |
+
# Optional third‑party bits — each is optional and gated
|
| 46 |
try:
|
| 47 |
+
import numpy as np
|
| 48 |
except Exception:
|
| 49 |
+
np = None # type: ignore
|
| 50 |
+
|
| 51 |
+
# Background removal quick path
|
| 52 |
+
try:
|
| 53 |
+
from rembg import remove as rembg_remove # type: ignore
|
| 54 |
+
_HAS_REMBG = True
|
| 55 |
+
except Exception:
|
| 56 |
+
_HAS_REMBG = False
|
| 57 |
+
|
| 58 |
+
# OpenCV for canny, array ops
|
| 59 |
+
try:
|
| 60 |
+
import cv2 # type: ignore
|
| 61 |
+
_HAS_CV2 = True
|
| 62 |
+
except Exception:
|
| 63 |
+
_HAS_CV2 = False
|
| 64 |
+
|
| 65 |
+
# Diffusers for redraw / ControlNet
|
| 66 |
+
try:
|
| 67 |
+
import torch # type: ignore
|
| 68 |
+
except Exception:
|
| 69 |
+
torch = None # type: ignore
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
+
try:
|
| 72 |
+
from diffusers import StableDiffusionControlNetPipeline, ControlNetModel, StableDiffusionImg2ImgPipeline # type: ignore
|
| 73 |
+
_HAS_DIFFUSERS = True
|
| 74 |
+
except Exception:
|
| 75 |
+
_HAS_DIFFUSERS = False
|
| 76 |
|
| 77 |
+
# ControlNet Aux (OpenPose/DWPose, Canny, etc.)
|
| 78 |
+
try:
|
| 79 |
+
from controlnet_aux import OpenposeDetector # type: ignore
|
| 80 |
+
_HAS_CN_AUX = True
|
| 81 |
+
except Exception:
|
| 82 |
+
_HAS_CN_AUX = False
|
| 83 |
|
| 84 |
+
# Weapon detection models (GroundingDINO, SAM) and inpaint (LaMa)
|
| 85 |
+
try:
|
| 86 |
+
import groundingdino # type: ignore
|
| 87 |
+
_HAS_DINO = True
|
| 88 |
+
except Exception:
|
| 89 |
+
_HAS_DINO = False
|
| 90 |
|
| 91 |
+
try:
|
| 92 |
+
import segment_anything # type: ignore
|
| 93 |
+
_HAS_SAM = True
|
| 94 |
+
except Exception:
|
| 95 |
+
_HAS_SAM = False
|
| 96 |
|
| 97 |
+
# LaMa inpainting (there are several wrappers; we abstract via a tiny shim)
|
| 98 |
+
try:
|
| 99 |
+
# Placeholder import name; many use 'lama_cleaner' or custom router.
|
| 100 |
+
import lama_cleaner # type: ignore
|
| 101 |
+
_HAS_LAMA = True
|
| 102 |
+
except Exception:
|
| 103 |
+
_HAS_LAMA = False
|
| 104 |
+
|
| 105 |
+
# 3D stack
|
| 106 |
+
try:
|
| 107 |
+
import trimesh # type: ignore
|
| 108 |
+
_HAS_TRIMESH = True
|
| 109 |
+
except Exception:
|
| 110 |
+
_HAS_TRIMESH = False
|
| 111 |
+
|
| 112 |
+
try:
|
| 113 |
+
import pyrender # type: ignore
|
| 114 |
+
_HAS_PYRENDER = True
|
| 115 |
+
except Exception:
|
| 116 |
+
_HAS_PYRENDER = False
|
| 117 |
+
|
| 118 |
+
# HF Space client
|
| 119 |
+
try:
|
| 120 |
+
from gradio_client import Client # type: ignore
|
| 121 |
+
_HAS_GRADIO_CLIENT = True
|
| 122 |
+
except Exception:
|
| 123 |
+
_HAS_GRADIO_CLIENT = False
|
| 124 |
+
|
| 125 |
+
# UI
|
| 126 |
+
import gradio as gr
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
# =========================
|
| 130 |
+
# 2) Configuration & Constants
|
| 131 |
+
# =========================
|
| 132 |
+
APP_ROOT = Path(__file__).resolve().parent
|
| 133 |
+
OUT_ROOT = APP_ROOT / "outputs"
|
| 134 |
+
OUT_ROOT.mkdir(parents=True, exist_ok=True)
|
| 135 |
+
|
| 136 |
+
# Environment / Space
|
| 137 |
+
HY3D_SPACE = os.environ.get("HY3D_SPACE", "tencent/Hunyuan3D-2.1")
|
| 138 |
+
HF_TOKEN = os.environ.get("HF_TOKEN")
|
| 139 |
+
CALL_TIMEOUT = int(os.environ.get("CALL_TIMEOUT", "900"))
|
| 140 |
+
CALL_RETRIES = int(os.environ.get("CALL_RETRIES", "2"))
|
| 141 |
+
|
| 142 |
+
SIX_VIEWS = [
|
| 143 |
+
(0, 0), (0, 180), (0, 90), (0, -90), (45, 0), (-45, 0)
|
| 144 |
]
|
| 145 |
+
|
| 146 |
+
# Device strategy (soft). If torch CUDA available, we’ll use it for diffusers.
|
| 147 |
+
def _pick_device() -> str:
|
| 148 |
+
try:
|
| 149 |
+
if torch is not None and torch.cuda.is_available():
|
| 150 |
+
return "cuda"
|
| 151 |
+
except Exception:
|
| 152 |
+
pass
|
| 153 |
+
return "cpu"
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
# =========================
|
| 157 |
+
# 3) Dataclasses (Unified Config & State)
|
| 158 |
+
# =========================
|
| 159 |
+
@dataclass
|
| 160 |
+
class RedrawCfg:
|
| 161 |
+
enable_redraw: bool = True
|
| 162 |
+
use_canny: bool = True
|
| 163 |
+
canny_low: int = 100
|
| 164 |
+
canny_high: int = 200
|
| 165 |
+
strength: float = 0.6
|
| 166 |
+
guidance_scale: float = 7.0
|
| 167 |
+
steps: int = 30
|
| 168 |
+
seed: int = 0
|
| 169 |
+
randomize_seed: bool = True
|
| 170 |
+
|
| 171 |
+
@dataclass
|
| 172 |
+
class ControlNetPoseCfg:
|
| 173 |
+
enable_openpose: bool = True
|
| 174 |
+
enable_hands: bool = True
|
| 175 |
+
|
| 176 |
+
@dataclass
|
| 177 |
+
class WeaponRemovalCfg:
|
| 178 |
+
enable_weapon_removal: bool = True
|
| 179 |
+
prompt_terms: str = "sword, axe, gun, spear, bow, dagger"
|
| 180 |
+
# IoU / score thresholds would go here
|
| 181 |
+
|
| 182 |
+
@dataclass
|
| 183 |
+
class Hy3DCfg:
|
| 184 |
+
prompt: str = "T‑pose anime character, weaponless, clean style"
|
| 185 |
+
negative_prompt: str = "blurry, lowres, cutoff, extra limbs"
|
| 186 |
+
t_pose_hint: bool = True
|
| 187 |
+
keep_transparent_bg: bool = True
|
| 188 |
+
upscale: bool = False
|
| 189 |
+
# Advanced knobs
|
| 190 |
+
steps: int = 50
|
| 191 |
+
guidance_scale: float = 7.0
|
| 192 |
+
seed: int = 0
|
| 193 |
+
randomize_seed: bool = True
|
| 194 |
+
octree_resolution: int = 512
|
| 195 |
+
num_chunks: int = 1
|
| 196 |
+
|
| 197 |
+
@dataclass
|
| 198 |
+
class TextureCfg:
|
| 199 |
+
enable_texture_pass: bool = False
|
| 200 |
+
tex_steps: int = 30
|
| 201 |
+
tex_guidance: float = 6.0
|
| 202 |
+
|
| 203 |
+
@dataclass
|
| 204 |
+
class AppCfg:
|
| 205 |
+
redraw: RedrawCfg = RedrawCfg()
|
| 206 |
+
pose: ControlNetPoseCfg = ControlNetPoseCfg()
|
| 207 |
+
weapon: WeaponRemovalCfg = WeaponRemovalCfg()
|
| 208 |
+
hy3d: Hy3DCfg = Hy3DCfg()
|
| 209 |
+
tex: TextureCfg = TextureCfg()
|
| 210 |
+
|
| 211 |
+
@dataclass
|
| 212 |
+
class AppState:
|
| 213 |
+
step1_png: Optional[Path] = None
|
| 214 |
+
step2_glb: Optional[Path] = None
|
| 215 |
+
step2b_textured_glb: Optional[Path] = None
|
| 216 |
+
six_views: List[Path] = None # type: ignore
|
| 217 |
+
autorig_zip: Optional[Path] = None
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
# =========================
|
| 221 |
+
# 4) Generic utils
|
| 222 |
+
# =========================
|
| 223 |
+
def _ensure_rgba(img: Image.Image) -> Image.Image:
|
| 224 |
+
return img.convert("RGBA") if img.mode != "RGBA" else img
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def _save_png(img: Image.Image, path: Union[str, Path]) -> Path:
|
| 228 |
+
path = Path(path)
|
| 229 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 230 |
+
_ensure_rgba(img).save(path, format="PNG")
|
| 231 |
+
return path
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def _zip_files(files: List[Union[str, Path]], zip_path: Union[str, Path]) -> Path:
|
| 235 |
+
zip_path = Path(zip_path)
|
| 236 |
+
zip_path.parent.mkdir(parents=True, exist_ok=True)
|
| 237 |
+
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
| 238 |
+
for f in files:
|
| 239 |
+
p = Path(f)
|
| 240 |
+
if p.exists():
|
| 241 |
+
zf.write(p, arcname=p.name)
|
| 242 |
+
return zip_path
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def _normalize_mesh_to_unit(mesh: "trimesh.Trimesh") -> "trimesh.Trimesh": # type: ignore
|
| 246 |
+
if not _HAS_TRIMESH:
|
| 247 |
+
return mesh
|
| 248 |
+
mesh = mesh.copy()
|
| 249 |
+
bbox = mesh.bounds
|
| 250 |
+
size = (bbox[1] - bbox[0]).max()
|
| 251 |
+
if size > 0:
|
| 252 |
+
mesh.apply_scale(1.0 / float(size))
|
| 253 |
+
centroid = mesh.centroid
|
| 254 |
+
mesh.apply_translation(-centroid)
|
| 255 |
+
return mesh
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def _render_six_views(glb_path: Union[str, Path], out_dir: Union[str, Path]) -> List[Path]:
|
| 259 |
+
out_dir = Path(out_dir)
|
| 260 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 261 |
+
|
| 262 |
+
if not (_HAS_TRIMESH and _HAS_PYRENDER):
|
| 263 |
+
note = out_dir / "RENDER_INFO.txt"
|
| 264 |
+
note.write_text(
|
| 265 |
+
"Install trimesh, pyrender, PyOpenGL to enable six‑view rendering.\n"
|
| 266 |
+
"pip install trimesh pyrender PyOpenGL\n"
|
| 267 |
+
)
|
| 268 |
+
return [note]
|
| 269 |
+
|
| 270 |
+
scene = pyrender.Scene(bg_color=[0, 0, 0, 0])
|
| 271 |
+
outputs: List[Path] = []
|
| 272 |
+
try:
|
| 273 |
+
mesh = trimesh.load(glb_path)
|
| 274 |
+
if isinstance(mesh, trimesh.Scene):
|
| 275 |
+
mesh = trimesh.util.concatenate(mesh.dump())
|
| 276 |
+
mesh = _normalize_mesh_to_unit(mesh)
|
| 277 |
+
tm = pyrender.Mesh.from_trimesh(mesh, smooth=True)
|
| 278 |
+
node = scene.add(tm)
|
| 279 |
+
light = pyrender.DirectionalLight(intensity=3.0)
|
| 280 |
+
scene.add(light)
|
| 281 |
+
renderer = pyrender.OffscreenRenderer(512, 512)
|
| 282 |
+
|
| 283 |
+
for idx, (elev, azim) in enumerate(SIX_VIEWS):
|
| 284 |
+
cam = pyrender.PerspectiveCamera(yfov=60 * math.pi / 180)
|
| 285 |
+
cam_node = scene.add(cam)
|
| 286 |
+
r = 2.2
|
| 287 |
+
phi = math.radians(90 - elev)
|
| 288 |
+
theta = math.radians(azim)
|
| 289 |
+
x = r * math.sin(phi) * math.cos(theta)
|
| 290 |
+
y = r * math.cos(phi)
|
| 291 |
+
z = r * math.sin(phi) * math.sin(theta)
|
| 292 |
+
cam_pose = trimesh.transformations.look_at(eye=[x, y, z], target=[0, 0, 0], up=[0, 1, 0])
|
| 293 |
+
scene.set_pose(cam_node, cam_pose)
|
| 294 |
+
color, _ = renderer.render(scene)
|
| 295 |
+
out = out_dir / f"view_{idx+1}.png"
|
| 296 |
+
Image.fromarray(color).save(out)
|
| 297 |
+
outputs.append(out)
|
| 298 |
+
scene.remove_node(cam_node)
|
| 299 |
+
|
| 300 |
+
renderer.delete()
|
| 301 |
+
scene.remove_node(node)
|
| 302 |
+
return outputs
|
| 303 |
+
except Exception as e:
|
| 304 |
+
info = out_dir / "RENDER_ERROR.txt"
|
| 305 |
+
info.write_text(f"Render failed: {e}\n{traceback.format_exc()}")
|
| 306 |
+
return [info]
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
# =========================
|
| 310 |
+
# 5) Detection / Inpaint (Weapon removal)
|
| 311 |
+
# =========================
|
| 312 |
+
def _detect_weapon_boxes(img: Image.Image, terms: str) -> List[Tuple[int, int, int, int]]:
|
| 313 |
+
"""Return list of bounding boxes (x1, y1, x2, y2). Fallback: empty."""
|
| 314 |
+
if not _HAS_DINO:
|
| 315 |
+
return []
|
| 316 |
+
# Placeholder: In real usage, run GroundingDINO inference with the prompt terms.
|
| 317 |
+
# We return [] here unless you integrate an actual inference call.
|
| 318 |
+
return []
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def _mask_from_boxes_with_sam(img: Image.Image, boxes: List[Tuple[int, int, int, int]]):
|
| 322 |
+
if not _HAS_SAM or not boxes:
|
| 323 |
+
return None
|
| 324 |
+
# Placeholder: Given boxes, run SAM to get a segmentation mask. Return a numpy mask.
|
| 325 |
+
return None
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def _inpaint_lama(img: Image.Image, mask: Optional[Any]) -> Image.Image:
|
| 329 |
+
if not _HAS_LAMA or mask is None:
|
| 330 |
+
return img
|
| 331 |
+
# Placeholder: Call lama_cleaner or your preferred inpaint API.
|
| 332 |
+
return img
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
def _remove_bg(img: Image.Image) -> Image.Image:
|
| 336 |
+
if not _HAS_REMBG:
|
| 337 |
+
return _ensure_rgba(img)
|
| 338 |
+
try:
|
| 339 |
+
if np is None:
|
| 340 |
+
return _ensure_rgba(img)
|
| 341 |
+
arr = np.array(img.convert("RGBA"))
|
| 342 |
+
out = rembg_remove(arr) # type: ignore
|
| 343 |
+
return Image.fromarray(out)
|
| 344 |
+
except Exception:
|
| 345 |
+
return _ensure_rgba(img)
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
def _weapon_auto_remove_pipeline(img: Image.Image, cfg: WeaponRemovalCfg, also_remove_bg: bool = True) -> Image.Image:
|
| 349 |
+
"""DINO→SAM→LaMa when available; otherwise fallback to rembg background removal.
|
| 350 |
+
We do NOT falsely promise perfect weapon removal; this is a best‑effort pipeline.
|
| 351 |
"""
|
| 352 |
+
base = img
|
| 353 |
+
if cfg.enable_weapon_removal:
|
| 354 |
+
boxes = _detect_weapon_boxes(base, cfg.prompt_terms)
|
| 355 |
+
mask = _mask_from_boxes_with_sam(base, boxes)
|
| 356 |
+
if mask is not None:
|
| 357 |
+
base = _inpaint_lama(base, mask)
|
| 358 |
+
if also_remove_bg:
|
| 359 |
+
base = _remove_bg(base)
|
| 360 |
+
return _ensure_rgba(base)
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
# =========================
|
| 364 |
+
# 6) Redraw / T‑pose hint (ControlNet)
|
| 365 |
+
# =========================
|
| 366 |
+
class _RedrawEngines:
|
| 367 |
+
cn_openpose: Optional[OpenposeDetector] = None
|
| 368 |
+
sd_img2img: Optional[StableDiffusionImg2ImgPipeline] = None
|
| 369 |
+
sd_cn_pose: Optional[StableDiffusionControlNetPipeline] = None
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
def _init_redraw_pipelines(device: str, log: List[str]):
|
| 373 |
+
if not _HAS_DIFFUSERS:
|
| 374 |
+
log.append("diffusers 미설치 — 리드로우는 스킵")
|
| 375 |
+
return
|
| 376 |
try:
|
| 377 |
+
# A very lightweight default; users should swap to their preferred models.
|
| 378 |
+
if _RedrawEngines.sd_img2img is None:
|
| 379 |
+
_RedrawEngines.sd_img2img = StableDiffusionImg2ImgPipeline.from_pretrained(
|
| 380 |
+
"runwayml/stable-diffusion-v1-5", torch_dtype=torch.float16 if device == "cuda" else torch.float32
|
| 381 |
+
).to(device)
|
| 382 |
+
_RedrawEngines.sd_img2img.safety_checker = None
|
| 383 |
+
if _HAS_CN_AUX and _RedrawEngines.cn_openpose is None:
|
| 384 |
+
_RedrawEngines.cn_openpose = OpenposeDetector.from_pretrained("lllyasviel/ControlNet")
|
| 385 |
+
# ControlNet (OpenPose)
|
| 386 |
+
if _RedrawEngines.sd_cn_pose is None and _HAS_CN_AUX:
|
| 387 |
+
try:
|
| 388 |
+
cn = ControlNetModel.from_pretrained("lllyasviel/sd-controlnet-openpose", torch_dtype=torch.float16 if device=="cuda" else torch.float32)
|
| 389 |
+
_RedrawEngines.sd_cn_pose = StableDiffusionControlNetPipeline.from_pretrained(
|
| 390 |
+
"runwayml/stable-diffusion-v1-5", controlnet=cn,
|
| 391 |
+
torch_dtype=torch.float16 if device=="cuda" else torch.float32
|
| 392 |
+
).to(device)
|
| 393 |
+
_RedrawEngines.sd_cn_pose.safety_checker = None
|
| 394 |
+
except Exception as e:
|
| 395 |
+
log.append(f"ControlNet 초기화 실패: {e}")
|
| 396 |
except Exception as e:
|
| 397 |
+
log.append(f"리드로우 파이프 초기화 실패: {e}")
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
def _make_canny(img: Image.Image, low: int, high: int) -> Optional[Image.Image]:
|
| 401 |
+
if not _HAS_CV2 or np is None:
|
| 402 |
+
return None
|
| 403 |
+
try:
|
| 404 |
+
arr = np.array(img.convert("RGB"))
|
| 405 |
+
edges = cv2.Canny(arr, low, high)
|
| 406 |
+
edges = cv2.cvtColor(edges, cv2.COLOR_GRAY2RGB)
|
| 407 |
+
return Image.fromarray(edges)
|
| 408 |
+
except Exception:
|
| 409 |
+
return None
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def _apply_img2img(img: Image.Image, prompt: str, negative: str, strength: float, guidance: float, steps: int, seed: int, device: str, log: List[str]) -> Image.Image:
|
| 413 |
+
if not _HAS_DIFFUSERS or _RedrawEngines.sd_img2img is None:
|
| 414 |
+
return img
|
| 415 |
+
try:
|
| 416 |
+
generator = None
|
| 417 |
+
if seed >= 0 and not math.isnan(seed):
|
| 418 |
+
generator = torch.Generator(device=device).manual_seed(int(seed)) if torch is not None else None
|
| 419 |
+
out = _RedrawEngines.sd_img2img(
|
| 420 |
+
prompt=prompt,
|
| 421 |
+
negative_prompt=negative or None,
|
| 422 |
+
image=img,
|
| 423 |
+
strength=float(strength),
|
| 424 |
+
guidance_scale=float(guidance),
|
| 425 |
+
num_inference_steps=int(steps),
|
| 426 |
+
generator=generator,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 427 |
)
|
| 428 |
+
if hasattr(out, "images") and out.images:
|
| 429 |
+
return out.images[0]
|
| 430 |
+
return img
|
| 431 |
+
except Exception as e:
|
| 432 |
+
log.append(f"img2img 실패: {e}")
|
| 433 |
+
return img
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
def _apply_openpose_tpose(img: Image.Image, prompt: str, negative: str, guidance: float, steps: int, seed: int, device: str, log: List[str]) -> Image.Image:
|
| 437 |
+
"""Use OpenPose ControlNet to nudge towards T‑pose. This is a hint, not a guarantee."""
|
| 438 |
+
if not (_HAS_DIFFUSERS and _HAS_CN_AUX and _RedrawEngines.sd_cn_pose is not None and _RedrawEngines.cn_openpose is not None):
|
| 439 |
+
return img
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 440 |
try:
|
| 441 |
+
pose_map = _RedrawEngines.cn_openpose(img)
|
| 442 |
+
generator = torch.Generator(device=device).manual_seed(int(seed)) if (torch is not None and seed >= 0) else None
|
| 443 |
+
out = _RedrawEngines.sd_cn_pose(
|
| 444 |
+
prompt=prompt,
|
| 445 |
+
negative_prompt=negative or None,
|
| 446 |
+
image=img,
|
| 447 |
+
control_image=pose_map,
|
| 448 |
+
num_inference_steps=int(steps),
|
| 449 |
+
guidance_scale=float(guidance),
|
| 450 |
+
generator=generator,
|
| 451 |
+
)
|
| 452 |
+
if hasattr(out, "images") and out.images:
|
| 453 |
+
return out.images[0]
|
| 454 |
+
return img
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 455 |
except Exception as e:
|
| 456 |
+
log.append(f"OpenPose ControlNet 실패: {e}")
|
| 457 |
+
return img
|
| 458 |
+
|
| 459 |
|
| 460 |
+
# =========================
|
| 461 |
+
# 7) Hunyuan3D Space calls
|
| 462 |
+
# =========================
|
| 463 |
+
def _call_positional_multi(
|
| 464 |
+
inputs: List[Any],
|
| 465 |
+
fn_index: Optional[int] = None,
|
| 466 |
+
api_name: Optional[str] = None,
|
| 467 |
+
space_id: Optional[str] = None,
|
| 468 |
+
hf_token: Optional[str] = None,
|
| 469 |
+
timeout: int = CALL_TIMEOUT,
|
| 470 |
+
retries: int = CALL_RETRIES,
|
| 471 |
+
) -> Any:
|
| 472 |
+
if not _HAS_GRADIO_CLIENT:
|
| 473 |
+
raise RuntimeError("gradio_client 미설치: pip install gradio_client")
|
| 474 |
+
space = space_id or HY3D_SPACE
|
| 475 |
+
if not space:
|
| 476 |
+
raise RuntimeError("Space id 가 없습니다 (HY3D_SPACE env 설정 또는 인수 지정)")
|
| 477 |
+
client_kwargs = {"hf_token": hf_token or HF_TOKEN} if (hf_token or HF_TOKEN) else {}
|
| 478 |
+
|
| 479 |
+
last_err: Optional[Exception] = None
|
| 480 |
+
for attempt in range(1, retries + 2):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 481 |
try:
|
| 482 |
+
client = Client(space, **client_kwargs) # type: ignore
|
| 483 |
+
if api_name is not None:
|
| 484 |
+
return client.predict(*inputs, api_name=api_name, timeout=timeout)
|
| 485 |
+
if fn_index is not None:
|
| 486 |
+
return client.predict(*inputs, fn_index=fn_index, timeout=timeout)
|
| 487 |
+
raise RuntimeError("fn_index 또는 api_name 을 지정하세요.")
|
| 488 |
except Exception as e:
|
| 489 |
+
last_err = e
|
| 490 |
+
time.sleep(min(2 * attempt, 6))
|
| 491 |
+
raise RuntimeError(f"Space call 실패: {last_err}")
|
| 492 |
|
|
|
|
|
|
|
| 493 |
|
| 494 |
+
def _pick_glb_from_result(r: Any) -> Optional[str]:
|
| 495 |
+
def pick(x: Any) -> Optional[str]:
|
| 496 |
+
try:
|
| 497 |
+
if isinstance(x, str) and x.lower().endswith(".glb"):
|
| 498 |
+
return x
|
| 499 |
+
if hasattr(x, "name") and str(getattr(x, "name")).lower().endswith(".glb"):
|
| 500 |
+
return str(getattr(x, "name"))
|
| 501 |
+
if isinstance(x, dict):
|
| 502 |
+
name = x.get("name") or x.get("path")
|
| 503 |
+
if name and str(name).lower().endswith(".glb"):
|
| 504 |
+
return str(name)
|
| 505 |
+
except Exception:
|
| 506 |
+
return None
|
| 507 |
+
return None
|
| 508 |
+
if isinstance(r, (list, tuple)):
|
| 509 |
+
for it in r:
|
| 510 |
+
g = pick(it)
|
| 511 |
+
if g:
|
| 512 |
+
return g
|
| 513 |
+
return None
|
| 514 |
+
return pick(r)
|
| 515 |
|
|
|
|
|
|
|
| 516 |
|
| 517 |
+
# =========================
|
| 518 |
+
# 8) AutoRig (hook / placeholder)
|
| 519 |
+
# =========================
|
| 520 |
+
def _try_autorig(glb_path: Union[str, Path], out_dir: Union[str, Path]) -> Optional[Path]:
|
| 521 |
+
"""Placeholder for an AutoRig tool. If you have a CLI, call it here.
|
| 522 |
+
Returns a zip path including rigged model and textures, or None if not available.
|
| 523 |
+
"""
|
| 524 |
+
out_dir = Path(out_dir)
|
| 525 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 526 |
+
# If you have an AutoRig binary, run it here via subprocess and zip outputs.
|
| 527 |
+
# For now, just zip the GLB itself as a stub.
|
| 528 |
+
zip_path = out_dir / f"autorig_{Path(glb_path).stem}.zip"
|
| 529 |
+
_zip_files([glb_path], zip_path)
|
| 530 |
+
return zip_path if zip_path.exists() else None
|
| 531 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 532 |
|
| 533 |
+
# =========================
|
| 534 |
+
# 9) Step Executors
|
| 535 |
+
# =========================
|
| 536 |
+
def run_step1(img: Image.Image, cfg: AppCfg, device: str, log: List[str]) -> Path:
|
| 537 |
+
out_dir = OUT_ROOT / "step1"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 538 |
out_dir.mkdir(parents=True, exist_ok=True)
|
| 539 |
+
base = img
|
| 540 |
+
|
| 541 |
+
# 1) Weapon remove + background (best‑effort)
|
| 542 |
+
base = _weapon_auto_remove_pipeline(base, cfg.weapon, also_remove_bg=cfg.hy3d.keep_transparent_bg)
|
| 543 |
+
|
| 544 |
+
# 2) Optional redraw (canny or plain img2img)
|
| 545 |
+
if cfg.redraw.enable_redraw:
|
| 546 |
+
seed = random.randint(0, 10_000_000) if cfg.redraw.randomize_seed else cfg.redraw.seed
|
| 547 |
+
_init_redraw_pipelines(device, log)
|
| 548 |
+
if cfg.redraw.use_canny:
|
| 549 |
+
canny = _make_canny(base, cfg.redraw.canny_low, cfg.redraw.canny_high)
|
| 550 |
+
# If no canny available, we still try img2img directly.
|
| 551 |
+
base = _apply_img2img(
|
| 552 |
+
img=base,
|
| 553 |
+
prompt=cfg.hy3d.prompt,
|
| 554 |
+
negative=cfg.hy3d.negative_prompt,
|
| 555 |
+
strength=cfg.redraw.strength,
|
| 556 |
+
guidance=cfg.redraw.guidance_scale,
|
| 557 |
+
steps=cfg.redraw.steps,
|
| 558 |
+
seed=seed,
|
| 559 |
+
device=device,
|
| 560 |
+
log=log,
|
| 561 |
+
)
|
| 562 |
+
|
| 563 |
+
# 3) Optional T‑pose hint via OpenPose ControlNet
|
| 564 |
+
if cfg.hy3d.t_pose_hint and cfg.pose.enable_openpose:
|
| 565 |
+
seed2 = random.randint(0, 10_000_000) if cfg.hy3d.randomize_seed else cfg.hy3d.seed
|
| 566 |
+
base = _apply_openpose_tpose(
|
| 567 |
+
img=base,
|
| 568 |
+
prompt=cfg.hy3d.prompt,
|
| 569 |
+
negative=cfg.hy3d.negative_prompt,
|
| 570 |
+
guidance=cfg.hy3d.guidance_scale,
|
| 571 |
+
steps=cfg.hy3d.steps,
|
| 572 |
+
seed=seed2,
|
| 573 |
+
device=device,
|
| 574 |
+
log=log,
|
| 575 |
+
)
|
| 576 |
+
|
| 577 |
+
out_png = out_dir / "input_preprocessed.png"
|
| 578 |
+
_save_png(base, out_png)
|
| 579 |
+
return out_png
|
| 580 |
+
|
| 581 |
+
|
| 582 |
+
def run_step2_model(in_png: Path, cfg: AppCfg, api_name: Optional[str], fn_index: Optional[int], log: List[str]) -> Optional[Path]:
|
| 583 |
+
if not in_png.exists():
|
| 584 |
+
raise RuntimeError("STEP1 결과 PNG가 없습니다.")
|
| 585 |
+
|
| 586 |
+
if not _HAS_GRADIO_CLIENT:
|
| 587 |
+
raise RuntimeError("gradio_client 미설치: pip install gradio_client")
|
| 588 |
+
|
| 589 |
+
# Construct input variants for the Space. Adjust to your Space signature.
|
| 590 |
+
tried: List[str] = []
|
| 591 |
+
result: Any = None
|
| 592 |
|
| 593 |
+
# Variant A: (prompt, image, steps, guidance, seed)
|
| 594 |
try:
|
| 595 |
+
tried.append("(prompt, image, steps, guidance, seed)")
|
| 596 |
+
steps = cfg.hy3d.steps
|
| 597 |
+
guidance = cfg.hy3d.guidance_scale
|
| 598 |
+
seed = random.randint(0, 10_000_000) if cfg.hy3d.randomize_seed else cfg.hy3d.seed
|
| 599 |
+
result = _call_positional_multi([
|
| 600 |
+
cfg.hy3d.prompt,
|
| 601 |
+
str(in_png),
|
| 602 |
+
steps,
|
| 603 |
+
guidance,
|
| 604 |
+
seed,
|
| 605 |
+
], api_name=api_name, fn_index=fn_index)
|
| 606 |
+
except Exception:
|
| 607 |
+
# Variant B: (image,) only
|
| 608 |
+
tried.append("(image,)")
|
| 609 |
+
result = _call_positional_multi([str(in_png)], api_name=api_name, fn_index=fn_index)
|
| 610 |
+
|
| 611 |
+
glb_path = _pick_glb_from_result(result)
|
| 612 |
+
if not glb_path:
|
| 613 |
+
log.append("Hunyuan3D 응답에서 GLB 경로를 찾지 못했습니다. tried=" + ", ".join(tried))
|
| 614 |
+
return None
|
| 615 |
+
|
| 616 |
+
out_dir = OUT_ROOT / "step2"
|
| 617 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 618 |
+
dst = out_dir / Path(glb_path).name
|
| 619 |
+
try:
|
| 620 |
+
with open(glb_path, "rb") as rf, open(dst, "wb") as wf:
|
| 621 |
+
wf.write(rf.read())
|
| 622 |
+
except Exception:
|
| 623 |
+
dst = Path(glb_path)
|
| 624 |
+
return dst if dst.exists() else None
|
| 625 |
+
|
| 626 |
+
|
| 627 |
+
def run_step2b_texture(glb_path: Path, cfg: AppCfg, api_name: Optional[str], fn_index: Optional[int], log: List[str]) -> Optional[Path]:
|
| 628 |
+
if not cfg.tex.enable_texture_pass:
|
| 629 |
+
return None
|
| 630 |
+
if not glb_path.exists():
|
| 631 |
+
raise RuntimeError("STEP2의 GLB가 없습니다.")
|
| 632 |
+
|
| 633 |
+
if not _HAS_GRADIO_CLIENT:
|
| 634 |
+
raise RuntimeError("gradio_client 미설치: pip install gradio_client")
|
| 635 |
|
| 636 |
+
# Adjust to your Space API for texturing (if it’s a separate endpoint)
|
| 637 |
+
tried: List[str] = []
|
| 638 |
try:
|
| 639 |
+
tried.append("(glb, tex_steps, tex_guidance)")
|
| 640 |
+
result = _call_positional_multi([
|
| 641 |
+
str(glb_path),
|
| 642 |
+
cfg.tex.tex_steps,
|
| 643 |
+
cfg.tex.tex_guidance,
|
| 644 |
+
], api_name=api_name, fn_index=fn_index)
|
| 645 |
+
glb2 = _pick_glb_from_result(result)
|
| 646 |
+
if glb2:
|
| 647 |
+
out_dir = OUT_ROOT / "step2b"
|
| 648 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 649 |
+
dst = out_dir / Path(glb2).name
|
| 650 |
+
try:
|
| 651 |
+
with open(glb2, "rb") as rf, open(dst, "wb") as wf:
|
| 652 |
+
wf.write(rf.read())
|
| 653 |
+
except Exception:
|
| 654 |
+
dst = Path(glb2)
|
| 655 |
+
return dst if dst.exists() else None
|
| 656 |
except Exception as e:
|
| 657 |
+
log.append(f"텍스처 패스 실패: {e} (tried: {tried})")
|
| 658 |
+
return None
|
|
|
|
| 659 |
|
| 660 |
+
|
| 661 |
+
def run_step3_render_and_zip(glb_path: Path, autorig: bool, log: List[str]) -> Tuple[List[Path], Optional[Path], Optional[Path]]:
|
| 662 |
+
if not glb_path.exists():
|
| 663 |
+
raise RuntimeError("GLB 파일이 없습니다.")
|
| 664 |
+
out_dir = OUT_ROOT / "step3"
|
| 665 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 666 |
+
|
| 667 |
+
# 6‑view render
|
| 668 |
+
renders = _render_six_views(glb_path, out_dir)
|
| 669 |
+
zip_path = out_dir / f"six_views_{int(time.time())}.zip"
|
| 670 |
+
_zip_files(renders, zip_path)
|
| 671 |
+
|
| 672 |
+
# AutoRig (optional)
|
| 673 |
+
rig_zip = None
|
| 674 |
+
if autorig:
|
| 675 |
+
rig_zip = _try_autorig(glb_path, out_dir)
|
| 676 |
+
|
| 677 |
+
return renders, zip_path if zip_path.exists() else None, rig_zip
|
| 678 |
+
|
| 679 |
+
|
| 680 |
+
# =========================
|
| 681 |
+
# 10) Gradio UI (3‑Step Builder)
|
| 682 |
+
# =========================
|
|
|
|
|
|
|
|
|
|
|
|
|
| 683 |
with gr.Blocks() as demo:
|
| 684 |
+
gr.Markdown(
|
| 685 |
+
"""
|
| 686 |
+
### Pixel→(Weapon/BG)→Clean Anime Redraw→(OpenPose+Hands)→Hunyuan3D→Texture→6View→AutoRig ZIP
|
| 687 |
+
|
| 688 |
+
이 데모는 **3단계**로 구성됩니다.
|
| 689 |
+
1) 전처리/리드로우/포즈힌트 (STEP 1)
|
| 690 |
+
2) 모델 생성 (STEP 2A) + 텍스처 패스(옵션, STEP 2B)
|
| 691 |
+
3) 6‑뷰 렌더 및 AutoRig ZIP (STEP 3)
|
| 692 |
+
|
| 693 |
+
⚠️ 설치가 안 된 기능은 자동으로 스킵되며, 상단 로그에 안내가 표시됩니다.
|
| 694 |
+
"""
|
| 695 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 696 |
|
| 697 |
+
with gr.Tab("3‑Step Builder"):
|
| 698 |
+
# Config accordion
|
| 699 |
+
with gr.Accordion("고급 설정 (필요 시 펼치기)", open=False):
|
| 700 |
+
with gr.Row():
|
| 701 |
+
hy_prompt = gr.Textbox(value=Hy3DCfg().prompt, label="프롬프트")
|
| 702 |
+
hy_neg = gr.Textbox(value=Hy3DCfg().negative_prompt, label="네거티브")
|
| 703 |
+
with gr.Row():
|
| 704 |
+
hy_steps = gr.Slider(10, 100, value=Hy3DCfg().steps, step=1, label="Hunyuan3D steps")
|
| 705 |
+
hy_guidance = gr.Slider(1.0, 15.0, value=Hy3DCfg().guidance_scale, step=0.1, label="guidance_scale")
|
| 706 |
+
hy_seed = gr.Number(value=0, label="seed")
|
| 707 |
+
hy_rand = gr.Checkbox(value=True, label="randomize_seed")
|
| 708 |
+
with gr.Row():
|
| 709 |
+
hy_tpose = gr.Checkbox(value=True, label="T‑pose 힌트(OpenPose)")
|
| 710 |
+
hy_keep_trans = gr.Checkbox(value=True, label="투명 배경 유지")
|
| 711 |
+
hy_upscale = gr.Checkbox(value=False, label="업스케일")
|
| 712 |
+
with gr.Row():
|
| 713 |
+
tex_enable = gr.Checkbox(value=False, label="텍스처 패스 실행")
|
| 714 |
+
tex_steps = gr.Slider(10, 100, value=30, step=1, label="Texture steps")
|
| 715 |
+
tex_guidance = gr.Slider(1.0, 15.0, value=6.0, step=0.1, label="Texture guidance")
|
| 716 |
with gr.Row():
|
| 717 |
+
redraw_enable = gr.Checkbox(value=True, label="리드로우 사용 (img2img)")
|
| 718 |
+
redraw_canny = gr.Checkbox(value=True, label="Canny 사용")
|
| 719 |
+
redraw_low = gr.Slider(50, 200, value=100, step=1, label="Canny low")
|
| 720 |
+
redraw_high = gr.Slider(100, 400, value=200, step=1, label="Canny high")
|
| 721 |
with gr.Row():
|
| 722 |
+
redraw_strength = gr.Slider(0.1, 1.0, value=0.6, step=0.05, label="img2img strength")
|
| 723 |
+
redraw_steps = gr.Slider(10, 100, value=30, step=1, label="img2img steps")
|
| 724 |
+
redraw_guidance = gr.Slider(1.0, 15.0, value=7.0, step=0.1, label="img2img guidance")
|
| 725 |
+
redraw_seed = gr.Number(value=0, label="img2img seed")
|
| 726 |
+
redraw_rand = gr.Checkbox(value=True, label="img2img randomize_seed")
|
| 727 |
with gr.Row():
|
| 728 |
+
weapon_enable = gr.Checkbox(value=True, label="무기 자동 제거 (best‑effort)")
|
| 729 |
+
weapon_terms = gr.Textbox(value="sword, axe, gun, spear, bow, dagger", label="무기 키워드")
|
| 730 |
+
|
| 731 |
+
# STEP 1 UI
|
| 732 |
+
gr.Markdown("**STEP 1 — 전처리 / 리드로우 / 포즈 힌트**")
|
| 733 |
+
s1_img = gr.Image(type="pil", label="입력 이미지")
|
| 734 |
+
s1_go = gr.Button("STEP 1 실행")
|
| 735 |
+
s1_gallery = gr.Gallery(label="전처리 결과").style(grid=[3], height=220)
|
| 736 |
+
|
| 737 |
+
# STEP 2 UI
|
| 738 |
+
gr.Markdown("**STEP 2A — Hunyuan3D 모델 생성** / **STEP 2B — 텍스처 패스(옵션)**")
|
| 739 |
+
s2_api_name = gr.Textbox(value="/run", label="Space api_name (또는 비워두고 fn_index 사용)")
|
| 740 |
+
s2_fn_index = gr.Number(value=None, label="Space fn_index")
|
| 741 |
+
s2_go = gr.Button("STEP 2A 실행 (모델 생성)")
|
| 742 |
+
s2_log = gr.Textbox(label="STEP2 로그")
|
| 743 |
+
s2_glb = gr.File(label="생성된 GLB")
|
| 744 |
+
s2b_go = gr.Button("STEP 2B 실행 (텍스처)")
|
| 745 |
+
s2b_glb = gr.File(label="텍스처 적용 GLB")
|
| 746 |
+
|
| 747 |
+
# STEP 3 UI
|
| 748 |
+
gr.Markdown("**STEP 3 — 6‑뷰 렌더 & AutoRig ZIP**")
|
| 749 |
+
autorig_enable = gr.Checkbox(value=False, label="AutoRig ZIP 생성")
|
| 750 |
+
s3_go = gr.Button("STEP 3 실행")
|
| 751 |
+
s3_gallery = gr.Gallery(label="6‑뷰 결과").style(grid=[3], height=220)
|
| 752 |
+
s3_zip = gr.File(label="6‑뷰 ZIP")
|
| 753 |
+
s3_rig = gr.File(label="AutoRig ZIP (옵션)")
|
| 754 |
+
|
| 755 |
+
# Hidden state (python only)
|
| 756 |
+
state_json = gr.State(value="{}")
|
| 757 |
+
|
| 758 |
+
# ---------- Callbacks ----------
|
| 759 |
+
def _collect_cfg(**kwargs) -> AppCfg:
|
| 760 |
+
# Build AppCfg from UI widgets
|
| 761 |
+
redraw = RedrawCfg(
|
| 762 |
+
enable_redraw=bool(kwargs.get("redraw_enable")),
|
| 763 |
+
use_canny=bool(kwargs.get("redraw_canny")),
|
| 764 |
+
canny_low=int(kwargs.get("redraw_low")),
|
| 765 |
+
canny_high=int(kwargs.get("redraw_high")),
|
| 766 |
+
strength=float(kwargs.get("redraw_strength")),
|
| 767 |
+
guidance_scale=float(kwargs.get("redraw_guidance")),
|
| 768 |
+
steps=int(kwargs.get("redraw_steps")),
|
| 769 |
+
seed=int(kwargs.get("redraw_seed")),
|
| 770 |
+
randomize_seed=bool(kwargs.get("redraw_rand")),
|
| 771 |
+
)
|
| 772 |
+
pose = ControlNetPoseCfg(enable_openpose=bool(kwargs.get("hy_tpose")), enable_hands=True)
|
| 773 |
+
weapon = WeaponRemovalCfg(enable_weapon_removal=bool(kwargs.get("weapon_enable")), prompt_terms=str(kwargs.get("weapon_terms")))
|
| 774 |
+
hy = Hy3DCfg(
|
| 775 |
+
prompt=str(kwargs.get("hy_prompt")),
|
| 776 |
+
negative_prompt=str(kwargs.get("hy_neg")),
|
| 777 |
+
t_pose_hint=bool(kwargs.get("hy_tpose")),
|
| 778 |
+
keep_transparent_bg=bool(kwargs.get("hy_keep_trans")),
|
| 779 |
+
upscale=bool(kwargs.get("hy_upscale")),
|
| 780 |
+
steps=int(kwargs.get("hy_steps")),
|
| 781 |
+
guidance_scale=float(kwargs.get("hy_guidance")),
|
| 782 |
+
seed=int(kwargs.get("hy_seed")),
|
| 783 |
+
randomize_seed=bool(kwargs.get("hy_rand")),
|
| 784 |
+
octree_resolution=512,
|
| 785 |
+
num_chunks=1,
|
| 786 |
+
)
|
| 787 |
+
tex = TextureCfg(enable_texture_pass=bool(kwargs.get("tex_enable")), tex_steps=int(kwargs.get("tex_steps")), tex_guidance=float(kwargs.get("tex_guidance")))
|
| 788 |
+
return AppCfg(redraw=redraw, pose=pose, weapon=weapon, hy3d=hy, tex=tex)
|
| 789 |
+
|
| 790 |
+
def _step1(img, **kwargs):
|
| 791 |
if img is None:
|
| 792 |
+
raise gr.Error("이미지를 업로드하세요.")
|
| 793 |
+
log: List[str] = []
|
| 794 |
+
device = _pick_device()
|
| 795 |
+
cfg = _collect_cfg(**kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 796 |
try:
|
| 797 |
+
png_path = run_step1(img, cfg, device, log)
|
| 798 |
+
st = AppState(step1_png=png_path)
|
| 799 |
+
state_json_val = json.dumps({"step1_png": str(png_path)}, ensure_ascii=False)
|
| 800 |
+
return [(str(png_path), "preprocessed")], state_json_val
|
| 801 |
except Exception as e:
|
| 802 |
+
raise gr.Error(f"STEP1 실패: {e}\n" + "\n".join(log))
|
| 803 |
+
|
| 804 |
+
def _step2(api_name, fn_index, state_s, **kwargs):
|
| 805 |
+
log: List[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 806 |
try:
|
| 807 |
+
st_d = json.loads(state_s or "{}")
|
| 808 |
+
step1_png = Path(st_d.get("step1_png", ""))
|
| 809 |
+
cfg = _collect_cfg(**kwargs)
|
| 810 |
+
fn_idx = None
|
| 811 |
+
if fn_index is not None:
|
| 812 |
+
try:
|
| 813 |
+
fn_idx = int(fn_index)
|
| 814 |
+
except Exception:
|
| 815 |
+
fn_idx = None
|
| 816 |
+
glb = run_step2_model(step1_png, cfg, api_name or None, fn_idx, log)
|
| 817 |
+
if not glb:
|
| 818 |
+
return ("STEP2: GLB 경로를 찾지 못했습니다.\n" + "\n".join(log)), None, state_s
|
| 819 |
+
st_d["step2_glb"] = str(glb)
|
| 820 |
+
return ("STEP2 성공\n" + "\n".join(log)), str(glb), json.dumps(st_d, ensure_ascii=False)
|
| 821 |
except Exception as e:
|
| 822 |
+
tb = traceback.format_exc()
|
| 823 |
+
return f"STEP2 실패: {e}\n{tb}", None, state_s
|
| 824 |
+
|
| 825 |
+
def _step2b(api_name, fn_index, state_s, **kwargs):
|
| 826 |
+
log: List[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 827 |
try:
|
| 828 |
+
st_d = json.loads(state_s or "{}")
|
| 829 |
+
glb = Path(st_d.get("step2_glb", ""))
|
| 830 |
+
if not glb.exists():
|
| 831 |
+
return None, state_s
|
| 832 |
+
cfg = _collect_cfg(**kwargs)
|
| 833 |
+
fn_idx = None
|
| 834 |
+
if fn_index is not None:
|
| 835 |
+
try:
|
| 836 |
+
fn_idx = int(fn_index)
|
| 837 |
+
except Exception:
|
| 838 |
+
fn_idx = None
|
| 839 |
+
glb2 = run_step2b_texture(glb, cfg, api_name or None, fn_idx, log)
|
| 840 |
+
if glb2:
|
| 841 |
+
st_d["step2b_glb"] = str(glb2)
|
| 842 |
+
return str(glb2), json.dumps(st_d, ensure_ascii=False)
|
| 843 |
+
return None, state_s
|
| 844 |
except Exception as e:
|
| 845 |
+
raise gr.Error(f"STEP2B 실패: {e}\n" + "\n".join(log))
|
| 846 |
+
|
| 847 |
+
def _step3(autorig, state_s):
|
| 848 |
+
log: List[str] = []
|
| 849 |
+
try:
|
| 850 |
+
st_d = json.loads(state_s or "{}")
|
| 851 |
+
glb = Path(st_d.get("step2b_glb") or st_d.get("step2_glb", ""))
|
| 852 |
+
if not glb.exists():
|
| 853 |
+
raise gr.Error("STEP2 결과 GLB가 없습니다.")
|
| 854 |
+
renders, zip_path, rig_zip = run_step3_render_and_zip(glb, bool(autorig), log)
|
| 855 |
+
gallery = [(str(p), p.name) for p in renders]
|
| 856 |
+
return gallery, str(zip_path) if zip_path else None, str(rig_zip) if rig_zip else None
|
| 857 |
+
except Exception as e:
|
| 858 |
+
raise gr.Error(f"STEP3 실패: {e}\n" + "\n".join(log))
|
| 859 |
+
|
| 860 |
+
# Wire
|
| 861 |
+
s1_go.click(
|
| 862 |
+
_step1,
|
| 863 |
+
inputs=[
|
| 864 |
+
s1_img,
|
| 865 |
+
hy_prompt, hy_neg, hy_steps, hy_guidance, hy_seed, hy_rand, hy_tpose, hy_keep_trans, hy_upscale,
|
| 866 |
+
tex_enable, tex_steps, tex_guidance,
|
| 867 |
+
redraw_enable, redraw_canny, redraw_low, redraw_high, redraw_strength, redraw_steps, redraw_guidance, redraw_seed, redraw_rand,
|
| 868 |
+
weapon_enable, weapon_terms,
|
| 869 |
+
],
|
| 870 |
+
outputs=[s1_gallery, state_json],
|
| 871 |
)
|
| 872 |
|
| 873 |
+
s2_go.click(
|
| 874 |
+
_step2,
|
| 875 |
+
inputs=[s2_api_name, s2_fn_index, state_json,
|
| 876 |
+
hy_prompt, hy_neg, hy_steps, hy_guidance, hy_seed, hy_rand, hy_tpose, hy_keep_trans, hy_upscale,
|
| 877 |
+
tex_enable, tex_steps, tex_guidance,
|
| 878 |
+
redraw_enable, redraw_canny, redraw_low, redraw_high, redraw_strength, redraw_steps, redraw_guidance, redraw_seed, redraw_rand,
|
| 879 |
+
weapon_enable, weapon_terms],
|
| 880 |
+
outputs=[s2_log, s2_glb, state_json],
|
| 881 |
+
)
|
| 882 |
|
| 883 |
+
s2b_go.click(
|
| 884 |
+
_step2b,
|
| 885 |
+
inputs=[s2_api_name, s2_fn_index, state_json,
|
| 886 |
+
hy_prompt, hy_neg, hy_steps, hy_guidance, hy_seed, hy_rand, hy_tpose, hy_keep_trans, hy_upscale,
|
| 887 |
+
tex_enable, tex_steps, tex_guidance,
|
| 888 |
+
redraw_enable, redraw_canny, redraw_low, redraw_high, redraw_strength, redraw_steps, redraw_guidance, redraw_seed, redraw_rand,
|
| 889 |
+
weapon_enable, weapon_terms],
|
| 890 |
+
outputs=[s2b_glb, state_json],
|
| 891 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 892 |
|
| 893 |
+
s3_go.click(
|
| 894 |
+
_step3,
|
| 895 |
+
inputs=[autorig_enable, state_json],
|
| 896 |
+
outputs=[s3_gallery, s3_zip, s3_rig],
|
| 897 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 898 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 899 |
|
| 900 |
+
if __name__ == "__main__":
|
| 901 |
+
demo.queue(concurrency_count=2, max_size=64).launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", "7860")))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|