feat(humanize): add coordinate/duration/swipe-path jitter module
This commit is contained in:
@@ -0,0 +1,87 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
|
||||||
|
|
||||||
|
from tools.humanize import (
|
||||||
|
HumanizeConfig,
|
||||||
|
jitter_duration,
|
||||||
|
jitter_point,
|
||||||
|
load_humanize_config,
|
||||||
|
set_rng,
|
||||||
|
swipe_waypoints,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_jitter_point_stays_within_radius():
|
||||||
|
rng = random.Random(42)
|
||||||
|
for _ in range(500):
|
||||||
|
x, y = jitter_point(100, 200, radius=5.0, rng=rng)
|
||||||
|
assert math.hypot(x - 100, y - 200) <= 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_jitter_point_centered_on_input():
|
||||||
|
rng = random.Random(0)
|
||||||
|
xs = [jitter_point(0, 0, radius=4.0, rng=rng)[0] for _ in range(4000)]
|
||||||
|
assert abs(sum(xs) / len(xs)) < 0.3
|
||||||
|
|
||||||
|
|
||||||
|
def test_jitter_duration_within_spread():
|
||||||
|
rng = random.Random(3)
|
||||||
|
for _ in range(200):
|
||||||
|
v = jitter_duration(1000, spread=0.15, rng=rng)
|
||||||
|
assert 850 <= v <= 1150
|
||||||
|
|
||||||
|
|
||||||
|
def test_jitter_duration_never_zero():
|
||||||
|
rng = random.Random(9)
|
||||||
|
for _ in range(100):
|
||||||
|
assert jitter_duration(1, spread=0.5, rng=rng) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_swipe_waypoints_endpoints_exact_interior_offset():
|
||||||
|
rng = random.Random(7)
|
||||||
|
pts = swipe_waypoints((0, 0), (100, 0), curvature=0.2, n=4, rng=rng)
|
||||||
|
assert pts[0] == (0, 0)
|
||||||
|
assert pts[-1] == (100, 0)
|
||||||
|
assert len(pts) == 6 # start + 4 interior + end
|
||||||
|
assert any(abs(p[1]) > 1e-6 for p in pts[1:-1])
|
||||||
|
|
||||||
|
|
||||||
|
def test_swipe_waypoints_degenerate_short_path_returns_two_points():
|
||||||
|
rng = random.Random(1)
|
||||||
|
pts = swipe_waypoints((5, 5), (5, 5), curvature=0.2, n=8, rng=rng)
|
||||||
|
assert pts == [(5, 5), (5, 5)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_defaults_when_unset():
|
||||||
|
cfg = load_humanize_config(env={})
|
||||||
|
assert cfg == HumanizeConfig()
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_disabled():
|
||||||
|
cfg = load_humanize_config(env={"APEX_HUMANIZE_ENABLED": "false"})
|
||||||
|
assert cfg.enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_overrides():
|
||||||
|
cfg = load_humanize_config(
|
||||||
|
env={
|
||||||
|
"APEX_HUMANIZE_TAP_RADIUS_PX": "9",
|
||||||
|
"APEX_HUMANIZE_SWIPE_CURVATURE": "0.3",
|
||||||
|
"APEX_HUMANIZE_SWIPE_WAYPOINTS": "12",
|
||||||
|
"APEX_HUMANIZE_DURATION_SPREAD": "0.2",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert cfg.tap_radius_px == 9.0
|
||||||
|
assert cfg.swipe_curvature == 0.3
|
||||||
|
assert cfg.swipe_waypoints == 12
|
||||||
|
assert cfg.duration_spread == 0.2
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_rng_makes_output_reproducible():
|
||||||
|
set_rng(random.Random(99))
|
||||||
|
a = jitter_point(0, 0, radius=5.0, rng=random.Random(99))
|
||||||
|
set_rng(None) # reset to default rng
|
||||||
|
assert isinstance(a, tuple) and len(a) == 2
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
ENABLED_ENV = "APEX_HUMANIZE_ENABLED"
|
||||||
|
TAP_RADIUS_ENV = "APEX_HUMANIZE_TAP_RADIUS_PX"
|
||||||
|
SWIPE_CURVATURE_ENV = "APEX_HUMANIZE_SWIPE_CURVATURE"
|
||||||
|
SWIPE_WAYPOINTS_ENV = "APEX_HUMANIZE_SWIPE_WAYPOINTS"
|
||||||
|
DURATION_SPREAD_ENV = "APEX_HUMANIZE_DURATION_SPREAD"
|
||||||
|
|
||||||
|
DEFAULT_TAP_RADIUS_PX = 5.0
|
||||||
|
DEFAULT_SWIPE_CURVATURE = 0.15
|
||||||
|
DEFAULT_SWIPE_WAYPOINTS = 8
|
||||||
|
DEFAULT_DURATION_SPREAD = 0.15
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HumanizeConfig:
|
||||||
|
enabled: bool = True
|
||||||
|
tap_radius_px: float = DEFAULT_TAP_RADIUS_PX
|
||||||
|
swipe_curvature: float = DEFAULT_SWIPE_CURVATURE
|
||||||
|
swipe_waypoints: int = DEFAULT_SWIPE_WAYPOINTS
|
||||||
|
duration_spread: float = DEFAULT_DURATION_SPREAD
|
||||||
|
|
||||||
|
|
||||||
|
def load_humanize_config(env: Mapping[str, str] | None = None) -> HumanizeConfig:
|
||||||
|
values = env or os.environ
|
||||||
|
return HumanizeConfig(
|
||||||
|
enabled=_parse_bool(values.get(ENABLED_ENV), default=True),
|
||||||
|
tap_radius_px=_parse_float(values.get(TAP_RADIUS_ENV), DEFAULT_TAP_RADIUS_PX),
|
||||||
|
swipe_curvature=_parse_float(
|
||||||
|
values.get(SWIPE_CURVATURE_ENV), DEFAULT_SWIPE_CURVATURE
|
||||||
|
),
|
||||||
|
swipe_waypoints=_parse_int(
|
||||||
|
values.get(SWIPE_WAYPOINTS_ENV), DEFAULT_SWIPE_WAYPOINTS
|
||||||
|
),
|
||||||
|
duration_spread=_parse_float(
|
||||||
|
values.get(DURATION_SPREAD_ENV), DEFAULT_DURATION_SPREAD
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_bool(value: str | None, *, default: bool) -> bool:
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
return value.strip().lower() in {"1", "true", "yes", "on", "enabled"}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_float(value: str | None, default: float) -> float:
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_int(value: str | None, default: int) -> int:
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
_rng: random.Random | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_rng() -> random.Random:
|
||||||
|
global _rng
|
||||||
|
if _rng is None:
|
||||||
|
_rng = random.Random()
|
||||||
|
return _rng
|
||||||
|
|
||||||
|
|
||||||
|
def set_rng(rng: random.Random | None) -> None:
|
||||||
|
"""Inject a seeded rng (tests). Pass ``None`` to reset to the default."""
|
||||||
|
global _rng
|
||||||
|
_rng = rng
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
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
|
||||||
|
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
|
||||||
|
fx = x + dx * scale
|
||||||
|
fy = y + dy * scale
|
||||||
|
return (fx, fy)
|
||||||
|
|
||||||
|
|
||||||
|
def jitter_duration(
|
||||||
|
value_ms: int, *, spread: float, rng: random.Random
|
||||||
|
) -> int:
|
||||||
|
"""Uniform jitter within ``value_ms * spread``; floored at 1ms."""
|
||||||
|
delta = value_ms * spread
|
||||||
|
return max(1, int(value_ms + rng.uniform(-delta, delta)))
|
||||||
|
|
||||||
|
|
||||||
|
def swipe_waypoints(
|
||||||
|
start: tuple[float, float],
|
||||||
|
end: tuple[float, float],
|
||||||
|
*,
|
||||||
|
curvature: float,
|
||||||
|
n: int,
|
||||||
|
rng: random.Random,
|
||||||
|
) -> list[tuple[float, float]]:
|
||||||
|
"""Return start + ``n`` interior + end points. Interior points deviate
|
||||||
|
perpendicular to the path by gaussian noise scaled to ``curvature *
|
||||||
|
path_length``. Degenerate (zero-length) path returns ``[start, end]``."""
|
||||||
|
sx, sy = start
|
||||||
|
ex, ey = end
|
||||||
|
dx = ex - sx
|
||||||
|
dy = ey - sy
|
||||||
|
length = math.hypot(dx, dy)
|
||||||
|
if length < 1e-6 or n <= 0:
|
||||||
|
return [start, end]
|
||||||
|
ux, uy = dx / length, dy / length
|
||||||
|
px, py = -uy, ux # perpendicular unit vector
|
||||||
|
amplitude = length * curvature
|
||||||
|
points: list[tuple[float, float]] = [start]
|
||||||
|
for i in range(1, n + 1):
|
||||||
|
t = i / (n + 1)
|
||||||
|
bx = sx + dx * t
|
||||||
|
by = sy + dy * t
|
||||||
|
offset = rng.gauss(0.0, amplitude / 2.0)
|
||||||
|
points.append((bx + px * offset, by + py * offset))
|
||||||
|
points.append(end)
|
||||||
|
return points
|
||||||
Reference in New Issue
Block a user