tests/HumanoidRetargeter.Tests/Formats/BvhCalibrationTrimTests.cs

Unit tests for the BVH importer trimming logic. They synthesize small BVH streams and assert that short rest-like calibration segments at clip starts/ends are removed only when criteria are met, and that legitimate motion is not trimmed.

File Access
using System.Globalization;
using System.Numerics;
using System.Text;
using HumanoidRetargeter.Core.Formats.Bvh;
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Skeleton;
using Xunit;

namespace HumanoidRetargeter.Tests.Formats;

/// <summary>
/// The BVH importer's calibration rest-frame trim (user report: 47_01.bvh — "starts either
/// ends or starts at t-pose … when i move the preview slider to 0 then there's a t-pose").
/// Mocap conversions (CMU asf/amc exports among them) prepend a skeleton-calibration
/// segment: the rest pose itself, hard-cut — or blend-ramped over 2–3 frames
/// (makehuman-retarget exports) — into the real motion. Measured on the repro corpus:
/// calibration frames/ramps sit ≤ 24° (max joint rotation) from the identity-rotation bind
/// with a 25–177° discontinuity into the motion; every real clip edge sits ≥ 80° from the
/// bind with ≤ 8° edge continuity. The importer drops such a segment (≤ 4 frames) per clip
/// end, and ONLY when its frames are rest-like AND the clip beyond is not AND the segment's
/// exit discontinuity is large both absolutely and against the clip's median inter-frame
/// delta — a clip legitimately idling near rest must never lose its first frame.
/// </summary>
public class BvhCalibrationTrimTests
{
    private const float Rad2Deg = 180f / MathF.PI;

    // ---------------------------------------------------------------- synthetic fixtures

    /// <summary>
    /// Two-joint rig (30 native fps = the import grid, so native frames map 1:1 onto clip
    /// frames) whose Head rotation per native frame is caller-chosen. Offsets keep the rest
    /// height above the meters heuristic threshold.
    /// </summary>
    private static byte[] BvhWithHeadAngles(params float[] headZDegPerFrame)
    {
        var sb = new StringBuilder();
        sb.AppendLine("HIERARCHY");
        sb.AppendLine("ROOT Hips");
        sb.AppendLine("{");
        sb.AppendLine("    OFFSET 0 0 0");
        sb.AppendLine("    CHANNELS 6 Xposition Yposition Zposition Zrotation Yrotation Xrotation");
        sb.AppendLine("    JOINT Head");
        sb.AppendLine("    {");
        sb.AppendLine("        OFFSET 0 70 0");
        sb.AppendLine("        CHANNELS 3 Zrotation Yrotation Xrotation");
        sb.AppendLine("        End Site");
        sb.AppendLine("        {");
        sb.AppendLine("            OFFSET 0 10 0");
        sb.AppendLine("        }");
        sb.AppendLine("    }");
        sb.AppendLine("}");
        sb.AppendLine("MOTION");
        sb.AppendLine($"Frames: {headZDegPerFrame.Length}");
        sb.AppendLine("Frame Time: 0.0333333");
        foreach (var z in headZDegPerFrame)
        {
            sb.AppendLine(string.Create(
                CultureInfo.InvariantCulture, $"0 90 0 0 0 0 {z} 0 0"));
        }
        return Encoding.ASCII.GetBytes(sb.ToString());
    }

    private static float HeadAngleDeg(SourceScene scene, int frame)
    {
        var head = scene.Skeleton.IndexOf("Head");
        return MathQ.AngleBetween(
            Quaternion.Identity, scene.Clips[0].Frames[frame][head].Rot) * Rad2Deg;
    }

    /// <summary>A held pose at 70° with ~1°/frame drift; interior deltas ≈ 1°.</summary>
    private static float[] HeldPose(int frames, float startDeg = 70f)
    {
        var values = new float[frames];
        for (var f = 0; f < frames; f++)
            values[f] = startDeg + f;
        return values;
    }

    // ---------------------------------------------------------------- trims

    [Fact]
    public void LeadingCalibrationRestFrame_IsTrimmed()
    {
        var angles = new float[10];
        HeldPose(9).CopyTo(angles, 1); // frame 0 stays 0 = the rest pose; 1..9 held at ~70°
        var scene = BvhImporter.Import(BvhWithHeadAngles(angles));

        var clip = Assert.Single(scene.Clips);
        Assert.Equal(9, clip.FrameCount); // 10 native − 1 calibration frame
        Assert.InRange(HeadAngleDeg(scene, 0), 65f, 75f); // clip now opens on the real pose
    }

    [Fact]
    public void TrailingCalibrationRestFrame_IsTrimmed()
    {
        var angles = new float[10];
        HeldPose(9).CopyTo(angles, 0); // frames 0..8 held at ~70°; frame 9 back to rest
        var scene = BvhImporter.Import(BvhWithHeadAngles(angles));

        var clip = Assert.Single(scene.Clips);
        Assert.Equal(9, clip.FrameCount);
        Assert.InRange(HeadAngleDeg(scene, clip.FrameCount - 1), 65f, 85f); // ends on the pose
    }

    [Fact]
    public void CalibrationFramesAtBothEnds_AreBothTrimmed()
    {
        var angles = new float[11];
        HeldPose(9).CopyTo(angles, 1); // frame 0 AND frame 10 at rest, 1..9 held
        var scene = BvhImporter.Import(BvhWithHeadAngles(angles));

        var clip = Assert.Single(scene.Clips);
        Assert.Equal(9, clip.FrameCount);
        Assert.InRange(HeadAngleDeg(scene, 0), 65f, 75f);
        Assert.InRange(HeadAngleDeg(scene, clip.FrameCount - 1), 65f, 85f);
    }

    /// <summary>A 2-frame rest→motion blend ramp (measured on a makehuman-retarget export:
    /// rest 10°, ramp 24°, then motion ≥ 49°) is a calibration segment too.</summary>
    [Fact]
    public void CalibrationBlendRamp_IsTrimmed()
    {
        var angles = new float[12];
        angles[1] = 12f; // frame 0 at rest, frame 1 mid-blend — both rest-like
        HeldPose(10).CopyTo(angles, 2); // frames 2..11 held at ~70°
        var scene = BvhImporter.Import(BvhWithHeadAngles(angles));

        var clip = Assert.Single(scene.Clips);
        Assert.Equal(10, clip.FrameCount); // both ramp frames trimmed
        Assert.InRange(HeadAngleDeg(scene, 0), 65f, 75f);
    }

    // ---------------------------------------------------------------- must-NOT-trim guards

    /// <summary>An idle that legitimately STARTS at the rest pose ramps continuously into
    /// motion — rest-similarity alone must never cost it its first frame.</summary>
    [Fact]
    public void IdleStartingAtRest_KeepsItsFirstFrame()
    {
        var angles = new float[20];
        for (var f = 0; f < angles.Length; f++)
            angles[f] = f * 3f; // 3°/frame ramp from exact rest
        var scene = BvhImporter.Import(BvhWithHeadAngles(angles));

        var clip = Assert.Single(scene.Clips);
        Assert.Equal(20, clip.FrameCount);
        Assert.InRange(HeadAngleDeg(scene, 0), 0f, 0.01f);
    }

    /// <summary>A clip resting throughout (e.g. a bind-pose export) has nothing to trim:
    /// the neighbor frame is rest-like too.</summary>
    [Fact]
    public void AllRestClip_IsNotTrimmed()
    {
        var scene = BvhImporter.Import(BvhWithHeadAngles(new float[8]));

        var clip = Assert.Single(scene.Clips);
        Assert.Equal(8, clip.FrameCount);
    }

    /// <summary>A hard cut between two NON-rest poses (action edit) is not a calibration
    /// frame — the edge must be rest-like for the trim to engage.</summary>
    [Fact]
    public void HardCutBetweenRealPoses_IsNotTrimmed()
    {
        var angles = HeldPose(10, startDeg: 120f);
        for (var f = 1; f < angles.Length; f++)
            angles[f] = 60f + f; // frame 0 at 120°, cut to ~60° held
        var scene = BvhImporter.Import(BvhWithHeadAngles(angles));

        var clip = Assert.Single(scene.Clips);
        Assert.Equal(10, clip.FrameCount);
    }

    /// <summary>Two-frame clips are never trimmed (no interior to judge typical motion by).</summary>
    [Fact]
    public void TwoFrameClip_IsNotTrimmed()
    {
        var scene = BvhImporter.Import(BvhWithHeadAngles(0f, 70f));
        Assert.Equal(2, Assert.Single(scene.Clips).FrameCount);
    }

    // ---------------------------------------------------------------- real repro file

    /// <summary>47_01.bvh (CMU mocap, the user repro): native frame 0 is a calibration
    /// T-pose 21° from the bind with an 89.6° cut into frame 1 (measured); the imported
    /// clip must open on the real motion instead. Silently green when the local corpus
    /// file is absent.</summary>
    [Fact]
    public void Cmu4701_CalibrationTPose_DoesNotReachTheClip()
    {
        var path = TestUtil.RepoFile("dev", "corpus", "todo", "47_01.bvh");
        if (!File.Exists(path))
            return;

        var scene = BvhImporter.Import(File.ReadAllBytes(path));
        var clip = Assert.Single(scene.Clips);

        // 1320 native frames at 120 fps → 1319 after the trim → 330 on the 30 fps grid.
        Assert.Equal(330, clip.FrameCount);

        // Frame 0 is now a real pose, far from the identity-rotation bind …
        float MaxJointAngleDeg(int frame)
        {
            var max = 0f;
            foreach (var x in clip.Frames[frame])
                max = MathF.Max(max, MathQ.AngleBetween(Quaternion.Identity, x.Rot) * Rad2Deg);
            return max;
        }
        Assert.True(MaxJointAngleDeg(0) > 60f,
            $"clip still opens rest-like ({MaxJointAngleDeg(0):F1}° from bind) — calibration frame not trimmed");

        // … and continuous into its neighbor (the old frame 0 cut 89.6° deep).
        var maxDelta = 0f;
        for (var i = 0; i < scene.Skeleton.Count; i++)
        {
            maxDelta = MathF.Max(maxDelta, MathQ.AngleBetween(
                clip.Frames[0][i].Rot, clip.Frames[1][i].Rot) * Rad2Deg);
        }
        Assert.True(maxDelta < 30f, $"frame 0→1 still discontinuous ({maxDelta:F1}°)");
    }
}