| """studio.rigging.deform — forward kinematics + Linear Blend Skinning warp. |
| |
| Pose representation: a dict mapping joint_name → local rotation (radians). Local |
| rotations propagate down the tree to produce a world rotation per joint; each |
| joint's deformed position is its parent's deformed position plus the rest offset |
| rotated by the parent's world rotation. |
| |
| Pixel warping is LBS forward-splat: each foreground pixel computes its blended |
| output position as Σ_b weight_b · T_b(pixel), then bilinearly splats its color |
| into the output buffer. Subject preservation is structural: every output pixel |
| is a weighted combination of source pixels — no pixel is invented. |
| """ |
| from __future__ import annotations |
|
|
| from typing import Mapping, Tuple |
|
|
| import numpy as np |
|
|
| from pixel_cursor.rigging import BONES, JOINT_INDEX, NUM_JOINTS, PARENTS, Skeleton |
|
|
|
|
| def _eval_order() -> Tuple[int, ...]: |
| root_idx = PARENTS.index(-1) |
| order = [root_idx] |
| queue = [root_idx] |
| seen = {root_idx} |
| while queue: |
| parent = queue.pop(0) |
| for child, parent_idx in enumerate(PARENTS): |
| if parent_idx == parent and child not in seen: |
| order.append(child) |
| seen.add(child) |
| queue.append(child) |
| return tuple(order) |
|
|
|
|
| _EVAL_ORDER: Tuple[int, ...] = _eval_order() |
|
|
|
|
| def forward_kinematics( |
| rest: Skeleton, |
| local_rotations: Mapping[str, float] | None = None, |
| ) -> Tuple[np.ndarray, np.ndarray]: |
| """Run FK from a dict of local rotations. |
| |
| Returns (deformed_positions shape (14, 2), world_rotations shape (14,)). |
| """ |
| local = np.zeros(NUM_JOINTS, dtype=np.float32) |
| if local_rotations: |
| for name, angle in local_rotations.items(): |
| local[JOINT_INDEX[name]] = float(angle) |
|
|
| deformed = rest.positions.copy().astype(np.float32) |
| world_rot = np.zeros(NUM_JOINTS, dtype=np.float32) |
|
|
| root_idx = PARENTS.index(-1) |
| world_rot[root_idx] = local[root_idx] |
|
|
| for joint in _EVAL_ORDER: |
| if joint == root_idx: |
| continue |
| parent = PARENTS[joint] |
| world_rot[joint] = world_rot[parent] + local[joint] |
| rest_offset = rest.positions[joint] - rest.positions[parent] |
| c, s = np.cos(world_rot[parent]), np.sin(world_rot[parent]) |
| rotated_y = c * rest_offset[0] - s * rest_offset[1] |
| rotated_x = s * rest_offset[0] + c * rest_offset[1] |
| deformed[joint, 0] = deformed[parent, 0] + rotated_y |
| deformed[joint, 1] = deformed[parent, 1] + rotated_x |
|
|
| return deformed.astype(np.float32), world_rot |
|
|
|
|
| def deform_sprite( |
| image: np.ndarray, |
| mask: np.ndarray, |
| rest: Skeleton, |
| weights: np.ndarray, |
| *, |
| local_rotations: Mapping[str, float] | None = None, |
| ) -> Tuple[np.ndarray, np.ndarray]: |
| """Apply LBS warp to a sprite. Returns (deformed_rgba (H, W, 4), deformed_mask (H, W) bool). |
| |
| Algorithm: |
| 1. FK produces deformed joint positions + per-joint world rotations. |
| 2. For each foreground source pixel p: |
| T_b(p) = deformed[parent(b)] + R(world_rot[parent(b)]) · (p - rest[parent(b)]) |
| out_pos = Σ_b weights[p, b] · T_b(p) |
| 3. Bilinearly splat source RGBA into the output accumulator at out_pos. |
| 4. Normalise accumulator by accumulated weight per output pixel. |
| """ |
| if mask.ndim != 2: |
| raise ValueError(f"mask must be 2D, got {mask.shape}") |
| H, W = mask.shape |
|
|
| deformed_pos, world_rot = forward_kinematics(rest, local_rotations) |
|
|
| if image.ndim == 2: |
| rgba_in = np.stack( |
| [image, image, image, np.full_like(image, 255)], axis=-1 |
| ) |
| elif image.shape[-1] == 3: |
| rgba_in = np.concatenate( |
| [image, np.full(image.shape[:2] + (1,), 255, dtype=np.uint8)], axis=-1 |
| ) |
| elif image.shape[-1] == 4: |
| rgba_in = image |
| else: |
| raise ValueError(f"unsupported image shape {image.shape}") |
|
|
| mask_bool = mask.astype(bool) |
| ys, xs = np.where(mask_bool) |
| if ys.size == 0: |
| return np.zeros((H, W, 4), dtype=np.uint8), np.zeros((H, W), dtype=bool) |
|
|
| src_coords = np.stack([ys, xs], axis=-1).astype(np.float32) |
| src_colors = rgba_in[ys, xs].astype(np.float32) |
| src_weights = weights[ys, xs] |
|
|
| out_pos = np.zeros_like(src_coords) |
| for b, (parent_idx, _child_idx) in enumerate(BONES): |
| rot = float(world_rot[parent_idx]) |
| c, s = np.cos(rot), np.sin(rot) |
| offset = src_coords - rest.positions[parent_idx] |
| ry = c * offset[:, 0] - s * offset[:, 1] |
| rx = s * offset[:, 0] + c * offset[:, 1] |
| rotated = np.stack([ry, rx], axis=-1) |
| per_bone_out = deformed_pos[parent_idx] + rotated |
| out_pos += src_weights[:, b : b + 1] * per_bone_out |
|
|
| accum = np.zeros((H, W, 4), dtype=np.float32) |
| wsum = np.zeros((H, W), dtype=np.float32) |
|
|
| oy = out_pos[:, 0] |
| ox = out_pos[:, 1] |
| oy_int = np.floor(oy).astype(np.int32) |
| ox_int = np.floor(ox).astype(np.int32) |
| fy = oy - oy_int |
| fx = ox - ox_int |
|
|
| for dy in (0, 1): |
| for dx in (0, 1): |
| wy = (1.0 - fy) if dy == 0 else fy |
| wx = (1.0 - fx) if dx == 0 else fx |
| wgt = wy * wx |
| yy = oy_int + dy |
| xx = ox_int + dx |
| valid = (yy >= 0) & (yy < H) & (xx >= 0) & (xx < W) & (wgt > 0) |
| if not valid.any(): |
| continue |
| yy_v = yy[valid] |
| xx_v = xx[valid] |
| w_v = wgt[valid] |
| for ch in range(4): |
| np.add.at(accum[..., ch], (yy_v, xx_v), w_v * src_colors[valid, ch]) |
| np.add.at(wsum, (yy_v, xx_v), w_v) |
|
|
| deformed_rgba = np.zeros((H, W, 4), dtype=np.uint8) |
| has_color = wsum > 0 |
| np.divide( |
| accum, |
| np.maximum(wsum[..., None], 1e-12), |
| out=accum, |
| where=has_color[..., None], |
| ) |
| deformed_rgba[has_color] = np.clip(accum[has_color], 0, 255).astype(np.uint8) |
|
|
| return deformed_rgba, has_color |
|
|
|
|
| __all__ = ["forward_kinematics", "deform_sprite"] |
|
|