Spaces:
Running on Zero
Running on Zero
File size: 6,772 Bytes
5c331a4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 | """Learn humanoid skinning remotely and retain the production geometry/materials."""
from dataclasses import dataclass
import json
import os
from pathlib import Path
import shutil
import subprocess
import time
from .artifacts import digest
@dataclass(frozen=True)
class RigResult:
mesh: Path
source_fbx: Path
provenance: dict
def rig_character(mesh: Path, output_dir: Path, notify=None) -> RigResult:
from gradio_client import Client, handle_file
from huggingface_hub import HfApi, get_token
output_dir.mkdir(parents=True, exist_ok=True)
space = os.environ.get("STUDIO_RIG_SPACE", "jasongzy/Make-It-Animatable")
token = os.environ.get("HF_TOKEN") or get_token()
revision = HfApi(token=token).space_info(space).sha
client = Client(
space,
token=token,
download_files=output_dir / "provider",
verbose=False,
)
endpoint = client.view_api(return_format="dict", print_info=False)["named_endpoints"]["/pipeline"]
restore_global = any(p["label"] == "Restore Global Transform" for p in endpoint["parameters"])
arguments = [handle_file(str(mesh.resolve())), False, "No", [], False, 0.01]
if restore_global:
arguments.append(True)
arguments.extend([restore_global, True, "LeftArm", False, None, True, True])
job = client.submit(*arguments, api_name="/pipeline")
receipt = {
"provider": space,
"provider_revision": revision,
"endpoint": "/pipeline",
"source_sha256": digest(mesh),
"started": time.time(),
"status": "running",
"restore_global": restore_global,
"reset_to_rest": False,
"animation_file": None,
}
receipt_path = output_dir / "rigging.json"
receipt_path.write_text(json.dumps(receipt, indent=2))
previous = None
try:
while not job.done():
status = job.status().code.name
if status != previous and notify:
notify(f"Learned character rig: {status.lower()}")
previous = status
time.sleep(2)
results = job.result()
# The generator's final yield can contain only Gradio skip updates. Keep the
# last concrete downloadable FBX among all yielded outputs.
candidates = [results, *job.outputs()]
fbx = None
normalized = None
for result in candidates:
for value in result if isinstance(result, (tuple, list)) else [result]:
if isinstance(value, dict):
value = value.get("value", value.get("path"))
if isinstance(value, dict):
value = value.get("path")
if isinstance(value, str) and value.lower().endswith(".fbx") and Path(value).is_file():
fbx = Path(value)
if isinstance(value, str) and Path(value).name == "normed.glb" and Path(value).is_file():
normalized = Path(value)
if fbx is None:
raise RuntimeError("Learned rig service did not return an FBX skin")
source_fbx = output_dir / "learned-rig.fbx"
shutil.copy2(fbx, source_fbx)
blender = os.environ.get("BLENDER_BIN") or shutil.which("blender")
if not blender:
mac = Path("/Applications/Blender.app/Contents/MacOS/Blender")
blender = str(mac) if mac.is_file() else None
if not blender:
raise RuntimeError("Blender is required to preserve production textures during rig transfer")
rigged = output_dir / "rigged.glb"
script = Path(__file__).resolve().parents[1] / "scripts/transfer_learned_rig.py"
normalization_args = []
if not restore_global:
if normalized is None:
raise RuntimeError("Rig service omitted the normalization mesh needed to restore coordinates")
normalization = output_dir / "normalization.json"
normalization.write_text(json.dumps(recover_normalization(mesh, normalized), indent=2))
normalization_args = ["--normalization", str(normalization.resolve())]
completed = subprocess.run(
[
blender,
"--background",
"--python-exit-code",
"1",
"--python",
str(script),
"--",
"--source",
str(mesh.resolve()),
"--rig",
str(source_fbx.resolve()),
"--output",
str(rigged.resolve()),
*normalization_args,
],
capture_output=True,
text=True,
timeout=600,
)
(output_dir / "transfer.log").write_text(completed.stdout + completed.stderr)
if completed.returncode or not rigged.is_file():
raise RuntimeError("Learned rig transfer failed; inspect rigging/transfer.log")
receipt.update(
status="complete",
finished=time.time(),
rig_sha256=digest(rigged),
source_fbx_sha256=digest(source_fbx),
transfer=json.loads(rigged.with_suffix(".transfer.json").read_text()),
)
return RigResult(rigged, source_fbx, receipt)
except Exception as exc:
receipt.update(status="failed", error=str(exc), finished=time.time())
raise
finally:
receipt_path.write_text(json.dumps(receipt, indent=2))
def recover_normalization(source: Path, normalized: Path) -> dict:
"""Recover the provider normalization using preserved indexed mesh topology."""
import numpy as np
import trimesh
original = trimesh.load(source, force="mesh")
canonical = trimesh.load(normalized, force="mesh")
if original.vertices.shape != canonical.vertices.shape or not np.array_equal(
original.faces, canonical.faces
):
raise RuntimeError("Rig normalization changed topology; cannot safely restore original coordinates")
homogeneous = np.column_stack((original.vertices, np.ones(len(original.vertices))))
transform = np.linalg.lstsq(homogeneous, canonical.vertices, rcond=None)[0]
residual = float(np.max(np.linalg.norm(homogeneous @ transform - canonical.vertices, axis=1)))
if residual > float(np.max(canonical.extents)) * 1e-5:
raise RuntimeError("Rig normalization is not an affine transform of the production mesh")
matrix = np.eye(4)
matrix[:3] = transform.T
conversion = np.array([[1, 0, 0, 0], [0, 0, -1, 0], [0, 1, 0, 0], [0, 0, 0, 1]])
return {
"inverse_blender": (conversion @ np.linalg.inv(matrix) @ np.linalg.inv(conversion)).tolist(),
"normalized_height": float(np.ptp(canonical.vertices[:, 1])),
"fit_residual": residual,
}
|