InferenceWorker/HandCapture.cs
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using HumanoidMocap.Editor;
using HumanoidMocap.Inference;
using HumanoidMocap.Motion;
namespace HumanoidMocap.Worker;
/// <param name="EstimateFocal">Replace Camera's focal length with the clip's hand-scale
/// estimate when enough hands are found. Camera then supplies only the fallback.</param>
public sealed record HandCaptureRequest(string Video,string Models,string Output,string Backend,double Start,double End,WildHandsCrop.Camera Camera,bool EstimateFocal=false);
/// <summary>Bounded, resumable native hand reconstruction. Target selection and
/// corrections are deliberately excluded from the expensive prediction cache.</summary>
public static class HandCapture
{
public const string ImplementationVersion="native-mano-v7-followed-hands";
public const string FollowedSource="WiLoR followed through detector loss";
/// <summary>A hand WiLoR is following by itself after the landmark detector lost it.</summary>
public sealed record FollowedHand(string Side,float[][] Image,float[] GlobalRotation,float CropSize,double Since);
public sealed record CropObservation(string Side,float Presence,float Handedness,float[][] Image,float[][] World,bool Tracked)
{
public static CropObservation From(HandObservation hand)=>new(hand.Side,hand.Presence,hand.Handedness,
hand.ImageLandmarks.Select(MotionDocument.A).ToArray(),hand.RelativeWorldLandmarks.Select(MotionDocument.A).ToArray(),hand.Tracked);
public HandObservation ToObservation()=>new(Side,Presence,Handedness,Image.Select(MotionDocument.V).ToArray(),World.Select(MotionDocument.V).ToArray(),Tracked);
}
public sealed class State
{
public string Key { get; set; }="";
public string Status { get; set; }="pending";
public string? Error { get; set; }
public List<ManoFrameSample> Frames { get; set; }=new();
public List<CropObservation> Tracking { get; set; }=new();
/// <summary>Per-side WiLoR result of the last completed frame, detected or followed.</summary>
public List<FollowedHand> Following { get; set; }=new();
/// <summary>Camera the predictions were made with. Persisted so a resumed
/// WildHands job keeps the camera-ray encodings of its completed frames.</summary>
public WildHandsCrop.Camera? InferenceCamera { get; set; }
public int FocalEstimateSamples { get; set; }
public double InferenceSeconds { get; set; }
/// <summary>Of <see cref="InferenceSeconds"/>, the part spent finding and landmarking hands.</summary>
public double DetectionSeconds { get; set; }
public long PeakRamBytes { get; set; }
}
public static string Run(HandCaptureRequest request,CancellationToken cancellation=default,Action<string>? progress=null)
{
if(request.Backend is not ("mobilehand" or "wildhands" or "wilor"))throw new NotSupportedException("Choose MobileHand, WildHands or WiLoR. ACE is never selected automatically.");
if(!double.IsFinite(request.Start)||!double.IsFinite(request.End)||request.Start<0||request.End<=request.Start)
throw new ArgumentException("Select a nonempty video range.");
if(request.Camera is null||request.Camera.Fx<=0||request.Camera.Fy<=0||!new[]{request.Camera.Fx,request.Camera.Fy,request.Camera.Cx,request.Camera.Cy}.All(float.IsFinite))
throw new ArgumentException("Supply estimated or calibrated pinhole camera parameters.");
var metadata=Mp4Metadata.Read(request.Video);
var times=metadata.CaptureTimes.Where(t=>t>=request.Start&&t<request.End).ToArray();
if(times.Length is <1 or >1800)throw new ArgumentException("Choose between 1 and 1800 frames; the end time is exclusive.");
var wild=request.Backend=="wildhands";var mobile=request.Backend=="mobilehand";
var checkpointPath=Path.Combine(request.Models,mobile?"mobilehand/hmr_model_freihand_auc.pth":wild?"wildhands/wildhands.ckpt":"wilor/wilor_final.ckpt");
var detectorPath=Path.Combine(request.Models,"hand_landmarker.task");
// Model files keep a remembered checksum beside them; the video is hashed afresh and nothing is written next to it.
static string Hash(string path){using var input=File.OpenRead(path);return Convert.ToHexString(SHA256.HashData(input)).ToLowerInvariant();}
static string ModelHash(string path)=>FileChecksum.Sha256(path).ToLowerInvariant();
cancellation.ThrowIfCancellationRequested();var sourceHash=Hash(request.Video);var detectorHash=ModelHash(detectorPath);
var modelHash=mobile?MobileHandModel.CheckpointSha256:wild?WildHandsModel.CheckpointSha256:WilorModel.CheckpointSha256;
// Verify cached jobs too; a different file must not masquerade as pinned weights.
if(ModelHash(checkpointPath)!=modelHash)throw new InvalidDataException("Hand model checksum mismatch.");
// Reduced precision changes predictions slightly, so it keeps its own cache.
var wilorPrecision=!wild&&!mobile?GpuBackbone.KeySuffix??WilorModel.ChoosePrecision():null;
var implementation=ImplementationVersion+(wild?"; "+WildHandsCrop.ImplementationVersion:"")+(wilorPrecision is null or WilorModel.Float32?"":"; wilor-blocks-"+wilorPrecision);
var keyData=JsonSerializer.Serialize(new{pipeline=implementation+(metadata.CaptureStride>1?"; every"+metadata.CaptureStride:""),decoder=WindowsVideoDecoder.ImplementationVersion,detectorImplementation=ManagedHands.ImplementationVersion+"; "+LiteOnnx.KeySuffix,sourceHash,detectorHash,modelHash,request.Backend,request.Start,request.End,request.Camera});
var key=Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(keyData))).ToLowerInvariant();
var directory=Path.Combine(Path.GetFullPath(request.Output),key);Directory.CreateDirectory(directory);
using var jobLock=new FileStream(Path.Combine(directory,"job.lock"),FileMode.OpenOrCreate,FileAccess.ReadWrite,FileShare.None);
var statePath=Path.Combine(directory,"reconstruction.json");
if(File.Exists(statePath)&&new FileInfo(statePath).Length>64*1024*1024)throw new InvalidDataException("Cached hand job exceeds metadata limit.");
// An unreadable checkpoint (interrupted write) is started again rather than failing every later attempt.
State? cached=null;
try{if(File.Exists(statePath))cached=JsonSerializer.Deserialize<State>(File.ReadAllText(statePath),MotionDocument.JsonOptions);}
catch(JsonException){}
var state=cached??new State{Key=key};
if(state.Key!=key||state.Frames.Count>times.Length||state.Frames.Where((f,i)=>Math.Abs(f.Time-times[i])>.001).Any())
state=new State{Key=key};
void Save(string status)
{
state.Status=status;state.PeakRamBytes=Math.Max(state.PeakRamBytes,Process.GetCurrentProcess().PeakWorkingSet64);
Atomic(statePath,JsonSerializer.Serialize(state,MotionDocument.JsonOptions));
}
try
{
if(state.Frames.Count<times.Length)
{
progress?.Invoke("Loading "+request.Backend+" and MediaPipe crop detector");
var detector=new ManagedHands(File.ReadAllBytes(detectorPath));
if(state.InferenceCamera is null)
{
if(state.Frames.Count>0)throw new InvalidDataException("Cached hand job has no inference camera.");
state.InferenceCamera=request.Camera;
// WildHands consumes the lens before inference, and MobileHand's own hand scale is too
// unreliable to size one; WiLoR sizes it from its own predictions when motion is built.
if(request.EstimateFocal&&(wild||mobile))
{
progress?.Invoke("Estimating the recording lens from hand size");
var samples=FocalSamples(request.Video,times,detector,cancellation);state.FocalEstimateSamples=samples.Count;
if(CaptureCameraFraming.FocalLengthFromHandScale(samples,metadata.Width,metadata.Height) is float focal)
state.InferenceCamera=request.Camera with{Fx=focal,Fy=focal};
}
Save("running");
}
using var wildModel=wild?new WildHandsModel(checkpointPath,cancellation):null;
using var wilorModel=!wild&&!mobile?new WilorModel(checkpointPath,cancellation,GpuBackbone.Device is null?wilorPrecision:null,Path.Combine(request.Models,"gpu"),progress):null;
using var mobileModel=mobile?new MobileHandModel(checkpointPath,cancellation):null;
// Decoding runs a few frames ahead on its own thread, overlapping inference.
using var frames=PrefetchedFrames.Read(request.Video,cancellation,state.Frames.Count<times.Length?times[state.Frames.Count]:0).GetEnumerator();var saved=Stopwatch.StartNew();
for(var i=state.Frames.Count;i<times.Length;i++)
{
cancellation.ThrowIfCancellationRequested();DecodedVideoFrame frame;
do{if(!frames.MoveNext())throw new InvalidDataException("Video ended before the selected range.");frame=frames.Current;}while(frame.Time<times[i]-.00001);
if(Math.Abs(frame.Time-times[i])>.001)throw new InvalidDataException("Decoded timestamps differ from the video sample table.");
var clock=Stopwatch.StartNew();
var observations=detector.DetectTracked(frame.Rgba,frame.Width,frame.Height,state.Tracking.Select(h=>h.ToObservation()).ToArray(),cancellation)
.GroupBy(h=>h.Side).Select(g=>g.OrderByDescending(h=>h.Presence).First()).ToArray();
state.DetectionSeconds+=clock.Elapsed.TotalSeconds;
WildHandsCrop.Box Bounds(HandObservation hand)=>new(hand.ImageLandmarks.Min(p=>p.X),hand.ImageLandmarks.Min(p=>p.Y),hand.ImageLandmarks.Max(p=>p.X),hand.ImageLandmarks.Max(p=>p.Y));
var reconstructed=new List<ManoHandSample>();
if(wild&&observations.Length>0)
{
var r=observations.FirstOrDefault(h=>h.Side=="R");var l=observations.FirstOrDefault(h=>h.Side=="L");
var prepared=WildHandsCrop.Prepare(frame,r is null?null:Bounds(r),l is null?null:Bounds(l),state.InferenceCamera);
var prediction=wildModel!.Run(prepared.Image,prepared.Right,prepared.Left,prepared.RightCenter,prepared.RightCorners,prepared.LeftCenter,prepared.LeftCorners,cancellation);
foreach(var observed in observations)
{
var hand=observed.Side=="R"?prediction.Right:prediction.Left;var box=Bounds(observed);
reconstructed.Add(new(observed.Side,hand.RotationMatrices,hand.Shape,hand.WeakCamera,
new((box.Left+box.Right)/2,(box.Top+box.Bottom)/2,Math.Max(box.Right-box.Left,box.Bottom-box.Top)),observed.Presence,observed.Handedness,
observed.Tracked?"MediaPipe tracked landmark ROI":"MediaPipe palm detector",DetectorImageLandmarks:observed.ImageLandmarks.Select(MotionDocument.A).ToArray()));
}
}
else if(mobile)foreach(var observed in observations)
{
var crop=MobileHandCrop.Prepare(frame,Bounds(observed),observed.Side=="R");
var hand=mobileModel!.Run(crop.Image,cancellation);
if(hand.WeakCamera[0]<=0)throw new InvalidDataException("MobileHand predicted a nonpositive projection scale. Raw completed frames were preserved.");
reconstructed.Add(new(observed.Side,hand.RotationMatrices,hand.Shape,hand.WeakCamera,crop.Box,observed.Presence,observed.Handedness,
observed.Tracked?"MediaPipe tracked landmark ROI":"MediaPipe palm detector",hand.Parameters,observed.ImageLandmarks.Select(MotionDocument.A).ToArray()));
}
else if(!wild)
{
var following=new List<FollowedHand>();
foreach(var observed in observations)
{
var crop=WilorCrop.Prepare(frame,Bounds(observed),observed.Side=="R");
var hand=wilorModel!.Run(crop.Image,cancellation);
reconstructed.Add(new(observed.Side,hand.RotationMatrices,hand.Shape,hand.WeakCamera,crop.Box,observed.Presence,observed.Handedness,
observed.Tracked?"MediaPipe tracked landmark ROI":"MediaPipe palm detector",DetectorImageLandmarks:observed.ImageLandmarks.Select(MotionDocument.A).ToArray()));
following.Add(HandFollowing.Describe(observed.Side,hand,crop.Box,frame.Width,frame.Height,frame.Time));
}
// The landmark detector gives up on hands that are partly hidden, for example gripping
// an object edge-on, although they are in plain view. WiLoR keeps reconstructing such a
// hand from its own projected joints while it stays inside the image and moves plausibly.
foreach(var last in state.Following.Where(f=>observations.All(o=>o.Side!=f.Side)))
{
var bounds=new WildHandsCrop.Box(last.Image.Min(p=>p[0]),last.Image.Min(p=>p[1]),last.Image.Max(p=>p[0]),last.Image.Max(p=>p[1]));
if(!(bounds.Right>bounds.Left)||!(bounds.Bottom>bounds.Top))continue;
var crop=WilorCrop.Prepare(frame,bounds,last.Side=="R");var hand=wilorModel!.Run(crop.Image,cancellation);
var next=HandFollowing.Describe(last.Side,hand,crop.Box,frame.Width,frame.Height,last.Since);
if(!HandFollowing.Plausible(last,next,frame.Width,frame.Height,frame.Time))continue;
reconstructed.Add(new(last.Side,hand.RotationMatrices,hand.Shape,hand.WeakCamera,crop.Box,0,1,FollowedSource));
following.Add(next);
}
state.Following=following;
}
state.InferenceSeconds+=clock.Elapsed.TotalSeconds;
// A followed hand keeps its place in the tracker: its projected joints are the next
// frame's first search region, so the detector reacquires it under the same side.
observations=observations.Concat(state.Following.Where(f=>observations.All(o=>o.Side!=f.Side)).Select(f=>
new HandObservation(f.Side,0,1,f.Image.Select(p=>new System.Numerics.Vector3(p[0],p[1],0)).ToArray(),new System.Numerics.Vector3[21],true))).ToArray();
state.Tracking=observations.Select(CropObservation.From).ToList();state.Frames.Add(new(frame.Time,reconstructed));
// The checkpoint holds every frame so far; rewriting it per frame grows with the square of the clip length.
if(saved.Elapsed.TotalSeconds>=5||i==times.Length-1){Save("running");saved.Restart();}
progress?.Invoke($"Reconstructed {state.Frames.Count}/{times.Length} frames · {reconstructed.Count} hand(s)");
}
}
else progress?.Invoke("Reusing cached hand predictions");
cancellation.ThrowIfCancellationRequested();
var camera=state.InferenceCamera??throw new InvalidDataException("Cached hand job has no inference camera.");
var estimated=request.EstimateFocal&&camera!=request.Camera;
// Focal length never enters the WiLoR or MobileHand networks, so their own
// metric hand scale refines the detector-based estimate without new inference.
if(request.EstimateFocal&&!wild&&!mobile)
{
// Distance is taken along the ray of the detected palm, where the wrist is
// anchored, rather than the crop centre; they differ in wide-angle views.
int[] palm={0,5,9,13,17};
var samples=state.Frames.SelectMany(f=>f.Hands).Where(h=>h.WeakCamera[0]>0).Select(h=>
{
var landmarks=h.DetectorImageLandmarks is {Length:21} image&&image.All(p=>p is {Length:>=2})?image:null;
return new CaptureCameraFraming.HandScaleSample(mobile?224/(1000*h.WeakCamera[0]*h.Crop.Size):2/(h.Crop.Size*h.WeakCamera[0]),
landmarks is null?h.Crop.CenterX:palm.Average(i=>landmarks[i][0]),landmarks is null?h.Crop.CenterY:palm.Average(i=>landmarks[i][1]));
}).ToArray();
if(CaptureCameraFraming.FocalLengthFromHandScale(samples,metadata.Width,metadata.Height) is float focal)
{camera=camera with{Fx=focal,Fy=focal};estimated=true;state.FocalEstimateSamples=samples.Length;}
}
var motion=ManoMotionBuilder.Build(state.Frames,request.Backend,checkpointPath,Path.GetFileNameWithoutExtension(request.Video),
Path.GetFullPath(request.Video),sourceHash,metadata.CaptureFrameRate,camera,metadata.Width,metadata.Height,cancellation,estimated?state.FocalEstimateSamples:null);
if(metadata.SamplingNote is { } sampling)motion.Diagnostics.Add(sampling);
if(request.Backend=="wilor")motion.Diagnostics.Add(GpuBackbone.DeviceNote);
motion.ModelVersion+="; "+implementation+"; crop detector "+ManagedHands.ImplementationVersion+"; "+WindowsVideoDecoder.ImplementationVersion;
var motionPath=Path.Combine(directory,"raw-hands-v5-camera.hmotion");Atomic(motionPath,motion.ToJson());
state.Error=null;Save("complete");return motionPath;
}
catch(OperationCanceledException){Save("cancelled_resumable");throw;}
catch(Exception error){state.Error=error.Message;Save("failed_resumable");throw;}
}
/// <summary>Untracked detections on at most 48 evenly spaced frames. These only
/// size the lens assumption; they are never reused as reconstruction observations.</summary>
static List<CaptureCameraFraming.HandScaleSample> FocalSamples(string video,double[] times,ManagedHands detector,CancellationToken cancellation)
{
var wanted=Enumerable.Range(0,Math.Min(48,times.Length)).Select(i=>times.Length<=48?i:(int)Math.Round(i*(times.Length-1)/47d)).ToHashSet();
var samples=new List<CaptureCameraFraming.HandScaleSample>();using var decoder=new WindowsVideoDecoder(video);
for(var i=0;i<times.Length;i++)
{
cancellation.ThrowIfCancellationRequested();DecodedVideoFrame? frame;
do{frame=decoder.Read(cancellation);if(frame is null)return samples;}while(frame.Time<times[i]-.00001);
if(!wanted.Contains(i))continue;
foreach(var hand in detector.Detect(frame.Rgba,frame.Width,frame.Height,cancellation))
if(CaptureCameraFraming.DepthPerFocal(hand.ImageLandmarks,hand.RelativeWorldLandmarks) is float depth)
samples.Add(new(depth,hand.ImageLandmarks.Average(p=>p.X),hand.ImageLandmarks.Average(p=>p.Y)));
}
// Release the decoded full-resolution frames before the pose model loads its working set.
GC.Collect();
return samples;
}
static void Atomic(string path,string content)
{var temporary=path+".tmp";File.WriteAllText(temporary,content);File.Move(temporary,path,true);}
}