From f2c8aa547bf1d24c35c3f1e9767092c5bd570d02 Mon Sep 17 00:00:00 2001 From: Ulzii Otgonbaatar Date: Mon, 27 Apr 2026 13:57:46 -0600 Subject: [PATCH] Add arc-length easing to mouse trajectory for natural velocity profile The previous tweenPoints used easeOutQuad on array indices, producing near-constant pixel distances between consecutive points (~15px per step). With Gaussian timing jitter (PR #228), velocity = distance/time still has low variance because the distance component is constant. Real human mouse movement follows a bell-shaped velocity profile: slow acceleration from rest, peak speed in the middle, deceleration to the target (Fitts's law / minimum-jerk model). This requires varying the pixel distance per step, not just the timing. Replace index-based easeOutQuad with arc-length-based easeInOutCubic: - Build cumulative arc-length table along the Bezier curve - Sample at non-uniform arc-length positions via easeInOutCubic - Binary search + linear interpolation for sub-segment precision Result: steps range from ~3px at endpoints to ~25px in the middle, creating the distance variance that produces human-like velocity variance when combined with Gaussian timing. Made-with: Cursor --- server/lib/mousetrajectory/mousetrajectory.go | 69 +++++++++++++++---- .../mousetrajectory/mousetrajectory_test.go | 49 +++++++++++++ 2 files changed, 106 insertions(+), 12 deletions(-) diff --git a/server/lib/mousetrajectory/mousetrajectory.go b/server/lib/mousetrajectory/mousetrajectory.go index d4d7988b..5407804a 100644 --- a/server/lib/mousetrajectory/mousetrajectory.go +++ b/server/lib/mousetrajectory/mousetrajectory.go @@ -288,17 +288,28 @@ func normalDist(rng *rand.Rand, mean, stdDev float64) float64 { return mean + stdDev*math.Sqrt(-2*math.Log(u1))*math.Cos(2*math.Pi*u2) } -func (t *HumanizeMouseTrajectory) easeOutQuad(n float64) float64 { - return -n * (n - 2) +// easeInOutCubic produces slow-fast-slow movement matching human motor control. +// t ∈ [0,1] → [0,1] with zero velocity at endpoints and peak velocity at t=0.5. +func easeInOutCubic(t float64) float64 { + if t < 0.5 { + return 4 * t * t * t + } + return 1 - math.Pow(-2*t+2, 3)/2 } func (t *HumanizeMouseTrajectory) tweenPoints(points [][2]float64, opts *Options) [][2]float64 { - var totalLength float64 + if len(points) < 2 { + return points + } + + // Build cumulative arc-length table from source points. + cumLen := make([]float64, len(points)) for i := 1; i < len(points); i++ { dx := points[i][0] - points[i-1][0] dy := points[i][1] - points[i-1][1] - totalLength += math.Sqrt(dx*dx + dy*dy) + cumLen[i] = cumLen[i-1] + math.Sqrt(dx*dx+dy*dy) } + totalLength := cumLen[len(cumLen)-1] targetPoints := int(math.Min( float64(defaultMaxPoints), @@ -319,18 +330,52 @@ func (t *HumanizeMouseTrajectory) tweenPoints(points [][2]float64, opts *Options targetPoints = 2 } + if totalLength < 1e-9 { + res := make([][2]float64, targetPoints) + for i := range res { + res[i] = points[0] + } + return res + } + + // Sample at non-uniform arc-length positions using ease-in-out. + // The previous easeOutQuad sampled by array index, producing + // near-constant pixel distances between consecutive output points. + // This version samples along the curve's physical arc length with + // an ease-in-out profile: small pixel steps at start/end (slow + // velocity), large pixel steps in the middle (fast velocity). + // This matches the bell-shaped velocity profile of real human + // arm movements (Fitts's law / minimum-jerk trajectory model). res := make([][2]float64, targetPoints) - for i := 0; i < targetPoints; i++ { + res[0] = points[0] + res[targetPoints-1] = points[len(points)-1] + + for i := 1; i < targetPoints-1; i++ { tt := float64(i) / float64(targetPoints-1) - easedT := t.easeOutQuad(tt) - idx := int(easedT * float64(len(points)-1)) - if idx < 0 { - idx = 0 + easedT := easeInOutCubic(tt) + targetLen := easedT * totalLength + + // Binary search for the segment containing targetLen. + lo, hi := 0, len(cumLen)-1 + for lo < hi-1 { + mid := (lo + hi) / 2 + if cumLen[mid] < targetLen { + lo = mid + } else { + hi = mid + } + } + + // Interpolate within the segment [lo, hi]. + segLen := cumLen[hi] - cumLen[lo] + var frac float64 + if segLen > 1e-9 { + frac = (targetLen - cumLen[lo]) / segLen } - if idx >= len(points) { - idx = len(points) - 1 + res[i] = [2]float64{ + points[lo][0] + frac*(points[hi][0]-points[lo][0]), + points[lo][1] + frac*(points[hi][1]-points[lo][1]), } - res[i] = points[idx] } return res } diff --git a/server/lib/mousetrajectory/mousetrajectory_test.go b/server/lib/mousetrajectory/mousetrajectory_test.go index bef1c02e..027f4ad9 100644 --- a/server/lib/mousetrajectory/mousetrajectory_test.go +++ b/server/lib/mousetrajectory/mousetrajectory_test.go @@ -163,6 +163,55 @@ func TestGenerateMultiSegmentTrajectory_SinglePoint(t *testing.T) { assert.Equal(t, 100, result.Points[0][1]) } +func TestHumanizeMouseTrajectory_VelocityProfile(t *testing.T) { + // Arc-length easing should produce a bell-shaped velocity profile: + // small pixel steps at start/end, large steps in the middle. + traj := NewHumanizeMouseTrajectoryWithSeed(0, 0, 400, 0, 42) + points := traj.GetPointsInt() + require.GreaterOrEqual(t, len(points), 10) + + distances := make([]float64, len(points)-1) + for i := 1; i < len(points); i++ { + dx := float64(points[i][0] - points[i-1][0]) + dy := float64(points[i][1] - points[i-1][1]) + distances[i-1] = math.Sqrt(dx*dx + dy*dy) + } + + // First quarter should have smaller average step than middle half. + n := len(distances) + q1End := n / 4 + midStart := n / 4 + midEnd := 3 * n / 4 + + var sumFirst, sumMid float64 + for i := 0; i < q1End; i++ { + sumFirst += distances[i] + } + for i := midStart; i < midEnd; i++ { + sumMid += distances[i] + } + avgFirst := sumFirst / float64(q1End) + avgMid := sumMid / float64(midEnd-midStart) + + assert.Greater(t, avgMid, avgFirst, + "middle steps (avg=%.2f) should be larger than initial steps (avg=%.2f) for acceleration profile", + avgMid, avgFirst) + + // Step distance variance should be substantial (not near-constant). + var wN int + var wMean, wM2 float64 + for _, d := range distances { + wN++ + delta := d - wMean + wMean += delta / float64(wN) + wM2 += delta * (d - wMean) + } + distVariance := wM2 / float64(wN-1) + + assert.Greater(t, distVariance, 5.0, + "step distance variance (%.2f) should be substantial for human-like velocity profile", distVariance) +} + func TestGenerateMultiSegmentTrajectory_ContinuousPath(t *testing.T) { waypoints := [][2]int{{100, 100}, {500, 500}, {900, 100}, {1300, 500}} result := GenerateMultiSegmentTrajectory(waypoints, 1920, 1080, nil)