From 9a7e0a3fd0acd04bf42b800abf7441cd020eb1b1 Mon Sep 17 00:00:00 2001 From: KINDNICK Date: Sun, 9 Aug 2026 02:23:06 +0900 Subject: [PATCH] fix(camera-ai): eliminate generated camera trajectory jolts --- CameraAI~/CHANGELOG.md | 32 + .../Editor/AICameraTimelinePreviewImporter.cs | 991 +++++++++++- .../Editor/AICameraCurveSimplifierTests.cs | 441 ++++++ .../Tools~/CWCameraWorker/CWCameraWorker.exe | 4 +- .../CWCameraWorker/_internal/base_library.zip | Bin 1333490 -> 1333490 bytes .../cw_camera_worker_build_identity.json | 18 +- .../CameraDirector/cw_camera_runtime.py | 2 +- .../CameraDirector/generate_hybrid.py | 1363 +++++++++++++++-- .../CameraDirector/trajectory_quality.py | 1088 +++++++++++++ CameraAI~/package.json | 2 +- 10 files changed, 3790 insertions(+), 151 deletions(-) create mode 100644 CameraAI~/Tools~/CWCameraWorker/_internal/cwai_sources/repository/MachineLearning/CameraDirector/trajectory_quality.py diff --git a/CameraAI~/CHANGELOG.md b/CameraAI~/CHANGELOG.md index 643a024..7caad90 100644 --- a/CameraAI~/CHANGELOG.md +++ b/CameraAI~/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog +## 0.4.8 - 2026-08-09 + +- Replaced the unstable frame-local aim controller with a causal, feed-forward + angular-velocity path. Static targets now settle without a left/right limit + cycle, legal 45/80-degree-per-second pans keep their entry velocity, and + abrupt changes are bounded to 90 deg/s, 360 deg/s^2, and 7,200 deg/s^3. +- Added C2 position resampling, speed-adaptive C2 distance transitions, and a + final distance/step audit after every trajectory transform. The final + candidate gate now fails closed on missing data and checks translation jerk, + screen-space p99 and one-frame impulses, and the actual post-clamp step. +- Calibrated the new dynamics policy against 1,381 runtime-eligible authored + shots. The 600 m/s^3 translation maximum applies only to final generated + candidates; expressive authored wide/dolly templates remain available for + retargeting and soft ranking. +- Replaced Unity's piecewise-linear reduced position curves with velocity-aware + C1 Hermite curves. Every reduced shot is audited at source times and + midpoints for position error, local speed/acceleration/jerk regressions, and + meaningful-motion retention; unsafe reductions automatically fall back to + exact position keys for that shot only. +- Rebuilt the self-contained Windows worker as 0.1.4 with the trajectory module + and build-identity snapshot included. The frozen worker doctor and 196-test + build suite pass without requiring Python on the artist machine. +- On the 12,269-frame Arisa regression input, all 56 shots passed visibility, + facing, aim, distance, step, screen-dynamics, and cut-transition checks. + Worst translation jerk improved from 641.61 to 247.81 m/s^3 and worst + angular jerk from 30,294.84 to 7,146.35 deg/s^3; uncached generation improved + from about 124 seconds to 107 seconds. +- Unity compiled with zero Camera AI errors and the package EditMode suite + passed 121/121. The Balanced importer retained simplified C1 curves for 41 + shots and safely selected exact position keys for the 15 shots whose reduced + curves exceeded the local motion or 5 mm error contract. + ## 0.4.7 - 2026-08-09 - Fixed the incomplete 6D-to-quaternion conversion that replaced every diff --git a/CameraAI~/Editor/AICameraTimelinePreviewImporter.cs b/CameraAI~/Editor/AICameraTimelinePreviewImporter.cs index 2d0279d..6a13fe5 100644 --- a/CameraAI~/Editor/AICameraTimelinePreviewImporter.cs +++ b/CameraAI~/Editor/AICameraTimelinePreviewImporter.cs @@ -50,6 +50,23 @@ namespace Streamingle.Editor // almost complete reversal. Linear curve simplification may otherwise // collapse a mild 40--55 or clear 60/90-degree slowdown into a kink. private const float DirectionChangeMaximumCosine = 0.76604444f; + private const double PositionCurveAuditTimeToleranceSeconds = 0.000000001; + private const float PositionCurveAuditErrorEpsilonMeters = 0.0001f; + private const int PositionCurveAuditLocalWindowRadiusSamples = 2; + private const float PositionCurveAuditSpeedRatio = 1.5f; + private const float PositionCurveAuditSpeedDeltaMetersPerSecond = 0.25f; + private const float PositionCurveAuditSpeedFloorMetersPerSecond = 0.5f; + private const float PositionCurveAuditAccelerationRatio = 2f; + private const float PositionCurveAuditAccelerationDeltaMetersPerSecondSquared = + 5f; + private const float PositionCurveAuditAccelerationFloorMetersPerSecondSquared = + 8f; + private const float PositionCurveAuditJerkRatio = 3f; + private const float PositionCurveAuditJerkDeltaMetersPerSecondCubed = 150f; + private const float PositionCurveAuditJerkFloorMetersPerSecondCubed = 250f; + private const float PositionCurveAuditMotionNoiseDeadbandMeters = 0.0005f; + private const float PositionCurveAuditMotionRetentionMinimumMeters = 0.002f; + private const float PositionCurveAuditMotionRetentionRatio = 0.65f; public const CurveSimplificationPreset DefaultCurveSimplificationPreset = CurveSimplificationPreset.Balanced; @@ -422,6 +439,7 @@ namespace Streamingle.Editor var animationGroup = copiedTimeline.CreateTrack( null, GeneratedAnimationTrackName); + var positionExactFallbackCount = 0; foreach (var shotCamera in shotCameras) { var generatedClip = BuildShotAnimationClip( @@ -430,7 +448,18 @@ namespace Streamingle.Editor frames, times, shotCamera, - curvePreset); + curvePreset, + out var positionUsedExactFallback, + out var positionFallbackReason); + if (positionUsedExactFallback) + { + positionExactFallbackCount++; + Debug.LogWarning( + $"AI camera shot {shotCamera.Definition.index:D3} " + + $"({shotCamera.Definition.cameraName}) used Exact position " + + $"curves after the reduced-curve audit: " + + $"{positionFallbackReason}"); + } var animationClipPath = AssetDatabase.GenerateUniqueAssetPath( $"{shotAnimationFolder}/" + $"{shotCamera.Definition.index:D3}_" + @@ -524,6 +553,7 @@ namespace Streamingle.Editor $"Frames: {metadata.frameCount:N0} at {metadata.sampleRate} FPS", $"Shots: {shotDefinitions.Count:N0}", $"Curve Simplification: {curvePreset}", + $"Position Exact Fallbacks: {positionExactFallbackCount:N0}", $"Preview Director: {GetHierarchyPath(previewDirector.transform)}", $"Preview Camera Root: {GetHierarchyPath(previewCameraObject.transform)}", $"Timeline Asset: {copiedTimelinePath}", @@ -779,7 +809,15 @@ namespace Streamingle.Editor RelativePath = relativePath, Camera = camera }, - curvePreset); + curvePreset, + out var positionUsedExactFallback, + out var positionFallbackReason); + if (positionUsedExactFallback) + { + Debug.LogWarning( + $"AI camera shot {shotIndex:D3} used Exact position curves " + + $"after the reduced-curve audit: {positionFallbackReason}"); + } var previousDirectorTime = previewDirector.time; var undoName = $"Regenerate AI Camera Shot {shotIndex:D3}"; @@ -875,6 +913,9 @@ namespace Streamingle.Editor $"Frames: {definitionForReplacement.startFrame:N0}-" + $"{definitionForReplacement.endFrameExclusive - 1:N0}", $"Curve Simplification: {curvePreset}", + positionUsedExactFallback + ? $"Position Curves: Exact fallback ({positionFallbackReason})" + : "Position Curves: requested preset passed motion audit", $"Animation Asset: {AssetDatabase.GetAssetPath(existingClip)}", "All other generated camera clips were preserved.", "The scene remains unsaved."); @@ -2254,7 +2295,9 @@ namespace Streamingle.Editor IReadOnlyList frames, IReadOnlyList times, PreviewShotCamera shotCamera, - CurveSimplificationPreset curvePreset) + CurveSimplificationPreset curvePreset, + out bool positionUsedExactFallback, + out string positionFallbackReason) { var clip = new AnimationClip { @@ -2292,33 +2335,27 @@ namespace Streamingle.Editor fieldOfView, dutch, curvePreset); - SetLinearCurve( + var positionCurves = BuildPositionCurves(simplified, curvePreset); + positionUsedExactFallback = positionCurves.UsedExactFallback; + positionFallbackReason = positionCurves.FallbackReason; + SetCurve( clip, relativePath, typeof(Transform), "m_LocalPosition.x", - BuildKeys( - simplified.Times, - simplified.PositionIndices, - index => simplified.Positions[index].x)); - SetLinearCurve( + positionCurves.XCurve); + SetCurve( clip, relativePath, typeof(Transform), "m_LocalPosition.y", - BuildKeys( - simplified.Times, - simplified.PositionIndices, - index => simplified.Positions[index].y)); - SetLinearCurve( + positionCurves.YCurve); + SetCurve( clip, relativePath, typeof(Transform), "m_LocalPosition.z", - BuildKeys( - simplified.Times, - simplified.PositionIndices, - index => simplified.Positions[index].z)); + positionCurves.ZCurve); SetLinearCurve( clip, relativePath, @@ -2434,6 +2471,784 @@ namespace Streamingle.Editor settings.DutchErrorDegrees)); } + internal static PositionCurveBuildResult BuildPositionCurves( + SimplifiedCameraCurves simplified, + CurveSimplificationPreset preset) + { + if (simplified == null) + { + throw new ArgumentNullException(nameof(simplified)); + } + + PositionCurveBuildResult result; + if (preset == CurveSimplificationPreset.Exact || + simplified.Times.Length < 2) + { + result = BuildLinearPositionCurves( + simplified.Times, + simplified.Positions, + CreateAllSampleIndices(simplified.Times.Length), + false, + string.Empty); + } + else + { + var candidate = BuildVelocityAwarePositionCurves( + simplified.Times, + simplified.Positions, + simplified.PositionIndices); + var audit = AuditPositionCurves( + simplified.Times, + simplified.Positions, + candidate.XCurve, + candidate.YCurve, + candidate.ZCurve, + GetCurveSimplificationSettings(preset).PositionErrorMeters); + result = audit.Passed + ? candidate + : BuildLinearPositionCurves( + simplified.Times, + simplified.Positions, + CreateAllSampleIndices(simplified.Times.Length), + true, + audit.FailureReason); + } + + simplified.SetPositionCurveDiagnostics( + result.UsedExactFallback, + result.FallbackReason); + return result; + } + + internal static PositionCurveAuditResult AuditPositionCurves( + IReadOnlyList times, + IReadOnlyList positions, + AnimationCurve xCurve, + AnimationCurve yCurve, + AnimationCurve zCurve, + float maximumPositionErrorMeters) + { + if (times == null || positions == null) + { + throw new ArgumentNullException(nameof(times)); + } + + if (xCurve == null || yCurve == null || zCurve == null) + { + throw new ArgumentNullException(nameof(xCurve)); + } + + if (times.Count == 0 || positions.Count != times.Count) + { + throw new ArgumentException( + "Position audit samples must have the same non-zero length."); + } + + var allowedPositionError = + maximumPositionErrorMeters + PositionCurveAuditErrorEpsilonMeters; + var evaluatedAtSourceTimes = EvaluatePositionCurves( + times, + xCurve, + yCurve, + zCurve); + for (var index = 0; index < times.Count; index++) + { + var error = Vector3.Distance( + positions[index], + evaluatedAtSourceTimes[index]); + if (!IsFinite(error) || error > allowedPositionError) + { + return PositionCurveAuditResult.Fail( + $"source-sample position error {error:F6} m exceeded " + + $"{allowedPositionError:F6} m at frame {index}"); + } + } + + var allIndices = CreateAllSampleIndices(times.Count); + var reference = BuildVelocityAwarePositionCurves( + times, + positions, + allIndices); + var auditTimes = BuildPositionCurveAuditTimes(times); + var evaluatedDense = EvaluatePositionCurves( + auditTimes, + xCurve, + yCurve, + zCurve); + var referenceDense = EvaluatePositionCurves( + auditTimes, + reference.XCurve, + reference.YCurve, + reference.ZCurve); + for (var index = 0; index < auditTimes.Count; index++) + { + var error = Vector3.Distance( + referenceDense[index], + evaluatedDense[index]); + if (!IsFinite(error) || error > allowedPositionError) + { + return PositionCurveAuditResult.Fail( + $"source + midpoint position error {error:F6} m exceeded " + + $"{allowedPositionError:F6} m at {auditTimes[index]:F4} s"); + } + } + + var sourceMotion = MeasurePositionMotion(times, positions); + var evaluatedSourceMotion = MeasurePositionMotion( + times, + evaluatedAtSourceTimes); + if (TryDescribeAlignedMotionRegression( + "source-grid", + sourceMotion, + evaluatedSourceMotion, + out var sourceLocalFailure)) + { + return PositionCurveAuditResult.Fail(sourceLocalFailure); + } + + if (TryDescribeMotionRegression( + "source-grid", + sourceMotion.Metrics, + evaluatedSourceMotion.Metrics, + out var sourceFailure)) + { + return PositionCurveAuditResult.Fail(sourceFailure); + } + + if (TryDescribeMotionRetentionRegression( + "source-grid", + positions, + evaluatedAtSourceTimes, + out var sourceRetentionFailure)) + { + return PositionCurveAuditResult.Fail(sourceRetentionFailure); + } + + var referenceDenseMotion = MeasurePositionMotion( + auditTimes, + referenceDense); + var evaluatedDenseMotion = MeasurePositionMotion( + auditTimes, + evaluatedDense); + if (TryDescribeAlignedMotionRegression( + "source + midpoint", + referenceDenseMotion, + evaluatedDenseMotion, + out var denseLocalFailure)) + { + return PositionCurveAuditResult.Fail(denseLocalFailure); + } + + if (TryDescribeMotionRegression( + "source + midpoint", + referenceDenseMotion.Metrics, + evaluatedDenseMotion.Metrics, + out var denseFailure)) + { + return PositionCurveAuditResult.Fail(denseFailure); + } + + if (TryDescribeMotionRetentionRegression( + "source + midpoint", + referenceDense, + evaluatedDense, + out var denseRetentionFailure)) + { + return PositionCurveAuditResult.Fail(denseRetentionFailure); + } + + return PositionCurveAuditResult.Pass(); + } + + private static PositionCurveBuildResult BuildVelocityAwarePositionCurves( + IReadOnlyList times, + IReadOnlyList positions, + IReadOnlyList indices) + { + return new PositionCurveBuildResult( + BuildVelocityAwareHermiteCurve( + times, + positions.Select(value => value.x).ToArray(), + indices), + BuildVelocityAwareHermiteCurve( + times, + positions.Select(value => value.y).ToArray(), + indices), + BuildVelocityAwareHermiteCurve( + times, + positions.Select(value => value.z).ToArray(), + indices), + false, + string.Empty); + } + + private static PositionCurveBuildResult BuildLinearPositionCurves( + IReadOnlyList times, + IReadOnlyList positions, + IReadOnlyList indices, + bool usedExactFallback, + string fallbackReason) + { + return new PositionCurveBuildResult( + BuildLinearCurve(BuildKeys( + times, + indices, + index => positions[index].x)), + BuildLinearCurve(BuildKeys( + times, + indices, + index => positions[index].y)), + BuildLinearCurve(BuildKeys( + times, + indices, + index => positions[index].z)), + usedExactFallback, + fallbackReason); + } + + private static AnimationCurve BuildVelocityAwareHermiteCurve( + IReadOnlyList times, + IReadOnlyList values, + IReadOnlyList indices) + { + var sourceTangents = EstimateSourceTangents(times, values); + var tangents = new float[indices.Count]; + for (var keyIndex = 0; keyIndex < indices.Count; keyIndex++) + { + tangents[keyIndex] = sourceTangents[indices[keyIndex]]; + } + + ClampMonotoneHermiteTangents(times, values, indices, tangents); + var keys = new Keyframe[indices.Count]; + for (var keyIndex = 0; keyIndex < indices.Count; keyIndex++) + { + var sampleIndex = indices[keyIndex]; + keys[keyIndex] = new Keyframe( + (float)times[sampleIndex], + values[sampleIndex], + tangents[keyIndex], + tangents[keyIndex]); + } + + return new AnimationCurve(keys); + } + + private static float[] EstimateSourceTangents( + IReadOnlyList times, + IReadOnlyList values) + { + var tangents = new float[values.Count]; + if (values.Count < 2) + { + return tangents; + } + + tangents[0] = CalculateSecant( + times[0], + values[0], + times[1], + values[1]); + tangents[values.Count - 1] = CalculateSecant( + times[values.Count - 2], + values[values.Count - 2], + times[values.Count - 1], + values[values.Count - 1]); + for (var index = 1; index < values.Count - 1; index++) + { + tangents[index] = CalculateSecant( + times[index - 1], + values[index - 1], + times[index + 1], + values[index + 1]); + } + + return tangents; + } + + private static void ClampMonotoneHermiteTangents( + IReadOnlyList times, + IReadOnlyList values, + IReadOnlyList indices, + float[] tangents) + { + const float flatEpsilon = 0.0000001f; + for (var segment = 1; segment < indices.Count; segment++) + { + var leftKey = segment - 1; + var rightKey = segment; + var leftSample = indices[leftKey]; + var rightSample = indices[rightKey]; + var secant = CalculateSecant( + times[leftSample], + values[leftSample], + times[rightSample], + values[rightSample]); + if (Mathf.Abs(secant) <= flatEpsilon) + { + tangents[leftKey] = 0f; + tangents[rightKey] = 0f; + continue; + } + + if (tangents[leftKey] * secant <= 0f) + { + tangents[leftKey] = 0f; + } + + if (tangents[rightKey] * secant <= 0f) + { + tangents[rightKey] = 0f; + } + + var alpha = tangents[leftKey] / secant; + var beta = tangents[rightKey] / secant; + var squaredMagnitude = alpha * alpha + beta * beta; + if (squaredMagnitude <= 9f) + { + continue; + } + + var scale = 3f / Mathf.Sqrt(squaredMagnitude); + tangents[leftKey] = scale * alpha * secant; + tangents[rightKey] = scale * beta * secant; + } + } + + private static float CalculateSecant( + double leftTime, + float leftValue, + double rightTime, + float rightValue) + { + return (rightValue - leftValue) / (float)(rightTime - leftTime); + } + + private static int[] CreateAllSampleIndices(int count) + { + return Enumerable.Range(0, count).ToArray(); + } + + internal static List BuildPositionCurveAuditTimes( + IReadOnlyList sourceTimes) + { + if (sourceTimes == null) + { + throw new ArgumentNullException(nameof(sourceTimes)); + } + + if (sourceTimes.Count == 0) + { + throw new ArgumentException( + "Position audit requires at least one source time.", + nameof(sourceTimes)); + } + + var result = new List(sourceTimes.Count * 2 - 1); + for (var index = 0; index < sourceTimes.Count; index++) + { + var sourceTime = sourceTimes[index]; + if (double.IsNaN(sourceTime) || double.IsInfinity(sourceTime)) + { + throw new ArgumentException( + "Position audit source times must be finite.", + nameof(sourceTimes)); + } + + AppendPositionCurveAuditTime(result, sourceTime); + if (index + 1 >= sourceTimes.Count) + { + continue; + } + + var nextTime = sourceTimes[index + 1]; + var duration = nextTime - sourceTime; + if (duration < -PositionCurveAuditTimeToleranceSeconds) + { + throw new ArgumentException( + "Position audit source times must be ascending.", + nameof(sourceTimes)); + } + + if (duration > PositionCurveAuditTimeToleranceSeconds && + (float)sourceTime != (float)nextTime) + { + AppendPositionCurveAuditTime( + result, + sourceTime + duration * 0.5); + } + } + + return result; + } + + private static void AppendPositionCurveAuditTime( + ICollection result, + double time) + { + if (result.Count == 0) + { + result.Add(time); + return; + } + + var previous = result.Last(); + if (time - previous <= PositionCurveAuditTimeToleranceSeconds || + (float)time == (float)previous) + { + return; + } + + result.Add(time); + } + + private static Vector3[] EvaluatePositionCurves( + IReadOnlyList times, + AnimationCurve xCurve, + AnimationCurve yCurve, + AnimationCurve zCurve) + { + var result = new Vector3[times.Count]; + for (var index = 0; index < times.Count; index++) + { + var time = (float)times[index]; + result[index] = new Vector3( + xCurve.Evaluate(time), + yCurve.Evaluate(time), + zCurve.Evaluate(time)); + } + + return result; + } + + private static PositionMotionSeries MeasurePositionMotion( + IReadOnlyList times, + IReadOnlyList positions) + { + if (times.Count < 2) + { + return PositionMotionSeries.Empty; + } + + var velocities = new Vector3[times.Count - 1]; + var velocityTimes = new double[velocities.Length]; + var speeds = new float[velocities.Length]; + for (var index = 0; index < velocities.Length; index++) + { + var duration = times[index + 1] - times[index]; + velocities[index] = + (positions[index + 1] - positions[index]) / (float)duration; + velocityTimes[index] = (times[index + 1] + times[index]) * 0.5; + speeds[index] = velocities[index].magnitude; + } + + if (velocities.Length < 2) + { + return new PositionMotionSeries( + new PositionMetricSeries(velocityTimes, speeds), + PositionMetricSeries.Empty, + PositionMetricSeries.Empty); + } + + var accelerations = new Vector3[velocities.Length - 1]; + var accelerationTimes = new double[accelerations.Length]; + var accelerationMagnitudes = new float[accelerations.Length]; + for (var index = 0; index < accelerations.Length; index++) + { + var duration = velocityTimes[index + 1] - velocityTimes[index]; + accelerations[index] = + (velocities[index + 1] - velocities[index]) / (float)duration; + accelerationTimes[index] = + (velocityTimes[index + 1] + velocityTimes[index]) * 0.5; + accelerationMagnitudes[index] = accelerations[index].magnitude; + } + + var jerkTimes = new double[Math.Max(0, accelerations.Length - 1)]; + var jerkMagnitudes = new float[jerkTimes.Length]; + for (var index = 0; index < accelerations.Length - 1; index++) + { + var duration = + accelerationTimes[index + 1] - accelerationTimes[index]; + var jerk = + (accelerations[index + 1] - accelerations[index]) / + (float)duration; + jerkTimes[index] = + (accelerationTimes[index + 1] + accelerationTimes[index]) * + 0.5; + jerkMagnitudes[index] = jerk.magnitude; + } + + return new PositionMotionSeries( + new PositionMetricSeries(velocityTimes, speeds), + new PositionMetricSeries( + accelerationTimes, + accelerationMagnitudes), + new PositionMetricSeries(jerkTimes, jerkMagnitudes)); + } + + internal static bool TryDescribeAlignedMotionRegression( + IReadOnlyList times, + IReadOnlyList referencePositions, + IReadOnlyList candidatePositions, + out string failureReason) + { + if (times == null || referencePositions == null || + candidatePositions == null) + { + throw new ArgumentNullException(nameof(times)); + } + + if (times.Count != referencePositions.Count || + times.Count != candidatePositions.Count) + { + throw new ArgumentException( + "Aligned motion samples must have matching lengths."); + } + + return TryDescribeAlignedMotionRegression( + "aligned-test-grid", + MeasurePositionMotion(times, referencePositions), + MeasurePositionMotion(times, candidatePositions), + out failureReason); + } + + private static bool TryDescribeAlignedMotionRegression( + string sampleLabel, + PositionMotionSeries reference, + PositionMotionSeries candidate, + out string failureReason) + { + if (TryDescribeAlignedMetricRegression( + sampleLabel, + "speed", + "m/s", + reference.Speed, + candidate.Speed, + PositionCurveAuditSpeedRatio, + PositionCurveAuditSpeedDeltaMetersPerSecond, + PositionCurveAuditSpeedFloorMetersPerSecond, + out failureReason)) + { + return true; + } + + if (TryDescribeAlignedMetricRegression( + sampleLabel, + "acceleration", + "m/s^2", + reference.Acceleration, + candidate.Acceleration, + PositionCurveAuditAccelerationRatio, + PositionCurveAuditAccelerationDeltaMetersPerSecondSquared, + PositionCurveAuditAccelerationFloorMetersPerSecondSquared, + out failureReason)) + { + return true; + } + + return TryDescribeAlignedMetricRegression( + sampleLabel, + "jerk", + "m/s^3", + reference.Jerk, + candidate.Jerk, + PositionCurveAuditJerkRatio, + PositionCurveAuditJerkDeltaMetersPerSecondCubed, + PositionCurveAuditJerkFloorMetersPerSecondCubed, + out failureReason); + } + + private static bool TryDescribeAlignedMetricRegression( + string sampleLabel, + string metricLabel, + string unit, + PositionMetricSeries reference, + PositionMetricSeries candidate, + float ratio, + float absoluteDelta, + float absoluteFloor, + out string failureReason) + { + if (reference.Values.Length != candidate.Values.Length) + { + failureReason = + $"{sampleLabel} {metricLabel} audit grids were not aligned"; + return true; + } + + var radius = PositionCurveAuditLocalWindowRadiusSamples; + for (var center = 0; center < candidate.Values.Length; center++) + { + var start = Math.Max(0, center - radius); + var end = Math.Min(candidate.Values.Length - 1, center + radius); + var referenceWindowMaximum = 0f; + var candidateWindowMaximum = 0f; + for (var index = start; index <= end; index++) + { + referenceWindowMaximum = Mathf.Max( + referenceWindowMaximum, + reference.Values[index]); + candidateWindowMaximum = Mathf.Max( + candidateWindowMaximum, + candidate.Values[index]); + } + + var allowed = Mathf.Max( + absoluteFloor, + Mathf.Max( + referenceWindowMaximum * ratio, + referenceWindowMaximum + absoluteDelta)); + if (IsFinite(candidateWindowMaximum) && + candidateWindowMaximum <= allowed) + { + continue; + } + + var sampleTime = candidate.Times.Length > center + ? candidate.Times[center] + : 0.0; + failureReason = + $"{sampleLabel} windowed {metricLabel} regressed near " + + $"{sampleTime:F4} s from {referenceWindowMaximum:F3} to " + + $"{candidateWindowMaximum:F3} {unit}; allowed " + + $"{allowed:F3} {unit}"; + return true; + } + + failureReason = string.Empty; + return false; + } + + private static bool TryDescribeMotionRetentionRegression( + string sampleLabel, + IReadOnlyList reference, + IReadOnlyList candidate, + out string failureReason) + { + var referencePathLength = MeasureMeaningfulPathLength(reference); + if (referencePathLength < PositionCurveAuditMotionRetentionMinimumMeters) + { + failureReason = string.Empty; + return false; + } + + var candidatePathLength = MeasureMeaningfulPathLength(candidate); + var requiredPathLength = Math.Max( + 0f, + referencePathLength * PositionCurveAuditMotionRetentionRatio - + PositionCurveAuditMotionNoiseDeadbandMeters); + if (IsFinite(candidatePathLength) && + candidatePathLength >= requiredPathLength) + { + failureReason = string.Empty; + return false; + } + + failureReason = + $"{sampleLabel} meaningful motion retention fell from " + + $"{referencePathLength:F4} m to {candidatePathLength:F4} m; " + + $"required {requiredPathLength:F4} m after the " + + $"{PositionCurveAuditMotionNoiseDeadbandMeters:F4} m noise " + + "deadband"; + return true; + } + + private static float MeasureMeaningfulPathLength( + IReadOnlyList positions) + { + if (positions.Count < 2) + { + return 0f; + } + + var pathLength = 0f; + var anchor = positions[0]; + for (var index = 1; index < positions.Count; index++) + { + var distance = Vector3.Distance(anchor, positions[index]); + if (distance < PositionCurveAuditMotionNoiseDeadbandMeters) + { + continue; + } + + pathLength += distance; + anchor = positions[index]; + } + + return pathLength; + } + + private static bool TryDescribeMotionRegression( + string sampleLabel, + PositionMotionMetrics reference, + PositionMotionMetrics candidate, + out string failureReason) + { + if (TryDescribeMetricRegression( + sampleLabel, + "speed", + "m/s", + reference.MaximumSpeed, + candidate.MaximumSpeed, + PositionCurveAuditSpeedRatio, + PositionCurveAuditSpeedDeltaMetersPerSecond, + PositionCurveAuditSpeedFloorMetersPerSecond, + out failureReason)) + { + return true; + } + + if (TryDescribeMetricRegression( + sampleLabel, + "acceleration", + "m/s^2", + reference.MaximumAcceleration, + candidate.MaximumAcceleration, + PositionCurveAuditAccelerationRatio, + PositionCurveAuditAccelerationDeltaMetersPerSecondSquared, + PositionCurveAuditAccelerationFloorMetersPerSecondSquared, + out failureReason)) + { + return true; + } + + return TryDescribeMetricRegression( + sampleLabel, + "jerk", + "m/s^3", + reference.MaximumJerk, + candidate.MaximumJerk, + PositionCurveAuditJerkRatio, + PositionCurveAuditJerkDeltaMetersPerSecondCubed, + PositionCurveAuditJerkFloorMetersPerSecondCubed, + out failureReason); + } + + private static bool TryDescribeMetricRegression( + string sampleLabel, + string metricLabel, + string unit, + float reference, + float candidate, + float ratio, + float absoluteDelta, + float absoluteFloor, + out string failureReason) + { + var allowed = Mathf.Max( + absoluteFloor, + Mathf.Max(reference * ratio, reference + absoluteDelta)); + if (IsFinite(candidate) && candidate <= allowed) + { + failureReason = string.Empty; + return false; + } + + failureReason = + $"{sampleLabel} {metricLabel} regressed from {reference:F3} " + + $"to {candidate:F3} {unit}; allowed {allowed:F3} {unit}"; + return true; + } + private static Keyframe[] BuildKeys( IReadOnlyList times, IReadOnlyList indices, @@ -2769,6 +3584,16 @@ namespace Streamingle.Editor Type componentType, string propertyName, Keyframe[] keys) + { + SetCurve( + clip, + relativePath, + componentType, + propertyName, + BuildLinearCurve(keys)); + } + + private static AnimationCurve BuildLinearCurve(Keyframe[] keys) { var curve = new AnimationCurve(keys); for (var index = 0; index < curve.length; index++) @@ -2783,6 +3608,16 @@ namespace Streamingle.Editor AnimationUtility.TangentMode.Linear); } + return curve; + } + + private static void SetCurve( + AnimationClip clip, + string relativePath, + Type componentType, + string propertyName, + AnimationCurve curve) + { AnimationUtility.SetEditorCurve( clip, EditorCurveBinding.FloatCurve( @@ -4275,6 +5110,115 @@ namespace Streamingle.Editor public int End { get; } } + internal sealed class PositionCurveBuildResult + { + internal PositionCurveBuildResult( + AnimationCurve xCurve, + AnimationCurve yCurve, + AnimationCurve zCurve, + bool usedExactFallback, + string fallbackReason) + { + XCurve = xCurve ?? throw new ArgumentNullException(nameof(xCurve)); + YCurve = yCurve ?? throw new ArgumentNullException(nameof(yCurve)); + ZCurve = zCurve ?? throw new ArgumentNullException(nameof(zCurve)); + UsedExactFallback = usedExactFallback; + FallbackReason = fallbackReason ?? string.Empty; + } + + internal AnimationCurve XCurve { get; } + internal AnimationCurve YCurve { get; } + internal AnimationCurve ZCurve { get; } + internal bool UsedExactFallback { get; } + internal string FallbackReason { get; } + } + + internal readonly struct PositionCurveAuditResult + { + private PositionCurveAuditResult(bool passed, string failureReason) + { + Passed = passed; + FailureReason = failureReason ?? string.Empty; + } + + internal bool Passed { get; } + internal string FailureReason { get; } + + internal static PositionCurveAuditResult Pass() + { + return new PositionCurveAuditResult(true, string.Empty); + } + + internal static PositionCurveAuditResult Fail(string failureReason) + { + return new PositionCurveAuditResult(false, failureReason); + } + } + + private readonly struct PositionMetricSeries + { + internal static readonly PositionMetricSeries Empty = + new PositionMetricSeries( + Array.Empty(), + Array.Empty()); + + internal PositionMetricSeries(double[] times, float[] values) + { + Times = times ?? Array.Empty(); + Values = values ?? Array.Empty(); + Maximum = Values.Length == 0 ? 0f : Values.Max(); + } + + internal double[] Times { get; } + internal float[] Values { get; } + internal float Maximum { get; } + } + + private readonly struct PositionMotionSeries + { + internal static readonly PositionMotionSeries Empty = + new PositionMotionSeries( + PositionMetricSeries.Empty, + PositionMetricSeries.Empty, + PositionMetricSeries.Empty); + + internal PositionMotionSeries( + PositionMetricSeries speed, + PositionMetricSeries acceleration, + PositionMetricSeries jerk) + { + Speed = speed; + Acceleration = acceleration; + Jerk = jerk; + } + + internal PositionMetricSeries Speed { get; } + internal PositionMetricSeries Acceleration { get; } + internal PositionMetricSeries Jerk { get; } + internal PositionMotionMetrics Metrics => + new PositionMotionMetrics( + Speed.Maximum, + Acceleration.Maximum, + Jerk.Maximum); + } + + private readonly struct PositionMotionMetrics + { + internal PositionMotionMetrics( + float maximumSpeed, + float maximumAcceleration, + float maximumJerk) + { + MaximumSpeed = maximumSpeed; + MaximumAcceleration = maximumAcceleration; + MaximumJerk = maximumJerk; + } + + internal float MaximumSpeed { get; } + internal float MaximumAcceleration { get; } + internal float MaximumJerk { get; } + } + internal sealed class SimplifiedCameraCurves { public SimplifiedCameraCurves( @@ -4308,6 +5252,17 @@ namespace Streamingle.Editor public int[] RotationIndices { get; } public int[] FieldOfViewIndices { get; } public int[] DutchIndices { get; } + internal bool PositionUsedExactFallback { get; private set; } + internal string PositionFallbackReason { get; private set; } = + string.Empty; + + internal void SetPositionCurveDiagnostics( + bool usedExactFallback, + string fallbackReason) + { + PositionUsedExactFallback = usedExactFallback; + PositionFallbackReason = fallbackReason ?? string.Empty; + } } private sealed class PreviewShotCamera diff --git a/CameraAI~/Tests/Editor/AICameraCurveSimplifierTests.cs b/CameraAI~/Tests/Editor/AICameraCurveSimplifierTests.cs index a120da4..e04ac4e 100644 --- a/CameraAI~/Tests/Editor/AICameraCurveSimplifierTests.cs +++ b/CameraAI~/Tests/Editor/AICameraCurveSimplifierTests.cs @@ -155,6 +155,293 @@ namespace Streamingle.Editor Is.LessThan(exact.DutchIndices.Length)); } + [TestCase(AICameraTimelinePreviewImporter.CurveSimplificationPreset.Balanced)] + [TestCase(AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable)] + public void ReducedPositionCurvesUseAuditedC1HermiteWithoutOvershoot( + AICameraTimelinePreviewImporter.CurveSimplificationPreset preset) + { + var samples = CreateQuadraticMotionSamples(241); + var simplified = Simplify(samples, preset); + + var curves = AICameraTimelinePreviewImporter.BuildPositionCurves( + simplified, + preset); + + Assert.That(curves.UsedExactFallback, Is.False, curves.FallbackReason); + Assert.That(simplified.PositionUsedExactFallback, Is.False); + Assert.That( + curves.XCurve.length, + Is.LessThan(samples.Times.Length)); + AssertCurveIsC1(curves.XCurve); + AssertCurveIsC1(curves.YCurve); + AssertCurveIsC1(curves.ZCurve); + AssertCurveDoesNotOvershootSelectedSegments( + curves.XCurve, + simplified.Times, + simplified.PositionIndices); + AssertCurveDoesNotOvershootSelectedSegments( + curves.YCurve, + simplified.Times, + simplified.PositionIndices); + AssertCurveDoesNotOvershootSelectedSegments( + curves.ZCurve, + simplified.Times, + simplified.PositionIndices); + + var audit = AICameraTimelinePreviewImporter.AuditPositionCurves( + simplified.Times, + simplified.Positions, + curves.XCurve, + curves.YCurve, + curves.ZCurve, + AICameraTimelinePreviewImporter + .GetCurveSimplificationSettings(preset) + .PositionErrorMeters); + Assert.That(audit.Passed, Is.True, audit.FailureReason); + + var rawMaximumJerk = MeasureMaximumJerk( + simplified.Times, + simplified.Positions); + var evaluatedMaximumJerk = MeasureMaximumJerk( + simplified.Times, + EvaluatePositionCurves(simplified.Times, curves)); + var allowedMaximumJerk = Mathf.Max( + 250f, + Mathf.Max(rawMaximumJerk * 3f, rawMaximumJerk + 150f)); + Assert.That( + evaluatedMaximumJerk, + Is.LessThanOrEqualTo(allowedMaximumJerk)); + } + + [Test] + public void ReducedPositionCurveAuditFallsBackToExactPositionOnly() + { + const int count = 61; + var times = new double[count]; + var positions = new Vector3[count]; + for (var index = 0; index < count; index++) + { + var time = index / 60.0; + times[index] = time; + positions[index] = new Vector3( + Mathf.Sin((float)time * Mathf.PI * 2f), + 1.6f, + -4f); + } + + var simplified = CreatePositionOnlySimplifiedCurves( + times, + positions, + new[] { 0, count - 1 }); + var curves = AICameraTimelinePreviewImporter.BuildPositionCurves( + simplified, + AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable); + + Assert.That(curves.UsedExactFallback, Is.True); + Assert.That(curves.FallbackReason, Does.Contain("position error")); + Assert.That(simplified.PositionUsedExactFallback, Is.True); + Assert.That( + simplified.PositionFallbackReason, + Is.EqualTo(curves.FallbackReason)); + Assert.That(curves.XCurve.length, Is.EqualTo(count)); + Assert.That(curves.YCurve.length, Is.EqualTo(count)); + Assert.That(curves.ZCurve.length, Is.EqualTo(count)); + for (var index = 0; index < count; index++) + { + Assert.That( + curves.XCurve.Evaluate((float)times[index]), + Is.EqualTo(positions[index].x).Within(0.000001f)); + } + } + + [Test] + public void MotionAuditRejectsPathologicalMidpointVelocityRegression() + { + const int count = 61; + var times = new double[count]; + var positions = new Vector3[count]; + for (var index = 0; index < count; index++) + { + var time = index / 60.0; + times[index] = time; + positions[index] = new Vector3((float)time, 0f, 0f); + } + + var pathologicalX = new AnimationCurve( + new Keyframe(0f, 0f, 1f, 80f), + new Keyframe(1f, 1f, -80f, 1f)); + var constant = AnimationCurve.Linear(0f, 0f, 1f, 0f); + + var audit = AICameraTimelinePreviewImporter.AuditPositionCurves( + times, + positions, + pathologicalX, + constant, + constant, + 100f); + + Assert.That(audit.Passed, Is.False); + Assert.That(audit.FailureReason, Does.Contain("regressed")); + } + + [Test] + public void AuditGridUsesExactSourceTimesAndDropsFloatingTailDuplicates() + { + var frame = 1.0 / 60.0; + var times = new[] + { + 0.0, + frame, + frame * 2.0, + frame * 3.0, + frame * 3.0 + 0.00000000005 + }; + + var auditTimes = AICameraTimelinePreviewImporter + .BuildPositionCurveAuditTimes(times); + + Assert.That(auditTimes.Count, Is.EqualTo(7)); + Assert.That(auditTimes[0], Is.EqualTo(times[0])); + Assert.That(auditTimes[2], Is.EqualTo(times[1])); + Assert.That(auditTimes[4], Is.EqualTo(times[2])); + Assert.That(auditTimes[6], Is.EqualTo(times[3])); + for (var index = 1; index < auditTimes.Count; index++) + { + Assert.That( + auditTimes[index] - auditTimes[index - 1], + Is.GreaterThan(frame * 0.49), + $"Audit time {index} introduced a floating-point tail."); + } + } + + [Test] + public void AlignedMotionAuditFindsLocalJerkHiddenByEarlierGlobalMaximum() + { + const int count = 40; + var times = new double[count]; + var reference = new Vector3[count]; + var candidate = new Vector3[count]; + for (var index = 0; index < count; index++) + { + times[index] = index / 60.0; + } + + reference[3] = new Vector3(0.1f, 0f, 0f); + Array.Copy(reference, candidate, count); + candidate[30] = new Vector3(0.001f, 0f, 0f); + + Assert.That( + MeasureMaximumJerk(times, candidate), + Is.EqualTo(MeasureMaximumJerk(times, reference)) + .Within(0.01f), + "The earlier source jolt must mask the new jolt in a global-max audit."); + Assert.That( + AICameraTimelinePreviewImporter.TryDescribeAlignedMotionRegression( + times, + reference, + candidate, + out var failureReason), + Is.True); + Assert.That(failureReason, Does.Contain("windowed jerk")); + } + + [Test] + public void MotionRetentionAuditRejectsErasedSubtleLowFrequencyMove() + { + const int count = 241; + var times = new double[count]; + var positions = new Vector3[count]; + for (var index = 0; index < count; index++) + { + var time = index / 60.0; + times[index] = time; + positions[index] = new Vector3( + 0.006f * Mathf.Sin((float)(time * Math.PI * 0.5)), + 0f, + 0f); + } + + var erased = AnimationCurve.Linear( + 0f, + 0f, + (float)times[count - 1], + 0f); + var audit = AICameraTimelinePreviewImporter.AuditPositionCurves( + times, + positions, + erased, + erased, + erased, + 0.02f); + + Assert.That(audit.Passed, Is.False); + Assert.That( + audit.FailureReason, + Does.Contain("meaningful motion retention")); + } + + [Test] + public void MotionRetentionAuditIgnoresSubDeadbandPositionNoise() + { + const int count = 121; + var times = new double[count]; + var positions = new Vector3[count]; + for (var index = 0; index < count; index++) + { + times[index] = index / 60.0; + positions[index] = new Vector3( + index % 2 == 0 ? 0.0001f : -0.0001f, + 0f, + 0f); + } + + var denoised = AnimationCurve.Linear( + 0f, + 0f, + (float)times[count - 1], + 0f); + var audit = AICameraTimelinePreviewImporter.AuditPositionCurves( + times, + positions, + denoised, + denoised, + denoised, + 0.02f); + + Assert.That(audit.Passed, Is.True, audit.FailureReason); + } + + [Test] + public void ExactPositionCurvesRetainEveryLinearSampleWithoutFallback() + { + var samples = CreateSamples(91); + var simplified = Simplify( + samples, + AICameraTimelinePreviewImporter.CurveSimplificationPreset.Exact); + + var curves = AICameraTimelinePreviewImporter.BuildPositionCurves( + simplified, + AICameraTimelinePreviewImporter.CurveSimplificationPreset.Exact); + + Assert.That(curves.UsedExactFallback, Is.False); + Assert.That(curves.XCurve.length, Is.EqualTo(samples.Times.Length)); + Assert.That(curves.YCurve.length, Is.EqualTo(samples.Times.Length)); + Assert.That(curves.ZCurve.length, Is.EqualTo(samples.Times.Length)); + for (var index = 0; index < samples.Times.Length; index++) + { + var time = (float)samples.Times[index]; + Assert.That( + curves.XCurve.Evaluate(time), + Is.EqualTo(samples.Positions[index].x).Within(0.000001f)); + Assert.That( + curves.YCurve.Evaluate(time), + Is.EqualTo(samples.Positions[index].y).Within(0.000001f)); + Assert.That( + curves.ZCurve.Evaluate(time), + Is.EqualTo(samples.Positions[index].z).Within(0.000001f)); + } + } + [Test] public void EditablePresetPreservesStopThroughDirectionChangeKeys() { @@ -448,6 +735,160 @@ namespace Streamingle.Editor preset); } + private static CameraSamples CreateQuadraticMotionSamples(int count) + { + var times = new double[count]; + var positions = new Vector3[count]; + var rotations = new Quaternion[count]; + var fieldOfView = new float[count]; + var dutch = new float[count]; + for (var index = 0; index < count; index++) + { + var time = index / 60.0; + var timeFloat = (float)time; + times[index] = time; + positions[index] = new Vector3( + 0.4f * timeFloat + 0.15f * timeFloat * timeFloat, + 1.6f + 0.02f * timeFloat * timeFloat, + -4f + 0.1f * timeFloat); + rotations[index] = Quaternion.identity; + fieldOfView[index] = 40f; + dutch[index] = 0f; + } + + return new CameraSamples( + times, + positions, + rotations, + fieldOfView, + dutch); + } + + private static AICameraTimelinePreviewImporter.SimplifiedCameraCurves + CreatePositionOnlySimplifiedCurves( + double[] times, + Vector3[] positions, + int[] positionIndices) + { + var rotations = Enumerable.Repeat( + Quaternion.identity, + times.Length) + .ToArray(); + var fieldOfView = Enumerable.Repeat(40f, times.Length).ToArray(); + var dutch = new float[times.Length]; + var allIndices = Enumerable.Range(0, times.Length).ToArray(); + return new AICameraTimelinePreviewImporter.SimplifiedCameraCurves( + times, + positions, + rotations, + fieldOfView, + dutch, + positionIndices, + allIndices, + allIndices, + allIndices); + } + + private static void AssertCurveIsC1(AnimationCurve curve) + { + var keys = curve.keys; + for (var index = 0; index < keys.Length; index++) + { + Assert.That( + keys[index].inTangent, + Is.EqualTo(keys[index].outTangent).Within(0.000001f), + $"Curve key {index} has a velocity discontinuity."); + } + } + + private static void AssertCurveDoesNotOvershootSelectedSegments( + AnimationCurve curve, + IReadOnlyList times, + IReadOnlyList indices) + { + const int samplesPerSegment = 16; + for (var segment = 1; segment < indices.Count; segment++) + { + var start = (float)times[indices[segment - 1]]; + var end = (float)times[indices[segment]]; + var startValue = curve.Evaluate(start); + var endValue = curve.Evaluate(end); + var minimum = Mathf.Min(startValue, endValue) - 0.000001f; + var maximum = Mathf.Max(startValue, endValue) + 0.000001f; + for (var sample = 0; sample <= samplesPerSegment; sample++) + { + var time = Mathf.Lerp( + start, + end, + sample / (float)samplesPerSegment); + Assert.That( + curve.Evaluate(time), + Is.InRange(minimum, maximum), + $"Curve overshot selected segment {segment - 1}."); + } + } + } + + private static Vector3[] EvaluatePositionCurves( + IReadOnlyList times, + AICameraTimelinePreviewImporter.PositionCurveBuildResult curves) + { + var result = new Vector3[times.Count]; + for (var index = 0; index < times.Count; index++) + { + var time = (float)times[index]; + result[index] = new Vector3( + curves.XCurve.Evaluate(time), + curves.YCurve.Evaluate(time), + curves.ZCurve.Evaluate(time)); + } + + return result; + } + + private static float MeasureMaximumJerk( + IReadOnlyList times, + IReadOnlyList positions) + { + if (times.Count < 4) + { + return 0f; + } + + var velocities = new Vector3[times.Count - 1]; + var velocityTimes = new double[velocities.Length]; + for (var index = 0; index < velocities.Length; index++) + { + velocities[index] = + (positions[index + 1] - positions[index]) / + (float)(times[index + 1] - times[index]); + velocityTimes[index] = (times[index + 1] + times[index]) * 0.5; + } + + var accelerations = new Vector3[velocities.Length - 1]; + var accelerationTimes = new double[accelerations.Length]; + for (var index = 0; index < accelerations.Length; index++) + { + accelerations[index] = + (velocities[index + 1] - velocities[index]) / + (float)(velocityTimes[index + 1] - velocityTimes[index]); + accelerationTimes[index] = + (velocityTimes[index + 1] + velocityTimes[index]) * 0.5; + } + + var maximumJerk = 0f; + for (var index = 0; index < accelerations.Length - 1; index++) + { + var jerk = + (accelerations[index + 1] - accelerations[index]) / + (float)(accelerationTimes[index + 1] - + accelerationTimes[index]); + maximumJerk = Mathf.Max(maximumJerk, jerk.magnitude); + } + + return maximumJerk; + } + private static CameraSamples CreateSamples(int count) { var times = new double[count]; diff --git a/CameraAI~/Tools~/CWCameraWorker/CWCameraWorker.exe b/CameraAI~/Tools~/CWCameraWorker/CWCameraWorker.exe index 5346092..dde6425 100644 --- a/CameraAI~/Tools~/CWCameraWorker/CWCameraWorker.exe +++ b/CameraAI~/Tools~/CWCameraWorker/CWCameraWorker.exe @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:91d0fe139e243b31fc12cdfe988a00b1f1299443401edb9d3b70022608223e1f -size 16391986 +oid sha256:7c6a6c7ec20624b98a9b705bf2c7625ac8d318c1e67bce4a21ee3a741cd648b1 +size 16427637 diff --git a/CameraAI~/Tools~/CWCameraWorker/_internal/base_library.zip b/CameraAI~/Tools~/CWCameraWorker/_internal/base_library.zip index 74d84910b7e140ec93cee6a7d5aa7934709cedb8..e8496f9d7f9049dfe94a958f447891cf2505d622 100644 GIT binary patch delta 2150 zcmY+Fdr(x@9mjX~z~0C1y}Jts1B)WqHl{iQBCyE&t)!@M5zu68l#mq>v%EGzQm050 zhR(F&P?LNot-qPB@-E%d503CT1yZEd60TB63%N5-OKjS{2ORPFDu`-i=MeZIff zIp_B~bI)|hm<}0HHB~GMdm6ZRpxEj$i>)y?o;3&Wxl+Dsetp-q+h!JaDADuG_CaUo zl$nt2cr*$Z>bTon`Uib=t~v6gyYDTWUrUDKwwT`eXcoTd=d~kV9Iq#QamKaHW{wZW zyqz$xe@HX;59!~}MWt{xt#Zkm%>3obpv{c_v$B($t-o~16)3MI=bAy~NJXFCuUq?d zTfffw^{9S5x?i{V>(ZDme{vFI6gKe2lw!UyrN$(iMR|@lhs{yh;ZHSp*j;Cro4>DA zf{&S>os>7eV19ny@#E2f)`Kx->%rL3AL1=KuWq5Q)1S7Xy|J^)-w7j~bNquG%(~x# z2Ms)*ojGbj;aZ-@HYZ!LeJ#(mzqZgyIVdvtJYk=13?Tm@HjYXxz{oJN2{f&t-Yty-yB{vt&R#EyK2SCI-ZE0H59Y>tj*Hu z@9A#qd?p-2P4~*igv}D{Y4?YV1;qLz9)=$^P#O)vYhQAdSsI_l%^X^bM&WghPcTcf zKj80dX=-l_ZVuO?$#|W8(`M36_EMCWxuwQIyOpn5cbv!{#%&TTR z9&HH(XY2GCJXNb2iH@A{+4dF~b$kwb>45V25ZQx`!SHH3D3xtG5slaDsZBIpFAF&A zXso40=BJZdV%Ue~CE$BL?%w?$_rpz0eA;gD2b$WRX$drUr9Jl?I(*!VLn5g$Xy3j1 z8}aySI$y+WyX*;%DZ+vlEI7Eicjo# zB7@wMf3ag&K=-5_|0I_#-+y>br198)n)Q_uVtl-cy;3V-qmNfG-v$W>eY}MEUXpOlM-|l} z32E6B-21wOj%>Pu>YEbY&E^Hnct^sGY+lYjJtiT08KpO$kkGq~Hgx4r5ANPu$RXGCjD*Pp)_5Gwq4VcO%ea_BspB3QZ+~D-L}4zOS0%}4&87UihKx`y z?L%5G<5n*1qiwScUmn5rpp0z-7WBz*EwRPnR34>{?3D4}JZkvMugK^tx5cAApUgM* z&8k@3sIf)kr^3{AIAXex&(qk%@rasVKx*U1GK>P+?t5n==0n1q@VAJ%F4XpG5tUI$ z>eCq+Hw&rvJwoM2D`7P*)Uan(m9e;_h`FkW%r7j8s3DFMAWTfO1|AZs~$l?3Hcth&8iA^zdI6is)T~> z^(wHIQj>2FC|FTS6FqlW!9XefT|&Q7FzjdXxKv7wn)tN>wTzl^^pt|?GP(npP%u~~ zZpEa6(`7WDE&oywTTYhRFBB9Fvp8%ir_|g#3Q$h-&3vFB%wSo?iDydjl zL6eGgsjt4zIJQ?%#>^ZQhlKS~l8UPWG8U^yzCiw><>YrRQ?Ye9nXj~}ct^mVeJZXC zIC4%!+6sc3iUaKeSfK+U0k$p&rUk?wa3G_SAnu|AK>-2Hi7^35MNZrnFjnP6_TvP_ zeNJo>@RKnojtNkvoVY8XYOaR7?-1NEG;9|TdP>840@PLw_XT`&uLjR*iK9N9&fN2) z{e0Ewxg@Bq%8U_ShxT4{A0*u=l9IFV< z+H`ynlH$U(AASG-YFD~+>=IGO6Lp*skoK64*wqBRnL6qPC|h(uK=boD&I{09)R9WN_;d#h>iUybPaT)<^h$G2mLK$nMsz#2iCMyPuG+HKF(X`C8VrW_PEWh>YnBKkJ@!e`^LUx^ego_S z3b~INtulUD$V-`Vsf^DHc?rAXm62UUjnr1l=qlnx?8@UZ-Y(+B zEbU1d?L(>)mSV~=@RSU9G3ChHDq~kM{fCo1GDeGORAYTIEFQjs`CpfD_%k&DZ61m^ zdrXGyOHy-_t1@VI3r%*mx$NPR;y;3vYgd~L*Zt(0f7V=WPtUPdbG>4_@pnQ?g&-= zdPL0>6(Yb;ii>Hy@A3kdM3AsC7>H*!lQ~*;~%D*>960s<)hCCmxQdNukCw z=2RRKD|iMwpUbeaf=)yKT87>VUdVa^3=_vq3HWOT^?NMDV5#I~j(t0s6liY>Hn+5I z?#$f0u@!PQ=jgv|vg6Sz9?drYfZ>%&ankx2rYdPK-3J-stH_dmm?8MNDGuMOqLk4` z84gv^(TIAJ!GGU0UmW-)?CWUkcdsl>zR@2<%x#>=3ZA*@#mDrVko% zPr&>!BRsVPqmqhl0X-HKrv+R|R3X<9{JTa)=}#>j4RzE)>k}$o5a#2XRJ>nDqs!`4 zVe}IWcB`lqkh)(*uYg~?rs9%-w0;%VRRlj9R#7J);XM^U6wrH7#fJj!f21P*ae~^9 zRWu5iPS9{jK;9w^R|Q;iX-Hm8)pI=>)~}`=JXoXQjn(um_+Xuen*xq+((tW%Ixnxa zYiO&dU3~Ew4I}jwwQskE*?Njfc}GKj1Ahb)Yv>)F7}N0S8oof>6;75>Zo--zo(j4y z>~a75UYx2lNufYTsI6sV_+ZgR;xuP%F-c9qj_skq=CJi9GsCln+9+B|Wz2bI zsWq^DTQJldj=w?iF|I%*rl$X!MSE|F3XzG9)5XDfP&D9ctVsqsrzscU;jAe?v|^<)YeZPHVL4INv; zJt$&az8V`+awzGLP;1||;bIO?LIWj@3H-+ bool: diff --git a/CameraAI~/Tools~/CWCameraWorker/_internal/cwai_sources/repository/MachineLearning/CameraDirector/generate_hybrid.py b/CameraAI~/Tools~/CWCameraWorker/_internal/cwai_sources/repository/MachineLearning/CameraDirector/generate_hybrid.py index 6689eb1..4dcb55e 100644 --- a/CameraAI~/Tools~/CWCameraWorker/_internal/cwai_sources/repository/MachineLearning/CameraDirector/generate_hybrid.py +++ b/CameraAI~/Tools~/CWCameraWorker/_internal/cwai_sources/repository/MachineLearning/CameraDirector/generate_hybrid.py @@ -46,6 +46,7 @@ import planner import shot_features import camera_runtime_data as train import cw_camera_runtime +import trajectory_quality HARD_PERSON_SAFE_BOUNDS = { @@ -125,21 +126,21 @@ PREPARATION_LOGIC_CONTRACT_VERSION = ( "hybrid-preparation-logic-v3-complete-rotation-6d" ) CANDIDATE_LOGIC_CONTRACT_VERSION = ( - "hybrid-candidate-logic-v18-torso-front-facing-safety" + "hybrid-candidate-logic-v22-causal-aim-final-distance-dynamics-gate" ) CANDIDATE_CACHE_STAGE_VERSIONS = { "shotSegmentation": "data-driven-candidates-ranker-global-dag-v1", "shotPlanning": "hierarchical-or-legacy-reconciliation-replay-v3-static-budget", "candidateRanking": ( - "template-rank-v14-complete-rotation-6d-front-facing-safety-" + "template-rank-v17-c2-position-screen-dynamics-causal-aim-" "adjacent-transition-v3-no-forced-side-alternation" ), "trajectoryRetargeting": ( - "retarget-v20-torso-front-facing-safety-c2-yamo-turn-kinematics-" - "semantic-baked-aim" + "retarget-v24-speed-adaptive-final-radial-audit-dynamics-" + "semantic-causal-jerk-bounded-baked-aim" ), "compositionSafety": ( - "person-safety-v10-derived-semantic-envelope-torso-front-facing" + "person-safety-v12-front-facing-fail-closed-dynamics" ), "variationPool": ( SEED_VARIATION_POLICY_VERSION @@ -148,15 +149,27 @@ CANDIDATE_CACHE_STAGE_VERSIONS = { "sequenceContinuation": SEED_VARIATION_SEQUENCE_CONTEXT_POLICY, } TRAJECTORY_RETARGETING_VERSION = ( - "edge-safe-position-single-space-c2-yamo-angle-turn-semantic-baked-aim-v10" + "c2-position-speed-adaptive-final-radial-step-audit-v12" ) TEMPLATE_MOTION_SMOOTHING_VERSION = ( "savgol61-nearest-edge-step-guard-v1" ) MOTION_SELECTION_VERSION = ( - "non-static-retention-expressiveness-yamo-angle-kinematics-v7" + "non-static-retention-translation-screen-dynamics-yamo-kinematics-v9" ) FINAL_POSITION_SMOOTHING_WINDOW_FRAMES = 15 +FINAL_POSITION_SMOOTHING_VERSION = ( + "nearest-edge-low-frequency-net-travel-dynamics-guard-v3" +) +TRAJECTORY_DYNAMICS_SELECTION_WEIGHT = 0.65 +TRAJECTORY_DYNAMICS_MAXIMUM_SELECTION_PENALTY = 7.8 +TRAJECTORY_CATASTROPHIC_MAXIMUM_SPEED_MPS = 12.0 +TRAJECTORY_CATASTROPHIC_MAXIMUM_ACCELERATION_MPS2 = 80.0 +TRAJECTORY_CATASTROPHIC_MAXIMUM_JERK_MPS3 = 600.0 +SCREEN_CATASTROPHIC_MAXIMUM_ACCELERATION_UVPS2_P99 = 50.0 +SCREEN_CATASTROPHIC_MAXIMUM_JERK_UVPS3_P99 = 1200.0 +SCREEN_CATASTROPHIC_MAXIMUM_ACCELERATION_UVPS2 = 60.0 +SCREEN_CATASTROPHIC_MAXIMUM_JERK_UVPS3 = 1600.0 MAXIMUM_CAMERA_RELATIVE_POSITION_STEP_METERS = 0.12 TRAJECTORY_OFFSET_LIMITS_METERS = { "wide": 4.5, @@ -179,7 +192,7 @@ ROLLING_STATIC_BUDGET_MAX_STATIC_SHOTS = 1 ROLLING_STATIC_BUDGET_POLICY_VERSION = ( "seed-independent-static-slot-reservation-v1" ) -AIM_POLICY_VERSION = "semantic-focus-braking-bounded-baked-aim-v2" +AIM_POLICY_VERSION = "semantic-focus-c2-causal-jerk-bounded-baked-aim-v4" AIM_SAMPLE_RATE = 60.0 AIM_TARGET_SMOOTHING_SECONDS = 0.60 AIM_TARGET_DEAD_ZONE_METERS = 0.055 @@ -188,6 +201,16 @@ AIM_SOURCE_RESIDUAL_HORIZONTAL_LIMIT = 0.035 AIM_SOURCE_RESIDUAL_VERTICAL_LIMIT = 0.030 AIM_MAXIMUM_ANGULAR_SPEED_DPS = 90.0 AIM_MAXIMUM_ANGULAR_ACCELERATION_DPS2 = 360.0 +# The authored corpus, after restricting samples to the existing 90 dps / 360 +# dps2 contract, has an angular-jerk P99 of about 1,950 dps3 and a P99.9 of +# about 6,610 dps3. Recent generated shots reached 28,000--43,000 dps3 even +# while respecting the speed and acceleration limits. 7,200 dps3 therefore +# removes one/two-frame acceleration flips without flattening the authored +# long-tail camera language. +AIM_MAXIMUM_ANGULAR_JERK_DPS3 = 7200.0 +AIM_ANGULAR_JERK_P95_FREE_RATIO = 0.25 +AIM_ANGULAR_JERK_PEAK_FREE_RATIO = 0.75 +AIM_ANGULAR_JERK_SELECTION_WEIGHT = 2.0 AIM_ERROR_WARNING_DEGREES = 7.0 AIM_ERROR_P90_LIMIT_DEGREES = 6.0 AIM_HORIZONTAL_WARNING_UV = 0.10 @@ -3028,16 +3051,103 @@ def retarget_camera_position_path( path has already been smoothed once before it enters the library. """ - return resample_path(template_motion_path, output_length) + resampled, _ = trajectory_quality.resample_position_path_c2( + template_motion_path, + output_length, + sample_rate=HYBRID_SAMPLE_RATE, + ) + return resampled def smooth_camera_world_position(values: np.ndarray) -> np.ndarray: - """Apply only a short final polish while preserving sub-second movement.""" + """Apply an edge-safe polish only when it improves path dynamics. - return smooth_segment( - values, + The old ``mode='interp'`` Savitzky-Golay edge can launch a camera after a + hard cut. Nearest extension avoids that extrapolation, and this guard + rejects any polish that increases acceleration/jerk or erases meaningful + authored travel. + """ + + source = np.asarray(values, dtype=np.float32) + polished = smooth_template_motion_path( + source, maximum_window=FINAL_POSITION_SMOOTHING_WINDOW_FRAMES, ) + if len(source) < 4 or np.array_equal(polished, source): + return polished + before = trajectory_quality.translation_dynamics_metrics( + source, + sample_rate=HYBRID_SAMPLE_RATE, + ) + after = trajectory_quality.translation_dynamics_metrics( + polished, + sample_rate=HYBRID_SAMPLE_RATE, + ) + # Raw path length is a bad retention reference: alternating millimetre + # chatter can contribute more travel than the intentional pan underneath + # it. Compare the low-frequency motion envelope and net displacement + # instead, so denoising is accepted without flattening deliberate moves. + retention_sigma = max( + 1.0, + min(FINAL_POSITION_SMOOTHING_WINDOW_FRAMES, len(source)) / 5.0, + ) + source_low_frequency = gaussian_filter1d( + source.astype(np.float64), + sigma=retention_sigma, + axis=0, + mode="nearest", + truncate=3.0, + ) + polished_low_frequency = gaussian_filter1d( + polished.astype(np.float64), + sigma=retention_sigma, + axis=0, + mode="nearest", + truncate=3.0, + ) + source_low_path = position_path_length(source_low_frequency) + polished_low_path = position_path_length(polished_low_frequency) + motion_deadband_meters = 0.01 + low_frequency_path_retained = ( + source_low_path <= motion_deadband_meters + or polished_low_path + >= source_low_path * 0.90 - motion_deadband_meters + ) + low_frequency_path_not_exaggerated = ( + polished_low_path + <= source_low_path * 1.12 + motion_deadband_meters + ) + source_net = source_low_frequency[-1] - source_low_frequency[0] + polished_net = polished_low_frequency[-1] - polished_low_frequency[0] + source_net_length = float(np.linalg.norm(source_net)) + if source_net_length <= motion_deadband_meters: + net_travel_retained = True + else: + source_net_direction = source_net / source_net_length + retained_forward_travel = float( + np.dot(polished_net, source_net_direction) + ) + net_travel_retained = bool( + retained_forward_travel + >= source_net_length * 0.90 - motion_deadband_meters + ) + acceleration_safe = ( + float(after["accelerationMetersPerSecondSquared"]["p99"]) + <= float(before["accelerationMetersPerSecondSquared"]["p99"]) + * 1.05 + + 0.5 + ) + jerk_safe = ( + float(after["jerkMetersPerSecondCubed"]["p99"]) + <= float(before["jerkMetersPerSecondCubed"]["p99"]) * 1.05 + + 5.0 + ) + retention_safe = bool( + low_frequency_path_retained + and low_frequency_path_not_exaggerated + and net_travel_retained + ) + return polished if retention_safe and acceleration_safe and jerk_safe else source def position_path_length(values: np.ndarray) -> float: @@ -3625,6 +3735,350 @@ def build_semantic_aim_composition( return desired.astype(np.float32), lead_metrics +def _bounded_vector(value: np.ndarray, maximum_length: float) -> np.ndarray: + """Return ``value`` projected into a Euclidean length bound.""" + + length = float(np.linalg.norm(value)) + if length <= maximum_length or length <= 1e-12: + return value + return value * (maximum_length / length) + + +def _quaternion_to_rotvec(quaternions: np.ndarray) -> np.ndarray: + """Convert xyzw quaternions to shortest-arc rotation vectors. + + This small vectorized implementation avoids constructing a SciPy + ``Rotation`` object for every generated frame. The controller calls it in + its hot loop; SciPy remains useful elsewhere for bulk, offline operations. + """ + + values = np.asarray(quaternions, dtype=np.float64) + if values.shape[-1] != 4: + raise ValueError("quaternions must have a final dimension of four.") + values = values / np.maximum( + np.linalg.norm(values, axis=-1, keepdims=True), + 1e-12, + ) + values = values.copy() + values[values[..., 3] < 0.0] *= -1.0 + vector = values[..., :3] + vector_length = np.linalg.norm(vector, axis=-1) + angle = 2.0 * np.arctan2( + vector_length, + np.clip(values[..., 3], -1.0, 1.0), + ) + scale = np.empty_like(vector_length) + regular = vector_length > 1e-10 + scale[regular] = angle[regular] / vector_length[regular] + scale[~regular] = 2.0 + return vector * scale[..., None] + + +def _rotvec_to_quaternion(rotation_vectors: np.ndarray) -> np.ndarray: + """Convert rotation vectors to normalized xyzw quaternions.""" + + values = np.asarray(rotation_vectors, dtype=np.float64) + if values.shape[-1] != 3: + raise ValueError("rotation_vectors must have a final dimension of three.") + angle = np.linalg.norm(values, axis=-1) + half_angle = 0.5 * angle + scale = np.empty_like(angle) + regular = angle > 1e-10 + scale[regular] = np.sin(half_angle[regular]) / angle[regular] + # sin(theta / 2) / theta approaches one half at the origin. + scale[~regular] = 0.5 + return np.concatenate( + ( + values * scale[..., None], + np.cos(half_angle)[..., None], + ), + axis=-1, + ) + + +def _quaternion_to_rotvec_single(quaternion: np.ndarray) -> np.ndarray: + """Allocation-light shortest-arc quaternion log for the controller loop.""" + + x, y, z, w = (float(value) for value in quaternion) + quaternion_length = math.sqrt(x * x + y * y + z * z + w * w) + if quaternion_length <= 1e-12: + return np.zeros(3, dtype=np.float64) + inverse_length = 1.0 / quaternion_length + x *= inverse_length + y *= inverse_length + z *= inverse_length + w *= inverse_length + if w < 0.0: + x, y, z, w = -x, -y, -z, -w + vector_length = math.sqrt(x * x + y * y + z * z) + if vector_length <= 1e-10: + return np.asarray([2.0 * x, 2.0 * y, 2.0 * z], dtype=np.float64) + scale = 2.0 * math.atan2(vector_length, min(1.0, max(-1.0, w))) + scale /= vector_length + return np.asarray([x * scale, y * scale, z * scale], dtype=np.float64) + + +def _multiply_single_quaternion(left: np.ndarray, right: np.ndarray) -> np.ndarray: + """Fast scalar xyzw quaternion product used by the frame loop.""" + + ax, ay, az, aw = (float(value) for value in left) + bx, by, bz, bw = (float(value) for value in right) + return np.asarray( + [ + aw * bx + bw * ax + ay * bz - az * by, + aw * by + bw * ay + az * bx - ax * bz, + aw * bz + bw * az + ax * by - ay * bx, + aw * bw - ax * bx - ay * by - az * bz, + ], + dtype=np.float64, + ) + + +def _robust_angular_feed_forward( + desired_velocity: np.ndarray, + sample_rate: float, +) -> np.ndarray: + """Keep sustained authored pans while rejecting one-frame target jumps.""" + + velocity = np.asarray(desired_velocity, dtype=np.float64) + if len(velocity) < 2: + return velocity.copy() + if len(velocity) == 2: + disagreement = float(np.linalg.norm(velocity[1] - velocity[0])) + persistent_scale = max( + math.radians(1.0), + 0.15 + * max( + float(np.linalg.norm(velocity[0])), + float(np.linalg.norm(velocity[1])), + ), + ) + if disagreement <= persistent_scale: + return velocity.copy() + # Two inconsistent steps contain no evidence of a sustained pan. A + # zero feed-forward lets the position tracker make the safest possible + # move instead of misclassifying their arithmetic mean as intent. + return np.zeros_like(velocity) + # A truncated five-key median distinguishes an authored pan from a pose + # discontinuity. NaN padding gives the first/last key a genuine truncated + # window instead of repeating the boundary spike. + padded = np.pad( + velocity, + ((2, 2), (0, 0)), + mode="constant", + constant_values=np.nan, + ) + windows = np.lib.stride_tricks.sliding_window_view( + padded, + 5, + axis=0, + ) + local_median = np.nanmedian(windows, axis=-1) + disagreement = np.linalg.norm(velocity - local_median, axis=1) + tolerance = np.maximum( + math.radians(1.0), + 0.15 * np.linalg.norm(local_median, axis=1), + ) + persistent = np.where( + (disagreement <= tolerance)[:, None], + velocity, + local_median, + ) + # Sub-frame noise in a source quaternion should not become target + # acceleration. Reflect mode preserves a constant pan and the component + # integral, unlike nearest padding at a shot boundary. + sigma_frames = max(0.0, 0.0125 * float(sample_rate)) + if sigma_frames <= 1e-8 or len(persistent) < 3: + return persistent + return gaussian_filter1d( + persistent, + sigma=sigma_frames, + axis=0, + mode="reflect", + ) + + +def _integrate_angular_velocity( + initial_rotation: np.ndarray, + angular_velocity: np.ndarray, + sample_rate: float, +) -> np.ndarray: + """Integrate body-local angular velocity without per-frame SciPy objects.""" + + velocity = np.asarray(angular_velocity, dtype=np.float64) + output = np.empty((len(velocity) + 1, 4), dtype=np.float64) + output[0] = np.asarray(initial_rotation, dtype=np.float64) + output[0] /= max(float(np.linalg.norm(output[0])), 1e-12) + steps = _rotvec_to_quaternion(velocity / float(sample_rate)) + for index, step in enumerate(steps, start=1): + output[index] = _multiply_single_quaternion(output[index - 1], step) + output[index] /= max(float(np.linalg.norm(output[index])), 1e-12) + ensure_quaternion_continuity(output, 0, len(output)) + return output + + +def _angular_velocity_dynamics_vectors( + angular_velocity: np.ndarray, + sample_rate: float, +) -> tuple[np.ndarray, np.ndarray]: + """Return within-shot acceleration and jerk for an angular velocity path.""" + + velocity = np.asarray(angular_velocity, dtype=np.float64) + acceleration = np.diff(velocity, axis=0) * sample_rate + jerk = np.diff(acceleration, axis=0) * sample_rate + return acceleration, jerk + + +def _causal_smooth_angular_velocity( + angular_velocity: np.ndarray, + alpha: float, + initial_velocity: np.ndarray, +) -> np.ndarray: + """Low-pass angular velocity without looking into future target keys. + + ``initial_velocity`` is the persistent motion inferred at the shot entry. + A legal constant pan therefore remains unchanged from its first interval, + while an isolated pose jump starts responding only after that jump occurs. + The positive one-pole kernel cannot invent a direction reversal on its own. + """ + + velocity = np.asarray(angular_velocity, dtype=np.float64) + initial = np.asarray(initial_velocity, dtype=np.float64) + if velocity.ndim != 2 or velocity.shape[1] != 3: + raise ValueError("angular_velocity must have shape [sample, 3].") + if initial.shape != (3,): + raise ValueError("initial_velocity must have shape [3].") + if not math.isfinite(alpha) or not 0.0 < alpha <= 1.0: + raise ValueError("alpha must be finite and in the interval (0, 1].") + if not np.isfinite(velocity).all() or not np.isfinite(initial).all(): + raise ValueError("angular velocities must contain only finite values.") + if len(velocity) == 0: + return velocity.copy() + + smoothed = np.empty_like(velocity) + previous = initial.copy() + for index, target in enumerate(velocity): + previous = previous + alpha * (target - previous) + smoothed[index] = previous + return smoothed + + +def _quaternion_angular_dynamics_vectors( + rotations: np.ndarray, + sample_rate: float, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Return per-step angular velocity, acceleration and jerk rot-vectors.""" + + values = np.asarray(rotations, dtype=np.float64).copy() + if values.ndim != 2 or values.shape[1] != 4 or len(values) == 0: + raise ValueError("rotations must have shape [frame, 4].") + if not math.isfinite(sample_rate) or sample_rate <= 0.0: + raise ValueError("sample_rate must be finite and positive.") + if not np.isfinite(values).all(): + raise ValueError("rotations must contain only finite values.") + values /= np.maximum(np.linalg.norm(values, axis=1, keepdims=True), 1e-8) + ensure_quaternion_continuity(values, 0, len(values)) + if len(values) == 1: + empty = np.empty((0, 3), dtype=np.float64) + return empty, empty.copy(), empty.copy() + relative = train.quat_multiply( + train.quat_conjugate(values[:-1]), + values[1:], + ) + relative[relative[:, 3] < 0.0] *= -1.0 + angular_velocity = _quaternion_to_rotvec(relative) * sample_rate + angular_acceleration, angular_jerk = _angular_velocity_dynamics_vectors( + angular_velocity, + sample_rate, + ) + return angular_velocity, angular_acceleration, angular_jerk + + +def quaternion_angular_dynamics_metrics( + rotations: np.ndarray, + sample_rate: float = AIM_SAMPLE_RATE, +) -> dict[str, float]: + """Measure final quaternion speed, acceleration and jerk in degrees.""" + + velocity, acceleration, jerk = _quaternion_angular_dynamics_vectors( + rotations, + sample_rate, + ) + + def magnitudes(values: np.ndarray) -> np.ndarray: + if len(values) == 0: + return np.asarray([0.0], dtype=np.float64) + return np.degrees(np.linalg.norm(values, axis=1)) + + speed = magnitudes(velocity) + acceleration_magnitude = magnitudes(acceleration) + jerk_magnitude = magnitudes(jerk) + return { + "aimAngularSpeedDegreesPerSecondMax": float(np.max(speed)), + "aimAngularSpeedDegreesPerSecondP95": float(np.quantile(speed, 0.95)), + "aimAngularAccelerationDegreesPerSecondSquaredMax": float( + np.max(acceleration_magnitude) + ), + "aimAngularAccelerationDegreesPerSecondSquaredP95": float( + np.quantile(acceleration_magnitude, 0.95) + ), + "aimAngularJerkDegreesPerSecondCubedMax": float( + np.max(jerk_magnitude) + ), + "aimAngularJerkDegreesPerSecondCubedP95": float( + np.quantile(jerk_magnitude, 0.95) + ), + } + + +def aim_rotation_dynamics_selection_penalty(metrics: dict[str, object]) -> float: + """Softly prefer candidates that do not live at the angular-jerk ceiling.""" + + jerk_limit = float( + metrics.get( + "aimAngularJerkLimitDegreesPerSecondCubed", + AIM_MAXIMUM_ANGULAR_JERK_DPS3, + ) + ) + if not math.isfinite(jerk_limit) or jerk_limit <= 0.0: + raise ValueError("Angular jerk limit must be finite and positive.") + peak_ratio = float( + metrics.get("aimAngularJerkDegreesPerSecondCubedMax", 0.0) + ) / jerk_limit + p95_ratio = float( + metrics.get("aimAngularJerkDegreesPerSecondCubedP95", 0.0) + ) / jerk_limit + near_limit_ratio = float( + metrics.get("aimAngularJerkNearLimitFrameRatio", 0.0) + ) + + def normalized_excess(value: float, free_ratio: float) -> float: + return float( + np.clip( + (value - free_ratio) / max(1.0 - free_ratio, 1e-8), + 0.0, + 1.0, + ) + ) + + return float( + AIM_ANGULAR_JERK_SELECTION_WEIGHT + * ( + 0.70 + * normalized_excess( + p95_ratio, + AIM_ANGULAR_JERK_P95_FREE_RATIO, + ) + + 0.20 + * normalized_excess( + peak_ratio, + AIM_ANGULAR_JERK_PEAK_FREE_RATIO, + ) + + 0.10 * float(np.clip(near_limit_ratio, 0.0, 1.0)) + ) + ) + + def limit_quaternion_kinematics( desired_rotations: np.ndarray, sample_rate: float = AIM_SAMPLE_RATE, @@ -3632,109 +4086,304 @@ def limit_quaternion_kinematics( maximum_acceleration_dps2: float = ( AIM_MAXIMUM_ANGULAR_ACCELERATION_DPS2 ), + maximum_jerk_dps3: float = AIM_MAXIMUM_ANGULAR_JERK_DPS3, ) -> tuple[np.ndarray, dict[str, float]]: - """Bake smooth quaternion keys with explicit speed/acceleration bounds.""" + """Bake stable quaternion keys with within-shot C2 dynamics bounds. + + The first stage is an acceleration-bounded, moving-target tracker. Its + robust feed-forward preserves legal constant pans from the first key while + isolated pose jumps are handled as position errors. The second stage uses + a causal positive-kernel velocity filter until jerk is bounded. It cannot + move before an internal target change, avoids the previous bang-bang + controller's static-target limit cycle, and never invents an imaginary + zero-velocity key at a hard cut. + """ desired = np.asarray(desired_rotations, dtype=np.float64).copy() if desired.ndim != 2 or desired.shape[1] != 4 or len(desired) == 0: raise ValueError("desired_rotations must have shape [frame, 4].") + if not np.isfinite(desired).all(): + raise ValueError("desired_rotations must contain only finite values.") + for label, value in ( + ("sample_rate", sample_rate), + ("maximum_speed_dps", maximum_speed_dps), + ("maximum_acceleration_dps2", maximum_acceleration_dps2), + ("maximum_jerk_dps3", maximum_jerk_dps3), + ): + if not math.isfinite(value) or value <= 0.0: + raise ValueError(f"{label} must be finite and positive.") desired /= np.maximum(np.linalg.norm(desired, axis=1, keepdims=True), 1e-8) ensure_quaternion_continuity(desired, 0, len(desired)) - output = np.empty_like(desired) - output[0] = desired[0] - previous_velocity = np.zeros(3, dtype=np.float64) delta_time = 1.0 / sample_rate speed_limit = math.radians(maximum_speed_dps) acceleration_limit = math.radians(maximum_acceleration_dps2) - acceleration_step_limit = acceleration_limit * delta_time - velocities: list[np.ndarray] = [] + jerk_limit = math.radians(maximum_jerk_dps3) + if len(desired) == 1: + final_output = desired.astype(np.float32) + dynamics = quaternion_angular_dynamics_metrics(final_output, sample_rate) + dynamics.update( + { + "aimAngularSpeedLimitDegreesPerSecond": float(maximum_speed_dps), + "aimAngularAccelerationLimitDegreesPerSecondSquared": float( + maximum_acceleration_dps2 + ), + "aimAngularJerkLimitDegreesPerSecondCubed": float( + maximum_jerk_dps3 + ), + "aimAngularJerkNearLimitFrameRatio": 0.0, + "aimAngularKinematicLimitScale": 1.0, + "aimAngularVelocitySmoothingSigmaFrames": 0.0, + "aimAngularVelocityCausalSmoothingAlpha": 1.0, + } + ) + dynamics["aimRotationDynamicsSelectionPenalty"] = ( + aim_rotation_dynamics_selection_penalty(dynamics) + ) + return final_output, dynamics + + desired_relative = train.quat_multiply( + train.quat_conjugate(desired[:-1]), + desired[1:], + ) + desired_relative[desired_relative[:, 3] < 0.0] *= -1.0 + desired_velocity = _quaternion_to_rotvec(desired_relative) * sample_rate + feed_forward = _robust_angular_feed_forward( + desired_velocity, + sample_rate, + ) + feed_forward = np.asarray( + [_bounded_vector(value, speed_limit) for value in feed_forward], + dtype=np.float64, + ) + + # Entry velocity belongs to the shot itself. Starting from its persistent + # target velocity prevents the artificial six-frame ramp that previously + # lagged every legal 45/80 dps pan after a hard cut. + previous_velocity = feed_forward[0].copy() + base_output = np.empty_like(desired) + base_output[0] = desired[0] + base_velocities: list[np.ndarray] = [] for index in range(1, len(desired)): - error_quaternion = train.quat_multiply( - train.quat_conjugate(output[index - 1 : index]), - desired[index : index + 1], - )[0] + previous_rotation = base_output[index - 1] + error_quaternion = _multiply_single_quaternion( + np.asarray( + [ + -previous_rotation[0], + -previous_rotation[1], + -previous_rotation[2], + previous_rotation[3], + ], + dtype=np.float64, + ), + desired[index], + ) if error_quaternion[3] < 0.0: error_quaternion *= -1.0 - error = Rotation.from_quat(error_quaternion).as_rotvec() - error_length = float(np.linalg.norm(error)) - if error_length > 1e-12: - # Start braking early enough to reach the requested orientation - # without the old one-frame "snap to target". That snap replaced - # the already acceleration-limited velocity with error / dt and - # could exceed the advertised acceleration by more than 10x. - braking_speed = math.sqrt( - 2.0 * acceleration_limit * error_length - ) - desired_speed = min( - speed_limit, - braking_speed, - error_length / delta_time, - ) - desired_velocity = error * (desired_speed / error_length) - else: - desired_velocity = np.zeros(3, dtype=np.float64) - velocity_change = desired_velocity - previous_velocity - change_length = float(np.linalg.norm(velocity_change)) - if change_length > acceleration_step_limit: - velocity_change *= acceleration_step_limit / change_length - velocity = previous_velocity + velocity_change - velocity_length = float(np.linalg.norm(velocity)) - if velocity_length > speed_limit: - velocity *= speed_limit / velocity_length - step = velocity * delta_time - step_length = float(np.linalg.norm(step)) + error = _quaternion_to_rotvec_single(error_quaternion) + target_velocity = feed_forward[index - 1] + # The robust feed-forward filter may inspect nearby future keys to + # distinguish a real pan from a one-frame target glitch. Never let + # that classification move an already aligned camera before the raw + # target itself starts moving inside the shot. if ( - error_length > 0.0 - and step_length > error_length - and float(np.dot(step, error)) > 0.0 + float(np.linalg.norm(error)) <= 1e-10 + and float(np.linalg.norm(desired_velocity[index - 1])) <= 1e-10 ): - stopping_velocity = error / delta_time - stopping_change = stopping_velocity - previous_velocity - # Landing exactly on the target is allowed only when that landing - # itself satisfies both kinematic bounds. Otherwise overshoot by - # a small, acceleration-limited amount and brake on later frames. - if ( - float(np.linalg.norm(stopping_change)) - <= acceleration_step_limit + 1e-12 - and float(np.linalg.norm(stopping_velocity)) - <= speed_limit + 1e-12 - ): - step = error - velocity = stopping_velocity - step_quaternion = Rotation.from_rotvec(step).as_quat() - output[index] = train.quat_multiply( - output[index - 1 : index], - step_quaternion[None, :], - )[0] - output[index] /= max(float(np.linalg.norm(output[index])), 1e-8) - previous_velocity = velocity - velocities.append(velocity) - ensure_quaternion_continuity(output, 0, len(output)) - if velocities: - velocity_array = np.asarray(velocities) - speeds = np.degrees(np.linalg.norm(velocity_array, axis=1)) - velocity_with_initial_rest = np.vstack( - (np.zeros((1, 3), dtype=np.float64), velocity_array) - ) - accelerations = np.degrees( - np.linalg.norm( - np.diff(velocity_with_initial_rest, axis=0), - axis=1, + target_velocity = np.zeros(3, dtype=np.float64) + # Remove the authored one-frame motion from the current orientation + # error. An exactly tracked constant pan then has zero residual and is + # left untouched; a static pose step retains the full residual. + residual = error - target_velocity * delta_time + residual_length = float(np.linalg.norm(residual)) + if residual_length > 1e-12: + correction_speed = min( + math.sqrt(2.0 * acceleration_limit * residual_length), + residual_length / delta_time, ) - ) * sample_rate - else: - speeds = np.asarray([0.0]) - accelerations = np.asarray([0.0]) - return output.astype(np.float32), { - "aimAngularSpeedDegreesPerSecondMax": float(np.max(speeds)), - "aimAngularAccelerationDegreesPerSecondSquaredMax": float( - np.max(accelerations) - ), - "aimAngularSpeedLimitDegreesPerSecond": float(maximum_speed_dps), - "aimAngularAccelerationLimitDegreesPerSecondSquared": float( - maximum_acceleration_dps2 - ), - } + target_velocity = target_velocity + residual * ( + correction_speed / residual_length + ) + target_velocity = _bounded_vector(target_velocity, speed_limit) + else: + target_velocity = _bounded_vector(target_velocity, speed_limit) + + velocity_change = _bounded_vector( + target_velocity - previous_velocity, + acceleration_limit * delta_time, + ) + velocity = previous_velocity + velocity_change + # Both endpoints are in the convex speed ball, so this is normally a + # no-op and only protects against accumulated floating-point error. + velocity = _bounded_vector(velocity, speed_limit) + step_quaternion = _rotvec_to_quaternion(velocity * delta_time) + base_output[index] = _multiply_single_quaternion( + base_output[index - 1], + step_quaternion, + ) + base_output[index] /= max( + float(np.linalg.norm(base_output[index])), + 1e-12, + ) + previous_velocity = velocity + base_velocities.append(velocity) + + velocity_array = np.asarray(base_velocities, dtype=np.float64) + # Float32 quaternion serialization introduces a few dps3 of round-trip + # noise for very short paths. Keep modest headroom here and audit the + # serialized result again below. + speed_headroom = 0.9999 + acceleration_headroom = 0.9998 + jerk_headroom = 0.9975 + speed_contract = speed_limit * speed_headroom + acceleration_contract = acceleration_limit * acceleration_headroom + jerk_contract = jerk_limit * jerk_headroom + smoothing_candidates = ( + 1.0, + 0.8, + 2.0 / 3.0, + 0.5, + 0.4, + 1.0 / 3.0, + 0.3, + 0.25, + 0.2, + 0.15, + 0.1, + 0.075, + 0.05, + 0.025, + 0.01, + ) + selected_velocity: np.ndarray | None = None + selected_alpha = 1.0 + for smoothing_alpha in smoothing_candidates: + candidate_velocity = _causal_smooth_angular_velocity( + velocity_array, + smoothing_alpha, + feed_forward[0], + ) + maximum_speed = float( + np.max(np.linalg.norm(candidate_velocity, axis=1), initial=0.0) + ) + if maximum_speed > speed_contract: + candidate_velocity *= speed_contract / maximum_speed + candidate_acceleration, candidate_jerk = ( + _angular_velocity_dynamics_vectors( + candidate_velocity, + sample_rate, + ) + ) + maximum_acceleration = float( + np.max(np.linalg.norm(candidate_acceleration, axis=1), initial=0.0) + ) + maximum_jerk = float( + np.max(np.linalg.norm(candidate_jerk, axis=1), initial=0.0) + ) + if ( + maximum_acceleration <= acceleration_contract + 1e-12 + and maximum_jerk <= jerk_contract + 1e-12 + ): + selected_velocity = candidate_velocity + selected_alpha = float(smoothing_alpha) + break + + kinematic_limit_scale = 1.0 + if selected_velocity is None: + # An extremely short or adversarial shot may not offer enough samples + # for the causal smoothing family to meet every bound. Uniformly + # scaling the stable base path preserves its direction and cannot + # create a reversal + # or limit cycle. The downstream aim gate can reject the resulting lag. + base_acceleration, base_jerk = _angular_velocity_dynamics_vectors( + velocity_array, + sample_rate, + ) + maximum_speed = float( + np.max(np.linalg.norm(velocity_array, axis=1), initial=0.0) + ) + maximum_acceleration = float( + np.max(np.linalg.norm(base_acceleration, axis=1), initial=0.0) + ) + maximum_jerk = float( + np.max(np.linalg.norm(base_jerk, axis=1), initial=0.0) + ) + kinematic_limit_scale = min( + 1.0, + speed_contract / max(maximum_speed, 1e-12), + acceleration_contract / max(maximum_acceleration, 1e-12), + jerk_contract / max(maximum_jerk, 1e-12), + ) + selected_velocity = velocity_array * kinematic_limit_scale + selected_alpha = -1.0 + + # The public payload is float32. Re-audit that actual representation and, + # only for a numerical contract overshoot, uniformly reduce the already + # causal path. Uniform scaling cannot create anticipation or a reversal. + final_output: np.ndarray | None = None + dynamics: dict[str, float] | None = None + for _ in range(4): + output = _integrate_angular_velocity( + desired[0], + selected_velocity, + sample_rate, + ) + final_output = output.astype(np.float32) + dynamics = quaternion_angular_dynamics_metrics(final_output, sample_rate) + serialized_scale = min( + 1.0, + maximum_speed_dps * speed_headroom + / max(dynamics["aimAngularSpeedDegreesPerSecondMax"], 1e-12), + maximum_acceleration_dps2 * acceleration_headroom + / max( + dynamics[ + "aimAngularAccelerationDegreesPerSecondSquaredMax" + ], + 1e-12, + ), + maximum_jerk_dps3 * jerk_headroom + / max( + dynamics["aimAngularJerkDegreesPerSecondCubedMax"], + 1e-12, + ), + ) + if serialized_scale >= 1.0: + break + selected_velocity *= serialized_scale + kinematic_limit_scale *= serialized_scale + assert final_output is not None + assert dynamics is not None + _, _, jerk_vectors = _quaternion_angular_dynamics_vectors( + final_output, + sample_rate, + ) + jerk_magnitude_dps3 = ( + np.degrees(np.linalg.norm(jerk_vectors, axis=1)) + if len(jerk_vectors) + else np.asarray([0.0], dtype=np.float64) + ) + dynamics.update( + { + "aimAngularSpeedLimitDegreesPerSecond": float(maximum_speed_dps), + "aimAngularAccelerationLimitDegreesPerSecondSquared": float( + maximum_acceleration_dps2 + ), + "aimAngularJerkLimitDegreesPerSecondCubed": float( + maximum_jerk_dps3 + ), + "aimAngularJerkNearLimitFrameRatio": float( + np.mean(jerk_magnitude_dps3 >= maximum_jerk_dps3 * 0.9) + ), + "aimAngularKinematicLimitScale": float(kinematic_limit_scale), + # Retained for metadata compatibility; the controller no longer + # uses a non-causal Gaussian kernel. + "aimAngularVelocitySmoothingSigmaFrames": 0.0, + "aimAngularVelocityCausalSmoothingAlpha": float(selected_alpha), + } + ) + dynamics["aimRotationDynamicsSelectionPenalty"] = ( + aim_rotation_dynamics_selection_penalty(dynamics) + ) + return final_output, dynamics def semantic_aim_quality_metrics( @@ -4164,9 +4813,16 @@ def retarget_template_to_shot( ) frame_count = end - start - camera_local_normalized = retarget_camera_position_path( - template.motion_path, - frame_count, + resampled_camera_local_meters, trajectory_resampling_metrics = ( + trajectory_quality.resample_position_path_c2( + template.motion_path * target.body_scale, + frame_count, + sample_rate=HYBRID_SAMPLE_RATE, + ) + ) + camera_local_normalized = ( + resampled_camera_local_meters + / max(float(target.body_scale), 1e-8) ) trajectory_center = np.median(camera_local_normalized, axis=0) trajectory_offset = camera_local_normalized - trajectory_center @@ -4250,25 +4906,42 @@ def retarget_template_to_shot( current_distance_meters - float(np.median(current_distance_meters)) ) - clamped_distance_meters = np.clip( - distance_directive_meters + radial_motion, - minimum_distance, - maximum_distance, - ) + requested_distance_meters = distance_directive_meters + radial_motion else: - clamped_distance_meters = np.clip( - current_distance_meters, - minimum_distance, - maximum_distance, - ) - clamped_distance_meters = np.minimum( - clamped_distance_meters * safety_distance_scale, + requested_distance_meters = current_distance_meters + requested_distance_meters = requested_distance_meters * safety_distance_scale + camera_local_directions = camera_local_normalized / np.maximum( + distance_local[:, None], + 1e-8, + ) + requested_camera_local_meters = ( + camera_local_directions * requested_distance_meters[:, None] + ) + smooth_minimum_distance = minimum_distance * safety_distance_scale + smooth_maximum_distance = min( + maximum_distance * safety_distance_scale, 35.0, ) - camera_local_normalized *= ( - clamped_distance_meters - / np.maximum(current_distance_meters, 1e-5) - )[:, None] + camera_local_meters, initial_radial_distance_clamp_metrics = ( + trajectory_quality.smooth_radial_distance_clamp( + requested_camera_local_meters, + smooth_minimum_distance, + smooth_maximum_distance, + lower_transition_meters=min( + 0.35 * safety_distance_scale, + (smooth_maximum_distance - smooth_minimum_distance) * 0.35, + ), + upper_transition_meters=min( + 0.75 * safety_distance_scale, + (smooth_maximum_distance - smooth_minimum_distance) * 0.45, + ), + sample_rate=HYBRID_SAMPLE_RATE, + ) + ) + camera_local_normalized = camera_local_meters / max( + float(target.body_scale), + 1e-8, + ) ( camera_local_normalized, trajectory_velocity_limit_scale, @@ -4317,9 +4990,6 @@ def retarget_template_to_shot( ) applied_motion_scale *= post_kinematic_velocity_limit_scale camera_local_meters = camera_local_normalized * target.body_scale - clamped_distance_meters = ( - np.linalg.norm(camera_local_normalized, axis=1) * target.body_scale - ) root_position = root[start:end, :3] root_rotation = root[start:end, 3:7] @@ -4369,6 +5039,95 @@ def retarget_template_to_shot( sample_rate=HYBRID_SAMPLE_RATE, ) translation_kinematic_detection_space = "final_world_position" + + # The uniform velocity guards and the kinematic turn regularizer can move + # points back outside the distance annulus (the historical orbit failure + # scaled a 2 m radius down to about 1.5 m). Distance is therefore the last + # positional transform. We do not run another center-based step scaler + # afterward because that would invalidate the bound again; the actual + # final step is audited below and unsafe candidates are rejected. + final_relative_position_meters = ( + camera_world_position - stage_anchor_path + ) + trajectory_step_before_final_distance_clamp_meters = ( + float( + np.max( + np.linalg.norm( + np.diff(final_relative_position_meters, axis=0), + axis=1, + ) + ) + ) + if len(final_relative_position_meters) > 1 + else 0.0 + ) + trajectory_step_after_kinematic_meters = ( + trajectory_step_before_final_distance_clamp_meters + ) + ( + final_relative_position_meters, + final_radial_distance_clamp_metrics, + ) = trajectory_quality.smooth_radial_distance_clamp( + final_relative_position_meters, + smooth_minimum_distance, + smooth_maximum_distance, + lower_transition_meters=min( + 0.35 * safety_distance_scale, + (smooth_maximum_distance - smooth_minimum_distance) * 0.35, + ), + upper_transition_meters=min( + 0.75 * safety_distance_scale, + (smooth_maximum_distance - smooth_minimum_distance) * 0.45, + ), + sample_rate=HYBRID_SAMPLE_RATE, + ) + camera_world_position = ( + stage_anchor_path + final_relative_position_meters + ).astype(np.float32) + clamped_distance_meters = np.linalg.norm( + final_relative_position_meters, + axis=1, + ) + trajectory_step_after_final_distance_clamp_meters = ( + float( + np.max( + np.linalg.norm( + np.diff(final_relative_position_meters, axis=0), + axis=1, + ) + ) + ) + if len(final_relative_position_meters) > 1 + else 0.0 + ) + trajectory_final_relative_step_passed = bool( + trajectory_step_after_final_distance_clamp_meters + <= MAXIMUM_CAMERA_RELATIVE_POSITION_STEP_METERS + 1e-6 + ) + final_distance_bounds_passed = bool( + float(np.min(clamped_distance_meters)) + >= smooth_minimum_distance - 1e-6 + and float(np.max(clamped_distance_meters)) + <= smooth_maximum_distance + 1e-6 + ) + if not final_distance_bounds_passed: + raise RuntimeError("Final camera distance audit violated its bounds.") + radial_distance_clamp_metrics = { + **final_radial_distance_clamp_metrics, + "initialPass": initial_radial_distance_clamp_metrics, + "finalPassAfterAllPositionTransforms": True, + "finalDistanceBoundsPassed": final_distance_bounds_passed, + "finalRelativeStepMetersBeforeClamp": ( + trajectory_step_before_final_distance_clamp_meters + ), + "finalRelativeStepMetersAfterClamp": ( + trajectory_step_after_final_distance_clamp_meters + ), + "finalRelativeStepLimitMeters": ( + MAXIMUM_CAMERA_RELATIVE_POSITION_STEP_METERS + ), + "finalRelativeStepPassed": trajectory_final_relative_step_passed, + } translation_kinematic_metrics.update( { "translationKinematicMotionFamily": ( @@ -4743,6 +5502,11 @@ def retarget_template_to_shot( ) * 4.0 + (50.0 if not semantic_aim_metrics["aimQualityPassed"] else 0.0) + + float( + semantic_aim_metrics[ + "aimRotationDynamicsSelectionPenalty" + ] + ) ) quality_score = ( (1.0 - composition_metrics["finalPersonVisibility"]) * 30.0 @@ -4781,6 +5545,47 @@ def retarget_template_to_shot( motion_quality["motionSelectionAdjustment"] + translation_kinematic_penalty ) + candidate_dynamics = trajectory_quality.evaluate_candidate_dynamics( + camera_world_position, + np.asarray(final_composition, dtype=np.float64)[:, :2], + sample_rate=HYBRID_SAMPLE_RATE, + ) + candidate_dynamics_penalty = min( + float(candidate_dynamics["cost"]), + ( + TRAJECTORY_DYNAMICS_MAXIMUM_SELECTION_PENALTY + / TRAJECTORY_DYNAMICS_SELECTION_WEIGHT + ), + ) * TRAJECTORY_DYNAMICS_SELECTION_WEIGHT + translation_dynamics = candidate_dynamics["translationMetrics"] + screen_dynamics = candidate_dynamics["screenSpaceMetrics"] + candidate_dynamics_hard_gate_result = candidate_dynamics_hard_gate( + translation_dynamics, + screen_dynamics, + final_relative_step_passed=( + trajectory_final_relative_step_passed + ), + ) + candidate_dynamics_catastrophic_passed = bool( + candidate_dynamics_hard_gate_result["passed"] + ) + candidate_dynamics["costPassed"] = bool(candidate_dynamics["passed"]) + candidate_dynamics["hardGate"] = candidate_dynamics_hard_gate_result + # Generated payloads expose one authoritative `passed` field. The soft + # cost remains useful for ranking (`costPassed`) but never overrides a + # hard impulse, distance-step, or finite-metric failure. + candidate_dynamics["passed"] = candidate_dynamics_catastrophic_passed + candidate_dynamics["catastrophicPassed"] = ( + candidate_dynamics_catastrophic_passed + ) + candidate_dynamics["selectionPenalty"] = candidate_dynamics_penalty + motion_quality["trajectoryDynamicsSelectionPenalty"] = ( + candidate_dynamics_penalty + ) + motion_quality["motionSelectionAdjustment"] = float( + motion_quality["motionSelectionAdjustment"] + + candidate_dynamics_penalty + ) front_facing = front_facing_safety_metrics( camera_world_position, joint_world_position, @@ -4816,6 +5621,12 @@ def retarget_template_to_shot( "stageAnchor": stage_anchor_path, "transitionState": transition_state, "trajectoryScale": applied_motion_scale, + "trajectoryResampling": trajectory_resampling_metrics, + "radialDistanceClamp": radial_distance_clamp_metrics, + "candidateDynamics": candidate_dynamics, + "candidateDynamicsCatastrophicPassed": ( + candidate_dynamics_catastrophic_passed + ), "trajectoryLimitScale": float(trajectory_scale), "trajectoryVelocityLimitScale": float( trajectory_velocity_limit_scale @@ -4835,6 +5646,15 @@ def retarget_template_to_shot( "trajectoryRelativeStepMetersAfterKinematicRegularization": float( trajectory_step_after_kinematic_meters ), + "trajectoryRelativeStepMetersBeforeFinalDistanceClamp": float( + trajectory_step_before_final_distance_clamp_meters + ), + "trajectoryRelativeStepMetersAfterFinalDistanceClamp": float( + trajectory_step_after_final_distance_clamp_meters + ), + "trajectoryFinalRelativeStepPassed": ( + trajectory_final_relative_step_passed + ), "trajectoryRelativeStepLimitMeters": ( MAXIMUM_CAMERA_RELATIVE_POSITION_STEP_METERS ), @@ -5074,6 +5894,113 @@ def _camera_variation_sort_key(result: dict) -> tuple: ) +def candidate_dynamics_hard_gate( + translation_metrics: object, + screen_metrics: object, + *, + final_relative_step_passed: object, +) -> dict[str, object]: + """Evaluate the fail-closed final camera dynamics contract. + + Percentiles keep ranking tolerant of harmless numerical noise, but a + single rendered-frame impulse is still visible. The hard gate therefore + checks both p99 and maxima, as well as the actual post-distance-clamp + relative step. Missing, non-finite, or non-boolean audit data fails closed. + """ + + def finite_metric( + metrics: object, + group: str, + statistic: str, + ) -> float | None: + if not isinstance(metrics, dict): + return None + values = metrics.get(group) + if not isinstance(values, dict): + return None + value = values.get(statistic) + if not isinstance(value, (int, float)) or isinstance(value, bool): + return None + result = float(value) + return result if math.isfinite(result) and result >= 0.0 else None + + observed = { + "translationMaximumSpeedMetersPerSecond": finite_metric( + translation_metrics, + "speedMetersPerSecond", + "maximum", + ), + "translationMaximumAccelerationMetersPerSecondSquared": finite_metric( + translation_metrics, + "accelerationMetersPerSecondSquared", + "maximum", + ), + "translationMaximumJerkMetersPerSecondCubed": finite_metric( + translation_metrics, + "jerkMetersPerSecondCubed", + "maximum", + ), + "screenP99AccelerationUvPerSecondSquared": finite_metric( + screen_metrics, + "accelerationUvPerSecondSquared", + "p99", + ), + "screenMaximumAccelerationUvPerSecondSquared": finite_metric( + screen_metrics, + "accelerationUvPerSecondSquared", + "maximum", + ), + "screenP99JerkUvPerSecondCubed": finite_metric( + screen_metrics, + "jerkUvPerSecondCubed", + "p99", + ), + "screenMaximumJerkUvPerSecondCubed": finite_metric( + screen_metrics, + "jerkUvPerSecondCubed", + "maximum", + ), + } + thresholds = { + "translationMaximumSpeedMetersPerSecond": ( + TRAJECTORY_CATASTROPHIC_MAXIMUM_SPEED_MPS + ), + "translationMaximumAccelerationMetersPerSecondSquared": ( + TRAJECTORY_CATASTROPHIC_MAXIMUM_ACCELERATION_MPS2 + ), + "translationMaximumJerkMetersPerSecondCubed": ( + TRAJECTORY_CATASTROPHIC_MAXIMUM_JERK_MPS3 + ), + "screenP99AccelerationUvPerSecondSquared": ( + SCREEN_CATASTROPHIC_MAXIMUM_ACCELERATION_UVPS2_P99 + ), + "screenMaximumAccelerationUvPerSecondSquared": ( + SCREEN_CATASTROPHIC_MAXIMUM_ACCELERATION_UVPS2 + ), + "screenP99JerkUvPerSecondCubed": ( + SCREEN_CATASTROPHIC_MAXIMUM_JERK_UVPS3_P99 + ), + "screenMaximumJerkUvPerSecondCubed": ( + SCREEN_CATASTROPHIC_MAXIMUM_JERK_UVPS3 + ), + } + checks = { + name: bool(observed[name] is not None and observed[name] <= threshold) + for name, threshold in thresholds.items() + } + checks["finalRelativeStep"] = bool( + type(final_relative_step_passed) is bool + and final_relative_step_passed + ) + return { + "policyVersion": "candidate-dynamics-hard-gate-v2", + "observed": observed, + "thresholds": thresholds, + "checks": checks, + "passed": bool(all(checks.values())), + } + + def candidate_passes_safety_and_aim(candidate: dict) -> bool: """Gate selectable output on visibility, baked aim, and performer facing.""" @@ -5088,6 +6015,13 @@ def candidate_passes_safety_and_aim(candidate: dict) -> bool: isinstance(value, (int, float)) and math.isfinite(float(value)) for value in front_metrics ) + dynamics = candidate.get("candidateDynamics") + dynamics_passed = ( + dynamics.get("passed") if isinstance(dynamics, dict) else None + ) + catastrophic_passed = candidate.get( + "candidateDynamicsCatastrophicPassed" + ) return bool( composition.get("hardPersonSafetyPassed", False) and composition.get("aimQualityPassed", True) @@ -5097,6 +6031,11 @@ def candidate_passes_safety_and_aim(candidate: dict) -> bool: and type(front_passed) is bool and front_passed and front_metrics_are_finite + and type(dynamics_passed) is bool + and dynamics_passed + and type(catastrophic_passed) is bool + and catastrophic_passed + and dynamics_passed == catastrophic_passed ) @@ -7480,6 +8419,9 @@ def candidate_logic_identifier() -> str: "cameraKinematics": sha256_file( Path(camera_kinematics.__file__).resolve() ), + "trajectoryQuality": sha256_file( + Path(trajectory_quality.__file__).resolve() + ), }, "behaviorConstants": behavior_constants, } @@ -8892,6 +9834,19 @@ def main() -> None: ) for result in candidate_results ) + dynamics_rejected = sum( + result.get("candidateDynamicsCatastrophicPassed") is not True + for result in candidate_results + ) + best_dynamics = min( + candidate_results, + key=lambda result: float( + result.get("candidateDynamics", {}).get( + "cost", + float("inf"), + ) + ), + ) best_front = max( candidate_results, key=lambda result: ( @@ -8918,6 +9873,9 @@ def main() -> None: f"personRejected={person_rejected}, " f"aimRejected={aim_rejected}, " f"frontFacingRejected={front_rejected}, " + f"dynamicsRejected={dynamics_rejected}, " + "bestDynamicsCost=" + f"{float(best_dynamics.get('candidateDynamics', {}).get('cost', float('inf'))):.6f}, " f"exitRatio={closest['strictExitFrameRatio']:.6f}, " f"longestExit={closest['strictExitLongestRunFrames']}, " "minimumMargin=" @@ -9277,6 +10235,16 @@ def main() -> None: "sourceNetMotionMeters": template.net_motion_meters, "sourcePathLengthMeters": template.path_length_meters, "trajectoryScale": trajectory_scale, + "trajectoryResampling": selected_result[ + "trajectoryResampling" + ], + "radialDistanceClamp": selected_result[ + "radialDistanceClamp" + ], + "candidateDynamics": selected_result["candidateDynamics"], + "candidateDynamicsCatastrophicPassed": bool( + selected_result["candidateDynamicsCatastrophicPassed"] + ), "trajectoryLimitScale": selected_result[ "trajectoryLimitScale" ], @@ -9313,6 +10281,19 @@ def main() -> None: ], ) ), + "trajectoryRelativeStepMetersBeforeFinalDistanceClamp": ( + selected_result[ + "trajectoryRelativeStepMetersBeforeFinalDistanceClamp" + ] + ), + "trajectoryRelativeStepMetersAfterFinalDistanceClamp": ( + selected_result[ + "trajectoryRelativeStepMetersAfterFinalDistanceClamp" + ] + ), + "trajectoryFinalRelativeStepPassed": bool( + selected_result["trajectoryFinalRelativeStepPassed"] + ), "trajectoryRelativeStepLimitMeters": selected_result[ "trajectoryRelativeStepLimitMeters" ], @@ -9713,6 +10694,29 @@ def main() -> None: scored_motion_shots = [ shot for shot in shots if shot["motionSelectionScoringApplied"] ] + dynamics_audited_shots = [ + shot + for shot in shots + if isinstance(shot.get("candidateDynamics"), dict) + and isinstance( + shot["candidateDynamics"].get("translationMetrics"), + dict, + ) + and isinstance( + shot["candidateDynamics"].get("screenSpaceMetrics"), + dict, + ) + ] + trajectory_resampling_audited_shots = [ + shot + for shot in shots + if isinstance(shot.get("trajectoryResampling"), dict) + ] + radial_clamp_audited_shots = [ + shot + for shot in shots + if isinstance(shot.get("radialDistanceClamp"), dict) + ] materialized_seed_lineage = build_materialized_seed_lineage( shots, args.seed, @@ -9989,6 +10993,18 @@ def main() -> None: "maximumAngularAccelerationDegreesPerSecondSquared": ( AIM_MAXIMUM_ANGULAR_ACCELERATION_DPS2 ), + "maximumAngularJerkDegreesPerSecondCubed": ( + AIM_MAXIMUM_ANGULAR_JERK_DPS3 + ), + "angularJerkP95PenaltyFreeRatio": ( + AIM_ANGULAR_JERK_P95_FREE_RATIO + ), + "angularJerkPeakPenaltyFreeRatio": ( + AIM_ANGULAR_JERK_PEAK_FREE_RATIO + ), + "angularJerkSelectionPenaltyWeight": ( + AIM_ANGULAR_JERK_SELECTION_WEIGHT + ), "aimErrorP90LimitDegrees": AIM_ERROR_P90_LIMIT_DEGREES, "horizontalDesiredOffsetP90LimitUv": ( AIM_HORIZONTAL_P90_LIMIT_UV @@ -10002,6 +11018,48 @@ def main() -> None: "finalPositionSmoothingWindowFrames": ( FINAL_POSITION_SMOOTHING_WINDOW_FRAMES ), + "trajectoryQualityPolicy": { + "version": trajectory_quality.TRAJECTORY_QUALITY_VERSION, + "resamplingVersion": ( + trajectory_quality.C2_RESAMPLING_POLICY_VERSION + ), + "radialClampVersion": ( + trajectory_quality.RADIAL_CLAMP_POLICY_VERSION + ), + "dynamicsVersion": trajectory_quality.DYNAMICS_POLICY_VERSION, + "finalPositionSmoothingVersion": ( + FINAL_POSITION_SMOOTHING_VERSION + ), + "selectionWeight": TRAJECTORY_DYNAMICS_SELECTION_WEIGHT, + "maximumSelectionPenalty": ( + TRAJECTORY_DYNAMICS_MAXIMUM_SELECTION_PENALTY + ), + "catastrophicMaximumSpeedMetersPerSecond": ( + TRAJECTORY_CATASTROPHIC_MAXIMUM_SPEED_MPS + ), + "catastrophicMaximumAccelerationMetersPerSecondSquared": ( + TRAJECTORY_CATASTROPHIC_MAXIMUM_ACCELERATION_MPS2 + ), + "catastrophicMaximumJerkMetersPerSecondCubed": ( + TRAJECTORY_CATASTROPHIC_MAXIMUM_JERK_MPS3 + ), + "screenCatastrophicMaximumAccelerationUvPerSecondSquaredP99": ( + SCREEN_CATASTROPHIC_MAXIMUM_ACCELERATION_UVPS2_P99 + ), + "screenCatastrophicMaximumJerkUvPerSecondCubedP99": ( + SCREEN_CATASTROPHIC_MAXIMUM_JERK_UVPS3_P99 + ), + "screenCatastrophicMaximumAccelerationUvPerSecondSquared": ( + SCREEN_CATASTROPHIC_MAXIMUM_ACCELERATION_UVPS2 + ), + "screenCatastrophicMaximumJerkUvPerSecondCubed": ( + SCREEN_CATASTROPHIC_MAXIMUM_JERK_UVPS3 + ), + "finalRelativeStepMeters": ( + MAXIMUM_CAMERA_RELATIVE_POSITION_STEP_METERS + ), + "hardGatePolicyVersion": "candidate-dynamics-hard-gate-v2", + }, "trajectoryOffsetLimitsMeters": dict( TRAJECTORY_OFFSET_LIMITS_METERS ), @@ -10194,6 +11252,7 @@ def main() -> None: Path(cut_reference.__file__), Path(planner.__file__), Path(shot_features.__file__), + Path(trajectory_quality.__file__), Path(train.__file__), ] ), @@ -10261,6 +11320,68 @@ def main() -> None: ), "plannerMismatchReplanCount": planner_replan_count, "shotCount": len(shots), + "candidateDynamicsAuditShotCount": len(dynamics_audited_shots), + "candidateDynamicsAuditComplete": ( + len(dynamics_audited_shots) == len(shots) + ), + "candidateDynamicsSafetyPassed": bool(dynamics_audited_shots) + and all( + bool(shot.get("candidateDynamicsCatastrophicPassed", False)) + for shot in dynamics_audited_shots + ), + "candidateDynamicsCostMinMedianP90Max": value_distribution( + [ + float(shot["candidateDynamics"]["cost"]) + for shot in dynamics_audited_shots + ] + ), + "translationJerkP99MetersPerSecondCubedMinMedianP90Max": ( + value_distribution( + [ + float( + shot["candidateDynamics"]["translationMetrics"][ + "jerkMetersPerSecondCubed" + ]["p99"] + ) + for shot in dynamics_audited_shots + ] + ) + ), + "translationJerkMaximumMetersPerSecondCubedMinMedianP90Max": ( + value_distribution( + [ + float( + shot["candidateDynamics"]["translationMetrics"][ + "jerkMetersPerSecondCubed" + ]["maximum"] + ) + for shot in dynamics_audited_shots + ] + ) + ), + "screenJerkP99UvPerSecondCubedMinMedianP90Max": value_distribution( + [ + float( + shot["candidateDynamics"]["screenSpaceMetrics"][ + "jerkUvPerSecondCubed" + ]["p99"] + ) + for shot in dynamics_audited_shots + ] + ), + "trajectoryC2ResamplingAuditShotCount": len( + trajectory_resampling_audited_shots + ), + "trajectoryC2ResamplingFallbackShotCount": sum( + bool(shot["trajectoryResampling"].get("fallbackUsed", False)) + for shot in trajectory_resampling_audited_shots + ), + "radialClampAuditShotCount": len(radial_clamp_audited_shots), + "radialClampPlateauShotCount": sum( + int(shot["radialDistanceClamp"]["lowerPlateauFrameCount"]) > 0 + or int(shot["radialDistanceClamp"]["upperPlateauFrameCount"]) > 0 + for shot in radial_clamp_audited_shots + ), "shotTypeCounts": shot_counts, "shotDurationSecondsMinMedianMax": [ float(durations.min()), diff --git a/CameraAI~/Tools~/CWCameraWorker/_internal/cwai_sources/repository/MachineLearning/CameraDirector/trajectory_quality.py b/CameraAI~/Tools~/CWCameraWorker/_internal/cwai_sources/repository/MachineLearning/CameraDirector/trajectory_quality.py new file mode 100644 index 0000000..62d4491 --- /dev/null +++ b/CameraAI~/Tools~/CWCameraWorker/_internal/cwai_sources/repository/MachineLearning/CameraDirector/trajectory_quality.py @@ -0,0 +1,1088 @@ +"""Trajectory reconstruction, distance limiting, and dynamics scoring helpers. + +The functions in this module are deliberately independent from the camera +generator. They provide small, testable building blocks that can be adopted by +candidate generation and ranking without silently applying a global low-pass +filter to authored camera motion. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Mapping, Sequence + +import numpy as np + + +TRAJECTORY_QUALITY_VERSION = "trajectory-quality-v2" +C2_RESAMPLING_POLICY_VERSION = "clamped-cubic-c2-endpoint-speed-guard-v1" +RADIAL_CLAMP_POLICY_VERSION = "quintic-bound-identity-c2-speed-adaptive-v2" +DYNAMICS_POLICY_VERSION = "translation-screen-space-cost-hard-gate-v2" + + +@dataclass(frozen=True) +class ResamplingGuardPolicy: + """Safety limits used before accepting a cubic position reconstruction.""" + + maximum_axis_overshoot_meters: float = 0.02 + maximum_path_length_ratio: float = 1.12 + maximum_path_length_delta_meters: float = 0.05 + maximum_speed_ratio: float = 1.20 + maximum_speed_delta_meters_per_second: float = 0.25 + maximum_acceleration_ratio: float = 1.35 + maximum_acceleration_delta_meters_per_second_squared: float = 8.0 + maximum_jerk_ratio: float = 1.50 + maximum_jerk_delta_meters_per_second_cubed: float = 350.0 + + def validate(self) -> None: + values = asdict(self) + for name, value in values.items(): + if not np.isfinite(value) or value < 0.0: + raise ValueError(f"{name} must be finite and non-negative") + + +@dataclass(frozen=True) +class CandidateDynamicsPolicy: + """Transparent scales and weights for a candidate dynamics cost.""" + + translation_speed_p99_scale: float = 4.0 + translation_acceleration_p99_scale: float = 12.0 + translation_jerk_p99_scale: float = 300.0 + lateral_reversal_count_scale: float = 3.0 + screen_speed_p99_scale: float = 1.2 + screen_acceleration_p99_scale: float = 12.0 + screen_jerk_p99_scale: float = 300.0 + screen_horizontal_reversal_count_scale: float = 3.0 + translation_speed_weight: float = 0.10 + translation_acceleration_weight: float = 0.75 + translation_jerk_weight: float = 1.00 + lateral_reversal_weight: float = 0.60 + screen_speed_weight: float = 0.15 + screen_acceleration_weight: float = 0.90 + screen_jerk_weight: float = 1.20 + screen_horizontal_reversal_weight: float = 0.80 + maximum_component_ratio: float = 8.0 + maximum_acceptable_cost: float = 6.0 + + def validate(self) -> None: + values = asdict(self) + for name, value in values.items(): + if not np.isfinite(value): + raise ValueError(f"{name} must be finite") + if name.endswith("_scale") and value <= 0.0: + raise ValueError(f"{name} must be positive") + if name.endswith("_weight") and value < 0.0: + raise ValueError(f"{name} must be non-negative") + if self.maximum_component_ratio <= 0.0: + raise ValueError("maximum_component_ratio must be positive") + if self.maximum_acceptable_cost < 0.0: + raise ValueError("maximum_acceptable_cost must be non-negative") + + +def _frame_array( + values: np.ndarray | Sequence[Sequence[float]], + *, + components: int, + name: str, +) -> np.ndarray: + result = np.asarray(values, dtype=np.float64) + if result.ndim != 2 or result.shape[1] != components: + raise ValueError(f"{name} must have shape [frame, {components}]") + if len(result) == 0: + raise ValueError(f"{name} must contain at least one frame") + if not np.all(np.isfinite(result)): + raise ValueError(f"{name} must contain only finite values") + return result + + +def _validate_sample_rate(sample_rate: float) -> float: + value = float(sample_rate) + if not np.isfinite(value) or value <= 0.0: + raise ValueError("sample_rate must be finite and positive") + return value + + +def _linear_resample(values: np.ndarray, output_length: int) -> np.ndarray: + if len(values) == 1: + return np.repeat(values, output_length, axis=0) + source_time = np.linspace(0.0, 1.0, len(values), dtype=np.float64) + output_time = np.linspace(0.0, 1.0, output_length, dtype=np.float64) + columns = [ + np.interp(output_time, source_time, values[:, axis]) + for axis in range(values.shape[1]) + ] + result = np.column_stack(columns) + result[0] = values[0] + result[-1] = values[-1] + return result + + +def _solve_tridiagonal( + lower: np.ndarray, + diagonal: np.ndarray, + upper: np.ndarray, + right_hand_side: np.ndarray, +) -> np.ndarray: + """Solve a tridiagonal system with one or more right-hand sides.""" + + count = len(diagonal) + diagonal_work = np.array(diagonal, dtype=np.float64, copy=True) + rhs_work = np.array(right_hand_side, dtype=np.float64, copy=True) + upper_work = np.array(upper, dtype=np.float64, copy=True) + for index in range(1, count): + multiplier = lower[index - 1] / diagonal_work[index - 1] + diagonal_work[index] -= multiplier * upper_work[index - 1] + rhs_work[index] -= multiplier * rhs_work[index - 1] + + result = np.empty_like(rhs_work) + result[-1] = rhs_work[-1] / diagonal_work[-1] + for index in range(count - 2, -1, -1): + result[index] = ( + rhs_work[index] - upper_work[index] * result[index + 1] + ) / diagonal_work[index] + return result + + +def _clamped_cubic_second_derivatives( + times: np.ndarray, + values: np.ndarray, + start_tangent: np.ndarray, + end_tangent: np.ndarray, +) -> np.ndarray: + count = len(times) + intervals = np.diff(times) + slopes = np.diff(values, axis=0) / intervals[:, None] + diagonal = np.empty(count, dtype=np.float64) + lower = np.empty(count - 1, dtype=np.float64) + upper = np.empty(count - 1, dtype=np.float64) + rhs = np.empty_like(values) + + diagonal[0] = 2.0 * intervals[0] + upper[0] = intervals[0] + rhs[0] = 6.0 * (slopes[0] - start_tangent) + for index in range(1, count - 1): + left_interval = intervals[index - 1] + right_interval = intervals[index] + lower[index - 1] = left_interval + diagonal[index] = 2.0 * (left_interval + right_interval) + upper[index] = right_interval + rhs[index] = 6.0 * (slopes[index] - slopes[index - 1]) + lower[-1] = intervals[-1] + diagonal[-1] = 2.0 * intervals[-1] + rhs[-1] = 6.0 * (end_tangent - slopes[-1]) + return _solve_tridiagonal(lower, diagonal, upper, rhs) + + +def _evaluate_cubic_spline( + times: np.ndarray, + values: np.ndarray, + second_derivatives: np.ndarray, + output_times: np.ndarray, +) -> np.ndarray: + interval_indices = np.searchsorted(times, output_times, side="right") - 1 + interval_indices = np.minimum( + np.maximum(interval_indices, 0), + len(times) - 2, + ) + left_times = times[interval_indices] + right_times = times[interval_indices + 1] + interval_lengths = right_times - left_times + left_weights = (right_times - output_times) / interval_lengths + right_weights = (output_times - left_times) / interval_lengths + left_values = values[interval_indices] + right_values = values[interval_indices + 1] + left_second = second_derivatives[interval_indices] + right_second = second_derivatives[interval_indices + 1] + curvature = ( + ((left_weights**3 - left_weights) * interval_lengths**2)[:, None] + * left_second + + ((right_weights**3 - right_weights) * interval_lengths**2)[:, None] + * right_second + ) / 6.0 + return ( + left_weights[:, None] * left_values + + right_weights[:, None] * right_values + + curvature + ) + + +def _path_length(values: np.ndarray) -> float: + if len(values) < 2: + return 0.0 + return float(np.sum(np.linalg.norm(np.diff(values, axis=0), axis=1))) + + +def _series_statistics(values: np.ndarray) -> dict[str, float]: + data = np.asarray(values, dtype=np.float64).reshape(-1) + if len(data) == 0: + return { + "mean": 0.0, + "p50": 0.0, + "p90": 0.0, + "p99": 0.0, + "maximum": 0.0, + } + return { + "mean": float(np.mean(data)), + "p50": float(np.quantile(data, 0.50)), + "p90": float(np.quantile(data, 0.90)), + "p99": float(np.quantile(data, 0.99)), + "maximum": float(np.max(data)), + } + + +def _vector_dynamics(values: np.ndarray, sample_rate: float) -> dict[str, np.ndarray]: + velocity = np.diff(values, axis=0) * sample_rate + acceleration = np.diff(velocity, axis=0) * sample_rate + jerk = np.diff(acceleration, axis=0) * sample_rate + return { + "velocity": velocity, + "acceleration": acceleration, + "jerk": jerk, + } + + +def _maximum_vector_norm(values: np.ndarray) -> float: + if len(values) == 0: + return 0.0 + return float(np.max(np.linalg.norm(values, axis=1))) + + +def _regressed( + candidate_value: float, + baseline_value: float, + ratio: float, + additive_delta: float, +) -> bool: + return candidate_value > baseline_value * ratio + additive_delta + 1e-12 + + +def resample_position_path_c2( + positions_meters: np.ndarray | Sequence[Sequence[float]], + output_length: int, + *, + sample_rate: float = 60.0, + guard_policy: ResamplingGuardPolicy | None = None, +) -> tuple[np.ndarray, dict[str, object]]: + """Resample a camera path using a guarded clamped C2 cubic spline. + + The clamped spline preserves both endpoint positions and the endpoint + tangents estimated from the source path. It is compared against a linear + baseline at the same output cadence. Any non-finite result, excessive + overshoot, path inflation, or dynamics regression returns the safe linear + baseline instead of smoothing the entire trajectory. + """ + + positions = _frame_array( + positions_meters, + components=3, + name="positions_meters", + ) + sample_rate_value = _validate_sample_rate(sample_rate) + if not isinstance(output_length, (int, np.integer)) or output_length <= 0: + raise ValueError("output_length must be a positive integer") + if len(positions) > 1 and output_length < 2: + raise ValueError( + "output_length must be at least two when the source has two endpoints" + ) + + policy = guard_policy or ResamplingGuardPolicy() + policy.validate() + common_metadata: dict[str, object] = { + "version": TRAJECTORY_QUALITY_VERSION, + "policyVersion": C2_RESAMPLING_POLICY_VERSION, + "sourceFrameCount": int(len(positions)), + "outputFrameCount": int(output_length), + "sampleRate": sample_rate_value, + "guardPolicy": asdict(policy), + } + + if len(positions) == 1: + result = np.repeat(positions, output_length, axis=0) + return result.astype(np.float32), { + **common_metadata, + "method": "constant", + "fallbackUsed": False, + "fallbackReasons": [], + } + if output_length == len(positions): + return positions.astype(np.float32, copy=True), { + **common_metadata, + "method": "identity", + "fallbackUsed": False, + "fallbackReasons": [], + } + + linear = _linear_resample(positions, output_length) + if len(positions) < 3: + return linear.astype(np.float32), { + **common_metadata, + "method": "linear", + "fallbackUsed": True, + "fallbackReasons": ["insufficient_points_for_cubic"], + } + + source_times = np.linspace(0.0, 1.0, len(positions), dtype=np.float64) + output_times = np.linspace(0.0, 1.0, output_length, dtype=np.float64) + source_tangents = np.gradient( + positions, + source_times, + axis=0, + edge_order=2, + ) + start_tangent = source_tangents[0] + end_tangent = source_tangents[-1] + second_derivatives = _clamped_cubic_second_derivatives( + source_times, + positions, + start_tangent, + end_tangent, + ) + cubic = _evaluate_cubic_spline( + source_times, + positions, + second_derivatives, + output_times, + ) + cubic[0] = positions[0] + cubic[-1] = positions[-1] + + source_minimum = np.min(positions, axis=0) + source_maximum = np.max(positions, axis=0) + below = np.maximum(source_minimum - cubic, 0.0) + above = np.maximum(cubic - source_maximum, 0.0) + maximum_axis_overshoot = float(np.max(np.maximum(below, above))) + linear_dynamics = _vector_dynamics(linear, sample_rate_value) + cubic_dynamics = _vector_dynamics(cubic, sample_rate_value) + linear_path_length = _path_length(linear) + cubic_path_length = _path_length(cubic) + linear_maximum_speed = _maximum_vector_norm(linear_dynamics["velocity"]) + cubic_maximum_speed = _maximum_vector_norm(cubic_dynamics["velocity"]) + linear_maximum_acceleration = _maximum_vector_norm( + linear_dynamics["acceleration"] + ) + cubic_maximum_acceleration = _maximum_vector_norm( + cubic_dynamics["acceleration"] + ) + linear_maximum_jerk = _maximum_vector_norm(linear_dynamics["jerk"]) + cubic_maximum_jerk = _maximum_vector_norm(cubic_dynamics["jerk"]) + + fallback_reasons: list[str] = [] + if not np.all(np.isfinite(cubic)): + fallback_reasons.append("non_finite_cubic_result") + if maximum_axis_overshoot > policy.maximum_axis_overshoot_meters + 1e-12: + fallback_reasons.append("axis_overshoot") + if _regressed( + cubic_path_length, + linear_path_length, + policy.maximum_path_length_ratio, + policy.maximum_path_length_delta_meters, + ): + fallback_reasons.append("path_length_regression") + if _regressed( + cubic_maximum_speed, + linear_maximum_speed, + policy.maximum_speed_ratio, + policy.maximum_speed_delta_meters_per_second, + ): + fallback_reasons.append("speed_regression") + if _regressed( + cubic_maximum_acceleration, + linear_maximum_acceleration, + policy.maximum_acceleration_ratio, + policy.maximum_acceleration_delta_meters_per_second_squared, + ): + fallback_reasons.append("acceleration_regression") + if _regressed( + cubic_maximum_jerk, + linear_maximum_jerk, + policy.maximum_jerk_ratio, + policy.maximum_jerk_delta_meters_per_second_cubed, + ): + fallback_reasons.append("jerk_regression") + + accepted = not fallback_reasons + selected = cubic if accepted else linear + metadata = { + **common_metadata, + "method": "clamped_cubic_c2" if accepted else "linear", + "fallbackUsed": not accepted, + "fallbackReasons": fallback_reasons, + "maximumAxisOvershootMeters": maximum_axis_overshoot, + "linearPathLengthMeters": linear_path_length, + "cubicPathLengthMeters": cubic_path_length, + "linearMaximumSpeedMetersPerSecond": linear_maximum_speed, + "cubicMaximumSpeedMetersPerSecond": cubic_maximum_speed, + "linearMaximumAccelerationMetersPerSecondSquared": ( + linear_maximum_acceleration + ), + "cubicMaximumAccelerationMetersPerSecondSquared": ( + cubic_maximum_acceleration + ), + "linearMaximumJerkMetersPerSecondCubed": linear_maximum_jerk, + "cubicMaximumJerkMetersPerSecondCubed": cubic_maximum_jerk, + "sourceEndpointTangentsPerNormalizedProgress": [ + start_tangent.tolist(), + end_tangent.tolist(), + ], + "endpointTangentConstraintMaximumError": 0.0, + } + return selected.astype(np.float32), metadata + + +def quintic_c2_transition( + normalized_progress: np.ndarray | Sequence[float] | float, +) -> np.ndarray: + """Evaluate the monotonic bound-to-identity C2 transition polynomial. + + ``h(t) = 3t^5 - 8t^4 + 6t^3`` has value/first/second derivative + ``(0, 0, 0)`` at the hard bound and ``(1, 1, 0)`` at the identity edge. + """ + + progress = np.asarray(normalized_progress, dtype=np.float64) + if not np.all(np.isfinite(progress)): + raise ValueError("normalized_progress must contain only finite values") + if np.any(progress < 0.0) or np.any(progress > 1.0): + raise ValueError("normalized_progress must be inside [0, 1]") + return ( + 3.0 * progress**5 + - 8.0 * progress**4 + + 6.0 * progress**3 + ) + + +def _effective_transition_widths( + lower_bound: float, + upper_bound: float, + lower_width: float, + upper_width: float, +) -> tuple[float, float]: + span = upper_bound - lower_bound + requested_sum = lower_width + upper_width + if requested_sum <= span: + return lower_width, upper_width + scale = span / requested_sum + return lower_width * scale, upper_width * scale + + +def _boundary_radial_speed( + radii: np.ndarray, + *, + bound: float, + transition_width: float, + sample_rate: float, + lower_bound: bool, +) -> float: + """Return the fastest radial step that reaches a bound's local band. + + Only intervals touching the requested transition band participate. A fast + move elsewhere in the shot must not needlessly consume the available + distance span, while a frame pair that jumps across the band is included. + """ + + if len(radii) < 2: + return 0.0 + left = radii[:-1] + right = radii[1:] + if lower_bound: + local = np.minimum(left, right) <= bound + transition_width + else: + local = np.maximum(left, right) >= bound - transition_width + if not np.any(local): + return 0.0 + return float(np.max(np.abs(right[local] - left[local])) * sample_rate) + + +def _fill_missing_directions( + directions: np.ndarray, + valid: np.ndarray, + fallback_direction: np.ndarray, +) -> tuple[np.ndarray, int]: + missing_count = int(np.count_nonzero(~valid)) + if missing_count == 0: + return directions, 0 + filled = np.array(directions, copy=True) + if np.any(valid): + indices = np.arange(len(directions), dtype=np.float64) + valid_indices = indices[valid] + for axis in range(3): + filled[~valid, axis] = np.interp( + indices[~valid], + valid_indices, + filled[valid, axis], + ) + interpolated_norms = np.linalg.norm(filled[~valid], axis=1) + unresolved = interpolated_norms <= 1e-12 + if np.any(~unresolved): + missing_indices = np.flatnonzero(~valid) + resolved_indices = missing_indices[~unresolved] + filled[resolved_indices] /= interpolated_norms[~unresolved, None] + if np.any(unresolved): + missing_indices = np.flatnonzero(~valid) + filled[missing_indices[unresolved]] = fallback_direction + else: + filled[:] = fallback_direction + return filled, missing_count + + +def smooth_radial_distance_clamp( + relative_positions_meters: np.ndarray | Sequence[Sequence[float]], + lower_bound_meters: float, + upper_bound_meters: float, + *, + lower_transition_meters: float = 0.35, + upper_transition_meters: float = 0.75, + sample_rate: float = 60.0, + minimum_transition_seconds: float = 0.75, + fallback_direction: np.ndarray | Sequence[float] = (0.0, 0.0, 1.0), +) -> tuple[np.ndarray, dict[str, object]]: + """Apply exact radial bounds with speed-adaptive C2 transitions. + + Distances outside a bound sit exactly on that bound. The transition band + uses :func:`quintic_c2_transition`, so it joins the bound plateau and the + unchanged interior with matching value, first derivative, and second + derivative. Its width grows with the radial crossing speed, giving the + blend a minimum temporal duration instead of forcing a fast camera through + a fixed 35 cm spatial band. No hard ``numpy.clip`` is used on the distance + curve. + """ + + positions = _frame_array( + relative_positions_meters, + components=3, + name="relative_positions_meters", + ) + lower_bound = float(lower_bound_meters) + upper_bound = float(upper_bound_meters) + lower_width = float(lower_transition_meters) + upper_width = float(upper_transition_meters) + sample_rate_value = _validate_sample_rate(sample_rate) + transition_seconds = float(minimum_transition_seconds) + scalar_values = ( + lower_bound, + upper_bound, + lower_width, + upper_width, + ) + if not all(np.isfinite(value) for value in scalar_values): + raise ValueError("bounds and transition widths must be finite") + if lower_bound < 0.0 or upper_bound <= lower_bound: + raise ValueError("bounds must satisfy 0 <= lower_bound < upper_bound") + if lower_width < 0.0 or upper_width <= 0.0: + raise ValueError( + "lower_transition_meters must be non-negative and " + "upper_transition_meters must be positive" + ) + if lower_bound > 0.0 and lower_width <= 0.0: + raise ValueError( + "lower_transition_meters must be positive for a positive lower bound" + ) + if not np.isfinite(transition_seconds) or transition_seconds < 0.0: + raise ValueError( + "minimum_transition_seconds must be finite and non-negative" + ) + + fallback = np.asarray(fallback_direction, dtype=np.float64) + if fallback.shape != (3,) or not np.all(np.isfinite(fallback)): + raise ValueError("fallback_direction must be a finite three-vector") + fallback_norm = float(np.linalg.norm(fallback)) + if fallback_norm <= 1e-12: + raise ValueError("fallback_direction must be non-zero") + fallback /= fallback_norm + + radii = np.linalg.norm(positions, axis=1) + lower_radial_speed = _boundary_radial_speed( + radii, + bound=lower_bound, + transition_width=lower_width, + sample_rate=sample_rate_value, + lower_bound=True, + ) + upper_radial_speed = _boundary_radial_speed( + radii, + bound=upper_bound, + transition_width=upper_width, + sample_rate=sample_rate_value, + lower_bound=False, + ) + adaptive_lower_width = max( + lower_width, + lower_radial_speed * transition_seconds, + ) + adaptive_upper_width = max( + upper_width, + upper_radial_speed * transition_seconds, + ) + effective_lower_width, effective_upper_width = _effective_transition_widths( + lower_bound, + upper_bound, + adaptive_lower_width, + adaptive_upper_width, + ) + mapped_radii = np.array(radii, copy=True) + + lower_plateau = radii <= lower_bound + lower_transition = np.zeros(len(radii), dtype=bool) + if effective_lower_width > 0.0: + lower_transition = ( + (radii > lower_bound) + & (radii < lower_bound + effective_lower_width) + ) + progress = ( + radii[lower_transition] - lower_bound + ) / effective_lower_width + mapped_radii[lower_transition] = lower_bound + effective_lower_width * ( + quintic_c2_transition(progress) + ) + mapped_radii[lower_plateau] = lower_bound + + upper_plateau = radii >= upper_bound + upper_transition = ( + (radii > upper_bound - effective_upper_width) + & (radii < upper_bound) + ) + progress = ( + upper_bound - radii[upper_transition] + ) / effective_upper_width + mapped_radii[upper_transition] = upper_bound - effective_upper_width * ( + quintic_c2_transition(progress) + ) + mapped_radii[upper_plateau] = upper_bound + + nonzero = radii > 1e-12 + directions = np.zeros_like(positions) + directions[nonzero] = positions[nonzero] / radii[nonzero, None] + direction_fallback_count = 0 + if lower_bound > 0.0: + directions, direction_fallback_count = _fill_missing_directions( + directions, + nonzero, + fallback, + ) + result = directions * mapped_radii[:, None] + output_radii = np.linalg.norm(result, axis=1) + tolerance = max(1e-9, upper_bound * 1e-9) + if np.any(output_radii < lower_bound - tolerance) or np.any( + output_radii > upper_bound + tolerance + ): + raise RuntimeError("smooth radial clamp failed to enforce its bounds") + + metadata: dict[str, object] = { + "version": TRAJECTORY_QUALITY_VERSION, + "policyVersion": RADIAL_CLAMP_POLICY_VERSION, + "lowerBoundMeters": lower_bound, + "upperBoundMeters": upper_bound, + "requestedLowerTransitionMeters": lower_width, + "requestedUpperTransitionMeters": upper_width, + "adaptiveLowerTransitionMeters": adaptive_lower_width, + "adaptiveUpperTransitionMeters": adaptive_upper_width, + "effectiveLowerTransitionMeters": effective_lower_width, + "effectiveUpperTransitionMeters": effective_upper_width, + "sampleRate": sample_rate_value, + "minimumTransitionSeconds": transition_seconds, + "lowerBoundaryMaximumRadialSpeedMetersPerSecond": ( + lower_radial_speed + ), + "upperBoundaryMaximumRadialSpeedMetersPerSecond": ( + upper_radial_speed + ), + "lowerPlateauFrameCount": int(np.count_nonzero(lower_plateau)), + "lowerTransitionFrameCount": int(np.count_nonzero(lower_transition)), + "upperTransitionFrameCount": int(np.count_nonzero(upper_transition)), + "upperPlateauFrameCount": int(np.count_nonzero(upper_plateau)), + "directionFallbackFrameCount": direction_fallback_count, + "minimumInputDistanceMeters": float(np.min(radii)), + "maximumInputDistanceMeters": float(np.max(radii)), + "minimumOutputDistanceMeters": float(np.min(output_radii)), + "maximumOutputDistanceMeters": float(np.max(output_radii)), + "maximumRadialAdjustmentMeters": float( + np.max(np.abs(mapped_radii - radii)) + ), + } + return result.astype(np.float32), metadata + + +def _persistent_direction_reversals( + scalar_velocity: np.ndarray, + *, + sample_rate: float, + minimum_speed: float, + persistence_seconds: float, +) -> tuple[int, list[int]]: + if minimum_speed < 0.0 or not np.isfinite(minimum_speed): + raise ValueError("minimum_speed must be finite and non-negative") + if persistence_seconds < 0.0 or not np.isfinite(persistence_seconds): + raise ValueError("persistence_seconds must be finite and non-negative") + minimum_run = max(1, int(round(persistence_seconds * sample_rate))) + signs = np.zeros(len(scalar_velocity), dtype=np.int8) + signs[scalar_velocity > minimum_speed] = 1 + signs[scalar_velocity < -minimum_speed] = -1 + + runs: list[tuple[int, int, int]] = [] + index = 0 + while index < len(signs): + sign = int(signs[index]) + if sign == 0: + index += 1 + continue + end = index + 1 + while end < len(signs) and int(signs[end]) == sign: + end += 1 + if end - index >= minimum_run: + runs.append((sign, index, end)) + index = end + + reversal_frames: list[int] = [] + for previous, current in zip(runs, runs[1:]): + if previous[0] != current[0]: + reversal_frames.append(int(current[1] + 1)) + return len(reversal_frames), reversal_frames + + +def translation_dynamics_metrics( + positions_meters: np.ndarray | Sequence[Sequence[float]], + *, + sample_rate: float = 60.0, + minimum_lateral_speed_meters_per_second: float = 0.03, + reversal_persistence_seconds: float = 0.08, +) -> dict[str, object]: + """Measure world-space translation dynamics without changing the path.""" + + positions = _frame_array( + positions_meters, + components=3, + name="positions_meters", + ) + sample_rate_value = _validate_sample_rate(sample_rate) + dynamics = _vector_dynamics(positions, sample_rate_value) + velocity = dynamics["velocity"] + acceleration = dynamics["acceleration"] + jerk = dynamics["jerk"] + speed = np.linalg.norm(velocity, axis=1) + acceleration_magnitude = np.linalg.norm(acceleration, axis=1) + jerk_magnitude = np.linalg.norm(jerk, axis=1) + horizontal_speed = ( + np.linalg.norm(velocity[:, (0, 2)], axis=1) + if len(velocity) + else np.empty(0, dtype=np.float64) + ) + vertical_speed = ( + np.abs(velocity[:, 1]) + if len(velocity) + else np.empty(0, dtype=np.float64) + ) + lateral_speed = ( + np.abs(velocity[:, 0]) + if len(velocity) + else np.empty(0, dtype=np.float64) + ) + lateral_acceleration = ( + np.abs(acceleration[:, 0]) + if len(acceleration) + else np.empty(0, dtype=np.float64) + ) + lateral_jerk = ( + np.abs(jerk[:, 0]) + if len(jerk) + else np.empty(0, dtype=np.float64) + ) + reversal_count, reversal_frames = _persistent_direction_reversals( + velocity[:, 0] if len(velocity) else np.empty(0, dtype=np.float64), + sample_rate=sample_rate_value, + minimum_speed=minimum_lateral_speed_meters_per_second, + persistence_seconds=reversal_persistence_seconds, + ) + displacement = positions[-1] - positions[0] + return { + "version": TRAJECTORY_QUALITY_VERSION, + "metricSpace": "world_translation_meters", + "frameCount": int(len(positions)), + "sampleRate": sample_rate_value, + "durationSeconds": float((len(positions) - 1) / sample_rate_value), + "pathLengthMeters": _path_length(positions), + "netDisplacementMeters": float(np.linalg.norm(displacement)), + "speedMetersPerSecond": _series_statistics(speed), + "accelerationMetersPerSecondSquared": _series_statistics( + acceleration_magnitude + ), + "jerkMetersPerSecondCubed": _series_statistics(jerk_magnitude), + "horizontalSpeedMetersPerSecond": _series_statistics(horizontal_speed), + "verticalSpeedMetersPerSecond": _series_statistics(vertical_speed), + "lateralSpeedMetersPerSecond": _series_statistics(lateral_speed), + "lateralAccelerationMetersPerSecondSquared": _series_statistics( + lateral_acceleration + ), + "lateralJerkMetersPerSecondCubed": _series_statistics(lateral_jerk), + "persistentLateralReversalCount": reversal_count, + "persistentLateralReversalFrames": reversal_frames, + "reversalMinimumSpeedMetersPerSecond": float( + minimum_lateral_speed_meters_per_second + ), + "reversalPersistenceSeconds": float(reversal_persistence_seconds), + } + + +def screen_space_dynamics_metrics( + screen_positions_uv: np.ndarray | Sequence[Sequence[float]], + *, + sample_rate: float = 60.0, + minimum_horizontal_speed_uv_per_second: float = 0.01, + reversal_persistence_seconds: float = 0.08, +) -> dict[str, object]: + """Measure image-plane motion in normalized UV coordinates.""" + + positions = _frame_array( + screen_positions_uv, + components=2, + name="screen_positions_uv", + ) + sample_rate_value = _validate_sample_rate(sample_rate) + dynamics = _vector_dynamics(positions, sample_rate_value) + velocity = dynamics["velocity"] + acceleration = dynamics["acceleration"] + jerk = dynamics["jerk"] + speed = np.linalg.norm(velocity, axis=1) + acceleration_magnitude = np.linalg.norm(acceleration, axis=1) + jerk_magnitude = np.linalg.norm(jerk, axis=1) + horizontal_speed = ( + np.abs(velocity[:, 0]) + if len(velocity) + else np.empty(0, dtype=np.float64) + ) + horizontal_acceleration = ( + np.abs(acceleration[:, 0]) + if len(acceleration) + else np.empty(0, dtype=np.float64) + ) + horizontal_jerk = ( + np.abs(jerk[:, 0]) + if len(jerk) + else np.empty(0, dtype=np.float64) + ) + reversal_count, reversal_frames = _persistent_direction_reversals( + velocity[:, 0] if len(velocity) else np.empty(0, dtype=np.float64), + sample_rate=sample_rate_value, + minimum_speed=minimum_horizontal_speed_uv_per_second, + persistence_seconds=reversal_persistence_seconds, + ) + return { + "version": TRAJECTORY_QUALITY_VERSION, + "metricSpace": "normalized_screen_uv", + "frameCount": int(len(positions)), + "sampleRate": sample_rate_value, + "durationSeconds": float((len(positions) - 1) / sample_rate_value), + "pathLengthUv": _path_length(positions), + "speedUvPerSecond": _series_statistics(speed), + "accelerationUvPerSecondSquared": _series_statistics( + acceleration_magnitude + ), + "jerkUvPerSecondCubed": _series_statistics(jerk_magnitude), + "horizontalSpeedUvPerSecond": _series_statistics(horizontal_speed), + "horizontalAccelerationUvPerSecondSquared": _series_statistics( + horizontal_acceleration + ), + "horizontalJerkUvPerSecondCubed": _series_statistics(horizontal_jerk), + "persistentHorizontalReversalCount": reversal_count, + "persistentHorizontalReversalFrames": reversal_frames, + "reversalMinimumSpeedUvPerSecond": float( + minimum_horizontal_speed_uv_per_second + ), + "reversalPersistenceSeconds": float(reversal_persistence_seconds), + } + + +def _required_metric( + metrics: Mapping[str, object], + group: str, + statistic: str, +) -> float: + group_value = metrics.get(group) + if not isinstance(group_value, Mapping) or statistic not in group_value: + raise ValueError(f"metrics are missing {group}.{statistic}") + value = float(group_value[statistic]) + if not np.isfinite(value) or value < 0.0: + raise ValueError(f"metrics value {group}.{statistic} is invalid") + return value + + +def candidate_dynamics_cost( + translation_metrics: Mapping[str, object], + screen_space_metrics: Mapping[str, object] | None = None, + *, + policy: CandidateDynamicsPolicy | None = None, +) -> tuple[float, dict[str, object]]: + """Return a transparent dynamics cost for a generated camera candidate. + + High-frequency acceleration, jerk, and persistent lateral reversals receive + more weight than steady speed. This allows deliberate low-frequency camera + travel while making left/right chatter expensive. + """ + + selected_policy = policy or CandidateDynamicsPolicy() + selected_policy.validate() + raw_components = [ + ( + "translationSpeedP99", + _required_metric( + translation_metrics, + "speedMetersPerSecond", + "p99", + ), + selected_policy.translation_speed_p99_scale, + selected_policy.translation_speed_weight, + ), + ( + "translationAccelerationP99", + _required_metric( + translation_metrics, + "accelerationMetersPerSecondSquared", + "p99", + ), + selected_policy.translation_acceleration_p99_scale, + selected_policy.translation_acceleration_weight, + ), + ( + "translationJerkP99", + _required_metric( + translation_metrics, + "jerkMetersPerSecondCubed", + "p99", + ), + selected_policy.translation_jerk_p99_scale, + selected_policy.translation_jerk_weight, + ), + ( + "persistentLateralReversalCount", + float(translation_metrics.get("persistentLateralReversalCount", 0)), + selected_policy.lateral_reversal_count_scale, + selected_policy.lateral_reversal_weight, + ), + ] + if screen_space_metrics is not None: + raw_components.extend( + [ + ( + "screenSpeedP99", + _required_metric( + screen_space_metrics, + "speedUvPerSecond", + "p99", + ), + selected_policy.screen_speed_p99_scale, + selected_policy.screen_speed_weight, + ), + ( + "screenAccelerationP99", + _required_metric( + screen_space_metrics, + "accelerationUvPerSecondSquared", + "p99", + ), + selected_policy.screen_acceleration_p99_scale, + selected_policy.screen_acceleration_weight, + ), + ( + "screenJerkP99", + _required_metric( + screen_space_metrics, + "jerkUvPerSecondCubed", + "p99", + ), + selected_policy.screen_jerk_p99_scale, + selected_policy.screen_jerk_weight, + ), + ( + "persistentHorizontalReversalCount", + float( + screen_space_metrics.get( + "persistentHorizontalReversalCount", + 0, + ) + ), + selected_policy.screen_horizontal_reversal_count_scale, + selected_policy.screen_horizontal_reversal_weight, + ), + ] + ) + + components: dict[str, dict[str, float]] = {} + total_cost = 0.0 + for name, value, scale, weight in raw_components: + if not np.isfinite(value) or value < 0.0: + raise ValueError(f"candidate dynamics component {name} is invalid") + normalized = min(value / scale, selected_policy.maximum_component_ratio) + contribution = weight * normalized**2 + components[name] = { + "value": float(value), + "scale": float(scale), + "weight": float(weight), + "normalized": float(normalized), + "cost": float(contribution), + } + total_cost += contribution + + details: dict[str, object] = { + "version": TRAJECTORY_QUALITY_VERSION, + "policyVersion": DYNAMICS_POLICY_VERSION, + "cost": float(total_cost), + "passed": bool(total_cost <= selected_policy.maximum_acceptable_cost), + "maximumAcceptableCost": float( + selected_policy.maximum_acceptable_cost + ), + "components": components, + "screenSpaceIncluded": screen_space_metrics is not None, + "policy": asdict(selected_policy), + } + return float(total_cost), details + + +def evaluate_candidate_dynamics( + positions_meters: np.ndarray | Sequence[Sequence[float]], + screen_positions_uv: np.ndarray | Sequence[Sequence[float]] | None = None, + *, + sample_rate: float = 60.0, + policy: CandidateDynamicsPolicy | None = None, +) -> dict[str, object]: + """Measure and score a candidate in one call.""" + + translation = translation_dynamics_metrics( + positions_meters, + sample_rate=sample_rate, + ) + screen = None + if screen_positions_uv is not None: + screen = screen_space_dynamics_metrics( + screen_positions_uv, + sample_rate=sample_rate, + ) + if screen["frameCount"] != translation["frameCount"]: + raise ValueError( + "positions_meters and screen_positions_uv must have equal frame counts" + ) + _, cost_details = candidate_dynamics_cost( + translation, + screen, + policy=policy, + ) + return { + **cost_details, + "translationMetrics": translation, + "screenSpaceMetrics": screen, + } + + +__all__ = [ + "C2_RESAMPLING_POLICY_VERSION", + "DYNAMICS_POLICY_VERSION", + "RADIAL_CLAMP_POLICY_VERSION", + "TRAJECTORY_QUALITY_VERSION", + "CandidateDynamicsPolicy", + "ResamplingGuardPolicy", + "candidate_dynamics_cost", + "evaluate_candidate_dynamics", + "quintic_c2_transition", + "resample_position_path_c2", + "screen_space_dynamics_metrics", + "smooth_radial_distance_clamp", + "translation_dynamics_metrics", +] diff --git a/CameraAI~/package.json b/CameraAI~/package.json index dfa83a4..403331a 100644 --- a/CameraAI~/package.json +++ b/CameraAI~/package.json @@ -1,6 +1,6 @@ { "name": "com.mingle.cw-ai", - "version": "0.4.7", + "version": "0.4.8", "displayName": "Mingle Camera Work AI", "description": "Self-contained high-quality Unity Timeline camera generation with an embedded prepared reference library, per-shot editable clips, and A/B review tools.", "unity": "6000.0",