aptol commited on
Commit
489330d
·
verified ·
1 Parent(s): 19b215c

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +853 -964
app.py CHANGED
@@ -1,1012 +1,901 @@
1
- # app.py (ZeroGPU/Gradio Space용: Blender 제외, 오토리깅 ZIP 생성 포함)
2
- # - 입력 PNG → 배경제거 → (체크) 무기 자동 제거 → (체크) 원신풍 리드로우
3
- # → (체크) T-포즈 정렬(손가락 21점 MediaPipe로 합성 가능)
4
- # → Hunyuan3D 2.1 원격 호출(GLB) → 6뷰 렌더(가능 시) → 오토리깅 번들 ZIP 생성
5
- # - SDK: Gradio, Space 유형: Gradio(ZeroGPU 호환)
6
- # - t_pose_openpose.png 기본 경로: assets/t_pose_openpose.png
7
-
8
- import os, io, json, gc, math, tempfile, zipfile, shutil, subprocess
9
- import numpy as np
10
- from PIL import Image, ImageDraw
11
- import gradio as gr
12
- # --- ZeroGPU dummy function to satisfy startup ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  try:
14
- from spaces import GPU
15
  except Exception:
16
- # 로컬/비제로 환경 호환
17
- def GPU(*a, **k):
18
- def deco(f): return f
19
- return deco
20
-
21
- @GPU(duration=10)
22
- def _zero_gpu_noop(x: int = 0):
23
- # 아주 가벼운 더미 연산
24
- return x + 1
25
-
26
- # -------------------------------------------------------
27
- # 설정
28
- # -------------------------------------------------------
29
- DEFAULT_POSE_PATH = os.path.join("assets", "t_pose_openpose.png")
30
- HY3D_SPACE = "aptol/Hunyuan3D-2.1" # 원격 호출
31
- USE_GPU = False # ZeroGPU에서는 런타임이 알아서 GPU를 붙여줌. torch.cuda.is_available() 체크로 자동 전환.
32
-
33
- # -------------------------------------------------------
34
- # 유틸
35
- # -------------------------------------------------------
36
- import numpy as _np
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
- # Threshold/옵션 (필요시 env로 조정)
169
- BOX_THR = float(os.getenv("WEAPON_BOX_THR", "0.25"))
170
- TEXT_THR = float(os.getenv("WEAPON_TEXT_THR", "0.25"))
 
 
171
 
172
- # 지연 로더
173
- _gdino_proc = _gdino = _sam_proc = _sam = _lama = None
 
 
 
 
174
 
175
- def _lazy_det_models():
176
- """GroundingDINO / SAM / LaMa 로더 (필요시만 로드, 실패 대비 폴백)"""
177
- global _gdino_proc, _gdino, _sam_proc, _sam, _lama
 
 
 
178
 
179
- if _gdino_proc is None or _gdino is None:
180
- _gdino_proc = AutoProcessor.from_pretrained(GDINO_ID)
181
- _gdino = AutoModelForZeroShotObjectDetection.from_pretrained(GDINO_ID).to(DEVICE)
 
 
182
 
183
- if _sam_proc is None or _sam is None:
184
- last_err = None
185
- for sid in SAM_ID_CANDIDATES:
186
- try:
187
- _sam_proc = SamProcessor.from_pretrained(sid)
188
- _sam = SamModel.from_pretrained(sid).to(DEVICE)
189
- last_err = None
190
- break
191
- except Exception as e:
192
- last_err = e
193
- _sam_proc = _sam = None
194
- if last_err:
195
- print("[WARN] SAM load failed -> box-mask fallback only:", last_err)
196
-
197
- if _lama is None:
198
- _lama = SimpleLama() # CPU OK
199
-
200
- DEFAULT_WEAPON_TERMS = [
201
- "sword","greatsword","katana","dagger","knife","axe","halberd",
202
- "spear","lance","polearm","staff","wand","bow","crossbow",
203
- "gun","pistol","rifle","shield","hammer","mace","scythe","whip",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  ]
205
- def load_weapon_terms():
206
- """assets/weapons.txt 있으면 우선 사용"""
207
- p = os.path.join("assets", "weapons.txt")
208
- if os.path.exists(p):
209
- with open(p, "r", encoding="utf-8") as f:
210
- arr = [x.strip() for x in f if x.strip()]
211
- if arr: return arr
212
- return DEFAULT_WEAPON_TERMS
213
-
214
- @torch.no_grad()
215
- def detect_weapon_boxes(pil_img: Image.Image, query: str):
216
- """GroundingDINO로 무기 bbox 검출 -> Tensor[N,4] (xyxy)"""
217
- _lazy_det_models()
218
- inputs = _gdino_proc(
219
- images=pil_img.convert("RGB"),
220
- text=query,
221
- return_tensors="pt"
222
- )
223
- # 모델 추론
224
- outputs = _gdino(**{k: v.to(DEVICE) if hasattr(v, "to") else v for k,v in inputs.items()})
225
- target_sizes = torch.tensor([pil_img.size[::-1]], device=DEVICE) # (H,W)
226
-
227
- # GroundingDINO 전용 후처리
228
- if hasattr(_gdino_proc, "post_process_grounded_object_detection"):
229
- res = _gdino_proc.post_process_grounded_object_detection(
230
- outputs,
231
- target_sizes=target_sizes,
232
- box_threshold=BOX_THR,
233
- text_threshold=TEXT_THR,
234
- )[0]
235
- boxes = res.get("boxes")
236
- else:
237
- # 일반 DETR 후처리 (보통은 위 API가 존재)
238
- res = _gdino_proc.post_process_object_detection(outputs, threshold=BOX_THR, target_sizes=target_sizes)[0]
239
- boxes = res.get("boxes")
240
-
241
- if boxes is None:
242
- return torch.empty((0,4), device=DEVICE)
243
-
244
- # xyxy float tensor 보장
245
- boxes = boxes.to(DEVICE)
246
- return boxes
247
-
248
- @torch.no_grad()
249
- def mask_from_boxes_with_sam(pil_img: Image.Image, boxes_xyxy: torch.Tensor):
250
- """SAM으로 boxes를 정밀 마스크로 변환 (여러 박스 union)"""
251
- _lazy_det_models()
252
- if _sam_proc is None or _sam is None: # SAM 없음 → 직사각형 마스크 합성 폴백
253
- W, H = pil_img.size
254
- m = np.zeros((H, W), dtype=np.uint8)
255
- for b in boxes_xyxy.detach().cpu().numpy().astype(int):
256
- x0,y0,x1,y1 = b.tolist()
257
- x0 = max(0, min(W, x0)); x1 = max(0, min(W, x1))
258
- y0 = max(0, min(H, y0)); y1 = max(0, min(H, y1))
259
- m[y0:y1, x0:x1] = 255
260
- return Image.fromarray(m, mode="L")
261
-
262
- image = np.array(pil_img.convert("RGB"))
263
- # SamProcessor가 입력 리사이즈/정규화/좌표 변환을 알아서 처리
264
- inputs = _sam_proc(images=image, input_boxes=[boxes_xyxy], return_tensors="pt").to(DEVICE)
265
- outputs = _sam(**inputs)
266
- # post_process_masks가 원본 해상도로 복원
267
- masks = _sam_proc.post_process_masks(
268
- outputs.pred_masks,
269
- inputs["original_sizes"],
270
- inputs["reshaped_input_sizes"]
271
- )[0] # (num_boxes, H, W)
272
- m = masks.sum(dim=0).clamp(0,1).detach().cpu().numpy().astype(np.uint8) * 255
273
- return Image.fromarray(m, mode="L")
274
-
275
- def inpaint_lama(pil_img: Image.Image, mask_img: Image.Image) -> Image.Image:
276
- """LaMa로 마스크 영역 인페인트. RGBA 알파는 원본 유지."""
277
- with tempfile.TemporaryDirectory() as td:
278
- ip = os.path.join(td, "in.png")
279
- mp = os.path.join(td, "m.png")
280
- op = os.path.join(td, "out.png")
281
- pil_img.convert("RGB").save(ip)
282
- mask_img.convert("L").save(mp)
283
- _lama(ip, mp, op)
284
- out = Image.open(op).convert("RGBA")
285
- base = pil_img.copy()
286
- final_rgb = Image.composite(out.convert("RGB"), base.convert("RGB"), mask_img.convert("L"))
287
- final = final_rgb.convert("RGBA")
288
- final.putalpha(pil_img.split()[-1]) # 입력 알파 유지
289
- return final
290
-
291
- def auto_remove_weapons(pil_img: Image.Image) -> Image.Image:
292
- """
293
- 1) GroundingDINO로 '무기' 후보 박스 검출
294
- 2) SAM으로 정확 마스크 생성(폴백: 박스 union)
295
- 3) LaMa 인페인트로 제거
296
- 실패 시 원본 그대로 반환.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
  try:
299
- terms = ", ".join(load_weapon_terms())
300
- boxes = detect_weapon_boxes(pil_img, terms) # Tensor[N,4] (xyxy)
301
- if boxes is None or boxes.numel() == 0:
302
- return pil_img
303
- mask = mask_from_boxes_with_sam(pil_img, boxes)
304
- if np.max(np.array(mask)) == 0:
305
- return pil_img
306
- return inpaint_lama(pil_img, mask)
 
 
 
 
 
 
 
 
 
 
 
307
  except Exception as e:
308
- print("[WARN] auto_remove_weapons failed:", e)
309
- return pil_img
310
-
311
-
312
- # -------------------------------------------------------
313
- # 3) 원신풍 리드로우 (픽셀감 제거) : SD1.5 + ControlNet Canny
314
- # -------------------------------------------------------
315
- from diffusers import StableDiffusionControlNetImg2ImgPipeline, ControlNetModel
316
- import cv2
317
-
318
- CN_CANNY_ID = "lllyasviel/control_v11p_sd15_canny"
319
- SD15_ID = "runwayml/stable-diffusion-v1-5"
320
-
321
- _cn_canny=_img2img=None
322
- def _lazy_redraw():
323
- global _cn_canny,_img2img
324
- if _cn_canny is None:
325
- _cn_canny = ControlNetModel.from_pretrained(CN_CANNY_ID, torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32)
326
- if _img2img is None:
327
- _img2img = StableDiffusionControlNetImg2ImgPipeline.from_pretrained(
328
- SD15_ID, controlnet=_cn_canny, torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32
329
- )
330
- if torch.cuda.is_available():
331
- _img2img.enable_xformers_memory_efficient_attention()
332
- _img2img.to("cuda")
333
-
334
- PROMPT = ("masterpiece, best quality, clean lineart, anime style, genshin impact style, "
335
- "flat color with soft shading, detailed costume, symmetrical, full body")
336
- NEG = ("low quality, worst quality, jpeg artifacts, extra limbs, deformed, "
337
- "pixelated, mosaic, noisy, text, watermark, logo")
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 torch.cuda.is_available():
397
- _sd_pose.enable_xformers_memory_efficient_attention()
398
- _sd_pose.to("cuda")
399
-
400
- def detect_hands_xy(pil_img: Image.Image):
401
- img_rgb = np.array(pil_img.convert("RGB"))
402
- h, w, _ = img_rgb.shape
403
- res = _mp_hands.process(img_rgb)
404
- hands = []
405
- if res.multi_hand_landmarks:
406
- for lm, handed in zip(res.multi_hand_landmarks, res.multi_handedness):
407
- pts = []
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
- import trimesh, pyrender
514
- scene_or_mesh = trimesh.load(glb_path, force='scene')
515
- if isinstance(scene_or_mesh, trimesh.Scene):
516
- geos=[g for g in scene_or_mesh.geometry.values() if isinstance(g,trimesh.Trimesh)]
517
- mesh = trimesh.util.concatenate(geos) if geos else scene_or_mesh.dump().sum()
518
- else:
519
- mesh = scene_or_mesh
520
- scene = pyrender.Scene(ambient_light=[.6,.6,.6,1], bg_color=[0,0,0,0])
521
- mnode = scene.add(pyrender.Mesh.from_trimesh(mesh, smooth=False))
522
- cam = pyrender.OrthographicCamera(xmag=1, ymag=1, znear=0.01, zfar=1000)
523
- cnode = scene.add(cam, pose=np.eye(4))
524
- lnode = scene.add(pyrender.DirectionalLight(intensity=3.0), pose=np.eye(4))
525
- bbox=mesh.bounds; size3=(bbox[1]-bbox[0]); scale=float(np.max(size3))
526
- views=[("front",(0,0)),("left",(90,0)),("right",(-90,0)),("back",(180,0)),("top",(0,90)),("bottom",(0,-90))]
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
- print("[WARN] sixview_render skipped:", e)
544
- return []
 
545
 
546
- # -------------------------------------------------------
547
- # 7) 오토리깅 번들 ZIP 생성 (Blender는 포함하지 않음)
548
- # -------------------------------------------------------
549
- AUTORIG_SCRIPT = r'''<AUTORIG_PIPELINE_PY_PLACEHOLDER>'''
550
- # 위 자리에는 이전에 제공한 blender/autorig_pipeline.py 전체본을 그대로 붙여 넣으세요.
551
- # (메시지 길이 제약상 플레이스홀더 처리)
552
-
553
- RUN_SH = r'''#!/usr/bin/env bash
554
- BLENDER=${BLENDER:-blender}
555
- IN=${1:-model.glb}
556
- OUT=${2:-rigged_toon.fbx}
557
- $BLENDER -b --python blender/autorig_pipeline.py -- \
558
- --in "$IN" --out "$OUT" --export fbx \
559
- --outline --toon --surface_deform --limit_cloth_bones --try_faceit
560
- '''
561
- RUN_BAT = r'''@echo off
562
- set BLENDER=blender
563
- set IN=%1
564
- if "%IN%"=="" set IN=model.glb
565
- set OUT=%2
566
- if "%OUT%"=="" set OUT=rigged_toon.fbx
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
- pose_control = build_pose_control_image(
636
- base_pose_png=pose_src,
637
- input_image_for_hands=clean,
638
- add_hands_with_mediapipe=add_hand_details
639
- )
640
- clean = force_tpose(clean, pose_control, strength=0.25, scale=5.0)
641
  except Exception as e:
642
- print("[WARN] tpose:", e)
 
 
643
 
644
- # e) Hunyuan3D → GLB
645
- glb_path=run_hunyuan3d(clean)
646
 
647
- # f) 6뷰 (환경에 따라 생략될 수 있음)
648
- views=sixview_render(glb_path, size=1024)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
649
 
650
- # g) 오토리깅 번들 ZIP (Blender는 포함 안 함)
651
- zip_path = make_autorig_bundle(glb_path)
652
 
653
- return clean, glb_path, views, zip_path, pose_src
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- def _save_tmp_pil(pil_img, ext=".png", max_side=1024):
662
- if pil_img is None:
663
- raise ValueError("pil_img is None")
664
- pil = pil_img
665
- w, h = pil.size
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
- fname = f"{_ptime.time_ns()}_{_uuid.uuid4().hex}{ext}"
672
- out_path = str(out_dir / fname)
673
- pil.save(out_path)
674
- return out_path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
675
 
676
- def _gpu_redraw(pil_img, gender="neutral", strength=0.55, scale=6.5, steps=22):
677
  try:
678
- if 'redraw_to_clean_anime' in globals():
679
- return redraw_to_clean_anime(pil_img, gender=gender, strength=strength, scale=scale, steps=steps)
680
- if '_lazy_redraw' in globals() and '_img2img' in globals():
681
- _lazy_redraw()
682
- return _img2img(pil_img, prompt=None, negative_prompt=None, guidance_scale=scale,
683
- num_inference_steps=steps, strength=strength)
684
- except Exception as e:
685
- print("[WARN] redraw wrapper failed:", e)
686
- return pil_img
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
687
 
688
- def _gpu_force_tpose(pil_img, pose_control_img, strength=0.25, scale=5.0, steps=22):
 
689
  try:
690
- if 'force_tpose' in globals():
691
- return force_tpose(pil_img, pose_control_img, strength=strength, scale=scale)
692
- if '_lazy_pose' in globals() and '_sd_pose' in globals():
693
- _lazy_pose()
694
- return pil_img
 
 
 
 
 
 
 
 
 
 
 
 
695
  except Exception as e:
696
- print("[WARN] tpose wrapper failed:", e)
697
- return pil_img
698
- # ------------------------------------------------------
699
 
700
- # ===== Hunyuan3D cfg helper (drop-in) =====
701
- import os
702
- def _as_bool(x, default=False):
703
- if x is None: return default
704
- s = str(x).strip().lower()
705
- return s in ("1","true","yes","y","on")
706
-
707
- def _hy3d_cfg_generic(prefix: str):
708
- steps = int(os.getenv(f"{prefix}_STEPS", 22 if prefix!="HY3D_T" else 30))
709
- guide = float(os.getenv(f"{prefix}_GUIDE", 6.0 if prefix!="HY3D_T" else 7.0))
710
- octree = int(os.getenv(f"{prefix}_OCT", 192 if prefix!="HY3D_T" else 256))
711
- chunks = int(os.getenv(f"{prefix}_CHUNKS", 8000 if prefix!="HY3D_T" else 200000))
712
- rseed = _as_bool(os.getenv(f"{prefix}_RAND", "true"))
713
- seed = os.getenv(f"{prefix}_SEED")
714
- seed = int(seed) if (seed is not None and str(seed).isdigit()) else 1234
715
- return {
716
- "steps": steps,
717
- "guidance_scale": guide,
718
- "octree_resolution": octree,
719
- "num_chunks": chunks,
720
- "randomize_seed": rseed,
721
- "seed": seed,
722
- }
723
- # ==========================================
724
- # -------------------------------------------------------
725
- # 9) UI
726
- # -------------------------------------------------------
727
  with gr.Blocks() as demo:
728
- gr.Markdown("### Pixel→(BG/Weapon)→Clean Anime Redraw→(OpenPose+Hands)→Hunyuan3D→6View→AutoRig ZIP (ZeroGPU)")
729
-
730
- with gr.Tab("3-Step Builder"):
731
- gr.Markdown("### 3단계 빌더\n1) 6방향 T-포즈 이미지 생성 → 2) 6장 업로드로 화이트 모델 생성 → 3) 텍스처 포함 최종 모델")
732
-
733
- # 로그 컴포넌트를 먼저 만들어 두면 아래 outputs에서 바로 쓸 수 있음
734
- hy3_logs = gr.Textbox(label="3-Step 로그", lines=10)
735
-
736
- # -------- STEP 1: 출력 전용 6뷰 --------
737
- with gr.Group():
738
- gr.Markdown("**STEP 1 — 6방향 이미지 생성 (T-포즈, 투명 배경, 무기 제거/리드로우 옵션 적용)**")
739
- s1_img = gr.Image(type="pil", label="입력 이미지")
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
- btn_s1 = gr.Button("① 6방향 이미지 생성", variant="secondary")
750
- s1_zip = gr.File(label="6방향 ZIP (출력)", file_count="single", file_types=[".zip"])
 
 
751
  with gr.Row():
752
- s1_front = gr.Image(type="pil", label="front", interactive=False, sources=[], height=256)
753
- s1_right = gr.Image(type="pil", label="right", interactive=False, sources=[], height=256)
754
- s1_back = gr.Image(type="pil", label="back", interactive=False, sources=[], height=256)
755
- s1_left = gr.Image(type="pil", label="left", interactive=False, sources=[], height=256)
 
756
  with gr.Row():
757
- s1_top = gr.Image(type="pil", label="top", interactive=False, sources=[], height=256)
758
- s1_bottom = gr.Image(type="pil", label="bottom", interactive=False, sources=[], height=256)
759
-
760
- # -------- STEP 2: 입력 6장 → 화이트 메쉬 파일 --------
761
- with gr.Group():
762
- gr.Markdown("**STEP 2 — 6장 업로드로 화이트 모델 생성**")
763
- s2_front = gr.Image(type="filepath", label="front.png")
764
- s2_back = gr.Image(type="filepath", label="back.png")
765
- s2_left = gr.Image(type="filepath", label="left.png")
766
- s2_right = gr.Image(type="filepath", label="right.png")
767
- s2_top = gr.Image(type="filepath", label="top.png")
768
- s2_bottom = gr.Image(type="filepath", label="bottom.png")
769
- s2_space = gr.Textbox(value=HY3D_SPACE, label="Hunyuan3D Space ID/URL")
770
- btn_s2 = gr.Button(" 화이트 모델 생성 (/shape_generation)", variant="secondary")
771
- s2_white = gr.File(label="화이트 메쉬(GLB/OBJ)", file_types=[".glb", ".obj"])
772
-
773
- # -------- STEP 3: 입력 6장 → 텍스처 GLB --------
774
- with gr.Group():
775
- gr.Markdown("**STEP 3 — 6장 업로드로 텍스처 포함 최종 모델**")
776
- s3_front = gr.Image(type="filepath", label="front.png")
777
- s3_back = gr.Image(type="filepath", label="back.png")
778
- s3_left = gr.Image(type="filepath", label="left.png")
779
- s3_right = gr.Image(type="filepath", label="right.png")
780
- s3_top = gr.Image(type="filepath", label="top.png")
781
- s3_bottom = gr.Image(type="filepath", label="bottom.png")
782
- s3_space = gr.Textbox(value=HY3D_SPACE, label="Hunyuan3D Space ID/URL")
783
- btn_s3 = gr.Button("③ 텍스처 포함 최종 모델 (/generation_all)", variant="primary")
784
- s3_textured = gr.File(label="텍스처 GLB", file_types=[".glb"])
785
-
786
- # ---------- Handlers (정의 먼저) ----------
787
- def _step1_generate(img, has_weapon, do_redraw, force_t, hand, gender_choice, pose_img, space_id):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
788
  if img is None:
789
- raise gr.Error("이미지를 넣어주세요.")
790
- base = img.convert("RGBA")
791
- clean = remove_bg_keep_alpha(base)
792
- if has_weapon:
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
- r = _call_positional_multi(space_id, "/shape_generation", img_front=prep_path, img_back=None, img_left=None, img_right=None, cfg=cfg)
815
- white_glb = _pick_glb_from_result(r)
 
 
816
  except Exception as e:
817
- raise gr.Error(f"STEP1 shape_generation 실패: {e}")
818
- if not white_glb:
819
- raise gr.Error("화이트 메쉬(GLB)를 찾지 못했습니다.")
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
- r = _call_positional_multi(space_id, "/shape_generation",
837
- img_front=front, img_back=back, img_left=left, img_right=right, cfg=cfg)
838
- white_glb = _pick_glb_from_result(r)
 
 
 
 
 
 
 
 
 
 
 
839
  except Exception as e:
840
- raise gr.Error(f"STEP2 shape_generation 실패: {e}")
841
- if not white_glb:
842
- raise gr.Error("화이트 메쉬(GLB)를 찾지 못했습니다.")
843
- log = f"[STEP2] white={white_glb}\n(note) HY3D는 4-view만 사용합니다 (front/back/left/right)"
844
- return white_glb, log
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
- r = _call_positional_multi(space_id, "/generation_all",
864
- img_front=front, img_back=back, img_left=left, img_right=right, cfg=cfg)
865
- textured_glb = _pick_glb_from_result(r)
 
 
 
 
 
 
 
 
 
 
 
 
 
866
  except Exception as e:
867
- raise gr.Error(f"STEP3 generation_all 실패: {e}")
868
- if not textured_glb:
869
- raise gr.Error("텍스처 GLB를 찾지 못했습니다.")
870
- log = f"[STEP3] textured={textured_glb}\n(enhance) top/bottom 투사 적용={'YES' if (top or bottom) else 'NO'}"
871
- return textured_glb, log
872
-
873
- # ---------- click 바인딩(정의 끝난 뒤) ----------
874
- btn_s1.click(
875
- fn=_step1_generate,
876
- inputs=[s1_img, s1_has_weapon, s1_redraw, s1_force_t, s1_hand, gender, s1_pose, s1_space],
877
- outputs=[s1_front, s1_right, s1_back, s1_left, s1_top, s1_bottom, s1_zip, hy3_logs]
878
- )
879
- btn_s2.click(
880
- fn=_step2_model,
881
- inputs=[s2_front, s2_back, s2_left, s2_right, s2_top, s2_bottom, s2_space],
882
- outputs=[s2_white, hy3_logs]
883
- )
884
- btn_s3.click(
885
- fn=_step3_texture,
886
- inputs=[s3_front, s3_back, s3_left, s3_right, s3_top, s3_bottom, s3_space],
887
- outputs=[s3_textured, hy3_logs]
 
 
 
 
 
888
  )
889
 
890
- if __name__=="__main__":
891
- demo.launch()
 
 
 
 
 
 
 
892
 
893
- # =========================
894
- # 3-STEP BUILDER HELPERS
895
- # =========================
896
- import numpy as _np
897
- import trimesh as _trimesh
898
- import pyrender as _pyrender
899
- from PIL import Image as _PILImage
900
- from gradio_client import Client as _Client, handle_file as _handle_file
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
- def _save_png_rgba(pil, out_path):
914
- pil = pil.convert("RGBA")
915
- pil.save(out_path, "PNG")
916
-
917
- def _normalize_mesh_to_unit(mesh):
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
- def _zip_files(file_dict, out_zip):
1006
- import zipfile
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")))