forked from patrikoss/pyclick
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Added mouse movement based on bezier curves
- Loading branch information
0 parents
commit f3f7cc2
Showing
12 changed files
with
434 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
|
||
|
||
|
||
|
||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
|
Oops, something went wrong.