From 9da73cc6e37c9d21013232db0e17f8aaa478d355 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Wed, 15 Jul 2026 19:58:02 +0800 Subject: [PATCH] fix(humanize): preserve gaussian magnitude in jitter_point; unify swipe return shape --- tests/test_humanize.py | 22 ++++++++++++++++++++++ tools/humanize.py | 28 ++++++++++++++++++---------- tools/swipe.py | 2 ++ 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/tests/test_humanize.py b/tests/test_humanize.py index 1276aaa..edba7b3 100644 --- a/tests/test_humanize.py +++ b/tests/test_humanize.py @@ -27,6 +27,28 @@ def test_jitter_point_centered_on_input(): assert abs(sum(xs) / len(xs)) < 0.3 +def test_jitter_point_concentrated_near_target(): + """Regression: jitter must be polar-Gaussian (concentrated near target), + not uniform-on-circle (all points at exactly ``radius``). + + For a folded Gaussian with sigma=radius/2, the expected mean distance is + ~0.8*sigma ~= 0.4*radius. Using ``< 0.7*radius`` gives a safe margin that + fails the previous reprojection bug (mean distance == radius exactly). + """ + rng = random.Random(1234) + radius = 5.0 + n = 5000 + distances = [ + math.hypot(x - 0.0, y - 0.0) + for x, y in (jitter_point(0.0, 0.0, radius=radius, rng=rng) for _ in range(n)) + ] + mean_distance = sum(distances) / n + assert mean_distance < radius * 0.7, ( + f"mean distance {mean_distance:.3f} is too large; jitter looks like " + "uniform-on-circle rather than polar-Gaussian" + ) + + def test_jitter_duration_within_spread(): rng = random.Random(3) for _ in range(200): diff --git a/tools/humanize.py b/tools/humanize.py index ff032dc..8628a4a 100644 --- a/tools/humanize.py +++ b/tools/humanize.py @@ -90,23 +90,31 @@ def set_rng(rng: random.Random | None) -> None: def jitter_point( x: float, y: float, *, radius: float, rng: random.Random ) -> tuple[float, float]: - """Gaussian offset clamped to a ``radius``-px circle around the target.""" + """Polar-Gaussian offset around the target. + + Magnitude is drawn from a folded Gaussian with sigma=radius/2, then + capped at ``radius``. The angle is uniform on [0, 2*pi). This concentrates + jittered points near the target rather than uniformly on the circle edge, + matching how humans tap close to (but not exactly on) a button center. + """ r = abs(rng.gauss(0.0, radius / 2.0)) r = min(r, radius) angle = rng.uniform(0, 2 * math.pi) dx = r * math.cos(angle) dy = r * math.sin(angle) - # Reproject to exact radius to absorb FP drift in cos/sin - d = math.sqrt(dx * dx + dy * dy) - if d > 0: - dx = dx / d * radius - dy = dy / d * radius + # Guard against cos/sin FP drift pushing distance slightly past radius. + fd = math.hypot(dx, dy) + if fd > radius: + scale = radius / fd + dx *= scale + dy *= scale + # Final clamp: hypot of the returned offset may overshoot by ulps; tighten + # to ``radius - 1e-10`` to keep ``<= radius`` after subtraction/hypot. fx = x + dx fy = y + dy - fd = math.hypot(fx - x, fy - y) - if fd > radius: - # Scale to a slightly tighter radius to absorb FP rounding in addition - scale = (radius - 1e-10) / fd + fd_final = math.hypot(fx - x, fy - y) + if fd_final > radius: + scale = (radius - 1e-10) / fd_final fx = x + dx * scale fy = y + dy * scale return (fx, fy) diff --git a/tools/swipe.py b/tools/swipe.py index 8658fec..6b3abc4 100644 --- a/tools/swipe.py +++ b/tools/swipe.py @@ -35,6 +35,8 @@ def swipe( return { "ok": True, "action": "swipe", + "start": {"x": start_x, "y": start_y}, + "end": {"x": end_x, "y": end_y}, "waypoints": waypoints, "duration_ms": dms, }