Spaces:
Running on Zero
Running on Zero
Download studio/animation.py from mantrakp/component-studio-reference: direct link, hf CLI and curl.
- Browser
- Download file 9.4 kB
-
https://huggingface.co/spaces/mantrakp/component-studio-reference/resolve/main/studio/animation.py
- Command line
-
hf download hf://spaces/mantrakp/component-studio-reference/studio/animation.py
-
curl -L -o animation.py https://huggingface.co/spaces/mantrakp/component-studio-reference/resolve/main/studio/animation.py
9.4 kB
| """Headless learned-rig retargeting, rendered evidence and full-frame collision review.""" | |
| import json | |
| import os | |
| from pathlib import Path | |
| import shutil | |
| import subprocess | |
| ROOT = Path(__file__).resolve().parents[1] | |
| def _run(command, log_path, timeout=1800): | |
| with Path(log_path).open("w") as log: | |
| result = subprocess.run(command, stdout=log, stderr=subprocess.STDOUT, timeout=timeout) | |
| if result.returncode: | |
| tail = Path(log_path).read_text(errors="replace")[-4000:] | |
| raise RuntimeError(f"Animation stage failed ({result.returncode}): {tail}") | |
| def animate_character( | |
| rigged_glb, motion_npz, motion_bvh, output_dir, *, body_clearance=True, smooth_joints=False | |
| ): | |
| """Retarget real Kimodo motion to a learned Mixamo rig; never substitutes a canned clip.""" | |
| import numpy as np | |
| out = Path(output_dir).resolve() | |
| out.mkdir(parents=True, exist_ok=True) | |
| blender = os.getenv("BLENDER_BIN") or shutil.which("blender") | |
| if not blender and Path("/Applications/Blender.app/Contents/MacOS/Blender").exists(): | |
| blender = "/Applications/Blender.app/Contents/MacOS/Blender" | |
| if not blender: | |
| raise RuntimeError("Blender is required for animation export and collision auditing.") | |
| ffmpeg = shutil.which("ffmpeg") | |
| if not ffmpeg: | |
| raise RuntimeError("ffmpeg is required for the downloadable animation video.") | |
| with np.load(motion_npz, allow_pickle=False) as motion: | |
| poses = motion["posed_joints"] | |
| if poses.ndim != 3 or poses.shape[2] != 3 or len(poses) < 2 or not np.isfinite(poses).all(): | |
| raise ValueError("Kimodo output must contain finite frame/joint/xyz motion.") | |
| frames = len(poses) | |
| def blender_stage(script, arguments, name, timeout=1800): | |
| _run( | |
| [ | |
| blender, | |
| "--background", | |
| "--python-exit-code", | |
| "1", | |
| "--python", | |
| str(ROOT / "scripts" / script), | |
| "--", | |
| *map(str, arguments), | |
| ], | |
| out / f"{name}.log", | |
| timeout, | |
| ) | |
| def retarget(destination, smooth, name): | |
| destination.mkdir(parents=True, exist_ok=True) | |
| blender_stage( | |
| "retarget_character.py", | |
| [ | |
| "--input", | |
| Path(rigged_glb).resolve(), | |
| "--motion", | |
| Path(motion_npz).resolve(), | |
| "--bvh", | |
| Path(motion_bvh).resolve(), | |
| "--output", | |
| destination, | |
| *(["--body-clearance"] if body_clearance else []), | |
| *(["--smooth-joints", "--arm-blend-band", "0.9"] if smooth else []), | |
| ], | |
| name, | |
| ) | |
| blender_stage( | |
| "audit_character_collisions.py", | |
| ["--input", destination / "animation.glb", "--output", destination / "audit", "--frames", frames], | |
| name + "-audit", | |
| 3600, | |
| ) | |
| audit = json.loads((destination / "audit" / "audit.json").read_text()) | |
| technical_animation_check(audit, frames) | |
| (destination / "audit" / "audit.json").write_text(json.dumps(audit, indent=2)) | |
| return audit | |
| selected = out | |
| audit = retarget(out, smooth_joints, "retarget") | |
| selection = {"selected": "baseline", "baseline": audit["summary"]} | |
| if not audit["summary"]["technical_pass"] and not smooth_joints: | |
| candidate_dir = out / "repairs" / "smooth-joints" | |
| candidate = retarget(candidate_dir, True, "smooth-joints") | |
| improved, comparison = repair_improves(audit, candidate, frames) | |
| selection.update(candidate=candidate["summary"], comparison=comparison) | |
| if improved: | |
| selected, audit = candidate_dir, candidate | |
| selection["selected"] = "smooth-joints" | |
| (out / "repair-selection.json").write_text(json.dumps(selection, indent=2)) | |
| glb = selected / "animation.glb" | |
| blender_stage( | |
| "render_animation.py", | |
| [ | |
| "--input", | |
| glb, | |
| "--output", | |
| out / "frames", | |
| "--step", | |
| 1, | |
| "--size", | |
| 512, | |
| "--angle", | |
| "front", | |
| "--frame-count", | |
| frames, | |
| ], | |
| "render", | |
| 3600, | |
| ) | |
| video = out / "animation.mp4" | |
| _run( | |
| [ | |
| ffmpeg, | |
| "-y", | |
| "-framerate", | |
| "30", | |
| "-i", | |
| str(out / "frames" / "%04d.png"), | |
| "-c:v", | |
| "libx264", | |
| "-pix_fmt", | |
| "yuv420p", | |
| "-movflags", | |
| "+faststart", | |
| str(video), | |
| ], | |
| out / "video.log", | |
| ) | |
| from PIL import Image, ImageOps, ImageDraw | |
| samples = sorted(set(round(i * (frames - 1) / 15) + 1 for i in range(16))) | |
| sheet = Image.new("RGB", (4 * 256, 4 * 280), "#182830") | |
| draw = ImageDraw.Draw(sheet) | |
| for index, frame in enumerate(samples): | |
| with Image.open(out / "frames" / f"{frame:04d}.png") as image: | |
| sheet.paste(ImageOps.contain(image, (256, 256)), ((index % 4) * 256, (index // 4) * 280)) | |
| draw.text(((index % 4) * 256 + 8, (index // 4) * 280 + 258), f"Frame {frame}", fill="white") | |
| sheet.save(out / "contact-sheet.jpg") | |
| blender_stage( | |
| "render_animation.py", | |
| [ | |
| "--input", | |
| glb, | |
| "--output", | |
| out / "side", | |
| "--preview", | |
| "--angle", | |
| "side", | |
| "--size", | |
| 320, | |
| "--frame-count", | |
| frames, | |
| ], | |
| "side-render", | |
| ) | |
| side_files = sorted((out / "side").glob("*.png")) | |
| side_sheet = Image.new("RGB", (320 * len(side_files), 320), "#182830") | |
| for index, path in enumerate(side_files): | |
| with Image.open(path) as image: | |
| side_sheet.paste(image, (index * 320, 0)) | |
| side_sheet.save(out / "side-contact-sheet.jpg") | |
| audit = json.loads((selected / "audit" / "audit.json").read_text()) | |
| technical_pass = technical_animation_check(audit, frames) | |
| (selected / "audit" / "audit.json").write_text(json.dumps(audit, indent=2)) | |
| return { | |
| "animated_glb": str(glb), | |
| "video": str(video), | |
| "blend": str(selected / "animation.blend"), | |
| "retargeting": str(selected / "retargeting.json"), | |
| "repair_selection": str(out / "repair-selection.json"), | |
| "contact_sheet": str(out / "contact-sheet.jpg"), | |
| "contact_sheets": [str(out / "contact-sheet.jpg"), str(out / "side-contact-sheet.jpg")], | |
| "technical_pass": technical_pass, | |
| "audit": str(selected / "audit" / "audit.json"), | |
| "frames": frames, | |
| "fps": 30, | |
| "collision_summary": audit["summary"], | |
| } | |
| def technical_animation_check(audit, frames): | |
| import numpy as np | |
| tracks = np.array([[row[key] for key in sorted(row)] for row in audit["joints"]]) | |
| movement = float(np.linalg.norm(tracks - tracks[:1], axis=2).max()) | |
| summary = audit["summary"] | |
| technical_pass = bool( | |
| summary["frames"] == frames | |
| and len(tracks) == frames | |
| and summary["peak_new_pairs"] == 0 | |
| and summary["ground_min_z"] >= -1e-5 | |
| and summary.get("ground_min_all_vertices_z", summary["ground_min_z"]) >= -1e-5 | |
| and np.isfinite(tracks).all() | |
| and movement > 0.01 | |
| ) | |
| summary["maximum_joint_displacement"] = movement | |
| summary["technical_pass"] = technical_pass | |
| return technical_pass | |
| def repair_improves(baseline, candidate, frames): | |
| """Choose a repair only when geometry improves without sacrificing sampled motion.""" | |
| import numpy as np | |
| def tracks(audit): | |
| names = sorted(audit["joints"][0]) | |
| values = np.array([[row[name] for name in names] for row in audit["joints"]]) | |
| anchor = names.index("Head") if "Head" in names else 0 | |
| return values - values[:, anchor : anchor + 1], float( | |
| np.linalg.norm(values - values[:1], axis=2).max() | |
| ) | |
| (before, before_motion), (after, after_motion) = tracks(baseline), tracks(candidate) | |
| compatible = before.shape == after.shape and len(after) == frames | |
| finite = bool(np.isfinite(after).all()) | |
| difference = float(np.linalg.norm(after - before, axis=2).max()) if compatible else float("inf") | |
| left, right = baseline["summary"], candidate["summary"] | |
| checks = { | |
| "all_frames_finite": compatible and finite, | |
| "motion_preserved": compatible | |
| and finite | |
| and difference <= 1e-4 | |
| and after_motion >= max(0.01, before_motion * 0.95), | |
| "maximum_relative_joint_difference": difference, | |
| "fewer_intersections": right["peak_new_pairs"] < left["peak_new_pairs"], | |
| "no_rest_regression": right.get("rest_intersections", 0) <= left.get("rest_intersections", 0), | |
| "no_deeper_crossings": right["peak_triangle_plane_depth"] <= left["peak_triangle_plane_depth"] + 1e-6, | |
| "no_more_affected_surface": right["peak_affected_surface_fraction"] | |
| <= left["peak_affected_surface_fraction"] + 1e-6, | |
| "no_ground_regression": right["ground_min_all_vertices_z"] | |
| >= min(left["ground_min_all_vertices_z"], -1e-5) - 1e-6, | |
| } | |
| accepted = all(value for key, value in checks.items() if key != "maximum_relative_joint_difference") | |
| return bool(accepted), checks | |