Image2Model / Retarget /retarget.py
Daankular's picture
Port MeshForge features to ZeroGPU Space: FireRed, PSHuman, Motion Search
8f1bcd9
"""
retarget.py
Pure-Python port of KeeMapBoneOperators.py core math.
Replaces bpy / mathutils with numpy. No Blender dependency.
Public API mirrors the Blender operator flow:
get_bone_position_ws(bone, arm) → np.ndarray(3)
get_bone_ws_quat(bone, arm) → np.ndarray(4) w,x,y,z
set_bone_position_ws(bone, arm, pos)
set_bone_rotation(...)
set_bone_position(...)
set_bone_position_pole(...)
set_bone_scale(...)
calc_rotation_offset(bone_item, src_arm, dst_arm, settings)
calc_location_offset(bone_item, src_arm, dst_arm)
transfer_frame(src_arm, dst_arm, bone_items, settings, do_keyframe)
→ Dict[bone_name → (pose_loc, pose_rot, pose_scale)]
transfer_animation(src_anim, dst_arm, bone_items, settings)
→ List[Dict[bone_name → (pose_loc, pose_rot, pose_scale)]]
"""
from __future__ import annotations
import math
import sys
from typing import Dict, List, Optional, Tuple
import numpy as np
from .skeleton import Armature, PoseBone
from .math3d import (
quat_identity, quat_normalize, quat_mul, quat_conjugate,
quat_rotation_difference, quat_dot,
quat_to_matrix4, matrix4_to_quat,
euler_to_quat, quat_to_euler,
translation_matrix, vec3, get_point_on_vector,
)
from .io.mapping import BoneMappingItem, KeeMapSettings
# ---------------------------------------------------------------------------
# Progress bar (console)
# ---------------------------------------------------------------------------
def _update_progress(job: str, progress: float) -> None:
length = 40
block = int(round(length * progress))
msg = f"\r{job}: [{'#'*block}{'-'*(length-block)}] {round(progress*100, 1)}%"
if progress >= 1:
msg += " DONE\r\n"
sys.stdout.write(msg)
sys.stdout.flush()
# ---------------------------------------------------------------------------
# World-space position / quaternion getters
# ---------------------------------------------------------------------------
def get_bone_position_ws(bone: PoseBone, arm: Armature) -> np.ndarray:
"""
Return world-space position of bone head.
Equivalent to Blender's GetBonePositionWS().
"""
ws_matrix = arm.world_matrix @ bone.matrix_armature
return ws_matrix[:3, 3].copy()
def get_bone_ws_quat(bone: PoseBone, arm: Armature) -> np.ndarray:
"""
Return world-space rotation as quaternion (w,x,y,z).
Equivalent to Blender's GetBoneWSQuat().
"""
ws_matrix = arm.world_matrix @ bone.matrix_armature
return matrix4_to_quat(ws_matrix)
# ---------------------------------------------------------------------------
# World-space position setter
# ---------------------------------------------------------------------------
def set_bone_position_ws(bone: PoseBone, arm: Armature, position: np.ndarray) -> None:
"""
Move bone so its world-space head = position.
Equivalent to Blender's SetBonePositionWS().
Strategy:
1. Build new armature-space matrix = old rotation + new translation
2. Strip parent transform to get new local translation
3. Update pose_location so FK matches
"""
# Current armature-space matrix (rotation/scale part preserved)
arm_mat = bone.matrix_armature.copy()
# Target armature-space position
arm_world_inv = np.linalg.inv(arm.world_matrix)
target_arm_pos = (arm_world_inv @ np.append(position, 1.0))[:3]
# New armature-space matrix with replaced translation
new_arm_mat = arm_mat.copy()
new_arm_mat[:3, 3] = target_arm_pos
# Convert to local (parent-relative) space
if bone.parent is not None:
parent_arm_mat = bone.parent.matrix_armature
new_local = np.linalg.inv(parent_arm_mat) @ new_arm_mat
else:
new_local = new_arm_mat
# Extract translation from new_local = rest_local @ T(pose_loc) @ ...
# Approximate: strip rest_local rotation contribution to isolate pose_location
rest_inv = np.linalg.inv(bone.rest_matrix_local)
pose_delta = rest_inv @ new_local
bone.pose_location = pose_delta[:3, 3].copy()
# Recompute FK for this bone and its subtree
if bone.parent is not None:
bone._fk(bone.parent.matrix_armature)
else:
bone._fk(np.eye(4))
# ---------------------------------------------------------------------------
# Rotation setter (core retargeting math)
# ---------------------------------------------------------------------------
def set_bone_rotation(
src_arm: Armature, src_name: str,
dst_arm: Armature, dst_name: str,
dst_twist_name: str,
correction_quat: np.ndarray,
has_twist: bool,
xfer_axis: str,
transpose: str,
mode: str,
) -> None:
"""
Port of Blender's SetBoneRotation().
Drives dst bone rotation to match src bone world-space rotation.
mode: "EULER" | "QUATERNION"
xfer_axis: "X" "Y" "Z" "XY" "XZ" "YZ" "XYZ"
transpose: "NONE" "ZYX" "ZXY" "XZY" "YZX" "YXZ"
"""
src_bone = src_arm.get_bone(src_name)
dst_bone = dst_arm.get_bone(dst_name)
# ------------------------------------------------------------------
# Get source and destination world-space quaternions (current pose)
# ------------------------------------------------------------------
src_ws_quat = get_bone_ws_quat(src_bone, src_arm)
dst_ws_quat = get_bone_ws_quat(dst_bone, dst_arm)
# Rotation difference: r such that dst_ws @ r ≈ src_ws
diff = quat_rotation_difference(dst_ws_quat, src_ws_quat)
# FinalQuat = dst_local_pose_delta @ diff @ correction
final_quat = quat_normalize(
quat_mul(quat_mul(dst_bone.pose_rotation_quat, diff), correction_quat)
)
# ------------------------------------------------------------------
# Apply axis masking / transpose (EULER mode)
# ------------------------------------------------------------------
if mode == "EULER":
euler = quat_to_euler(final_quat, order="XYZ")
# Transpose axes
if transpose == "ZYX":
euler = np.array([euler[2], euler[1], euler[0]])
elif transpose == "ZXY":
euler = np.array([euler[2], euler[0], euler[1]])
elif transpose == "XZY":
euler = np.array([euler[0], euler[2], euler[1]])
elif transpose == "YZX":
euler = np.array([euler[1], euler[2], euler[0]])
elif transpose == "YXZ":
euler = np.array([euler[1], euler[0], euler[2]])
# else NONE — no change
# Mask axes
if xfer_axis == "X":
euler[1] = 0.0; euler[2] = 0.0
elif xfer_axis == "Y":
euler[0] = 0.0; euler[2] = 0.0
elif xfer_axis == "Z":
euler[0] = 0.0; euler[1] = 0.0
elif xfer_axis == "XY":
euler[2] = 0.0
elif xfer_axis == "XZ":
euler[1] = 0.0
elif xfer_axis == "YZ":
euler[0] = 0.0
# XYZ → no masking
final_quat = euler_to_quat(euler[0], euler[1], euler[2], order="XYZ")
# Twist bone: peel Y rotation off to twist bone
if has_twist and dst_twist_name:
twist_bone = dst_arm.get_bone(dst_twist_name)
y_euler = quat_to_euler(final_quat, order="XYZ")[1]
# Remove Y from main bone
euler_no_y = quat_to_euler(final_quat, order="XYZ")
euler_no_y[1] = 0.0
final_quat = euler_to_quat(*euler_no_y, order="XYZ")
# Apply Y to twist bone
twist_euler = quat_to_euler(twist_bone.pose_rotation_quat, order="XYZ")
twist_euler[1] = math.degrees(y_euler)
twist_bone.pose_rotation_quat = euler_to_quat(*twist_euler, order="XYZ")
else: # QUATERNION
if final_quat[0] < 0:
final_quat = -final_quat
final_quat = quat_normalize(final_quat)
dst_bone.pose_rotation_quat = final_quat
# Recompute FK
parent = dst_bone.parent
dst_bone._fk(parent.matrix_armature if parent else np.eye(4))
# ---------------------------------------------------------------------------
# Position setter
# ---------------------------------------------------------------------------
def set_bone_position(
src_arm: Armature, src_name: str,
dst_arm: Armature, dst_name: str,
dst_twist_name: str,
correction: np.ndarray,
gain: float,
) -> None:
"""
Port of Blender's SetBonePosition().
Moves dst bone to match src bone world-space position, with offset/gain.
"""
src_bone = src_arm.get_bone(src_name)
dst_bone = dst_arm.get_bone(dst_name)
target_ws = get_bone_position_ws(src_bone, src_arm)
set_bone_position_ws(dst_bone, dst_arm, target_ws)
# Apply correction and gain to pose_location
dst_bone.pose_location[0] = (dst_bone.pose_location[0] + correction[0]) * gain
dst_bone.pose_location[1] = (dst_bone.pose_location[1] + correction[1]) * gain
dst_bone.pose_location[2] = (dst_bone.pose_location[2] + correction[2]) * gain
parent = dst_bone.parent
dst_bone._fk(parent.matrix_armature if parent else np.eye(4))
# ---------------------------------------------------------------------------
# Pole bone position setter
# ---------------------------------------------------------------------------
def set_bone_position_pole(
src_arm: Armature, src_name: str,
dst_arm: Armature, dst_name: str,
dst_twist_name: str,
pole_distance: float,
) -> None:
"""
Port of Blender's SetBonePositionPole().
Positions an IK pole target relative to source limb geometry.
"""
src_bone = src_arm.get_bone(src_name)
dst_bone = dst_arm.get_bone(dst_name)
parent_src = src_bone.parent_recursive[0] if src_bone.parent_recursive else src_bone
base_parent_ws = get_bone_position_ws(parent_src, src_arm)
base_child_ws = get_bone_position_ws(src_bone, src_arm)
# Tail = head + Y-axis direction of bone in world space
src_ws_mat = src_arm.world_matrix @ src_bone.matrix_armature
tail_ws = src_ws_mat[:3, 3] + src_ws_mat[:3, :3] @ np.array([0.0, 1.0, 0.0])
length_parent = np.linalg.norm(base_child_ws - base_parent_ws)
length_child = np.linalg.norm(tail_ws - base_child_ws)
total = length_parent + length_child
c_p_ratio = length_parent / total if total > 1e-12 else 0.5
length_pp_to_tail = np.linalg.norm(base_parent_ws - tail_ws)
average_location = get_point_on_vector(base_parent_ws, tail_ws, length_pp_to_tail * c_p_ratio)
distance = np.linalg.norm(base_child_ws - average_location)
if distance > 0.001:
pole_pos = get_point_on_vector(base_child_ws, average_location, pole_distance)
set_bone_position_ws(dst_bone, dst_arm, pole_pos)
parent = dst_bone.parent
dst_bone._fk(parent.matrix_armature if parent else np.eye(4))
# ---------------------------------------------------------------------------
# Scale setter
# ---------------------------------------------------------------------------
def set_bone_scale(
src_arm: Armature, src_name: str,
dst_arm: Armature, dst_name: str,
src_scale_bone_name: str,
gain: float,
axis: str,
max_scale: float,
min_scale: float,
) -> None:
"""
Port of Blender's SetBoneScale().
Scales dst bone based on dot product between two source bone quaternions.
"""
src_bone = src_arm.get_bone(src_name)
dst_bone = dst_arm.get_bone(dst_name)
secondary = src_arm.get_bone(src_scale_bone_name)
q1 = get_bone_ws_quat(src_bone, src_arm)
q2 = get_bone_ws_quat(secondary, src_arm)
amount = quat_dot(q1, q2) * gain
if amount < 0:
amount = -amount
amount = max(min_scale, min(max_scale, amount))
s = dst_bone.pose_scale
if axis == "X":
s[0] = amount
elif axis == "Y":
s[1] = amount
elif axis == "Z":
s[2] = amount
elif axis == "XY":
s[0] = s[1] = amount
elif axis == "XZ":
s[0] = s[2] = amount
elif axis == "YZ":
s[1] = s[2] = amount
else: # XYZ
s[:] = amount
parent = dst_bone.parent
dst_bone._fk(parent.matrix_armature if parent else np.eye(4))
# ---------------------------------------------------------------------------
# Correction calculators
# ---------------------------------------------------------------------------
def calc_rotation_offset(
item: BoneMappingItem,
src_arm: Armature,
dst_arm: Armature,
settings: KeeMapSettings,
) -> None:
"""
Auto-compute the rotation correction factor for one bone mapping.
Port of Blender's CalcRotationOffset().
Modifies item.correction_factor and item.quat_correction_factor in-place.
"""
if not item.source_bone_name or not item.destination_bone_name:
return
if not src_arm.has_bone(item.source_bone_name):
return
if not dst_arm.has_bone(item.destination_bone_name):
return
dst_bone = dst_arm.get_bone(item.destination_bone_name)
# Snapshot destination bone state
snap_r = dst_bone.pose_rotation_quat.copy()
snap_t = dst_bone.pose_location.copy()
starting_ws_quat = get_bone_ws_quat(dst_bone, dst_arm)
# Apply with identity correction
set_bone_rotation(
src_arm, item.source_bone_name,
dst_arm, item.destination_bone_name,
item.twist_bone_name,
quat_identity(),
False,
item.bone_rotation_application_axis,
item.bone_transpose_axis,
settings.bone_rotation_mode,
)
dst_arm.update_fk()
modified_ws_quat = get_bone_ws_quat(dst_bone, dst_arm)
# Correction = rotation that takes modified_ws back to starting_ws
q_diff = quat_rotation_difference(modified_ws_quat, starting_ws_quat)
euler = quat_to_euler(q_diff, order="XYZ")
item.correction_factor = euler.copy()
item.quat_correction_factor = q_diff.copy()
# Restore
dst_bone.pose_rotation_quat = snap_r
dst_bone.pose_location = snap_t
parent = dst_bone.parent
dst_bone._fk(parent.matrix_armature if parent else np.eye(4))
def calc_location_offset(
item: BoneMappingItem,
src_arm: Armature,
dst_arm: Armature,
) -> None:
"""
Auto-compute position correction for one bone mapping.
Port of Blender's CalcLocationOffset().
"""
if not item.source_bone_name or not item.destination_bone_name:
return
if not src_arm.has_bone(item.source_bone_name):
return
if not dst_arm.has_bone(item.destination_bone_name):
return
src_bone = src_arm.get_bone(item.source_bone_name)
dst_bone = dst_arm.get_bone(item.destination_bone_name)
source_ws_pos = get_bone_position_ws(src_bone, src_arm)
dest_ws_pos = get_bone_position_ws(dst_bone, dst_arm)
# Snapshot
snap_loc = dst_bone.pose_location.copy()
# Move dest to source position
set_bone_position_ws(dst_bone, dst_arm, source_ws_pos)
dst_arm.update_fk()
moved_pose_loc = dst_bone.pose_location.copy()
# Restore
set_bone_position_ws(dst_bone, dst_arm, dest_ws_pos)
dst_arm.update_fk()
delta = snap_loc - moved_pose_loc
item.position_correction_factor = delta.copy()
def calc_all_corrections(
bone_items: List[BoneMappingItem],
src_arm: Armature,
dst_arm: Armature,
settings: KeeMapSettings,
) -> None:
"""Auto-calculate rotation and position corrections for all mapped bones."""
for item in bone_items:
calc_rotation_offset(item, src_arm, dst_arm, settings)
if "pole" not in item.name.lower():
calc_location_offset(item, src_arm, dst_arm)
# ---------------------------------------------------------------------------
# Single-frame transfer
# ---------------------------------------------------------------------------
def transfer_frame(
src_arm: Armature,
dst_arm: Armature,
bone_items: List[BoneMappingItem],
settings: KeeMapSettings,
) -> Dict[str, Tuple[np.ndarray, np.ndarray, np.ndarray]]:
"""
Apply retargeting for all bone mappings at the current source frame.
src_arm must already have FK updated for the current frame.
Returns a dict of bone_name → (pose_location, pose_rotation_quat, pose_scale)
suitable for writing into a keyframe list.
"""
for item in bone_items:
if not item.source_bone_name or not item.destination_bone_name:
continue
if not src_arm.has_bone(item.source_bone_name):
continue
if not dst_arm.has_bone(item.destination_bone_name):
continue
# Build correction quaternion
if settings.bone_rotation_mode == "EULER":
cf = item.correction_factor
correction_quat = euler_to_quat(cf[0], cf[1], cf[2], order="XYZ")
else:
correction_quat = quat_normalize(item.quat_correction_factor)
# Rotation
if item.set_bone_rotation:
set_bone_rotation(
src_arm, item.source_bone_name,
dst_arm, item.destination_bone_name,
item.twist_bone_name,
correction_quat,
item.has_twist_bone,
item.bone_rotation_application_axis,
item.bone_transpose_axis,
settings.bone_rotation_mode,
)
dst_arm.update_fk()
# Position
if item.set_bone_position:
if item.postion_type == "SINGLE_BONE_OFFSET":
set_bone_position(
src_arm, item.source_bone_name,
dst_arm, item.destination_bone_name,
item.twist_bone_name,
item.position_correction_factor,
item.position_gain,
)
else:
set_bone_position_pole(
src_arm, item.source_bone_name,
dst_arm, item.destination_bone_name,
item.twist_bone_name,
-item.position_pole_distance,
)
dst_arm.update_fk()
# Scale
if item.set_bone_scale and item.scale_secondary_bone_name:
if src_arm.has_bone(item.scale_secondary_bone_name):
set_bone_scale(
src_arm, item.source_bone_name,
dst_arm, item.destination_bone_name,
item.scale_secondary_bone_name,
item.scale_gain,
item.bone_scale_application_axis,
item.scale_max,
item.scale_min,
)
dst_arm.update_fk()
# Snapshot destination bone state for this frame
result: Dict[str, Tuple[np.ndarray, np.ndarray, np.ndarray]] = {}
for item in bone_items:
if not item.destination_bone_name:
continue
if not dst_arm.has_bone(item.destination_bone_name):
continue
dst_bone = dst_arm.get_bone(item.destination_bone_name)
result[item.destination_bone_name] = (
dst_bone.pose_location.copy(),
dst_bone.pose_rotation_quat.copy(),
dst_bone.pose_scale.copy(),
)
return result
# ---------------------------------------------------------------------------
# Full animation transfer
# ---------------------------------------------------------------------------
def transfer_animation(
src_anim, # BVHAnimation or any object with .armature + .apply_frame(i) + .num_frames
dst_arm: Armature,
bone_items: List[BoneMappingItem],
settings: KeeMapSettings,
) -> List[Dict[str, Tuple[np.ndarray, np.ndarray, np.ndarray]]]:
"""
Transfer all frames from src_anim to dst_arm.
Returns list of keyframe dicts (one per frame sampled).
Equivalent to Blender's PerformAnimationTransfer operator.
"""
keyframes: List[Dict] = []
step = max(1, settings.keyframe_every_n_frames)
start = settings.start_frame_to_apply
total = settings.number_of_frames_to_apply
end = start + total
src_arm = src_anim.armature
i = start
n_steps = len(range(start, end, step))
step_i = 0
while i < end and i < src_anim.num_frames:
src_anim.apply_frame(i) # updates src_arm FK
dst_arm.update_fk()
frame_data = transfer_frame(src_arm, dst_arm, bone_items, settings)
keyframes.append(frame_data)
step_i += 1
_update_progress("Retargeting", step_i / n_steps)
i += step
_update_progress("Retargeting", 1.0)
return keyframes