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,
    }