InferenceWorker/ShotCutDetector.cs
using HumanoidMocap.Inference;

namespace HumanoidMocap.Worker;

/// <summary>Finds the first hard cut in the selected range. Edited footage that jumps to a close-up or
/// another angle is a different camera and often a different framing of the body; following the
/// subject across it produced confident nonsense on a sample that cut from a full figure to a shot
/// of the legs. A cut is a frame whose coarse thumbnail both differs strongly from the previous one
/// and no longer correlates with it, which fast movement, pans and lighting changes do not do.</summary>
public static class ShotCutDetector
{
    public const string Prefix="Footage cuts to another shot";
    const int Columns=32,Rows=18;
    public const float MinimumDifference=.10f,MaximumCorrelation=.55f;
    /// <summary>Index into the selected frames of the first frame after a cut, or null.</summary>
    public static int? FirstCut(string video,IReadOnlyList<double> times,CancellationToken cancellation)
    {
        using var decoder=new WindowsVideoDecoder(video);
        IEnumerable<float[]> Thumbnails()
        {
            for(var i=0;i<times.Count;i++)
            {
                cancellation.ThrowIfCancellationRequested();DecodedVideoFrame? frame;
                do{frame=decoder.Read(cancellation);if(frame is null)yield break;}while(frame.Time<times[i]-.00001);
                yield return Thumbnail(frame);
            }
        }
        return FirstCut(Thumbnails());
    }
    /// <summary>How many frames a damaged picture may last before it counts as a new shot.</summary>
    public const int MaximumGlitchFrames=3;
    /// <summary>A cut stays cut. A damaged frame (a broken stream, a dropped packet) also differs wildly from
    /// the one before, but the scene returns a frame or two later; on a kata sample one such frame ended a
    /// 28-second capture after 1.9 seconds. A candidate is therefore confirmed only if the following frames
    /// still do not match the picture from before it.</summary>
    public static int? FirstCut(IEnumerable<float[]> thumbnails)
    {
        float[]? before=null;var candidate=-1;var index=0;
        foreach(var current in thumbnails)
        {
            if(before is null){before=current;}
            else if(candidate<0)
            {
                if(IsCut(before,current))candidate=index;else before=current;
            }
            else if(!IsCut(before,current)){candidate=-1;before=current;} // the scene came back: a glitch
            else if(index-candidate>=MaximumGlitchFrames)return candidate;
            index++;
        }
        // Too few frames remain to tell a cut from a glitch; the half-second minimum shot length makes either harmless.
        return candidate>=0&&index-candidate>=2?candidate:null;
    }
    public static bool IsCut(float[] a,float[] b)
    {
        if(a.Length!=b.Length||a.Length==0)throw new ArgumentException("Thumbnails differ in size.");
        double difference=0,meanA=0,meanB=0;
        for(var i=0;i<a.Length;i++){difference+=Math.Abs(a[i]-b[i]);meanA+=a[i];meanB+=b[i];}
        difference/=a.Length;meanA/=a.Length;meanB/=a.Length;
        if(difference<MinimumDifference)return false;
        double covariance=0,varianceA=0,varianceB=0;
        for(var i=0;i<a.Length;i++){var x=a[i]-meanA;var y=b[i]-meanB;covariance+=x*y;varianceA+=x*x;varianceB+=y*y;}
        // A flat frame has no structure to correlate; a large jump to or from one still counts.
        var correlation=varianceA<1e-9||varianceB<1e-9?0:covariance/Math.Sqrt(varianceA*varianceB);
        return correlation<MaximumCorrelation;
    }
    /// <summary>Mean luminance, 0-1, of a 32x18 grid of cells.</summary>
    public static float[] Thumbnail(DecodedVideoFrame frame)
    {
        var result=new float[Columns*Rows];var counts=new int[Columns*Rows];
        // Every fourth pixel is plenty for a cell average.
        for(var y=0;y<frame.Height;y+=4)for(var x=0;x<frame.Width;x+=4)
        {
            var cell=Math.Min(Rows-1,y*Rows/frame.Height)*Columns+Math.Min(Columns-1,x*Columns/frame.Width);var i=(y*frame.Width+x)*4;
            result[cell]+=(frame.Rgba[i]*.299f+frame.Rgba[i+1]*.587f+frame.Rgba[i+2]*.114f)/255;counts[cell]++;
        }
        for(var i=0;i<result.Length;i++)if(counts[i]>0)result[i]/=counts[i];
        return result;
    }
}