Code/HumanoidMocap/Inference/HandIdentity.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
namespace HumanoidMocap.Inference;
/// <summary>Retain established left/right identities only while both independently
/// observed hands remain close to their own, spatially separated prior tracks.
/// This changes labels, never landmarks or presence, and creates no observations.</summary>
public static class HandIdentity
{
static Vector2 WristOf(HandObservation h)=>new(h.ImageLandmarks[0].X,h.ImageLandmarks[0].Y);
static float SpanOf(HandObservation h)=>new[]{5,9,13,17}.Max(i=>Vector2.Distance(WristOf(h),new(h.ImageLandmarks[i].X,h.ImageLandmarks[i].Y)));
static bool Usable(HandObservation h)=>h.Side is "L" or "R"&&h.ImageLandmarks.Length==21&&float.IsFinite(h.Handedness)&&h.Handedness>=0&&h.Handedness<=1&&
h.ImageLandmarks.All(p=>float.IsFinite(p.X+p.Y))&&SpanOf(h)>=4;
/// <summary>Physical consistency between consecutive frames, applied after
/// <see cref="PreserveSeparatedTracks"/>. A hand resting on one established track
/// cannot be the other hand when that hand's track was somewhere else a frame ago,
/// however confident the side classifier is. Two observations on the same physical
/// hand collapse to one. Labels and duplicates only: no landmark is edited and no
/// observation is created.</summary>
public static List<HandObservation> ResolveConflicts(IReadOnlyList<HandObservation> current,IReadOnlyList<HandObservation> previous)
{
var result=current.ToList();
if(result.Count==0||result.Any(h=>!Usable(h)))return result;
var tracks=previous.Where(Usable).ToArray();
HandObservation? Nearest(HandObservation hand)
{
var nearest=tracks.OrderBy(t=>Vector2.Distance(WristOf(t),WristOf(hand))).FirstOrDefault();
return nearest is not null&&Vector2.Distance(WristOf(nearest),WristOf(hand))<=SpanOf(nearest)*.7f?nearest:null;
}
if(tracks.Length==2&&tracks[0].Side!=tracks[1].Side)
{
var separation=Vector2.Distance(WristOf(tracks[0]),WristOf(tracks[1]));var span=tracks.Max(SpanOf);
if(separation>=span*1.5f)for(var i=0;i<result.Count;i++)
{
var hand=result[i];if(Nearest(hand) is not { } own||own.Side==hand.Side)continue;
var claimed=tracks.First(t=>t.Side==hand.Side);
// The claimed side's hand was at least a palm and a half away one frame ago.
if(Vector2.Distance(WristOf(claimed),WristOf(hand))<span*1.5f)continue;
result[i]=hand with{Side=own.Side,Handedness=1-hand.Handedness};
}
}
for(var i=0;i<result.Count;i++)for(var j=result.Count-1;j>i;j--)
{
var a=result[i];var b=result[j];var span=Math.Max(SpanOf(a),SpanOf(b));
var apart=Enumerable.Range(0,21).Average(k=>Vector2.Distance(new(a.ImageLandmarks[k].X,a.ImageLandmarks[k].Y),new(b.ImageLandmarks[k].X,b.ImageLandmarks[k].Y)));
if(apart>=span*.5f)continue;
// Same physical hand: keep the label of the track it continues, else the stronger presence.
var side=Nearest(a)?.Side??Nearest(b)?.Side;
var keepFirst=side is null||a.Side==b.Side?a.Presence>=b.Presence:a.Side==side;
if(!keepFirst)result[i]=b;
result.RemoveAt(j);
}
return result;
}
public static List<HandObservation> PreserveSeparatedTracks(IReadOnlyList<HandObservation> current,IReadOnlyList<HandObservation> previous)
{
var result=current.ToList();
if(current.Count!=2||previous.Count!=2||previous[0].Side==previous[1].Side||current.Any(h=>!h.Tracked)||
current.Concat(previous).Any(h=>h.Side is not ("L" or "R")||h.ImageLandmarks.Length!=21||
!float.IsFinite(h.Handedness)||h.Handedness<0||h.Handedness>1))return result;
Vector2 Wrist(HandObservation h)=>new(h.ImageLandmarks[0].X,h.ImageLandmarks[0].Y);
float Span(HandObservation h)=>new[]{5,9,13,17}.Max(i=>Vector2.Distance(Wrist(h),new(h.ImageLandmarks[i].X,h.ImageLandmarks[i].Y)));
var spans=previous.Select(Span).ToArray();
if(spans.Any(s=>!float.IsFinite(s)||s<4))return result;
var assignment=new int[2];
for(var i=0;i<2;i++)
{
var a=Vector2.Distance(Wrist(current[i]),Wrist(previous[0]));
var b=Vector2.Distance(Wrist(current[i]),Wrist(previous[1]));
if(!float.IsFinite(a)||!float.IsFinite(b))return result;
var nearest=a<b?0:1;var distance=Math.Min(a,b);var alternative=Math.Max(a,b);
// Bounds scale with the observed palm. Close/crossing tracks, jumps and
// reacquisition use the fresh classifier instead of forced identity.
if(distance>spans[nearest]*.7f||alternative-distance<spans.Max()*.75f)return result;
assignment[i]=nearest;
}
if(assignment[0]==assignment[1]||Vector2.Distance(Wrist(current[0]),Wrist(current[1]))<spans.Max()*1.5f)return result;
for(var i=0;i<2;i++)
{
var side=previous[assignment[i]].Side;
// Conservative heuristic: do not overrule a strong side prediction or
// a weak presence result solely because its wrist stayed nearby.
if(current[i].Side!=side&¤t[i].Handedness<.75f&¤t[i].Presence>=.9f)
// The binary classifier's actual probability for the retained side.
// A value below 0.5 honestly records disagreement, not added confidence.
result[i]=current[i] with {Side=side,Handedness=1-current[i].Handedness};
}
return result;
}
}