Editor/FlowVideoProcessor.cs
using System;
using System.Threading;
using System.Threading.Tasks;
using HumanoidMocapX.Inference;
using Sandbox;
namespace HumanoidMocapX.EditorTools;
/// <summary>Connects detection, camera reconstruction, body/hand features and motion
/// sampling. Model stages run sequentially so their GPU weights do not coexist.</summary>
internal sealed class FlowVideoProcessor
{
readonly CaptureModels models;
readonly FlowModel model;
readonly int cameraInterval, requestedLanes;
readonly Action<string> progress;
readonly Action<string, double?> reportProgress;
internal FlowVideoProcessor(CaptureModels models, CaptureOptions options, Action<string> progress,
Action<string, double?> reportProgress = null)
{
ArgumentNullException.ThrowIfNull(models); ArgumentNullException.ThrowIfNull(options); options.Validate();
this.models = models; model = options.Model; cameraInterval = options.CameraInterval; requestedLanes = options.Lanes; this.progress = progress;
this.reportProgress = reportProgress;
}
/// <summary>Reconstructs one person's motion from a 30 fps timeline, over the frames in
/// which they are visible; the motion's start time is the first of those, in seconds from
/// the timeline's start. Requires installed verified models and a live GPU. The caller owns
/// frames; all failures/cancellation propagate and no partial motion is returned. Detail
/// refines the motion's document; see <see cref="ObservedDetail"/>.</summary>
internal async Task<(FlowMotion Motion, PersonSpan Person, ObservedDetail Detail)> RunAsync(ICaptureFrames frames, CancellationToken cancellation)
{
var person = await DetectAsync(frames, cancellation);
var visible = person.Visible(frames);
var camera = await CameraAsync(visible, cancellation);
var (features, observations) = await FeaturesAsync(visible, person.Boxes, camera.Intrinsics, cancellation);
var motion = await GenerateAsync(features, camera.Rotations, visible.Count, cancellation);
motion.StartTime = person.StartSeconds;
return (motion, person, new ObservedDetail(observations, camera.Rotations, camera.Travel));
}
internal async Task<PersonSpan> DetectAsync(ICaptureFrames frames, CancellationToken cancellation)
{
var network = new YoloNetwork(); Report("Loading person detector", 0);
var weights = await Task.Run(() => YoloCheckpoint.Load(models.Detector, network, cancellation), cancellation);
await GameTask.MainThread(); cancellation.ThrowIfCancellationRequested();
// Fast convolutions leave detection waiting on each frame's CPU resize and box decoding,
// so it uses as many lanes as features do.
var laneCount = Math.Min(Lanes(), frames.Count);
var detectors = new YoloGpuDetector[laneCount]; var pixels = new byte[laneCount][];
var detections = new YoloBox[laneCount][]; var running = new Task<int>[laneCount];
var track = new PersonTrack(frames.Count);
try
{
for (var lane = 0; lane < laneCount; lane++)
{
detectors[lane] = new YoloGpuDetector(network, weights);
pixels[lane] = Pixels(frames); detections[lane] = new YoloBox[YoloNetwork.Predictions];
}
for (var start = 0; start < frames.Count; start += laneCount)
{
var count = Math.Min(laneCount, frames.Count - start);
for (var lane = 0; lane < count; lane++)
{
Report($"Tracking person · {start + lane + 1}/{frames.Count}", 0.10 * start / frames.Count);
await frames.ReadAsync(start + lane, pixels[lane], cancellation); await GameTask.MainThread();
running[lane] = detectors[lane].DetectAsync(pixels[lane], frames.Width, frames.Height, detections[lane], cancellation);
}
// The tracker links people frame to frame, so results are added in frame order.
for (var lane = 0; lane < count; lane++) track.Add(detections[lane].AsSpan(0, await running[lane]));
Array.Clear(running);
reportProgress?.Invoke($"Tracked person · {start + count}/{frames.Count}", 0.10 * (start + count) / frames.Count);
}
return track.Complete();
}
finally
{
foreach (var task in running) if (task is not null) try { await task; } catch { }
await GameTask.MainThread();
foreach (var detector in detectors) detector?.Dispose();
}
}
internal async Task<(float[] Rotations, float[] Intrinsics, float Travel)> CameraAsync(ICaptureFrames frames, CancellationToken cancellation)
{
var pixels = Pixels(frames); var count = FlowCamera.SampleCount(frames.Count, cameraInterval);
var reader = new CameraFrames(frames, pixels, cameraInterval, message => Report(message, 0.10));
var shape = new OmegaShape(count, reader.Image.Height, reader.Image.Width);
Report("Loading camera model", 0.10);
await GameTask.MainThread(); cancellation.ThrowIfCancellationRequested();
// Release each part's CPU weights after upload so later parts need not keep them live.
var camera = await OmegaGpuModel.LoadAsync(prefixes => VisionCheckpoint.LoadOmegaPart(models.Omega, cancellation, prefixes), shape, cancellation);
var poses = new float[count * FlowCamera.PoseChannels];
try { await camera.RunAsync(reader.ReadAsync, poses, cancellation); }
finally { await GameTask.MainThread(); camera.Dispose(); }
var rotations = new float[frames.Count * 9]; var intrinsics = new float[rotations.Length];
await Task.Run(() => FlowCamera.Decode(poses, frames.Count, cameraInterval, frames.Width, frames.Height, rotations, intrinsics, cancellation), cancellation);
reportProgress?.Invoke("Camera reconstructed", 0.20);
return (rotations, intrinsics, FlowCamera.Travel(poses));
}
/// <summary>FlowHMR's per-frame SAM features and, beside them, the frames' <see cref="SamObservations"/>.</summary>
internal async Task<(float[] Features, float[] Observations)> FeaturesAsync(ICaptureFrames frames, ReadOnlyMemory<float> boxes, ReadOnlyMemory<float> intrinsics, CancellationToken cancellation)
{
if (boxes.Length != frames.Count * 4 || intrinsics.Length != frames.Count * 9)
throw new ArgumentException("Capture needs one person box and intrinsic matrix per frame.");
SamDecoder.Finite(boxes.Span); SamDecoder.Finite(intrinsics.Span);
var features = new float[frames.Count * SamGpuFeatures.Elements];
var observations = new float[frames.Count * SamObservations.Elements];
var laneCount = Math.Min(Lanes(), frames.Count);
using var shared = new FlowGpuWeights();
SamGpuFeatures[] lanes = null; var running = new Task[laneCount];
try
{
lanes = await SamFeatures.LoadAsync(models.Sam, models.Mhr, laneCount, shared, message => Report(message, 0.20), cancellation);
var pixels = new byte[laneCount][];
for (var lane = 0; lane < laneCount; lane++) pixels[lane] = Pixels(frames);
for (var start = 0; start < frames.Count; start += laneCount)
{
var count = Math.Min(laneCount, frames.Count - start);
// Frames are read in order; each lane's GPU work then overlaps the other lane's CPU feedback.
for (var lane = 0; lane < count; lane++)
{
var i = start + lane;
Report($"Reconstructing body and hands · {i + 1}/{frames.Count}", 0.20 + 0.77 * start / frames.Count);
await frames.ReadAsync(i, pixels[lane], cancellation); await GameTask.MainThread();
running[lane] = lanes[lane].RunAsync(pixels[lane], frames.Width, frames.Height, boxes.Slice(i * 4, 4), intrinsics.Slice(i * 9, 9),
features.AsMemory(i * SamGpuFeatures.Elements, SamGpuFeatures.Elements), cancellation,
observations.AsMemory(i * SamObservations.Elements, SamObservations.Elements));
}
await Task.WhenAll(running[..count]);
Array.Clear(running);
reportProgress?.Invoke($"Reconstructed body and hands · {start + count}/{frames.Count}", 0.20 + 0.77 * (start + count) / frames.Count);
}
return (features, observations);
}
finally
{
// A lane still running after another failed must finish before its GPU buffers are released.
foreach (var task in running) if (task is not null) try { await task; } catch { }
await GameTask.MainThread();
if (lanes is not null) foreach (var lane in lanes) lane.Dispose();
}
}
internal async Task<FlowMotion> GenerateAsync(ReadOnlyMemory<float> features, ReadOnlyMemory<float> rotations, int frames, CancellationToken cancellation)
{
if (frames < 1 || frames > CaptureOptions.MaximumFrames || features.Length != frames * SamGpuFeatures.Elements || rotations.Length != frames * 9)
throw new ArgumentException("Motion sampling requires matching complete body and camera features.");
SamDecoder.Finite(features.Span); SamDecoder.Finite(rotations.Span); Report("Loading FlowHMR " + model, 0.97);
var weights = await Task.Run(() => FlowCheckpoint.Load(models.Flow, cancellation), cancellation);
var decoder = await Task.Run(() => FlowCheckpoint.LoadDecoder(models.Flow, models.Smpl, cancellation), cancellation);
var noise = new float[frames * FlowSampler.Channels]; var sampled = new float[noise.Length];
await Task.Run(() => FlowNoise.Fill(noise, 0, cancellation), cancellation);
await GameTask.MainThread(); cancellation.ThrowIfCancellationRequested(); var sampler = new FlowGpuSampler(weights, frames);
try { Report("Solving motion · 20 sampling steps", 0.97); await sampler.SampleAsync(features, rotations, noise, sampled, cancellation); }
finally { await GameTask.MainThread(); sampler.Dispose(); }
var motion = await Task.Run(() => decoder.Decode(sampled, cancellation), cancellation);
motion.Model = model; motion.Validate(); return motion;
}
void Report(string message, double fraction)
{ progress?.Invoke(message); reportProgress?.Invoke(message, fraction); }
// Frames processed at once: one frame's CPU work overlaps another's GPU work. Read when
// each stage starts, so memory freed by the previous stage counts.
int Lanes() => CaptureLanes.Resolve(requestedLanes, VideoMemoryBudget, Graphics.VideoMemoryUsed);
/// <summary>Lanes this GPU allows right now, for display in the settings.</summary>
internal static int LaneCapacity() => CaptureLanes.Capacity(VideoMemoryBudget, Graphics.VideoMemoryUsed);
// Engine APIs only, so Windows and Linux behave alike: the OS budget when the driver
// reports one, otherwise the card's total memory.
static ulong VideoMemoryBudget => Graphics.VideoMemoryBudget is > 0 and var budget ? budget : Sandbox.Engine.SystemInfo.GpuMemory;
static byte[] Pixels(ICaptureFrames frames)
{
ArgumentNullException.ThrowIfNull(frames);
if (frames.Width < 1 || frames.Height < 1 || (long)frames.Width * frames.Height > 33_177_600
|| frames.Count < 1 || frames.Count > CaptureOptions.MaximumFrames)
throw new ArgumentException("Capture frames must have fixed dimensions up to 8K and a length of 1–5000 frames.");
return new byte[checked(frames.Width * frames.Height * 4)];
}
sealed class CameraFrames
{
readonly ICaptureFrames frames;
readonly byte[] pixels;
readonly int interval;
readonly Action<string> progress;
readonly Action prepare;
Memory<float> output;
CancellationToken cancellation;
internal OmegaImage Image { get; }
internal CameraFrames(ICaptureFrames frames, byte[] pixels, int interval, Action<string> progress)
{
this.frames = frames; this.pixels = pixels; this.interval = interval; this.progress = progress;
Image = new(frames.Width, frames.Height); prepare = Prepare;
}
internal async Task ReadAsync(int sample, Memory<float> destination, CancellationToken token)
{
progress?.Invoke($"Reconstructing camera · {sample + 1}/{FlowCamera.SampleCount(frames.Count, interval)}");
await frames.ReadAsync(FlowCamera.SampleFrame(sample, frames.Count, interval), pixels, token);
output = destination; cancellation = token;
try { await Task.Run(prepare, token); }
finally { output = default; cancellation = default; }
}
void Prepare() => Image.Prepare(pixels, output.Span, cancellation);
}
}