the-bumblebee 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bumblebee/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ from .keyboard import *
2
+ from .mouse import *
@@ -0,0 +1,2 @@
1
+ from .predict import Predictor
2
+ from .rnn import CursorRNN
@@ -0,0 +1,3 @@
1
+ ## Production-Ready Models
2
+
3
+ This directory contains pre-trained models, fully optimized and ready for production use.
Binary file
@@ -0,0 +1,394 @@
1
+ import os
2
+
3
+ import numpy as np
4
+ import torch
5
+ from scipy.interpolate import interp1d
6
+ from scipy.ndimage import gaussian_filter1d
7
+ from scipy.special import gamma
8
+
9
+ from bumblebee.ai.rnn import CursorRNN as RNN
10
+
11
+
12
+ class Predictor:
13
+ def __init__(self, model_name: str = "bumblebee-c-v1.pth"):
14
+ """
15
+ Initialize the prediction model with a pre-trained RNN for generating intermediate path steps.
16
+
17
+ Parameters:
18
+ model_name (str): The filename of the pre-trained model to load. Defaults to "bumblebee-c-v1.pth".
19
+
20
+ Attributes:
21
+ MAX_COORDINATE (int): Maximum coordinate value supported (e.g., 4096 for 4K resolution).
22
+ MIN_COORDINATE (int): Minimum coordinate value.
23
+ INPUT_DIM (int): Number of input features (start and destination x, y coordinates).
24
+ HIDDEN_DIM (int): Size of the hidden state in the RNN.
25
+ STEPS (int): Number of intermediate steps predicted between the start and destination.
26
+ OUTPUT_DIM (int): Number of output features (2 coordinates for each intermediate step).
27
+ LSTM_LAYERS (int): The number of LSTM layers in the RNN.
28
+ device (torch.device): The device (CUDA, MPS, or CPU) on which the model computations are performed.
29
+ model (RNN): The RNN model instance used for predicting paths.
30
+ model_path (str): The full file path to the pre-trained model file.
31
+
32
+ The constructor loads the pre-trained model, maps it to the appropriate computation device,
33
+ and sets the model to evaluation mode.
34
+ """
35
+ # Constants for normalization
36
+ self.MAX_COORDINATE = 4096 # allows to use 4k display,taken from training notebook: `rnn-train.ipynb`
37
+ self.MIN_COORDINATE = 0
38
+
39
+ # Constant for RNN model
40
+ self.INPUT_DIM = 4 # Start (x, y) and Destination (x, y) coordinates
41
+ self.HIDDEN_DIM = 512
42
+ self.STEPS = 39 # Number of intemediate steps between start and destination
43
+ self.OUTPUT_DIM = self.STEPS * 2 # 2 coordinates (x, y) for each steps
44
+ self.LSTM_LAYERS = 2
45
+
46
+ self.device = torch.device(
47
+ "cuda"
48
+ if torch.cuda.is_available()
49
+ else "mps" if torch.backends.mps.is_available() else "cpu"
50
+ ) # Check for best hardware to run the model on
51
+
52
+ self.model = RNN(
53
+ self.INPUT_DIM,
54
+ self.HIDDEN_DIM,
55
+ self.OUTPUT_DIM,
56
+ num_layers=self.LSTM_LAYERS,
57
+ ).to(self.device)
58
+
59
+ self.model_path = os.path.join(os.path.dirname(__file__), "models", model_name)
60
+ if self.device == "cuda":
61
+ self.model.load_state_dict(torch.load(self.model_path))
62
+ else:
63
+ self.model.load_state_dict(
64
+ torch.load(self.model_path, map_location=torch.device("cpu"))
65
+ )
66
+ self.model.eval()
67
+
68
+ def __normalize(self, data: np.ndarray) -> np.ndarray:
69
+ """
70
+ Normalize coordinates to [0,1] range for model input
71
+ """
72
+ return (data - self.MIN_COORDINATE) / (
73
+ self.MAX_COORDINATE - self.MIN_COORDINATE
74
+ )
75
+
76
+ def __denormalize(self, data: np.ndarray) -> np.ndarray:
77
+ """
78
+ Denormalize coordinates from [0,1] range to original range
79
+ """
80
+ return data * (self.MAX_COORDINATE - self.MIN_COORDINATE) + self.MIN_COORDINATE
81
+
82
+ def _calculate_distance(self, point1: np.ndarray, point2: np.ndarray) -> float:
83
+ """
84
+ Calculate the Euclidean distance between two points in 2D space.
85
+ Parameters:
86
+ point1 (np.ndarray): The first point as [x1, y1].
87
+ point2 (np.ndarray): The second point as [x2, y2].
88
+ Returns:
89
+ float: The Euclidean distance between point1 and point2.
90
+ """
91
+ return float(np.linalg.norm(point1 - point2))
92
+
93
+ def __calculate_angle(
94
+ self, point1: np.ndarray, point2: np.ndarray, point3: np.ndarray
95
+ ) -> float:
96
+ """
97
+ Calculate the angle between three points in 2D space.
98
+ Parameters:
99
+ point1 (np.ndarray): The first point as [x1, y1].
100
+ point2 (np.ndarray): The second point as [x2, y2].
101
+ point3 (np.ndarray): The third point as [x3, y3].
102
+ Returns:
103
+ float: The angle in radians between the line segments formed by point1-point2 and point2-point3.
104
+ """
105
+ vector1 = point2 - point1
106
+ vector2 = point3 - point2
107
+
108
+ dot_product = np.dot(vector1, vector2)
109
+ magnitude1 = np.linalg.norm(vector1)
110
+ magnitude2 = np.linalg.norm(vector2)
111
+
112
+ if magnitude1 == 0 or magnitude2 == 0:
113
+ return 0.0
114
+ cos_angle = dot_product / (magnitude1 * magnitude2)
115
+ cos_angle = np.clip(
116
+ cos_angle, -1.0, 1.0
117
+ ) # Ensure the value is within the valid range for arccos
118
+ angle = np.arccos(cos_angle)
119
+ return angle
120
+
121
+ def __clean_path(
122
+ self, path: np.ndarray, min_distance=10.0, deviation_threshold=50.0
123
+ ) -> np.ndarray:
124
+ """
125
+ Clean the predicted path by removing points that are too close to each other.
126
+ Parameters:
127
+ path (np.ndarray): The predicted path as an array of shape (STEPS, 2).
128
+ min_distance (float): The minimum distance between consecutive points to keep them.
129
+ devaition_threshold (float): The threshold for deviation to consider a point as part of the path.
130
+ Returns:
131
+ np.ndarray: The cleaned path with points that are sufficiently spaced apart.
132
+ """
133
+ destination = path[-1]
134
+ start = path[0]
135
+
136
+ cleaned_path = [start] # Start with the first point
137
+
138
+ for i in range(1, len(path) - 1):
139
+ if self._calculate_distance(cleaned_path[-1], path[i]) >= min_distance:
140
+ cleaned_path.append(path[i])
141
+ cleaned_path.append(destination) # Always add the last point
142
+
143
+ undeviated_path = np.array([], dtype=np.float32)
144
+ cleaned_path = np.array(cleaned_path)
145
+ if len(cleaned_path) <= 2:
146
+ return cleaned_path
147
+ previous_point_distance = float("inf")
148
+ for i in range(len(cleaned_path)):
149
+ distance_to_dest = self._calculate_distance(cleaned_path[i], destination)
150
+ if distance_to_dest > previous_point_distance:
151
+ if distance_to_dest <= deviation_threshold:
152
+ undeviated_path = np.append(undeviated_path, cleaned_path[i])
153
+ previous_point_distance = distance_to_dest
154
+ else:
155
+ undeviated_path = np.append(undeviated_path, cleaned_path[i])
156
+ previous_point_distance = distance_to_dest
157
+
158
+ undeviated_path = undeviated_path.reshape(-1, 2)
159
+
160
+ return undeviated_path
161
+
162
+ def __calculate_speed_factor(self, progress: float) -> float:
163
+ """
164
+ Calculate the speed factor based on the progress of the path; uses gamma distribution.
165
+ Parameters:
166
+ progress (float): The progress of the path as a value between 0 and 1.
167
+ Returns:
168
+ float: The speed factor, which increases gradually and tapers off.
169
+ """
170
+ # Parameters for gamma distribution
171
+ k = 1.8 # Shape parameter
172
+ theta = 1.0 # Scale parameter
173
+
174
+ # Precompute normalization factor over the scaled range [0, 10]
175
+ x = np.linspace(0, 10, 1000) # Scaled progress range
176
+ y = (x ** (k - 1) * np.exp(-x / theta)) / (theta**k * gamma(k))
177
+ max_val = np.max(y) # Maximum value for normalization
178
+
179
+ # Scale progress to fit gamma's natural range
180
+ scaled_progress = progress * 10
181
+
182
+ # Base gamma distribution
183
+ speed_factor = (
184
+ scaled_progress ** (k - 1) * np.exp(-scaled_progress / theta)
185
+ ) / (theta**k * gamma(k))
186
+ speed_factor = speed_factor / max_val # Normalize
187
+
188
+ # Define flat region and transitions
189
+ flat_value = 1.0 # Base value for flat region
190
+ smooth_width = 0.15 # Transition width
191
+
192
+ # Precompute gamma curve values at key points for transitions
193
+ base_y = y / max_val # Normalized base curve
194
+ x_0_05 = 0.05 # Start of lower transition
195
+ y_0_05 = base_y[np.searchsorted(x / 5, x_0_05)] # Value at x = 0.05
196
+ y_1_0 = base_y[-1] # Value at x = 1.0
197
+
198
+ # Apply flat region and transitions
199
+ if 0.20 <= progress <= 0.90: # Flat region with noise
200
+ noise = np.random.uniform(-0.05, 0.0) # Noise between -0.05 and 0
201
+ speed_factor = flat_value + noise
202
+
203
+ elif (0.20 - smooth_width) < progress < 0.20: # Lower transition (0.05 to 0.20)
204
+ speed_factor = np.interp(
205
+ progress, [0.20 - smooth_width, 0.20], [y_0_05, flat_value]
206
+ )
207
+
208
+ elif 0.90 < progress < (0.90 + smooth_width): # Upper transition (0.90 to 1.0)
209
+ speed_factor = np.interp(progress, [0.90, 1.0], [flat_value, y_1_0])
210
+
211
+ return speed_factor
212
+
213
+ def __smooth_path(self, path: np.ndarray, noise_factor=1.5) -> np.ndarray:
214
+ """
215
+ Smooth the predicted path by applying Gaussian noise to the coordinates.
216
+ Parameters:
217
+ path (np.ndarray): The predicted path as an array of shape (STEPS, 2).
218
+ noise_factor (float): The standard deviation of the Gaussian noise to be added.
219
+ Returns:
220
+ np.ndarray: The smoothed path with Gaussian noise applied to the coordinates.
221
+ """
222
+ smoothed_x = gaussian_filter1d(
223
+ path[:, 0], sigma=0.5
224
+ ) # apply gaussian filter to x coordinates
225
+ smoothed_y = gaussian_filter1d(
226
+ path[:, 1], sigma=0.5
227
+ ) # apply gaussian filter to y coordinates
228
+
229
+ noise_x = np.random.normal(
230
+ 0, noise_factor, size=smoothed_x.shape
231
+ ) # generate noise for x coordinates
232
+ noise_y = np.random.normal(
233
+ 0, noise_factor, size=smoothed_y.shape
234
+ ) # generate noise for y coordinates
235
+
236
+ t = np.linspace(0, 1, len(smoothed_x)) # Normalized time variable
237
+ noise_decay = np.exp(-4 * t) # Exponential decay function
238
+
239
+ # Here we are adding noise decay so that noise reduces as we move from start to end
240
+
241
+ smoothed_x += noise_x * noise_decay
242
+ smoothed_y += noise_y * noise_decay
243
+
244
+ # Apply final smoothing to prevent abrupt jumps
245
+ smoothed_x = gaussian_filter1d(smoothed_x, sigma=0.5)
246
+ smoothed_y = gaussian_filter1d(smoothed_y, sigma=0.5)
247
+ return np.column_stack((smoothed_x, smoothed_y))
248
+
249
+ def __interpolate_path(self, path: np.ndarray, num_points=50) -> np.ndarray:
250
+ """
251
+ Interpolate the predicted path to have a fixed number of intermediate points for smoother transitions.
252
+ Parameters:
253
+ path (np.ndarray): The predicted path as an array of shape (STEPS, 2).
254
+ num_points (int): The number of points to interpolate to.
255
+ Returns:
256
+ np.ndarray: The interpolated path with a fixed number of points.
257
+ """
258
+ try:
259
+ x = path[:, 0]
260
+ y = path[:, 1]
261
+ t = np.linspace(0, 1, len(x))
262
+
263
+ interp_func_x = interp1d(t, x, kind="linear")
264
+ interp_func_y = interp1d(t, y, kind="linear")
265
+
266
+ t_new = np.linspace(0, 1, num_points)
267
+ x_new = interp_func_x(t_new)
268
+ y_new = interp_func_y(t_new)
269
+
270
+ return np.column_stack((x_new, y_new))
271
+ except Exception as e:
272
+ print(f"Interpolation failed: {e}")
273
+ return path
274
+
275
+ def __predict_path(self, input_arr: np.ndarray) -> np.ndarray:
276
+ """
277
+ Predict the intermediate path steps between the start and destination coordinates.
278
+
279
+ Parameters:
280
+ input_arr (np.ndarray): The input data containing start and destination coordinates. e.g [[start_x, start_y, dest_x, dest_y]]
281
+
282
+ Returns:
283
+ np.ndarray: The predicted intermediate steps of the path.
284
+
285
+ This method uses the pre-trained RNN model to generate intermediate steps between the start
286
+ and destination. The input coordinates are normalized before being fed into the model.
287
+ """
288
+
289
+ # Normalize the input array to be within the [0, 1] range for model compatibility
290
+ input_normalized_arr = self.__normalize(input_arr)
291
+
292
+ # Convert the normalized array to a PyTorch tensor, send it to the appropriate device, and add a dimension for batch processing (unsqueeze simulates a batch of size 1)
293
+ input_tensor = torch.tensor(input_normalized_arr).to(self.device).unsqueeze(1)
294
+
295
+ # Clean up intermediate arrays to free memory
296
+ del input_arr
297
+ del input_normalized_arr
298
+
299
+ with torch.no_grad():
300
+ # Pass the input tensor through the model to get the output
301
+ output = self.model(input_tensor)
302
+
303
+ # Clean up the input tensor to free memory
304
+ del input_tensor
305
+
306
+ # Reshape the output to match the expected dimensions and move it to CPU
307
+ output = output[0].view([self.STEPS, 2]).cpu().numpy()
308
+
309
+ # Denormalize the output to convert it back to the original coordinate range
310
+ output = self.__denormalize(output)
311
+
312
+ return output
313
+
314
+ def __add_speed_factor(self, path: np.ndarray) -> np.ndarray:
315
+ """
316
+ Add speed factor to the predicted path to simulate variable speed along the path.
317
+ Parameters:
318
+ path (np.ndarray): The predicted path as an array of shape (STEPS, 2).
319
+ Returns:
320
+ np.ndarray: The path with speed factor applied to simulate variable speed, with shape (STEPS, 3).
321
+ """
322
+ path_with_speed = np.zeros((len(path), 3), dtype=np.float64)
323
+ for i in range(len(path)):
324
+ progress = i / (len(path) - 1)
325
+ angle = (
326
+ self.__calculate_angle(path[i - 1], path[i], path[i + 1])
327
+ if i > 0 and i < len(path) - 1
328
+ else 0
329
+ )
330
+
331
+ angle_speed_factor = 1 - (angle / np.pi)
332
+ progress_speed_factor = self.__calculate_speed_factor(progress)
333
+ speed_factor = (0.60 * angle_speed_factor) + (
334
+ 0.4 * progress_speed_factor
335
+ ) # 60% weight to angle and 40% to progress
336
+ path_with_speed[i] = np.array([path[i][0], path[i][1], speed_factor])
337
+
338
+ return path_with_speed
339
+
340
+ def predict(self, start: np.ndarray, dest: np.ndarray) -> np.ndarray:
341
+ """
342
+ Predict the path from start to destination coordinates.
343
+
344
+ Parameters:
345
+ start (np.ndarray): The starting coordinates as [x, y].
346
+ dest (np.ndarray): The destination coordinates as [x, y].
347
+
348
+ Returns:
349
+ np.ndarray: The predicted path with speed factor as an array of shape (STEPS, 3).
350
+ """
351
+ # Combine start and destination coordinates into a single input array
352
+ input_arr = np.concatenate([start, dest]).reshape(
353
+ 1, 4
354
+ ) # the input must be in [[x1,y1, x2, y2]] format, required by model
355
+
356
+ # Predict the intermediate path using the model
357
+ path = self.__predict_path(input_arr)
358
+ path = np.vstack([start, path, dest])
359
+ path = self.__clean_path(path)
360
+
361
+ path = self.__smooth_path(path)
362
+ interpolated_path = np.array([], dtype=np.float32)
363
+
364
+ # Interpolate the path to ensure smooth transitions, make sure that there are no jumps, if distance between two points is more than 20 pixels, we interpolate
365
+ for i in range(len(path) - 1):
366
+ distance_with_previous_point = int(
367
+ self._calculate_distance(path[i], path[i + 1]) if i > 0 else 0
368
+ )
369
+ if distance_with_previous_point >= 40:
370
+ current_num_points = int(distance_with_previous_point / 20)
371
+ current_interpolated_path = self.__interpolate_path(
372
+ path[i : i + 2], num_points=current_num_points
373
+ )
374
+ interpolated_path = (
375
+ np.vstack([interpolated_path, current_interpolated_path])
376
+ if interpolated_path.size
377
+ else current_interpolated_path
378
+ )
379
+ else:
380
+ interpolated_path = (
381
+ np.vstack([interpolated_path, path[i]])
382
+ if interpolated_path.size
383
+ else path[i]
384
+ )
385
+
386
+ path = interpolated_path
387
+ if not np.array_equal(path[-1], dest):
388
+ path = np.vstack([path, dest])
389
+ if not np.array_equal(path[0], start):
390
+ path = np.vstack([start, path])
391
+
392
+ path = self.__add_speed_factor(path)
393
+
394
+ return path
bumblebee/ai/rnn.py ADDED
@@ -0,0 +1,71 @@
1
+ """
2
+ This file contains the code for neural network models used in the project. They are copied from the training notebooks.
3
+ """
4
+
5
+ import torch
6
+ import torch.nn as nn
7
+
8
+ """
9
+ Attention and CursorRNN are copied from 'rnn-train.ipynb' notebook
10
+ Attention class is used to compute the attention weights and context vector
11
+ CursorRNN class is the main model that combines LSTM, attention, and residual connections to predict the path
12
+ """
13
+
14
+
15
+ class Attention(nn.Module):
16
+ def __init__(self, hidden_dim):
17
+ super(Attention, self).__init__()
18
+ self.attn = nn.Linear(
19
+ hidden_dim, 1, bias=False
20
+ ) # Attention layer to assign weights to different time steps
21
+
22
+ def forward(self, lstm_out):
23
+ scores = self.attn(lstm_out) # Compute attention scores for each time step
24
+ attn_weights = torch.softmax(
25
+ scores, dim=1
26
+ ) # Apply softmax to normalize attention weights
27
+ context = torch.sum(
28
+ attn_weights * lstm_out, dim=1
29
+ ) # Create context vector by weighted sum of LSTM outputs
30
+
31
+ return context, attn_weights
32
+
33
+
34
+ class CursorRNN(nn.Module):
35
+ def __init__(self, input_dim, hidden_dim, output_dim, num_layers=1, dropout=0.2):
36
+ super(CursorRNN, self).__init__()
37
+ self.lstm = nn.LSTM(
38
+ input_dim,
39
+ hidden_dim,
40
+ num_layers=num_layers,
41
+ batch_first=True,
42
+ bidirectional=False,
43
+ dropout=dropout,
44
+ ) # LSTM layer for sequence processing
45
+ self.attention = Attention(
46
+ hidden_dim
47
+ ) # Attention mechanism to focus on important time steps
48
+ self.residual_fc = nn.Linear(
49
+ input_dim, hidden_dim
50
+ ) # Residual connection to help with gradient flow
51
+ self.layer_norm = nn.LayerNorm(
52
+ hidden_dim
53
+ ) # Layer normalization for training stability
54
+ self.fc = nn.Linear(
55
+ hidden_dim, output_dim
56
+ ) # Output projection layer to generate final predictions
57
+
58
+ def forward(self, x):
59
+ lstm_out, _ = self.lstm(x) # Process sequence through LSTM
60
+ context, attn_weights = self.attention(
61
+ lstm_out
62
+ ) # Apply attention to focus on relevant parts
63
+ residual = self.residual_fc(
64
+ x[:, -1, :]
65
+ ) # Create residual connection from last input
66
+ combined = self.layer_norm(
67
+ context + residual
68
+ ) # Combine attention output with residual and normalize
69
+ output = self.fc(combined) # Generate final trajectory prediction
70
+
71
+ return output, attn_weights