Skip to content

Commit

Permalink
Added mouse movement based on bezier curves
Browse files Browse the repository at this point in the history
  • Loading branch information
patrikoss committed Feb 15, 2018
0 parents commit f3f7cc2
Show file tree
Hide file tree
Showing 12 changed files with 434 additions and 0 deletions.
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# pyclick
This is a library for generating human-like mouse movements.
The movements are based on the concept of bezier curve:
https://en.wikipedia.org/wiki/B%C3%A9zier_curve

### Simple Example:
```
from humanclicker import HumanClicker
# initialize HumanClicker object
hc = HumanClicker()
# move the mouse to position (100,100) on the screen in approximately 2 seconds
hc.move((100,100),2)
# mouse click(left button)
hc.click()
```
You can also customize the mouse curve by passing a HumanCurve to HumanClicker. You can control the the
- number of internal knots, to change the overall shape of the curve,
- distortion to simulate shivering
- tween to simulate acceleration and speed of movement

3 changes: 3 additions & 0 deletions pyclick/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
name = 'pyclick'
from pyclick.humanclicker import HumanClicker
from pyclick.humancurve import HumanCurve
41 changes: 41 additions & 0 deletions pyclick/_beziercurve.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import math

class BezierCurve():
@staticmethod
def binomial(n, k):
"""Returns the binomial coefficient "n choose k" """
return math.factorial(n) / float(math.factorial(k) * math.factorial(n - k))

@staticmethod
def bernsteinPolynomialPoint(x, i, n):
"""Calculate the i-th component of a bernstein polynomial of degree n"""
return BezierCurve.binomial(n, i) * (x ** i) * ((1 - x) ** (n - i))

@staticmethod
def bernsteinPolynomial(points):
"""
Given list of control points, returns a function, which given a point [0,1] returns
a point in the bezier curve described by these points
"""
def bern(t):
n = len(points) - 1
x = y = 0
for i, point in enumerate(points):
bern = BezierCurve.bernsteinPolynomialPoint(t, i, n)
x += point[0] * bern
y += point[1] * bern
return x, y
return bern

@staticmethod
def curvePoints(n, points):
"""
Given list of control points, returns n points in the bezier curve,
described by these points
"""
curvePoints = []
bernstein_polynomial = BezierCurve.bernsteinPolynomial(points)
for i in range(n):
t = i / (n - 1)
curvePoints += bernstein_polynomial(t),
return curvePoints
13 changes: 13 additions & 0 deletions pyclick/_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import numpy as np

def isNumeric(val):
return isinstance(val, (float, int, np.int32, np.int64, np.float32, np.float64))

def isListOfPoints(l):
if not isinstance(l, list):
return False
try:
isPoint = lambda p: ((len(p) == 2) and isNumeric(p[0]) and isNumeric(p[1]))
return all(map(isPoint, l))
except (KeyError, TypeError) as e:
return False
33 changes: 33 additions & 0 deletions pyclick/humanclicker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import pyautogui
from pyclick.humancurve import HumanCurve

def setup_pyautogui():
# Any duration less than this is rounded to 0.0 to instantly move the mouse.
pyautogui.MINIMUM_DURATION = 0 # Default: 0.1
# Minimal number of seconds to sleep between mouse moves.
pyautogui.MINIMUM_SLEEP = 0 # Default: 0.05
# The number of seconds to pause after EVERY public function call.
pyautogui.PAUSE = 0.015 # Default: 0.1

setup_pyautogui()

class HumanClicker():
def __init__(self):
pass

def move(self, toPoint, duration=2, humanCurve=None):
fromPoint = pyautogui.position()
if not humanCurve:
humanCurve = HumanCurve(fromPoint, toPoint)

pyautogui.PAUSE = duration / len(humanCurve.points)
for point in humanCurve.points:
pyautogui.moveTo(point)

def click(self):
pyautogui.click()





124 changes: 124 additions & 0 deletions pyclick/humancurve.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import pytweening
import numpy as np
import random
from pyclick._utils import isListOfPoints, isNumeric
from pyclick._beziercurve import BezierCurve

class HumanCurve():
"""
Generates a human-like mouse curve starting at given source point,
and finishing in a given destination point
"""

def __init__(self, fromPoint, toPoint, **kwargs):
self.fromPoint = fromPoint
self.toPoint = toPoint
self.points = self.generateCurve(**kwargs)

def generateCurve(self, **kwargs):
"""
Generates a curve according to the parameters specified below.
You can override any of the below parameters. If no parameter is
passed, the default value is used.
"""
offsetBoundaryX = kwargs.get("offsetBoundaryX", 100)
offsetBoundaryY = kwargs.get("offsetBoundaryY", 100)
leftBoundary = kwargs.get("leftBoundary", min(self.fromPoint[0], self.toPoint[0])) - offsetBoundaryX
rightBoundary = kwargs.get("rightBoundary", max(self.fromPoint[0], self.toPoint[0])) + offsetBoundaryX
downBoundary = kwargs.get("downBoundary", min(self.fromPoint[1], self.toPoint[1])) - offsetBoundaryY
upBoundary = kwargs.get("upBoundary", max(self.fromPoint[1], self.toPoint[1])) + offsetBoundaryY
knotsCount = kwargs.get("knotsCount", 2)
distortionMean = kwargs.get("distortionMean", 1)
distortionStdev = kwargs.get("distortionStdev", 1)
distortionFrequency = kwargs.get("distortionFrequency", 0.5)
tween = kwargs.get("tweening", pytweening.easeOutQuad)
targetPoints = kwargs.get("targetPoints", 100)

internalKnots = self.generateInternalKnots(leftBoundary,rightBoundary, \
downBoundary, upBoundary, knotsCount)
points = self.generatePoints(internalKnots)
points = self.distortPoints(points, distortionMean, distortionStdev, distortionFrequency)
points = self.tweenPoints(points, tween, targetPoints)
return points

def generateInternalKnots(self, \
leftBoundary, rightBoundary, \
downBoundary, upBoundary,\
knotsCount):
"""
Generates the internal knots used during generation of bezier curvePoints
or any interpolation function. The points are taken at random from
a surface delimited by given boundaries.
Exactly knotsCount internal knots are randomly generated.
"""
if not (isNumeric(leftBoundary) and isNumeric(rightBoundary) and
isNumeric(downBoundary) and isNumeric(upBoundary)):
raise ValueError("Boundaries must be numeric")
if not isinstance(knotsCount, int) or knotsCount < 0:
raise ValueError("knotsCount must be non-negative integer")
if leftBoundary > rightBoundary:
raise ValueError("leftBoundary must be less than or equal to rightBoundary")
if downBoundary > upBoundary:
raise ValueError("downBoundary must be less than or equal to upBoundary")

knotsX = np.random.choice(range(leftBoundary, rightBoundary), size=knotsCount)
knotsY = np.random.choice(range(downBoundary, upBoundary), size=knotsCount)
knots = list(zip(knotsX, knotsY))
return knots

def generatePoints(self, knots):
"""
Generates bezier curve points on a curve, according to the internal
knots passed as parameter.
"""
if not isListOfPoints(knots):
raise ValueError("knots must be valid list of points")

midPtsCnt = max( \
abs(self.fromPoint[0] - self.toPoint[0]), \
abs(self.fromPoint[1] - self.toPoint[1]), \
2)
knots = [self.fromPoint] + knots + [self.toPoint]
return BezierCurve.curvePoints(midPtsCnt, knots)

def distortPoints(self, points, distortionMean, distortionStdev, distortionFrequency):
"""
Distorts the curve described by (x,y) points, so that the curve is
not ideally smooth.
Distortion happens by randomly, according to normal distribution,
adding an offset to some of the points.
"""
if not(isNumeric(distortionMean) and isNumeric(distortionStdev) and \
isNumeric(distortionFrequency)):
raise ValueError("Distortions must be numeric")
if not isListOfPoints(points):
raise ValueError("points must be valid list of points")
if not (0 <= distortionFrequency <= 1):
raise ValueError("distortionFrequency must be in range [0,1]")

distorted = []
for i in range(1, len(points)-1):
x,y = points[i]
delta = np.random.normal(distortionMean, distortionStdev) if \
random.random() < distortionFrequency else 0
distorted += (x,y+delta),
distorted = [points[0]] + distorted + [points[-1]]
return distorted

def tweenPoints(self, points, tween, targetPoints):
"""
Chooses a number of points(targetPoints) from the list(points)
according to tweening function(tween).
This function in fact controls the velocity of mouse movement
"""
if not isListOfPoints(points):
raise ValueError("points must be valid list of points")
if not isinstance(targetPoints, int) or targetPoints < 2:
raise ValueError("targetPoints must be an integer greater or equal to 2")

# tween is a function that takes a float 0..1 and returns a float 0..1
res = []
for i in range(targetPoints):
index = int(tween(float(i)/(targetPoints-1)) * (len(points)-1))
res += points[index],
return res
10 changes: 10 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
numpy==1.14.2
Pillow==5.0.0
pkg-resources==0.0.0
PyAutoGUI==0.9.36
PyMsgBox==1.0.6
PyScreeze==0.1.14
python3-xlib==0.15
PyTweening==1.0.3
six==1.11.0
xlib==0.21
Empty file added tests/__init__.py
Empty file.
51 changes: 51 additions & 0 deletions tests/test_beziercurve.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import unittest
from pyclick._beziercurve import BezierCurve

class TestBezierCurve(unittest.TestCase):
def test_binomial(self):
self.assertEqual(BezierCurve.binomial(10,2), 45)
self.assertEqual(BezierCurve.binomial(4,2), 6)
self.assertEqual(BezierCurve.binomial(1,1), 1)
self.assertEqual(BezierCurve.binomial(2,0), 1)

def test_bernstein_polynomial_point(self):
self.assertEqual(BezierCurve.bernsteinPolynomialPoint(5,0,0), 1)

self.assertEqual(BezierCurve.bernsteinPolynomialPoint(3, 0, 1), -2)
self.assertEqual(BezierCurve.bernsteinPolynomialPoint(3, 1, 1), 3)

self.assertEqual(BezierCurve.bernsteinPolynomialPoint(3, 0, 2), 4)
self.assertEqual(BezierCurve.bernsteinPolynomialPoint(3, 1, 2), -12)
self.assertEqual(BezierCurve.bernsteinPolynomialPoint(3, 2, 2), 9)

def test_simpleBernsteinPolynomial(self):
bernsteinPolynomial = BezierCurve.bernsteinPolynomial([(0,0), (50,50), (100,100)])

self.assertEqual(bernsteinPolynomial(0), (0,0))
self.assertEqual(bernsteinPolynomial(0.25), (25, 25))
self.assertEqual(bernsteinPolynomial(0.5), (50, 50))
self.assertEqual(bernsteinPolynomial(0.75), (75, 75))
self.assertEqual(bernsteinPolynomial(1), (100,100))


def test_complexBernsteinPolynomial(self):
bernsteinPolynomial = BezierCurve.bernsteinPolynomial([(0,0), (40,40), (100,100)])

self.assertEqual(bernsteinPolynomial(0), (0,0))
self.assertEqual(bernsteinPolynomial(0.25), (21.25,21.25))
self.assertEqual(bernsteinPolynomial(0.5), (45,45))
self.assertEqual(bernsteinPolynomial(0.75), (71.25, 71.25))
self.assertEqual(bernsteinPolynomial(1), (100,100))

def test_simpleCurvePoints(self):
points = [(0,0), (50,50), (100,100)]
n = 5
expected_curve_points = [(0,0),(25,25),(50,50),(75,75),(100,100)]
self.assertEqual(BezierCurve.curvePoints(n, points), expected_curve_points)


def test_complexCurvePoints(self):
points = [(0,0), (40,40), (100,100)]
n = 5
expected_curve_points = [(0,0),(21.25,21.25),(45,45),(71.25,71.25),(100,100)]
self.assertEqual(BezierCurve.curvePoints(n, points), expected_curve_points)
27 changes: 27 additions & 0 deletions tests/test_humanclicker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import unittest
from pyclick.humanclicker import HumanClicker
import pyautogui
import random

class TestHumanClicker(unittest.TestCase):

def test_simple(self):
width, height = pyautogui.size()
toPoint = (width//2, height//2)
hc = HumanClicker()
hc.move(toPoint)
self.assertTrue(pyautogui.position() == toPoint)

def test_identityMove(self):
toPoint = pyautogui.position()
hc = HumanClicker()
hc.move(toPoint)
self.assertTrue(pyautogui.position() == toPoint)

def test_randomMove(self):
width, height = pyautogui.size()
toPoint = random.randint(width//2,width-1), random.randint(height//2,height-1)
hc = HumanClicker()
hc.move(toPoint)
self.assertTrue(pyautogui.position() == toPoint)

Loading

0 comments on commit f3f7cc2

Please sign in to comment.