"""studio.rigging.skin — inverse-distance-falloff mesh skinning weights. For each foreground pixel and each bone, weight ∝ 1 / distance_to_bone^p, normalised across bones to sum to 1 per pixel. Background pixels get zero weight on every bone. This is the "fast / good-enough" baseline picked in PHASE_3_RESEARCH.md. Heat-diffusion skinning (better crease quality) is the upgrade path for Session 4 if creases hurt visual quality. """ from __future__ import annotations import numpy as np from pixel_cursor.rigging import BONES, NUM_JOINTS, Skeleton DEFAULT_FALLOFF: float = 4.0 DEFAULT_EPSILON: float = 0.5 def _point_segment_distance( points: np.ndarray, a: np.ndarray, b: np.ndarray ) -> np.ndarray: """Distance from each point to the closed segment AB. points: shape (..., 2) in (y, x) a, b: shape (2,) endpoints Returns shape (...) """ ab = b - a ap = points - a ab_len_sq = float((ab * ab).sum()) if ab_len_sq < 1e-6: return np.linalg.norm(ap, axis=-1) t = (ap * ab).sum(axis=-1) / ab_len_sq t = np.clip(t, 0.0, 1.0) closest = a + t[..., None] * ab return np.linalg.norm(points - closest, axis=-1) def compute_skin_weights( skeleton: Skeleton, mask: np.ndarray, *, falloff: float = DEFAULT_FALLOFF, epsilon: float = DEFAULT_EPSILON, ) -> np.ndarray: """Return (H, W, N_BONES) inverse-distance-falloff skinning weights. Properties: - Each foreground pixel's weights sum to 1.0 (partition of unity). - Each background pixel's weights are exactly 0 across all bones. - Deterministic: same skeleton + mask → same weights, bitwise. """ if mask.ndim != 2: raise ValueError(f"mask must be 2D, got {mask.shape}") if skeleton.image_shape != mask.shape: raise ValueError( f"skeleton.image_shape={skeleton.image_shape} != mask.shape={mask.shape}" ) H, W = mask.shape ys, xs = np.indices((H, W)) coords = np.stack([ys, xs], axis=-1).astype(np.float32) n_bones = len(BONES) dists = np.empty((H, W, n_bones), dtype=np.float32) rest = skeleton.positions for b, (parent_idx, child_idx) in enumerate(BONES): dists[..., b] = _point_segment_distance( coords, rest[parent_idx], rest[child_idx] ) inv = 1.0 / np.maximum(dists, epsilon) ** falloff total = inv.sum(axis=-1, keepdims=True) weights = inv / np.maximum(total, 1e-12) mask_bool = mask.astype(bool)[..., None] weights = np.where(mask_bool, weights, 0.0).astype(np.float32) return weights __all__ = ["compute_skin_weights", "DEFAULT_FALLOFF", "DEFAULT_EPSILON"]