HumanoidMocap/Core/Motion/FingerLimits.cs

Static utility that constrains captured finger joint rotations in a MotionDocument to biomechanical limits. It computes rest-pose axes, finds finger chains, decomposes joint rotation into swing components, clamps bend and sideways angles per-joint, and writes corrected quaternions back into frames.

Native Interop
using System;
using System.Collections.Generic;
using System.Linq;
using HumanoidMocap.Core.Mapping;

namespace HumanoidMocap.Core.Motion;
using Vector3 = System.Numerics.Vector3;
using Quaternion = System.Numerics.Quaternion;

/// <summary>Keeps a capture's fingers within what fingers can do, with angle limits in the manner of Spurr et al.,
/// "Weakly supervised 3D hand pose estimation via biomechanical constraints" (Hand-BMC). The hand network, working
/// from a small blurred crop of each hand, bent middle knuckles up to 14 degrees backwards and turned finger joints
/// about their own length, which a character shows as broken fingers. Each finger joint's rotation is split into
/// a bend toward the palm, a sideways bend and a turn about the finger: the turn is dropped, the sideways bend is
/// kept only at the knuckles, and both bends are held within <see cref="Knuckle"/>, <see cref="Middle"/> and
/// <see cref="Tip"/>. Thumbs are left as captured: their joints do not bend toward the palm.</summary>
public static class FingerLimits
{
    /// <summary>Bend toward the palm (negative: backwards) and sideways bend either way, degrees.</summary>
    public readonly record struct Limit(float Back,float Forward,float Sideways);
    public static readonly Limit Knuckle=new(30,100,25),Middle=new(5,115,5),Tip=new(15,90,5);
    static readonly string[] Fingers={"Index","Middle","Ring","Pinky"};

    /// <returns>The number of finger joint samples changed by more than a degree.</returns>
    public static int Apply(MotionDocument motion)
    {
        var bones=motion.Bones;int Role(string name)=>Enum.TryParse<BoneRole>(name,out var role)?bones.FindIndex(b=>b.Role==role):-1;
        int hips=Role("Hips"),head=Role("Head");
        if(hips<0||head<0||motion.Frames.Count==0)return 0;
        var restRotation=new Quaternion[bones.Count];var restPosition=new Vector3[bones.Count];
        for(var i=0;i<bones.Count;i++)
        {
            var parent=bones[i].Parent;var local=Quaternion.Normalize(MotionDocument.Q(bones[i].RestRotation));var offset=MotionDocument.V(bones[i].RestPosition);
            if(parent<0){restRotation[i]=local;restPosition[i]=offset;}
            else{restRotation[i]=Quaternion.Normalize(restRotation[parent]*local);restPosition[i]=restPosition[parent]+Vector3.Transform(offset,restRotation[parent]);}
        }
        // The rest pose holds the arms out with the palms down.
        var palm=restPosition[hips]-restPosition[head];if(palm.LengthSquared()<1e-8f)return 0;palm=Vector3.Normalize(palm);
        var changed=0;
        foreach(var side in new[]{"L","R"})foreach(var finger in Fingers)
        {
            var chain=new[]{Role(finger+"Prox"+side),Role(finger+"Mid"+side),Role(finger+"Dist"+side)};
            if(chain.Any(b=>b<0)||bones[chain[1]].Parent!=chain[0]||bones[chain[2]].Parent!=chain[1])continue;
            for(var k=0;k<3;k++)
            {
                var bone=chain[k];var limit=k==0?Knuckle:k==1?Middle:Tip;
                // The segment the joint turns: toward the next joint, or the last segment's own direction.
                var along=MotionDocument.V(bones[k<2?chain[k+1]:bone].RestPosition);if(along.LengthSquared()<1e-10f)continue;
                along=Vector3.Normalize(along);
                var toward=Vector3.Transform(palm,Quaternion.Inverse(restRotation[bone]));toward-=along*Vector3.Dot(toward,along);
                if(toward.LengthSquared()<1e-8f)continue;
                var bend=Vector3.Normalize(Vector3.Cross(along,Vector3.Normalize(toward)));var sideways=Vector3.Cross(bend,along);
                foreach(var frame in motion.Frames)
                {
                    var q=Quaternion.Normalize(MotionDocument.Q(frame.Rotations[bone]));if(q.W<0)q=-q;
                    // Swing: the rotation carrying the segment to where it points, without its turn about itself.
                    var to=Vector3.Transform(along,q);var swing=Between(along,to);
                    var axis=new Vector3(swing.X,swing.Y,swing.Z);var sin=axis.Length();var angle=2*MathF.Atan2(sin,swing.W);
                    var vector=sin>1e-9f?axis/sin*angle:Vector3.Zero;
                    var forward=Vector3.Dot(vector,bend)*180/MathF.PI;var across=Vector3.Dot(vector,sideways)*180/MathF.PI;
                    var limited=bend*(Math.Clamp(forward,-limit.Back,limit.Forward)*MathF.PI/180)+sideways*(Math.Clamp(across,-limit.Sideways,limit.Sideways)*MathF.PI/180);
                    var length=limited.Length();
                    var result=length>1e-9f?Quaternion.CreateFromAxisAngle(limited/length,length):Quaternion.Identity;
                    if(MotionCleanup.Angle(q,result)>MathF.PI/180)changed++;
                    frame.Rotations[bone]=new[]{result.X,result.Y,result.Z,result.W};
                }
            }
        }
        return changed;
    }
    /// <summary>The shortest rotation taking unit vector <paramref name="from"/> to unit vector <paramref name="to"/>.</summary>
    static Quaternion Between(Vector3 from,Vector3 to)
    {
        var dot=Vector3.Dot(from,to);
        if(dot<-0.999999f)
        {
            var axis=Vector3.Cross(Vector3.UnitX,from);if(axis.LengthSquared()<1e-6f)axis=Vector3.Cross(Vector3.UnitY,from);
            return Quaternion.CreateFromAxisAngle(Vector3.Normalize(axis),MathF.PI);
        }
        var cross=Vector3.Cross(from,to);
        return Quaternion.Normalize(new Quaternion(cross.X,cross.Y,cross.Z,1+dot));
    }
}