InferenceWorker/CameraMotionCheck.cs
using System.Runtime.InteropServices;
using HumanoidMocap.Inference;
using OpenCvSharp;

namespace HumanoidMocap.Worker;

/// <summary>Decides whether the recording camera stayed still, from background image
/// features tracked against the first selected frame. The tracked person's crop is
/// excluded. This is an image-motion test only: it does not recover camera motion,
/// and a still image of a moving scene can mislead it.</summary>
public sealed class CameraMotionCheck : IDisposable
{
    public const string Version="background-features-v3";
    public const string StationaryPrefix="Stationary recording camera:";
    public const string MovingPrefix="Moving or undetermined recording camera:";
    /// <summary>Background shift, as a fraction of image width, below which the camera counts as still.</summary>
    public const float StationaryShift=.004f;
    const int WorkingWidth=480,SampleInterval=5,MinimumPoints=24;
    /// <summary>Largest mean change of background brightness, 0-1, below which a featureless background counts as unchanged.</summary>
    public const float UnchangedBackground=.012f;
    public sealed record Result(bool Stationary,float MedianShift,float LargestShift,int Samples,int UsableSamples,bool PlainBackground=false,float LargestBackgroundChange=0)
    {
        public string Diagnostic=>PlainBackground
            ?FormattableString.Invariant($"{StationaryPrefix} the background has too little detail to follow ({UsableSamples}/{Samples} sampled frames had enough features), but outside the tracked person its brightness changed by at most {LargestBackgroundChange*100:F1}% from the first frame, so nothing shows the camera moving and it is treated as still. Image test on a plain backdrop, not camera tracking.")
            :FormattableString.Invariant($"{(Stationary?StationaryPrefix:MovingPrefix)} background features moved {MedianShift*100:F2}% of image width at the median and {LargestShift*100:F2}% at most across {UsableSamples}/{Samples} sampled frames, outside the tracked person. Image-motion test, not camera tracking.");
    }
    Mat? reference;Point2f[] points=Array.Empty<Point2f>();float scale=1;int frames;
    readonly List<float> shifts=new();int samples;float largestChange;Rect2f referenceBody;
    public void Add(DecodedVideoFrame frame,GvhmrDecoder.Box person)
    {
        var index=frames++;if(index%SampleInterval!=0)return;
        scale=WorkingWidth/(float)frame.Width;
        using var rgba=new Mat(frame.Height,frame.Width,MatType.CV_8UC4);Marshal.Copy(frame.Rgba,0,rgba.Data,frame.Rgba.Length);
        using var full=new Mat();Cv2.CvtColor(rgba,full,ColorConversionCodes.RGBA2GRAY);
        var gray=new Mat();Cv2.Resize(full,gray,new Size(WorkingWidth,Math.Max(1,(int)MathF.Round(frame.Height*scale))),0,0,InterpolationFlags.Area);
        // The model crop is a generous square around a tall, narrow subject; masking all of
        // it left no background on the tennis sample. Limbs outside this band are handled
        // by forward-backward agreement and the median.
        Rect2f Body()=>new((person.CenterX-person.Size*.3f)*scale,(person.CenterY-person.Size*.55f)*scale,person.Size*.6f*scale,person.Size*1.1f*scale);
        if(reference is null)
        {
            reference=gray;var body=Body();referenceBody=body;
            using var mask=new Mat(gray.Size(),MatType.CV_8UC1,Scalar.White);
            Cv2.Rectangle(mask,new Rect((int)body.X,(int)body.Y,(int)body.Width,(int)body.Height),Scalar.Black,-1);
            points=Cv2.GoodFeaturesToTrack(gray,400,.01,8,mask,3,false,.04);
            return;
        }
        using(gray)
        {
            samples++;
            // Brightness change outside the person in both frames: the only evidence a plain backdrop offers.
            using(var difference=new Mat())
            using(var outside=new Mat(gray.Size(),MatType.CV_8UC1,Scalar.White))
            {
                Cv2.Absdiff(reference,gray,difference);
                foreach(var box in new[]{referenceBody,Body()})
                    Cv2.Rectangle(outside,new Rect((int)(box.X-box.Width*.25f),(int)(box.Y-box.Height*.1f),(int)(box.Width*1.5f),(int)(box.Height*1.2f)),Scalar.Black,-1);
                if(Cv2.CountNonZero(outside)>gray.Width*gray.Height/10)largestChange=Math.Max(largestChange,(float)Cv2.Mean(difference,outside).Val0/255);
                else largestChange=1; // the person fills the frame: no background to judge
            }
            if(points.Length<MinimumPoints)return;
            var tracked=new Point2f[points.Length];
            Cv2.CalcOpticalFlowPyrLK(reference,gray,points,ref tracked,out var status,out _,new Size(21,21),4);
            var returned=new Point2f[points.Length];
            Cv2.CalcOpticalFlowPyrLK(gray,reference,tracked,ref returned,out var back,out _,new Size(21,21),4);
            var now=Body();var moved=new List<float>();
            for(var i=0;i<points.Length;i++)
            {
                if(status[i]==0||back[i]==0||now.Contains(tracked[i]))continue;
                // Forward-backward agreement rejects features lost behind the moving person.
                if(points[i].DistanceTo(returned[i])>1)continue;
                moved.Add((float)points[i].DistanceTo(tracked[i])/WorkingWidth);
            }
            if(moved.Count<MinimumPoints)return;
            moved.Sort();shifts.Add(moved[moved.Count/2]);
        }
    }
    public Result Finish()
    {
        if(samples>=2&&shifts.Count<samples*.8f&&largestChange<UnchangedBackground)
            return new(true,0,0,samples,shifts.Count,true,largestChange);
        if(samples<2||shifts.Count<samples*.8f)return new(false,shifts.Count==0?0:shifts.OrderBy(s=>s).ElementAt(shifts.Count/2),shifts.Count==0?0:shifts.Max(),samples,shifts.Count);
        var ordered=shifts.OrderBy(s=>s).ToArray();
        return new(ordered[^1]<StationaryShift,ordered[ordered.Length/2],ordered[^1],samples,ordered.Length);
    }
    public void Dispose(){reference?.Dispose();reference=null;}
}