File size: 6,021 Bytes
7faaef2 | 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 162 163 164 165 166 167 168 169 170 171 172 | """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] # (N_fg, N_BONES)
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"]
|