Daankular commited on
Commit
b1116df
Β·
verified Β·
1 Parent(s): 1f4445b

Ping-verify bpy worker, retry load after restart, default to texture-preserving transfer

Browse files
Files changed (1) hide show
  1. demo.py +803 -786
demo.py CHANGED
@@ -1,786 +1,803 @@
1
- import argparse
2
- import importlib
3
- import os
4
- import sys
5
- import tempfile
6
- from pathlib import Path
7
- from typing import List, Optional, Tuple
8
-
9
- import gradio as gr
10
- from torch import Tensor
11
- from tqdm import tqdm
12
-
13
- # ---------------------------------------------------------------------------
14
- # ZeroGPU compatibility shim. The hosted HF Space provides the `spaces`
15
- # package; running locally we substitute a no-op.
16
- # ---------------------------------------------------------------------------
17
- try:
18
- spaces = importlib.import_module("spaces")
19
- except Exception:
20
- class _SpacesCompat:
21
- @staticmethod
22
- def GPU(*args, **kwargs):
23
- if len(args) == 1 and callable(args[0]) and not kwargs:
24
- return args[0]
25
-
26
- def _decorator(fn):
27
- return fn
28
-
29
- return _decorator
30
-
31
- spaces = _SpacesCompat()
32
-
33
- os.environ.setdefault("XFORMERS_IGNORE_FLASH_VERSION_CHECK", "1")
34
- gr.TEMP_DIR = "tmp_gradio"
35
-
36
-
37
- # ---------------------------------------------------------------------------
38
- # Install the bundled `bpy` wheel at runtime if it isn't already importable.
39
- #
40
- # Why this is non-trivial:
41
- # - Putting the wheel in requirements.txt fails: HF Spaces' Docker build
42
- # mounts only requirements.txt BEFORE the repo COPY, so the wheel path
43
- # doesn't exist at pip-install time.
44
- # - PyPI doesn't ship a bpy wheel matching this exact build (rc0 / cp312 /
45
- # manylinux_2_39).
46
- # - The `bpy-*.whl` committed in this repo gets auto-tracked by HF's LFS
47
- # layer (Hub auto-LFS for blobs > ~10 MB even when .gitattributes doesn't
48
- # list `*.whl`). The container's COPY-from-repo only carries the LFS
49
- # *pointer* file β€” a ~150-byte text stub β€” not the actual wheel binary.
50
- # So `pip install <wheel>` and `zipfile.ZipFile(<wheel>)` both fail with
51
- # "is not a zip file" / "Wheel is invalid".
52
- #
53
- # So: we detect the LFS-pointer case and re-fetch the real wheel from the
54
- # HF Hub at runtime (where the API resolves LFS server-side), then extract
55
- # it directly into site-packages.
56
- # ---------------------------------------------------------------------------
57
- def _ensure_bpy_installed():
58
- try:
59
- import bpy # noqa: F401
60
- return
61
- except Exception:
62
- pass
63
-
64
- import glob
65
- import sysconfig
66
- import zipfile
67
-
68
- here = os.path.dirname(os.path.abspath(__file__))
69
- wheels = sorted(glob.glob(os.path.join(here, "bpy-*.whl")))
70
- if not wheels:
71
- print("[demo] WARNING: bpy not importable and no bundled wheel found", flush=True)
72
- return
73
-
74
- wheel = wheels[-1]
75
- wheel_name = os.path.basename(wheel)
76
-
77
- # Detect LFS pointer (text stub starting with "version https://git-lfs...").
78
- is_real_zip = False
79
- try:
80
- with open(wheel, "rb") as f:
81
- is_real_zip = f.read(4).startswith(b"PK")
82
- except Exception:
83
- pass
84
-
85
- if not is_real_zip:
86
- print(
87
- f"[demo] {wheel_name} on disk is an LFS pointer ({os.path.getsize(wheel)} B); "
88
- f"fetching real wheel from HF Hub...",
89
- flush=True,
90
- )
91
- from huggingface_hub import hf_hub_download
92
-
93
- space_id = os.environ.get("SPACE_ID", "VAST-AI/SkinTokens")
94
- token = os.environ.get("HF_TOKEN") # set as a Space secret for private repos
95
- wheel = hf_hub_download(
96
- repo_id=space_id,
97
- repo_type="space",
98
- filename=wheel_name,
99
- token=token,
100
- )
101
- print(f"[demo] fetched -> {wheel} ({os.path.getsize(wheel)} B)", flush=True)
102
-
103
- site = sysconfig.get_paths()["purelib"]
104
- print(f"[demo] Extracting {wheel_name} into {site}", flush=True)
105
- with zipfile.ZipFile(wheel) as z:
106
- z.extractall(site)
107
- print("[demo] bpy wheel extracted.", flush=True)
108
-
109
-
110
- _ensure_bpy_installed()
111
-
112
-
113
- # ---------------------------------------------------------------------------
114
- # Download model checkpoints (TokenRig + SkinTokens FSQ-CVAE) and the Qwen3
115
- # tokenizer/config on first cold-start.
116
- #
117
- # These live in the *model* repo `VAST-AI/SkinTokens` (private), separate
118
- # from this Space repo, so they aren't COPYed into the container. Re-uses
119
- # `HF_TOKEN` from the Space secrets.
120
- # ---------------------------------------------------------------------------
121
- def _ensure_models_downloaded():
122
- here = os.path.dirname(os.path.abspath(__file__))
123
- needed_ckpts = [
124
- "experiments/skin_vae_2_10_32768/last.ckpt",
125
- "experiments/articulation_xl_quantization_256_token_4/grpo_1400.ckpt",
126
- ]
127
- qwen_dir = os.path.join(here, "models", "Qwen3-0.6B")
128
-
129
- all_present = (
130
- all(os.path.exists(os.path.join(here, p)) for p in needed_ckpts)
131
- and os.path.exists(os.path.join(qwen_dir, "tokenizer.json"))
132
- )
133
- if all_present:
134
- return
135
-
136
- from huggingface_hub import hf_hub_download, snapshot_download
137
-
138
- token = os.environ.get("HF_TOKEN")
139
-
140
- for rel in needed_ckpts:
141
- target = os.path.join(here, rel)
142
- if os.path.exists(target):
143
- continue
144
- print(f"[demo] Downloading checkpoint: {rel}", flush=True)
145
- hf_hub_download(
146
- repo_id="VAST-AI/SkinTokens",
147
- filename=rel,
148
- local_dir=here,
149
- token=token,
150
- )
151
-
152
- if not os.path.exists(os.path.join(qwen_dir, "tokenizer.json")):
153
- print("[demo] Downloading Qwen3-0.6B tokenizer/config", flush=True)
154
- snapshot_download(
155
- repo_id="Qwen/Qwen3-0.6B",
156
- local_dir=qwen_dir,
157
- ignore_patterns=["*.bin", "*.safetensors"],
158
- )
159
-
160
- print("[demo] All checkpoints ready.", flush=True)
161
-
162
-
163
- _ensure_models_downloaded()
164
-
165
-
166
- from src.data.dataset import DatasetConfig, RigDatasetModule
167
- from src.data.transform import Transform
168
- from src.model.tokenrig import TokenRigResult
169
- from src.tokenizer.parse import get_tokenizer
170
- from src.server.spec import (
171
- BpyClient,
172
- bpy_server_request,
173
- is_bpy_server_alive,
174
- get_model,
175
- )
176
- from src.data.vertex_group import voxel_skin
177
-
178
-
179
- # ---------------------------------------------------------------------------
180
- # Pre-warm `bpy_server` in the main (Gradio) process at module load.
181
- #
182
- # Why this is necessary on ZeroGPU: each user request runs inside a fresh
183
- # `@spaces.GPU` worker process with a hard time budget (β‰ˆ60 s on free tier).
184
- # Importing the Blender shared object inside that budget burns 30–60 s, so
185
- # the worker is killed *during* bpy import β€” manifesting as
186
- # "GPU task aborted" before any model code runs.
187
- #
188
- # We start `bpy_server.py` here, in the always-running main process, so the
189
- # slow bpy import happens exactly once at Space boot.
190
- #
191
- # Communication between the main process / ZeroGPU workers and the bpy
192
- # worker happens via **file-based IPC** under ``/tmp/bpy_ipc/``, avoiding
193
- # TCP listening ports (which trigger HF Space proxy issues) and HTTP body-
194
- # size limits.
195
- # ---------------------------------------------------------------------------
196
-
197
- MODEL_CKPTS = [
198
- "experiments/articulation_xl_quantization_256_token_4/grpo_1400.ckpt",
199
- ]
200
-
201
- HF_PATHS = [
202
- "None",
203
- ]
204
-
205
-
206
- def get_dataloader_workers() -> int:
207
- # Always use 0 workers β€” the bpy IPC server is single-threaded and
208
- # must process requests sequentially, so parallel loading provides
209
- # no benefit. More importantly, DataLoader worker processes wrap
210
- # exceptions from asset loading in a way that can leave the
211
- # DataLoader iterator in an inconsistent state, blocking Gradio's
212
- # event loop on subsequent requests.
213
- return 0
214
-
215
-
216
- # ---------------------------------------------------------------------------
217
- # bpy_server lifecycle β€” lazy start so the heavy import doesn't fight ZeroGPU
218
- # during module load. Uses file-based IPC (no TCP listening).
219
- # ---------------------------------------------------------------------------
220
- _BPY_CLIENT: "Optional[BpyClient]" = None
221
-
222
-
223
- def ensure_bpy_server_started(force: bool = False):
224
- """Start the bpy worker (if not already running) and wait until ready.
225
-
226
- Uses a fast PID-file check to detect an already-running bpy_server
227
- (works across processes, including ZeroGPU workers). Falls back to
228
- launching a fresh subprocess when no server is found.
229
-
230
- When *force* is True the existing worker (if any) is killed and a
231
- fresh one is started unconditionally β€” use after a timeout to
232
- recover from a hung server.
233
- """
234
- global _BPY_CLIENT
235
-
236
- if not force:
237
- # Fast path: we hold a live client handle (same-process, e.g. main).
238
- if _BPY_CLIENT is not None and _BPY_CLIENT.is_alive():
239
- return
240
-
241
- # Cross-process fast path: another process already started the worker.
242
- if is_bpy_server_alive():
243
- return
244
-
245
- # If we had a dead client handle, clean it up.
246
- if _BPY_CLIENT is not None:
247
- try:
248
- _BPY_CLIENT.shutdown()
249
- except Exception:
250
- pass
251
-
252
- # Launch a fresh bpy worker (this blocks until bpy is imported, ~30–60 s
253
- # on a cold container, but the @spaces.GPU duration budget accommodates).
254
- _BPY_CLIENT = BpyClient.launch()
255
-
256
-
257
- # ---------------------------------------------------------------------------
258
- # Lazy model loading.
259
- # ---------------------------------------------------------------------------
260
- model = None
261
- tokenizer = None
262
- transform = None
263
- CURRENT_MODEL_CKPT: Optional[str] = None
264
- CURRENT_HF_PATH: Optional[str] = None
265
-
266
-
267
- def load_model(model_ckpt: str, hf_path: Optional[str]) -> Tuple[str, str]:
268
- global model, tokenizer, transform, CURRENT_MODEL_CKPT, CURRENT_HF_PATH
269
- if hf_path == "None":
270
- hf_path = None
271
- if model is not None and model_ckpt == CURRENT_MODEL_CKPT and hf_path == CURRENT_HF_PATH:
272
- return ("Model already loaded.", model_ckpt)
273
-
274
- if not model_ckpt:
275
- raise RuntimeError("model_ckpt is empty. Please select a checkpoint.")
276
-
277
- print(f"Loading model: {model_ckpt}, hf_path={hf_path}")
278
- model = get_model(model_ckpt, hf_path=hf_path)
279
- assert model.tokenizer_config is not None
280
- tokenizer = get_tokenizer(**model.tokenizer_config)
281
- transform = Transform.parse(**model.transform_config["predict_transform"])
282
- CURRENT_MODEL_CKPT = model_ckpt
283
- CURRENT_HF_PATH = hf_path
284
- return ("Model loaded.", model_ckpt)
285
-
286
-
287
- # ---------------------------------------------------------------------------
288
- # File utilities (CLI-side).
289
- # ---------------------------------------------------------------------------
290
- SUPPORTED_EXT = {".obj", ".fbx", ".glb"}
291
-
292
-
293
- def collect_files(input_path: Path) -> List[Path]:
294
- if input_path.is_file():
295
- return [input_path]
296
-
297
- files = []
298
- for p in input_path.rglob("*"):
299
- if p.suffix.lower() in SUPPORTED_EXT:
300
- files.append(p)
301
- return files
302
-
303
-
304
- def map_output_path(in_path: Path, input_root: Path, output_root: Path) -> Path:
305
- rel = in_path.relative_to(input_root)
306
- return (output_root / rel).with_suffix(".glb")
307
-
308
-
309
- # ---------------------------------------------------------------------------
310
- # Core inference (shared by CLI and Gradio).
311
- # ---------------------------------------------------------------------------
312
- def run_rig(
313
- filepaths: List[Path],
314
- top_k: int,
315
- top_p: float,
316
- temperature: float,
317
- repetition_penalty: float,
318
- num_beams: int,
319
- use_skeleton: bool,
320
- use_transfer: bool,
321
- use_postprocess: bool,
322
- output_paths: List[Path],
323
- model_ckpt: str,
324
- hf_path: Optional[str],
325
- ):
326
- assert len(filepaths) == len(output_paths)
327
- ensure_bpy_server_started()
328
- load_model(model_ckpt, hf_path)
329
-
330
- datapath = {
331
- "data_name": None,
332
- "loader": "bpy_server",
333
- "filepaths": {"articulation": [str(p) for p in filepaths]},
334
- }
335
-
336
- dataset_config = DatasetConfig.parse(
337
- shuffle=False,
338
- batch_size=1,
339
- num_workers=get_dataloader_workers(),
340
- pin_memory=get_dataloader_workers() > 0,
341
- persistent_workers=False,
342
- datapath=datapath,
343
- ).split_by_cls()
344
-
345
- module = RigDatasetModule(
346
- predict_dataset_config=dataset_config,
347
- predict_transform=transform,
348
- tokenizer=tokenizer,
349
- process_fn=model._process_fn,
350
- )
351
-
352
- dataloader = module.predict_dataloader()["articulation"]
353
-
354
- results_out = []
355
- infer_device = model.device if model is not None else "cuda"
356
-
357
- # Use explicit next() so we can catch errors from asset loading
358
- # (which happens inside __getitem__ β†’ next(dataloader)) per-file.
359
- dataloader_iter = iter(dataloader)
360
- for i in tqdm(range(len(filepaths)), desc="Processing"):
361
- filepath = filepaths[i]
362
- out_path = output_paths[i]
363
- try:
364
- batch = next(dataloader_iter)
365
- except StopIteration:
366
- break
367
- except Exception as exc:
368
- err_msg = str(exc)
369
- print(f"[Error] Failed to load '{filepath}': {err_msg}", flush=True)
370
- import traceback
371
- traceback.print_exc()
372
- # Force-restart if the server timed out (hung) so the next file
373
- # gets a clean interpreter instead of hitting the same hang.
374
- _restart_bpy_if_dead(force=("timeout" in err_msg.lower()))
375
- continue
376
-
377
- try:
378
- batch = {
379
- k: v.to(infer_device) if isinstance(v, Tensor) else v
380
- for k, v in batch.items()
381
- }
382
-
383
- if not use_skeleton:
384
- batch.pop("skeleton_tokens", None)
385
- batch.pop("skeleton_mask", None)
386
-
387
- batch["generate_kwargs"] = dict(
388
- max_length=2048,
389
- top_k=int(top_k),
390
- top_p=float(top_p),
391
- temperature=float(temperature),
392
- repetition_penalty=float(repetition_penalty),
393
- num_return_sequences=1,
394
- num_beams=int(num_beams),
395
- do_sample=True,
396
- )
397
-
398
- if "skeleton_tokens" in batch and "skeleton_mask" in batch:
399
- mask = batch["skeleton_mask"][0] == 1
400
- skeleton_tokens = batch["skeleton_tokens"][0][mask].cpu().numpy()
401
- else:
402
- skeleton_tokens = None
403
-
404
- preds: List[TokenRigResult] = model.predict_step(
405
- batch,
406
- skeleton_tokens=[skeleton_tokens] if skeleton_tokens is not None else None,
407
- make_asset=True,
408
- )["results"]
409
-
410
- asset = preds[0].asset
411
- assert asset is not None
412
-
413
- if use_postprocess:
414
- voxel = asset.voxel(resolution=196)
415
- asset.skin *= voxel_skin(
416
- grid=0,
417
- grid_coords=voxel.coords,
418
- joints=asset.joints,
419
- vertices=asset.vertices,
420
- faces=asset.faces,
421
- mode="square",
422
- voxel_size=voxel.voxel_size,
423
- )
424
- asset.normalize_skin()
425
-
426
- out_path.parent.mkdir(parents=True, exist_ok=True)
427
-
428
- if use_transfer:
429
- payload = dict(
430
- source_asset=asset,
431
- target_path=asset.path,
432
- export_path=str(out_path),
433
- group_per_vertex=4,
434
- )
435
- res = bpy_server_request("transfer", payload)
436
- else:
437
- payload = dict(
438
- asset=asset,
439
- filepath=str(out_path),
440
- group_per_vertex=4,
441
- )
442
- res = bpy_server_request("export", payload)
443
-
444
- if res != "ok":
445
- print(f"[Error] {filepath}: {res}")
446
- else:
447
- print(f"[OK] Exported: {out_path}")
448
- results_out.append(out_path)
449
- except Exception as exc:
450
- err_msg = str(exc)
451
- print(f"[Error] Failed to process '{filepath}': {err_msg}", flush=True)
452
- import traceback
453
- traceback.print_exc()
454
- # Force-restart if the server timed out (hung).
455
- _restart_bpy_if_dead(force=("timeout" in err_msg.lower()))
456
- continue
457
-
458
- return results_out
459
-
460
-
461
- def _restart_bpy_if_dead(force: bool = False):
462
- """If the bpy worker process died or hung, start a new one.
463
-
464
- When *force* is True, the existing worker is killed and replaced
465
- unconditionally β€” use after a request timeout to guarantee a clean
466
- bpy interpreter for the next file.
467
- """
468
- if force:
469
- print("[run_rig] bpy worker timed out β€” force-restarting...", flush=True)
470
- try:
471
- ensure_bpy_server_started(force=True)
472
- except Exception as exc:
473
- print(f"[run_rig] Failed to force-restart bpy worker: {exc}", flush=True)
474
- elif not is_bpy_server_alive():
475
- print("[run_rig] bpy worker is dead β€” restarting...", flush=True)
476
- try:
477
- ensure_bpy_server_started()
478
- except Exception as exc:
479
- print(f"[run_rig] Failed to restart bpy worker: {exc}", flush=True)
480
-
481
-
482
- # ---------------------------------------------------------------------------
483
- # CLI entry point.
484
- # ---------------------------------------------------------------------------
485
- def run_cli(args):
486
- input_path = Path(args.input).resolve()
487
- output_path = Path(args.output).resolve()
488
-
489
- files = collect_files(input_path)
490
- if not files:
491
- raise RuntimeError("No valid 3D files found.")
492
-
493
- if len(files) == 1 and output_path.suffix:
494
- outputs = [output_path]
495
- else:
496
- outputs = [map_output_path(f, input_path, output_path) for f in files]
497
-
498
- run_rig(
499
- files,
500
- args.top_k,
501
- args.top_p,
502
- args.temperature,
503
- args.repetition_penalty,
504
- args.num_beams,
505
- args.use_skeleton,
506
- args.use_transfer,
507
- args.use_postprocess,
508
- outputs,
509
- args.model_ckpt,
510
- args.hf_path,
511
- )
512
-
513
-
514
- # ---------------------------------------------------------------------------
515
- # Gradio wrapper (with ZeroGPU duration estimator).
516
- # ---------------------------------------------------------------------------
517
- TOT = 0
518
-
519
-
520
- def _gpu_duration(
521
- files,
522
- top_k,
523
- top_p,
524
- temperature,
525
- repetition_penalty,
526
- num_beams,
527
- use_skeleton,
528
- use_transfer,
529
- use_postprocess,
530
- model_ckpt,
531
- hf_path,
532
- ):
533
- # Cold workers spend ~30–60 s importing bpy + loading the model before
534
- # any GPU work. Give every request a generous 240 s floor.
535
- file_count = len(files) if files is not None else 1
536
- return min(900, max(240, 240 + 60 * file_count))
537
-
538
-
539
- @spaces.GPU(duration=_gpu_duration)
540
- def run_gradio(
541
- files,
542
- top_k,
543
- top_p,
544
- temperature,
545
- repetition_penalty,
546
- num_beams,
547
- use_skeleton,
548
- use_transfer,
549
- use_postprocess,
550
- model_ckpt,
551
- hf_path,
552
- ):
553
- try:
554
- if not files:
555
- return "Please upload at least one 3D model.", None
556
-
557
- tmp_out = Path(tempfile.mkdtemp(prefix="tokenrig_"))
558
- filepaths = [Path(f.name) for f in files]
559
- global TOT
560
- outputs = []
561
- for filepath in filepaths:
562
- TOT += 1
563
- outputs.append(tmp_out / f"res_{TOT}.glb")
564
-
565
- run_rig(
566
- filepaths,
567
- top_k,
568
- top_p,
569
- temperature,
570
- repetition_penalty,
571
- num_beams,
572
- use_skeleton,
573
- use_transfer,
574
- use_postprocess,
575
- outputs,
576
- model_ckpt,
577
- hf_path,
578
- )
579
-
580
- # Collect successfully exported files
581
- succeeded = [str(p) for p in outputs if p.exists()]
582
- if not succeeded:
583
- return "❌ No output files were produced β€” check the server logs for details.", None
584
- return f"βœ… Processed {len(succeeded)} model(s).", succeeded
585
- except Exception as exc:
586
- import traceback
587
- traceback.print_exc()
588
- return f"❌ Error: {exc}", None
589
-
590
-
591
- # ---------------------------------------------------------------------------
592
- # Gradio UI.
593
- # ---------------------------------------------------------------------------
594
- def build_gradio_app():
595
- model_ckpts = MODEL_CKPTS
596
- hf_paths = HF_PATHS
597
- default_ckpt = model_ckpts[0] if model_ckpts else ""
598
- default_hf = hf_paths[0] if hf_paths else "None"
599
-
600
- with gr.Blocks(title="SkinTokens Β· TokenRig Demo") as app:
601
- gr.Markdown(
602
- """
603
- ## 🦴 Mesh to Rig with [SkinTokens](https://zjp-shadow.github.io/works/SkinTokens/) · TokenRig
604
-
605
- Automated **skeleton generation + skinning weight prediction** for any 3D mesh, via a unified
606
- autoregressive model over learned *SkinTokens*. Successor to
607
- [UniRig](https://github.com/VAST-AI-Research/UniRig) (SIGGRAPH&nbsp;'25).
608
-
609
- * Upload one or more meshes β†’ click **Run** β†’ download a rigged `.glb`.
610
- * **Paper**: [arXiv&nbsp;2602.04805](https://arxiv.org/abs/2602.04805) &nbsp;Β·&nbsp;
611
- **Code**: [VAST-AI-Research/SkinTokens](https://github.com/VAST-AI-Research/SkinTokens) &nbsp;Β·&nbsp;
612
- **Weights**: [πŸ€—&nbsp;VAST-AI/SkinTokens](https://huggingface.co/VAST-AI/SkinTokens)
613
- * Looking for **image β†’ rigged 3D** instead? Try our sibling Space
614
- [πŸ€—&nbsp;VAST-AI/AniGen](https://huggingface.co/spaces/VAST-AI/AniGen).
615
- * Want a full AI-powered 3D workspace? β†’ [Tripo](https://www.tripo3d.ai)
616
- """
617
- )
618
-
619
- gr.HTML(
620
- """
621
- <style>
622
- @keyframes gentle-pulse {
623
- 0%, 100% { opacity: 1; }
624
- 50% { opacity: 0.35; }
625
- }
626
- </style>
627
- <div style="text-align:left; color:#888; font-size:1em; line-height:1.6; margin: 4px 0 -4px 0;">
628
- <span style="animation: gentle-pulse 3s ease-in-out infinite; display:inline-block;">&#128161; <b>Tips</b></span>&ensp;
629
- Defaults work well for most meshes.
630
- &nbsp;β€’ If your mesh already has a skeleton and you only want skinning, enable
631
- <b>Use existing skeleton</b> below.
632
- &nbsp;β€’ To keep your original textures and world scale, enable <b>Preserve original texture &amp; scale</b>.
633
- </div>
634
- """
635
- )
636
-
637
- with gr.Row():
638
- with gr.Column(scale=1):
639
- files = gr.File(
640
- label="3D Models ( .obj / .fbx / .glb, up to a few at a time )",
641
- file_count="multiple",
642
- file_types=[".obj", ".fbx", ".glb"],
643
- )
644
-
645
- with gr.Accordion("βš™οΈ Generation Settings", open=False):
646
- model_ckpt = gr.Dropdown(
647
- choices=model_ckpts,
648
- value=default_ckpt,
649
- label="Model checkpoint",
650
- info="TokenRig autoregressive rigging model. The default is the GRPO-refined checkpoint recommended for most assets.",
651
- interactive=True,
652
- )
653
- # Keep the hf_path component for callback compatibility, but hide it
654
- # from the UI since it currently only exposes the default ("None") option.
655
- hf_path = gr.Dropdown(
656
- choices=hf_paths,
657
- value=default_hf,
658
- label="HF path (advanced)",
659
- visible=False,
660
- )
661
-
662
- gr.Markdown("**Sampling parameters** β€” control autoregressive decoding of the rig.")
663
- top_k = gr.Slider(
664
- 1, 200, value=5, step=1,
665
- label="top_k",
666
- info="Sample from the K most likely next tokens at each step. Lower = more deterministic output.",
667
- )
668
- top_p = gr.Slider(
669
- 0.1, 1.0, value=0.95, step=0.01,
670
- label="top_p (nucleus)",
671
- info="Sample from the smallest set of tokens whose cumulative probability β‰₯ p.",
672
- )
673
- temperature = gr.Slider(
674
- 0.1, 2.0, value=1.0, step=0.1,
675
- label="temperature",
676
- info="Softmax temperature. <1 sharpens the distribution (more conservative), >1 makes it flatter (more diverse).",
677
- )
678
- repetition_penalty = gr.Slider(
679
- 0.5, 3.0, value=2.0, step=0.1,
680
- label="repetition_penalty",
681
- info="Multiplicative penalty on tokens that have already been generated. 1.0 = no penalty.",
682
- )
683
- num_beams = gr.Slider(
684
- 1, 20, value=10, step=1,
685
- label="num_beams",
686
- info="Beam-search width. Larger = higher quality but slower; 1 disables beam search.",
687
- )
688
-
689
- gr.Markdown("**Pipeline toggles**")
690
- use_skeleton = gr.Checkbox(
691
- False,
692
- label="Use existing skeleton (predict skinning only)",
693
- info="If the uploaded file already contains a skeleton, keep it and only predict per-vertex skinning weights.",
694
- )
695
- use_transfer = gr.Checkbox(
696
- False,
697
- label="Preserve original texture & scale",
698
- info="Transfer the predicted rig back onto the original (unprocessed) mesh, so textures and world units are preserved.",
699
- )
700
- use_postprocess = gr.Checkbox(
701
- False,
702
- label="Voxel skin post-processing",
703
- info="Apply a voxel-based mask to the predicted skin weights before normalization. Slower.",
704
- )
705
-
706
- run_btn = gr.Button("πŸš€ Run", variant="primary")
707
-
708
- with gr.Column(scale=1):
709
- log = gr.Textbox(label="Status", lines=2, interactive=False)
710
- output = gr.File(label="Rigged GLB output", interactive=False)
711
- gr.Markdown(
712
- """
713
- **Notes**
714
- - The output `.glb` contains the predicted **skeleton + skinning weights**. Import it in Blender (File β†’ Import β†’ glTF&nbsp;2.0) or any DCC tool that reads glTF.
715
- - In Blender, if you see a `glTF_not_exported` placeholder node, you can safely remove it.
716
- - On busy moments Zero-GPU may queue your request for ~10–30&nbsp;s before inference starts β€” the status box will update once the GPU is attached.
717
- - Please do **not** upload confidential or NSFW content. See the
718
- [project page](https://zjp-shadow.github.io/works/SkinTokens/) for paper-accurate results and the
719
- [code repo](https://github.com/VAST-AI-Research/SkinTokens) for local / batch inference.
720
- """
721
- )
722
-
723
- run_btn.click(
724
- run_gradio,
725
- inputs=[
726
- files,
727
- top_k,
728
- top_p,
729
- temperature,
730
- repetition_penalty,
731
- num_beams,
732
- use_skeleton,
733
- use_transfer,
734
- use_postprocess,
735
- model_ckpt,
736
- hf_path,
737
- ],
738
- outputs=[log, output],
739
- )
740
-
741
- return app
742
-
743
-
744
- demo = build_gradio_app()
745
-
746
-
747
- # Note: we do NOT pre-warm `bpy_server` in the main process. `bpy_server.py`
748
- # transitively imports `src.model.michelangelo.utils.misc`, whose
749
- # module-level `use_flash3 = FLASH3()` calls `torch.cuda.get_device_name(0)`
750
- # at import time. That call fails ("RuntimeError: No CUDA GPUs are
751
- # available") in the main Gradio process on ZeroGPU, where the GPU is only
752
- # attached inside `@spaces.GPU`-decorated workers. So the bpy_server boot
753
- # happens on first request, inside the worker.
754
-
755
-
756
- # ---------------------------------------------------------------------------
757
- # Entry point.
758
- # ---------------------------------------------------------------------------
759
- if __name__ == "__main__":
760
- parser = argparse.ArgumentParser("TokenRig Demo")
761
- parser.add_argument("--input", help="Input file or directory")
762
- parser.add_argument("--output", help="Output file or directory")
763
-
764
- parser.add_argument("--top_k", type=int, default=5)
765
- parser.add_argument("--top_p", type=float, default=0.95)
766
- parser.add_argument("--temperature", type=float, default=1.0)
767
- parser.add_argument("--repetition_penalty", type=float, default=2.0)
768
- parser.add_argument("--num_beams", type=int, default=10)
769
-
770
- parser.add_argument("--use_skeleton", action="store_true")
771
- parser.add_argument("--use_transfer", action="store_true")
772
- parser.add_argument("--use_postprocess", action="store_true")
773
-
774
- parser.add_argument("--model_ckpt", default=MODEL_CKPTS[0] if MODEL_CKPTS else "")
775
- parser.add_argument("--hf_path", default=None)
776
-
777
- parser.add_argument("--gradio", action="store_true")
778
-
779
- args = parser.parse_args()
780
-
781
- if args.gradio or not args.input:
782
- demo.queue()
783
- demo.launch(ssr_mode=False)
784
- else:
785
- ensure_bpy_server_started()
786
- run_cli(args)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import importlib
3
+ import os
4
+ import sys
5
+ import tempfile
6
+ from pathlib import Path
7
+ from typing import List, Optional, Tuple
8
+
9
+ import gradio as gr
10
+ from torch import Tensor
11
+ from tqdm import tqdm
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # ZeroGPU compatibility shim. The hosted HF Space provides the `spaces`
15
+ # package; running locally we substitute a no-op.
16
+ # ---------------------------------------------------------------------------
17
+ try:
18
+ spaces = importlib.import_module("spaces")
19
+ except Exception:
20
+ class _SpacesCompat:
21
+ @staticmethod
22
+ def GPU(*args, **kwargs):
23
+ if len(args) == 1 and callable(args[0]) and not kwargs:
24
+ return args[0]
25
+
26
+ def _decorator(fn):
27
+ return fn
28
+
29
+ return _decorator
30
+
31
+ spaces = _SpacesCompat()
32
+
33
+ os.environ.setdefault("XFORMERS_IGNORE_FLASH_VERSION_CHECK", "1")
34
+ gr.TEMP_DIR = "tmp_gradio"
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Install the bundled `bpy` wheel at runtime if it isn't already importable.
39
+ #
40
+ # Why this is non-trivial:
41
+ # - Putting the wheel in requirements.txt fails: HF Spaces' Docker build
42
+ # mounts only requirements.txt BEFORE the repo COPY, so the wheel path
43
+ # doesn't exist at pip-install time.
44
+ # - PyPI doesn't ship a bpy wheel matching this exact build (rc0 / cp312 /
45
+ # manylinux_2_39).
46
+ # - The `bpy-*.whl` committed in this repo gets auto-tracked by HF's LFS
47
+ # layer (Hub auto-LFS for blobs > ~10 MB even when .gitattributes doesn't
48
+ # list `*.whl`). The container's COPY-from-repo only carries the LFS
49
+ # *pointer* file β€” a ~150-byte text stub β€” not the actual wheel binary.
50
+ # So `pip install <wheel>` and `zipfile.ZipFile(<wheel>)` both fail with
51
+ # "is not a zip file" / "Wheel is invalid".
52
+ #
53
+ # So: we detect the LFS-pointer case and re-fetch the real wheel from the
54
+ # HF Hub at runtime (where the API resolves LFS server-side), then extract
55
+ # it directly into site-packages.
56
+ # ---------------------------------------------------------------------------
57
+ def _ensure_bpy_installed():
58
+ try:
59
+ import bpy # noqa: F401
60
+ return
61
+ except Exception:
62
+ pass
63
+
64
+ import glob
65
+ import sysconfig
66
+ import zipfile
67
+
68
+ here = os.path.dirname(os.path.abspath(__file__))
69
+ wheels = sorted(glob.glob(os.path.join(here, "bpy-*.whl")))
70
+ if not wheels:
71
+ print("[demo] WARNING: bpy not importable and no bundled wheel found", flush=True)
72
+ return
73
+
74
+ wheel = wheels[-1]
75
+ wheel_name = os.path.basename(wheel)
76
+
77
+ # Detect LFS pointer (text stub starting with "version https://git-lfs...").
78
+ is_real_zip = False
79
+ try:
80
+ with open(wheel, "rb") as f:
81
+ is_real_zip = f.read(4).startswith(b"PK")
82
+ except Exception:
83
+ pass
84
+
85
+ if not is_real_zip:
86
+ print(
87
+ f"[demo] {wheel_name} on disk is an LFS pointer ({os.path.getsize(wheel)} B); "
88
+ f"fetching real wheel from HF Hub...",
89
+ flush=True,
90
+ )
91
+ from huggingface_hub import hf_hub_download
92
+
93
+ space_id = os.environ.get("SPACE_ID", "VAST-AI/SkinTokens")
94
+ token = os.environ.get("HF_TOKEN") # set as a Space secret for private repos
95
+ wheel = hf_hub_download(
96
+ repo_id=space_id,
97
+ repo_type="space",
98
+ filename=wheel_name,
99
+ token=token,
100
+ )
101
+ print(f"[demo] fetched -> {wheel} ({os.path.getsize(wheel)} B)", flush=True)
102
+
103
+ site = sysconfig.get_paths()["purelib"]
104
+ print(f"[demo] Extracting {wheel_name} into {site}", flush=True)
105
+ with zipfile.ZipFile(wheel) as z:
106
+ z.extractall(site)
107
+ print("[demo] bpy wheel extracted.", flush=True)
108
+
109
+
110
+ _ensure_bpy_installed()
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # Download model checkpoints (TokenRig + SkinTokens FSQ-CVAE) and the Qwen3
115
+ # tokenizer/config on first cold-start.
116
+ #
117
+ # These live in the *model* repo `VAST-AI/SkinTokens` (private), separate
118
+ # from this Space repo, so they aren't COPYed into the container. Re-uses
119
+ # `HF_TOKEN` from the Space secrets.
120
+ # ---------------------------------------------------------------------------
121
+ def _ensure_models_downloaded():
122
+ here = os.path.dirname(os.path.abspath(__file__))
123
+ needed_ckpts = [
124
+ "experiments/skin_vae_2_10_32768/last.ckpt",
125
+ "experiments/articulation_xl_quantization_256_token_4/grpo_1400.ckpt",
126
+ ]
127
+ qwen_dir = os.path.join(here, "models", "Qwen3-0.6B")
128
+
129
+ all_present = (
130
+ all(os.path.exists(os.path.join(here, p)) for p in needed_ckpts)
131
+ and os.path.exists(os.path.join(qwen_dir, "tokenizer.json"))
132
+ )
133
+ if all_present:
134
+ return
135
+
136
+ from huggingface_hub import hf_hub_download, snapshot_download
137
+
138
+ token = os.environ.get("HF_TOKEN")
139
+
140
+ for rel in needed_ckpts:
141
+ target = os.path.join(here, rel)
142
+ if os.path.exists(target):
143
+ continue
144
+ print(f"[demo] Downloading checkpoint: {rel}", flush=True)
145
+ hf_hub_download(
146
+ repo_id="VAST-AI/SkinTokens",
147
+ filename=rel,
148
+ local_dir=here,
149
+ token=token,
150
+ )
151
+
152
+ if not os.path.exists(os.path.join(qwen_dir, "tokenizer.json")):
153
+ print("[demo] Downloading Qwen3-0.6B tokenizer/config", flush=True)
154
+ snapshot_download(
155
+ repo_id="Qwen/Qwen3-0.6B",
156
+ local_dir=qwen_dir,
157
+ ignore_patterns=["*.bin", "*.safetensors"],
158
+ )
159
+
160
+ print("[demo] All checkpoints ready.", flush=True)
161
+
162
+
163
+ _ensure_models_downloaded()
164
+
165
+
166
+ from src.data.dataset import DatasetConfig, RigDatasetModule
167
+ from src.data.transform import Transform
168
+ from src.model.tokenrig import TokenRigResult
169
+ from src.tokenizer.parse import get_tokenizer
170
+ from src.server.spec import (
171
+ BpyClient,
172
+ bpy_server_request,
173
+ bpy_server_ping,
174
+ is_bpy_server_alive,
175
+ get_model,
176
+ )
177
+ from src.data.vertex_group import voxel_skin
178
+
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # Pre-warm `bpy_server` in the main (Gradio) process at module load.
182
+ #
183
+ # Why this is necessary on ZeroGPU: each user request runs inside a fresh
184
+ # `@spaces.GPU` worker process with a hard time budget (β‰ˆ60 s on free tier).
185
+ # Importing the Blender shared object inside that budget burns 30–60 s, so
186
+ # the worker is killed *during* bpy import β€” manifesting as
187
+ # "GPU task aborted" before any model code runs.
188
+ #
189
+ # We start `bpy_server.py` here, in the always-running main process, so the
190
+ # slow bpy import happens exactly once at Space boot.
191
+ #
192
+ # Communication between the main process / ZeroGPU workers and the bpy
193
+ # worker happens via **file-based IPC** under ``/tmp/bpy_ipc/``, avoiding
194
+ # TCP listening ports (which trigger HF Space proxy issues) and HTTP body-
195
+ # size limits.
196
+ # ---------------------------------------------------------------------------
197
+
198
+ MODEL_CKPTS = [
199
+ "experiments/articulation_xl_quantization_256_token_4/grpo_1400.ckpt",
200
+ ]
201
+
202
+ HF_PATHS = [
203
+ "None",
204
+ ]
205
+
206
+
207
+ def get_dataloader_workers() -> int:
208
+ # Always use 0 workers β€” the bpy IPC server is single-threaded and
209
+ # must process requests sequentially, so parallel loading provides
210
+ # no benefit. More importantly, DataLoader worker processes wrap
211
+ # exceptions from asset loading in a way that can leave the
212
+ # DataLoader iterator in an inconsistent state, blocking Gradio's
213
+ # event loop on subsequent requests.
214
+ return 0
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # bpy_server lifecycle β€” lazy start so the heavy import doesn't fight ZeroGPU
219
+ # during module load. Uses file-based IPC (no TCP listening).
220
+ # ---------------------------------------------------------------------------
221
+ _BPY_CLIENT: "Optional[BpyClient]" = None
222
+
223
+
224
+ def ensure_bpy_server_started(force: bool = False):
225
+ """Start the bpy worker (if not already running) and wait until ready.
226
+
227
+ Uses a fast PID-file check to detect an already-running bpy_server
228
+ (works across processes, including ZeroGPU workers). Falls back to
229
+ launching a fresh subprocess when no server is found.
230
+
231
+ When *force* is True the existing worker (if any) is killed and a
232
+ fresh one is started unconditionally β€” use after a timeout to
233
+ recover from a hung server.
234
+ """
235
+ global _BPY_CLIENT
236
+
237
+ if not force:
238
+ # Fast path: we hold a live client handle (same-process, e.g. main).
239
+ if _BPY_CLIENT is not None and _BPY_CLIENT.is_alive():
240
+ return
241
+
242
+ # Cross-process fast path: another process already started the worker.
243
+ # A live PID is not enough β€” on ZeroGPU the worker can outlive the
244
+ # GPU process that spawned it yet stop servicing the IPC dir, so
245
+ # require an actual ping reply before reusing it.
246
+ if is_bpy_server_alive():
247
+ if bpy_server_ping(timeout=5.0):
248
+ return
249
+ print("[demo] bpy worker PID alive but unresponsive β€” restarting", flush=True)
250
+
251
+ # If we had a dead client handle, clean it up.
252
+ if _BPY_CLIENT is not None:
253
+ try:
254
+ _BPY_CLIENT.shutdown()
255
+ except Exception:
256
+ pass
257
+
258
+ # Launch a fresh bpy worker (this blocks until bpy is imported, ~30–60 s
259
+ # on a cold container, but the @spaces.GPU duration budget accommodates).
260
+ _BPY_CLIENT = BpyClient.launch()
261
+
262
+
263
+ # ---------------------------------------------------------------------------
264
+ # Lazy model loading.
265
+ # ---------------------------------------------------------------------------
266
+ model = None
267
+ tokenizer = None
268
+ transform = None
269
+ CURRENT_MODEL_CKPT: Optional[str] = None
270
+ CURRENT_HF_PATH: Optional[str] = None
271
+
272
+
273
+ def load_model(model_ckpt: str, hf_path: Optional[str]) -> Tuple[str, str]:
274
+ global model, tokenizer, transform, CURRENT_MODEL_CKPT, CURRENT_HF_PATH
275
+ if hf_path == "None":
276
+ hf_path = None
277
+ if model is not None and model_ckpt == CURRENT_MODEL_CKPT and hf_path == CURRENT_HF_PATH:
278
+ return ("Model already loaded.", model_ckpt)
279
+
280
+ if not model_ckpt:
281
+ raise RuntimeError("model_ckpt is empty. Please select a checkpoint.")
282
+
283
+ print(f"Loading model: {model_ckpt}, hf_path={hf_path}")
284
+ model = get_model(model_ckpt, hf_path=hf_path)
285
+ assert model.tokenizer_config is not None
286
+ tokenizer = get_tokenizer(**model.tokenizer_config)
287
+ transform = Transform.parse(**model.transform_config["predict_transform"])
288
+ CURRENT_MODEL_CKPT = model_ckpt
289
+ CURRENT_HF_PATH = hf_path
290
+ return ("Model loaded.", model_ckpt)
291
+
292
+
293
+ # ---------------------------------------------------------------------------
294
+ # File utilities (CLI-side).
295
+ # ---------------------------------------------------------------------------
296
+ SUPPORTED_EXT = {".obj", ".fbx", ".glb"}
297
+
298
+
299
+ def collect_files(input_path: Path) -> List[Path]:
300
+ if input_path.is_file():
301
+ return [input_path]
302
+
303
+ files = []
304
+ for p in input_path.rglob("*"):
305
+ if p.suffix.lower() in SUPPORTED_EXT:
306
+ files.append(p)
307
+ return files
308
+
309
+
310
+ def map_output_path(in_path: Path, input_root: Path, output_root: Path) -> Path:
311
+ rel = in_path.relative_to(input_root)
312
+ return (output_root / rel).with_suffix(".glb")
313
+
314
+
315
+ # ---------------------------------------------------------------------------
316
+ # Core inference (shared by CLI and Gradio).
317
+ # ---------------------------------------------------------------------------
318
+ def run_rig(
319
+ filepaths: List[Path],
320
+ top_k: int,
321
+ top_p: float,
322
+ temperature: float,
323
+ repetition_penalty: float,
324
+ num_beams: int,
325
+ use_skeleton: bool,
326
+ use_transfer: bool,
327
+ use_postprocess: bool,
328
+ output_paths: List[Path],
329
+ model_ckpt: str,
330
+ hf_path: Optional[str],
331
+ ):
332
+ assert len(filepaths) == len(output_paths)
333
+ ensure_bpy_server_started()
334
+ load_model(model_ckpt, hf_path)
335
+
336
+ datapath = {
337
+ "data_name": None,
338
+ "loader": "bpy_server",
339
+ "filepaths": {"articulation": [str(p) for p in filepaths]},
340
+ }
341
+
342
+ dataset_config = DatasetConfig.parse(
343
+ shuffle=False,
344
+ batch_size=1,
345
+ num_workers=get_dataloader_workers(),
346
+ pin_memory=get_dataloader_workers() > 0,
347
+ persistent_workers=False,
348
+ datapath=datapath,
349
+ ).split_by_cls()
350
+
351
+ module = RigDatasetModule(
352
+ predict_dataset_config=dataset_config,
353
+ predict_transform=transform,
354
+ tokenizer=tokenizer,
355
+ process_fn=model._process_fn,
356
+ )
357
+
358
+ dataloader = module.predict_dataloader()["articulation"]
359
+
360
+ results_out = []
361
+ infer_device = model.device if model is not None else "cuda"
362
+
363
+ # Use explicit next() so we can catch errors from asset loading
364
+ # (which happens inside __getitem__ β†’ next(dataloader)) per-file.
365
+ dataloader_iter = iter(dataloader)
366
+ for i in tqdm(range(len(filepaths)), desc="Processing"):
367
+ filepath = filepaths[i]
368
+ out_path = output_paths[i]
369
+ try:
370
+ try:
371
+ batch = next(dataloader_iter)
372
+ except RuntimeError as exc:
373
+ if "timeout" not in str(exc).lower():
374
+ raise
375
+ # Hung bpy worker: restart it and retry this file once.
376
+ print("[run_rig] load timed out β€” restarting bpy worker and retrying once", flush=True)
377
+ ensure_bpy_server_started(force=True)
378
+ dataloader_iter = iter(module.predict_dataloader()["articulation"])
379
+ for _ in range(i):
380
+ next(dataloader_iter, None)
381
+ batch = next(dataloader_iter)
382
+ except StopIteration:
383
+ break
384
+ except Exception as exc:
385
+ err_msg = str(exc)
386
+ print(f"[Error] Failed to load '{filepath}': {err_msg}", flush=True)
387
+ import traceback
388
+ traceback.print_exc()
389
+ # Force-restart if the server timed out (hung) so the next file
390
+ # gets a clean interpreter instead of hitting the same hang.
391
+ _restart_bpy_if_dead(force=("timeout" in err_msg.lower()))
392
+ continue
393
+
394
+ try:
395
+ batch = {
396
+ k: v.to(infer_device) if isinstance(v, Tensor) else v
397
+ for k, v in batch.items()
398
+ }
399
+
400
+ if not use_skeleton:
401
+ batch.pop("skeleton_tokens", None)
402
+ batch.pop("skeleton_mask", None)
403
+
404
+ batch["generate_kwargs"] = dict(
405
+ max_length=2048,
406
+ top_k=int(top_k),
407
+ top_p=float(top_p),
408
+ temperature=float(temperature),
409
+ repetition_penalty=float(repetition_penalty),
410
+ num_return_sequences=1,
411
+ num_beams=int(num_beams),
412
+ do_sample=True,
413
+ )
414
+
415
+ if "skeleton_tokens" in batch and "skeleton_mask" in batch:
416
+ mask = batch["skeleton_mask"][0] == 1
417
+ skeleton_tokens = batch["skeleton_tokens"][0][mask].cpu().numpy()
418
+ else:
419
+ skeleton_tokens = None
420
+
421
+ preds: List[TokenRigResult] = model.predict_step(
422
+ batch,
423
+ skeleton_tokens=[skeleton_tokens] if skeleton_tokens is not None else None,
424
+ make_asset=True,
425
+ )["results"]
426
+
427
+ asset = preds[0].asset
428
+ assert asset is not None
429
+
430
+ if use_postprocess:
431
+ voxel = asset.voxel(resolution=196)
432
+ asset.skin *= voxel_skin(
433
+ grid=0,
434
+ grid_coords=voxel.coords,
435
+ joints=asset.joints,
436
+ vertices=asset.vertices,
437
+ faces=asset.faces,
438
+ mode="square",
439
+ voxel_size=voxel.voxel_size,
440
+ )
441
+ asset.normalize_skin()
442
+
443
+ out_path.parent.mkdir(parents=True, exist_ok=True)
444
+
445
+ if use_transfer:
446
+ payload = dict(
447
+ source_asset=asset,
448
+ target_path=asset.path,
449
+ export_path=str(out_path),
450
+ group_per_vertex=4,
451
+ )
452
+ res = bpy_server_request("transfer", payload)
453
+ else:
454
+ payload = dict(
455
+ asset=asset,
456
+ filepath=str(out_path),
457
+ group_per_vertex=4,
458
+ )
459
+ res = bpy_server_request("export", payload)
460
+
461
+ if res != "ok":
462
+ print(f"[Error] {filepath}: {res}")
463
+ else:
464
+ print(f"[OK] Exported: {out_path}")
465
+ results_out.append(out_path)
466
+ except Exception as exc:
467
+ err_msg = str(exc)
468
+ print(f"[Error] Failed to process '{filepath}': {err_msg}", flush=True)
469
+ import traceback
470
+ traceback.print_exc()
471
+ # Force-restart if the server timed out (hung).
472
+ _restart_bpy_if_dead(force=("timeout" in err_msg.lower()))
473
+ continue
474
+
475
+ return results_out
476
+
477
+
478
+ def _restart_bpy_if_dead(force: bool = False):
479
+ """If the bpy worker process died or hung, start a new one.
480
+
481
+ When *force* is True, the existing worker is killed and replaced
482
+ unconditionally β€” use after a request timeout to guarantee a clean
483
+ bpy interpreter for the next file.
484
+ """
485
+ if force:
486
+ print("[run_rig] bpy worker timed out β€” force-restarting...", flush=True)
487
+ try:
488
+ ensure_bpy_server_started(force=True)
489
+ except Exception as exc:
490
+ print(f"[run_rig] Failed to force-restart bpy worker: {exc}", flush=True)
491
+ elif not is_bpy_server_alive():
492
+ print("[run_rig] bpy worker is dead β€” restarting...", flush=True)
493
+ try:
494
+ ensure_bpy_server_started()
495
+ except Exception as exc:
496
+ print(f"[run_rig] Failed to restart bpy worker: {exc}", flush=True)
497
+
498
+
499
+ # ---------------------------------------------------------------------------
500
+ # CLI entry point.
501
+ # ---------------------------------------------------------------------------
502
+ def run_cli(args):
503
+ input_path = Path(args.input).resolve()
504
+ output_path = Path(args.output).resolve()
505
+
506
+ files = collect_files(input_path)
507
+ if not files:
508
+ raise RuntimeError("No valid 3D files found.")
509
+
510
+ if len(files) == 1 and output_path.suffix:
511
+ outputs = [output_path]
512
+ else:
513
+ outputs = [map_output_path(f, input_path, output_path) for f in files]
514
+
515
+ run_rig(
516
+ files,
517
+ args.top_k,
518
+ args.top_p,
519
+ args.temperature,
520
+ args.repetition_penalty,
521
+ args.num_beams,
522
+ args.use_skeleton,
523
+ args.use_transfer,
524
+ args.use_postprocess,
525
+ outputs,
526
+ args.model_ckpt,
527
+ args.hf_path,
528
+ )
529
+
530
+
531
+ # ---------------------------------------------------------------------------
532
+ # Gradio wrapper (with ZeroGPU duration estimator).
533
+ # ---------------------------------------------------------------------------
534
+ TOT = 0
535
+
536
+
537
+ def _gpu_duration(
538
+ files,
539
+ top_k,
540
+ top_p,
541
+ temperature,
542
+ repetition_penalty,
543
+ num_beams,
544
+ use_skeleton,
545
+ use_transfer,
546
+ use_postprocess,
547
+ model_ckpt,
548
+ hf_path,
549
+ ):
550
+ # Cold workers spend ~30–60 s importing bpy + loading the model before
551
+ # any GPU work. Give every request a generous 240 s floor.
552
+ file_count = len(files) if files is not None else 1
553
+ return min(900, max(240, 240 + 60 * file_count))
554
+
555
+
556
+ @spaces.GPU(duration=_gpu_duration)
557
+ def run_gradio(
558
+ files,
559
+ top_k,
560
+ top_p,
561
+ temperature,
562
+ repetition_penalty,
563
+ num_beams,
564
+ use_skeleton,
565
+ use_transfer,
566
+ use_postprocess,
567
+ model_ckpt,
568
+ hf_path,
569
+ ):
570
+ try:
571
+ if not files:
572
+ return "Please upload at least one 3D model.", None
573
+
574
+ tmp_out = Path(tempfile.mkdtemp(prefix="tokenrig_"))
575
+ filepaths = [Path(f.name) for f in files]
576
+ global TOT
577
+ outputs = []
578
+ for filepath in filepaths:
579
+ TOT += 1
580
+ outputs.append(tmp_out / f"res_{TOT}.glb")
581
+
582
+ run_rig(
583
+ filepaths,
584
+ top_k,
585
+ top_p,
586
+ temperature,
587
+ repetition_penalty,
588
+ num_beams,
589
+ use_skeleton,
590
+ use_transfer,
591
+ use_postprocess,
592
+ outputs,
593
+ model_ckpt,
594
+ hf_path,
595
+ )
596
+
597
+ # Collect successfully exported files
598
+ succeeded = [str(p) for p in outputs if p.exists()]
599
+ if not succeeded:
600
+ return "❌ No output files were produced β€” check the server logs for details.", None
601
+ return f"βœ… Processed {len(succeeded)} model(s).", succeeded
602
+ except Exception as exc:
603
+ import traceback
604
+ traceback.print_exc()
605
+ return f"❌ Error: {exc}", None
606
+
607
+
608
+ # ---------------------------------------------------------------------------
609
+ # Gradio UI.
610
+ # ---------------------------------------------------------------------------
611
+ def build_gradio_app():
612
+ model_ckpts = MODEL_CKPTS
613
+ hf_paths = HF_PATHS
614
+ default_ckpt = model_ckpts[0] if model_ckpts else ""
615
+ default_hf = hf_paths[0] if hf_paths else "None"
616
+
617
+ with gr.Blocks(title="SkinTokens Β· TokenRig Demo") as app:
618
+ gr.Markdown(
619
+ """
620
+ ## 🦴 Mesh to Rig with [SkinTokens](https://zjp-shadow.github.io/works/SkinTokens/) · TokenRig
621
+
622
+ Automated **skeleton generation + skinning weight prediction** for any 3D mesh, via a unified
623
+ autoregressive model over learned *SkinTokens*. Successor to
624
+ [UniRig](https://github.com/VAST-AI-Research/UniRig) (SIGGRAPH&nbsp;'25).
625
+
626
+ * Upload one or more meshes β†’ click **Run** β†’ download a rigged `.glb`.
627
+ * **Paper**: [arXiv&nbsp;2602.04805](https://arxiv.org/abs/2602.04805) &nbsp;Β·&nbsp;
628
+ **Code**: [VAST-AI-Research/SkinTokens](https://github.com/VAST-AI-Research/SkinTokens) &nbsp;Β·&nbsp;
629
+ **Weights**: [πŸ€—&nbsp;VAST-AI/SkinTokens](https://huggingface.co/VAST-AI/SkinTokens)
630
+ * Looking for **image β†’ rigged 3D** instead? Try our sibling Space
631
+ [πŸ€—&nbsp;VAST-AI/AniGen](https://huggingface.co/spaces/VAST-AI/AniGen).
632
+ * Want a full AI-powered 3D workspace? β†’ [Tripo](https://www.tripo3d.ai)
633
+ """
634
+ )
635
+
636
+ gr.HTML(
637
+ """
638
+ <style>
639
+ @keyframes gentle-pulse {
640
+ 0%, 100% { opacity: 1; }
641
+ 50% { opacity: 0.35; }
642
+ }
643
+ </style>
644
+ <div style="text-align:left; color:#888; font-size:1em; line-height:1.6; margin: 4px 0 -4px 0;">
645
+ <span style="animation: gentle-pulse 3s ease-in-out infinite; display:inline-block;">&#128161; <b>Tips</b></span>&ensp;
646
+ Defaults work well for most meshes.
647
+ &nbsp;β€’ If your mesh already has a skeleton and you only want skinning, enable
648
+ <b>Use existing skeleton</b> below.
649
+ &nbsp;β€’ To keep your original textures and world scale, enable <b>Preserve original texture &amp; scale</b>.
650
+ </div>
651
+ """
652
+ )
653
+
654
+ with gr.Row():
655
+ with gr.Column(scale=1):
656
+ files = gr.File(
657
+ label="3D Models ( .obj / .fbx / .glb, up to a few at a time )",
658
+ file_count="multiple",
659
+ file_types=[".obj", ".fbx", ".glb"],
660
+ )
661
+
662
+ with gr.Accordion("βš™οΈ Generation Settings", open=False):
663
+ model_ckpt = gr.Dropdown(
664
+ choices=model_ckpts,
665
+ value=default_ckpt,
666
+ label="Model checkpoint",
667
+ info="TokenRig autoregressive rigging model. The default is the GRPO-refined checkpoint recommended for most assets.",
668
+ interactive=True,
669
+ )
670
+ # Keep the hf_path component for callback compatibility, but hide it
671
+ # from the UI since it currently only exposes the default ("None") option.
672
+ hf_path = gr.Dropdown(
673
+ choices=hf_paths,
674
+ value=default_hf,
675
+ label="HF path (advanced)",
676
+ visible=False,
677
+ )
678
+
679
+ gr.Markdown("**Sampling parameters** β€” control autoregressive decoding of the rig.")
680
+ top_k = gr.Slider(
681
+ 1, 200, value=5, step=1,
682
+ label="top_k",
683
+ info="Sample from the K most likely next tokens at each step. Lower = more deterministic output.",
684
+ )
685
+ top_p = gr.Slider(
686
+ 0.1, 1.0, value=0.95, step=0.01,
687
+ label="top_p (nucleus)",
688
+ info="Sample from the smallest set of tokens whose cumulative probability β‰₯ p.",
689
+ )
690
+ temperature = gr.Slider(
691
+ 0.1, 2.0, value=1.0, step=0.1,
692
+ label="temperature",
693
+ info="Softmax temperature. <1 sharpens the distribution (more conservative), >1 makes it flatter (more diverse).",
694
+ )
695
+ repetition_penalty = gr.Slider(
696
+ 0.5, 3.0, value=2.0, step=0.1,
697
+ label="repetition_penalty",
698
+ info="Multiplicative penalty on tokens that have already been generated. 1.0 = no penalty.",
699
+ )
700
+ num_beams = gr.Slider(
701
+ 1, 20, value=10, step=1,
702
+ label="num_beams",
703
+ info="Beam-search width. Larger = higher quality but slower; 1 disables beam search.",
704
+ )
705
+
706
+ gr.Markdown("**Pipeline toggles**")
707
+ use_skeleton = gr.Checkbox(
708
+ False,
709
+ label="Use existing skeleton (predict skinning only)",
710
+ info="If the uploaded file already contains a skeleton, keep it and only predict per-vertex skinning weights.",
711
+ )
712
+ use_transfer = gr.Checkbox(
713
+ True,
714
+ label="Preserve original texture & scale",
715
+ info="Transfer the predicted rig back onto the original (unprocessed) mesh, so textures and world units are preserved.",
716
+ )
717
+ use_postprocess = gr.Checkbox(
718
+ False,
719
+ label="Voxel skin post-processing",
720
+ info="Apply a voxel-based mask to the predicted skin weights before normalization. Slower.",
721
+ )
722
+
723
+ run_btn = gr.Button("πŸš€ Run", variant="primary")
724
+
725
+ with gr.Column(scale=1):
726
+ log = gr.Textbox(label="Status", lines=2, interactive=False)
727
+ output = gr.File(label="Rigged GLB output", interactive=False)
728
+ gr.Markdown(
729
+ """
730
+ **Notes**
731
+ - The output `.glb` contains the predicted **skeleton + skinning weights**. Import it in Blender (File β†’ Import β†’ glTF&nbsp;2.0) or any DCC tool that reads glTF.
732
+ - In Blender, if you see a `glTF_not_exported` placeholder node, you can safely remove it.
733
+ - On busy moments Zero-GPU may queue your request for ~10–30&nbsp;s before inference starts β€” the status box will update once the GPU is attached.
734
+ - Please do **not** upload confidential or NSFW content. See the
735
+ [project page](https://zjp-shadow.github.io/works/SkinTokens/) for paper-accurate results and the
736
+ [code repo](https://github.com/VAST-AI-Research/SkinTokens) for local / batch inference.
737
+ """
738
+ )
739
+
740
+ run_btn.click(
741
+ run_gradio,
742
+ inputs=[
743
+ files,
744
+ top_k,
745
+ top_p,
746
+ temperature,
747
+ repetition_penalty,
748
+ num_beams,
749
+ use_skeleton,
750
+ use_transfer,
751
+ use_postprocess,
752
+ model_ckpt,
753
+ hf_path,
754
+ ],
755
+ outputs=[log, output],
756
+ )
757
+
758
+ return app
759
+
760
+
761
+ demo = build_gradio_app()
762
+
763
+
764
+ # Note: we do NOT pre-warm `bpy_server` in the main process. `bpy_server.py`
765
+ # transitively imports `src.model.michelangelo.utils.misc`, whose
766
+ # module-level `use_flash3 = FLASH3()` calls `torch.cuda.get_device_name(0)`
767
+ # at import time. That call fails ("RuntimeError: No CUDA GPUs are
768
+ # available") in the main Gradio process on ZeroGPU, where the GPU is only
769
+ # attached inside `@spaces.GPU`-decorated workers. So the bpy_server boot
770
+ # happens on first request, inside the worker.
771
+
772
+
773
+ # ---------------------------------------------------------------------------
774
+ # Entry point.
775
+ # ---------------------------------------------------------------------------
776
+ if __name__ == "__main__":
777
+ parser = argparse.ArgumentParser("TokenRig Demo")
778
+ parser.add_argument("--input", help="Input file or directory")
779
+ parser.add_argument("--output", help="Output file or directory")
780
+
781
+ parser.add_argument("--top_k", type=int, default=5)
782
+ parser.add_argument("--top_p", type=float, default=0.95)
783
+ parser.add_argument("--temperature", type=float, default=1.0)
784
+ parser.add_argument("--repetition_penalty", type=float, default=2.0)
785
+ parser.add_argument("--num_beams", type=int, default=10)
786
+
787
+ parser.add_argument("--use_skeleton", action="store_true")
788
+ parser.add_argument("--no_transfer", dest="use_transfer", action="store_false")
789
+ parser.add_argument("--use_postprocess", action="store_true")
790
+
791
+ parser.add_argument("--model_ckpt", default=MODEL_CKPTS[0] if MODEL_CKPTS else "")
792
+ parser.add_argument("--hf_path", default=None)
793
+
794
+ parser.add_argument("--gradio", action="store_true")
795
+
796
+ args = parser.parse_args()
797
+
798
+ if args.gradio or not args.input:
799
+ demo.queue()
800
+ demo.launch(ssr_mode=False)
801
+ else:
802
+ ensure_bpy_server_started()
803
+ run_cli(args)