InferenceWorker/BodyHandTracks.cs
using System.Numerics;
using HumanoidMocap.Inference;
using HumanoidMocap.Mapping;
using HumanoidMocap.Motion;

namespace HumanoidMocap.Worker;

/// <summary>Finger motion for a third-person body capture. GVHMR reconstructs no fingers, so each
/// hand is cropped where the body's own 2D wrist and elbow put it and reconstructed with WiLoR;
/// only its finger articulation, relative to the wrist, is attached beneath the body's hand
/// bone. Wrist placement and orientation stay the body model's. Hands too small in the image, or
/// whose forearm was not confidently seen, are skipped and labelled unobserved rather than guessed.</summary>
public static class BodyHandTracks
{
    public const string Version="body-wilor-fingers-v6-smooth-edges";
    /// <summary>A forearm shorter than this many source pixels gives WiLoR too little hand to read.</summary>
    public const float MinimumForearmPixels=36;
    static readonly string[] Roles={"IndexProx","IndexMid","IndexDist","MiddleProx","MiddleMid","MiddleDist",
        "PinkyProx","PinkyMid","PinkyDist","RingProx","RingMid","RingDist","ThumbProx","ThumbMid","ThumbDist"};
    static readonly int[] Parents={-1,0,1,2,0,4,5,0,7,8,0,10,11,0,13,14};
    /// <param name="LocalRotations">Fifteen finger joints, xyzw, already reflected for a left hand.</param>
    /// <param name="RestOffsets">Fifteen parent-relative rest offsets in metres, already reflected for a left hand.</param>
    public sealed record Sample(float[] LocalRotations,float[] RestOffsets);

    /// <summary>Image box around a hand from COCO-17 body joints (x, y, score), or null when the forearm
    /// was not confidently seen, is too short, or the hand would lie mostly outside the picture.</summary>
    public static WildHandsCrop.Box? Region(float[] joints,bool left,int width,int height)
    {
        if(joints.Length!=51)throw new ArgumentException("Expected COCO-17 joints.");
        var w=left?9:10;var e=left?7:8;
        if(!(joints[w*3+2]>=.5f)||!(joints[e*3+2]>=.5f))return null;
        var wrist=new Vector2(joints[w*3],joints[w*3+1]);var elbow=new Vector2(joints[e*3],joints[e*3+1]);
        var forearm=Vector2.Distance(wrist,elbow);if(!(forearm>=MinimumForearmPixels))return null;
        // The hand continues the forearm; its knuckles sit roughly a third of a forearm past the wrist.
        var centre=wrist+(wrist-elbow)*.35f;var half=forearm*.5f;
        if(centre.X<0||centre.Y<0||centre.X>=width||centre.Y>=height)return null;
        return new(centre.X-half,centre.Y-half,centre.X+half,centre.Y+half);
    }
    public static Sample Reconstruct(WilorModel model,DecodedVideoFrame frame,WildHandsCrop.Box box,bool left,CancellationToken cancellation)
    {
        var hand=model.Run(WilorCrop.Prepare(frame,box,!left).Image,cancellation).Hand;var sign=left?-1:1;
        var rotations=new float[60];var offsets=new float[45];
        for(var j=1;j<16;j++)
        {
            var q=hand.LocalRotations[j];if(left)q=new(q.X,-q.Y,-q.Z,q.W); // S R S, S=diag(-1,1,1)
            rotations[(j-1)*4]=q.X;rotations[(j-1)*4+1]=q.Y;rotations[(j-1)*4+2]=q.Z;rotations[(j-1)*4+3]=q.W;
            var offset=hand.RestJoints[j]-hand.RestJoints[Parents[j]];
            offsets[(j-1)*3]=sign*offset.X;offsets[(j-1)*3+1]=offset.Y;offsets[(j-1)*3+2]=offset.Z;
        }
        return new(rotations,offsets);
    }
    /// <summary>Adds finger bones beneath HandL/HandR for each side seen in at least a tenth of the frames.
    /// Short losses glide into the reacquired pose and are labelled inferred; the rest stay unobserved.</summary>
    /// <returns>Observed samples per side, left then right.</returns>
    /// <summary>Consecutive frames a hand must be seen for its fingers to be used, about a third of a second at 25 fps.</summary>
    public const int MinimumRunFrames=8;
    static int RunLength(IReadOnlyList<Sample?[]> samples,int side,int t)
    {
        int start=t,end=t;while(start>0&&samples[start-1][side] is not null)start--;while(end+1<samples.Count&&samples[end+1][side] is not null)end++;
        return end-start+1;
    }
    public static int[] Append(MotionDocument document,IReadOnlyList<Sample?[]> samples)
    {
        if(samples.Count!=document.Frames.Count)throw new ArgumentException("Hand samples do not match the motion sample count.");
        var counts=new int[2];
        for(var side=0;side<2;side++)
        {
            var suffix=side==0?"L":"R";var wrist=document.Bones.FindIndex(b=>b.Role==(side==0?BoneRole.HandL:BoneRole.HandR));
            // Sightings shorter than MinimumRunFrames are dropped: a small hand seen in scattered moments gave
            // fingers that disagreed by 25-60 degrees from frame to frame on the kata sample. Longer runs are kept
            // and the gaps glide between them.
            var seen=Enumerable.Range(0,samples.Count).Where(t=>samples[t][side] is not null).ToArray();
            var observed=seen.Where(t=>RunLength(samples,side,t)>=MinimumRunFrames).ToArray();counts[side]=observed.Length;
            if(wrist<0||observed.Length<Math.Max(5,samples.Count/10))continue;
            var rest=new float[45];
            foreach(var t in observed)for(var i=0;i<45;i++)rest[i]+=samples[t][side]!.RestOffsets[i]/observed.Length;
            var first=document.Bones.Count;
            for(var j=0;j<15;j++)document.Bones.Add(new(){Name=Roles[j]+suffix,Role=Enum.Parse<BoneRole>(Roles[j]+suffix),Parent=Parents[j+1]==0?wrist:first+Parents[j+1]-1,Group="fingers",
                RestPosition=new[]{rest[j*3],rest[j*3+1],rest[j*3+2]}});
            for(var j=0;j<15;j++)
            {
                Quaternion At(int t){var r=samples[t][side]!.LocalRotations;return Quaternion.Normalize(new(r[j*4],r[j*4+1],r[j*4+2],r[j*4+3]));}
                // Reconstructed is the enum's zero value, so unseen frames must be marked explicitly.
                var track=new Quaternion[samples.Count];var evidence=Enumerable.Repeat(JointEvidence.Unobserved,samples.Count).ToArray();
                foreach(var t in observed){track[t]=At(t);evidence[t]=JointEvidence.Reconstructed;}
                // Single-frame flips go first, within each run of observations.
                for(var start=0;start<samples.Count;)
                {
                    if(evidence[start]!=JointEvidence.Reconstructed){start++;continue;}
                    var end=start;while(end<samples.Count&&evidence[end]==JointEvidence.Reconstructed)end++;
                    var raw=track[start..end];MocapSmooth.RemoveSpikes(raw);Array.Copy(raw,0,track,start,raw.Length);start=end;
                }
                for(var t=0;t<samples.Count;t++)
                {
                    if(evidence[t]==JointEvidence.Reconstructed)continue;
                    var before=observed.LastOrDefault(o=>o<t,-1);var after=observed.FirstOrDefault(o=>o>t,-1);
                    evidence[t]=JointEvidence.Unobserved;
                    if(before<0){track[t]=track[after];continue;}
                    if(after<0){track[t]=track[before];continue;}
                    // Hold, then glide into the reacquired pose over at most 1.5 s, as hand captures do.
                    var end=document.Frames[after].Time;var start=Math.Max(document.Frames[before].Time,end-1.5);
                    var amount=(float)Math.Clamp((document.Frames[t].Time-start)/(end-start),0,1);amount=amount*amount*(3-2*amount);
                    track[t]=Quaternion.Slerp(track[before],track[after],amount);evidence[t]=JointEvidence.InferredGap;
                }
                // Then zero-phase Butterworth at the First Person default (Rokoko strength 7, 3.5 Hz) across each
                // continuous stretch, glides included, so the noisy first frames after a loss are smoothed too.
                var rate=1/Math.Max(1e-3,(document.Frames[^1].Time-document.Frames[0].Time)/Math.Max(1,samples.Count-1));
                for(var start=0;start<samples.Count;)
                {
                    if(evidence[start]==JointEvidence.Unobserved){start++;continue;}
                    var end=start;while(end<samples.Count&&evidence[end]!=JointEvidence.Unobserved)end++;
                    if(end-start>=8&&rate>7.8)
                    {
                        var run=MocapSmooth.Quaternions(track[start..end],Math.Min(3.5,rate*.45),rate);
                        Array.Copy(run,0,track,start,run.Length);
                    }
                    start=end;
                }
                // Before the first and after the last sighting the hand holds its nearest filtered pose.
                var firstSeen=observed[0];var lastSeen=observed[^1];
                for(var t=0;t<firstSeen;t++)track[t]=track[firstSeen];
                for(var t=lastSeen+1;t<samples.Count;t++)track[t]=track[lastSeen];
                for(var t=0;t<samples.Count;t++)Extend(document.Frames[t],document.Bones[first+j].RestPosition,track[t],evidence[t]);
            }
        }
        document.Diagnostics.RemoveAll(d=>d.StartsWith("GVHMR provides no detailed finger capture",StringComparison.Ordinal));
        document.Diagnostics.Add(FormattableString.Invariant(
            $"Fingers: WiLoR reconstructed the left hand in {counts[0]} and the right in {counts[1]} of {samples.Count} frames, cropped where the body's 2D wrist and elbow placed it. Only finger articulation relative to the wrist is used; wrist placement and orientation remain GVHMR's. Hands under {MinimumForearmPixels:F0} px of forearm, or seen in under a tenth of the frames, are not reconstructed. No per-joint confidence."));
        document.Validate();return counts;
    }
    static void Extend(MotionFrame frame,float[] restPosition,Quaternion rotation,JointEvidence evidence)
    {
        frame.Positions=frame.Positions.Append((float[])restPosition.Clone()).ToArray();
        frame.Rotations=frame.Rotations.Append(MotionDocument.A(rotation)).ToArray();
        frame.Evidence=frame.Evidence.Append(evidence).ToArray();
        if(frame.Confidence is { } confidence)frame.Confidence=confidence.Append(null).ToArray();
    }
    /// <summary>A document rebuilt from a capture keeps its finger bones (the builder copies the whole
    /// source) but writes fresh notes; carry the finger note across and drop the claim that none exist.</summary>
    public static void CarryFingerNotes(MotionDocument source,MotionDocument destination)
    {
        // Notes about how the capture was made, which the editor status and the reader rely on.
        string[] kept={"Lens:","Vision transformers","High frame rate footage","Performer not in view","Footage cuts to another shot"};
        destination.Diagnostics.AddRange(source.Diagnostics.Where(d=>kept.Any(p=>d.StartsWith(p,StringComparison.Ordinal))&&!destination.Diagnostics.Contains(d)));
        var notes=source.Diagnostics.Where(d=>d.StartsWith("Fingers:",StringComparison.Ordinal)).ToArray();
        if(notes.Length==0||destination.Bones.Count<=22)return;
        for(var i=0;i<destination.Diagnostics.Count;i++)
            destination.Diagnostics[i]=destination.Diagnostics[i].Replace(" Detailed fingers and object motion are not captured."," Object motion is not captured.");
        destination.Diagnostics.AddRange(notes.Where(n=>!destination.Diagnostics.Contains(n)));
    }
}